From 4d1da8bb71ac150e93430a474fbf925f739ad39b Mon Sep 17 00:00:00 2001 From: arkon Date: Sun, 25 Jan 2026 07:42:05 +0100 Subject: [PATCH] fix: persist respawn config across server restarts - persistSessionState() now saves respawnEnabled based on config.enabled (user intent) instead of controller.state (running state) - cleanupSession() saves respawn config BEFORE removing controller - AiIdleChecker and AiPlanChecker constructors filter undefined values to prevent overwriting defaults (fixes "timed out after undefinedms") - updateConfig() methods also filter undefined values Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 2 +- package.json | 2 +- src/ai-idle-checker.ts | 12 ++++++++++-- src/ai-plan-checker.ts | 12 ++++++++++-- src/web/server.ts | 24 +++++++++++++++++++++--- 5 files changed, 43 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fa05c901..a2238568 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Claudeman is a Claude Code session manager with a web interface and autonomous Ralph Loop. It spawns Claude CLI processes via PTY, streams output in real-time via SSE, and supports scheduled/timed runs. -**Version**: 0.1344 +**Version**: 0.1345 **Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty diff --git a/package.json b/package.json index 4c14c83b..94b85e81 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1344", + "version": "0.1345", "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/ai-idle-checker.ts b/src/ai-idle-checker.ts index edb74c1c..67e3ef87 100644 --- a/src/ai-idle-checker.ts +++ b/src/ai-idle-checker.ts @@ -153,7 +153,11 @@ export class AiIdleChecker extends EventEmitter { constructor(sessionId: string, config: Partial = {}) { super(); this.sessionId = sessionId; - this.config = { ...DEFAULT_AI_CHECK_CONFIG, ...config }; + // Filter out undefined values to prevent overwriting defaults + const filteredConfig = Object.fromEntries( + Object.entries(config).filter(([, v]) => v !== undefined) + ) as Partial; + this.config = { ...DEFAULT_AI_CHECK_CONFIG, ...filteredConfig }; } /** Get the current status */ @@ -287,7 +291,11 @@ export class AiIdleChecker extends EventEmitter { /** Update configuration at runtime */ updateConfig(config: Partial): void { - this.config = { ...this.config, ...config }; + // Filter out undefined values to prevent overwriting existing config + const filteredConfig = Object.fromEntries( + Object.entries(config).filter(([, v]) => v !== undefined) + ) as Partial; + this.config = { ...this.config, ...filteredConfig }; if (config.enabled === false) { this.disable('Disabled by config'); } else if (config.enabled === true && this._status === 'disabled') { diff --git a/src/ai-plan-checker.ts b/src/ai-plan-checker.ts index 90e4917a..e4671c6d 100644 --- a/src/ai-plan-checker.ts +++ b/src/ai-plan-checker.ts @@ -161,7 +161,11 @@ export class AiPlanChecker extends EventEmitter { constructor(sessionId: string, config: Partial = {}) { super(); this.sessionId = sessionId; - this.config = { ...DEFAULT_PLAN_CHECK_CONFIG, ...config }; + // Filter out undefined values to prevent overwriting defaults + const filteredConfig = Object.fromEntries( + Object.entries(config).filter(([, v]) => v !== undefined) + ) as Partial; + this.config = { ...DEFAULT_PLAN_CHECK_CONFIG, ...filteredConfig }; } /** Get the current status */ @@ -295,7 +299,11 @@ export class AiPlanChecker extends EventEmitter { /** Update configuration at runtime */ updateConfig(config: Partial): void { - this.config = { ...this.config, ...config }; + // Filter out undefined values to prevent overwriting existing config + const filteredConfig = Object.fromEntries( + Object.entries(config).filter(([, v]) => v !== undefined) + ) as Partial; + this.config = { ...this.config, ...filteredConfig }; if (config.enabled === false) { this.disable('Disabled by config'); } else if (config.enabled === true && this._status === 'disabled') { diff --git a/src/web/server.ts b/src/web/server.ts index c80e41bc..4fa751d8 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1582,9 +1582,19 @@ export class WebServer extends EventEmitter { ? Math.round((timerInfo.endAt - timerInfo.startedAt) / 60000) : undefined; state.respawnConfig = { ...config, durationMinutes }; - state.respawnEnabled = controller.state !== 'stopped'; + // Use config.enabled instead of controller.state - this way the respawn + // will be restored on server restart even if it was temporarily stopped + // due to errors. Intentional stops via /respawn/stop call clearRespawnConfig(). + state.respawnEnabled = config.enabled; } else { - state.respawnEnabled = false; + // Don't overwrite respawnConfig if it exists in state - preserve it for restart + const existingState = this.store.getSession(session.id); + if (existingState?.respawnConfig) { + state.respawnConfig = existingState.respawnConfig; + state.respawnEnabled = existingState.respawnConfig.enabled ?? false; + } else { + state.respawnEnabled = false; + } } this.store.setSession(session.id, state); } @@ -1664,9 +1674,17 @@ export class WebServer extends EventEmitter { private async _doCleanupSession(sessionId: string, killScreen: boolean): Promise { const session = this.sessions.get(sessionId); - // Stop and remove respawn controller + // Stop and remove respawn controller - but save config first for restart recovery const controller = this.respawnControllers.get(sessionId); if (controller) { + // Save the config BEFORE removing controller, so it can be restored on restart + const config = controller.getConfig(); + const timerInfo = this.respawnTimers.get(sessionId); + const durationMinutes = timerInfo + ? Math.round((timerInfo.endAt - timerInfo.startedAt) / 60000) + : undefined; + this.saveRespawnConfig(sessionId, config, durationMinutes); + controller.stop(); controller.removeAllListeners(); this.respawnControllers.delete(sessionId);