From 3bd7e81233c2bb2e6d8fad17e49d581dac50adce Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:48:00 +0200 Subject: [PATCH 1/3] fix(mux): give a discovered tmux session its pane's directory, not the server cwd reconcileSessions() adopts codeman-* sessions it finds on the socket but has no record of (a lost or stale mux-sessions.json) as "Restored: ..." entries, and set their workingDir to process.cwd(). Every such session then looked like it lived wherever the server was started, and a later respawn of it cd'd there instead of into its project. tmux already knows the answer as #{pane_current_path}. It is appended as the last field of PANE_LIST_FORMAT, so a directory containing the separator stays whole, and parsePaneRows() reads it as PaneRow.currentPath. No extra tmux call: the same list-panes read that yields the pid now yields the directory. The path is set by whoever owns the pane, so it is admitted only if it passes isValidPath(), the check createSession() and respawnPane() already apply to a workingDir; otherwise, and when tmux reported none, process.cwd() stays the fallback. A hand-split session takes the first pane tmux lists. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/tmux-manager.ts | 22 ++++++++++++-- test/tmux-manager.test.ts | 63 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index d3a2d174..a52cf7de 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -252,10 +252,14 @@ const PANE_LIST_SEP = '|'; * know a variable substitutes the empty string rather than failing, which is how * tmux 3.2a answers `#{pane_dead_signal}` (added in 3.4), and the parser reads a * short row as "pid known, deadness unknown" rather than discarding it. + * + * `#{pane_current_path}` comes last on purpose: a directory name may contain the + * separator, and only the final field can absorb it without ambiguity. */ const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}` + - `${PANE_LIST_SEP}#{pane_dead}${PANE_LIST_SEP}#{pane_dead_status}${PANE_LIST_SEP}#{pane_dead_signal}`; + `${PANE_LIST_SEP}#{pane_dead}${PANE_LIST_SEP}#{pane_dead_status}${PANE_LIST_SEP}#{pane_dead_signal}` + + `${PANE_LIST_SEP}#{pane_current_path}`; /** * 构建 pane 启动前的 nofile 修复命令。 @@ -282,6 +286,8 @@ export interface PaneRow { exitStatus?: number; /** `#{pane_dead_signal}` — the killing signal, absent before tmux 3.4 and when unsignalled. */ exitSignal?: number; + /** `#{pane_current_path}` — the pane's current directory, absent when tmux reported none. */ + currentPath?: string; } /** @@ -347,6 +353,8 @@ export function parsePaneRows(output: string): PaneRow[] { dead: deadFlag === '1' ? true : deadFlag === '0' ? false : undefined, exitStatus: paneField(fields, 3), exitSignal: paneField(fields, 4), + // Everything after the fifth separator, so a path containing `|` stays whole. + currentPath: fields.slice(5).join(PANE_LIST_SEP) || undefined, }); } return rows; @@ -3115,6 +3123,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // socket tag to reconcile and no cross-socket ambiguity that could mark a // live session dead (the root cause of vanished/duplicate tabs). let active: Map; + const paneDirs = new Map(); try { const output = execSync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, { encoding: 'utf-8', @@ -3122,6 +3131,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { }).trim(); 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. + for (const row of rows) { + if (row.currentPath && !paneDirs.has(row.sessionName) && isValidPath(row.currentPath)) { + paneDirs.set(row.sessionName, row.currentPath); + } + } // The same read answers both questions, so recovery starts with a pane-exit // reading rather than waiting for the first stats tick — which may never // come, since the collector only starts when boot found a live session. @@ -3175,7 +3192,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { muxName: sessionName, pid, createdAt: Date.now(), - workingDir: process.cwd(), + // Where the pane actually works, when tmux said; the server's cwd is only a fallback. + workingDir: paneDirs.get(sessionName) ?? process.cwd(), mode: 'claude', attached: false, name: `Restored: ${sessionName}`, diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts index 24b0659f..08d47a73 100644 --- a/test/tmux-manager.test.ts +++ b/test/tmux-manager.test.ts @@ -903,6 +903,52 @@ describe('TmuxManager (unit)', () => { nonTestManager.destroy(); } }); + + it('gives a discovered session the directory tmux reports for its pane, not the server cwd', async () => { + mockedExecSync.mockImplementation((cmd: string) => { + if (typeof cmd === 'string' && cmd.includes(' list-panes ')) { + return [ + 'codeman-aaaa1111|101|0|||/home/user/project-a', + // A hand-split session answers with the pane tmux lists first. + 'codeman-bbbb2222|102|0|||/home/user/project-b', + 'codeman-bbbb2222|103|0|||/home/user/project-b/sub', + // Set by whoever owns the pane, so it must pass the same check as any workingDir. + 'codeman-cccc3333|104|0|||/home/user/$(touch pwned)', + // A tmux that reported no path. + 'codeman-dddd4444|105|0|||', + ].join('\n'); + } + return ''; + }); + const NonTestTmuxManager = await importWithTmuxCommandsEnabled(); + const nonTestManager = new NonTestTmuxManager(); + + try { + const { discovered } = await nonTestManager.reconcileSessions(); + expect(discovered).toEqual([ + 'restored-aaaa1111', + 'restored-bbbb2222', + 'restored-cccc3333', + 'restored-dddd4444', + ]); + const dirs = Object.fromEntries(nonTestManager.getSessions().map((s) => [s.muxName, s.workingDir])); + expect(dirs).toEqual({ + 'codeman-aaaa1111': '/home/user/project-a', + 'codeman-bbbb2222': '/home/user/project-b', + 'codeman-cccc3333': process.cwd(), + 'codeman-dddd4444': process.cwd(), + }); + + // The directory comes from the same list-panes call, as its last field. + const listCalls = mockedExecSync.mock.calls + .map(([cmd]) => cmd) + .filter((cmd): cmd is string => typeof cmd === 'string' && cmd.includes(' list-panes ')); + expect(listCalls).toHaveLength(1); + expect(listCalls[0]).toContain("|#{pane_current_path}'"); + } finally { + nonTestManager.destroy(); + } + }); }); }); @@ -1013,6 +1059,23 @@ describe('parsePaneRows', () => { expect(row.dead).toBeUndefined(); }); + it('reads the pane directory from the last field', () => { + const [row] = parsePaneRows('codeman-aaaa|1234|0|||/home/user/project'); + expect(row.pid).toBe(1234); + expect(row.dead).toBe(false); + expect(row.currentPath).toBe('/home/user/project'); + }); + + it('keeps a pane directory that contains the separator whole', () => { + const [row] = parsePaneRows('codeman-aaaa|1234|0|||/home/user/a|b'); + expect(row.currentPath).toBe('/home/user/a|b'); + }); + + it('leaves the pane directory undefined when tmux reported none', () => { + expect(parsePaneRows('codeman-aaaa|1234')[0].currentPath).toBeUndefined(); + expect(parsePaneRows('codeman-aaaa|1234|0|||')[0].currentPath).toBeUndefined(); + }); + it('returns one row per pane of a split session, in tmux order', () => { const rows = parsePaneRows('codeman-aaaa|100|0|||\ncodeman-aaaa|200|1|0|'); expect(rows.map((row) => row.pid)).toEqual([100, 200]); 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 2/3] 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(); + } + }); }); }); From 6d1a542018c9522cd62e99289379c8f4d8fb5004 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 22 Oct 2026 19:10:08 +0200 Subject: [PATCH 3/3] fix(test): pin that a deleted pane directory is not adopted on rediscovery Merge-time fix for #667. tmux reports the directory of a pane whose directory was deleted while it ran as `/path (deleted)` (tmux 3.4 on Linux). Today the rediscovered session falls back to the server cwd only because isValidPath() rejects the parentheses; this row in the wiring test keeps a later relaxation of the path pattern from quietly adopting a dead directory. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/tmux-manager.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts index 39fe2344..cf0889e1 100644 --- a/test/tmux-manager.test.ts +++ b/test/tmux-manager.test.ts @@ -931,6 +931,9 @@ describe('TmuxManager (unit)', () => { 'codeman-dddd4444|105|0|||', // The launch directory every pane starts in; a remote or docker pane never leaves it. 'codeman-eeee5555|106|0|||/tmp', + // A directory deleted while its pane ran, as tmux 3.4 on Linux reports it. isValidPath() + // rejects it for the parentheses; a relaxed path pattern must not adopt a dead directory. + 'codeman-abcd7777|108|0|||/home/user/gone (deleted)', // Last row, path ending in a space: the output must not be trimmed. 'codeman-ffff6666|107|0|||/home/user/trailing ', ].join('\n'); @@ -948,6 +951,7 @@ describe('TmuxManager (unit)', () => { 'restored-cccc3333', 'restored-dddd4444', 'restored-eeee5555', + 'restored-abcd7777', 'restored-ffff6666', ]); const dirs = Object.fromEntries(nonTestManager.getSessions().map((s) => [s.muxName, s.workingDir])); @@ -957,6 +961,7 @@ describe('TmuxManager (unit)', () => { 'codeman-cccc3333': process.cwd(), 'codeman-dddd4444': process.cwd(), 'codeman-eeee5555': process.cwd(), + 'codeman-abcd7777': process.cwd(), 'codeman-ffff6666': '/home/user/trailing ', });