Skip to content

fix: harden tmux clipboard delivery and polling (v0.4.2)#153

Merged
gbasin merged 5 commits into
masterfrom
fix/clipboard-robustness
Jun 26, 2026
Merged

fix: harden tmux clipboard delivery and polling (v0.4.2)#153
gbasin merged 5 commits into
masterfrom
fix/clipboard-robustness

Conversation

@gbasin

@gbasin gbasin commented Jun 26, 2026

Copy link
Copy Markdown
Owner

What

Hardens the tmux paste-buffer → browser clipboard path (the relay that serves agent copies to the browser, especially iOS Safari). Originated from an audit of the clipboard machinery added in #151; findings were verified with independent reviewers before and after each change.

Changes

  • Deterministic delivery (fix: correct xterm CSS import to use scoped package name #1): pin tmux set-clipboard on so an app's OSC 52 copy actually lands in a paste buffer the poll can read (the modern external default forwards OSC 52 outward but stores no buffer). Server-global, so it's opt-out via AGENTBOARD_TMUX_SET_CLIPBOARD=0 (documented in README).
  • No event-loop blocking (feat: persistent tmux client support #2): the 750ms buffer poll now uses async Bun.spawn instead of spawnSync, with a kill-timer so a hung tmux can't wedge the poll, and stderr: 'ignore' to avoid an undrained pipe.
  • No clipboard clobber (feat: add systemd user service for persistent deployment #3): offers are gated on a created-time correlation — only buffers created at/after the arming gesture are offered, so a pre-existing/cross-window buffer can't overwrite the clipboard. Arming covers all selection styles (drag, double/triple-click).
  • Correct recency (feat: discover all tmux sessions by default #5): same-second buffers tiebreak by tmux's monotonic buffer<N> index, not size.
  • Concurrency safety: a watch-generation token invalidates stale async work across attach/re-attach, and freshness is re-validated after each async tmux read before delivery.

Review

Audited, then independently reviewed across three rounds (codex + workflow reviewers). Round 1 caught generation races (fixed); round 2 confirmed them fixed and caught a slack edge (fixed); the residual is an irreducible same-second + sub-attach-window case that fails safe. One efficiency item (coalescing per-connection polls) is deferred as a documented known-limitation.

Verification

bun run lint && bun run typecheck && bun run test — green (lint 0, typecheck clean, 912 pass / 0 fail), including 4 dedicated clipboard tests.

🤖 Generated with Claude Code

gbasin added 5 commits June 25, 2026 16:58
- pin set-clipboard on so app copies land in a tmux paste buffer the
  poll can read (modern 'external' default forwards OSC 52 but stores
  no buffer), making browser/iOS delivery deterministic (#1)
- run the 750ms buffer poll via async Bun.spawn instead of spawnSync so
  it no longer blocks the event loop / stalls terminal streaming (#2)
- arm the clipboard watch only on a real left-button drag, not bare
  taps, to avoid clobbering the clipboard with unrelated buffer changes
  during the armed window (#3)
- tiebreak same-second buffers by tmux's monotonic buffer index instead
  of size when picking the latest (#5)

Adds regression tests for set-clipboard setup and tap-vs-drag arming.
- revert over-tight drag-only arming: it dropped double/triple-click
  selections (button-0, no motion bit) which are real copies. Arm on
  any left-button event again, but gate offers on a new created-time
  correlation so taps still can't clobber the clipboard (#1)
- only offer buffers created at/after the arming gesture, preventing a
  pre-existing or cross-window buffer from clobbering the clipboard and
  closing the async-baseline race (#3/#4)
- re-validate socket/session freshness after each async tmux read before
  sending, so a stale/wrong-session offer can't be delivered (#2)
- parse the monotonic order only from tmux's 'buffer<N>' names so an
  explicitly named buffer ending in digits can't outrank a newer one (#5)
- readTmuxCapture: ignore stderr (no undrained pipe) and add a kill-timer
  so a hung tmux can't wedge clipboardPollInFlight forever (#6/#7)
- make the server-global set-clipboard opt-out via
  AGENTBOARD_TMUX_SET_CLIPBOARD=0, documented in README (#8/global side effect)

Tests: click-only selection arms+offers; pre-gesture buffer not offered.
Two independent reviewers flagged DO-NOT-SHIP races in the async refactor,
all rooted in the lack of a generation token across watch stop/restart:

- a stale baseline read could write into a later watch (after a session
  switch/re-attach), watermarking and suppressing the real copy (high)
- an in-flight poll's finally could clear clipboardPollInFlight for a newer
  watch, allowing overlapping reads + stale state mutation (medium)
- the async baseline could watermark a copy the user made before it
  resolved, silently dropping that offer (medium)

Fix: stopClipboardBufferWatch bumps ws.data.clipboardWatchGen; the baseline
read and pollClipboardBuffer capture the generation at launch and bail (incl.
the finally guard) if it no longer matches. The baseline also skips when the
watch is already armed, so it can't watermark the user's own fresh copy.

Adds a regression test: a poll closure from a superseded generation is inert
while the current-generation poll still delivers the offer.
…ound 3)

Round-2 reviewers found the 1s slack was the sole cause of a residual edge:
the baseline skips when already armed, so a pre-existing buffer created within
the slack window before the gesture could be offered as the user's copy.

The slack was unnecessary — handleTerminalInputPersistent arms BEFORE forwarding
the input to tmux, so any buffer the gesture creates is stamped at/after the arm
second. With slack=0 the poll's created-correlation now rejects every pre-existing
buffer from an earlier second, shrinking the edge to an irreducible same-second +
gesture-within-ms-of-attach case. No legitimate copy is missed (created >= armSec
always holds). Test copy-buffer timestamps widened for CI timing headroom.
@gbasin gbasin merged commit 5216db9 into master Jun 26, 2026
5 checks passed
@gbasin gbasin deleted the fix/clipboard-robustness branch June 26, 2026 00:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant