From e0369360c9626d0ccd59e9f92644361db50e8f84 Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 29 Jul 2026 12:03:18 +0300 Subject: [PATCH] fix(sessions): restore provider and working directory from live panes --- src/tmux-manager.ts | 176 +++++++++++++++++++++++++++++----- test/tmux-manager.test.ts | 193 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 347 insertions(+), 22 deletions(-) diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index ca29478e..708b3d3a 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -187,8 +187,100 @@ const SAFE_TMUX_SOCKET_PATTERN = /^[a-zA-Z0-9_.-]+$/; */ const PANE_LIST_SEP = '|'; -/** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */ -const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}`; +/** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneDiscoveryList}. */ +const PANE_LIST_FORMAT = ['#{session_name}', '#{pane_pid}', '#{@codeman-mode}', '#{pane_current_path}'].join( + PANE_LIST_SEP +); + +const SESSION_MODES = new Set(['claude', 'shell', 'opencode', 'codex', 'gemini']); + +export interface PaneDiscovery { + pid: number; + workingDir: string | undefined; + mode?: SessionMode; +} + +/** + * Parse live pane metadata. Current output includes a durable session-mode + * option before the path. Legacy pid-only and path-bearing output remains + * supported so panes created before the option was introduced can be migrated. + */ +export function parsePaneDiscoveryList(output: string): Map { + const result = new Map(); + for (const line of output.split('\n')) { + if (!line) continue; + const firstSep = line.indexOf(PANE_LIST_SEP); + if (firstSep === -1) continue; + const secondSep = line.indexOf(PANE_LIST_SEP, firstSep + 1); + const thirdSep = secondSep === -1 ? -1 : line.indexOf(PANE_LIST_SEP, secondSep + 1); + const name = line.slice(0, firstSep); + const pidText = secondSep === -1 ? line.slice(firstSep + 1) : line.slice(firstSep + 1, secondSep); + const pid = parseInt(pidText, 10); + const modeText = thirdSep === -1 ? undefined : line.slice(secondSep + 1, thirdSep); + const mode = modeText && SESSION_MODES.has(modeText as SessionMode) ? (modeText as SessionMode) : undefined; + const workingDir = + secondSep === -1 + ? undefined + : thirdSep === -1 + ? line.slice(secondSep + 1) || undefined + : line.slice(thirdSep + 1) || undefined; + if (name && !Number.isNaN(pid)) { + result.set(name, { pid, workingDir, ...(mode ? { mode } : {}) }); + } + } + return result; +} + +/** + * Infer the provider from a legacy pane's live process command. This is only a + * migration fallback for sessions created before `@codeman-mode`; new panes + * restore from explicit tmux metadata instead. + */ +export function inferSessionModeFromProcessCommand(command: string): SessionMode | undefined { + const tokens = String(command) + .replace(/\0/g, ' ') + .trim() + .split(/\s+/) + .map( + (token) => + token + .replace(/^["']|["']$/g, '') + .split('/') + .pop() + ?.toLowerCase() || '' + ); + + if (tokens.includes('codex')) return 'codex'; + if (tokens.includes('opencode')) return 'opencode'; + if (tokens.includes('gemini')) return 'gemini'; + if (tokens.includes('claude')) return 'claude'; + + const executable = tokens[0]; + if (['sh', 'bash', 'zsh', 'fish', 'pwsh', 'pwsh.exe', 'powershell.exe'].includes(executable)) { + return 'shell'; + } + return undefined; +} + +function inferSessionModeFromPaneProcess(pid: number): SessionMode | undefined { + try { + const command = readFileSync(`/proc/${pid}/cmdline`, 'utf8'); + const inferred = inferSessionModeFromProcessCommand(command); + if (inferred) return inferred; + } catch { + // procfs is Linux-only; macOS and other hosts use ps below. + } + + try { + const command = execSync(`ps -p ${pid} -o command=`, { + encoding: 'utf8', + timeout: EXEC_TIMEOUT_MS, + }); + return inferSessionModeFromProcessCommand(command); + } catch { + return undefined; + } +} /** * 构建 pane 启动前的 nofile 修复命令。 @@ -212,15 +304,8 @@ export function buildNofileLimitCommand(targetLimit = CLAUDE_CODE_NOFILE_LIMIT): */ export function parsePaneList(output: string): Map { const result = new Map(); - for (const line of output.split('\n')) { - if (!line) continue; - const sep = line.indexOf(PANE_LIST_SEP); - if (sep === -1) continue; - const name = line.slice(0, sep); - const pid = parseInt(line.slice(sep + 1), 10); - if (name && !Number.isNaN(pid)) { - result.set(name, pid); - } + for (const [name, pane] of parsePaneDiscoveryList(output)) { + result.set(name, pane.pid); } return result; } @@ -553,6 +638,13 @@ function isValidPath(path: string): boolean { return SAFE_PATH_PATTERN.test(path); } +function resolveDiscoveredWorkingDir(workingDir: string | undefined): string { + if (workingDir?.startsWith('/') && isValidPath(workingDir)) { + return workingDir; + } + return process.cwd(); +} + // =========================================================================== // Single-socket architecture: ALL Codeman sessions live on one dedicated tmux // socket (`tmux -L codeman`), isolated from the user's default tmux server. @@ -1396,6 +1488,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { return tmuxCommand(this.tmuxSocket); } + private persistSessionMode(muxName: string, mode: SessionMode): boolean { + if (!isValidMuxName(muxName)) return false; + try { + execSync(`${this.tmux()} set-option -t "${muxName}" @codeman-mode "${mode}"`, { + timeout: EXEC_TIMEOUT_MS, + stdio: 'ignore', + }); + return true; + } catch { + return false; + } + } + // Load saved sessions from disk (NEVER called in test mode) private loadSessions(): void { if (IS_TEST_MODE) return; @@ -1725,6 +1830,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } catch { /* Non-critical */ } + this.persistSessionMode(muxName, mode); // For OpenCode: set sensitive env vars and config via tmux setenv // (not visible in ps output or tmux history, inherited by panes) @@ -1922,6 +2028,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { }).catch(() => { /* Non-critical — keeps existing tmux history-limit */ }); + this.persistSessionMode(muxName, mode); } // Resolve CLI binary directory based on mode @@ -2263,18 +2370,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const alive: string[] = []; const dead: string[] = []; const discovered: string[] = []; + let metadataChanged = false; // Single batched query against the one socket Codeman owns. With a single // socket a session's location is a constant, so there is no per-session // 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; + let active: 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(); - active = parsePaneList(output); + active = parsePaneDiscoveryList(output); } catch (err) { console.error('[TmuxManager] Failed to list tmux panes:', err); active = new Map(); @@ -2282,10 +2390,32 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Check tracked sessions against the live pane list. for (const [sessionId, session] of this.sessions) { - const pid = active.get(session.muxName); - if (pid !== undefined) { + const pane = active.get(session.muxName); + if (pane !== undefined) { alive.push(sessionId); - if (pid !== session.pid) session.pid = pid; + if (pane.pid !== session.pid) session.pid = pane.pid; + const recoveredMode = + pane.mode ?? + (sessionId.startsWith('restored-') ? inferSessionModeFromPaneProcess(pane.pid) : undefined) ?? + session.mode; + if (recoveredMode && recoveredMode !== session.mode) { + session.mode = recoveredMode; + metadataChanged = true; + } + if (recoveredMode && !pane.mode) { + this.persistSessionMode(session.muxName, recoveredMode); + } + const recoveredWorkingDir = resolveDiscoveredWorkingDir(pane.workingDir); + const shouldRecoverWorkingDir = sessionId.startsWith('restored-') || session.workingDir === process.cwd(); + if ( + !session.remote && + !session.docker && + shouldRecoverWorkingDir && + recoveredWorkingDir !== session.workingDir + ) { + session.workingDir = recoveredWorkingDir; + metadataChanged = true; + } } else { dead.push(sessionId); this.sessions.delete(sessionId); @@ -2302,7 +2432,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { knownMuxNames.add(session.muxName); } - for (const [sessionName, pid] of active) { + for (const [sessionName, pane] of active) { if (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-')) continue; // Only admit names that pass the safe-name pattern. A foreign process on the // shared `tmux -L codeman` socket could create a `codeman-…` session whose name @@ -2316,23 +2446,25 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const fragment = sessionName.replace(/^(?:codeman|claudeman)-/, ''); const sessionId = `restored-${fragment}`; + const recoveredMode = pane.mode ?? inferSessionModeFromPaneProcess(pane.pid) ?? 'claude'; const session: MuxSession = { sessionId, muxName: sessionName, - pid, + pid: pane.pid, createdAt: Date.now(), - workingDir: process.cwd(), - mode: 'claude', + workingDir: resolveDiscoveredWorkingDir(pane.workingDir), + mode: recoveredMode, attached: false, name: `Restored: ${sessionName}`, }; this.sessions.set(sessionId, session); + if (!pane.mode) this.persistSessionMode(sessionName, recoveredMode); knownMuxNames.add(sessionName); discovered.push(sessionId); - console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`); + console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pane.pid})`); } - if (dead.length > 0 || discovered.length > 0) { + if (dead.length > 0 || discovered.length > 0 || metadataChanged) { this.saveSessions(); } diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts index 72545fe4..908e2242 100644 --- a/test/tmux-manager.test.ts +++ b/test/tmux-manager.test.ts @@ -13,6 +13,8 @@ import { buildRemoteKillCommand, buildRemoteLaunchCommand, formatPaneSnapshot, + inferSessionModeFromProcessCommand, + parsePaneDiscoveryList, parsePaneList, resolveActivePaneTarget, } from '../src/tmux-manager.js'; @@ -640,6 +642,10 @@ describe('TmuxManager (unit)', () => { ); expect(respawnCall?.[0]).toContain(`tmux -L 'codeman' respawn-pane -k -c /tmp -t "codeman-abc12345"`); expect(respawnCall?.[0]).toContain('cd \\"/mnt/gdrive/project with spaces\\" &&'); + const modePersistCall = mockedExecSync.mock.calls.find( + ([cmd]) => typeof cmd === 'string' && cmd.includes('@codeman-mode') + ); + expect(modePersistCall?.[0]).toContain('"shell"'); } finally { nonTestManager.destroy(); } @@ -672,6 +678,138 @@ describe('TmuxManager (unit)', () => { .mock.calls.find(([cmd]) => typeof cmd === 'string' && cmd.includes(' respawn-pane ')); expect(respawnCall?.[0]).toContain(`tmux -L 'codeman' respawn-pane -k -c /tmp -t "codeman-abcd1234"`); expect(respawnCall?.[0]).toContain('cd \\"/mnt/gdrive/project\\" &&'); + const modePersistCall = mockedExecSync.mock.calls.find( + ([cmd]) => typeof cmd === 'string' && cmd.includes('@codeman-mode') + ); + expect(modePersistCall?.[0]).toContain('"shell"'); + } finally { + nonTestManager.destroy(); + } + }); + + it('restores an untracked local session with its live provider and working directory', async () => { + mockedExecSync.mockImplementation((cmd: string) => { + if (typeof cmd === 'string' && cmd.includes('which tmux')) { + return '/usr/bin/tmux\n'; + } + if (typeof cmd === 'string' && cmd.includes('list-panes -a')) { + return 'codeman-deadbeef|4242||/tmp/restored project\n'; + } + if (typeof cmd === 'string' && cmd.includes('ps -p 4242')) { + return 'node /usr/bin/codex --dangerously-bypass-approvals-and-sandbox\n'; + } + return ''; + }); + const NonTestTmuxManager = await importWithTmuxCommandsEnabled(); + const nonTestManager = new NonTestTmuxManager(); + + try { + const result = await nonTestManager.reconcileSessions(); + + expect(result.discovered).toEqual(['restored-deadbeef']); + expect(nonTestManager.getSession('restored-deadbeef')).toMatchObject({ + pid: 4242, + workingDir: '/tmp/restored project', + mode: 'codex', + }); + const listCall = mockedExecSync.mock.calls.find( + ([cmd]) => typeof cmd === 'string' && cmd.includes('list-panes -a') + ); + expect(listCall?.[0]).toContain('#{@codeman-mode}'); + expect(listCall?.[0]).toContain('#{pane_current_path}'); + const modePersistCall = mockedExecSync.mock.calls.find( + ([cmd]) => typeof cmd === 'string' && cmd.includes(' set-option ') && cmd.includes('@codeman-mode') + ); + expect(modePersistCall?.[0]).toContain('"codex"'); + } finally { + nonTestManager.destroy(); + } + }); + + it('repairs a restored legacy session whose saved provider no longer matches its live process', async () => { + mockedExecSync.mockImplementation((cmd: string) => { + if (typeof cmd === 'string' && cmd.includes('which tmux')) { + return '/usr/bin/tmux\n'; + } + if (typeof cmd === 'string' && cmd.includes('list-panes -a')) { + return 'codeman-feedface|4242||/tmp/recovered-project\n'; + } + if (typeof cmd === 'string' && cmd.includes('ps -p 4242')) { + return 'node /usr/bin/codex --dangerously-bypass-approvals-and-sandbox\n'; + } + return ''; + }); + const NonTestTmuxManager = await importWithTmuxCommandsEnabled(); + const nonTestManager = new NonTestTmuxManager(); + nonTestManager.registerSession({ + sessionId: 'restored-feedface', + muxName: 'codeman-feedface', + pid: 1, + createdAt: Date.now(), + workingDir: '/stale/server/cwd', + mode: 'claude', + attached: false, + }); + + try { + const result = await nonTestManager.reconcileSessions(); + + expect(result.alive).toContain('restored-feedface'); + expect(nonTestManager.getSession('restored-feedface')).toMatchObject({ + mode: 'codex', + workingDir: '/tmp/recovered-project', + }); + } finally { + nonTestManager.destroy(); + } + }); + + it('repairs only placeholder local paths and preserves remote workspace metadata', async () => { + mockedExecSync.mockImplementation((cmd: string) => { + if (typeof cmd === 'string' && cmd.includes('which tmux')) { + return '/usr/bin/tmux\n'; + } + if (typeof cmd === 'string' && cmd.includes('list-panes -a')) { + return ['codeman-a0ca1000|4242|claude|/tmp/recovered-project', 'codeman-b0b0e000|4343|claude|/tmp'].join( + '\n' + ); + } + return ''; + }); + const NonTestTmuxManager = await importWithTmuxCommandsEnabled(); + const nonTestManager = new NonTestTmuxManager(); + nonTestManager.registerSession({ + sessionId: 'tracked-local', + muxName: 'codeman-a0ca1000', + pid: 1, + createdAt: Date.now(), + workingDir: process.cwd(), + mode: 'claude', + attached: false, + }); + nonTestManager.registerSession({ + sessionId: 'tracked-remote', + muxName: 'codeman-b0b0e000', + pid: 2, + createdAt: Date.now(), + workingDir: '/srv/remote-project', + mode: 'claude', + attached: false, + remote: { + hostId: 'remote', + label: 'Remote', + host: 'example.test', + username: 'user', + remotePath: '/srv/remote-project', + }, + }); + + try { + const result = await nonTestManager.reconcileSessions(); + + expect(result.alive).toEqual(expect.arrayContaining(['tracked-local', 'tracked-remote'])); + expect(nonTestManager.getSession('tracked-local')?.workingDir).toBe('/tmp/recovered-project'); + expect(nonTestManager.getSession('tracked-remote')?.workingDir).toBe('/srv/remote-project'); } finally { nonTestManager.destroy(); } @@ -744,3 +882,58 @@ describe('parsePaneList', () => { expect(result.get('codeman-aaaa')).toBe(1234); }); }); + +describe('parsePaneDiscoveryList', () => { + it('parses durable session mode metadata without splitting path spaces', () => { + const result = parsePaneDiscoveryList( + 'codeman-aaaa|1234|codex|/home/user/project one\nclaudeman-bbbb|5678|claude|/tmp/project-two' + ); + + expect(result.get('codeman-aaaa')).toEqual({ + pid: 1234, + mode: 'codex', + workingDir: '/home/user/project one', + }); + expect(result.get('claudeman-bbbb')).toEqual({ + pid: 5678, + mode: 'claude', + workingDir: '/tmp/project-two', + }); + }); + + it('supports legacy path-bearing and pid-only records', () => { + const result = parsePaneDiscoveryList('codeman-aaaa|1234|/home/user/project one\ncodeman-bbbb|5678'); + + expect(result.get('codeman-aaaa')).toEqual({ + pid: 1234, + workingDir: '/home/user/project one', + }); + expect(result.get('codeman-bbbb')).toEqual({ + pid: 5678, + workingDir: undefined, + }); + }); + + it('ignores unknown mode metadata while retaining the live path', () => { + expect(parsePaneDiscoveryList('codeman-aaaa|1234|unknown|/tmp/project').get('codeman-aaaa')).toEqual({ + pid: 1234, + workingDir: '/tmp/project', + }); + }); +}); + +describe('inferSessionModeFromProcessCommand', () => { + it.each([ + ['node /usr/bin/codex --dangerously-bypass-approvals-and-sandbox', 'codex'], + ['claude --dangerously-skip-permissions', 'claude'], + ['/usr/local/bin/opencode', 'opencode'], + ['node /usr/local/bin/gemini --yolo', 'gemini'], + ['/bin/zsh', 'shell'], + ])('infers %s as %s', (command, expectedMode) => { + expect(inferSessionModeFromProcessCommand(command)).toBe(expectedMode); + }); + + it('does not guess from an unrelated process command', () => { + expect(inferSessionModeFromProcessCommand('node server.js')).toBeUndefined(); + }); +});