From 43eaafe084c6f7f2f236f922f8d261ddf2815de2 Mon Sep 17 00:00:00 2001 From: arkon Date: Thu, 22 Jan 2026 20:36:06 +0100 Subject: [PATCH] feat: persist full session state to state.json for CLI visibility The web server now persists all per-session settings (name, mode, auto-compact, auto-clear, respawn config, ralph state, tokens, cost) to state.json so the CLI status/session commands can display them. Also removes unused pendingStepConfirm property from respawn controller. Co-Authored-By: Claude Opus 4.5 --- src/cli.ts | 37 ++++++++++++++++++++++++--- src/respawn-controller.ts | 9 ++----- src/session.ts | 12 +++++++++ src/types.ts | 30 ++++++++++++++++++++++ src/web/server.ts | 53 ++++++++++++++++++++++++++++++++++++--- 5 files changed, 127 insertions(+), 14 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index e27fec95..5ae3cfab 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -92,7 +92,25 @@ sessionCmd if (stoppedSessions.length > 0) { console.log(chalk.bold('\nStopped Sessions:')); for (const session of stoppedSessions) { - console.log(` ${chalk.gray(session.id.slice(0, 8))} ${chalk.gray('stopped')} ${session.workingDir}`); + const name = session.name ? ` (${session.name})` : ''; + console.log(` ${chalk.gray(session.id.slice(0, 8))} ${chalk.gray('stopped')}${name} ${session.workingDir}`); + } + } + + // Show active sessions from state (when web server manages them) + const activeSessions = Object.values(stored).filter((s) => s.status !== 'stopped'); + if (sessions.length === 0 && activeSessions.length > 0) { + console.log(chalk.bold('\nActive Sessions (from web server):')); + for (const session of activeSessions) { + const status = session.status === 'idle' + ? chalk.green('idle') + : session.status === 'busy' + ? chalk.yellow('busy') + : chalk.red(session.status); + const name = session.name ? ` (${session.name})` : ''; + const mode = session.mode === 'shell' ? chalk.gray(' [shell]') : ''; + const cost = session.totalCost ? chalk.gray(` $${session.totalCost.toFixed(4)}`) : ''; + console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status}${name}${mode}${cost} ${session.workingDir}`); } } console.log(''); @@ -371,16 +389,27 @@ program const loop = getRalphLoop(); const sessions = manager.getAllSessions(); + const stored = manager.getStoredSessions(); + const storedValues = Object.values(stored); const taskCounts = queue.getCount(); const loopStatus = loop.status; + // Use live sessions if available, otherwise fall back to stored state + const activeCount = sessions.length || storedValues.filter((s) => s.status !== 'stopped').length; + const idleCount = sessions.length + ? sessions.filter((s) => s.isIdle()).length + : storedValues.filter((s) => s.status === 'idle').length; + const busyCount = sessions.length + ? sessions.filter((s) => s.isBusy()).length + : storedValues.filter((s) => s.status === 'busy').length; + console.log(chalk.bold('\nClaudeman Status')); console.log('─'.repeat(40)); console.log(chalk.bold('\nSessions:')); - console.log(` Active: ${sessions.length}`); - console.log(` Idle: ${sessions.filter((s) => s.isIdle()).length}`); - console.log(` Busy: ${sessions.filter((s) => s.isBusy()).length}`); + console.log(` Active: ${activeCount}`); + console.log(` Idle: ${idleCount}`); + console.log(` Busy: ${busyCount}`); console.log(chalk.bold('\nTasks:')); console.log(` Total: ${taskCounts.total}`); diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index 2e392c58..f0734eb9 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -413,9 +413,6 @@ export class RespawnController extends EventEmitter { /** Timer for step completion confirmation (waits for silence after completion) */ private stepConfirmTimer: NodeJS.Timeout | null = null; - /** Which step is pending confirmation */ - private pendingStepConfirm: 'update' | 'init' | 'kickstart' | null = null; - /** Fallback timeout for /clear step (ms) - sends /init without waiting for prompt */ private static readonly CLEAR_FALLBACK_TIMEOUT_MS = 10000; @@ -996,7 +993,7 @@ export class RespawnController extends EventEmitter { if (this.stepConfirmTimer) { clearTimeout(this.stepConfirmTimer); this.stepConfirmTimer = null; - this.pendingStepConfirm = null; + } if (this.noOutputTimer) { clearTimeout(this.noOutputTimer); @@ -1112,7 +1109,6 @@ export class RespawnController extends EventEmitter { clearTimeout(this.stepConfirmTimer); } - this.pendingStepConfirm = step; this.log(`Step '${step}' completion detected, waiting ${this.config.completionConfirmMs}ms for silence...`); this.stepConfirmTimer = setTimeout(() => { @@ -1121,7 +1117,7 @@ export class RespawnController extends EventEmitter { if (msSinceOutput >= this.config.completionConfirmMs) { this.log(`Step '${step}' confirmed: ${msSinceOutput}ms silence`); this.stepConfirmTimer = null; - this.pendingStepConfirm = null; + // Proceed with the step completion switch (step) { @@ -1152,7 +1148,6 @@ export class RespawnController extends EventEmitter { this.stepConfirmTimer = null; this.log(`Step confirmation cancelled (working detected)`); } - this.pendingStepConfirm = null; } /** diff --git a/src/session.ts b/src/session.ts index 510b3d0e..e9a4521b 100644 --- a/src/session.ts +++ b/src/session.ts @@ -547,6 +547,18 @@ export class Session extends EventEmitter { currentTaskId: this._currentTaskId, createdAt: this.createdAt, lastActivityAt: this._lastActivityAt, + name: this._name, + mode: this.mode, + autoClearEnabled: this._autoClearEnabled, + autoClearThreshold: this._autoClearThreshold, + autoCompactEnabled: this._autoCompactEnabled, + autoCompactThreshold: this._autoCompactThreshold, + autoCompactPrompt: this._autoCompactPrompt, + totalCost: this._totalCost, + inputTokens: this._totalInputTokens, + outputTokens: this._totalOutputTokens, + ralphEnabled: this._ralphTracker.enabled, + ralphCompletionPhrase: this._ralphTracker.loopState.completionPhrase || undefined, }; } diff --git a/src/types.ts b/src/types.ts index 0d766c3d..e42f7212 100644 --- a/src/types.ts +++ b/src/types.ts @@ -54,6 +54,32 @@ export interface SessionState { createdAt: number; /** Timestamp of last activity */ lastActivityAt: number; + /** Session display name */ + name?: string; + /** Session mode: 'claude' or 'shell' */ + mode?: 'claude' | 'shell'; + /** Auto-clear enabled */ + autoClearEnabled?: boolean; + /** Auto-clear token threshold */ + autoClearThreshold?: number; + /** Auto-compact enabled */ + autoCompactEnabled?: boolean; + /** Auto-compact token threshold */ + autoCompactThreshold?: number; + /** Auto-compact prompt */ + autoCompactPrompt?: string; + /** Total cost in USD */ + totalCost?: number; + /** Input tokens used */ + inputTokens?: number; + /** Output tokens used */ + outputTokens?: number; + /** Respawn controller config (if enabled) */ + respawnConfig?: RespawnConfig & { durationMinutes?: number }; + /** Ralph / Todo tracker enabled */ + ralphEnabled?: boolean; + /** Ralph completion phrase (if set) */ + ralphCompletionPhrase?: string; } // ========== Task Types ========== @@ -171,6 +197,10 @@ export interface RespawnConfig { sendInit: boolean; /** Optional prompt to send if /init doesn't trigger work */ kickstartPrompt?: string; + /** Time to wait after completion message before confirming idle (ms) */ + completionConfirmMs?: number; + /** Fallback timeout when no output received at all (ms) */ + noOutputTimeoutMs?: number; } /** diff --git a/src/web/server.ts b/src/web/server.ts index c7d78f1a..f4db6ef6 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -225,6 +225,7 @@ export class WebServer extends EventEmitter { }); this.sessions.set(session.id, session); + this.persistSessionState(session); this.setupSessionListeners(session); this.broadcast('session:created', session.toDetailedState()); @@ -244,6 +245,7 @@ export class WebServer extends EventEmitter { session.name = body.name || ''; // Also update the screen name if this session has a screen this.screenManager.updateScreenName(id, session.name); + this.persistSessionState(session); this.broadcast('session:updated', session.toDetailedState()); return { success: true, name: session.name }; }); @@ -392,7 +394,8 @@ export class WebServer extends EventEmitter { todoExpirationMinutes: todoExpirationMinutes || 60 }; - // Broadcast the update + // Persist and broadcast the update + this.persistSessionState(session); this.broadcast('session:ralphLoopUpdate', { sessionId: id, state: session.ralphLoopState @@ -600,8 +603,9 @@ export class WebServer extends EventEmitter { controller.start(); - // Persist respawn config to screen session + // Persist respawn config to screen session and state.json this.saveRespawnConfig(id, controller.getConfig()); + this.persistSessionState(session); this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() }); @@ -622,6 +626,12 @@ export class WebServer extends EventEmitter { // Clear persisted respawn config this.screenManager.clearRespawnConfig(id); + // Update state.json (respawnConfig removed) + const session = this.sessions.get(id); + if (session) { + this.persistSessionState(session); + } + this.broadcast('respawn:stopped', { sessionId: id }); return { success: true }; @@ -641,6 +651,10 @@ export class WebServer extends EventEmitter { // Persist updated config this.saveRespawnConfig(id, controller.getConfig()); + const session = this.sessions.get(id); + if (session) { + this.persistSessionState(session); + } this.broadcast('respawn:configUpdated', { sessionId: id, config: controller.getConfig() }); @@ -681,6 +695,9 @@ export class WebServer extends EventEmitter { this.setupTimedRespawn(id, body.durationMinutes); } + // Persist full session state with respawn config + this.persistSessionState(session); + this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() }); return { @@ -727,8 +744,9 @@ export class WebServer extends EventEmitter { this.setupTimedRespawn(id, body.durationMinutes); } - // Persist respawn config to screen session + // Persist respawn config to screen session and state.json this.saveRespawnConfig(id, controller.getConfig(), body?.durationMinutes); + this.persistSessionState(session); this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() }); @@ -758,6 +776,7 @@ export class WebServer extends EventEmitter { } session.setAutoClear(body.enabled, body.threshold); + this.persistSessionState(session); this.broadcast('session:updated', session.toDetailedState()); return { @@ -790,6 +809,7 @@ export class WebServer extends EventEmitter { } session.setAutoCompact(body.enabled, body.threshold, body.prompt); + this.persistSessionState(session); this.broadcast('session:updated', session.toDetailedState()); return { @@ -820,6 +840,7 @@ export class WebServer extends EventEmitter { const session = new Session({ workingDir: dir }); this.sessions.set(session.id, session); + this.persistSessionState(session); this.setupSessionListeners(session); this.broadcast('session:created', session.toDetailedState()); @@ -1101,6 +1122,7 @@ export class WebServer extends EventEmitter { } this.sessions.set(session.id, session); + this.persistSessionState(session); this.setupSessionListeners(session); this.broadcast('session:created', session.toDetailedState()); @@ -1217,6 +1239,21 @@ export class WebServer extends EventEmitter { }); } + /** Persists full session state including respawn config to state.json */ + private persistSessionState(session: Session): void { + const state = session.toState(); + const controller = this.respawnControllers.get(session.id); + if (controller) { + const config = controller.getConfig(); + const timerInfo = this.respawnTimers.get(session.id); + const durationMinutes = timerInfo + ? Math.round((timerInfo.endAt - timerInfo.startedAt) / 60000) + : undefined; + state.respawnConfig = { ...config, durationMinutes }; + } + this.store.setSession(session.id, state); + } + // Helper to save respawn config to screen session for persistence private saveRespawnConfig(sessionId: string, config: RespawnConfig, durationMinutes?: number): void { const persistedConfig: PersistedRespawnConfig = { @@ -1311,6 +1348,7 @@ export class WebServer extends EventEmitter { session.removeAllListeners(); await session.stop(killScreen); this.sessions.delete(sessionId); + this.store.removeSession(sessionId); } this.broadcast('session:deleted', { id: sessionId }); @@ -1343,6 +1381,7 @@ export class WebServer extends EventEmitter { session.on('completion', (result, cost) => { this.broadcast('session:completion', { id: session.id, result, cost }); this.broadcast('session:updated', session.toDetailedState()); + this.persistSessionState(session); }); session.on('exit', (code) => { @@ -1350,6 +1389,7 @@ export class WebServer extends EventEmitter { try { this.broadcast('session:exit', { id: session.id, code }); this.broadcast('session:updated', session.toDetailedState()); + this.persistSessionState(session); } catch (err) { console.error(`[Server] Error broadcasting session exit for ${session.id}:`, err); } @@ -1478,6 +1518,11 @@ export class WebServer extends EventEmitter { this.broadcast('respawn:stopped', { sessionId, reason: 'duration_expired' }); } this.respawnTimers.delete(sessionId); + // Update persisted state (respawn no longer active) + const session = this.sessions.get(sessionId); + if (session) { + this.persistSessionState(session); + } }, durationMinutes * 60 * 1000); this.respawnTimers.set(sessionId, { timer, endAt, startedAt: now }); @@ -1557,6 +1602,7 @@ export class WebServer extends EventEmitter { // Create a session for this iteration session = new Session({ workingDir: run.workingDir }); this.sessions.set(session.id, session); + this.persistSessionState(session); this.setupSessionListeners(session); run.sessionId = session.id; @@ -1884,6 +1930,7 @@ export class WebServer extends EventEmitter { }); this.sessions.set(session.id, session); + this.persistSessionState(session); this.setupSessionListeners(session); // Restore Ralph tracking state (Ralph Wiggum settings) if it was saved