mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
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:
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = `
|
||||
<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() {
|
||||
|
||||
@@ -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()">×</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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user