From 9c70bf641a489c769aea72f2763a3a05c1c29553 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Wed, 9 Sep 2026 11:30:05 +0200 Subject: [PATCH] fix(remote): detach by ending the ssh client, not a tmux prefix keystroke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detach path wrote Ctrl-B d before killing the local client, which assumes the remote host uses tmux's default prefix. On a host that remapped it the d lands as literal text in the attached session's composer. Ending the local ssh client is enough: the remote tmux client loses its pty and tmux drops it. Measured against the live host — the attached client count goes 1 then back to 0 and the session keeps running. --- remote-attach.js | 12 ++++++------ test/remote-attach.test.js | 17 +++++++++-------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/remote-attach.js b/remote-attach.js index 1ef20abe..2c540966 100644 --- a/remote-attach.js +++ b/remote-attach.js @@ -5,8 +5,6 @@ const TMUX_FIELD_RE = /^([A-Za-z0-9._-]{1,64}):(@?\d{1,10}(?:\.%?\d{1,10})?)$/; const PROBE_SEP = '\u0001'; -const DETACH_KEYS = '\x02d'; // Ctrl-B d — tmux default prefix, then detach -const DETACH_GRACE_MS = 150; const DEFAULT_PROBE_TIMEOUT_MS = 15000; const DEFAULT_STATUS_LINES = 1; const NO_TMUX_ENV_EXIT_CODE = 3; @@ -220,12 +218,15 @@ function createTmuxAttachAdapter(opts = {}) { raw.onExit(() => { alive = false; }); let detaching = false; + // Ending the local ssh client is what detaches: the remote tmux client + // loses its pty and tmux drops it, leaving the session running. Sending a + // prefix keystroke instead would assume this host's prefix, and land as + // literal text in the remote session on any host that remapped it. + // see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") function detach() { if (detaching || !alive) return; detaching = true; - try { raw.write(DETACH_KEYS); } catch {} - // see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") - setTimeout(() => { try { raw.kill(); } catch {} }, DETACH_GRACE_MS); + try { raw.kill(); } catch {} } const ptyProcess = { @@ -264,5 +265,4 @@ module.exports = { parseDiscoveryProbeOutput, buildProbeCommand, buildAttachCommand, - DETACH_KEYS, }; diff --git a/test/remote-attach.test.js b/test/remote-attach.test.js index 8f2c40e0..aad3dcd2 100644 --- a/test/remote-attach.test.js +++ b/test/remote-attach.test.js @@ -206,8 +206,8 @@ test('attach() refuses a descriptor with no readable pid, before any ssh call', }); // Property 3 -- the returned ptyProcess is pilotable without any real local -// node-pty: writes reach the underlying process, and kill() detaches cleanly -// (Ctrl-B d) before ending the local ssh client, rather than killing outright. +// node-pty: writes reach the underlying process, and kill() detaches by +// ending the local ssh client, sending nothing to the remote session. test('the returned ptyProcess pilots the fake remote pty through write() and kill()', async () => { const raw = fakeRawPty(); const adapter = makeAdapter({ probeStdout: '200x50' + PROBE_SEP + 'status on', rawPtyFactory: () => raw.pty }); @@ -222,12 +222,13 @@ test('the returned ptyProcess pilots the fake remote pty through write() and kil assert.equal(ptyProcess.pid, 4242); ptyProcess.kill(); - // Detach sends Ctrl-B d before ending the local client -- see DETACH_KEYS. - assert.equal(raw.writes[raw.writes.length - 1], '\x02d', 'kill() must send the tmux detach sequence, not just end the process'); - assert.equal(raw.killedCount(), 0, 'the local client must not be ended immediately -- see the detach grace period'); - - await new Promise((resolve) => setTimeout(resolve, 250)); - assert.equal(raw.killedCount(), 1, 'the local client must be ended once the detach keystroke has had time to land'); + // Detaching means ending the local ssh client and nothing else: any prefix + // keystroke would assume this host's tmux prefix and land as literal text + // in the remote session on a host that remapped it. Measured on the live + // host: killing the client alone takes the attached client count back to 0 + // and leaves the session running. + assert.deepEqual(raw.writes, ['echo hi\n'], 'kill() must not send any keystroke to the remote session'); + assert.equal(raw.killedCount(), 1, 'kill() must end the local ssh client'); assert.equal(ptyProcess.isAlive(), false); });