mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
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:
@@ -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 ||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user