feat: persistent token tracking with global stats aggregation

- Add restoreTokens() method to Session class for recovery after restart
- Add GlobalStats type for cumulative usage tracking across all sessions
- Accumulate tokens from deleted sessions into global stats
- Track lifetime session count with incrementSessionsCreated()
- Add /api/stats endpoint for global stats
- Include globalStats in /api/status response
- Frontend shows aggregate tokens + cost in header
- Fix respawn controller config to filter undefined values
- Add 7 new tests for global stats functionality (v0.1343)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-25 07:26:49 +01:00
co-authored by Claude Opus 4.5
parent 0aca55b099
commit e56c8626e4
10 changed files with 322 additions and 19 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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",
+10 -2
View File
@@ -662,7 +662,11 @@ export class RespawnController extends EventEmitter {
constructor(session: Session, config: Partial<RespawnConfig> = {}) {
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<RespawnConfig>;
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<RespawnConfig>): 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<RespawnConfig>;
this.config = { ...this.config, ...filteredConfig };
// Sync AI checker config if relevant fields changed
if (config.aiIdleCheckEnabled !== undefined || config.aiIdleCheckModel !== undefined ||
+10
View File
@@ -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;
}
+65 -1
View File
@@ -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<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }>): {
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 {
+40
View File
@@ -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,
};
}
+27 -6
View File
@@ -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 ==========
+60 -8
View File
@@ -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<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
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<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
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();
+25
View File
@@ -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<RespawnConfig>;
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<RespawnConfig>);
const config = controller.getConfig();
expect(config.aiIdleCheckTimeoutMs).toBe(originalTimeout);
});
});
describe('Status', () => {
+83
View File
@@ -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