From ced88b331008d6514cc4fa7a2de3afabc4f4c057 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:02:05 +0200 Subject: [PATCH] fix(mux): treat the launch directory as no answer for a discovered pane Every pane starts in TMUX_LAUNCH_CWD (/tmp) and reaches its workspace with a cd inside the pane command, but a remote or docker pane never does that cd, so tmux reports /tmp for it. A rediscovered record carries no remote/docker metadata and looks local, so adopting that directory would root the session at /tmp: hooks written into /tmp/.claude, the Files panel at /tmp, and a respawn running claude in a directory any local account can write to. reconcileSessions() now skips a pane whose directory is the launch directory, in both spellings a pane can report it: the literal one and its realpath (/private/tmp on macOS, where a process cwd comes back resolved). The set is computed once at module load. Such a session falls back to process.cwd(), as before this series. Also stop trimming the list-panes output: the directory is the last field, so a trim cut a trailing space off the last row's path; parsePaneRows() already skips empty lines. And the comments that list what a discovered record knows for certain now include the directory, when tmux reported a usable one. Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude --- src/mux-interface.ts | 3 ++- src/tmux-manager.ts | 35 +++++++++++++++++++++++---- test/tmux-manager.test.ts | 50 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 82 insertions(+), 6 deletions(-) diff --git a/src/mux-interface.ts b/src/mux-interface.ts index c1df2492..702cd900 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -63,7 +63,8 @@ export interface MuxSession { statusLine?: StatusLineExporter; /** * This record was rebuilt from the tmux socket rather than from Codeman's own - * bookkeeping, so everything on it but the name and the pid is a guess. Its + * bookkeeping, so everything on it but the name, the pid and, when tmux + * reported a usable one, the directory is a guess. Its * synthetic `restored-` id cannot find the session's `state.json` * entry either, which means a remote or docker session rediscovered this way * arrives with no `remote`/`docker` metadata and looks local. Anything that diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index a52cf7de..92af82b8 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -27,7 +27,7 @@ import { execSync, exec, execFile } from 'node:child_process'; import { promisify } from 'node:util'; const execAsync = promisify(exec); -import { existsSync, readFileSync, mkdirSync } from 'node:fs'; +import { existsSync, readFileSync, mkdirSync, realpathSync } from 'node:fs'; import { writeFile, rename } from 'node:fs/promises'; import { dirname } from 'node:path'; import { homedir } from 'node:os'; @@ -183,6 +183,22 @@ const DEFAULT_REMOTE_RECONNECT_INTERVAL_MS = 5000; /** Stable cwd for tmux server/pane launch; actual session cwd is reached inside the pane. */ const TMUX_LAUNCH_CWD = '/tmp'; +/** + * TMUX_LAUNCH_CWD in every spelling a pane can report it. tmux reads a pane's + * directory from its process, which comes back resolved (`/private/tmp` on + * macOS, where `/tmp` is a symlink), so the literal and the resolved spelling + * both count. Computed once. + */ +const TMUX_LAUNCH_CWD_SPELLINGS: ReadonlySet = (() => { + const spellings = new Set([TMUX_LAUNCH_CWD]); + try { + spellings.add(realpathSync(TMUX_LAUNCH_CWD)); + } catch { + // Unresolvable launch directory: the literal spelling is all there is. + } + return spellings; +})(); + /** Claude Code native macOS recommendation for avoiding low nofile startup failures. */ export const CLAUDE_CODE_NOFILE_LIMIT = 2147483646; @@ -3128,14 +3144,24 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const output = execSync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, - }).trim(); + }); + // Not trimmed: the directory is the last field, so trimming would cut a + // trailing space off the last row's path. parsePaneRows skips empty lines. const rows = parsePaneRows(output); active = new Map(rows.map((row) => [row.sessionName, row.pid])); // A discovered session's directory, from its first pane with a usable path. // The path is admitted only if it passes the same check createSession and // respawnPane apply, since it was set by whoever owns the pane, not by us. + // The launch directory counts as no answer: every pane starts there, and a + // remote or docker pane never cds out of it, so for such a session it is + // not the workspace — and a rediscovered record cannot tell it is one. for (const row of rows) { - if (row.currentPath && !paneDirs.has(row.sessionName) && isValidPath(row.currentPath)) { + if ( + row.currentPath && + !TMUX_LAUNCH_CWD_SPELLINGS.has(row.currentPath) && + !paneDirs.has(row.sessionName) && + isValidPath(row.currentPath) + ) { paneDirs.set(row.sessionName, row.currentPath); } } @@ -3197,7 +3223,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { mode: 'claude', attached: false, name: `Restored: ${sessionName}`, - // Every field above except the name and the pid is a guess: this record + // Every field above except the name, the pid and, when tmux reported a + // usable one, the directory is a guess: this record // was rebuilt from the socket because Codeman's own bookkeeping did not // have it. The synthetic id also cannot find the session's state.json // entry, so a remote or docker session rediscovered this way arrives diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts index 08d47a73..39fe2344 100644 --- a/test/tmux-manager.test.ts +++ b/test/tmux-manager.test.ts @@ -52,11 +52,24 @@ vi.mock('node:child_process', async () => { }; }); +// Paths realpathSync should resolve differently than this host does, e.g. a +// macOS `/tmp` -> `/private/tmp`. Hoisted so it survives vi.resetModules(). +const realpathOverrides = vi.hoisted(() => new Map()); + // Mock fs to avoid file I/O vi.mock('node:fs', async () => { - const actual = await vi.importActual('node:fs'); + const actual = await vi.importActual('node:fs'); + const realpathSync = Object.assign( + vi.fn((path: string, ...rest: unknown[]) => + realpathOverrides.has(String(path)) + ? realpathOverrides.get(String(path)) + : (actual.realpathSync as (...args: unknown[]) => string)(path, ...rest) + ), + { native: actual.realpathSync.native } + ); return { ...actual, + realpathSync, existsSync: vi.fn().mockReturnValue(false), readFileSync: vi.fn(), mkdirSync: vi.fn(), @@ -916,6 +929,10 @@ describe('TmuxManager (unit)', () => { 'codeman-cccc3333|104|0|||/home/user/$(touch pwned)', // A tmux that reported no path. 'codeman-dddd4444|105|0|||', + // The launch directory every pane starts in; a remote or docker pane never leaves it. + 'codeman-eeee5555|106|0|||/tmp', + // Last row, path ending in a space: the output must not be trimmed. + 'codeman-ffff6666|107|0|||/home/user/trailing ', ].join('\n'); } return ''; @@ -930,6 +947,8 @@ describe('TmuxManager (unit)', () => { 'restored-bbbb2222', 'restored-cccc3333', 'restored-dddd4444', + 'restored-eeee5555', + 'restored-ffff6666', ]); const dirs = Object.fromEntries(nonTestManager.getSessions().map((s) => [s.muxName, s.workingDir])); expect(dirs).toEqual({ @@ -937,6 +956,8 @@ describe('TmuxManager (unit)', () => { 'codeman-bbbb2222': '/home/user/project-b', 'codeman-cccc3333': process.cwd(), 'codeman-dddd4444': process.cwd(), + 'codeman-eeee5555': process.cwd(), + 'codeman-ffff6666': '/home/user/trailing ', }); // The directory comes from the same list-panes call, as its last field. @@ -949,6 +970,33 @@ describe('TmuxManager (unit)', () => { nonTestManager.destroy(); } }); + + it('treats the resolved spelling of the launch directory as no answer too (macOS /private/tmp)', async () => { + // On macOS /tmp is a symlink and a pane reports its process cwd resolved. + realpathOverrides.set('/tmp', '/private/tmp'); + mockedExecSync.mockImplementation((cmd: string) => { + if (typeof cmd === 'string' && cmd.includes(' list-panes ')) { + return ['codeman-aaaa1111|101|0|||/private/tmp', 'codeman-bbbb2222|102|0|||/tmp'].join('\n'); + } + return ''; + }); + try { + const NonTestTmuxManager = await importWithTmuxCommandsEnabled(); + const nonTestManager = new NonTestTmuxManager(); + try { + await nonTestManager.reconcileSessions(); + const dirs = Object.fromEntries(nonTestManager.getSessions().map((s) => [s.muxName, s.workingDir])); + expect(dirs).toEqual({ + 'codeman-aaaa1111': process.cwd(), + 'codeman-bbbb2222': process.cwd(), + }); + } finally { + nonTestManager.destroy(); + } + } finally { + realpathOverrides.clear(); + } + }); }); });