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