feat: add token usage statistics with daily tracking and stats modal

- Add TokenUsageEntry and TokenStats types for daily tracking
- Add state-store methods for recording and retrieving daily usage
- Add /api/token-stats endpoint with daily breakdown and totals
- Add token stats modal with summary cards, 7-day bar chart, and table
- Record tokens periodically (5min) and on session delete
- Make header tokens clickable to open stats modal
- Fix subagents panel toggle icon direction (down when open)
- Increase subagents panel height when expanded

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-25 20:36:04 +01:00
co-authored by Claude Opus 4.5
parent 74e8dd27dd
commit 28fd933252
8 changed files with 530 additions and 3 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ When user says "COM": 1) Increment version in BOTH `package.json` AND `CLAUDE.md
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.1363 (must match `package.json`)
**Version**: 0.1364 (must match `package.json`)
**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.1363",
"version": "0.1364",
"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",
+91 -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, GlobalStats, createInitialGlobalStats } from './types.js';
import { AppState, createInitialState, RalphSessionState, createInitialRalphSessionState, GlobalStats, createInitialGlobalStats, TokenStats, TokenUsageEntry } from './types.js';
/** Debounce delay for batching state writes (ms) */
const SAVE_DEBOUNCE_MS = 500;
@@ -272,6 +272,96 @@ export class StateStore {
};
}
// ========== Token Stats Methods (Daily Tracking) ==========
/** Maximum days to keep in daily history */
private static readonly MAX_DAILY_HISTORY = 30;
/**
* Get or initialize token stats from state.
*/
getTokenStats(): TokenStats {
const state = this.state as AppState & { tokenStats?: TokenStats };
if (!state.tokenStats) {
state.tokenStats = {
daily: [],
lastUpdated: Date.now(),
};
}
return state.tokenStats;
}
/**
* Get today's date string in YYYY-MM-DD format.
*/
private getTodayDateString(): string {
const now = new Date();
return now.toISOString().split('T')[0];
}
/**
* Calculate estimated cost from tokens using Claude Opus pricing.
* Input: $15/M tokens, Output: $75/M tokens
*/
private calculateEstimatedCost(inputTokens: number, outputTokens: number): number {
const inputCost = (inputTokens / 1000000) * 15;
const outputCost = (outputTokens / 1000000) * 75;
return inputCost + outputCost;
}
/**
* Record token usage for today.
* Accumulates tokens to today's entry, creating it if needed.
* @param inputTokens Input tokens to add
* @param outputTokens Output tokens to add
*/
recordDailyUsage(inputTokens: number, outputTokens: number): void {
if (inputTokens <= 0 && outputTokens <= 0) return;
const stats = this.getTokenStats();
const today = this.getTodayDateString();
// Find or create today's entry
let todayEntry = stats.daily.find(e => e.date === today);
if (!todayEntry) {
todayEntry = {
date: today,
inputTokens: 0,
outputTokens: 0,
estimatedCost: 0,
sessions: 0,
};
stats.daily.unshift(todayEntry); // Add to front (most recent first)
}
// Accumulate tokens
todayEntry.inputTokens += inputTokens;
todayEntry.outputTokens += outputTokens;
todayEntry.estimatedCost = this.calculateEstimatedCost(
todayEntry.inputTokens,
todayEntry.outputTokens
);
todayEntry.sessions += 1;
// Prune old entries (keep last 30 days)
if (stats.daily.length > StateStore.MAX_DAILY_HISTORY) {
stats.daily = stats.daily.slice(0, StateStore.MAX_DAILY_HISTORY);
}
stats.lastUpdated = Date.now();
this.save();
}
/**
* Get daily stats for display.
* @param days Number of days to return (default: 30)
* @returns Array of daily entries, most recent first
*/
getDailyStats(days: number = 30): TokenUsageEntry[] {
const stats = this.getTokenStats();
return stats.daily.slice(0, days);
}
// ========== Inner State Methods (Ralph Loop tracking) ==========
private loadRalphStates(): void {
+28
View File
@@ -109,6 +109,34 @@ export interface GlobalStats {
lastUpdatedAt: number;
}
// ========== Token Usage History Types ==========
/**
* Daily token usage entry for historical tracking.
*/
export interface TokenUsageEntry {
/** Date in YYYY-MM-DD format */
date: string;
/** Input tokens used on this day */
inputTokens: number;
/** Output tokens used on this day */
outputTokens: number;
/** Estimated cost in USD */
estimatedCost: number;
/** Number of sessions that contributed to this day's usage */
sessions: number;
}
/**
* Token usage statistics with daily tracking.
*/
export interface TokenStats {
/** Daily usage entries (most recent first) */
daily: TokenUsageEntry[];
/** Timestamp of last update */
lastUpdated: number;
}
// ========== Task Types ==========
/**
+139
View File
@@ -726,6 +726,13 @@ class ClaudemanApp {
this.decreaseFontSize();
}
}, true); // Use capture phase to handle before terminal
// Token stats click handler
const tokenEl = this.$('headerTokens');
if (tokenEl) {
tokenEl.classList.add('clickable');
tokenEl.addEventListener('click', () => this.openTokenStats());
}
}
// ========== SSE Connection ==========
@@ -3229,6 +3236,7 @@ class ClaudemanApp {
toggleSubagentsPanel() {
const panel = document.getElementById('subagentsPanel');
const toggleBtn = document.getElementById('subagentsToggleBtn');
if (!panel) return;
// If hidden, show it first
@@ -3244,6 +3252,11 @@ class ClaudemanApp {
panel.classList.toggle('open');
this.subagentPanelVisible = panel.classList.contains('open');
// Update toggle button icon
if (toggleBtn) {
toggleBtn.innerHTML = this.subagentPanelVisible ? '&#x25BC;' : '&#x25B2;'; // Down when open, up when collapsed
}
if (this.subagentPanelVisible) {
this.renderSubagentPanel();
}
@@ -3292,6 +3305,7 @@ class ClaudemanApp {
this.closeSessionOptions();
this.closeAppSettings();
this.cancelCloseSession();
this.closeTokenStats();
document.getElementById('monitorPanel').classList.remove('open');
// Collapse subagents panel (don't hide it permanently)
const subagentsPanel = document.getElementById('subagentsPanel');
@@ -3301,6 +3315,131 @@ class ClaudemanApp {
this.subagentPanelVisible = false;
}
// ========== Token Statistics Modal ==========
async openTokenStats() {
try {
const response = await fetch('/api/token-stats');
const data = await response.json();
if (data.success) {
this.renderTokenStats(data);
document.getElementById('tokenStatsModal').classList.add('active');
} else {
this.showToast('Failed to load token stats', 'error');
}
} catch (err) {
console.error('Failed to fetch token stats:', err);
this.showToast('Failed to load token stats', 'error');
}
}
renderTokenStats(data) {
const { daily, totals } = data;
// Calculate period totals
const today = new Date().toISOString().split('T')[0];
const todayData = daily.find(d => d.date === today) || { inputTokens: 0, outputTokens: 0, estimatedCost: 0 };
// Last 7 days totals (for summary card)
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const last7Days = daily.filter(d => new Date(d.date) >= sevenDaysAgo);
const weekInput = last7Days.reduce((sum, d) => sum + d.inputTokens, 0);
const weekOutput = last7Days.reduce((sum, d) => sum + d.outputTokens, 0);
const weekCost = this.estimateCost(weekInput, weekOutput);
// Lifetime totals (from aggregate stats)
const lifetimeInput = totals.totalInputTokens;
const lifetimeOutput = totals.totalOutputTokens;
const lifetimeCost = this.estimateCost(lifetimeInput, lifetimeOutput);
// Render summary cards
const summaryEl = document.getElementById('statsSummary');
summaryEl.innerHTML = `
<div class="stat-card">
<span class="stat-card-label">Today</span>
<span class="stat-card-value">${this.formatTokens(todayData.inputTokens + todayData.outputTokens)}</span>
<span class="stat-card-cost">~$${todayData.estimatedCost.toFixed(2)}</span>
</div>
<div class="stat-card">
<span class="stat-card-label">7 Days</span>
<span class="stat-card-value">${this.formatTokens(weekInput + weekOutput)}</span>
<span class="stat-card-cost">~$${weekCost.toFixed(2)}</span>
</div>
<div class="stat-card">
<span class="stat-card-label">Lifetime</span>
<span class="stat-card-value">${this.formatTokens(lifetimeInput + lifetimeOutput)}</span>
<span class="stat-card-cost">~$${lifetimeCost.toFixed(2)}</span>
</div>
`;
// Render bar chart (last 7 days)
const chartEl = document.getElementById('statsChart');
const daysEl = document.getElementById('statsChartDays');
// Get last 7 days (fill gaps with empty data)
const chartData = [];
for (let i = 6; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
const dayData = daily.find(d => d.date === dateStr);
chartData.push({
date: dateStr,
dayName: date.toLocaleDateString('en-US', { weekday: 'short' }),
tokens: dayData ? dayData.inputTokens + dayData.outputTokens : 0,
cost: dayData ? dayData.estimatedCost : 0,
});
}
// Find max for scaling
const maxTokens = Math.max(...chartData.map(d => d.tokens), 1);
chartEl.innerHTML = chartData.map(d => {
const height = Math.max((d.tokens / maxTokens) * 100, 3);
const tooltip = `${d.dayName}: ${this.formatTokens(d.tokens)} (~$${d.cost.toFixed(2)})`;
return `<div class="bar" style="height: ${height}%" data-tooltip="${tooltip}"></div>`;
}).join('');
daysEl.innerHTML = chartData.map(d => `<span>${d.dayName}</span>`).join('');
// Render table (last 14 days with data)
const tableEl = document.getElementById('statsTable');
const tableData = daily.slice(0, 14);
if (tableData.length === 0) {
tableEl.innerHTML = '<div class="stats-no-data">No usage data recorded yet</div>';
} else {
tableEl.innerHTML = `
<div class="stats-table-header">
<span>Date</span>
<span>Input</span>
<span>Output</span>
<span>Cost</span>
</div>
${tableData.map(d => {
const dateObj = new Date(d.date + 'T00:00:00');
const dateStr = dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
return `
<div class="stats-table-row">
<span class="cell cell-date">${dateStr}</span>
<span class="cell">${this.formatTokens(d.inputTokens)}</span>
<span class="cell">${this.formatTokens(d.outputTokens)}</span>
<span class="cell cell-cost">$${d.estimatedCost.toFixed(2)}</span>
</div>
`;
}).join('')}
`;
}
}
closeTokenStats() {
const modal = document.getElementById('tokenStatsModal');
if (modal) {
modal.classList.remove('active');
}
}
// ========== Monitor Panel (combined Screen Sessions + Background Tasks) ==========
async toggleMonitorPanel() {
+23
View File
@@ -756,6 +756,29 @@
<!-- Help Button -->
<button class="help-btn" onclick="app.showHelp()" title="Keyboard shortcuts (Ctrl+?)">?</button>
<!-- Token Stats Modal -->
<div class="modal" id="tokenStatsModal">
<div class="modal-backdrop" onclick="app.closeTokenStats()"></div>
<div class="modal-content token-stats-modal">
<div class="modal-header">
<h3>Token Usage Statistics</h3>
<button class="modal-close" onclick="app.closeTokenStats()">&times;</button>
</div>
<div class="modal-body">
<div class="stats-summary" id="statsSummary"></div>
<div class="stats-chart-container">
<div class="stats-chart-label">Last 7 Days</div>
<div class="stats-chart" id="statsChart"></div>
<div class="stats-chart-days" id="statsChartDays"></div>
</div>
<div class="stats-table-container">
<div class="stats-table-label">Daily Breakdown</div>
<div class="stats-table" id="statsTable"></div>
</div>
</div>
</div>
</div>
</div>
<script src="app.js"></script>
+186
View File
@@ -2277,6 +2277,191 @@ kbd {
color: var(--text);
}
/* Token Stats Modal */
.token-stats-modal {
max-width: 560px;
max-height: 85vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.token-stats-modal .modal-body {
overflow-y: auto;
padding: 1rem;
}
.stats-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.stat-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border);
padding: 0.75rem;
border-radius: 8px;
text-align: center;
}
.stat-card-label {
display: block;
font-size: 0.65rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.25rem;
}
.stat-card-value {
display: block;
font-size: 1.1rem;
font-weight: 600;
color: var(--text);
font-family: 'SF Mono', Monaco, monospace;
}
.stat-card-cost {
display: block;
font-size: 0.7rem;
color: var(--accent-hover);
margin-top: 0.15rem;
}
.stats-chart-container {
margin-bottom: 1.25rem;
}
.stats-chart-label,
.stats-table-label {
font-size: 0.7rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.5rem;
}
.stats-chart {
display: flex;
align-items: flex-end;
gap: 4px;
height: 80px;
padding: 0 4px;
}
.stats-chart .bar {
flex: 1;
background: linear-gradient(to top, var(--accent), var(--green));
border-radius: 3px 3px 0 0;
min-height: 3px;
transition: opacity 0.15s;
position: relative;
}
.stats-chart .bar:hover {
opacity: 0.8;
}
.stats-chart .bar::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding: 0.25rem 0.5rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.65rem;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
z-index: 10;
}
.stats-chart .bar:hover::after {
opacity: 1;
}
.stats-chart-days {
display: flex;
gap: 4px;
padding: 0 4px;
margin-top: 0.35rem;
}
.stats-chart-days span {
flex: 1;
text-align: center;
font-size: 0.6rem;
color: var(--text-muted);
}
.stats-table-container {
max-height: 200px;
overflow-y: auto;
}
.stats-table {
width: 100%;
font-size: 0.75rem;
}
.stats-table-header,
.stats-table-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr 0.8fr;
gap: 0.5rem;
padding: 0.5rem 0.25rem;
border-bottom: 1px solid var(--border);
}
.stats-table-header {
color: var(--text-muted);
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.5px;
position: sticky;
top: 0;
background: var(--bg-card);
}
.stats-table-row:last-child {
border-bottom: none;
}
.stats-table-row .cell {
font-family: 'SF Mono', Monaco, monospace;
}
.stats-table-row .cell-date {
color: var(--text-dim);
}
.stats-table-row .cell-cost {
color: var(--accent-hover);
}
.stats-no-data {
text-align: center;
color: var(--text-muted);
padding: 2rem 1rem;
font-size: 0.8rem;
}
/* Make header tokens clickable */
.header-tokens.clickable {
cursor: pointer;
transition: color 0.15s;
}
.header-tokens.clickable:hover {
color: var(--text);
}
/* Help Button */
.help-btn {
position: fixed;
@@ -3121,6 +3306,7 @@ kbd {
.subagents-panel.open {
transform: translateY(0);
max-height: 65vh;
}
.subagents-panel.hidden {
+61
View File
@@ -258,6 +258,9 @@ export class WebServer extends EventEmitter {
private _isStopping: boolean = false;
// Spawn1337 agent orchestrator
private spawnOrchestrator: SpawnOrchestrator;
// Token recording for daily stats (track what's been recorded to avoid double-counting)
private lastRecordedTokens: Map<string, { input: number; output: number }> = new Map();
private tokenRecordingTimer: NodeJS.Timeout | null = null;
constructor(port: number = 3000, https: boolean = false) {
super();
@@ -374,6 +377,24 @@ export class WebServer extends EventEmitter {
};
});
// Token stats with daily history
this.app.get('/api/token-stats', async () => {
// Get aggregate totals (global + active sessions)
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,
daily: this.store.getDailyStats(30),
totals: this.store.getAggregateStats(activeSessionTokens),
};
});
this.app.get('/api/config', async () => {
return { success: true, config: this.store.getConfig() };
});
@@ -1862,6 +1883,14 @@ export class WebServer extends EventEmitter {
// 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);
// Record to daily stats (for what hasn't been recorded yet via periodic recording)
const lastRecorded = this.lastRecordedTokens.get(sessionId) || { input: 0, output: 0 };
const deltaInput = session.inputTokens - lastRecorded.input;
const deltaOutput = session.outputTokens - lastRecorded.output;
if (deltaInput > 0 || deltaOutput > 0) {
this.store.recordDailyUsage(deltaInput, deltaOutput);
}
this.lastRecordedTokens.delete(sessionId);
console.log(`[Server] Added to global stats: ${session.inputTokens + session.outputTokens} tokens, $${session.totalCost.toFixed(4)} from session ${sessionId}`);
}
@@ -2620,6 +2649,26 @@ export class WebServer extends EventEmitter {
}
}
/**
* Records token usage for long-running sessions periodically.
* Called every 5 minutes to capture usage in daily stats without waiting for session deletion.
*/
private recordPeriodicTokenUsage(): void {
for (const [sessionId, session] of this.sessions) {
const last = this.lastRecordedTokens.get(sessionId) || { input: 0, output: 0 };
const deltaInput = session.inputTokens - last.input;
const deltaOutput = session.outputTokens - last.output;
if (deltaInput > 0 || deltaOutput > 0) {
this.store.recordDailyUsage(deltaInput, deltaOutput);
this.lastRecordedTokens.set(sessionId, {
input: session.inputTokens,
output: session.outputTokens,
});
}
}
}
async start(): Promise<void> {
await this.setupRoutes();
await this.app.listen({ port: this.port, host: '0.0.0.0' });
@@ -2639,6 +2688,11 @@ export class WebServer extends EventEmitter {
this.cleanupDeadSSEClients();
}, SSE_HEALTH_CHECK_INTERVAL);
// Start token recording timer (every 5 minutes for long-running sessions)
this.tokenRecordingTimer = setInterval(() => {
this.recordPeriodicTokenUsage();
}, 5 * 60 * 1000);
// Restore screen sessions from previous run
await this.restoreScreenSessions();
@@ -2831,6 +2885,13 @@ export class WebServer extends EventEmitter {
}
this.stateUpdatePending.clear();
// Clear token recording timer
if (this.tokenRecordingTimer) {
clearInterval(this.tokenRecordingTimer);
this.tokenRecordingTimer = null;
}
this.lastRecordedTokens.clear();
// Clear scheduled cleanup timer
if (this.scheduledCleanupTimer) {
clearInterval(this.scheduledCleanupTimer);