Skip to content

Commit d81ae3c

Browse files
NagyViktclaude
andauthored
Fix double-paste: kitty router socket + daemon dedup window/normalization (#1)
* #2 Kill double-paste at source: kitty ctrl+v router skips tmux windows A single Ctrl+V inside kitty+tmux fired two handlers (kitty `map ctrl+v` + tmux `bind -n C-v`), both calling flashpaste-trigger ~hundreds of ms apart, which the daemon's dedup window had to race. The new router suppresses kitty's redundant handler when it can confidently detect tmux in the focused window (kitty remote control + foreground process is tmux), so tmux's bind is the only fire. On any uncertainty it falls through to the original path and the daemon dedup still backstops — a detection miss degrades to today's behaviour, never to a broken paste. - bin/kitty-paste-router.sh: the launch target for kitty's `map ctrl+v` - bin/kitty-focused-is-tmux.py: parses `kitten @ ls --match state:focused`, exits 0 only when the focused window's foreground process is tmux - kitty.conf `map ctrl+v` now points at the router (deployed to ~/.local/bin) Parser verified on 5 cases (tmux, non-tmux, empty, garbage, tmux-in-other- window). Latency: the kitten round-trip only delays kitty's REDUNDANT handler; tmux's fast bind already did the user-visible paste. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * #5 Log paste outcome + last-gate non-text guard on the text path The daemon logged "PASTED text/image" = "I dispatched", which can't tell a clean paste from a leak — this session's binary-wall and blob bugs were invisible in the journal until the user screenshotted them. - dispatch_text_paste now refuses to paste a payload that fails looks_like_text (image magic / NUL / non-UTF8) and logs outcome= rejected-nontext. The daemon must never dump image bytes into a pane as text; this is the single chokepoint all text pastes flow through, so it's the right last gate even though upstream staging should already reject. - "PASTED text" now carries sent_bytes, html_sanitized, outcome=clean so a sanitized or suppressed paste is visible in the log. - looks_like_text made pub(crate) so paste.rs shares the ipc.rs check. 30/30 tests pass (+2 covering the guard decision). Daemon rebuilt + restarted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * #6 + #1 Paste-correctness self-test + wire behavioral tests into CI Every bug this session shipped because only Rust unit tests existed and nothing exercised the actual paste-decision logic end to end. Add a headless, mocked-clipboard regression suite that IS that net: - tests/wl-paste-guard.test.sh: the image-coexistence guard (7 checks) — image present ⇒ text reads return empty; image/png + list-types still work. - tests/kitty-focused-is-tmux.test.sh: the router's tmux detector (5 checks) — exit 0 only when the focused window's foreground process is tmux. - bin/flashpaste-selftest.sh: one command runs the suite (+ `--rust` for cargo test). Mocks the clipboard, so it needs no display and never touches the user's real selection. - flashpaste-doctor now runs the suite and a failure counts as a doctor fail. - CI: new `behavior-tests` job in lint.yml runs the suite on every push/PR. Suite green: 12/12 checks. Doctor integration verified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * #8 Single-source keybindings + drift checker The double-paste bug was config drift: kitty.conf and tmux.conf each bound Ctrl+V independently and got out of sync. Rather than rewrite the user's hand-tuned, richly-commented dotfiles (high risk, low marginal value now that #2 fixed the actual handler), declare ONE canonical source and enforce it read-only: - config/keybindings.canonical: the source of truth — kitty ctrl+v must route through kitty-paste-router.sh, tmux C-v through flashpaste-trigger. - bin/flashpaste-keybindings-check.sh: read-only checker (env-overridable for tests) that flags drift. Escapes ERE metachars so the literal '+' in ctrl+v matches correctly (caught by its own test). - flashpaste-doctor warns (not fails) on drift — it degrades to daemon dedup. - tests/keybindings-check.test.sh: 4 fixture cases (consistent, kitty drift, missing tmux, the ctrl+v literal-plus regression). In CI via the suite. Live check passes: kitty + tmux consistent. Suite green: 16/16. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * #4 Refactor handle_paste into named phases (+ fix 2 pre-existing clippy lints) handle_paste was a ~290-line god-function where the probe/override races hide. Extract its three phases verbatim into named helpers so the hot path reads as a sequence and each phase is reviewable in isolation: - eager_screenshot_pickup() — inotify-lag screenshot pickup - eager_live_image_pickup() — browser "Copy Image" bridge - resolve_paste_intent() — the staged-slot + live-clipboard override cascade, returns the StagedSelection to dispatch handle_paste now reads: pickup → pickup → resolve → dispatch-match. Pure code move, no behaviour change — proven by the unchanged 30/30 test suite, clippy (-D warnings) and fmt both clean. Also fixes two pre-existing lints that failed release-clippy: is_text_target is test-only (now #[cfg(test)]); extract_blob_domain uses an array char pattern. These predate this work but blocked the clippy gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * #3 + #7 ADRs: scope the bash-reduction ocean and the Wayland source-fix Rather than force-implement two items that shouldn't be done blind, record the decision and the concrete path for each: - ADR 0006 (#3): the big bash surface (tmux-paste-dispatch.sh, 657 lines) is the Tier-1 zero-dependency fallback BY DESIGN (ADR 0001), so folding it into the daemon is partly a non-goal — it would delete the no-daemon guarantee. Reframes #3 as bounded, test-first dedup of shim/daemon overlap, not a multi-day rewrite. First slice named. - ADR 0007 (#7): owning the Wayland clipboard via data-control on wlroots/KDE would fix the blob leak at source (no shim needed), hooking the existing WAYLAND_WEDGED latch. Deferred + guarded: untestable on the maintainer's Mutter box, and shipping an unexercisable correctness path is a liability. Test matrix specified before it can be Accepted. Both indexed in docs/adr/README.md as Proposed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Harden router per review: fail-safe focus detection + empty-pane guard Two LOW findings from independent review, both on the "never drop a paste" guarantee: - kitty-focused-is-tmux.py: require is_focused IS True (was: not False). On old kitty that ignores --match and omits is_focused, no window qualifies, so we report "not tmux" and the router lets the paste happen — instead of risking a false-positive tmux match in a non-focused window that would suppress a paste kitty should have handled. Added a regression test. - kitty-paste-router.sh: only call flashpaste-trigger when the pane id is non-empty; an empty pane (no tmux) goes straight to the image fallback rather than handing the daemon a blank pane. Self-test: 17/17. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix double-paste: repair kitty router socket + widen/normalize daemon dedup Two independent causes made a single paste land twice: 1. kitty-paste-router.sh could never reach kitty's remote-control socket. It looked for `unix:$XDG_RUNTIME_DIR/kitty-main`, but kitty appends the pid (`kitty-main-<pid>`), and KITTY_LISTEN_ON is absent from the env that `launch --copy-env` copies. So `kitten @ ls` failed every time, the focused-window-is-tmux probe always returned false, and the router never suppressed kitty's redundant ctrl+v fire. Now resolves the socket via KITTY_LISTEN_ON, else globs the real `kitty-main-<pid>` socket. 2. The daemon dedup missed the second fire. kitty launches the router as a BACKGROUND process, so its paste can land well after tmux's bind already pasted — measured 1.84s apart, past the 1000ms window. And the two paths differ by a trailing newline ("+N" vs "+N+1 lines"), so they hashed to different signatures and dodged content-dedup entirely. Widen the window to 2500ms and trim trailing \n/\r in paste_signature so the variants collide and the second is absorbed. Verified: the daemon journal showed one keypress producing two requests ~1.8s apart with identical payload; real-wire capture confirmed flashpaste itself delivers exactly one bracketed paste. All 15 flashpasted unit tests pass (dedup window + trailing-newline cases added). * router: resolve kitty socket via shell glob, not $(ls) (review nit from PR #1) Avoids word-splitting on socket paths with spaces and drops the ls dependency. Behaviour unchanged for the single-kitty-instance case. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9cf6b16 commit d81ae3c

15 files changed

Lines changed: 740 additions & 78 deletions

.github/workflows/lint.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ jobs:
6666
-e SC1090,SC2034,SC2155 \
6767
"${shell_files[@]}"
6868
69+
behavior-tests:
70+
name: paste-correctness behavioral tests
71+
runs-on: ubuntu-latest
72+
steps:
73+
- uses: actions/checkout@v4
74+
75+
- name: Run behavioral regression suite (headless, mocked clipboard)
76+
run: bash bin/flashpaste-selftest.sh
77+
6978
rust:
7079
name: rust fmt + clippy + unit tests
7180
runs-on: ubuntu-24.04

bin/flashpaste-doctor.sh

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,37 @@ for f in $(ls "$RDIR" | sort -n); do
332332
esac
333333
done
334334

335+
# ── Behavioral regression suite (paste-correctness guards) ────────────
336+
# Runs the headless bash test suite (wl-paste image-coexistence guard,
337+
# kitty/tmux detector). A failure means a known paste bug could regress,
338+
# so it counts as a doctor failure.
339+
_selftest="$(dirname -- "$0")/flashpaste-selftest.sh"
340+
if [ -x "$_selftest" ]; then
341+
echo
342+
hdr "paste-correctness self-test"
343+
if "$_selftest" >/dev/null 2>&1; then
344+
ok "self-test" "behavioral paste-correctness suite passed"
345+
else
346+
fail "self-test" "behavioral paste-correctness suite FAILED — run $_selftest"
347+
fails=$((fails + 1))
348+
fi
349+
fi
350+
351+
# ── Keybinding drift check (kitty.conf vs tmux.conf vs canonical) ──────
352+
# The double-paste bug was Ctrl+V drifting between the two configs. Warn
353+
# (not fail) on drift: it degrades to the daemon's dedup, not a breakage.
354+
_kbcheck="$(dirname -- "$0")/flashpaste-keybindings-check.sh"
355+
if [ -x "$_kbcheck" ]; then
356+
echo
357+
hdr "keybinding drift"
358+
if "$_kbcheck" >/dev/null 2>&1; then
359+
ok "keybindings" "kitty + tmux Ctrl+V consistent with canonical source"
360+
else
361+
warn "keybindings" "Ctrl+V drift between kitty/tmux/canonical — run $_kbcheck"
362+
warns=$((warns + 1))
363+
fi
364+
fi
365+
335366
echo
336367
if [ "$fails" -eq 0 ] && [ "$warns" -eq 0 ]; then
337368
printf "${GREEN}All $core_checks core checks passed.${RESET} $optional_checks optional probe(s) also passed. flashpaste should work out of the box.\n"
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#!/usr/bin/env bash
2+
# flashpaste keybinding drift check — read-only.
3+
#
4+
# Verifies the live kitty.conf and tmux.conf bind Ctrl+V the way the canonical
5+
# source (config/keybindings.canonical) says they must. The double-paste bug
6+
# was these two drifting apart; this catches the drift instead of silently
7+
# pasting twice. Read-only: it never edits your dotfiles.
8+
#
9+
# Env overrides (for testing): KITTY_CONF, TMUX_CONF, CANONICAL.
10+
# Exit 0 = consistent, 1 = drift/missing, 2 = canonical unreadable.
11+
set -u
12+
13+
ROOT="$(cd "$(dirname -- "$0")/.." && pwd)"
14+
CANONICAL="${CANONICAL:-$ROOT/config/keybindings.canonical}"
15+
KITTY_CONF="${KITTY_CONF:-$HOME/.config/kitty/kitty.conf}"
16+
TMUX_CONF="${TMUX_CONF:-$HOME/.tmux.conf}"
17+
18+
[ -r "$CANONICAL" ] || { echo "FATAL: canonical not readable: $CANONICAL"; exit 2; }
19+
20+
drift=0
21+
while read -r surface key want; do
22+
case "$surface" in ""|\#*) continue ;; esac
23+
# Escape ERE metacharacters in the key (e.g. the '+' in ctrl+v).
24+
key_re=$(printf '%s' "$key" | sed 's/[][\\.^$*+?(){}|]/\\&/g')
25+
case "$surface" in
26+
kitty) conf="$KITTY_CONF"; line=$(grep -E "^[[:space:]]*map[[:space:]]+$key_re[[:space:]]" "$conf" 2>/dev/null | tail -1) ;;
27+
tmux) conf="$TMUX_CONF"; line=$(grep -E "^[[:space:]]*bind[[:space:]]+-n[[:space:]]+$key_re[[:space:]]" "$conf" 2>/dev/null | tail -1) ;;
28+
*) echo " ? unknown surface '$surface' in canonical"; drift=1; continue ;;
29+
esac
30+
if [ -z "$line" ]; then
31+
echo "$surface $key: no binding found in $conf"
32+
drift=1
33+
elif printf '%s' "$line" | grep -Fq -- "$want"; then
34+
echo "$surface $key -> $want"
35+
else
36+
echo "$surface $key: binding does not route through '$want'"
37+
echo " live: $line"
38+
drift=1
39+
fi
40+
done < "$CANONICAL"
41+
42+
if [ "$drift" = "0" ]; then
43+
echo "keybindings: consistent with canonical source"
44+
else
45+
echo "keybindings: DRIFT detected — fix the live config(s) above"
46+
fi
47+
exit "$drift"

