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 = ` +
+ Date + Input + Output + Cost +
+ ${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);