From db3893d6086ef2ffb00d7929bc7701152f1892c2 Mon Sep 17 00:00:00 2001 From: arkon Date: Thu, 19 Feb 2026 22:37:45 +0100 Subject: [PATCH] fix: tmux remain-on-exit prevents session loss when Claude exits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, when Claude exited inside tmux (exitCode=0 or crash), tmux destroyed the entire session — losing buffer, history, and causing crash loops on restart attempts. Now: - Set remain-on-exit on for all tmux sessions - Detect dead panes via #{pane_dead} instead of assuming session is gone - Respawn dead panes with tmux respawn-pane -k (preserves session) - Applied to both startInteractive and startShell paths Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- package.json | 2 +- src/mux-interface.ts | 6 ++++ src/session.ts | 39 +++++++++++++++++++++--- src/tmux-manager.ts | 72 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 07b7056e..8c6ca14d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1561 (must match `package.json` for npm publish) +**Version**: 0.1562 (must match `package.json` for npm publish) ## Project Overview diff --git a/package.json b/package.json index ab07f961..c8f815b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1561", + "version": "0.1562", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 9b206638..aaa401e7 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -158,4 +158,10 @@ export interface TerminalMultiplexer extends EventEmitter { /** Check if a multiplexer session actually exists (process-level check, not just tracked) */ muxSessionExists(muxName: string): boolean; + + /** Check if the pane in a session is dead (command exited but remain-on-exit keeps it alive) */ + isPaneDead(muxName: string): boolean; + + /** Respawn a dead pane with a fresh command. Returns the new PID or null on failure. */ + respawnPane(sessionId: string, workingDir: string, mode: 'claude' | 'shell', niceConfig?: NiceConfig, model?: string): Promise; } diff --git a/src/session.ts b/src/session.ts index fb1350ab..37a37410 100644 --- a/src/session.ts +++ b/src/session.ts @@ -849,14 +849,29 @@ export class Session extends EventEmitter { // If mux wrapping is enabled, create or attach to a mux session if (this._useMux && this._mux) { try { - // Verify stale mux session — tmux destroys session when command exits - // (remain-on-exit is off), but _muxSession reference persists + // Verify stale mux session — tmux may have been destroyed (e.g., killed externally) if (this._muxSession && !this._mux.muxSessionExists(this._muxSession.muxName)) { console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName); this._muxSession = null; } + + // Check if session exists but pane is dead (remain-on-exit keeps it alive) + // Respawn the pane instead of creating a whole new session — preserves tmux scrollback + let needsNewSession = false; + if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) { + console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName); + const newPid = await this._mux.respawnPane(this.id, this.workingDir, 'claude', this._niceConfig, this._model); + if (!newPid) { + console.error('[Session] Failed to respawn pane, will create new session'); + needsNewSession = true; + } else { + // Wait a moment for the respawned process to fully start + await new Promise(resolve => setTimeout(resolve, MUX_STARTUP_DELAY_MS)); + } + } + // Check if we already have a mux session (restored session) - const isRestoredSession = this._muxSession !== null; + const isRestoredSession = this._muxSession !== null && !needsNewSession; if (isRestoredSession) { console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName); } else { @@ -1134,13 +1149,27 @@ export class Session extends EventEmitter { // If mux wrapping is enabled, create or attach to a mux session if (this._useMux && this._mux) { try { - // Verify stale mux session — tmux destroys session when command exits + // Verify stale mux session — tmux may have been destroyed externally if (this._muxSession && !this._mux.muxSessionExists(this._muxSession.muxName)) { console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName); this._muxSession = null; } + + // Check if session exists but pane is dead (remain-on-exit keeps it alive) + let needsNewSession = false; + if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) { + console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName); + const newPid = await this._mux.respawnPane(this.id, this.workingDir, 'shell', this._niceConfig); + if (!newPid) { + console.error('[Session] Failed to respawn pane, will create new session'); + needsNewSession = true; + } else { + await new Promise(resolve => setTimeout(resolve, MUX_STARTUP_DELAY_MS)); + } + } + // Check if we already have a mux session (restored session) - const isRestoredSession = this._muxSession !== null; + const isRestoredSession = this._muxSession !== null && !needsNewSession; if (isRestoredSession) { console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName); } else { diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 92ebf4bc..c69d0d65 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -282,6 +282,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Disable tmux status bar — Claudeman's web UI provides session info execAsync(`tmux set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS }) .then(() => {}).catch(() => { /* Non-critical — session still works with status bar */ }), + // Keep pane alive after command exits — prevents session loss when Claude exits. + // Without this, tmux destroys the session entirely, losing all buffer/history. + // With remain-on-exit, the pane shows [Exited] and can be respawned. + execAsync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS }) + .then(() => {}).catch(() => { /* Non-critical — old behavior (session destroyed on exit) */ }), ]; // Enable 24-bit true color passthrough — server-wide, set once per lifetime @@ -352,6 +357,73 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { return this.sessionExists(muxName); } + /** + * Check if the pane in a tmux session is dead (command exited but remain-on-exit keeps it). + * Returns true if the session exists but the pane's command has exited. + */ + isPaneDead(muxName: string): boolean { + if (IS_TEST_MODE) return false; + if (!isValidMuxName(muxName)) return false; + try { + const output = execSync( + `tmux display-message -t "${muxName}" -p '#{pane_dead}'`, + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ).trim(); + return output === '1'; + } catch { + return false; + } + } + + /** + * Respawn a dead pane in an existing tmux session. + * Uses `tmux respawn-pane -k` to restart the command in the same pane, + * preserving the session and its scrollback buffer. + */ + async respawnPane(sessionId: string, workingDir: string, mode: 'claude' | 'shell', niceConfig?: NiceConfig, model?: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return null; + const muxName = session.muxName; + + if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null; + + const claudeDir = findClaudeDir(); + const pathExport = claudeDir ? `export PATH="${claudeDir}:$PATH" && ` : ''; + const envExports = [ + 'unset CLAUDECODE', + 'unset COLORTERM', + 'export CLAUDEMAN_MUX=1', + `export CLAUDEMAN_SESSION_ID=${sessionId}`, + `export CLAUDEMAN_MUX_NAME=${muxName}`, + `export CLAUDEMAN_API_URL=${process.env.CLAUDEMAN_API_URL || 'http://localhost:3000'}`, + ].join(' && '); + + const safeModel = (model && /^[a-zA-Z0-9._-]+$/.test(model)) ? model : undefined; + const modelFlag = (mode === 'claude' && safeModel) ? ` --model ${safeModel}` : ''; + const baseCmd = mode === 'claude' + ? `claude --dangerously-skip-permissions --session-id "${sessionId}"${modelFlag}` + : '$SHELL'; + + const config = niceConfig || DEFAULT_NICE_CONFIG; + const cmd = wrapWithNice(baseCmd, config); + const fullCmd = `${pathExport}${envExports} && ${cmd}`; + + try { + await execAsync( + `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); + if (pid) session.pid = pid; + return pid; + } catch (err) { + console.error('[TmuxManager] Failed to respawn pane:', err); + return null; + } + } + private sessionExists(muxName: string): boolean { if (IS_TEST_MODE) return false;