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 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-22 20:36:06 +01:00
co-authored by Claude Opus 4.5
parent 5a97403066
commit 43eaafe084
5 changed files with 127 additions and 14 deletions
+33 -4
View File
@@ -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}`);
+2 -7
View File
@@ -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;
}
/**
+12
View File
@@ -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,
};
}
+30
View File
@@ -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;
}
/**
+50 -3
View File
@@ -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