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] 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]);