From 25214fcc5fb034d6642e3f26355966d09068e7ae Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 04:38:05 +0100 Subject: [PATCH] fix: adopting a shell session no longer types into the foreign pane (release review) startShell() writes `clear\n` 100 ms after a new mux attach, and for an adopted session that write reaches someone else's live pane through the wrapper's tmux client. Measured on an isolated instance: a shell with `echo DRAFT_NOT_SUBMITTED_` typed at its prompt ran `echo DRAFT_NOT_SUBMITTED_clear` the moment the tab opened. `shell` is the feature's primary case and also the classifier's answer for anything it cannot name (vim, psql, a `read` prompt), so every adoption put those six bytes in front of whatever was reading stdin. After the fix a byte recorder in the foreign pane saw 0 bytes in the 6 s after adopting, and a real input afterwards arrived intact. Not in the review: its recorder covered the trust-dialog path, which is `claude` mode. Co-Authored-By: Claude Opus 5.5 --- src/session.ts | 5 ++- test/foreign-adoption-guards.test.ts | 49 +++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/session.ts b/src/session.ts index c6376013..ac1d62d4 100644 --- a/src/session.ts +++ b/src/session.ts @@ -4382,7 +4382,10 @@ export class Session extends EventEmitter { // For NEW sessions: clear by sending 'clear' command to the shell // For RESTORED sessions: don't clear - we want to see the existing output - if (!isRestored) { + // ⚠️ Never for an ADOPTED one: this pane is someone else's, so the write would land + // on their half-typed command and submit it (measured), or in whatever is reading + // stdin. `shell` is also the mode of anything the classifier cannot name. + if (!isRestored && !this._adopt) { setTimeout(() => { if (this.ptyProcess) { this._terminalBuffer.clear(); diff --git a/test/foreign-adoption-guards.test.ts b/test/foreign-adoption-guards.test.ts index bceaad7b..ece62c1a 100644 --- a/test/foreign-adoption-guards.test.ts +++ b/test/foreign-adoption-guards.test.ts @@ -1,9 +1,10 @@ /** - * @fileoverview The two adoption guards that are not reachable over HTTP: the - * boot-time workspace-hook sweep, and the workspace-trust auto-accept. + * @fileoverview The adoption guards that are not reachable over HTTP: the + * boot-time workspace-hook sweep, the workspace-trust auto-accept and the + * new-shell clear. * - * Both act on a session's WORKSPACE or its PANE, both run unattended, and both - * previously keyed off `session.remote` or the mode alone — neither of which + * Each acts on a session's WORKSPACE or its PANE, each runs unattended, and each + * previously keyed off `session.remote`, the mode or nothing at all, none of which * sees an adopted session, whose connection facts live on `adopt.remote` / * `adopt.docker`. So a tab wrapping someone's claude read as a plain local * claude: Codeman wrote `.claude/settings.local.json` into their repo on every @@ -122,3 +123,43 @@ describe('the workspace-trust auto-accept', () => { await s.stop(true).catch(() => {}); }); }); + +describe('the new-shell clear', () => { + /** What a shell session writes to its pane once the mux attach is up. */ + const bytesWrittenAfterStart = async (adopt?: Record) => { + const s = new Session({ + id: adopt ? 'adopt-shell' : 'own-shell', + name: 't', + workingDir: '/tmp', + mode: 'shell', + useMux: true, + mux: {}, + ...(adopt ? { adopt } : {}), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + const write = vi.fn(); + const disposable = () => ({ dispose() {} }); + const priv = s as unknown as Record; + priv._setupOrAttachMuxSession = async () => { + priv.ptyProcess = { write, onData: disposable, onExit: disposable, kill() {}, pid: 1 }; + return { isRestored: false, respawnedDeadPane: false }; + }; + await s.startShell(); + await new Promise((resolve) => setTimeout(resolve, 200)); + priv.ptyProcess = null; + await s.stop(true).catch(() => {}); + return write.mock.calls.map((c) => c[0]); + }; + + it('clears a shell Codeman started', async () => { + expect(await bytesWrittenAfterStart()).toEqual(['clear\n']); + }); + + it('types nothing into an adopted pane', async () => { + // ⚠️ That pane is someone else's, and `shell` is also the answer for anything the + // classifier cannot name (vim, psql, a `read` prompt). `clear` plus a newline landed + // on a half-typed command or in whatever was reading stdin, on every adoption. + const adopt = { location: 'local', socketPath: '/tmp/tmux-0/default', targetSession: 'work', viewSession: '' }; + expect(await bytesWrittenAfterStart(adopt)).toEqual([]); + }); +});