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
+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();