diff --git a/CLAUDE.md b/CLAUDE.md index df63f292..0cbc3319 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.1342 +**Version**: 0.1343 **Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty diff --git a/package.json b/package.json index a0cf60a3..07ce6578 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1342", + "version": "0.1343", "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/respawn-controller.ts b/src/respawn-controller.ts index 781360af..9d326a93 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -662,7 +662,11 @@ export class RespawnController extends EventEmitter { constructor(session: Session, config: Partial = {}) { super(); this.session = session; - this.config = { ...DEFAULT_CONFIG, ...config }; + // Filter out undefined values from config to prevent overwriting defaults + const filteredConfig = Object.fromEntries( + Object.entries(config).filter(([, v]) => v !== undefined) + ) as Partial; + this.config = { ...DEFAULT_CONFIG, ...filteredConfig }; this.aiChecker = new AiIdleChecker(session.id, { enabled: this.config.aiIdleCheckEnabled, model: this.config.aiIdleCheckModel, @@ -2169,7 +2173,11 @@ export class RespawnController extends EventEmitter { * @fires log - With updated config details */ updateConfig(config: Partial): void { - this.config = { ...this.config, ...config }; + // Filter out undefined values to prevent overwriting existing config with undefined + const filteredConfig = Object.fromEntries( + Object.entries(config).filter(([, v]) => v !== undefined) + ) as Partial; + this.config = { ...this.config, ...filteredConfig }; // Sync AI checker config if relevant fields changed if (config.aiIdleCheckEnabled !== undefined || config.aiIdleCheckModel !== undefined || diff --git a/src/session.ts b/src/session.ts index d9b46651..db610d7c 100644 --- a/src/session.ts +++ b/src/session.ts @@ -571,6 +571,16 @@ export class Session extends EventEmitter { return this._totalOutputTokens; } + /** + * Restore token and cost values from saved state. + * Called when recovering sessions after server restart. + */ + restoreTokens(inputTokens: number, outputTokens: number, totalCost: number): void { + this._totalInputTokens = inputTokens; + this._totalOutputTokens = outputTokens; + this._totalCost = totalCost; + } + get autoClearThreshold(): number { return this._autoClearThreshold; } diff --git a/src/state-store.ts b/src/state-store.ts index b132af7f..c3d1487c 100644 --- a/src/state-store.ts +++ b/src/state-store.ts @@ -17,7 +17,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; -import { AppState, createInitialState, RalphSessionState, createInitialRalphSessionState } from './types.js'; +import { AppState, createInitialState, RalphSessionState, createInitialRalphSessionState, GlobalStats, createInitialGlobalStats } from './types.js'; /** Debounce delay for batching state writes (ms) */ const SAVE_DEBOUNCE_MS = 500; @@ -208,6 +208,70 @@ export class StateStore { this.saveRalphStatesNow(); } + // ========== Global Stats Methods ========== + + /** Returns global stats, creating initial stats if needed. */ + getGlobalStats(): GlobalStats { + if (!this.state.globalStats) { + this.state.globalStats = createInitialGlobalStats(); + } + return this.state.globalStats; + } + + /** + * Adds tokens and cost to global stats. + * Call when a session is deleted to preserve its usage in lifetime stats. + */ + addToGlobalStats(inputTokens: number, outputTokens: number, cost: number): void { + const stats = this.getGlobalStats(); + stats.totalInputTokens += inputTokens; + stats.totalOutputTokens += outputTokens; + stats.totalCost += cost; + stats.lastUpdatedAt = Date.now(); + this.save(); + } + + /** Increments the total sessions created counter. */ + incrementSessionsCreated(): void { + const stats = this.getGlobalStats(); + stats.totalSessionsCreated += 1; + stats.lastUpdatedAt = Date.now(); + this.save(); + } + + /** + * Returns aggregate stats combining global (deleted sessions) + active sessions. + * @param activeSessions Map of active session states + */ + getAggregateStats(activeSessions: Record): { + totalInputTokens: number; + totalOutputTokens: number; + totalCost: number; + totalSessionsCreated: number; + activeSessionsCount: number; + } { + const global = this.getGlobalStats(); + let activeInput = 0; + let activeOutput = 0; + let activeCost = 0; + let activeCount = 0; + + for (const session of Object.values(activeSessions)) { + activeInput += session.inputTokens ?? 0; + activeOutput += session.outputTokens ?? 0; + activeCost += session.totalCost ?? 0; + activeCount++; + } + + return { + totalInputTokens: global.totalInputTokens + activeInput, + totalOutputTokens: global.totalOutputTokens + activeOutput, + totalCost: global.totalCost + activeCost, + totalSessionsCreated: global.totalSessionsCreated, + activeSessionsCount: activeCount, + }; + } + // ========== Inner State Methods (Ralph Loop tracking) ========== private loadRalphStates(): void { diff --git a/src/types.ts b/src/types.ts index aa070b57..f3a600e3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -88,6 +88,27 @@ export interface SessionState { childAgentIds?: string[]; } +// ========== Global Stats Types ========== + +/** + * Global statistics across all sessions (including deleted ones). + * Persisted to track cumulative usage over time. + */ +export interface GlobalStats { + /** Total input tokens used across all sessions */ + totalInputTokens: number; + /** Total output tokens used across all sessions */ + totalOutputTokens: number; + /** Total cost in USD across all sessions */ + totalCost: number; + /** Total number of sessions created (lifetime) */ + totalSessionsCreated: number; + /** Timestamp when stats were first recorded */ + firstRecordedAt: number; + /** Timestamp of last update */ + lastUpdatedAt: number; +} + // ========== Task Types ========== /** @@ -178,6 +199,8 @@ export interface AppState { ralphLoop: RalphLoopState; /** Application configuration */ config: AppConfig; + /** Global statistics (cumulative across all sessions) */ + globalStats?: GlobalStats; } // ========== Respawn Controller Types ========== @@ -744,6 +767,23 @@ export function createInitialState(): AppState { lastCheckAt: null, }, config: { ...DEFAULT_CONFIG }, + globalStats: createInitialGlobalStats(), + }; +} + +/** + * Creates initial global stats object + * @returns Fresh global stats with zero values + */ +export function createInitialGlobalStats(): GlobalStats { + const now = Date.now(); + return { + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + totalSessionsCreated: 0, + firstRecordedAt: now, + lastUpdatedAt: now, }; } diff --git a/src/web/public/app.js b/src/web/public/app.js index c4e4c6ae..84181a3e 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -355,6 +355,7 @@ class ClaudemanApp { this.cases = []; this.currentRun = null; this.totalTokens = 0; + this.globalStats = null; // Global token/cost stats across all sessions this.eventSource = null; this.terminal = null; this.fitAddon = null; @@ -1298,6 +1299,11 @@ class ClaudemanApp { this.respawnStatus = data.respawnStatus; } + // Store global stats for aggregate tracking + if (data.globalStats) { + this.globalStats = data.globalStats; + } + this.totalCost = data.sessions.reduce((sum, s) => sum + (s.totalCost || 0), 0); this.totalCost += data.scheduledRuns.reduce((sum, r) => sum + (r.totalCost || 0), 0); @@ -2404,15 +2410,30 @@ class ClaudemanApp { } updateTokens() { + // Use global stats if available (includes deleted sessions) let total = 0; - this.sessions.forEach(s => { - if (s.tokens) { - total += s.tokens.total || 0; - } - }); + if (this.globalStats) { + total = this.globalStats.totalInputTokens + this.globalStats.totalOutputTokens; + } else { + // Fallback to active sessions only + this.sessions.forEach(s => { + if (s.tokens) { + total += s.tokens.total || 0; + } + }); + } this.totalTokens = total; const display = total >= 1000 ? `${(total / 1000).toFixed(1)}k` : total; - this.$('headerTokens').textContent = `${display} tokens`; + + // Show global cost if available + const costDisplay = this.globalStats ? `$${this.globalStats.totalCost.toFixed(2)}` : ''; + const tokenEl = this.$('headerTokens'); + if (tokenEl) { + tokenEl.textContent = costDisplay ? `${display} tokens ยท ${costDisplay}` : `${display} tokens`; + tokenEl.title = this.globalStats + ? `Lifetime: ${this.globalStats.totalSessionsCreated} sessions created` + : 'Token usage across active sessions'; + } } // ========== Session Options Modal ========== diff --git a/src/web/server.ts b/src/web/server.ts index d8b2bcbb..c80e41bc 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -319,6 +319,23 @@ export class WebServer extends EventEmitter { // API Routes this.app.get('/api/status', async () => this.getFullState()); + // Global stats endpoint + this.app.get('/api/stats', async () => { + const activeSessionTokens: Record = {}; + for (const [sessionId, session] of this.sessions) { + activeSessionTokens[sessionId] = { + inputTokens: session.inputTokens, + outputTokens: session.outputTokens, + totalCost: session.totalCost, + }; + } + return { + success: true, + stats: this.store.getAggregateStats(activeSessionTokens), + raw: this.store.getGlobalStats(), + }; + }); + this.app.get('/api/config', async () => { return { success: true, config: this.store.getConfig() }; }); @@ -352,6 +369,7 @@ export class WebServer extends EventEmitter { }); this.sessions.set(session.id, session); + this.store.incrementSessionsCreated(); this.persistSessionState(session); this.setupSessionListeners(session); @@ -821,15 +839,15 @@ export class WebServer extends EventEmitter { autoAcceptPrompts: config.autoAcceptPrompts ?? currentConfig?.autoAcceptPrompts ?? true, autoAcceptDelayMs: config.autoAcceptDelayMs ?? currentConfig?.autoAcceptDelayMs ?? 8000, aiIdleCheckEnabled: config.aiIdleCheckEnabled ?? currentConfig?.aiIdleCheckEnabled ?? true, - aiIdleCheckModel: config.aiIdleCheckModel ?? currentConfig?.aiIdleCheckModel, - aiIdleCheckMaxContext: config.aiIdleCheckMaxContext ?? currentConfig?.aiIdleCheckMaxContext, - aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs, - aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs, + aiIdleCheckModel: config.aiIdleCheckModel ?? currentConfig?.aiIdleCheckModel ?? 'claude-opus-4-5-20251101', + aiIdleCheckMaxContext: config.aiIdleCheckMaxContext ?? currentConfig?.aiIdleCheckMaxContext ?? 16000, + aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs ?? 90000, + aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs ?? 180000, aiPlanCheckEnabled: config.aiPlanCheckEnabled ?? currentConfig?.aiPlanCheckEnabled ?? true, - aiPlanCheckModel: config.aiPlanCheckModel ?? currentConfig?.aiPlanCheckModel, - aiPlanCheckMaxContext: config.aiPlanCheckMaxContext ?? currentConfig?.aiPlanCheckMaxContext, - aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs, - aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs, + aiPlanCheckModel: config.aiPlanCheckModel ?? currentConfig?.aiPlanCheckModel ?? 'claude-opus-4-5-20251101', + aiPlanCheckMaxContext: config.aiPlanCheckMaxContext ?? currentConfig?.aiPlanCheckMaxContext ?? 8000, + aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs ?? 60000, + aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? 30000, durationMinutes: currentConfig?.durationMinutes, }; this.screenManager.updateRespawnConfig(id, merged); @@ -1024,6 +1042,7 @@ export class WebServer extends EventEmitter { const session = new Session({ workingDir: dir }); this.sessions.set(session.id, session); + this.store.incrementSessionsCreated(); this.persistSessionState(session); this.setupSessionListeners(session); @@ -1321,6 +1340,7 @@ export class WebServer extends EventEmitter { } this.sessions.set(session.id, session); + this.store.incrementSessionsCreated(); this.persistSessionState(session); this.setupSessionListeners(session); this.broadcast('session:created', session.toDetailedState()); @@ -1688,6 +1708,13 @@ export class WebServer extends EventEmitter { // Stop session and remove listeners if (session) { + // Accumulate tokens to global stats before removing session + // This preserves lifetime usage even after sessions are deleted + if (killScreen && (session.inputTokens > 0 || session.outputTokens > 0 || session.totalCost > 0)) { + this.store.addToGlobalStats(session.inputTokens, session.outputTokens, session.totalCost); + console.log(`[Server] Added to global stats: ${session.inputTokens + session.outputTokens} tokens, $${session.totalCost.toFixed(4)} from session ${sessionId}`); + } + session.removeAllListeners(); await session.stop(killScreen); this.sessions.delete(sessionId); @@ -1909,6 +1936,7 @@ export class WebServer extends EventEmitter { }); this.sessions.set(session.id, session); + this.store.incrementSessionsCreated(); this.setupSessionListeners(session); session.parentAgentId = name; @@ -2088,6 +2116,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.store.incrementSessionsCreated(); this.persistSessionState(session); this.setupSessionListeners(session); run.sessionId = session.id; @@ -2191,11 +2220,22 @@ export class WebServer extends EventEmitter { respawnStatus[sessionId] = controller.getStatus(); } + // Build active sessions token map for aggregate calculation + const activeSessionTokens: Record = {}; + for (const [sessionId, session] of this.sessions) { + activeSessionTokens[sessionId] = { + inputTokens: session.inputTokens, + outputTokens: session.outputTokens, + totalCost: session.totalCost, + }; + } + return { version: APP_VERSION, sessions: this.getSessionsState(), scheduledRuns: Array.from(this.scheduledRuns.values()), respawnStatus, + globalStats: this.store.getAggregateStats(activeSessionTokens), timestamp: Date.now(), }; } @@ -2449,6 +2489,18 @@ export class WebServer extends EventEmitter { savedState.autoClearThreshold ); } + // Token tracking + if (savedState.inputTokens !== undefined || savedState.outputTokens !== undefined || savedState.totalCost !== undefined) { + session.restoreTokens( + savedState.inputTokens ?? 0, + savedState.outputTokens ?? 0, + savedState.totalCost ?? 0 + ); + const totalTokens = (savedState.inputTokens ?? 0) + (savedState.outputTokens ?? 0); + if (totalTokens > 0) { + console.log(`[Server] Restored tokens for session ${session.id}: ${totalTokens} tokens, $${(savedState.totalCost ?? 0).toFixed(4)}`); + } + } // Ralph / Todo tracker if (savedState.ralphEnabled) { session.ralphTracker.enable(); diff --git a/test/respawn-controller.test.ts b/test/respawn-controller.test.ts index 027ff179..449bcd9c 100644 --- a/test/respawn-controller.test.ts +++ b/test/respawn-controller.test.ts @@ -248,6 +248,31 @@ describe('RespawnController', () => { const config = controller.getConfig(); expect(config.idleTimeoutMs).toBe(originalTimeout); }); + + it('should not override defaults with explicit undefined values in constructor', () => { + const configWithUndefined = { + idleTimeoutMs: 5000, + aiIdleCheckTimeoutMs: undefined, + aiIdleCheckModel: undefined, + } as Partial; + + const newController = new RespawnController(session as unknown as Session, configWithUndefined); + const config = newController.getConfig(); + + // Explicit undefined should not override defaults + expect(config.aiIdleCheckTimeoutMs).toBe(90000); // default value + expect(config.aiIdleCheckModel).toBe('claude-opus-4-5-20251101'); // default value + expect(config.idleTimeoutMs).toBe(5000); // explicit value should be preserved + newController.stop(); + }); + + it('should not override existing config with explicit undefined in updateConfig', () => { + const originalTimeout = controller.getConfig().aiIdleCheckTimeoutMs; + controller.updateConfig({ aiIdleCheckTimeoutMs: undefined } as Partial); + + const config = controller.getConfig(); + expect(config.aiIdleCheckTimeoutMs).toBe(originalTimeout); + }); }); describe('Status', () => { diff --git a/test/state-store.test.ts b/test/state-store.test.ts index a6e960af..16030ae0 100644 --- a/test/state-store.test.ts +++ b/test/state-store.test.ts @@ -331,6 +331,89 @@ describe('StateStore', () => { expect(store2.getRalphState('session-1')).toBeDefined(); }); }); + + describe('global stats', () => { + it('should initialize global stats with zero values', () => { + const store = new StateStore(testFilePath); + const stats = store.getGlobalStats(); + + expect(stats.totalInputTokens).toBe(0); + expect(stats.totalOutputTokens).toBe(0); + expect(stats.totalCost).toBe(0); + expect(stats.totalSessionsCreated).toBe(0); + expect(stats.firstRecordedAt).toBeDefined(); + expect(stats.lastUpdatedAt).toBeDefined(); + }); + + it('should add to global stats', () => { + const store = new StateStore(testFilePath); + + store.addToGlobalStats(1000, 500, 0.05); + + const stats = store.getGlobalStats(); + expect(stats.totalInputTokens).toBe(1000); + expect(stats.totalOutputTokens).toBe(500); + expect(stats.totalCost).toBe(0.05); + }); + + it('should accumulate global stats across multiple calls', () => { + const store = new StateStore(testFilePath); + + store.addToGlobalStats(1000, 500, 0.05); + store.addToGlobalStats(2000, 1000, 0.10); + + const stats = store.getGlobalStats(); + expect(stats.totalInputTokens).toBe(3000); + expect(stats.totalOutputTokens).toBe(1500); + expect(stats.totalCost).toBeCloseTo(0.15, 10); + }); + + it('should increment sessions created counter', () => { + const store = new StateStore(testFilePath); + + store.incrementSessionsCreated(); + store.incrementSessionsCreated(); + store.incrementSessionsCreated(); + + const stats = store.getGlobalStats(); + expect(stats.totalSessionsCreated).toBe(3); + }); + + it('should compute aggregate stats combining global and active sessions', () => { + const store = new StateStore(testFilePath); + + // Add global stats (from deleted sessions) + store.addToGlobalStats(5000, 2500, 0.25); + + // Simulate active sessions + const activeSessions = { + 'session-1': { inputTokens: 1000, outputTokens: 500, totalCost: 0.05 }, + 'session-2': { inputTokens: 2000, outputTokens: 1000, totalCost: 0.10 }, + }; + + const aggregate = store.getAggregateStats(activeSessions); + + expect(aggregate.totalInputTokens).toBe(8000); // 5000 + 1000 + 2000 + expect(aggregate.totalOutputTokens).toBe(4000); // 2500 + 500 + 1000 + expect(aggregate.totalCost).toBeCloseTo(0.40, 10); // 0.25 + 0.05 + 0.10 + expect(aggregate.activeSessionsCount).toBe(2); + }); + + it('should persist global stats across instances', () => { + const store1 = new StateStore(testFilePath); + store1.addToGlobalStats(10000, 5000, 0.50); + store1.incrementSessionsCreated(); + store1.flushAll(); + + const store2 = new StateStore(testFilePath); + const stats = store2.getGlobalStats(); + + expect(stats.totalInputTokens).toBe(10000); + expect(stats.totalOutputTokens).toBe(5000); + expect(stats.totalCost).toBe(0.50); + expect(stats.totalSessionsCreated).toBe(1); + }); + }); }); // Helper functions to create mock state objects