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 d3a2d174..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; @@ -252,10 +268,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 +302,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 +369,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,13 +3139,32 @@ 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', 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 && + !TMUX_LAUNCH_CWD_SPELLINGS.has(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,11 +3218,13 @@ 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}`, - // 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 24b0659f..cf0889e1 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(), @@ -903,6 +916,92 @@ 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|||', + // 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'); + } + 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', + 'restored-eeee5555', + 'restored-abcd7777', + 'restored-ffff6666', + ]); + 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(), + 'codeman-eeee5555': process.cwd(), + 'codeman-abcd7777': process.cwd(), + 'codeman-ffff6666': '/home/user/trailing ', + }); + + // 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(); + } + }); + + 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(); + } + }); }); }); @@ -1013,6 +1112,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]);