From ed37e8cbf439360b9175c0cdf1850f9beb393011 Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Thu, 25 Jun 2026 16:58:58 -0400 Subject: [PATCH 1/5] fix: harden tmux clipboard delivery and polling - 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. --- .../__tests__/isolated/indexHandlers.test.ts | 84 ++++++++++++++ .../__tests__/isolated/terminalProxy.test.ts | 43 ++++++++ src/server/index.ts | 104 ++++++++++++------ src/server/terminal/PtyTerminalProxy.ts | 15 +++ 4 files changed, 213 insertions(+), 33 deletions(-) diff --git a/src/server/__tests__/isolated/indexHandlers.test.ts b/src/server/__tests__/isolated/indexHandlers.test.ts index 065825bd..3ccc575f 100644 --- a/src/server/__tests__/isolated/indexHandlers.test.ts +++ b/src/server/__tests__/isolated/indexHandlers.test.ts @@ -2550,6 +2550,90 @@ describe('server message handlers', () => { } }) + test('does not arm the clipboard watch on a bare tap (no drag selection)', async () => { + const { serveOptions, registryInstance } = await loadIndex() + registryInstance.sessions = [baseSession] + + let intervalCallback: (() => void) | null = null + const originalSetIntervalForTest = globalThis.setInterval + const originalClearInterval = globalThis.clearInterval + globalThis.setInterval = ((callback: TimerHandler) => { + intervalCallback = callback as () => void + return 123 as unknown as ReturnType + }) as unknown as typeof globalThis.setInterval + globalThis.clearInterval = (() => {}) as typeof globalThis.clearInterval + + let bufferCreated = 1 + spawnSyncImpl = ((...args: Parameters) => { + const command = Array.isArray(args[0]) ? args[0] : [String(args[0])] + const tmuxArgs = getTmuxArgs(command as string[]) + if (tmuxArgs[0] === 'list-buffers') { + return { + exitCode: 0, + stdout: Buffer.from(tmuxLine('buffer0', String(bufferCreated), '18') + '\n'), + stderr: Buffer.from(''), + } as ReturnType + } + if (tmuxArgs[0] === 'show-buffer') { + return { + exitCode: 0, + stdout: Buffer.from('copied-from-tmux'), + stderr: Buffer.from(''), + } as ReturnType + } + return { + exitCode: 0, + stdout: Buffer.from(''), + stderr: Buffer.from(''), + } as ReturnType + }) as typeof Bun.spawnSync + + try { + const { ws, sent } = createWs() + const websocket = serveOptions.websocket + if (!websocket) { + throw new Error('WebSocket handlers not configured') + } + + websocket.open?.(ws as never) + websocket.message?.( + ws as never, + JSON.stringify({ + type: 'terminal-attach', + sessionId: baseSession.id, + tmuxTarget: baseSession.tmuxWindow, + }) + ) + + await new Promise((r) => setTimeout(r, 0)) + await new Promise((r) => setTimeout(r, 0)) + + const pollClipboard = intervalCallback as (() => void) | null + if (!pollClipboard) { + throw new Error('Expected clipboard poll interval') + } + + // Bare tap: left-button press + release at the same cell, no drag + // (button 32). This must NOT arm the watch. + websocket.message?.( + ws as never, + JSON.stringify({ + type: 'terminal-input', + sessionId: baseSession.id, + data: '\x1b[<0;10;4M\x1b[<0;10;4m', + }) + ) + bufferCreated = 2 + pollClipboard() + await new Promise((r) => setTimeout(r, 0)) + + expect(sent.some((message) => message.type === 'clipboard-offer')).toBe(false) + } finally { + globalThis.setInterval = originalSetIntervalForTest + globalThis.clearInterval = originalClearInterval + } + }) + test('session-only attach replays the current grouped view and keeps copy-mode targeting aligned in pty mode', async () => { const { serveOptions, registryInstance } = await loadIndex() registryInstance.sessions = [baseSession] diff --git a/src/server/__tests__/isolated/terminalProxy.test.ts b/src/server/__tests__/isolated/terminalProxy.test.ts index cbeca9a6..692b4ed6 100644 --- a/src/server/__tests__/isolated/terminalProxy.test.ts +++ b/src/server/__tests__/isolated/terminalProxy.test.ts @@ -317,6 +317,49 @@ describe('TerminalProxy', () => { ]) }) + test('enables set-clipboard so copies land in a tmux paste buffer', async () => { + const spawnSyncCalls: string[][] = [] + const spawnSync = (args: string[], _options?: Parameters[1]) => { + spawnSyncCalls.push(args) + const command = getTmuxCommand(args) + if (command === 'list-clients') { + return { + exitCode: 0, + stdout: Buffer.from(CLIENT_TTY_OUTPUT), + stderr: Buffer.from(''), + } as ReturnType + } + return { + exitCode: 0, + stdout: Buffer.from(''), + stderr: Buffer.from(''), + } as ReturnType + } + + const harness = createSpawnHarness() + const proxy = new TerminalProxy({ + connectionId: 'clipboard-test', + sessionName: 'agentboard-ws-clipboard-test', + baseSession: 'agentboard', + onData: () => {}, + spawn: harness.spawn, + spawnSync, + wait: async () => {}, + }) + + await proxy.start() + + // set-clipboard is a server option; `on` makes tmux store a paste buffer + // for the clipboard poll instead of only forwarding OSC 52 outward. + expect(spawnSyncCalls).toContainEqual([ + 'tmux', + 'set-option', + '-s', + 'set-clipboard', + 'on', + ]) + }) + test('copies mouse=off setting from base session to grouped session', async () => { const spawnSyncCalls: string[][] = [] const spawnSync = (args: string[], _options?: Parameters[1]) => { diff --git a/src/server/index.ts b/src/server/index.ts index b1b09c1b..b5ce893f 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -567,6 +567,10 @@ interface TmuxBufferSummary { name: string created: number size: number + // Monotonic index parsed from tmux's automatic buffer name (buffer0001, + // buffer0002, …); -1 for explicitly named buffers. Used as the recency + // tiebreaker when buffer_created collides at whole-second resolution. + order: number key: string } @@ -1974,53 +1978,65 @@ function parseTmuxBufferSummaries(output: string): TmuxBufferSummary[] { const created = Number.parseInt(parts[1] ?? '', 10) const size = Number.parseInt(parts[2] ?? '', 10) if (!name || !Number.isFinite(created) || !Number.isFinite(size)) continue + const orderMatch = name.match(/(\d+)$/) + const order = orderMatch ? Number.parseInt(orderMatch[1], 10) : -1 summaries.push({ name, created, size, + order, key: `${name}:${created}:${size}`, }) } - summaries.sort((a, b) => b.created - a.created || b.size - a.size) + // Newest first. tmux's #{buffer_created} is whole-second resolution, so when + // two buffers share a second, tiebreak by the monotonic automatic-buffer + // index (buffer0002 newer than buffer0001) rather than size, which carries + // no recency information. + summaries.sort((a, b) => b.created - a.created || b.order - a.order || b.size - a.size) return summaries } -function readLatestTmuxBufferSummary(): TmuxBufferSummary | null { +/** + * Run a read-only tmux command without blocking the event loop. The clipboard + * watch polls on a 750ms timer, so using the synchronous Bun.spawnSync here + * would stall terminal streaming for every session on each tick (up to + * tmuxTimeoutMs if tmux is slow). Bun.spawn keeps it off the JS thread. + */ +async function readTmuxCapture(tmuxArgs: string[]): Promise { try { - const result = Bun.spawnSync( - [ - 'tmux', - ...withTmuxUtf8Flag([ - 'list-buffers', - '-F', - buildTmuxFormat(['#{buffer_name}', '#{buffer_created}', '#{buffer_size}']), - ]), - ], - { stdout: 'pipe', stderr: 'pipe', timeout: config.tmuxTimeoutMs } - ) - if (result.exitCode !== 0) return null - return parseTmuxBufferSummaries(result.stdout?.toString() ?? '')[0] ?? null + const proc = Bun.spawn(['tmux', ...withTmuxUtf8Flag(tmuxArgs)], { + stdout: 'pipe', + stderr: 'pipe', + timeout: config.tmuxTimeoutMs, + }) + const [stdout, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + proc.exited, + ]) + if (exitCode !== 0) return null + return stdout } catch { return null } } -function readTmuxBufferText(summary: TmuxBufferSummary): string | null { - if (summary.size <= 0 || summary.size > CLIPBOARD_BUFFER_MAX_BYTES) { - return null - } +async function readLatestTmuxBufferSummary(): Promise { + const stdout = await readTmuxCapture([ + 'list-buffers', + '-F', + buildTmuxFormat(['#{buffer_name}', '#{buffer_created}', '#{buffer_size}']), + ]) + if (stdout === null) return null + return parseTmuxBufferSummaries(stdout)[0] ?? null +} - try { - const result = Bun.spawnSync( - ['tmux', ...withTmuxUtf8Flag(['show-buffer', '-b', summary.name])], - { stdout: 'pipe', stderr: 'pipe', timeout: config.tmuxTimeoutMs } - ) - if (result.exitCode !== 0) return null - const text = result.stdout?.toString() ?? '' - return text.trim() ? text : null - } catch { +async function readTmuxBufferText(summary: TmuxBufferSummary): Promise { + if (summary.size <= 0 || summary.size > CLIPBOARD_BUFFER_MAX_BYTES) { return null } + const text = await readTmuxCapture(['show-buffer', '-b', summary.name]) + if (text === null) return null + return text.trim() ? text : null } function stopClipboardBufferWatch(ws: ServerWebSocket) { @@ -2044,11 +2060,24 @@ function startClipboardBufferWatch( return } - const initial = readLatestTmuxBufferSummary() - ws.data.lastClipboardBufferKey = initial?.key ?? null ws.data.clipboardPollTimer = setInterval(() => { fireAndForget(pollClipboardBuffer(ws, sessionId), 'pollClipboardBuffer') }, CLIPBOARD_BUFFER_POLL_MS) + // Establish the baseline asynchronously so a pre-existing buffer isn't offered + // on the first poll. Guarded so it can't clobber a key that an early poll + // already advanced past during the sub-tick race before this resolves. + fireAndForget( + (async () => { + const initial = await readLatestTmuxBufferSummary() + if ( + ws.data.clipboardPollTimer !== null && + ws.data.lastClipboardBufferKey === null + ) { + ws.data.lastClipboardBufferKey = initial?.key ?? null + } + })(), + 'clipboardBaseline' + ) } async function pollClipboardBuffer( @@ -2064,7 +2093,7 @@ async function pollClipboardBuffer( ws.data.clipboardPollInFlight = true try { - const latest = readLatestTmuxBufferSummary() + const latest = await readLatestTmuxBufferSummary() if (!latest) { ws.data.lastClipboardBufferKey = null return @@ -2084,7 +2113,7 @@ async function pollClipboardBuffer( return } - const text = readTmuxBufferText(latest) + const text = await readTmuxBufferText(latest) if (!text) { logger.debug('clipboard_buffer_skipped', { sessionId, @@ -2108,12 +2137,21 @@ async function pollClipboardBuffer( } } +// Arm the clipboard buffer watch only on an actual left-button DRAG (a real +// text selection), not on a bare tap. SGR mouse encoding: low 2 bits = button +// (0 = left), bit 5 (32) = motion/drag, bits 6-7 (0xC0) = wheel/extended. A +// plain tap is button 0 with no motion bit and must NOT arm, otherwise an +// unrelated tmux buffer change during the armed window could clobber the +// clipboard with content the user never selected. function containsClipboardArmingMouseInput(data: string): boolean { SGR_MOUSE_INPUT_RE.lastIndex = 0 let match: RegExpExecArray | null while ((match = SGR_MOUSE_INPUT_RE.exec(data)) !== null) { const button = Number(match[1]) - if (button < 64 && (button & 3) === 0) { + const isLeftButton = (button & 3) === 0 + const isMotion = (button & 32) !== 0 + const isWheelOrExtended = (button & 0xc0) !== 0 + if (isLeftButton && isMotion && !isWheelOrExtended) { return true } } diff --git a/src/server/terminal/PtyTerminalProxy.ts b/src/server/terminal/PtyTerminalProxy.ts index d40e79bc..8c1652d9 100644 --- a/src/server/terminal/PtyTerminalProxy.ts +++ b/src/server/terminal/PtyTerminalProxy.ts @@ -173,6 +173,21 @@ class PtyTerminalProxy extends TerminalProxyBase { }) } + // Ensure copies inside the session land in a tmux paste buffer so the + // server-side clipboard poll can deliver them to the browser. This matters + // most on iOS Safari, where the async OSC 52 path can't satisfy the + // user-gesture rule. `set-clipboard` is a SERVER option: with the modern + // `external` default tmux forwards an app's OSC 52 to the outer terminal + // but stores no buffer for the poll to read; `on` makes it store one. + try { + this.runTmuxMutation(['set-option', '-s', 'set-clipboard', 'on']) + } catch (error) { + this.logEvent('terminal_set_clipboard_failed', { + sessionName: this.options.sessionName, + error: error instanceof Error ? error.message : String(error), + }) + } + if (attemptId !== this.startAttemptId) { await this.dispose() return From 0a85ccef80a541de3194fd10ebc0b3589476fe69 Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Thu, 25 Jun 2026 16:59:39 -0400 Subject: [PATCH 2/5] chore: bump version to 0.4.2 --- bun.lock | 8 ++++---- package.json | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/bun.lock b/bun.lock index ade5b68f..0b266421 100644 --- a/bun.lock +++ b/bun.lock @@ -52,10 +52,10 @@ "vite-plugin-pwa": "^1.2.0", }, "optionalDependencies": { - "@gbasin/agentboard-darwin-arm64": "0.4.1", - "@gbasin/agentboard-darwin-x64": "0.4.1", - "@gbasin/agentboard-linux-arm64": "0.4.1", - "@gbasin/agentboard-linux-x64": "0.4.1", + "@gbasin/agentboard-darwin-arm64": "0.4.2", + "@gbasin/agentboard-darwin-x64": "0.4.2", + "@gbasin/agentboard-linux-arm64": "0.4.2", + "@gbasin/agentboard-linux-x64": "0.4.2", }, }, }, diff --git a/package.json b/package.json index 0d6304a5..5907dca8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gbasin/agentboard", - "version": "0.4.1", + "version": "0.4.2", "type": "module", "description": "Web GUI for tmux optimized for AI agent TUIs", "author": "gbasin", @@ -20,10 +20,10 @@ "bun": ">=1.3.14" }, "optionalDependencies": { - "@gbasin/agentboard-darwin-arm64": "0.4.1", - "@gbasin/agentboard-darwin-x64": "0.4.1", - "@gbasin/agentboard-linux-x64": "0.4.1", - "@gbasin/agentboard-linux-arm64": "0.4.1" + "@gbasin/agentboard-darwin-arm64": "0.4.2", + "@gbasin/agentboard-darwin-x64": "0.4.2", + "@gbasin/agentboard-linux-x64": "0.4.2", + "@gbasin/agentboard-linux-arm64": "0.4.2" }, "scripts": { "dev": "concurrently -k \"bun run dev:server\" \"bun run dev:client\"", From 962fa91fcf2faf27ce90c228f123ce09f1752b54 Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Thu, 25 Jun 2026 17:21:31 -0400 Subject: [PATCH 3/5] fix: address code-review findings on clipboard hardening - 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' 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. --- README.md | 3 + .../__tests__/isolated/indexHandlers.test.ts | 119 ++++++++++++++++-- src/server/index.ts | 81 ++++++++++-- src/server/terminal/PtyTerminalProxy.ts | 25 ++-- 4 files changed, 198 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index a166da58..2454a502 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ PRUNE_WS_SESSIONS=true AGENTBOARD_PREFER_WINDOW_NAME=false TERMINAL_MODE=pty AGENTBOARD_CLAUDE_NO_FLICKER=true +AGENTBOARD_TMUX_SET_CLIPBOARD=true TERMINAL_MONITOR_TARGETS=true VITE_ALLOWED_HOSTS=nuc,myserver AGENTBOARD_DB_PATH=~/.agentboard/agentboard.db @@ -186,6 +187,8 @@ AGENTBOARD_PASTE_IMAGE_MAX_BYTES=41943040 Reasons to opt out: fullscreen rendering keeps the conversation in the alternate screen buffer instead of native terminal scrollback, so terminal-level search/copy workflows behave differently; Claude also captures mouse events unless you disable its mouse capture. Inside Claude Code, run `/tui default` to switch a session back to the classic renderer, or launch Claude with `CLAUDE_CODE_DISABLE_MOUSE=1` if you want fullscreen rendering but native mouse selection. +`AGENTBOARD_TMUX_SET_CLIPBOARD` controls whether Agentboard enables tmux's `set-clipboard on` when it attaches. By default it does, so copies made inside a session land in a tmux paste buffer that Agentboard can relay to the browser clipboard (important on iOS Safari, where the async OSC 52 path can't satisfy the user-gesture rule). Because `set-clipboard` is a server-global tmux option, this affects your whole tmux server and is not reverted on disconnect — set `AGENTBOARD_TMUX_SET_CLIPBOARD=0` (or `false`) to leave your tmux configuration untouched. + `TERMINAL_MONITOR_TARGETS` (pipe-pane only) polls tmux to detect closed targets (set to `false` to disable). `VITE_ALLOWED_HOSTS` allows access to the Vite dev server from other hostnames. Useful with Tailscale MagicDNS - add your machine name (e.g., `nuc`) to access the dev server at `http://nuc:5173` from other devices on your tailnet. diff --git a/src/server/__tests__/isolated/indexHandlers.test.ts b/src/server/__tests__/isolated/indexHandlers.test.ts index 3ccc575f..4b64861b 100644 --- a/src/server/__tests__/isolated/indexHandlers.test.ts +++ b/src/server/__tests__/isolated/indexHandlers.test.ts @@ -576,6 +576,7 @@ function createWs() { clipboardPollInFlight: false, lastClipboardBufferKey: null as string | null, clipboardBufferArmedUntil: 0, + clipboardArmedAtSec: 0, }, send: (payload: string) => { sent.push(JSON.parse(payload) as ServerMessage) @@ -2454,7 +2455,10 @@ describe('server message handlers', () => { }) as unknown as typeof globalThis.setInterval globalThis.clearInterval = (() => {}) as typeof globalThis.clearInterval - let bufferCreated = 1 + // Buffer creation times are real unix seconds: an offer requires the buffer + // to have been created at/after the arming gesture. + const nowSec = Math.floor(Date.now() / 1000) + let bufferCreated = nowSec - 10 spawnSyncImpl = ((...args: Parameters) => { const command = Array.isArray(args[0]) ? args[0] : [String(args[0])] const tmuxArgs = getTmuxArgs(command as string[]) @@ -2514,7 +2518,8 @@ describe('server message handlers', () => { sent.some((message) => message.type === 'clipboard-offer') ).toBe(false) - bufferCreated = 2 + // Unarmed buffer change: watermarked, not offered. + bufferCreated = nowSec - 5 pollClipboard() await new Promise((r) => setTimeout(r, 0)) expect( @@ -2529,7 +2534,8 @@ describe('server message handlers', () => { data: '\x1b[<0;10;4M\x1b[<32;11;4M\x1b[<0;11;4m', }) ) - bufferCreated = 3 + // Buffer created after the arming gesture: offered. + bufferCreated = nowSec + 1 pollClipboard() await new Promise((r) => setTimeout(r, 0)) expect(sent).toContainEqual({ @@ -2540,7 +2546,7 @@ describe('server message handlers', () => { }) const offerCount = sent.filter((message) => message.type === 'clipboard-offer').length - bufferCreated = 4 + bufferCreated = nowSec + 2 pollClipboard() await new Promise((r) => setTimeout(r, 0)) expect(sent.filter((message) => message.type === 'clipboard-offer')).toHaveLength(offerCount) @@ -2550,7 +2556,7 @@ describe('server message handlers', () => { } }) - test('does not arm the clipboard watch on a bare tap (no drag selection)', async () => { + test('arms on a click-only selection (double/triple-click, no drag) and offers the copy', async () => { const { serveOptions, registryInstance } = await loadIndex() registryInstance.sessions = [baseSession] @@ -2563,7 +2569,8 @@ describe('server message handlers', () => { }) as unknown as typeof globalThis.setInterval globalThis.clearInterval = (() => {}) as typeof globalThis.clearInterval - let bufferCreated = 1 + const nowSec = Math.floor(Date.now() / 1000) + let bufferCreated = nowSec - 10 spawnSyncImpl = ((...args: Parameters) => { const command = Array.isArray(args[0]) ? args[0] : [String(args[0])] const tmuxArgs = getTmuxArgs(command as string[]) @@ -2577,7 +2584,7 @@ describe('server message handlers', () => { if (tmuxArgs[0] === 'show-buffer') { return { exitCode: 0, - stdout: Buffer.from('copied-from-tmux'), + stdout: Buffer.from('word-from-double-click'), stderr: Buffer.from(''), } as ReturnType } @@ -2613,8 +2620,8 @@ describe('server message handlers', () => { throw new Error('Expected clipboard poll interval') } - // Bare tap: left-button press + release at the same cell, no drag - // (button 32). This must NOT arm the watch. + // Double-click word selection: left-button press + release with NO motion + // bit (button 32). This must still arm — it's a real selection that copies. websocket.message?.( ws as never, JSON.stringify({ @@ -2623,7 +2630,99 @@ describe('server message handlers', () => { data: '\x1b[<0;10;4M\x1b[<0;10;4m', }) ) - bufferCreated = 2 + bufferCreated = nowSec + 1 + pollClipboard() + await new Promise((r) => setTimeout(r, 0)) + + expect(sent).toContainEqual({ + type: 'clipboard-offer', + sessionId: baseSession.id, + text: 'word-from-double-click', + source: 'tmux-buffer', + }) + } finally { + globalThis.setInterval = originalSetIntervalForTest + globalThis.clearInterval = originalClearInterval + } + }) + + test('does not offer a buffer created before the arming gesture', async () => { + const { serveOptions, registryInstance } = await loadIndex() + registryInstance.sessions = [baseSession] + + let intervalCallback: (() => void) | null = null + const originalSetIntervalForTest = globalThis.setInterval + const originalClearInterval = globalThis.clearInterval + globalThis.setInterval = ((callback: TimerHandler) => { + intervalCallback = callback as () => void + return 123 as unknown as ReturnType + }) as unknown as typeof globalThis.setInterval + globalThis.clearInterval = (() => {}) as typeof globalThis.clearInterval + + const nowSec = Math.floor(Date.now() / 1000) + let bufferCreated = nowSec + spawnSyncImpl = ((...args: Parameters) => { + const command = Array.isArray(args[0]) ? args[0] : [String(args[0])] + const tmuxArgs = getTmuxArgs(command as string[]) + if (tmuxArgs[0] === 'list-buffers') { + return { + exitCode: 0, + stdout: Buffer.from(tmuxLine('buffer0', String(bufferCreated), '18') + '\n'), + stderr: Buffer.from(''), + } as ReturnType + } + if (tmuxArgs[0] === 'show-buffer') { + return { + exitCode: 0, + stdout: Buffer.from('stale-unrelated-buffer'), + stderr: Buffer.from(''), + } as ReturnType + } + return { + exitCode: 0, + stdout: Buffer.from(''), + stderr: Buffer.from(''), + } as ReturnType + }) as typeof Bun.spawnSync + + try { + const { ws, sent } = createWs() + const websocket = serveOptions.websocket + if (!websocket) { + throw new Error('WebSocket handlers not configured') + } + + websocket.open?.(ws as never) + websocket.message?.( + ws as never, + JSON.stringify({ + type: 'terminal-attach', + sessionId: baseSession.id, + tmuxTarget: baseSession.tmuxWindow, + }) + ) + + await new Promise((r) => setTimeout(r, 0)) + await new Promise((r) => setTimeout(r, 0)) + + const pollClipboard = intervalCallback as (() => void) | null + if (!pollClipboard) { + throw new Error('Expected clipboard poll interval') + } + + // Arm via a drag selection. + websocket.message?.( + ws as never, + JSON.stringify({ + type: 'terminal-input', + sessionId: baseSession.id, + data: '\x1b[<0;10;4M\x1b[<32;11;4M\x1b[<0;11;4m', + }) + ) + // A different buffer becomes "latest" but was created well before the + // gesture (e.g. an unrelated copy in another window). It must NOT be + // offered, even though the watch is armed. + bufferCreated = nowSec - 100 pollClipboard() await new Promise((r) => setTimeout(r, 0)) diff --git a/src/server/index.ts b/src/server/index.ts index b5ce893f..992e5ead 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -551,6 +551,10 @@ interface WSData { clipboardPollInFlight: boolean lastClipboardBufferKey: string | null clipboardBufferArmedUntil: number + // Unix seconds of the arming gesture (minus slack). Only buffers created at + // or after this are offered, so an unrelated/pre-existing buffer change + // during the armed window can't clobber the clipboard. + clipboardArmedAtSec: number } const sockets = new Set>() @@ -558,6 +562,10 @@ const localHostLabel = config.hostLabel const CLIPBOARD_BUFFER_POLL_MS = 750 const CLIPBOARD_BUFFER_MAX_BYTES = 512 * 1024 const CLIPBOARD_BUFFER_ARM_MS = 5000 +// Allow a buffer created up to this many seconds before the gesture to still be +// offered, covering whole-second clock boundaries between the mouse input and +// tmux writing the paste buffer. +const CLIPBOARD_ARM_SLACK_SEC = 1 const SGR_MOUSE_INPUT_RE = new RegExp( `${String.fromCharCode(0x1b)}\\[<(\\d+);\\d+;\\d+[Mm]`, 'g' @@ -1842,6 +1850,7 @@ function serverFetch(req: Request, server: Server) { clipboardPollInFlight: false, lastClipboardBufferKey: null, clipboardBufferArmedUntil: 0, + clipboardArmedAtSec: 0, }, }) ) { @@ -1978,7 +1987,10 @@ function parseTmuxBufferSummaries(output: string): TmuxBufferSummary[] { const created = Number.parseInt(parts[1] ?? '', 10) const size = Number.parseInt(parts[2] ?? '', 10) if (!name || !Number.isFinite(created) || !Number.isFinite(size)) continue - const orderMatch = name.match(/(\d+)$/) + // Only tmux's automatic buffers (buffer0001, buffer0002, …) carry a + // monotonic index. Explicitly named buffers (e.g. set-buffer -b snippet5) + // get -1 so their trailing digits don't masquerade as recency. + const orderMatch = name.match(/^buffer(\d+)$/) const order = orderMatch ? Number.parseInt(orderMatch[1], 10) : -1 summaries.push({ name, @@ -2003,12 +2015,28 @@ function parseTmuxBufferSummaries(output: string): TmuxBufferSummary[] { * tmuxTimeoutMs if tmux is slow). Bun.spawn keeps it off the JS thread. */ async function readTmuxCapture(tmuxArgs: string[]): Promise { + // Belt-and-suspenders timeout: don't rely solely on Bun.spawn's `timeout` + // option. Guarantee the process is killed (and thus `exited`/stdout resolve) + // so a hung tmux can never leave clipboardPollInFlight stuck forever. + let killTimer: ReturnType | null = null + let kill: (() => void) | null = null try { const proc = Bun.spawn(['tmux', ...withTmuxUtf8Flag(tmuxArgs)], { stdout: 'pipe', - stderr: 'pipe', + // Ignore stderr rather than piping it: we never read it, and an + // undrained pipe could fill its OS buffer and block the process exit. + stderr: 'ignore', timeout: config.tmuxTimeoutMs, + killSignal: 'SIGKILL', }) + kill = () => { + try { + proc.kill('SIGKILL') + } catch { + // already exited + } + } + killTimer = setTimeout(kill, config.tmuxTimeoutMs + 250) const [stdout, exitCode] = await Promise.all([ new Response(proc.stdout).text(), proc.exited, @@ -2016,7 +2044,10 @@ async function readTmuxCapture(tmuxArgs: string[]): Promise { if (exitCode !== 0) return null return stdout } catch { + kill?.() return null + } finally { + if (killTimer !== null) clearTimeout(killTimer) } } @@ -2047,6 +2078,7 @@ function stopClipboardBufferWatch(ws: ServerWebSocket) { ws.data.clipboardPollInFlight = false ws.data.lastClipboardBufferKey = null ws.data.clipboardBufferArmedUntil = 0 + ws.data.clipboardArmedAtSec = 0 } function startClipboardBufferWatch( @@ -2094,6 +2126,9 @@ async function pollClipboardBuffer( ws.data.clipboardPollInFlight = true try { const latest = await readLatestTmuxBufferSummary() + // The read yields the event loop, so the socket may have closed or switched + // sessions while tmux ran. Re-validate before touching state or offering. + if (!sockets.has(ws) || ws.data.currentSessionId !== sessionId) return if (!latest) { ws.data.lastClipboardBufferKey = null return @@ -2113,6 +2148,20 @@ async function pollClipboardBuffer( return } + // Only offer buffers created at/after the arming gesture. A pre-existing or + // unrelated (cross-window) buffer that becomes "latest" while armed has an + // older creation time and is skipped, so it can't clobber the clipboard. + if (latest.created < ws.data.clipboardArmedAtSec) { + logger.debug('clipboard_buffer_precedes_arm', { + sessionId, + bufferName: latest.name, + createdSec: latest.created, + armedAtSec: ws.data.clipboardArmedAtSec, + connectionId: ws.data.connectionId, + }) + return + } + const text = await readTmuxBufferText(latest) if (!text) { logger.debug('clipboard_buffer_skipped', { @@ -2124,6 +2173,10 @@ async function pollClipboardBuffer( return } + // Re-validate again after the second tmux read before delivering, so we + // never push a session A offer to a socket that has closed or moved to B. + if (!sockets.has(ws) || ws.data.currentSessionId !== sessionId) return + logger.debug('clipboard_buffer_offer', { sessionId, bufferName: latest.name, @@ -2132,26 +2185,26 @@ async function pollClipboardBuffer( }) send(ws, { type: 'clipboard-offer', sessionId, text, source: 'tmux-buffer' }) ws.data.clipboardBufferArmedUntil = 0 + ws.data.clipboardArmedAtSec = 0 } finally { ws.data.clipboardPollInFlight = false } } -// Arm the clipboard buffer watch only on an actual left-button DRAG (a real -// text selection), not on a bare tap. SGR mouse encoding: low 2 bits = button -// (0 = left), bit 5 (32) = motion/drag, bits 6-7 (0xC0) = wheel/extended. A -// plain tap is button 0 with no motion bit and must NOT arm, otherwise an -// unrelated tmux buffer change during the armed window could clobber the -// clipboard with content the user never selected. +// Arm the clipboard buffer watch on any left-button mouse event (press, drag, +// or release). This covers every selection style that copies to a tmux paste +// buffer — drag-select, double-click (word) and triple-click (line), which +// emit button-0 events with no motion bit. Bare taps also match, but they +// can't clobber the clipboard: the poll additionally requires the buffer to +// have been created at/after the gesture (see clipboardArmedAtSec), so a tap +// that copies nothing simply never produces an offer. +// SGR encoding: low 2 bits = button (0 = left), bits 6-7 (0xC0) = wheel/extended. function containsClipboardArmingMouseInput(data: string): boolean { SGR_MOUSE_INPUT_RE.lastIndex = 0 let match: RegExpExecArray | null while ((match = SGR_MOUSE_INPUT_RE.exec(data)) !== null) { const button = Number(match[1]) - const isLeftButton = (button & 3) === 0 - const isMotion = (button & 32) !== 0 - const isWheelOrExtended = (button & 0xc0) !== 0 - if (isLeftButton && isMotion && !isWheelOrExtended) { + if ((button & 3) === 0 && (button & 0xc0) === 0) { return true } } @@ -4135,7 +4188,9 @@ function handleTerminalInputPersistent( if (session?.remote && !config.remoteAllowAttach) return if (!session?.remote && containsClipboardArmingMouseInput(data)) { - ws.data.clipboardBufferArmedUntil = Date.now() + CLIPBOARD_BUFFER_ARM_MS + const now = Date.now() + ws.data.clipboardBufferArmedUntil = now + CLIPBOARD_BUFFER_ARM_MS + ws.data.clipboardArmedAtSec = Math.floor(now / 1000) - CLIPBOARD_ARM_SLACK_SEC } ws.data.terminal?.write(data) diff --git a/src/server/terminal/PtyTerminalProxy.ts b/src/server/terminal/PtyTerminalProxy.ts index 8c1652d9..afe2ebeb 100644 --- a/src/server/terminal/PtyTerminalProxy.ts +++ b/src/server/terminal/PtyTerminalProxy.ts @@ -12,6 +12,14 @@ const TARGET_IDENTITY_FORMAT = buildTmuxFormat([ '#{window_id}', ]) +// `set-clipboard` is a server-global tmux option, so enabling it touches the +// user's whole tmux server (and isn't reverted on disconnect). It's on by +// default because the clipboard poll needs a paste buffer to read; set +// AGENTBOARD_TMUX_SET_CLIPBOARD=0 (or =false) to leave the user's setting alone. +const SET_CLIPBOARD_ENABLED = + process.env.AGENTBOARD_TMUX_SET_CLIPBOARD !== '0' && + process.env.AGENTBOARD_TMUX_SET_CLIPBOARD !== 'false' + interface TmuxTargetIdentity { sessionName: string windowId: string | null @@ -179,13 +187,16 @@ class PtyTerminalProxy extends TerminalProxyBase { // user-gesture rule. `set-clipboard` is a SERVER option: with the modern // `external` default tmux forwards an app's OSC 52 to the outer terminal // but stores no buffer for the poll to read; `on` makes it store one. - try { - this.runTmuxMutation(['set-option', '-s', 'set-clipboard', 'on']) - } catch (error) { - this.logEvent('terminal_set_clipboard_failed', { - sessionName: this.options.sessionName, - error: error instanceof Error ? error.message : String(error), - }) + // Opt-out via AGENTBOARD_TMUX_SET_CLIPBOARD=0 to avoid the global change. + if (SET_CLIPBOARD_ENABLED) { + try { + this.runTmuxMutation(['set-option', '-s', 'set-clipboard', 'on']) + } catch (error) { + this.logEvent('terminal_set_clipboard_failed', { + sessionName: this.options.sessionName, + error: error instanceof Error ? error.message : String(error), + }) + } } if (attemptId !== this.startAttemptId) { From 786a3723cc5aac6b6d75cabe61365dd13ab0afce Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Thu, 25 Jun 2026 18:46:32 -0400 Subject: [PATCH 4/5] fix: add watch-generation token to clipboard poll (review round 2) 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. --- .../__tests__/isolated/indexHandlers.test.ts | 102 ++++++++++++++++++ src/server/index.ts | 39 +++++-- 2 files changed, 132 insertions(+), 9 deletions(-) diff --git a/src/server/__tests__/isolated/indexHandlers.test.ts b/src/server/__tests__/isolated/indexHandlers.test.ts index 4b64861b..113470ca 100644 --- a/src/server/__tests__/isolated/indexHandlers.test.ts +++ b/src/server/__tests__/isolated/indexHandlers.test.ts @@ -577,6 +577,7 @@ function createWs() { lastClipboardBufferKey: null as string | null, clipboardBufferArmedUntil: 0, clipboardArmedAtSec: 0, + clipboardWatchGen: 0, }, send: (payload: string) => { sent.push(JSON.parse(payload) as ServerMessage) @@ -2733,6 +2734,107 @@ describe('server message handlers', () => { } }) + test('a poll from a superseded watch generation is inert after re-attach', async () => { + const { serveOptions, registryInstance } = await loadIndex() + registryInstance.sessions = [baseSession] + + const intervalCallbacks: Array<() => void> = [] + const originalSetIntervalForTest = globalThis.setInterval + const originalClearInterval = globalThis.clearInterval + globalThis.setInterval = ((callback: TimerHandler) => { + intervalCallbacks.push(callback as () => void) + return intervalCallbacks.length as unknown as ReturnType + }) as unknown as typeof globalThis.setInterval + globalThis.clearInterval = (() => {}) as typeof globalThis.clearInterval + + const nowSec = Math.floor(Date.now() / 1000) + let bufferCreated = nowSec - 10 + spawnSyncImpl = ((...args: Parameters) => { + const command = Array.isArray(args[0]) ? args[0] : [String(args[0])] + const tmuxArgs = getTmuxArgs(command as string[]) + if (tmuxArgs[0] === 'list-buffers') { + return { + exitCode: 0, + stdout: Buffer.from(tmuxLine('buffer0', String(bufferCreated), '18') + '\n'), + stderr: Buffer.from(''), + } as ReturnType + } + if (tmuxArgs[0] === 'show-buffer') { + return { + exitCode: 0, + stdout: Buffer.from('fresh-copy'), + stderr: Buffer.from(''), + } as ReturnType + } + return { + exitCode: 0, + stdout: Buffer.from(''), + stderr: Buffer.from(''), + } as ReturnType + }) as typeof Bun.spawnSync + + try { + const { ws, sent } = createWs() + const websocket = serveOptions.websocket + if (!websocket) { + throw new Error('WebSocket handlers not configured') + } + + websocket.open?.(ws as never) + // First attach establishes watch generation N. + websocket.message?.( + ws as never, + JSON.stringify({ + type: 'terminal-attach', + sessionId: baseSession.id, + tmuxTarget: baseSession.tmuxWindow, + }) + ) + await new Promise((r) => setTimeout(r, 0)) + await new Promise((r) => setTimeout(r, 0)) + + // Re-attach supersedes it with generation N+1 (and a fresh poll closure). + websocket.message?.( + ws as never, + JSON.stringify({ + type: 'terminal-attach', + sessionId: baseSession.id, + tmuxTarget: baseSession.tmuxWindow, + }) + ) + await new Promise((r) => setTimeout(r, 0)) + await new Promise((r) => setTimeout(r, 0)) + + expect(intervalCallbacks.length).toBeGreaterThanOrEqual(2) + const stalePoll = intervalCallbacks[0] + const freshPoll = intervalCallbacks[intervalCallbacks.length - 1] + + // Arm and produce a post-gesture buffer. + websocket.message?.( + ws as never, + JSON.stringify({ + type: 'terminal-input', + sessionId: baseSession.id, + data: '\x1b[<0;10;4M\x1b[<32;11;4M\x1b[<0;11;4m', + }) + ) + bufferCreated = nowSec + 1 + + // The poll closure from the superseded generation must do nothing. + stalePoll() + await new Promise((r) => setTimeout(r, 0)) + expect(sent.some((message) => message.type === 'clipboard-offer')).toBe(false) + + // The current-generation poll delivers the offer. + freshPoll() + await new Promise((r) => setTimeout(r, 0)) + expect(sent.some((message) => message.type === 'clipboard-offer')).toBe(true) + } finally { + globalThis.setInterval = originalSetIntervalForTest + globalThis.clearInterval = originalClearInterval + } + }) + test('session-only attach replays the current grouped view and keeps copy-mode targeting aligned in pty mode', async () => { const { serveOptions, registryInstance } = await loadIndex() registryInstance.sessions = [baseSession] diff --git a/src/server/index.ts b/src/server/index.ts index 992e5ead..a7e37aee 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -555,6 +555,10 @@ interface WSData { // or after this are offered, so an unrelated/pre-existing buffer change // during the armed window can't clobber the clipboard. clipboardArmedAtSec: number + // Incremented on every stop/restart of the clipboard watch. Async work + // (baseline read, in-flight poll) captures the generation at launch and bails + // if it no longer matches, so a stale completion can't mutate a newer watch. + clipboardWatchGen: number } const sockets = new Set>() @@ -1851,6 +1855,7 @@ function serverFetch(req: Request, server: Server) { lastClipboardBufferKey: null, clipboardBufferArmedUntil: 0, clipboardArmedAtSec: 0, + clipboardWatchGen: 0, }, }) ) { @@ -2074,6 +2079,9 @@ function stopClipboardBufferWatch(ws: ServerWebSocket) { if (ws.data.clipboardPollTimer !== null) { clearInterval(ws.data.clipboardPollTimer) } + // Bump the generation so any in-flight baseline read or poll from the watch + // we're tearing down can't write back into a later watch on this socket. + ws.data.clipboardWatchGen += 1 ws.data.clipboardPollTimer = null ws.data.clipboardPollInFlight = false ws.data.lastClipboardBufferKey = null @@ -2092,18 +2100,21 @@ function startClipboardBufferWatch( return } + const gen = ws.data.clipboardWatchGen ws.data.clipboardPollTimer = setInterval(() => { - fireAndForget(pollClipboardBuffer(ws, sessionId), 'pollClipboardBuffer') + fireAndForget(pollClipboardBuffer(ws, sessionId, gen), 'pollClipboardBuffer') }, CLIPBOARD_BUFFER_POLL_MS) // Establish the baseline asynchronously so a pre-existing buffer isn't offered - // on the first poll. Guarded so it can't clobber a key that an early poll - // already advanced past during the sub-tick race before this resolves. + // on the first poll. Skip if this watch was already armed (a copy may have + // landed before the read resolved — don't watermark the user's own buffer; + // the created-time gate handles a genuinely pre-existing one) or superseded. fireAndForget( (async () => { const initial = await readLatestTmuxBufferSummary() if ( - ws.data.clipboardPollTimer !== null && - ws.data.lastClipboardBufferKey === null + ws.data.clipboardWatchGen === gen && + ws.data.lastClipboardBufferKey === null && + ws.data.clipboardBufferArmedUntil === 0 ) { ws.data.lastClipboardBufferKey = initial?.key ?? null } @@ -2114,8 +2125,10 @@ function startClipboardBufferWatch( async function pollClipboardBuffer( ws: ServerWebSocket, - sessionId: string + sessionId: string, + gen: number ) { + if (ws.data.clipboardWatchGen !== gen) return if (ws.data.clipboardPollInFlight) return if (!sockets.has(ws)) return if (ws.data.currentSessionId !== sessionId) return @@ -2126,8 +2139,10 @@ async function pollClipboardBuffer( ws.data.clipboardPollInFlight = true try { const latest = await readLatestTmuxBufferSummary() - // The read yields the event loop, so the socket may have closed or switched - // sessions while tmux ran. Re-validate before touching state or offering. + // The read yields the event loop, so the watch may have been torn down or + // the socket may have switched sessions while tmux ran. Re-validate before + // touching state or offering. + if (ws.data.clipboardWatchGen !== gen) return if (!sockets.has(ws) || ws.data.currentSessionId !== sessionId) return if (!latest) { ws.data.lastClipboardBufferKey = null @@ -2175,6 +2190,7 @@ async function pollClipboardBuffer( // Re-validate again after the second tmux read before delivering, so we // never push a session A offer to a socket that has closed or moved to B. + if (ws.data.clipboardWatchGen !== gen) return if (!sockets.has(ws) || ws.data.currentSessionId !== sessionId) return logger.debug('clipboard_buffer_offer', { @@ -2187,7 +2203,12 @@ async function pollClipboardBuffer( ws.data.clipboardBufferArmedUntil = 0 ws.data.clipboardArmedAtSec = 0 } finally { - ws.data.clipboardPollInFlight = false + // Only release the in-flight guard if the watch is still ours. If it was + // stopped/restarted mid-poll, stopClipboardBufferWatch already reset the + // guard for the new generation and we must not clear it out from under it. + if (ws.data.clipboardWatchGen === gen) { + ws.data.clipboardPollInFlight = false + } } } From e4d0ff5e841da89619b5bca7c9fbbce2bcdd1dd3 Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Thu, 25 Jun 2026 18:55:50 -0400 Subject: [PATCH 5/5] fix: drop arm-time slack from clipboard created-correlation (review round 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../__tests__/isolated/indexHandlers.test.ts | 8 ++++---- src/server/index.ts | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/server/__tests__/isolated/indexHandlers.test.ts b/src/server/__tests__/isolated/indexHandlers.test.ts index 113470ca..15484415 100644 --- a/src/server/__tests__/isolated/indexHandlers.test.ts +++ b/src/server/__tests__/isolated/indexHandlers.test.ts @@ -2536,7 +2536,7 @@ describe('server message handlers', () => { }) ) // Buffer created after the arming gesture: offered. - bufferCreated = nowSec + 1 + bufferCreated = nowSec + 30 pollClipboard() await new Promise((r) => setTimeout(r, 0)) expect(sent).toContainEqual({ @@ -2547,7 +2547,7 @@ describe('server message handlers', () => { }) const offerCount = sent.filter((message) => message.type === 'clipboard-offer').length - bufferCreated = nowSec + 2 + bufferCreated = nowSec + 31 pollClipboard() await new Promise((r) => setTimeout(r, 0)) expect(sent.filter((message) => message.type === 'clipboard-offer')).toHaveLength(offerCount) @@ -2631,7 +2631,7 @@ describe('server message handlers', () => { data: '\x1b[<0;10;4M\x1b[<0;10;4m', }) ) - bufferCreated = nowSec + 1 + bufferCreated = nowSec + 30 pollClipboard() await new Promise((r) => setTimeout(r, 0)) @@ -2818,7 +2818,7 @@ describe('server message handlers', () => { data: '\x1b[<0;10;4M\x1b[<32;11;4M\x1b[<0;11;4m', }) ) - bufferCreated = nowSec + 1 + bufferCreated = nowSec + 30 // The poll closure from the superseded generation must do nothing. stalePoll() diff --git a/src/server/index.ts b/src/server/index.ts index a7e37aee..147104d5 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -551,9 +551,9 @@ interface WSData { clipboardPollInFlight: boolean lastClipboardBufferKey: string | null clipboardBufferArmedUntil: number - // Unix seconds of the arming gesture (minus slack). Only buffers created at - // or after this are offered, so an unrelated/pre-existing buffer change - // during the armed window can't clobber the clipboard. + // Unix seconds of the arming gesture. Only buffers created at or after this + // are offered, so an unrelated/pre-existing buffer change during the armed + // window can't clobber the clipboard. clipboardArmedAtSec: number // Incremented on every stop/restart of the clipboard watch. Async work // (baseline read, in-flight poll) captures the generation at launch and bails @@ -566,10 +566,6 @@ const localHostLabel = config.hostLabel const CLIPBOARD_BUFFER_POLL_MS = 750 const CLIPBOARD_BUFFER_MAX_BYTES = 512 * 1024 const CLIPBOARD_BUFFER_ARM_MS = 5000 -// Allow a buffer created up to this many seconds before the gesture to still be -// offered, covering whole-second clock boundaries between the mouse input and -// tmux writing the paste buffer. -const CLIPBOARD_ARM_SLACK_SEC = 1 const SGR_MOUSE_INPUT_RE = new RegExp( `${String.fromCharCode(0x1b)}\\[<(\\d+);\\d+;\\d+[Mm]`, 'g' @@ -4211,7 +4207,11 @@ function handleTerminalInputPersistent( if (!session?.remote && containsClipboardArmingMouseInput(data)) { const now = Date.now() ws.data.clipboardBufferArmedUntil = now + CLIPBOARD_BUFFER_ARM_MS - ws.data.clipboardArmedAtSec = Math.floor(now / 1000) - CLIPBOARD_ARM_SLACK_SEC + // No slack: we arm here BEFORE forwarding the input to tmux below, so any + // buffer this gesture creates is stamped at/after this whole second. A + // slack would instead let a pre-existing buffer created just before the + // gesture be mis-offered as the user's copy. + ws.data.clipboardArmedAtSec = Math.floor(now / 1000) } ws.data.terminal?.write(data)