diff --git a/CLAUDE.md b/CLAUDE.md index d085b58e..07b7056e 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.1560 (must match `package.json` for npm publish) +**Version**: 0.1561 (must match `package.json` for npm publish) ## Project Overview diff --git a/package.json b/package.json index f96964ed..ab07f961 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1560", + "version": "0.1561", "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/session.ts b/src/session.ts index 9cd28065..fb1350ab 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2055,40 +2055,46 @@ export class Session extends EventEmitter { this.cleanupTrackerListeners(); if (this.ptyProcess) { - const pid = this.ptyProcess.pid; + if (killMux) { + // Full kill: SIGTERM → wait → SIGKILL the PTY and its children + const pid = this.ptyProcess.pid; - // First try graceful SIGTERM - try { - this.ptyProcess.kill(); - } catch (err) { - console.warn('[Session] Failed to send SIGTERM to PTY process (may already be dead):', err); - } - - // Give it a moment to terminate gracefully - await new Promise(resolve => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS)); - - // Force kill with SIGKILL if still alive - try { - if (pid) { - process.kill(pid, 'SIGKILL'); + // First try graceful SIGTERM + try { + this.ptyProcess.kill(); + } catch (err) { + console.warn('[Session] Failed to send SIGTERM to PTY process (may already be dead):', err); } - } catch (err) { - console.warn('[Session] Failed to send SIGKILL to process (already terminated):', err); - } - // Also try to kill any child processes in the process group - try { - if (pid) { - process.kill(-pid, 'SIGKILL'); + // Give it a moment to terminate gracefully + await new Promise(resolve => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS)); + + // Force kill with SIGKILL if still alive + try { + if (pid) { + process.kill(pid, 'SIGKILL'); + } + } catch (err) { + console.warn('[Session] Failed to send SIGKILL to process (already terminated):', err); } - } catch (err) { - console.warn('[Session] Failed to send SIGKILL to process group (may not exist):', err); + + // Also try to kill any child processes in the process group + try { + if (pid) { + process.kill(-pid, 'SIGKILL'); + } + } catch (err) { + console.warn('[Session] Failed to send SIGKILL to process group (may not exist):', err); + } + } else { + // Server shutdown: just detach — the process lives on inside tmux + console.log('[Session] Detaching from PTY (server shutdown) — mux session preserved'); } this.ptyProcess = null; } this._pid = null; - this._status = 'stopped'; + this._status = killMux ? 'stopped' : 'idle'; this._currentTaskId = null; // Clear task description cache and agent tree to prevent memory leak diff --git a/src/types.ts b/src/types.ts index 81d1d9cd..25fdb41f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -84,6 +84,7 @@ export type LifecycleEventType = | 'started' // PTY process launched (interactive/shell/prompt) | 'exit' // PTY process exited (with exit code) | 'deleted' // cleanupSession() called — session removed + | 'detached' // Server shutdown — PTY left alive in tmux for recovery | 'recovered' // Session restored from tmux on server restart | 'stale_cleaned' // Removed from state.json by cleanupStaleSessions() | 'mux_died' // tmux session died (detected by reconciliation) diff --git a/src/web/server.ts b/src/web/server.ts index 41ba8c7a..a3dd2145 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -3999,7 +3999,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; const session = this.sessions.get(sessionId); const lifecycleLog = getLifecycleLog(); lifecycleLog.log({ - event: 'deleted', + event: killMux ? 'deleted' : 'detached', sessionId, name: session?.name, mode: session?.mode, @@ -5522,14 +5522,48 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; Array.from(this.scheduledRuns.keys()).map(id => this.stopScheduledRun(id)) ); - // Properly clean up all remaining sessions in parallel (removes listeners, clears state, etc.) - // Don't kill mux sessions on server stop - they can be reattached on restart - // Use Promise.race with a 30s timeout to prevent shutdown from hanging indefinitely - const sessionCleanup = Promise.allSettled( - Array.from(this.sessions.keys()).map(id => this.cleanupSession(id, false, 'server_shutdown')) - ); - const shutdownTimeout = new Promise(resolve => setTimeout(resolve, 30_000)); - await Promise.race([sessionCleanup, shutdownTimeout]); + // On server shutdown, DO NOT call cleanupSession — it tears down session state, + // removes listeners, kills PTY processes, and broadcasts session:deleted. + // Instead, just persist current state and let the PTY die naturally when process exits. + // The tmux sessions survive independently, and restoreMuxSessions() will find them on restart. + for (const [sessionId, session] of this.sessions) { + // Persist final state so recovery has up-to-date tokens, ralph state, etc. + this._persistSessionStateNow(session); + // Remove listeners to avoid spurious events during teardown + const listeners = this.sessionListenerRefs.get(sessionId); + if (listeners) { + session.off('terminal', listeners.terminal); + session.off('clearTerminal', listeners.clearTerminal); + session.off('message', listeners.message); + session.off('error', listeners.error); + session.off('completion', listeners.completion); + session.off('exit', listeners.exit); + session.off('working', listeners.working); + session.off('idle', listeners.idle); + session.off('taskCreated', listeners.taskCreated); + session.off('taskUpdated', listeners.taskUpdated); + session.off('taskCompleted', listeners.taskCompleted); + session.off('taskFailed', listeners.taskFailed); + session.off('autoClear', listeners.autoClear); + session.off('autoCompact', listeners.autoCompact); + session.off('cliInfoUpdated', listeners.cliInfoUpdated); + session.off('ralphLoopUpdate', listeners.ralphLoopUpdate); + session.off('ralphTodoUpdate', listeners.ralphTodoUpdate); + session.off('ralphCompletionDetected', listeners.ralphCompletionDetected); + session.off('ralphStatusBlockDetected', listeners.ralphStatusBlockDetected); + session.off('ralphCircuitBreakerUpdate', listeners.ralphCircuitBreakerUpdate); + session.off('ralphExitGateMet', listeners.ralphExitGateMet); + session.off('bashToolStart', listeners.bashToolStart); + session.off('bashToolEnd', listeners.bashToolEnd); + session.off('bashToolsUpdate', listeners.bashToolsUpdate); + this.sessionListenerRefs.delete(sessionId); + } + session.removeAllListeners(); + // Close file streams and image watchers (these are server-side resources) + fileStreamManager.closeSessionStreams(sessionId); + imageWatcher.unwatchSession(sessionId); + } + // Don't delete sessions from the map or state.json — recovery needs them // Flush state store to prevent data loss from debounced saves this.store.flushAll();