mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 07:59:42 +02:00
fix: tmux remain-on-exit prevents session loss when Claude exits
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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`)
|
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`
|
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
|
## Project Overview
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudeman",
|
"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",
|
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -158,4 +158,10 @@ export interface TerminalMultiplexer extends EventEmitter {
|
|||||||
|
|
||||||
/** Check if a multiplexer session actually exists (process-level check, not just tracked) */
|
/** Check if a multiplexer session actually exists (process-level check, not just tracked) */
|
||||||
muxSessionExists(muxName: string): boolean;
|
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<number | null>;
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-5
@@ -849,14 +849,29 @@ export class Session extends EventEmitter {
|
|||||||
// If mux wrapping is enabled, create or attach to a mux session
|
// If mux wrapping is enabled, create or attach to a mux session
|
||||||
if (this._useMux && this._mux) {
|
if (this._useMux && this._mux) {
|
||||||
try {
|
try {
|
||||||
// Verify stale mux session — tmux destroys session when command exits
|
// Verify stale mux session — tmux may have been destroyed (e.g., killed externally)
|
||||||
// (remain-on-exit is off), but _muxSession reference persists
|
|
||||||
if (this._muxSession && !this._mux.muxSessionExists(this._muxSession.muxName)) {
|
if (this._muxSession && !this._mux.muxSessionExists(this._muxSession.muxName)) {
|
||||||
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
|
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
|
||||||
this._muxSession = null;
|
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)
|
// Check if we already have a mux session (restored session)
|
||||||
const isRestoredSession = this._muxSession !== null;
|
const isRestoredSession = this._muxSession !== null && !needsNewSession;
|
||||||
if (isRestoredSession) {
|
if (isRestoredSession) {
|
||||||
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
||||||
} else {
|
} else {
|
||||||
@@ -1134,13 +1149,27 @@ export class Session extends EventEmitter {
|
|||||||
// If mux wrapping is enabled, create or attach to a mux session
|
// If mux wrapping is enabled, create or attach to a mux session
|
||||||
if (this._useMux && this._mux) {
|
if (this._useMux && this._mux) {
|
||||||
try {
|
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)) {
|
if (this._muxSession && !this._mux.muxSessionExists(this._muxSession.muxName)) {
|
||||||
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
|
console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName);
|
||||||
this._muxSession = null;
|
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)
|
// Check if we already have a mux session (restored session)
|
||||||
const isRestoredSession = this._muxSession !== null;
|
const isRestoredSession = this._muxSession !== null && !needsNewSession;
|
||||||
if (isRestoredSession) {
|
if (isRestoredSession) {
|
||||||
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -282,6 +282,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
// Disable tmux status bar — Claudeman's web UI provides session info
|
// Disable tmux status bar — Claudeman's web UI provides session info
|
||||||
execAsync(`tmux set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS })
|
execAsync(`tmux set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS })
|
||||||
.then(() => {}).catch(() => { /* Non-critical — session still works with status bar */ }),
|
.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
|
// 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);
|
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<number | null> {
|
||||||
|
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 {
|
private sessionExists(muxName: string): boolean {
|
||||||
if (IS_TEST_MODE) return false;
|
if (IS_TEST_MODE) return false;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user