diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 21e750b0..3db7eaf8 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -545,6 +545,35 @@ Launching a new remote session (#222) and injection over the messaging socket at is the failure this refuses; an unparseable count is treated the same as "someone's there" rather than guessed. +- **Solo attach parity, issue #253.** A solo attach now makes the remote + tmux session look and behave like a local terminal instead of a plain + multiplexer view: `buildAttachCommand(socket, target, { solo, pre })` + prefixes the attach with three session-scoped (never `-g`, never `-w`) + `tmux ... \; ...` sets — `status off`, `mouse on`, `window-size latest` — + when `solo` is true, and emits the unchanged pre-#253 command when it + isn't (shared attach never touches another client's view). The probe + (`buildProbeCommand`) now also reads `mouse` and `window-size` alongside + `status`, and `parseProbeOutput` returns their raw pre-attach values as + `pre: { status, mouse, windowSize }` (`null` when an option is absent + from the probe output) in addition to the existing `cols`/`rows`. + `tmux show-options -A` marks an option inherited from a higher scope + with a trailing `*` on the option name (e.g. `status* on`, measured on + tmux 3.6) — `pre.` is `null` for both "absent" and "inherited + (starred)", since both mean no session override exists and restore + must `set -u`; it is non-null only for an actual session-scoped + override (unstarred), restored via `set -t`. The star never affects + the sizing rule — a starred `status* off`/`on`/`` sizes + `statusLines` exactly like its unstarred form. On + detach, when the attach was solo, the adapter fires a best-effort, + fire-and-forget `buildRestoreCommand(socket, target, pre)` ssh call that + sets each option back to its probed value (`set -t + `) or, when the probed value was `null`, unsets the session + override (`set -u -t `) so the host's own global option + applies again. The restore call's failure is only logged — it never + throws out of `detach()` and never blocks the local ssh client from being + killed. No shared-attach restore is ever sent, because a shared attach + never applied the options in the first place. + - **This is the first thing to populate the session-handle seam from issue #220** (see `.ai/contexts/trigger-watcher.md`, "Session handle"): a remote-attach entry sets `host: alias`, `kind: 'remote-attach'`, and diff --git a/remote-attach.js b/remote-attach.js index 2c540966..550faabe 100644 --- a/remote-attach.js +++ b/remote-attach.js @@ -27,12 +27,26 @@ function isSafeSocketPath(value) { return typeof value === 'string' && value.length > 0 && value.length <= 4096 && !/['"\\\s]/.test(value); } +function escapeRegExpLiteral(s) { + return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + // see .ai/contexts/session-cache.md ("Remote hosts — tmux attach", sizing rule) +// "status* on" = inherited, "status on" = session override — see .ai/contexts/session-cache.md ("solo attach parity, issue #253") +function parseOptionToken(part, name) { + const re = new RegExp(`${escapeRegExpLiteral(name)}(\\*?)\\s+(\\S+)`); + const m = re.exec(part || ''); + if (!m) return { value: null, inherited: false }; + return { value: m[2], inherited: m[1] === '*' }; +} + function parseProbeOutput(stdout) { const text = typeof stdout === 'string' ? stdout : ''; - const idx = text.indexOf(PROBE_SEP); - const sizePart = idx === -1 ? text : text.slice(0, idx); - const statusPart = idx === -1 ? '' : text.slice(idx + PROBE_SEP.length); + const parts = text.split(PROBE_SEP); + const sizePart = parts[0] || ''; + const statusPart = parts[1] || ''; + const mousePart = parts[2] || ''; + const windowSizePart = parts[3] || ''; const sizeMatch = /(\d+)x(\d+)/.exec(sizePart); if (!sizeMatch) return null; @@ -40,17 +54,35 @@ function parseProbeOutput(stdout) { const height = Number.parseInt(sizeMatch[2], 10); let statusLines = DEFAULT_STATUS_LINES; - const statusMatch = /status\s+(\S+)/.exec(statusPart); - if (statusMatch) { - if (statusMatch[1] === 'off') statusLines = 0; - else if (statusMatch[1] === 'on') statusLines = 1; + let status = null; + const statusParsed = parseOptionToken(statusPart, 'status'); + if (statusParsed.value != null) { + if (statusParsed.value === 'off') { statusLines = 0; status = 'off'; } + else if (statusParsed.value === 'on') { statusLines = 1; status = 'on'; } else { - const n = Number.parseInt(statusMatch[1], 10); - if (Number.isFinite(n) && n >= 0) statusLines = n; + const n = Number.parseInt(statusParsed.value, 10); + if (Number.isFinite(n) && n >= 0) { statusLines = n; status = n; } } } - return { cols: width, rows: height + statusLines }; + let mouse = null; + const mouseParsed = parseOptionToken(mousePart, 'mouse'); + if (mouseParsed.value === 'on' || mouseParsed.value === 'off') mouse = mouseParsed.value; + + let windowSize = null; + const windowSizeParsed = parseOptionToken(windowSizePart, 'window-size'); + if (['latest', 'largest', 'smallest', 'manual'].includes(windowSizeParsed.value)) windowSize = windowSizeParsed.value; + + // pre. non-null only for a session-scoped override; null means restore by `set -u` + return { + cols: width, + rows: height + statusLines, + pre: { + status: statusParsed.inherited ? null : status, + mouse: mouseParsed.inherited ? null : mouse, + windowSize: windowSizeParsed.inherited ? null : windowSize, + }, + }; } // see .ai/contexts/session-cache.md ("Remote hosts — tmux attach", socket discovery) @@ -60,11 +92,35 @@ function buildProbeCommand(pid, target) { `printf '%s${PROBE_SEP}' "$sock"; ` + `tmux -S "$sock" display-message -p -t ${target} '#{window_width}x#{window_height}'` + `; printf '${PROBE_SEP}'; tmux -S "$sock" show-options -A -t ${target} status 2>/dev/null` + + `; printf '${PROBE_SEP}'; tmux -S "$sock" show-options -A -t ${target} mouse 2>/dev/null` + + `; printf '${PROBE_SEP}'; tmux -S "$sock" show-options -A -t ${target} window-size 2>/dev/null` + `; printf '${PROBE_SEP}'; tmux -S "$sock" list-clients -t ${target} 2>/dev/null | wc -l`; } -function buildAttachCommand(socket, target) { - return `tmux -S '${socket}' attach -t ${target}`; +// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach", solo attach parity, issue #253) +function buildAttachCommand(socket, target, opts = {}) { + if (!opts.solo) { + return `tmux -S '${socket}' attach -t ${target}`; + } + return `tmux -S '${socket}' set -t ${target} status off \\; ` + + `set -t ${target} mouse on \\; ` + + `set -t ${target} window-size latest \\; ` + + `attach -t ${target}`; +} + +// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach", solo attach parity, issue #253) +function buildRestoreOptionSegment(target, name, value) { + return value == null ? `set -u -t ${target} ${name}` : `set -t ${target} ${name} ${value}`; +} + +function buildRestoreCommand(socket, target, pre) { + const p = pre || {}; + const segments = [ + buildRestoreOptionSegment(target, 'status', p.status), + buildRestoreOptionSegment(target, 'mouse', p.mouse), + buildRestoreOptionSegment(target, 'window-size', p.windowSize), + ]; + return `tmux -S '${socket}' ${segments.join(' \\; ')}`; } // see .ai/contexts/session-cache.md ("Remote hosts — tmux attach", solo vs shared) @@ -76,15 +132,16 @@ function parseClientCount(text) { } // see .ai/contexts/session-cache.md ("Remote hosts — tmux attach", socket discovery) +// parts: [socket, size, status, mouse, window-size, clientCount]; trailing ones optional function parseDiscoveryProbeOutput(stdout) { const text = typeof stdout === 'string' ? stdout : ''; const parts = text.split(PROBE_SEP); const socket = parts[0] || ''; if (parts.length < 3 || !isSafeSocketPath(socket)) return null; - const size = parseProbeOutput(parts.slice(1, 3).join(PROBE_SEP)); - if (!size) return null; - const clientCount = parseClientCount(parts[3]); - return { socket, cols: size.cols, rows: size.rows, clientCount }; + const probed = parseProbeOutput(parts.slice(1, 5).join(PROBE_SEP)); + if (!probed) return null; + const clientCount = parseClientCount(parts[5]); + return { socket, cols: probed.cols, rows: probed.rows, pre: probed.pre, clientCount }; } // see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") @@ -205,7 +262,7 @@ function createTmuxAttachAdapter(opts = {}) { const openRows = solo ? localSize.rows : discovery.rows; const sshPath = resolveSshPath(); - const argv = ['-tt', '-o', 'BatchMode=yes', alias, buildAttachCommand(discovery.socket, parsed.target)]; + const argv = ['-tt', '-o', 'BatchMode=yes', alias, buildAttachCommand(discovery.socket, parsed.target, { solo, pre: discovery.pre })]; let raw; try { @@ -227,6 +284,16 @@ function createTmuxAttachAdapter(opts = {}) { if (detaching || !alive) return; detaching = true; try { raw.kill(); } catch {} + if (solo) { + // best-effort restore — see .ai/contexts/session-cache.md ("solo attach parity, issue #253") + try { + const restoreCmd = buildRestoreCommand(discovery.socket, parsed.target, discovery.pre); + Promise.resolve(runRemoteCommand(alias, restoreCmd, { timeoutMs: DEFAULT_PROBE_TIMEOUT_MS })) + .catch((err) => log.warn(`[remote-attach:${alias}] restore-on-detach failed: ${err && err.message}`)); + } catch (err) { + log.warn(`[remote-attach:${alias}] restore-on-detach failed: ${err && err.message}`); + } + } } const ptyProcess = { @@ -265,4 +332,5 @@ module.exports = { parseDiscoveryProbeOutput, buildProbeCommand, buildAttachCommand, + buildRestoreCommand, }; diff --git a/test/remote-attach.test.js b/test/remote-attach.test.js index aad3dcd2..d84bd4ee 100644 --- a/test/remote-attach.test.js +++ b/test/remote-attach.test.js @@ -17,6 +17,9 @@ const { createTmuxAttachAdapter, parseTmuxField, parseProbeOutput, + buildProbeCommand, + buildAttachCommand, + buildRestoreCommand, } = require('../remote-attach'); const PROBE_SEP = ''; @@ -67,21 +70,57 @@ test('parseTmuxField accepts the CLI-written format and rejects the rest', () => // Property 1 -- sizing rule. test('parseProbeOutput sizes rows as height plus status lines (status on)', () => { - assert.deepEqual(parseProbeOutput('200x51' + PROBE_SEP + 'status on'), { cols: 200, rows: 52 }); + assert.deepEqual( + parseProbeOutput('200x51' + PROBE_SEP + 'status on'), + { cols: 200, rows: 52, pre: { status: 'on', mouse: null, windowSize: null } }, + ); }); test('parseProbeOutput sizes rows as height plus 0 when status is off', () => { - assert.deepEqual(parseProbeOutput('200x50' + PROBE_SEP + 'status off'), { cols: 200, rows: 50 }); + assert.deepEqual( + parseProbeOutput('200x50' + PROBE_SEP + 'status off'), + { cols: 200, rows: 50, pre: { status: 'off', mouse: null, windowSize: null } }, + ); }); test('parseProbeOutput honors a rendered status line count beyond on/off', () => { - assert.deepEqual(parseProbeOutput('200x51' + PROBE_SEP + 'status 2'), { cols: 200, rows: 53 }); + assert.deepEqual( + parseProbeOutput('200x51' + PROBE_SEP + 'status 2'), + { cols: 200, rows: 53, pre: { status: 2, mouse: null, windowSize: null } }, + ); }); test('parseProbeOutput returns null when the size cannot be parsed', () => { assert.equal(parseProbeOutput('garbage'), null); }); +// issue #253 -- pre-attach mouse/window-size, present. +test('parseProbeOutput parses pre-attach mouse and window-size when present', () => { + assert.deepEqual( + parseProbeOutput('200x50' + PROBE_SEP + 'status off' + PROBE_SEP + 'mouse on' + PROBE_SEP + 'window-size latest'), + { cols: 200, rows: 50, pre: { status: 'off', mouse: 'on', windowSize: 'latest' } }, + ); + assert.deepEqual( + parseProbeOutput('200x50' + PROBE_SEP + 'status on' + PROBE_SEP + 'mouse off' + PROBE_SEP + 'window-size manual'), + { cols: 200, rows: 51, pre: { status: 'on', mouse: 'off', windowSize: 'manual' } }, + ); +}); + +// issue #253 -- pre-attach mouse/window-size, absent/unset: the probe segment +// is empty (as it would be if the remote tmux produced no matching line), +// never guessed at. +test('parseProbeOutput reports null for mouse and window-size when absent from the probe output', () => { + assert.deepEqual( + parseProbeOutput('200x50' + PROBE_SEP + 'status on' + PROBE_SEP + '' + PROBE_SEP + ''), + { cols: 200, rows: 51, pre: { status: 'on', mouse: null, windowSize: null } }, + ); + // No separators at all beyond size+status -- same as the pre-#253 wire format. + assert.deepEqual( + parseProbeOutput('200x50' + PROBE_SEP + 'status off'), + { cols: 200, rows: 50, pre: { status: 'off', mouse: null, windowSize: null } }, + ); +}); + // Property 1, through the adapter: the size handed to spawnPty must already // carry the status-line correction, not the bare tmux window height. test('attach() spawns the pty at window height plus status lines, never the bare height', async () => { @@ -254,7 +293,7 @@ test('attach() opens at the local size and forwards resize to the ssh pty when n const raw = fakeRawPty(); const spawnCalls = []; const adapter = makeAdapter({ - probeStdout: '200x50' + PROBE_SEP + 'status on' + PROBE_SEP + '0', + probeStdout: '200x50' + PROBE_SEP + 'status on' + PROBE_SEP + 'mouse off' + PROBE_SEP + 'window-size manual' + PROBE_SEP + '0', spawnCalls, rawPtyFactory: () => raw.pty, }); @@ -281,7 +320,7 @@ test('attach() keeps the fixed remote size and ignores resize when another clien const logLines = []; const log = { info: (msg) => logLines.push(msg), warn() {}, error() {} }; const adapter = makeAdapter({ - probeStdout: '200x50' + PROBE_SEP + 'status on' + PROBE_SEP + '1', + probeStdout: '200x50' + PROBE_SEP + 'status on' + PROBE_SEP + 'mouse off' + PROBE_SEP + 'window-size manual' + PROBE_SEP + '1', spawnCalls, rawPtyFactory: () => raw.pty, log, @@ -307,7 +346,7 @@ test('attach() keeps the fixed remote size and ignores resize when another clien test('attach() fails closed to the fixed remote size when the client count cannot be parsed', async () => { const spawnCalls = []; const adapter = makeAdapter({ - probeStdout: '200x50' + PROBE_SEP + 'status on' + PROBE_SEP + 'garbage', + probeStdout: '200x50' + PROBE_SEP + 'status on' + PROBE_SEP + 'mouse off' + PROBE_SEP + 'window-size manual' + PROBE_SEP + 'garbage', spawnCalls, }); const result = await adapter.attach( @@ -327,7 +366,7 @@ test('the attached-client count rides the existing probe connection, never a sec const spawnCalls = []; const probeCalls = []; const adapter = makeAdapter({ - probeStdout: '200x50' + PROBE_SEP + 'status on' + PROBE_SEP + '0', + probeStdout: '200x50' + PROBE_SEP + 'status on' + PROBE_SEP + 'mouse off' + PROBE_SEP + 'window-size manual' + PROBE_SEP + '0', spawnCalls, probeCalls, }); @@ -339,3 +378,261 @@ test('the attached-client count rides the existing probe connection, never a sec assert.equal(probeCalls.length, 1, 'reading the client count must not add a second ssh round trip'); assert.match(probeCalls[0], /list-clients/, 'the probe command must ask tmux for the attached client count'); }); + +// --- Inherited (starred) option parsing, real-host measurement (tmux 3.6) - + +// `tmux show-options -A` marks an option with no session-scoped override +// (inherited from a higher scope) with a trailing `*` right after the +// option name -- e.g. "status* on". Sizing must react to the value exactly +// like the unstarred form; only `pre` (below) treats it differently. +test('parseProbeOutput sizes a starred (inherited) status option exactly like the unstarred form', () => { + assert.deepEqual( + parseProbeOutput('200x51' + PROBE_SEP + 'status* on'), + { cols: 200, rows: 52, pre: { status: null, mouse: null, windowSize: null } }, + ); + assert.deepEqual( + parseProbeOutput('200x50' + PROBE_SEP + 'status* off'), + { cols: 200, rows: 50, pre: { status: null, mouse: null, windowSize: null } }, + ); + assert.deepEqual( + parseProbeOutput('200x51' + PROBE_SEP + 'status* 2'), + { cols: 200, rows: 53, pre: { status: null, mouse: null, windowSize: null } }, + ); +}); + +// A session-scoped (unstarred) override must be preserved in `pre` for +// restore-via-`set -t`; an inherited (starred) one must not. +test('parseProbeOutput: pre.mouse is the value for a session-scoped override, null for an inherited one', () => { + assert.deepEqual( + parseProbeOutput('200x50' + PROBE_SEP + 'status on' + PROBE_SEP + 'mouse off' + PROBE_SEP + ''), + { cols: 200, rows: 51, pre: { status: 'on', mouse: 'off', windowSize: null } }, + 'unstarred "mouse off" is a real session override -- must be restored via set -t', + ); + assert.deepEqual( + parseProbeOutput('200x50' + PROBE_SEP + 'status on' + PROBE_SEP + 'mouse* on' + PROBE_SEP + ''), + { cols: 200, rows: 51, pre: { status: 'on', mouse: null, windowSize: null } }, + 'starred "mouse* on" is inherited -- no session override exists, restore must set -u', + ); +}); + +test('parseProbeOutput: pre.windowSize follows the same starred/unstarred rule as status and mouse', () => { + assert.deepEqual( + parseProbeOutput('200x50' + PROBE_SEP + 'status on' + PROBE_SEP + '' + PROBE_SEP + 'window-size manual'), + { cols: 200, rows: 51, pre: { status: 'on', mouse: null, windowSize: 'manual' } }, + ); + assert.deepEqual( + parseProbeOutput('200x50' + PROBE_SEP + 'status on' + PROBE_SEP + '' + PROBE_SEP + 'window-size* latest'), + { cols: 200, rows: 51, pre: { status: 'on', mouse: null, windowSize: null } }, + ); +}); + +// --- Solo attach parity (issue #253) ------------------------------------ + +test('buildAttachCommand: solo prefixes session-scoped option sets before attach, in order', () => { + const cmd = buildAttachCommand('/tmp/tmux-0/main', 'main:@0.%0', { solo: true }); + assert.equal( + cmd, + "tmux -S '/tmp/tmux-0/main' set -t main:@0.%0 status off \\; " + + 'set -t main:@0.%0 mouse on \\; ' + + 'set -t main:@0.%0 window-size latest \\; ' + + 'attach -t main:@0.%0', + ); + const statusIdx = cmd.indexOf('status off'); + const mouseIdx = cmd.indexOf('mouse on'); + const windowSizeIdx = cmd.indexOf('window-size latest'); + const attachIdx = cmd.indexOf('attach -t'); + assert.ok(statusIdx < mouseIdx && mouseIdx < windowSizeIdx && windowSizeIdx < attachIdx, 'sets must precede attach, in order'); + assert.ok(!cmd.includes('-g'), 'solo attach must never touch the global option scope'); + assert.ok(!cmd.includes('-w'), 'solo attach must never touch window-scoped options'); +}); + +test('buildAttachCommand: shared (solo false or omitted) emits the byte-identical unchanged command', () => { + const unchanged = "tmux -S '/tmp/tmux-0/main' attach -t main:@0.%0"; + assert.equal(buildAttachCommand('/tmp/tmux-0/main', 'main:@0.%0', { solo: false }), unchanged); + assert.equal(buildAttachCommand('/tmp/tmux-0/main', 'main:@0.%0'), unchanged); +}); + +test('buildRestoreCommand: restores each probed value when non-null', () => { + const cmd = buildRestoreCommand('/tmp/tmux-0/main', 'main:@0.%0', { status: 'on', mouse: 'off', windowSize: 'manual' }); + assert.equal( + cmd, + "tmux -S '/tmp/tmux-0/main' set -t main:@0.%0 status on \\; " + + 'set -t main:@0.%0 mouse off \\; ' + + 'set -t main:@0.%0 window-size manual', + ); +}); + +test('buildRestoreCommand: restores a numeric status value', () => { + const cmd = buildRestoreCommand('/tmp/tmux-0/main', 'main:@0.%0', { status: 2, mouse: 'on', windowSize: 'latest' }); + assert.match(cmd, /set -t main:@0\.%0 status 2 \\;/); +}); + +test('buildRestoreCommand: uses "set -u" for each probed value that was null', () => { + const cmd = buildRestoreCommand('/tmp/tmux-0/main', 'main:@0.%0', { status: null, mouse: null, windowSize: null }); + assert.equal( + cmd, + "tmux -S '/tmp/tmux-0/main' set -u -t main:@0.%0 status \\; " + + 'set -u -t main:@0.%0 mouse \\; ' + + 'set -u -t main:@0.%0 window-size', + ); +}); + +test('buildRestoreCommand: mixes "set" and "set -u" per option independently', () => { + const cmd = buildRestoreCommand('/tmp/tmux-0/main', 'main:@0.%0', { status: 'off', mouse: null, windowSize: 'latest' }); + assert.equal( + cmd, + "tmux -S '/tmp/tmux-0/main' set -t main:@0.%0 status off \\; " + + 'set -u -t main:@0.%0 mouse \\; ' + + 'set -t main:@0.%0 window-size latest', + ); +}); + +// No remote command string may ever contain a backtick -- these run over ssh, +// where a backtick executes (issue #253 acceptance criterion). +test('no builder ever emits a backtick', () => { + const commands = [ + buildProbeCommand(4242, 'main:@0.%0'), + buildAttachCommand('/tmp/tmux-0/main', 'main:@0.%0'), + buildAttachCommand('/tmp/tmux-0/main', 'main:@0.%0', { solo: true }), + buildAttachCommand('/tmp/tmux-0/main', 'main:@0.%0', { solo: false }), + buildRestoreCommand('/tmp/tmux-0/main', 'main:@0.%0', { status: 'on', mouse: 'off', windowSize: 'manual' }), + buildRestoreCommand('/tmp/tmux-0/main', 'main:@0.%0', { status: null, mouse: null, windowSize: null }), + buildRestoreCommand('/tmp/tmux-0/main', 'main:@0.%0', {}), + ]; + for (const cmd of commands) { + assert.ok(!cmd.includes('`'), `command must not contain a backtick: ${cmd}`); + } +}); + +// Solo attach must actually apply the session-scoped option sets end-to-end +// through attach(), not just at the buildAttachCommand unit level. +test('attach() applies the session-scoped option sets in the real ssh argv when solo', async () => { + const spawnCalls = []; + const adapter = makeAdapter({ + probeStdout: '200x50' + PROBE_SEP + 'status on' + PROBE_SEP + 'mouse off' + PROBE_SEP + 'window-size manual' + PROBE_SEP + '0', + spawnCalls, + }); + const result = await adapter.attach( + 'vps', { sessionId: 's1', pid: 4242, tmux: 'main:@0.%0' }, { cols: 100, rows: 40 }, + ); + assert.equal(result.ok, true); + const attachCommand = spawnCalls[0].args[spawnCalls[0].args.length - 1]; + assert.match(attachCommand, /set -t main:@0\.%0 status off \\; set -t main:@0\.%0 mouse on \\; set -t main:@0\.%0 window-size latest \\; attach -t main:@0\.%0/); +}); + +// Shared attach() must emit the byte-identical unchanged attach command -- +// never touch another attached client's view. +test('attach() emits the unchanged attach command in the real ssh argv when shared', async () => { + const spawnCalls = []; + const adapter = makeAdapter({ + probeStdout: '200x50' + PROBE_SEP + 'status on' + PROBE_SEP + 'mouse off' + PROBE_SEP + 'window-size manual' + PROBE_SEP + '1', + spawnCalls, + }); + const result = await adapter.attach( + 'vps', { sessionId: 's1', pid: 4242, tmux: 'main:@0.%0' }, { cols: 100, rows: 40 }, + ); + assert.equal(result.ok, true); + const attachCommand = spawnCalls[0].args[spawnCalls[0].args.length - 1]; + assert.equal(attachCommand, "tmux -S '/tmp/tmux-0/test' attach -t main:@0.%0"); +}); + +// Detach must run a best-effort restore ssh call using the probed pre-attach +// values, only when the attach was solo. +test('detach() runs a best-effort restore call with the probed pre-attach values when solo', async () => { + const raw = fakeRawPty(); + const restoreCalls = []; + const runRemoteCommand = async (alias, command) => { + if (/^tmux -S /.test(command) && !command.includes('attach') && !/display-message|show-options|list-clients/.test(command)) { + restoreCalls.push(command); + return { code: 0, stdout: '', stderr: '' }; + } + return { + code: 0, + stdout: `${FAKE_SOCKET}${PROBE_SEP}200x50${PROBE_SEP}status on${PROBE_SEP}mouse off${PROBE_SEP}window-size manual${PROBE_SEP}0`, + stderr: '', + }; + }; + const adapter = createTmuxAttachAdapter({ + spawnPty: () => raw.pty, + runRemoteCommand, + log: silentLog, + }); + const result = await adapter.attach( + 'vps', { sessionId: 's1', pid: 4242, tmux: 'main:@0.%0' }, { cols: 100, rows: 40 }, + ); + assert.equal(result.ok, true); + + result.ptyProcess.kill(); + // The restore call is fire-and-forget from inside kill(); let its microtask run. + await Promise.resolve(); + await Promise.resolve(); + + assert.equal(restoreCalls.length, 1, 'exactly one restore call must be sent on detach when solo'); + assert.equal( + restoreCalls[0], + "tmux -S '/tmp/tmux-0/test' set -t main:@0.%0 status on \\; set -t main:@0.%0 mouse off \\; set -t main:@0.%0 window-size manual", + ); +}); + +// Shared attach must never restore anything on detach -- it never changed +// anything in the first place, and another client's view must not move. +test('detach() sends no restore call when shared', async () => { + const raw = fakeRawPty(); + const restoreCalls = []; + const runRemoteCommand = async (alias, command) => { + if (/^tmux -S /.test(command) && !command.includes('attach') && !/display-message|show-options|list-clients/.test(command)) { + restoreCalls.push(command); + return { code: 0, stdout: '', stderr: '' }; + } + return { + code: 0, + stdout: `${FAKE_SOCKET}${PROBE_SEP}200x50${PROBE_SEP}status on${PROBE_SEP}mouse off${PROBE_SEP}window-size manual${PROBE_SEP}1`, + stderr: '', + }; + }; + const adapter = createTmuxAttachAdapter({ + spawnPty: () => raw.pty, + runRemoteCommand, + log: silentLog, + }); + const result = await adapter.attach( + 'vps', { sessionId: 's1', pid: 4242, tmux: 'main:@0.%0' }, { cols: 100, rows: 40 }, + ); + assert.equal(result.ok, true); + + result.ptyProcess.kill(); + await Promise.resolve(); + await Promise.resolve(); + + assert.equal(restoreCalls.length, 0, 'a shared attach must never send a restore call on detach'); +}); + +// A restore-on-detach failure must never throw out of kill()/detach(), and +// must not prevent the local ssh client from being killed. +test('detach() swallows a failing restore call without throwing', async () => { + const raw = fakeRawPty(); + const runRemoteCommand = async (alias, command) => { + if (/^tmux -S /.test(command) && !command.includes('attach') && !/display-message|show-options|list-clients/.test(command)) { + throw new Error('ssh: connection refused'); + } + return { + code: 0, + stdout: `${FAKE_SOCKET}${PROBE_SEP}200x50${PROBE_SEP}status on${PROBE_SEP}mouse off${PROBE_SEP}window-size manual${PROBE_SEP}0`, + stderr: '', + }; + }; + const adapter = createTmuxAttachAdapter({ + spawnPty: () => raw.pty, + runRemoteCommand, + log: silentLog, + }); + const result = await adapter.attach( + 'vps', { sessionId: 's1', pid: 4242, tmux: 'main:@0.%0' }, { cols: 100, rows: 40 }, + ); + assert.equal(result.ok, true); + + assert.doesNotThrow(() => result.ptyProcess.kill()); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(raw.killedCount(), 1, 'the local ssh client must still be killed even if the restore call rejects'); +});