diff --git a/CLAUDE.md b/CLAUDE.md
index e0a23645..32df0e01 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/package.json b/package.json
index e5fbfedc..1e8b6f3e 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/state-store.ts b/src/state-store.ts
index c3d1487c..6ada0178 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, 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 {
diff --git a/src/types.ts b/src/types.ts
index 4d11b86a..e9c6e202 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -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 ==========
/**
diff --git a/src/web/public/app.js b/src/web/public/app.js
index bd403e7b..877ddeaa 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -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 ? '▼' : '▲'; // 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 = `
+
+ Today
+ ${this.formatTokens(todayData.inputTokens + todayData.outputTokens)}
+ ~$${todayData.estimatedCost.toFixed(2)}
+
+
+ 7 Days
+ ${this.formatTokens(weekInput + weekOutput)}
+ ~$${weekCost.toFixed(2)}
+
+
+ Lifetime
+ ${this.formatTokens(lifetimeInput + lifetimeOutput)}
+ ~$${lifetimeCost.toFixed(2)}
+
+ `;
+
+ // 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 ``;
+ }).join('');
+
+ daysEl.innerHTML = chartData.map(d => `${d.dayName}`).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 = 'No usage data recorded yet
';
+ } else {
+ tableEl.innerHTML = `
+
+ ${tableData.map(d => {
+ const dateObj = new Date(d.date + 'T00:00:00');
+ const dateStr = dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
+ return `
+
+ ${dateStr}
+ ${this.formatTokens(d.inputTokens)}
+ ${this.formatTokens(d.outputTokens)}
+ $${d.estimatedCost.toFixed(2)}
+
+ `;
+ }).join('')}
+ `;
+ }
+ }
+
+ closeTokenStats() {
+ const modal = document.getElementById('tokenStatsModal');
+ if (modal) {
+ modal.classList.remove('active');
+ }
+ }
+
// ========== Monitor Panel (combined Screen Sessions + Background Tasks) ==========
async toggleMonitorPanel() {
diff --git a/src/web/public/index.html b/src/web/public/index.html
index de5722c0..d5592940 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -756,6 +756,29 @@
+
+
+
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index d00a3e76..5fd684aa 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -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 {
diff --git a/src/web/server.ts b/src/web/server.ts
index 96a90c72..d74068f3 100644
--- a/src/web/server.ts
+++ b/src/web/server.ts
@@ -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 = 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 = {};
+ 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 {
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);