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/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\"", diff --git a/src/server/__tests__/isolated/indexHandlers.test.ts b/src/server/__tests__/isolated/indexHandlers.test.ts index 065825bd..15484415 100644 --- a/src/server/__tests__/isolated/indexHandlers.test.ts +++ b/src/server/__tests__/isolated/indexHandlers.test.ts @@ -576,6 +576,8 @@ function createWs() { clipboardPollInFlight: false, lastClipboardBufferKey: null as string | null, clipboardBufferArmedUntil: 0, + clipboardArmedAtSec: 0, + clipboardWatchGen: 0, }, send: (payload: string) => { sent.push(JSON.parse(payload) as ServerMessage) @@ -2454,7 +2456,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 +2519,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 +2535,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 + 30 pollClipboard() await new Promise((r) => setTimeout(r, 0)) expect(sent).toContainEqual({ @@ -2540,7 +2547,7 @@ describe('server message handlers', () => { }) const offerCount = sent.filter((message) => message.type === 'clipboard-offer').length - bufferCreated = 4 + bufferCreated = nowSec + 31 pollClipboard() await new Promise((r) => setTimeout(r, 0)) expect(sent.filter((message) => message.type === 'clipboard-offer')).toHaveLength(offerCount) @@ -2550,6 +2557,284 @@ describe('server message handlers', () => { } }) + 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] + + 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 - 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('word-from-double-click'), + 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') + } + + // 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({ + type: 'terminal-input', + sessionId: baseSession.id, + data: '\x1b[<0;10;4M\x1b[<0;10;4m', + }) + ) + bufferCreated = nowSec + 30 + 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)) + + expect(sent.some((message) => message.type === 'clipboard-offer')).toBe(false) + } finally { + globalThis.setInterval = originalSetIntervalForTest + globalThis.clearInterval = originalClearInterval + } + }) + + 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 + 30 + + // 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/__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..147104d5 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -551,6 +551,14 @@ interface WSData { clipboardPollInFlight: boolean lastClipboardBufferKey: string | null clipboardBufferArmedUntil: number + // 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 + // if it no longer matches, so a stale completion can't mutate a newer watch. + clipboardWatchGen: number } const sockets = new Set>() @@ -567,6 +575,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 } @@ -1838,6 +1850,8 @@ function serverFetch(req: Request, server: Server) { clipboardPollInFlight: false, lastClipboardBufferKey: null, clipboardBufferArmedUntil: 0, + clipboardArmedAtSec: 0, + clipboardWatchGen: 0, }, }) ) { @@ -1974,63 +1988,101 @@ 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 + // 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, 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 { + // 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 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', + // 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, + ]) + if (exitCode !== 0) return null + return stdout } catch { + kill?.() return null + } finally { + if (killTimer !== null) clearTimeout(killTimer) } } -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) { 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 ws.data.clipboardBufferArmedUntil = 0 + ws.data.clipboardArmedAtSec = 0 } function startClipboardBufferWatch( @@ -2044,17 +2096,35 @@ function startClipboardBufferWatch( return } - const initial = readLatestTmuxBufferSummary() - ws.data.lastClipboardBufferKey = initial?.key ?? null + 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. 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.clipboardWatchGen === gen && + ws.data.lastClipboardBufferKey === null && + ws.data.clipboardBufferArmedUntil === 0 + ) { + ws.data.lastClipboardBufferKey = initial?.key ?? null + } + })(), + 'clipboardBaseline' + ) } 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 @@ -2064,7 +2134,12 @@ async function pollClipboardBuffer( ws.data.clipboardPollInFlight = true try { - const latest = readLatestTmuxBufferSummary() + const latest = await readLatestTmuxBufferSummary() + // 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 return @@ -2084,7 +2159,21 @@ async function pollClipboardBuffer( return } - const text = readTmuxBufferText(latest) + // 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', { sessionId, @@ -2095,6 +2184,11 @@ 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 (ws.data.clipboardWatchGen !== gen) return + if (!sockets.has(ws) || ws.data.currentSessionId !== sessionId) return + logger.debug('clipboard_buffer_offer', { sessionId, bufferName: latest.name, @@ -2103,17 +2197,31 @@ 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 + // 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 + } } } +// 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]) - if (button < 64 && (button & 3) === 0) { + if ((button & 3) === 0 && (button & 0xc0) === 0) { return true } } @@ -4097,7 +4205,13 @@ 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 + // 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) diff --git a/src/server/terminal/PtyTerminalProxy.ts b/src/server/terminal/PtyTerminalProxy.ts index d40e79bc..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 @@ -173,6 +181,24 @@ 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. + // 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) { await this.dispose() return