diff --git a/src/screen-manager.ts b/src/screen-manager.ts index c6b443a0..292473dd 100644 --- a/src/screen-manager.ts +++ b/src/screen-manager.ts @@ -19,7 +19,7 @@ import { spawn, execSync } from 'node:child_process'; import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { homedir } from 'node:os'; -import { ScreenSession, ProcessStats, ScreenSessionWithStats, getErrorMessage } from './types.js'; +import { ScreenSession, ProcessStats, ScreenSessionWithStats, PersistedRespawnConfig, getErrorMessage } from './types.js'; /** Path to persisted screen session metadata */ const SCREENS_FILE = join(homedir(), '.claudeman', 'screens.json'); @@ -570,6 +570,33 @@ export class ScreenManager extends EventEmitter { } } + // Update respawn config for a screen session (persisted across restarts) + updateRespawnConfig(sessionId: string, config: PersistedRespawnConfig | undefined): void { + const screen = this.screens.get(sessionId); + if (screen) { + screen.respawnConfig = config; + this.saveScreens(); + } + } + + // Clear respawn config when respawn is stopped + clearRespawnConfig(sessionId: string): void { + const screen = this.screens.get(sessionId); + if (screen && screen.respawnConfig) { + delete screen.respawnConfig; + this.saveScreens(); + } + } + + // Update inner loop (Ralph Wiggum) enabled state + updateInnerLoopEnabled(sessionId: string, enabled: boolean): void { + const screen = this.screens.get(sessionId); + if (screen) { + screen.innerLoopEnabled = enabled; + this.saveScreens(); + } + } + // Check if screen is available on the system static isScreenAvailable(): boolean { try { diff --git a/src/types.ts b/src/types.ts index ddcde499..b6b13dba 100644 --- a/src/types.ts +++ b/src/types.ts @@ -433,6 +433,29 @@ export interface CaseInfo { * * Claudeman uses GNU screen for session persistence across server restarts. */ +/** + * Persisted respawn configuration for screen sessions. + * Subset of RespawnConfig that gets saved to disk. + */ +export interface PersistedRespawnConfig { + /** Whether respawn was enabled */ + enabled: boolean; + /** How long to wait after seeing prompt before considering truly idle (ms) */ + idleTimeoutMs: number; + /** The prompt to send for updating docs */ + updatePrompt: string; + /** Delay between sending steps (ms) */ + interStepDelayMs: number; + /** Whether to send /clear after update prompt */ + sendClear: boolean; + /** Whether to send /init after /clear */ + sendInit: boolean; + /** Optional prompt to send if /init doesn't trigger work */ + kickstartPrompt?: string; + /** Duration in minutes if timed respawn was set */ + durationMinutes?: number; +} + export interface ScreenSession { /** Claudeman session ID */ sessionId: string; @@ -450,6 +473,10 @@ export interface ScreenSession { attached: boolean; /** Session display name (tab name) */ name?: string; + /** Persisted respawn controller configuration (restored on server restart) */ + respawnConfig?: PersistedRespawnConfig; + /** Whether inner loop (Ralph Wiggum) tracking is enabled */ + innerLoopEnabled?: boolean; } /** diff --git a/src/web/server.ts b/src/web/server.ts index 3c15aa82..0ac36238 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -39,6 +39,7 @@ import { type SessionResponse, type QuickStartResponse, type CaseInfo, + type PersistedRespawnConfig, } from '../types.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -305,6 +306,8 @@ export class WebServer extends EventEmitter { } else { session.innerLoopTracker.disable(); } + // Persist inner loop enabled state + this.screenManager.updateInnerLoopEnabled(id, enabled); } // Configure the inner loop tracker @@ -516,6 +519,10 @@ export class WebServer extends EventEmitter { } controller.start(); + + // Persist respawn config to screen session + this.saveRespawnConfig(id, controller.getConfig()); + this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() }); return { success: true, status: controller.getStatus() }; @@ -531,6 +538,10 @@ export class WebServer extends EventEmitter { } controller.stop(); + + // Clear persisted respawn config + this.screenManager.clearRespawnConfig(id); + this.broadcast('respawn:stopped', { sessionId: id }); return { success: true }; @@ -547,6 +558,10 @@ export class WebServer extends EventEmitter { } controller.updateConfig(config); + + // Persist updated config + this.saveRespawnConfig(id, controller.getConfig()); + this.broadcast('respawn:configUpdated', { sessionId: id, config: controller.getConfig() }); return { success: true, config: controller.getConfig() }; @@ -627,6 +642,9 @@ export class WebServer extends EventEmitter { this.setupTimedRespawn(id, body.durationMinutes); } + // Persist respawn config to screen session + this.saveRespawnConfig(id, controller.getConfig(), body?.durationMinutes); + this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() }); return { @@ -1083,6 +1101,21 @@ export class WebServer extends EventEmitter { }); } + // Helper to save respawn config to screen session for persistence + private saveRespawnConfig(sessionId: string, config: RespawnConfig, durationMinutes?: number): void { + const persistedConfig: PersistedRespawnConfig = { + enabled: config.enabled, + idleTimeoutMs: config.idleTimeoutMs, + updatePrompt: config.updatePrompt, + interStepDelayMs: config.interStepDelayMs, + sendClear: config.sendClear, + sendInit: config.sendInit, + kickstartPrompt: config.kickstartPrompt, + durationMinutes, + }; + this.screenManager.updateRespawnConfig(sessionId, persistedConfig); + } + // Get system CPU and memory usage private getSystemStats(): { cpu: number; memory: { usedMB: number; totalMB: number; percent: number } } { try { @@ -1690,6 +1723,38 @@ export class WebServer extends EventEmitter { session.innerLoopTracker.restoreState(innerState.loop, innerState.todos); console.log(`[Server] Restored inner loop state for session ${session.id} (enabled: ${innerState.loop.enabled})`); } + // Also check screen.innerLoopEnabled as a fallback + if (screen.innerLoopEnabled && !session.innerLoopTracker.enabled) { + session.innerLoopTracker.enable(); + console.log(`[Server] Enabled inner loop tracker for session ${session.id} from screen config`); + } + + // Restore respawn controller if it was enabled + if (screen.respawnConfig?.enabled) { + try { + const controller = new RespawnController(session, { + idleTimeoutMs: screen.respawnConfig.idleTimeoutMs, + updatePrompt: screen.respawnConfig.updatePrompt, + interStepDelayMs: screen.respawnConfig.interStepDelayMs, + enabled: screen.respawnConfig.enabled, + sendClear: screen.respawnConfig.sendClear, + sendInit: screen.respawnConfig.sendInit, + kickstartPrompt: screen.respawnConfig.kickstartPrompt, + }); + this.respawnControllers.set(session.id, controller); + this.setupRespawnListeners(session.id, controller); + controller.start(); + + // Set up timed respawn if duration was configured + if (screen.respawnConfig.durationMinutes && screen.respawnConfig.durationMinutes > 0) { + this.setupTimedRespawn(session.id, screen.respawnConfig.durationMinutes); + } + + console.log(`[Server] Restored respawn controller for session ${session.id}`); + } catch (err) { + console.error(`[Server] Failed to restore respawn for session ${session.id}:`, err); + } + } // Mark it as restored (not started yet - user needs to attach) console.log(`[Server] Restored session ${session.id} from screen ${screen.screenName}`);