bin/flashpaste-selftest.sh

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#!/usr/bin/env bash
2+
# flashpaste self-test — one command that proves the paste pipeline's
3+
# correctness guards still hold. Runs the behavioral regression suite (the
4+
# net under the image-bytes-as-text / blob-markup bug class) plus, with
5+
# --rust, the daemon's unit tests.
6+
#
7+
# flashpaste-selftest.sh behavioral bash tests only (fast, no build)
8+
# flashpaste-selftest.sh --rust also `cargo test -p flashpasted`
9+
#
10+
# Exits non-zero if any test fails. Headless-safe: the bash tests mock the
11+
# clipboard, so this never touches the user's real selection and needs no
12+
# display. Wired into CI (.github/workflows/lint.yml) and callable from
13+
# flashpaste-doctor.
14+
set -u
15+
16+
ROOT="$(cd "$(dirname -- "$0")/.." && pwd)"
17+
fail=0
18+
19+
echo "── flashpaste self-test ───────────────────────────────────"
20+
for t in "$ROOT"/tests/*.test.sh; do
21+
[ -f "$t" ] || continue
22+
echo
23+
echo "$(basename "$t")"
24+
if bash "$t"; then :; else fail=1; fi
25+
done
26+
27+
if [ "${1:-}" = "--rust" ]; then
28+
echo
29+
echo "▶ cargo test -p flashpasted"
30+
if cargo test --manifest-path "$ROOT/rs/Cargo.toml" -p flashpasted 2>&1 | tail -3; then :; else fail=1; fi
31+
fi
32+
33+
echo
34+
if [ "$fail" = "0" ]; then
35+
echo "✓ flashpaste self-test: ALL PASSED"
36+
else
37+
echo "✗ flashpaste self-test: FAILURES (see above)"
38+
fi
39+
exit "$fail"

bin/kitty-focused-is-tmux.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
#!/usr/bin/env python3
2+
"""Read `kitten @ ls --match state:focused` JSON on stdin; exit 0 iff the
3+
focused kitty window's foreground process is tmux.
4+
5+
Used by kitty-paste-router.sh to suppress kitty's redundant ctrl+v handler
6+
when tmux's own `bind -n C-v` will handle the paste. Exit non-zero on ANY
7+
uncertainty (no data, parse error, no tmux) so the caller falls through to
8+
the normal paste path — a miss must degrade to "paste anyway", never to a
9+
silent no-op.
10+
11+
Why the foreground process: when tmux runs inside a kitty window, that
12+
window's child IS the tmux client; the inner shells live under the tmux
13+
server's process tree, not the kitty window. So a focused window whose
14+
foreground process is `tmux` reliably means "this Ctrl+V is inside tmux".
15+
"""
16+
import json
17+
import sys
18+
19+
20+
def _is_tmux_cmdline(cmdline):
21+
if not cmdline:
22+
return False
23+
exe = cmdline[0].rsplit("/", 1)[-1]
24+
return exe == "tmux" or exe.startswith("tmux")
25+
26+
27+
def main():
28+
try:
29+
data = json.load(sys.stdin)
30+
except Exception:
31+
return 1
32+
if not isinstance(data, list):
33+
return 1
34+
for os_window in data:
35+
for tab in os_window.get("tabs", []):
36+
for window in tab.get("windows", []):
37+
# Only consider a window we can POSITIVELY confirm is focused.
38+
# `--match state:focused` already filters to it (and sets
39+
# is_focused=true), so requiring True here is exact on modern
40+
# kitty. On older kitty that ignores the match and returns
41+
# every window WITHOUT is_focused, no window qualifies -> we
42+
# report "not tmux" -> the router falls through and the paste
43+
# still happens. That is the fail-safe direction: never
44+
# suppress a paste on a window we cannot prove is focused.
45+
if window.get("is_focused") is not True:
46+
continue
47+
for proc in window.get("foreground_processes", []):
48+
if _is_tmux_cmdline(proc.get("cmdline")):
49+
return 0
50+
return 1
51+
52+
53+
if __name__ == "__main__":
54+
sys.exit(main())

bin/kitty-paste-router.sh

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#!/usr/bin/env bash
2+
# ─────────────────────────────────────────────────────────────────────
3+
# kitty ctrl+v router — eliminate the dual-handler double-paste at source.
4+
#
5+
# Problem: a single Ctrl+V inside kitty+tmux fires TWO handlers that both
6+
# call flashpaste-trigger for the same pane — kitty's `map ctrl+v` AND
7+
# tmux's `bind -n C-v`. They land hundreds of ms apart, so the daemon's
8+
# (pane, content) dedup window has to race them. This router removes the
9+
# redundant kitty fire entirely: if the FOCUSED kitty window is running
10+
# tmux, tmux's own bind already handles the paste, so kitty does nothing.
11+
#
12+
# Safety: this is a BELT on top of the daemon's dedup SUSPENDERS. We only
13+
# short-circuit when we can CONFIDENTLY detect tmux in the focused window
14+
# (kitty remote control works AND its foreground process is tmux). On any
15+
# uncertainty — remote control off, kitten missing, parse failure — we fall
16+
# through to the original path, and the daemon's dedup still absorbs the
17+
# duplicate. So a detection miss degrades to today's behaviour, never to a
18+
# broken paste.
19+
#
20+
# Latency: the kitten round-trip (~30ms) only delays kitty's REDUNDANT
21+
# handler. The user-visible paste already happened via tmux's fast bind, so
22+
# this adds zero latency to the actual paste. When NOT in tmux, kitty's
23+
# handler is the only one and ~30ms there is unnoticeable.
24+
# ─────────────────────────────────────────────────────────────────────
25+
set -u
26+
27+
PASTE_IMAGE_FALLBACK="${FLASHPASTE_IMAGE_FALLBACK:-/home/deadpool/paste_image.sh}"
28+
# Resolve kitty's remote-control socket.
29+
#
30+
# kitty appends "-<pid>" to the `listen_on` path, so `listen_on
31+
# unix:.../kitty-main` actually opens `.../kitty-main-<pid>`. Crucially,
32+
# KITTY_LISTEN_ON is NOT in the env this script inherits: kitty's `map
33+
# ctrl+v launch --copy-env` copies kitty's OWN process env, which does not
34+
# carry KITTY_LISTEN_ON (only child windows get it). So the old bare
35+
# `kitty-main` fallback never matched the real `kitty-main-<pid>` socket —
36+
# `kitten @ ls` failed every time, focused_window_is_tmux() always returned
37+
# false, and this router NEVER suppressed kitty's redundant fire. Net effect:
38+
# a single Ctrl+V pasted twice (tmux's bind immediately + this router ~1s
39+
# later). Resolve robustly: trust KITTY_LISTEN_ON when present, else glob the
40+
# real socket (newest wins if several kitty instances are running).
41+
resolve_kitty_sock() {
42+
if [ -n "${KITTY_LISTEN_ON:-}" ]; then
43+
printf '%s\n' "$KITTY_LISTEN_ON"
44+
return 0
45+
fi
46+
local base s
47+
base="/run/user/$(id -u)/kitty-main"
48+
# Direct shell glob — no `ls`, no word-splitting (paths with spaces stay
49+
# intact). kitty creates `kitty-main-<pid>`; with one instance there's
50+
# exactly one match. With several this picks the first lexically — good
51+
# enough for the common single-instance case, and a wrong guess only falls
52+
# through to the normal paste path (the daemon's dedup is the backstop). If
53+
# the glob matches nothing it stays literal and fails the `-S` test below.
54+
for s in "$base"-* "$base"; do
55+
[ -S "$s" ] && { printf 'unix:%s\n' "$s"; return 0; }
56+
done
57+
# Last-ditch: hand back the bare path; kitten will fail and we fall through
58+
# to the normal paste path (the daemon's dedup is the remaining safety net).
59+
printf 'unix:%s\n' "$base"
60+
}
61+
KITTY_SOCK="$(resolve_kitty_sock)"
62+
63+
# Confidently true (exit 0) ONLY when the focused kitty window's foreground
64+
# process is tmux. Any failure to determine that returns non-zero, so the
65+
# caller falls through to the normal paste path.
66+
focused_window_is_tmux() {
67+
command -v kitten >/dev/null 2>&1 || return 1
68+
command -v python3 >/dev/null 2>&1 || return 1
69+
kitten @ --to "$KITTY_SOCK" ls --match state:focused 2>/dev/null \
70+
| python3 "$(dirname -- "$0")/kitty-focused-is-tmux.py" 2>/dev/null
71+
}
72+
73+
if focused_window_is_tmux; then
74+
# tmux's `bind -n C-v` owns this paste. Do nothing.
75+
exit 0
76+
fi
77+
78+
# Not in tmux (or undetectable) — run the original kitty paste path. The
79+
# daemon dedups if this turns out to be a duplicate of tmux's fire.
80+
pane="$(tmux display-message -p "#{pane_id}" 2>/dev/null)"
81+
# Only call the daemon trigger when we actually have a pane id. An empty
82+
# pane (no tmux at all) can't be a paste target, so go straight to the
83+
# image fallback instead of handing the daemon a blank pane.
84+
if [ -n "$pane" ] && flashpaste-trigger "$pane" 2>/dev/null; then
85+
exit 0
86+
fi
87+
exec "$PASTE_IMAGE_FALLBACK"

config/keybindings.canonical

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# flashpaste — canonical keybinding source of truth
2+
#
3+
# The double-paste bug was config DRIFT: kitty.conf and tmux.conf each bound
4+
# Ctrl+V independently and got out of sync. This file is the single place that
5+
# declares what each side MUST bind. bin/flashpaste-keybindings-check.sh reads
6+
# it and verifies the live configs match, flagging drift (run by doctor).
7+
#
8+
# Format: one rule per non-comment line: <surface> <key> <must-contain-substring>
9+
# The check passes when the live config's binding for <key> contains the
10+
# substring. Keep substrings stable (script basenames), not full command lines.
11+
12+
# kitty's Ctrl+V must route through the tmux-aware router (suppresses kitty's
13+
# handler inside tmux so only tmux's bind fires — see kitty-paste-router.sh).
14+
kitty ctrl+v kitty-paste-router.sh
15+
16+
# tmux's root Ctrl+V must hit the fast Rust trigger (daemon), falling back to
17+
# the bash dispatcher when the daemon is down.
18+
tmux C-v flashpaste-trigger
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# ADR 0006 — Reduce the bash surface, bounded by the Tier-1 fallback guarantee
2+
3+
- **Status:** Proposed
4+
- **Date:** 2026-06-10
5+
- **Deciders:** maintainers
6+
- **Tags:** architecture, maintainability, scope
7+
8+
## Context and problem statement
9+
10+
flashpaste carries ~3.7k lines of bash across 17 tracked scripts alongside
11+
~4k lines of Rust. Bash is hard to test and each script has been a breakage
12+
source (the `wl-paste` shim, `clipboard-set.sh`, `tmux-paste-dispatch.sh`).
13+
A tempting "improvement" is to fold the shell into the Rust daemon so the
14+
logic is testable and typed.
15+
16+
## The constraint that makes this an ocean
17+
18+
Most of the bash is **load-bearing by design**, not accident:
19+
20+
- `bin/tmux-paste-dispatch.sh` (657 lines) is the **Tier-1 canonical path**
21+
per [ADR 0001](0001-three-progressive-tiers.md). It MUST run with zero
22+
daemon, zero Rust toolchain, zero systemd. The daemon (Tier 3) execs it on
23+
any failure. Folding it into the daemon would delete the very fallback that
24+
makes "flashpasted is not running" a non-event.
25+
- `bin/clipboard-set.sh` is invoked by tmux's `@clip` hook in the user's
26+
shell, before any daemon round-trip — it has to be a script tmux can exec.
27+
- `~/.local/bin/wl-paste` is a PATH shim that Claude Code reads through
28+
directly; it only works because it IS a `wl-paste`-named executable. It
29+
cannot become daemon-internal without changing how Claude reads the
30+
clipboard.
31+
32+
So "move the bash into Rust" is partly a **non-goal**: the bash surface is
33+
the zero-dependency tier. This downgrades the ROI estimate the improvement
34+
list gave #3 (friction +50% 🟠) — the genuinely reducible part is smaller.
35+
36+
## What IS reducible (the real first slices)
37+
38+
1. **Duplication between shims and daemon.** The daemon already has typed
39+
probes (`read_clipboard_text_if_present`, `read_clipboard_image_if_present`,
40+
`looks_like_text`) that re-implement logic also living in
41+
`get-clipboard-text.sh` (169 lines) and parts of `tmux-paste-dispatch.sh`.
42+
Where the daemon is up, those shells should call the daemon (one op) rather
43+
than re-deriving. First slice: a `flashpaste-trigger --get-text` op that
44+
`get-clipboard-text.sh` shells to when the socket exists, falling back to
45+
its current logic otherwise. Removes ~100 lines of duplicated probe logic
46+
without touching the Tier-1 guarantee.
47+
2. **Behavioral test coverage for the bash that must stay.** Tier-1 is
48+
permanent, so test it instead of deleting it. The `tests/*.test.sh` harness
49+
(mocked clipboard, headless, in CI as of this session) is the pattern; grow
50+
it to cover `tmux-paste-dispatch.sh`'s text-vs-image decision.
51+
52+
## Decision
53+
54+
**Proposed:** do NOT pursue a wholesale bash→Rust rewrite. Treat the Tier-1
55+
bash as a permanent, first-class artifact and invest in (a) deleting only the
56+
*duplication* between shims and the daemon, slice by slice, and (b) behavioral
57+
tests for the bash that must remain. Each slice ships independently and keeps
58+
the no-daemon guarantee intact.
59+
60+
## Consequences
61+
62+
- The ~657-line Tier-1 dispatcher stays. That's correct, not debt.
63+
- "Shrink the bash" becomes a bounded, test-first cleanup, not a multi-day
64+
rewrite that risks the fallback path.
65+
- Open question: if a future ADR retires the three-tier model (daemon becomes
66+
mandatory), this constraint lifts and a fuller consolidation reopens.

0 commit comments

Comments
 (0)