From 282704bbeef9f3712637ad418656b9d1db77a9d0 Mon Sep 17 00:00:00 2001 From: Teigen Date: Fri, 22 May 2026 14:35:32 +0800 Subject: [PATCH 1/2] fix: isolate codeman tmux sessions --- src/mux-interface.ts | 2 + src/tmux-manager.ts | 259 +++++++++++++++++++++++++------------- test/tmux-manager.test.ts | 17 ++- 3 files changed, 191 insertions(+), 87 deletions(-) diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 15d68224..700d8f19 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -24,6 +24,8 @@ export interface MuxSession { sessionId: string; /** Multiplexer session name (e.g., "codeman-abc12345") */ muxName: string; + /** Optional tmux socket name. Undefined means the legacy/default tmux server. */ + tmuxSocket?: string; /** Process PID */ pid: number; /** Timestamp when created */ diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 880dbf46..33a2d827 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -71,6 +71,9 @@ const GRACEFUL_SHUTDOWN_WAIT_MS = 100; /** Default stats collection interval (2 seconds) */ const DEFAULT_STATS_INTERVAL_MS = 2000; +/** Claude Code native macOS recommendation for avoiding low nofile startup failures. */ +export const CLAUDE_CODE_NOFILE_LIMIT = 2147483646; + /** * SAFETY: Test mode detection. * When running under vitest (VITEST env var is set automatically), @@ -98,6 +101,12 @@ const LEGACY_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/; /** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */ const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/; +/** Dedicated tmux socket for new Codeman-owned sessions. */ +const DEFAULT_CODEMAN_TMUX_SOCKET = 'codeman'; + +/** Regex to validate tmux socket names passed to `tmux -L`. */ +const SAFE_TMUX_SOCKET_PATTERN = /^[a-zA-Z0-9_.-]+$/; + /** * Separator used in `tmux list-panes -F` output between session name and pid. * @@ -114,6 +123,19 @@ 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}`; +/** + * 构建 pane 启动前的 nofile 修复命令。 + * + * macOS launchd/tmux 组合有时会让 pane 继承 256 的 soft nofile; + * 新版 Claude Code 会在这种环境下直接退出。这里避免使用 $变量 + * 或命令替换,因为 fullCmd 目前经由双引号 bash -c 传递,外层 + * shell 会提前展开它们。 + */ +export function buildNofileLimitCommand(targetLimit = CLAUDE_CODE_NOFILE_LIMIT): string { + const safeLimit = Number.isSafeInteger(targetLimit) && targetLimit > 0 ? targetLimit : CLAUDE_CODE_NOFILE_LIMIT; + return `ulimit -Sn ${safeLimit} 2>/dev/null || ulimit -n ${safeLimit} 2>/dev/null || true`; +} + /** * Parse the output of `tmux list-panes -a -F '#{session_name}|#{pane_pid}'` * into a Map of session-name → pane pid. Exported for unit testing. @@ -161,6 +183,24 @@ function isValidPath(path: string): boolean { return SAFE_PATH_PATTERN.test(path); } +function resolveConfiguredTmuxSocket(): string | undefined { + const raw = process.env.CODEMAN_TMUX_SOCKET ?? DEFAULT_CODEMAN_TMUX_SOCKET; + if (!raw) return undefined; + if (!SAFE_TMUX_SOCKET_PATTERN.test(raw)) { + console.warn(`[TmuxManager] Ignoring invalid CODEMAN_TMUX_SOCKET: ${JSON.stringify(raw)}`); + return DEFAULT_CODEMAN_TMUX_SOCKET; + } + return raw; +} + +function tmuxCommand(socket?: string): string { + return socket ? `tmux -L ${shellescape(socket)}` : 'tmux'; +} + +function tmuxSocketKey(socket?: string): string { + return socket || ''; +} + /** * Build Claude CLI permission flags for the tmux command string. * Validates allowedTools to prevent command injection. @@ -248,7 +288,7 @@ function buildSpawnCommand(options: { * Set sensitive environment variables on a tmux session via setenv. * These are inherited by panes but not visible in ps output or tmux history. */ -function setOpenCodeEnvVars(muxName: string): void { +function setOpenCodeEnvVars(muxName: string, socket?: string): void { const sensitiveVars = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY']; for (const key of sensitiveVars) { const val = process.env[key]; @@ -256,7 +296,7 @@ function setOpenCodeEnvVars(muxName: string): void { // Shell-escape: wrap in single quotes, escape any inner single quotes const escaped = val.replace(/'/g, "'\\''"); try { - execSync(`tmux setenv -t '${muxName}' ${key} '${escaped}'`, { + execSync(`${tmuxCommand(socket)} setenv -t '${muxName}' ${key} '${escaped}'`, { encoding: 'utf8', timeout: EXEC_TIMEOUT_MS, stdio: ['pipe', 'pipe', 'pipe'], @@ -272,7 +312,7 @@ function setOpenCodeEnvVars(muxName: string): void { * Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv. * Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON. */ -function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): void { +function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig, socket?: string): void { if (!config) return; let jsonContent: string | undefined; @@ -303,7 +343,7 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi if (jsonContent) { const escaped = jsonContent.replace(/'/g, "'\\''"); try { - execSync(`tmux setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, { + execSync(`${tmuxCommand(socket)} setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, { encoding: 'utf8', timeout: EXEC_TIMEOUT_MS, stdio: ['pipe', 'pipe', 'pipe'], @@ -336,6 +376,7 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi export class TmuxManager extends EventEmitter implements TerminalMultiplexer { readonly backend = 'tmux' as const; private sessions: Map = new Map(); + private readonly tmuxSocket = resolveConfiguredTmuxSocket(); private statsInterval: NodeJS.Timeout | null = null; private mouseSyncInterval: NodeJS.Timeout | null = null; /** Track last-known pane count per session to avoid unnecessary tmux set-option calls */ @@ -351,6 +392,21 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } } + private tmux(socket = this.tmuxSocket): string { + return tmuxCommand(socket); + } + + private getSocketForMuxName(muxName: string): string | undefined { + for (const session of this.sessions.values()) { + if (session.muxName === muxName) return session.tmuxSocket; + } + return this.tmuxSocket; + } + + private tmuxForMuxName(muxName: string): string { + return this.tmux(this.getSocketForMuxName(muxName)); + } + // Load saved sessions from disk (NEVER called in test mode) private loadSessions(): void { if (IS_TEST_MODE) return; @@ -428,7 +484,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * Key validation is strict (`/^[A-Z_][A-Z0-9_]*$/`) as defense-in-depth against * shell-metachar injection even if upstream schema check is bypassed. */ - private applyEnvOverrides(muxName: string, envOverrides?: Record): void { + private applyEnvOverrides(muxName: string, envOverrides?: Record, socket?: string): void { if (!envOverrides) return; const VALID_KEY = /^[A-Z_][A-Z0-9_]*$/; for (const [key, value] of Object.entries(envOverrides)) { @@ -438,7 +494,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { continue; } try { - execSync(`tmux setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, { + execSync(`${this.tmux(socket)} setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, { timeout: EXEC_TIMEOUT_MS, stdio: ['pipe', 'pipe', 'pipe'], }); @@ -470,9 +526,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * Sets sensitive API keys and config content via tmux setenv * (not visible in ps output or tmux history, inherited by panes). */ - private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void { - setOpenCodeEnvVars(muxName); - setOpenCodeConfigContent(muxName, openCodeConfig); + private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig, socket?: string): void { + setOpenCodeEnvVars(muxName, socket); + setOpenCodeConfigContent(muxName, openCodeConfig, socket); } /** @@ -494,6 +550,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { envOverrides, } = options; const muxName = `codeman-${sessionId.slice(0, 8)}`; + const socket = this.tmuxSocket; if (!isValidMuxName(muxName)) { throw new Error('Invalid session name: contains unsafe characters'); @@ -507,6 +564,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const session: MuxSession = { sessionId, muxName, + tmuxSocket: socket, pid: 99999, createdAt: Date.now(), workingDir, @@ -545,7 +603,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { try { // Build the full command to run inside tmux - const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`; + const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`; // Create tmux session in three steps to handle cold-start (no server running) // and avoid the race where the command exits before remain-on-exit is set: @@ -556,7 +614,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // (Production uses systemd which has a clean env, but dev/test may be nested.) const cleanEnv = { ...process.env }; delete cleanEnv.TMUX; - execSync(`tmux new-session -ds "${muxName}" -c "${workingDir}"`, { + execSync(`${this.tmux(socket)} new-session -ds "${muxName}" -c "${workingDir}"`, { cwd: workingDir, timeout: EXEC_TIMEOUT_MS, stdio: 'ignore', @@ -565,7 +623,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Set remain-on-exit now that the server is running — must be before respawn-pane try { - execSync(`tmux set-option -t "${muxName}" remain-on-exit on`, { + execSync(`${this.tmux(socket)} set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS, stdio: 'ignore', }); @@ -576,15 +634,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // For OpenCode: set sensitive env vars and config via tmux setenv // (not visible in ps output or tmux history, inherited by panes) if (mode === 'opencode') { - this._configureOpenCode(muxName, openCodeConfig); + this._configureOpenCode(muxName, openCodeConfig, socket); } // Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv // so secret values stay off the bash command line. Must run before respawn-pane. - this.applyEnvOverrides(muxName, envOverrides); + this.applyEnvOverrides(muxName, envOverrides, socket); // Replace the shell with the actual command (no echo in terminal) - execSync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, { + execSync(`${this.tmux(socket)} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, { timeout: EXEC_TIMEOUT_MS, stdio: 'ignore', }); @@ -598,20 +656,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // It gets enabled dynamically when panes are split (agent teams). const configPromises: Promise[] = [ // Disable tmux status bar — Codeman's web UI provides session info - execAsync(`tmux set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS }) + execAsync(`${this.tmux(socket)} set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS }) .then(() => {}) .catch(() => { /* Non-critical — session still works with status bar */ }), // Override global remain-on-exit with session-level setting - execAsync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS }) + execAsync(`${this.tmux(socket)} set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS }) .then(() => {}) .catch(() => { /* Already set globally as fallback */ }), // Raise tmux scrollback from its 2000-line default so re-attach preserves // more context. Matches the xterm-side default in constants.js. - execAsync(`tmux set-option -t "${muxName}" history-limit 50000`, { timeout: EXEC_TIMEOUT_MS }) + execAsync(`${this.tmux(socket)} set-option -t "${muxName}" history-limit 50000`, { timeout: EXEC_TIMEOUT_MS }) .then(() => {}) .catch(() => { /* Non-critical — falls back to tmux default */ @@ -621,7 +679,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Enable 24-bit true color passthrough — server-wide, set once per lifetime if (!this.trueColorConfigured) { configPromises.push( - execAsync(`tmux set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS }) + execAsync(`${this.tmux(socket)} set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS }) .then(() => { this.trueColorConfigured = true; }) @@ -636,10 +694,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { void Promise.all(configPromises); // Get the PID of the pane process (retry for tmux server cold-start) - let pid = this.getPanePid(muxName); + let pid = this.getPanePid(muxName, socket); for (let i = 0; !pid && i < GET_PID_MAX_RETRIES; i++) { await new Promise((resolve) => setTimeout(resolve, GET_PID_RETRY_MS)); - pid = this.getPanePid(muxName); + pid = this.getPanePid(muxName, socket); } if (!pid) { throw new Error('Failed to get tmux pane PID'); @@ -648,6 +706,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const session: MuxSession = { sessionId, muxName, + tmuxSocket: socket, pid, createdAt: Date.now(), workingDir, @@ -669,7 +728,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { /** * Get the PID of the process running in the tmux pane. */ - private getPanePid(muxName: string): number | null { + private getPanePid(muxName: string, socket?: string): number | null { if (IS_TEST_MODE) return 99999; if (!isValidMuxName(muxName)) { @@ -678,7 +737,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } try { - const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_pid}'`, { + const output = execSync(`${this.tmux(socket)} display-message -t "${muxName}" -p '#{pane_pid}'`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }).trim(); @@ -703,8 +762,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { isPaneDead(muxName: string): boolean { if (IS_TEST_MODE) return false; if (!isValidMuxName(muxName)) return false; + const socket = this.getSocketForMuxName(muxName); try { - const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_dead}'`, { + const output = execSync(`${this.tmux(socket)} display-message -t "${muxName}" -p '#{pane_dead}'`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }).trim(); @@ -735,6 +795,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const session = this.sessions.get(sessionId); if (!session) return null; const muxName = session.muxName; + const socket = session.tmuxSocket; if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null; @@ -754,23 +815,23 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { }); const config = niceConfig || DEFAULT_NICE_CONFIG; const cmd = wrapWithNice(baseCmd, config); - const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`; + const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`; try { // For OpenCode: set sensitive env vars via tmux setenv before respawn if (mode === 'opencode') { - this._configureOpenCode(muxName, openCodeConfig); + this._configureOpenCode(muxName, openCodeConfig, socket); } // Re-apply user env overrides before respawn so the new shell inherits them. - this.applyEnvOverrides(muxName, envOverrides); + this.applyEnvOverrides(muxName, envOverrides, socket); - await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, { + await execAsync(`${this.tmux(socket)} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, { timeout: EXEC_TIMEOUT_MS, }); // Wait for the respawned process to start await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS)); - const pid = this.getPanePid(muxName); + const pid = this.getPanePid(muxName, socket); if (pid) session.pid = pid; return pid; } catch (err) { @@ -783,7 +844,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { if (IS_TEST_MODE) return false; try { - execSync(`tmux has-session -t "${muxName}" 2>/dev/null`, { + execSync(`${this.tmuxForMuxName(muxName)} has-session -t "${muxName}" 2>/dev/null`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }); @@ -872,7 +933,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } // Get current PID (may have changed) - const currentPid = this.getPanePid(session.muxName) || session.pid; + const currentPid = this.getPanePid(session.muxName, session.tmuxSocket) || session.pid; console.log(`[TmuxManager] Killing session ${session.muxName} (PID ${currentPid})`); @@ -923,7 +984,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Strategy 3: Kill tmux session by name try { - execSync(`tmux kill-session -t "${session.muxName}" 2>/dev/null`, { + execSync(`${this.tmux(session.tmuxSocket)} kill-session -t "${session.muxName}" 2>/dev/null`, { timeout: EXEC_TIMEOUT_MS, }); } catch { @@ -988,20 +1049,31 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const dead: string[] = []; const discovered: string[] = []; - // Batch: single tmux call to get all session names + pane PIDs (replaces N per-session subprocess calls) - let activeSessions = new Map(); - try { - const output = execSync(`tmux list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - }).trim(); - activeSessions = parsePaneList(output); - } catch (err) { - console.error('[TmuxManager] Failed to list tmux panes:', err); + // Batch per socket: default server for legacy sessions, Codeman socket for new sessions. + const sockets = new Map(); + sockets.set(tmuxSocketKey(undefined), undefined); + if (this.tmuxSocket) sockets.set(tmuxSocketKey(this.tmuxSocket), this.tmuxSocket); + for (const session of this.sessions.values()) { + sockets.set(tmuxSocketKey(session.tmuxSocket), session.tmuxSocket); + } + + const activeBySocket = new Map>(); + for (const [key, socket] of sockets) { + try { + const output = execSync(`${this.tmux(socket)} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }).trim(); + activeBySocket.set(key, parsePaneList(output)); + } catch (err) { + console.error(`[TmuxManager] Failed to list tmux panes for socket ${socket || ''}:`, err); + activeBySocket.set(key, new Map()); + } } // Check known sessions against the batch result (O(1) map lookup instead of subprocess per session) for (const [sessionId, session] of this.sessions) { + const activeSessions = activeBySocket.get(tmuxSocketKey(session.tmuxSocket)) ?? new Map(); const pid = activeSessions.get(session.muxName); if (pid !== undefined) { alive.push(sessionId); @@ -1018,28 +1090,32 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Discover unknown codeman/claudeman sessions from the same batch result const knownMuxNames = new Set(); for (const session of this.sessions.values()) { - knownMuxNames.add(session.muxName); - } - - for (const [sessionName, pid] of activeSessions) { - if (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-')) continue; - if (knownMuxNames.has(sessionName)) continue; - - const fragment = sessionName.replace(/^(?:codeman|claudeman)-/, ''); - const sessionId = `restored-${fragment}`; - const session: MuxSession = { - sessionId, - muxName: sessionName, - pid, - createdAt: Date.now(), - workingDir: process.cwd(), - mode: 'claude', - attached: false, - name: `Restored: ${sessionName}`, - }; - this.sessions.set(sessionId, session); - discovered.push(sessionId); - console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`); + knownMuxNames.add(`${tmuxSocketKey(session.tmuxSocket)}:${session.muxName}`); + } + + for (const [key, activeSessions] of activeBySocket) { + const socket = sockets.get(key); + for (const [sessionName, pid] of activeSessions) { + if (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-')) continue; + if (knownMuxNames.has(`${key}:${sessionName}`)) continue; + + const fragment = sessionName.replace(/^(?:codeman|claudeman)-/, ''); + const sessionId = `restored-${fragment}`; + const session: MuxSession = { + sessionId, + muxName: sessionName, + tmuxSocket: socket, + pid, + createdAt: Date.now(), + workingDir: process.cwd(), + mode: 'claude', + attached: false, + name: `Restored: ${sessionName}`, + }; + this.sessions.set(sessionId, session); + discovered.push(sessionId); + console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`); + } } if (dead.length > 0 || discovered.length > 0) { @@ -1345,21 +1421,27 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Ink (Claude CLI's terminal framework) needs them split — sending both in a // single tmux invocation (via \;) causes Ink to interpret Enter as a newline // character in the input buffer rather than as form submission. - await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, { - timeout: EXEC_TIMEOUT_MS, - }); + await execAsync( + `${this.tmux(session.tmuxSocket)} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, + { + timeout: EXEC_TIMEOUT_MS, + } + ); await new Promise((resolve) => setTimeout(resolve, 50)); - await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, { + await execAsync(`${this.tmux(session.tmuxSocket)} send-keys -t "${session.muxName}" Enter`, { timeout: EXEC_TIMEOUT_MS, }); } else if (textPart) { // Text only, no Enter - await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, { - timeout: EXEC_TIMEOUT_MS, - }); + await execAsync( + `${this.tmux(session.tmuxSocket)} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, + { + timeout: EXEC_TIMEOUT_MS, + } + ); } else if (hasCarriageReturn) { // Enter only - await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, { + await execAsync(`${this.tmux(session.tmuxSocket)} send-keys -t "${session.muxName}" Enter`, { timeout: EXEC_TIMEOUT_MS, }); } @@ -1386,7 +1468,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } try { - execSync(`tmux set-option -t "${muxName}" mouse on`, { + execSync(`${this.tmuxForMuxName(muxName)} set-option -t "${muxName}" mouse on`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }); @@ -1410,7 +1492,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } try { - execSync(`tmux set-option -t "${muxName}" mouse off`, { + execSync(`${this.tmuxForMuxName(muxName)} set-option -t "${muxName}" mouse off`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }); @@ -1450,7 +1532,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { try { const output = execSync( - `tmux list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`, + `${this.tmuxForMuxName(muxName)} list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } ).trim(); @@ -1489,27 +1571,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Build target: sessionName.paneId (e.g., "codeman-abc12345.%1") const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; + const tmux = this.tmuxForMuxName(muxName); try { const hasCarriageReturn = input.includes('\r'); const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd(); if (textPart && hasCarriageReturn) { - execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, { + execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }); - execSync(`tmux send-keys -t ${shellescape(target)} Enter`, { + execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }); } else if (textPart) { - execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, { + execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }); } else if (hasCarriageReturn) { - execSync(`tmux send-keys -t ${shellescape(target)} Enter`, { + execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }); @@ -1540,7 +1623,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; try { - return execSync(`tmux capture-pane -p -e -t ${shellescape(target)} -S -5000`, { + return execSync(`${this.tmuxForMuxName(muxName)} capture-pane -p -e -t ${shellescape(target)} -S -5000`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }); @@ -1572,10 +1655,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; try { - execSync(`tmux pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - }); + execSync( + `${this.tmuxForMuxName(muxName)} pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, + { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + } + ); return true; } catch (err) { console.error('[TmuxManager] Failed to start pipe-pane:', err); @@ -1600,7 +1686,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; try { - execSync(`tmux pipe-pane -t ${shellescape(target)}`, { + execSync(`${this.tmuxForMuxName(muxName)} pipe-pane -t ${shellescape(target)}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }); @@ -1616,7 +1702,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } getAttachArgs(muxName: string): string[] { - return ['attach-session', '-t', muxName]; + const socket = this.getSocketForMuxName(muxName); + return socket ? ['-L', socket, 'attach-session', '-t', muxName] : ['attach-session', '-t', muxName]; } isAvailable(): boolean { diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts index 105a1a09..75e507a6 100644 --- a/test/tmux-manager.test.ts +++ b/test/tmux-manager.test.ts @@ -77,7 +77,22 @@ describe('TmuxManager (unit)', () => { }); describe('getAttachArgs', () => { - it('should return attach-session args', () => { + it('should attach unknown/new sessions through the isolated Codeman socket', () => { + const args = manager.getAttachArgs('codeman-abc12345'); + expect(args).toEqual(['-L', 'codeman', 'attach-session', '-t', 'codeman-abc12345']); + }); + + it('should keep legacy registered sessions on the default tmux socket', () => { + manager.registerSession({ + sessionId: 'legacy-session', + muxName: 'codeman-abc12345', + pid: 12345, + createdAt: Date.now(), + workingDir: '/tmp', + mode: 'claude', + attached: false, + }); + const args = manager.getAttachArgs('codeman-abc12345'); expect(args).toEqual(['attach-session', '-t', 'codeman-abc12345']); }); From 03396d35b0d29f652a5f520236037490979671ae Mon Sep 17 00:00:00 2001 From: Teigen Date: Mon, 25 May 2026 19:07:51 +0800 Subject: [PATCH 2/2] fix(tmux): unify all sessions onto a single dedicated socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the per-session `tmuxSocket` field that recorded which tmux server each session lived on (default vs the `codeman` socket). That field was a persisted cache of physical reality and could drift — causing live sessions to be wrongly marked dead ("tab shows no session found") and spawning duplicate "Restored:" tabs. All Codeman sessions now live on one process-wide socket (`tmux -L codeman`, overridable via CODEMAN_TMUX_SOCKET), exposed via TmuxManager.muxSocket on the TerminalMultiplexer interface. reconcileSessions() collapses from a multi-socket scan (locate / re-pin / cross-socket dedup) to a single `list-panes` query. loadSessions() strips the obsolete field from on-disk records so it stops being written back. Also fix two sibling bare-`tmux` call sites the unification would otherwise leave broken (same #80 regression class — bare tmux hits the user's default server and never finds a session on the codeman socket): - session.ts queryTmuxWindowSize(): add `-L ` (was silently falling back to 120x40 on re-attach, losing scrollback) - session-routes.ts send-key (Shift+Enter / Ctrl+Enter newline): route through ctx.mux.muxSocket SSH chooser scripts (tmux-manager.sh, tmux-chooser.sh) route every tmux call through `tmux -L $CODEMAN_TMUX_SOCKET`, matching the TS default. --- scripts/tmux-chooser.sh | 13 +- scripts/tmux-manager.sh | 25 ++- src/mux-interface.ts | 5 +- src/session.ts | 19 +- src/tmux-manager.ts | 279 ++++++++++++++-------------- src/web/routes/session-routes.ts | 15 +- test/mocks/mock-route-context.ts | 1 + test/routes/session-routes.test.ts | 46 +++++ test/tmux-manager.test.ts | 8 +- test/tmux-window-size-query.test.ts | 49 ++--- 10 files changed, 275 insertions(+), 185 deletions(-) diff --git a/scripts/tmux-chooser.sh b/scripts/tmux-chooser.sh index 0aef49d5..a6d94b9f 100755 --- a/scripts/tmux-chooser.sh +++ b/scripts/tmux-chooser.sh @@ -32,6 +32,13 @@ set -e CODEMAN_STATE="$HOME/.codeman/state.json" CODEMAN_SESSIONS="$HOME/.codeman/mux-sessions.json" +# Dedicated tmux socket all Codeman sessions live on. MUST match +# DEFAULT_CODEMAN_TMUX_SOCKET / CODEMAN_TMUX_SOCKET in src/tmux-manager.ts — +# otherwise list-sessions would enumerate the user's default tmux server +# (missing the real Codeman sessions, surfacing unrelated ones). +CODEMAN_TMUX_SOCKET="${CODEMAN_TMUX_SOCKET:-codeman}" +TMUX_CMD=(tmux -L "$CODEMAN_TMUX_SOCKET") + # iPhone 17 Pro portrait width (conservative) MAX_WIDTH=44 @@ -286,7 +293,7 @@ parse_sessions() { # Get PID from tmux local pid - pid=$(tmux display-message -t "$session_name" -p '#{pane_pid}' 2>/dev/null || echo "0") + pid=$("${TMUX_CMD[@]}" display-message -t "$session_name" -p '#{pane_pid}' 2>/dev/null || echo "0") SESSION_PIDS+=("$pid") MUX_NAMES+=("$session_name") @@ -314,7 +321,7 @@ parse_sessions() { fi i=$((i + 1)) - done < <(tmux list-sessions 2>/dev/null || true) + done < <("${TMUX_CMD[@]}" list-sessions 2>/dev/null || true) } # ============================================================================ @@ -462,7 +469,7 @@ attach_session() { echo -e "${D}(Ctrl+B D to detach)${R}" sleep 0.3 - tmux attach-session -t "$mux_name" + "${TMUX_CMD[@]}" attach-session -t "$mux_name" return 0 } diff --git a/scripts/tmux-manager.sh b/scripts/tmux-manager.sh index 01de1847..169535ef 100755 --- a/scripts/tmux-manager.sh +++ b/scripts/tmux-manager.sh @@ -20,6 +20,13 @@ REVERSE='\033[7m' # Use the same path as codeman (src/tmux-manager.ts) SESSIONS_FILE="${HOME}/.codeman/mux-sessions.json" +# Dedicated tmux socket all Codeman sessions live on. MUST match +# DEFAULT_CODEMAN_TMUX_SOCKET / CODEMAN_TMUX_SOCKET in src/tmux-manager.ts — +# otherwise this script would talk to the user's default tmux server and never +# see (or could mis-target) Codeman's sessions. +CODEMAN_TMUX_SOCKET="${CODEMAN_TMUX_SOCKET:-codeman}" +TMUX_CMD=(tmux -L "$CODEMAN_TMUX_SOCKET") + # Cached data CACHED_JSON="" @@ -92,7 +99,7 @@ declare -A ALIVE_CACHE check_alive() { local mux_name=$1 if [[ -z "${ALIVE_CACHE[$mux_name]+x}" ]]; then - if tmux has-session -t "$mux_name" 2>/dev/null; then + if "${TMUX_CMD[@]}" has-session -t "$mux_name" 2>/dev/null; then ALIVE_CACHE[$mux_name]=1 else ALIVE_CACHE[$mux_name]=0 @@ -111,8 +118,10 @@ kill_session() { local mux_name=$(get_session_field $idx "muxName") local pid=$(get_session_field $idx "pid") - # SAFETY: Never kill own tmux session - local current_session=$(tmux display-message -p '#{session_name}' 2>/dev/null || echo "") + # SAFETY: Never kill own tmux session. Queried on the Codeman socket; if run + # from a session on a different socket this returns empty (no match), which + # is fine — you can't be "inside" a Codeman-socket session you didn't attach to. + local current_session=$("${TMUX_CMD[@]}" display-message -p '#{session_name}' 2>/dev/null || echo "") if [[ -n "$current_session" && "$mux_name" == "$current_session" ]]; then echo -e "${RED}BLOCKED: Cannot kill own tmux session: $mux_name${NC}" return 1 @@ -120,7 +129,7 @@ kill_session() { pkill -TERM -P $pid 2>/dev/null kill -TERM -$pid 2>/dev/null - tmux kill-session -t "$mux_name" 2>/dev/null + "${TMUX_CMD[@]}" kill-session -t "$mux_name" 2>/dev/null kill -KILL $pid 2>/dev/null # Remove from JSON @@ -345,7 +354,7 @@ interactive_menu() { clear echo -e "${CYAN}Attaching... (Ctrl+B D to detach)${NC}" sleep 0.3 - tmux attach-session -t "$mux_name" + "${TMUX_CMD[@]}" attach-session -t "$mux_name" tput civis need_full_redraw=1 force_refresh @@ -474,7 +483,7 @@ main() { [[ -z "${2:-}" ]] && { echo "Usage: $0 attach "; exit 1; } force_refresh local mux_name=$(get_session_field $(($2-1)) "muxName") - check_alive "$mux_name" && tmux attach-session -t "$mux_name" || echo "Session dead or not found" + check_alive "$mux_name" && "${TMUX_CMD[@]}" attach-session -t "$mux_name" || echo "Session dead or not found" ;; kill) [[ -z "${2:-}" ]] && { echo "Usage: $0 kill "; exit 1; } @@ -492,8 +501,8 @@ main() { ;; kill-all) force_refresh - # SAFETY: Never kill own tmux session - local current_session=$(tmux display-message -p '#{session_name}' 2>/dev/null || echo "") + # SAFETY: Never kill own tmux session (queried on the Codeman socket) + local current_session=$("${TMUX_CMD[@]}" display-message -p '#{session_name}' 2>/dev/null || echo "") local killed=0 for ((i=CACHED_COUNT-1; i>=0; i--)); do local mux_name=$(get_session_field $i "muxName") diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 700d8f19..f70e4dbc 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -24,8 +24,6 @@ export interface MuxSession { sessionId: string; /** Multiplexer session name (e.g., "codeman-abc12345") */ muxName: string; - /** Optional tmux socket name. Undefined means the legacy/default tmux server. */ - tmuxSocket?: string; /** Process PID */ pid: number; /** Timestamp when created */ @@ -100,6 +98,9 @@ export interface TerminalMultiplexer extends EventEmitter { /** Which backend this instance uses */ readonly backend: 'tmux'; + /** The dedicated tmux socket name all sessions live on (e.g. "codeman"). */ + readonly muxSocket: string; + // ========== Lifecycle ========== /** diff --git a/src/session.ts b/src/session.ts index a483e054..ddb343c7 100644 --- a/src/session.ts +++ b/src/session.ts @@ -133,15 +133,22 @@ const TMUX_DISPLAY_TIMEOUT_MS = 2000; * (tmux dead, muxName unknown, malformed output) — caller never has to * differentiate "tmux unreachable" from "size 120x40". * + * `socket` MUST be the same dedicated socket the session lives on (`mux.muxSocket`); + * querying the default server would never find the session and silently fall back. + * * Argv form (execFileSync, not execSync) keeps `muxName` out of any shell so * a hostile session name can't inject options. */ -export function queryTmuxWindowSize(muxName: string): { cols: number; rows: number } { +export function queryTmuxWindowSize(muxName: string, socket: string): { cols: number; rows: number } { try { - const sizeStr = execFileSync('tmux', ['display', '-t', muxName, '-p', '#{window_width} #{window_height}'], { - timeout: TMUX_DISPLAY_TIMEOUT_MS, - encoding: 'utf8', - }).trim(); + const sizeStr = execFileSync( + 'tmux', + ['-L', socket, 'display', '-t', muxName, '-p', '#{window_width} #{window_height}'], + { + timeout: TMUX_DISPLAY_TIMEOUT_MS, + encoding: 'utf8', + } + ).trim(); const [w, h] = sizeStr.split(' ').map(Number); if (w > 0 && h > 0) { return { cols: w, rows: h }; @@ -978,7 +985,7 @@ export class Session extends EventEmitter { // Attach to the mux session via PTY // Query existing tmux window size so re-attach matches (avoids flicker from 120x40 default) - const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName); + const { cols: ptyCols, rows: ptyRows } = queryTmuxWindowSize(this._muxSession!.muxName, mux.muxSocket); try { this.ptyProcess = pty.spawn(mux.getAttachCommand(), mux.getAttachArgs(this._muxSession!.muxName), { name: 'xterm-256color', diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 33a2d827..53e53234 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -183,9 +183,19 @@ function isValidPath(path: string): boolean { return SAFE_PATH_PATTERN.test(path); } -function resolveConfiguredTmuxSocket(): string | undefined { +// =========================================================================== +// Single-socket architecture: ALL Codeman sessions live on one dedicated tmux +// socket (`tmux -L codeman`), isolated from the user's default tmux server. +// The socket name is a process-wide constant (env-overridable for test/multi- +// instance isolation) — it is never stored per-session, so it cannot drift. +// =========================================================================== + +/** + * Resolve the process-wide Codeman tmux socket name. Always returns a valid + * name: `CODEMAN_TMUX_SOCKET` env override if safe, else the built-in default. + */ +function resolveConfiguredTmuxSocket(): string { const raw = process.env.CODEMAN_TMUX_SOCKET ?? DEFAULT_CODEMAN_TMUX_SOCKET; - if (!raw) return undefined; if (!SAFE_TMUX_SOCKET_PATTERN.test(raw)) { console.warn(`[TmuxManager] Ignoring invalid CODEMAN_TMUX_SOCKET: ${JSON.stringify(raw)}`); return DEFAULT_CODEMAN_TMUX_SOCKET; @@ -193,12 +203,9 @@ function resolveConfiguredTmuxSocket(): string | undefined { return raw; } -function tmuxCommand(socket?: string): string { - return socket ? `tmux -L ${shellescape(socket)}` : 'tmux'; -} - -function tmuxSocketKey(socket?: string): string { - return socket || ''; +/** Build the `tmux -L ` command prefix. Socket name is shell-escaped. */ +function tmuxCommand(socket: string): string { + return `tmux -L ${shellescape(socket)}`; } /** @@ -288,7 +295,7 @@ function buildSpawnCommand(options: { * Set sensitive environment variables on a tmux session via setenv. * These are inherited by panes but not visible in ps output or tmux history. */ -function setOpenCodeEnvVars(muxName: string, socket?: string): void { +function setOpenCodeEnvVars(tmuxCmd: string, muxName: string): void { const sensitiveVars = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY']; for (const key of sensitiveVars) { const val = process.env[key]; @@ -296,7 +303,7 @@ function setOpenCodeEnvVars(muxName: string, socket?: string): void { // Shell-escape: wrap in single quotes, escape any inner single quotes const escaped = val.replace(/'/g, "'\\''"); try { - execSync(`${tmuxCommand(socket)} setenv -t '${muxName}' ${key} '${escaped}'`, { + execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, { encoding: 'utf8', timeout: EXEC_TIMEOUT_MS, stdio: ['pipe', 'pipe', 'pipe'], @@ -312,7 +319,7 @@ function setOpenCodeEnvVars(muxName: string, socket?: string): void { * Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv. * Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON. */ -function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig, socket?: string): void { +function setOpenCodeConfigContent(tmuxCmd: string, muxName: string, config?: OpenCodeConfig): void { if (!config) return; let jsonContent: string | undefined; @@ -343,7 +350,7 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig, sock if (jsonContent) { const escaped = jsonContent.replace(/'/g, "'\\''"); try { - execSync(`${tmuxCommand(socket)} setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, { + execSync(`${tmuxCmd} setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, { encoding: 'utf8', timeout: EXEC_TIMEOUT_MS, stdio: ['pipe', 'pipe', 'pipe'], @@ -392,19 +399,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } } - private tmux(socket = this.tmuxSocket): string { - return tmuxCommand(socket); - } - - private getSocketForMuxName(muxName: string): string | undefined { - for (const session of this.sessions.values()) { - if (session.muxName === muxName) return session.tmuxSocket; - } + /** The dedicated tmux socket all Codeman sessions live on (see {@link TerminalMultiplexer.muxSocket}). */ + get muxSocket(): string { return this.tmuxSocket; } - private tmuxForMuxName(muxName: string): string { - return this.tmux(this.getSocketForMuxName(muxName)); + private tmux(): string { + return tmuxCommand(this.tmuxSocket); } // Load saved sessions from disk (NEVER called in test mode) @@ -416,8 +417,40 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const content = readFileSync(MUX_SESSIONS_FILE, 'utf-8'); const data = JSON.parse(content); if (Array.isArray(data)) { + // Dedup by muxName: one live tmux session must map to exactly one + // tracked entry. A per-session socket-tag mismatch could historically + // let the same session be tracked twice — once under its real UUID and + // once under a "restored-" placeholder — surfacing as duplicate tabs. + // Single-socket unification removed that failure mode; this pass stays + // to clean any stale duplicates already on disk. Keep the real (UUID) + // entry and drop placeholder twins. + let dropped = 0; + const keptByMuxName = new Map(); // muxName -> kept sessionId for (const session of data) { + // Strip the obsolete per-session tmuxSocket tag (now a process-wide + // constant). Left in place it would be written back by saveSessions() + // and linger on disk as a zombie field forever. + delete (session as { tmuxSocket?: unknown }).tmuxSocket; + const muxName: string | undefined = session.muxName; + const priorId = muxName ? keptByMuxName.get(muxName) : undefined; + if (priorId) { + const incomingIsPlaceholder = String(session.sessionId).startsWith('restored-'); + const priorIsPlaceholder = priorId.startsWith('restored-'); + // Drop the incoming unless it's the real twin of a placeholder we kept. + if (incomingIsPlaceholder || !priorIsPlaceholder) { + dropped++; + continue; + } + this.sessions.delete(priorId); + dropped++; + } this.sessions.set(session.sessionId, session); + if (muxName) keptByMuxName.set(muxName, session.sessionId); + } + // Persist the cleaned list so the stale duplicates don't reload. + if (dropped > 0) { + console.log(`[TmuxManager] Dropped ${dropped} duplicate mux session record(s) on load`); + this.saveSessions(); } } } @@ -484,7 +517,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * Key validation is strict (`/^[A-Z_][A-Z0-9_]*$/`) as defense-in-depth against * shell-metachar injection even if upstream schema check is bypassed. */ - private applyEnvOverrides(muxName: string, envOverrides?: Record, socket?: string): void { + private applyEnvOverrides(muxName: string, envOverrides?: Record): void { if (!envOverrides) return; const VALID_KEY = /^[A-Z_][A-Z0-9_]*$/; for (const [key, value] of Object.entries(envOverrides)) { @@ -494,7 +527,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { continue; } try { - execSync(`${this.tmux(socket)} setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, { + execSync(`${this.tmux()} setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, { timeout: EXEC_TIMEOUT_MS, stdio: ['pipe', 'pipe', 'pipe'], }); @@ -526,9 +559,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * Sets sensitive API keys and config content via tmux setenv * (not visible in ps output or tmux history, inherited by panes). */ - private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig, socket?: string): void { - setOpenCodeEnvVars(muxName, socket); - setOpenCodeConfigContent(muxName, openCodeConfig, socket); + private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void { + const tmuxCmd = this.tmux(); + setOpenCodeEnvVars(tmuxCmd, muxName); + setOpenCodeConfigContent(tmuxCmd, muxName, openCodeConfig); } /** @@ -550,7 +584,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { envOverrides, } = options; const muxName = `codeman-${sessionId.slice(0, 8)}`; - const socket = this.tmuxSocket; if (!isValidMuxName(muxName)) { throw new Error('Invalid session name: contains unsafe characters'); @@ -564,7 +597,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const session: MuxSession = { sessionId, muxName, - tmuxSocket: socket, pid: 99999, createdAt: Date.now(), workingDir, @@ -614,7 +646,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // (Production uses systemd which has a clean env, but dev/test may be nested.) const cleanEnv = { ...process.env }; delete cleanEnv.TMUX; - execSync(`${this.tmux(socket)} new-session -ds "${muxName}" -c "${workingDir}"`, { + execSync(`${this.tmux()} new-session -ds "${muxName}" -c "${workingDir}"`, { cwd: workingDir, timeout: EXEC_TIMEOUT_MS, stdio: 'ignore', @@ -623,7 +655,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Set remain-on-exit now that the server is running — must be before respawn-pane try { - execSync(`${this.tmux(socket)} set-option -t "${muxName}" remain-on-exit on`, { + execSync(`${this.tmux()} set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS, stdio: 'ignore', }); @@ -634,15 +666,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // For OpenCode: set sensitive env vars and config via tmux setenv // (not visible in ps output or tmux history, inherited by panes) if (mode === 'opencode') { - this._configureOpenCode(muxName, openCodeConfig, socket); + this._configureOpenCode(muxName, openCodeConfig); } // Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv // so secret values stay off the bash command line. Must run before respawn-pane. - this.applyEnvOverrides(muxName, envOverrides, socket); + this.applyEnvOverrides(muxName, envOverrides); // Replace the shell with the actual command (no echo in terminal) - execSync(`${this.tmux(socket)} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, { + execSync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, { timeout: EXEC_TIMEOUT_MS, stdio: 'ignore', }); @@ -656,20 +688,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // It gets enabled dynamically when panes are split (agent teams). const configPromises: Promise[] = [ // Disable tmux status bar — Codeman's web UI provides session info - execAsync(`${this.tmux(socket)} set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS }) + execAsync(`${this.tmux()} set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS }) .then(() => {}) .catch(() => { /* Non-critical — session still works with status bar */ }), // Override global remain-on-exit with session-level setting - execAsync(`${this.tmux(socket)} set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS }) + execAsync(`${this.tmux()} set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS }) .then(() => {}) .catch(() => { /* Already set globally as fallback */ }), // Raise tmux scrollback from its 2000-line default so re-attach preserves // more context. Matches the xterm-side default in constants.js. - execAsync(`${this.tmux(socket)} set-option -t "${muxName}" history-limit 50000`, { timeout: EXEC_TIMEOUT_MS }) + execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit 50000`, { timeout: EXEC_TIMEOUT_MS }) .then(() => {}) .catch(() => { /* Non-critical — falls back to tmux default */ @@ -679,7 +711,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Enable 24-bit true color passthrough — server-wide, set once per lifetime if (!this.trueColorConfigured) { configPromises.push( - execAsync(`${this.tmux(socket)} set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS }) + execAsync(`${this.tmux()} set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS }) .then(() => { this.trueColorConfigured = true; }) @@ -694,10 +726,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { void Promise.all(configPromises); // Get the PID of the pane process (retry for tmux server cold-start) - let pid = this.getPanePid(muxName, socket); + let pid = this.getPanePid(muxName); for (let i = 0; !pid && i < GET_PID_MAX_RETRIES; i++) { await new Promise((resolve) => setTimeout(resolve, GET_PID_RETRY_MS)); - pid = this.getPanePid(muxName, socket); + pid = this.getPanePid(muxName); } if (!pid) { throw new Error('Failed to get tmux pane PID'); @@ -706,7 +738,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const session: MuxSession = { sessionId, muxName, - tmuxSocket: socket, pid, createdAt: Date.now(), workingDir, @@ -728,7 +759,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { /** * Get the PID of the process running in the tmux pane. */ - private getPanePid(muxName: string, socket?: string): number | null { + private getPanePid(muxName: string): number | null { if (IS_TEST_MODE) return 99999; if (!isValidMuxName(muxName)) { @@ -737,7 +768,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } try { - const output = execSync(`${this.tmux(socket)} display-message -t "${muxName}" -p '#{pane_pid}'`, { + const output = execSync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_pid}'`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }).trim(); @@ -762,9 +793,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { isPaneDead(muxName: string): boolean { if (IS_TEST_MODE) return false; if (!isValidMuxName(muxName)) return false; - const socket = this.getSocketForMuxName(muxName); try { - const output = execSync(`${this.tmux(socket)} display-message -t "${muxName}" -p '#{pane_dead}'`, { + const output = execSync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_dead}'`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }).trim(); @@ -795,7 +825,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const session = this.sessions.get(sessionId); if (!session) return null; const muxName = session.muxName; - const socket = session.tmuxSocket; if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null; @@ -820,18 +849,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { try { // For OpenCode: set sensitive env vars via tmux setenv before respawn if (mode === 'opencode') { - this._configureOpenCode(muxName, openCodeConfig, socket); + this._configureOpenCode(muxName, openCodeConfig); } // Re-apply user env overrides before respawn so the new shell inherits them. - this.applyEnvOverrides(muxName, envOverrides, socket); + this.applyEnvOverrides(muxName, envOverrides); - await execAsync(`${this.tmux(socket)} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, { + await execAsync(`${this.tmux()} respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, { timeout: EXEC_TIMEOUT_MS, }); // Wait for the respawned process to start await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS)); - const pid = this.getPanePid(muxName, socket); + const pid = this.getPanePid(muxName); if (pid) session.pid = pid; return pid; } catch (err) { @@ -844,7 +873,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { if (IS_TEST_MODE) return false; try { - execSync(`${this.tmuxForMuxName(muxName)} has-session -t "${muxName}" 2>/dev/null`, { + execSync(`${this.tmux()} has-session -t "${muxName}" 2>/dev/null`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }); @@ -933,7 +962,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } // Get current PID (may have changed) - const currentPid = this.getPanePid(session.muxName, session.tmuxSocket) || session.pid; + const currentPid = this.getPanePid(session.muxName) || session.pid; console.log(`[TmuxManager] Killing session ${session.muxName} (PID ${currentPid})`); @@ -984,7 +1013,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Strategy 3: Kill tmux session by name try { - execSync(`${this.tmux(session.tmuxSocket)} kill-session -t "${session.muxName}" 2>/dev/null`, { + execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, { timeout: EXEC_TIMEOUT_MS, }); } catch { @@ -1049,37 +1078,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const dead: string[] = []; const discovered: string[] = []; - // Batch per socket: default server for legacy sessions, Codeman socket for new sessions. - const sockets = new Map(); - sockets.set(tmuxSocketKey(undefined), undefined); - if (this.tmuxSocket) sockets.set(tmuxSocketKey(this.tmuxSocket), this.tmuxSocket); - for (const session of this.sessions.values()) { - sockets.set(tmuxSocketKey(session.tmuxSocket), session.tmuxSocket); - } - - const activeBySocket = new Map>(); - for (const [key, socket] of sockets) { - try { - const output = execSync(`${this.tmux(socket)} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - }).trim(); - activeBySocket.set(key, parsePaneList(output)); - } catch (err) { - console.error(`[TmuxManager] Failed to list tmux panes for socket ${socket || ''}:`, err); - activeBySocket.set(key, new Map()); - } + // 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; + 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); + } catch (err) { + console.error('[TmuxManager] Failed to list tmux panes:', err); + active = new Map(); } - // Check known sessions against the batch result (O(1) map lookup instead of subprocess per session) + // Check tracked sessions against the live pane list. for (const [sessionId, session] of this.sessions) { - const activeSessions = activeBySocket.get(tmuxSocketKey(session.tmuxSocket)) ?? new Map(); - const pid = activeSessions.get(session.muxName); + const pid = active.get(session.muxName); if (pid !== undefined) { alive.push(sessionId); - if (pid !== session.pid) { - session.pid = pid; - } + if (pid !== session.pid) session.pid = pid; } else { dead.push(sessionId); this.sessions.delete(sessionId); @@ -1087,35 +1107,34 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } } - // Discover unknown codeman/claudeman sessions from the same batch result + // Discover untracked codeman/claudeman sessions on our socket. Dedup by + // muxName (globally unique) so a name we already track never spawns a + // second "Restored:" entry. const knownMuxNames = new Set(); for (const session of this.sessions.values()) { - knownMuxNames.add(`${tmuxSocketKey(session.tmuxSocket)}:${session.muxName}`); - } - - for (const [key, activeSessions] of activeBySocket) { - const socket = sockets.get(key); - for (const [sessionName, pid] of activeSessions) { - if (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-')) continue; - if (knownMuxNames.has(`${key}:${sessionName}`)) continue; - - const fragment = sessionName.replace(/^(?:codeman|claudeman)-/, ''); - const sessionId = `restored-${fragment}`; - const session: MuxSession = { - sessionId, - muxName: sessionName, - tmuxSocket: socket, - pid, - createdAt: Date.now(), - workingDir: process.cwd(), - mode: 'claude', - attached: false, - name: `Restored: ${sessionName}`, - }; - this.sessions.set(sessionId, session); - discovered.push(sessionId); - console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`); - } + knownMuxNames.add(session.muxName); + } + + for (const [sessionName, pid] of active) { + if (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-')) continue; + if (knownMuxNames.has(sessionName)) continue; + + const fragment = sessionName.replace(/^(?:codeman|claudeman)-/, ''); + const sessionId = `restored-${fragment}`; + const session: MuxSession = { + sessionId, + muxName: sessionName, + pid, + createdAt: Date.now(), + workingDir: process.cwd(), + mode: 'claude', + attached: false, + name: `Restored: ${sessionName}`, + }; + this.sessions.set(sessionId, session); + knownMuxNames.add(sessionName); + discovered.push(sessionId); + console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`); } if (dead.length > 0 || discovered.length > 0) { @@ -1421,27 +1440,21 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Ink (Claude CLI's terminal framework) needs them split — sending both in a // single tmux invocation (via \;) causes Ink to interpret Enter as a newline // character in the input buffer rather than as form submission. - await execAsync( - `${this.tmux(session.tmuxSocket)} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, - { - timeout: EXEC_TIMEOUT_MS, - } - ); + await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, { + timeout: EXEC_TIMEOUT_MS, + }); await new Promise((resolve) => setTimeout(resolve, 50)); - await execAsync(`${this.tmux(session.tmuxSocket)} send-keys -t "${session.muxName}" Enter`, { + await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, { timeout: EXEC_TIMEOUT_MS, }); } else if (textPart) { // Text only, no Enter - await execAsync( - `${this.tmux(session.tmuxSocket)} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, - { - timeout: EXEC_TIMEOUT_MS, - } - ); + await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, { + timeout: EXEC_TIMEOUT_MS, + }); } else if (hasCarriageReturn) { // Enter only - await execAsync(`${this.tmux(session.tmuxSocket)} send-keys -t "${session.muxName}" Enter`, { + await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, { timeout: EXEC_TIMEOUT_MS, }); } @@ -1468,7 +1481,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } try { - execSync(`${this.tmuxForMuxName(muxName)} set-option -t "${muxName}" mouse on`, { + execSync(`${this.tmux()} set-option -t "${muxName}" mouse on`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }); @@ -1492,7 +1505,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } try { - execSync(`${this.tmuxForMuxName(muxName)} set-option -t "${muxName}" mouse off`, { + execSync(`${this.tmux()} set-option -t "${muxName}" mouse off`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }); @@ -1532,7 +1545,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { try { const output = execSync( - `${this.tmuxForMuxName(muxName)} list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`, + `${this.tmux()} list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } ).trim(); @@ -1571,7 +1584,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Build target: sessionName.paneId (e.g., "codeman-abc12345.%1") const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; - const tmux = this.tmuxForMuxName(muxName); + const tmux = this.tmux(); try { const hasCarriageReturn = input.includes('\r'); @@ -1623,7 +1636,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; try { - return execSync(`${this.tmuxForMuxName(muxName)} capture-pane -p -e -t ${shellescape(target)} -S -5000`, { + return execSync(`${this.tmux()} capture-pane -p -e -t ${shellescape(target)} -S -5000`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }); @@ -1655,13 +1668,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; try { - execSync( - `${this.tmuxForMuxName(muxName)} pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, - { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - } - ); + execSync(`${this.tmux()} pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }); return true; } catch (err) { console.error('[TmuxManager] Failed to start pipe-pane:', err); @@ -1686,7 +1696,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; try { - execSync(`${this.tmuxForMuxName(muxName)} pipe-pane -t ${shellescape(target)}`, { + execSync(`${this.tmux()} pipe-pane -t ${shellescape(target)}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }); @@ -1702,8 +1712,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } getAttachArgs(muxName: string): string[] { - const socket = this.getSocketForMuxName(muxName); - return socket ? ['-L', socket, 'attach-session', '-t', muxName] : ['attach-session', '-t', muxName]; + return ['-L', this.tmuxSocket, 'attach-session', '-t', muxName]; } isAvailable(): boolean { diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 2743848f..aa9e12ed 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -652,11 +652,18 @@ export function registerSessionRoutes( } try { + // Route through the dedicated Codeman socket — bare `tmux` would target the + // user's default server and never find this session (same #80 regression class). await new Promise((resolve, reject) => { - execFile('tmux', ['send-keys', '-H', '-t', muxName, ...hex], { timeout: 5000 }, (err) => { - if (err) reject(err); - else resolve(); - }); + execFile( + 'tmux', + ['-L', ctx.mux.muxSocket, 'send-keys', '-H', '-t', muxName, ...hex], + { timeout: 5000 }, + (err) => { + if (err) reject(err); + else resolve(); + } + ); }); } catch (err) { console.error('[Server] send-key failed:', err); diff --git a/test/mocks/mock-route-context.ts b/test/mocks/mock-route-context.ts index ed0f7f1e..dfcbe2a5 100644 --- a/test/mocks/mock-route-context.ts +++ b/test/mocks/mock-route-context.ts @@ -90,6 +90,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) { // -- InfraPort -- mux: { + muxSocket: 'codeman', createSession: vi.fn(), killSession: vi.fn(), listSessions: vi.fn(() => []), diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index 4ef1c921..d0c8a88c 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -7,6 +7,14 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; + +// Mock execFile so the send-key route's `tmux` invocation is observable (not run for real). +const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() })); +vi.mock('node:child_process', async (orig) => { + const actual = await orig(); + return { ...actual, execFile }; +}); + import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; describe('session-routes', () => { @@ -20,6 +28,44 @@ describe('session-routes', () => { await harness.app.close(); }); + // ========== POST /api/sessions/:id/send-key ========== + + describe('POST /api/sessions/:id/send-key', () => { + it('routes tmux send-keys through the dedicated Codeman socket (-L)', async () => { + // Regression guard: bare `tmux` would hit the user's default server and never + // find a session that lives only on the Codeman socket (#80 regression class). + execFile.mockReset(); + execFile.mockImplementation((_bin: string, _argv: string[], _opts: unknown, cb: (e: Error | null) => void) => + cb(null) + ); + + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/test-session-1/send-key', + payload: { key: 'S-Enter' }, + }); + + expect(res.statusCode).toBe(200); + expect(execFile).toHaveBeenCalledTimes(1); + const [bin, argv] = execFile.mock.calls[0]; + expect(bin).toBe('tmux'); + expect((argv as string[]).slice(0, 2)).toEqual(['-L', 'codeman']); + expect(argv).toContain('send-keys'); + expect(argv).toContain('-H'); + }); + + it('rejects keys outside the hex allowlist without invoking tmux', async () => { + execFile.mockReset(); + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/test-session-1/send-key', + payload: { key: 'rm -rf' }, + }); + expect(JSON.parse(res.body).success).toBe(false); + expect(execFile).not.toHaveBeenCalled(); + }); + }); + // ========== GET /api/sessions ========== describe('GET /api/sessions', () => { diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts index 75e507a6..b8f840f5 100644 --- a/test/tmux-manager.test.ts +++ b/test/tmux-manager.test.ts @@ -77,14 +77,14 @@ describe('TmuxManager (unit)', () => { }); describe('getAttachArgs', () => { - it('should attach unknown/new sessions through the isolated Codeman socket', () => { + it('should attach every session through the dedicated Codeman socket', () => { const args = manager.getAttachArgs('codeman-abc12345'); expect(args).toEqual(['-L', 'codeman', 'attach-session', '-t', 'codeman-abc12345']); }); - it('should keep legacy registered sessions on the default tmux socket', () => { + it('should attach registered sessions on the same dedicated socket (no per-session socket)', () => { manager.registerSession({ - sessionId: 'legacy-session', + sessionId: 'some-session', muxName: 'codeman-abc12345', pid: 12345, createdAt: Date.now(), @@ -94,7 +94,7 @@ describe('TmuxManager (unit)', () => { }); const args = manager.getAttachArgs('codeman-abc12345'); - expect(args).toEqual(['attach-session', '-t', 'codeman-abc12345']); + expect(args).toEqual(['-L', 'codeman', 'attach-session', '-t', 'codeman-abc12345']); }); }); diff --git a/test/tmux-window-size-query.test.ts b/test/tmux-window-size-query.test.ts index 94d0a3b5..9f36156b 100644 --- a/test/tmux-window-size-query.test.ts +++ b/test/tmux-window-size-query.test.ts @@ -8,7 +8,8 @@ * the next frame — visible flicker and one lost repaint of scrollback. * * The fix queries tmux for the actual window geometry first via - * `tmux display -t -p '#{window_width} #{window_height}'`. We cover: + * `tmux -L display -t -p '#{window_width} #{window_height}'` + * (the `-L ` targets the isolated Codeman socket). We cover: * - Happy path: tmux reports valid geometry → those numbers are used. * - Browser-resize-between-attaches: tmux reports a non-default size * (because a prior client resized it) → the helper picks that up. @@ -49,7 +50,7 @@ beforeEach(() => { describe('queryTmuxWindowSize — happy path', () => { it('returns the geometry tmux reports', () => { execFileSync.mockReturnValue('200 50\n'); - expect(queryTmuxWindowSize('codeman-abc')).toEqual({ cols: 200, rows: 50 }); + expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual({ cols: 200, rows: 50 }); }); it('picks up a non-default size left behind by a prior client (browser-resize-between-attaches)', () => { @@ -57,12 +58,12 @@ describe('queryTmuxWindowSize — happy path', () => { // tmux keeps the last-attached geometry. Client B re-attaches and should spawn // its PTY at 220x60, not 120x40 — that's the whole point of #80. execFileSync.mockReturnValue('220 60'); - expect(queryTmuxWindowSize('codeman-abc')).toEqual({ cols: 220, rows: 60 }); + expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual({ cols: 220, rows: 60 }); }); it('tolerates trailing whitespace and newlines in tmux output', () => { execFileSync.mockReturnValue(' 180 45 \n\n'); - expect(queryTmuxWindowSize('codeman-abc')).toEqual({ cols: 180, rows: 45 }); + expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual({ cols: 180, rows: 45 }); }); }); @@ -75,7 +76,7 @@ describe('queryTmuxWindowSize — fallback paths', () => { err.status = 1; throw err; }); - expect(queryTmuxWindowSize('bogus')).toEqual(DEFAULT); + expect(queryTmuxWindowSize('bogus', 'codeman')).toEqual(DEFAULT); }); it('falls back when tmux dies between query and parse (ETIMEDOUT / ENOENT)', () => { @@ -85,63 +86,65 @@ describe('queryTmuxWindowSize — fallback paths', () => { err.code = 'ETIMEDOUT'; throw err; }); - expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); + expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT); }); it('falls back when tmux returns empty output', () => { execFileSync.mockReturnValue(''); - expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); + expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT); }); it('falls back when tmux returns whitespace-only output', () => { execFileSync.mockReturnValue(' \n'); - expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); + expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT); }); it('falls back when tmux returns non-numeric output', () => { execFileSync.mockReturnValue('not a size\n'); - expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); + expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT); }); it('falls back when only one dimension is present', () => { execFileSync.mockReturnValue('200\n'); - expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); + expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT); }); it('falls back when a dimension is zero (degenerate geometry)', () => { // tmux reporting `0` would crash node-pty downstream — must not propagate. execFileSync.mockReturnValue('0 40\n'); - expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); + expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT); execFileSync.mockReturnValue('120 0\n'); - expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); + expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT); }); it('falls back when a dimension is negative', () => { execFileSync.mockReturnValue('-200 -50\n'); - expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); + expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT); }); it('falls back when tmux returns NaN-producing tokens', () => { execFileSync.mockReturnValue('abc def\n'); - expect(queryTmuxWindowSize('codeman-abc')).toEqual(DEFAULT); + expect(queryTmuxWindowSize('codeman-abc', 'codeman')).toEqual(DEFAULT); }); }); describe('queryTmuxWindowSize — call shape', () => { - it('invokes tmux with display -t -p ... via argv (not a shell)', () => { + it('invokes tmux on the dedicated socket via argv (not a shell)', () => { execFileSync.mockReturnValue('120 40\n'); - queryTmuxWindowSize('codeman-abc'); + queryTmuxWindowSize('codeman-abc', 'codeman'); expect(execFileSync).toHaveBeenCalledTimes(1); const [bin, argv, opts] = execFileSync.mock.calls[0]; expect(bin).toBe('tmux'); - expect(argv).toEqual(['display', '-t', 'codeman-abc', '-p', '#{window_width} #{window_height}']); + // `-L ` MUST lead: querying the default server would never find a + // session that lives on the isolated Codeman socket (the #80 regression). + expect(argv).toEqual(['-L', 'codeman', 'display', '-t', 'codeman-abc', '-p', '#{window_width} #{window_height}']); // execFileSync — not execSync — so muxName is never substituted into a shell string. expect(opts).toMatchObject({ encoding: 'utf8' }); }); it('uses a bounded timeout so a hung tmux server cannot block startup forever', () => { execFileSync.mockReturnValue('120 40\n'); - queryTmuxWindowSize('codeman-abc'); + queryTmuxWindowSize('codeman-abc', 'codeman'); const [, , opts] = execFileSync.mock.calls[0]; // Whatever the exact constant, the contract is: ≤5s so the user-visible // attach path can't hang on a stuck tmux server. @@ -152,12 +155,12 @@ describe('queryTmuxWindowSize — call shape', () => { it('passes a muxName that looks like a tmux flag as an argv element (no option injection)', () => { execFileSync.mockReturnValue('120 40\n'); - queryTmuxWindowSize('-x 1 -y 1; rm -rf'); + queryTmuxWindowSize('-x 1 -y 1; rm -rf', 'codeman'); const [, argv] = execFileSync.mock.calls[0]; - // The whole "name" lives in a single argv slot, so tmux interprets it as a - // target session name, not as additional flags. The `-t` flag preceding it - // pins it as the target argument. - expect(argv?.[2]).toBe('-x 1 -y 1; rm -rf'); + // The whole "name" lives in a single argv slot (index 4, after `-L codeman + // display -t`), so tmux interprets it as a target session name, not as + // additional flags. The `-t` flag preceding it pins it as the target. + expect(argv?.[4]).toBe('-x 1 -y 1; rm -rf'); expect((argv as string[]).indexOf('-x')).toBe(-1); }); });