ai-usage-analyzer 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,17 +1,11 @@
1
1
  # ai-usage-analyzer
2
2
 
3
- TUI analyzer for local AI coding agent token consumption. Auto-detects
3
+ A local web dashboard for AI coding agent token consumption. Auto-detects
4
4
  **Claude Code**, **Codex**, **OpenCode**, **MimoCode**, **GitHub Copilot**,
5
- **Antigravity**, and **Gemini CLI** data directories — no hardcoded paths.
5
+ **Antigravity**, and **Gemini CLI** data directories.
6
6
 
7
- ```
8
- ╭──────────────────────────────────────────────────────╮
9
- │ ◆ AI TOKEN ANALYZER ◆ │
10
- │ │
11
- │ 411 sessions • 1.56B total tokens • $29.40 USD │
12
- │ range: 2026-04-01 → 2026-06-27 │
13
- ╰──────────────────────────────────────────────────────╯
14
- ```
7
+ The default command starts a local server and opens the dashboard in your browser.
8
+ Usage data stays on your device. Press Ctrl+C in the terminal to stop the server.
15
9
 
16
10
  ## Install
17
11
 
@@ -31,18 +25,19 @@ The short alias `ai-usage` is also installed and works identically.
31
25
  ## Usage
32
26
 
33
27
  ```bash
34
- ai-usage-analyzer # default TUI
35
- ai-usage-analyzer --top 10 # show top 10 heaviest sessions
28
+ ai-usage-analyzer # open the dashboard
29
+ ai-usage-analyzer --static --top 10 # terminal report with top 10 sessions
36
30
  ai-usage-analyzer --json # machine-readable JSON
37
31
  ai-usage-analyzer --md > report.md # save as markdown
32
+ ai-usage-analyzer --json --quota # include a live quota snapshot
38
33
  ```
39
34
 
40
35
  ## Supported tools
41
36
 
42
37
  | Tool | Default path | Tokens |
43
38
  |---|---|---|
44
- | Claude Code | `~/.claude/projects` | presence only |
45
- | Codex | `~/.codex/sessions/YYYY/MM/DD/` | yes |
39
+ | Claude Code | `~/.claude/projects` | yes (estimated cost) |
40
+ | Codex | `~/.codex/sessions/` and `archived_sessions/` | yes (estimated cost) |
46
41
  | OpenCode | `~/.local/share/opencode/opencode.db` | yes (+cost) |
47
42
  | MimoCode | `~/.local/share/mimocode/mimocode.db` | yes (+cost) |
48
43
  | GitHub Copilot | `~/.copilot/session-state/` | presence only |
@@ -65,14 +60,34 @@ For tools that record token data, the analyzer shows input, output, cache
65
60
  read, cache write, and reasoning tokens. Cache hits are cheap; reasoning is
66
61
  the extended-thinking/chain-of-thought cost.
67
62
 
63
+ ## Quotas
64
+
65
+ Reports stay offline unless `--quota` is supplied. This requests Claude and Codex
66
+ usage limits using your existing logins. Vendor endpoints are internal and can
67
+ change. Codex credentials may be refreshed and written back to `auth.json`.
68
+
69
+ ## Costs
70
+
71
+ Claude and Codex costs use bundled API rates per model and are estimates,
72
+ not subscription charges or invoices. `~` marks an estimate, `*` marks a
73
+ partial total, and `n/a` means unavailable. Unknown models are listed rather
74
+ than assigned a fallback price. OpenCode and MimoCode costs are read from
75
+ their local databases. Pricing uses standard public API rates and the
76
+ five-minute Claude cache-write rate; regional, batch, and other modifiers may
77
+ differ. Rates are versioned with the app and can become outdated.
78
+
68
79
  ## How it works
69
80
 
70
81
  ```
71
82
  src/
72
83
  ├── detectors.js auto-path discovery (env → well-known locations)
73
84
  ├── loaders.js SQLite + JSONL parsers → unified session record
85
+ ├── quota.js Claude and Codex quota snapshots
86
+ ├── pricing.js bundled model pricing and estimates
87
+ ├── live.js incremental live usage monitor
74
88
  ├── aggregate.js per-project / per-week / per-month grouping
75
- ├── render.js TUI • markdown.js → Markdown report
89
+ ├── server.js local dashboard server
90
+ ├── render.js static terminal report • markdown.js → Markdown report
76
91
  bin/
77
92
  └── ai-usage.js entry point
78
93
  ```
package/bin/ai-usage.js CHANGED
@@ -18,7 +18,8 @@ import {
18
18
  renderPerWeek,
19
19
  renderTopSessions,
20
20
  } from "../src/render.js";
21
- import { runInteractive } from "../src/tui.js";
21
+ import { startDashboard } from "../src/server.js";
22
+ import { collectQuotas, renderQuota } from "../src/quota.js";
22
23
 
23
24
  const args = process.argv.slice(2);
24
25
  const hasFlag = (name) =>
@@ -26,6 +27,7 @@ const hasFlag = (name) =>
26
27
  const showHelp = hasFlag("help") || hasFlag("h");
27
28
  const jsonOut = hasFlag("json");
28
29
  const mdOut = hasFlag("markdown") || hasFlag("md");
30
+ const quotaOut = hasFlag("quota");
29
31
  const staticOut = hasFlag("static") || hasFlag("no-interactive");
30
32
  const topN = (() => {
31
33
  const i = args.indexOf("--top");
@@ -42,7 +44,7 @@ const yearFilter = (() => {
42
44
 
43
45
  if (showHelp) {
44
46
  console.log(`
45
- ai-usage-analyzer — local AI coding agent token consumption TUI
47
+ ai-usage-analyzer — local AI coding agent usage dashboard
46
48
 
47
49
  Usage:
48
50
  ai-usage [options]
@@ -52,15 +54,16 @@ Options:
52
54
  --json Output machine-readable JSON instead of TUI
53
55
  --markdown, --md Output as a Markdown report (GitHub-flavored tables)
54
56
  --static, --no-interactive Disable interactive mode; dump all sections at once
55
- --top N Show top N heaviest sessions (default: 5)
56
- --year YYYY Filter records to a single year (e.g. --year 2026)
57
+ --quota Include live quota in JSON, Markdown, or static reports
58
+ --top N Show top N sessions in reports (default: 5)
59
+ --year YYYY Filter reports to a single year (e.g. --year 2026)
57
60
 
58
61
  Examples:
59
- ai-usage # default TUI
60
- ai-usage --year 2026 # TUI, only 2026 sessions
62
+ ai-usage # open dashboard
63
+ ai-usage --static --year 2026 # report, only 2026 sessions
61
64
  ai-usage --json | jq .summary # pipe to jq
62
65
  ai-usage --md > report.md # save as markdown
63
- ai-usage --top 20 # show top 20 sessions
66
+ ai-usage --static --top 20 # show top 20 sessions
64
67
 
65
68
  Environment overrides (per-tool data path):
66
69
  CLAUDE_HOME, CODEX_HOME, OPENCODE_HOME, MIMOCODE_HOME,
@@ -85,6 +88,10 @@ if (jsonOut && mdOut) {
85
88
  }
86
89
 
87
90
  async function main() {
91
+ if (!jsonOut && !mdOut && !staticOut) {
92
+ await startDashboard();
93
+ return;
94
+ }
88
95
  const t0 = Date.now();
89
96
  const detections = detectAll();
90
97
  const { records: allRecords, errors } = await loadAll(detections);
@@ -101,9 +108,13 @@ async function main() {
101
108
  const range = dateRange(records);
102
109
  const tot = overall(records);
103
110
 
111
+ const quotaPromise = quotaOut ? collectQuotas() : null;
112
+
104
113
  if (jsonOut) {
114
+ const quotas = quotaPromise ? await quotaPromise : null;
105
115
  const out = {
106
116
  detections,
117
+ ...(quotas ? { quotas } : {}),
107
118
  summary: tot,
108
119
  dateRange: range,
109
120
  sessions: records,
@@ -125,22 +136,18 @@ async function main() {
125
136
  topN,
126
137
  generatedAt: new Date().toISOString(),
127
138
  });
128
- process.stdout.write(out);
139
+ const quota = quotaPromise ? await quotaPromise : null;
140
+ process.stdout.write(out + (quota ? `\n## Quotas\n\n\`\`\`\n${renderQuota(quota)}\n\`\`\`\n` : ""));
129
141
  return;
130
142
  }
131
143
 
132
- // Interactive TUI (default when stdout is a TTY and --static not given)
133
- if (!staticOut) {
134
- const handled = await runInteractive({ records, detections, errors, topN });
135
- if (handled) return;
136
- }
137
-
138
144
  // Static fallback (--static, --no-interactive, or piped output)
139
145
  const sections = [
140
146
  renderHeader({
141
147
  totalSessions: records.length,
142
148
  totalTokens: tot.tokensTotal,
143
149
  totalCost: tot.cost,
150
+ costStatus: tot.costStatus,
144
151
  dateRange: range,
145
152
  }),
146
153
  renderDetections(detections),
@@ -157,6 +164,7 @@ async function main() {
157
164
  );
158
165
  }
159
166
  sections.push(renderNotes(detections, errors));
167
+ if (quotaPromise) sections.push(renderQuota(await quotaPromise));
160
168
 
161
169
  console.log(sections.join("\n\n"));
162
170
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ai-usage-analyzer",
3
- "version": "0.4.0",
4
- "description": "TUI analyzer for local AI coding agent token usage. Auto-detects Claude Code, Codex, OpenCode, MimoCode, Copilot, Antigravity, and Gemini.",
3
+ "version": "0.5.0",
4
+ "description": "Local web dashboard for AI coding agent token usage.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.33.0",
7
7
  "bin": {
@@ -11,7 +11,8 @@
11
11
  "files": [
12
12
  "bin",
13
13
  "src",
14
- "README.md"
14
+ "README.md",
15
+ "web"
15
16
  ],
16
17
  "scripts": {
17
18
  "start": "node bin/ai-usage.js",
@@ -31,8 +32,8 @@
31
32
  "copilot",
32
33
  "antigravity",
33
34
  "gemini",
34
- "tui",
35
- "cli"
35
+ "cli",
36
+ "dashboard"
36
37
  ],
37
38
  "author": "ade",
38
39
  "license": "MIT",
@@ -48,6 +49,7 @@
48
49
  "boxen": "^7.1.1",
49
50
  "chalk": "^5.3.0",
50
51
  "cli-table3": "^0.6.5",
51
- "gradient-string": "^2.0.2"
52
+ "gradient-string": "^2.0.2",
53
+ "gsap": "^3.15.0"
52
54
  }
53
55
  }
package/src/aggregate.js CHANGED
@@ -34,6 +34,11 @@ export function groupBy(records, keyFn) {
34
34
  }
35
35
 
36
36
  function summarize(records) {
37
+ const statuses = records.map(r => r.costStatus || 'recorded');
38
+ const incomplete = statuses.some(status => status === 'partial' || status === 'unavailable');
39
+ const known = statuses.some(status => status !== 'unavailable');
40
+ const costStatus = !statuses.length ? 'unavailable' : incomplete ? (known ? 'partial' : 'unavailable') : statuses.includes('estimated') ? 'estimated' : 'recorded';
41
+ const unknownModels = [...new Set(records.flatMap(r => r.unknownModels || []))];
37
42
  return {
38
43
  n: records.length,
39
44
  tokensTotal: sum(records, "tokensTotal"),
@@ -43,6 +48,8 @@ function summarize(records) {
43
48
  tokensCacheWrite: sum(records, "tokensCacheWrite"),
44
49
  tokensReasoning: sum(records, "tokensReasoning"),
45
50
  cost: sum(records, "cost"),
51
+ costStatus,
52
+ unknownModels,
46
53
  avg: avg(records, "tokensTotal"),
47
54
  };
48
55
  }
package/src/live.js ADDED
@@ -0,0 +1,132 @@
1
+ import { openSync, closeSync, fstatSync, readSync, readdirSync, statSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+
4
+ const FIELDS = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens'];
5
+ const count = value => Number.isFinite(Number(value)) ? Math.max(0, Number(value)) : 0;
6
+
7
+ export class LiveUsageTailer {
8
+ constructor(detections) {
9
+ this.roots = [];
10
+ for (const d of detections) {
11
+ if (!d.path || !['claude', 'codex'].includes(d.key) || d.status === 'absent') continue;
12
+ if (d.key === 'codex' && basename(d.path) === 'archived_sessions') continue;
13
+ this.roots.push({ path: d.path, tool: d.key });
14
+ }
15
+ this.directories = new Map();
16
+ this.files = new Map();
17
+ this.events = [];
18
+ this.started = false;
19
+ this.lastFull = 0;
20
+ }
21
+
22
+ tick(now = Date.now()) {
23
+ const full = !this.started || now - this.lastFull >= 60000;
24
+ if (full) this.lastFull = now;
25
+ const discovered = new Set();
26
+ const walk = (dir, tool) => {
27
+ let st;
28
+ try { st = statSync(dir); } catch { return; }
29
+ if (!st.isDirectory()) return;
30
+ const key = `${tool}:${dir}`;
31
+ let cached = this.directories.get(key);
32
+ if (!cached || cached.mtime !== st.mtimeMs || full) {
33
+ let entries;
34
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
35
+ cached = { mtime: st.mtimeMs, dirs: [], files: [] };
36
+ for (const entry of entries) {
37
+ if (entry.isDirectory() && !(tool === 'claude' && entry.name === 'subagents') && !(tool === 'codex' && entry.name === 'archived_sessions')) cached.dirs.push(join(dir, entry.name));
38
+ else if (entry.isFile() && entry.name.endsWith('.jsonl') && (tool !== 'codex' || entry.name.startsWith('rollout-'))) cached.files.push(join(dir, entry.name));
39
+ }
40
+ this.directories.set(key, cached);
41
+ }
42
+ for (const child of cached.dirs) walk(child, tool);
43
+ for (const file of cached.files) {
44
+ const id = `${tool}:${file}`;
45
+ discovered.add(id);
46
+ if (!this.files.has(id)) this.files.set(id, { path: file, tool, offset: null, partial: Buffer.alloc(0), seen: new Map(), total: null, mtime: 0 });
47
+ }
48
+ };
49
+ for (const root of this.roots) walk(root.path, root.tool);
50
+ for (const [key, file] of this.files) {
51
+ if (!discovered.has(key)) { this.files.delete(key); continue; }
52
+ if (file.offset !== null && !full && now - file.mtime > 600000) continue;
53
+ this.readFile(file, now);
54
+ }
55
+ this.started = true;
56
+ this.events = this.events.filter(e => e.ts > now - 3600000);
57
+ return this.rate(now, 600);
58
+ }
59
+
60
+ readFile(file, now) {
61
+ let fd;
62
+ try {
63
+ fd = openSync(file.path, 'r');
64
+ const st = fstatSync(fd);
65
+ const identity = `${st.dev}:${st.ino}`;
66
+ let changedBoundary = false;
67
+ if (file.offset !== null && file.offset > 0 && file.boundary && st.size >= file.offset) {
68
+ const sample = Buffer.alloc(file.boundary.length);
69
+ const size = readSync(fd, sample, 0, sample.length, file.offset - sample.length);
70
+ changedBoundary = size !== sample.length || !sample.equals(file.boundary);
71
+ }
72
+ if (file.offset === null) {
73
+ file.offset = this.started ? 0 : st.size;
74
+ file.identity = identity;
75
+ } else if (file.identity !== identity || st.size < file.offset || changedBoundary) {
76
+ file.offset = 0;
77
+ file.identity = identity;
78
+ file.partial = Buffer.alloc(0);
79
+ file.seen.clear();
80
+ file.total = null;
81
+ }
82
+ file.mtime = st.mtimeMs;
83
+ while (file.offset < st.size) {
84
+ const buffer = Buffer.allocUnsafe(Math.min(65536, st.size - file.offset));
85
+ const size = readSync(fd, buffer, 0, buffer.length, file.offset);
86
+ if (!size) break;
87
+ file.offset += size;
88
+ const data = Buffer.concat([file.partial, buffer.subarray(0, size)]);
89
+ let from = 0, end;
90
+ while ((end = data.indexOf(10, from)) !== -1) {
91
+ this.consume(file, data.subarray(from, end).toString('utf8'), now);
92
+ from = end + 1;
93
+ }
94
+ file.partial = data.subarray(from);
95
+ }
96
+ const sample = Buffer.alloc(Math.min(64, file.offset));
97
+ if (sample.length) {
98
+ const size = readSync(fd, sample, 0, sample.length, file.offset - sample.length);
99
+ file.boundary = sample.subarray(0, size);
100
+ } else file.boundary = sample;
101
+ } catch {} finally { if (fd !== undefined) closeSync(fd); }
102
+ }
103
+
104
+ consume(file, line, now) {
105
+ let event;
106
+ try { event = JSON.parse(line); } catch { return; }
107
+ let tokens = 0;
108
+ if (file.tool === 'claude' && event.type === 'assistant' && event.message?.usage) {
109
+ const usage = event.message.usage;
110
+ const id = event.message.id ? JSON.stringify([event.message.id, event.requestId || null]) : null;
111
+ const previous = id ? file.seen.get(id) || {} : {};
112
+ const next = {};
113
+ for (const field of FIELDS) {
114
+ next[field] = Math.max(count(previous[field]), count(usage[field]));
115
+ tokens += next[field] - count(previous[field]);
116
+ }
117
+ if (id) file.seen.set(id, next);
118
+ } else if (file.tool === 'codex' && event.type === 'event_msg' && event.payload?.type === 'token_count') {
119
+ const info = event.payload.info || {};
120
+ const total = info.total_token_usage;
121
+ const key = total ? JSON.stringify(total) : null;
122
+ if (key && key === file.total) return;
123
+ if (key) file.total = key;
124
+ if (info.last_token_usage) tokens = count(info.last_token_usage.input_tokens) + count(info.last_token_usage.output_tokens);
125
+ }
126
+ if (tokens > 0) this.events.push({ ts: now, tokens });
127
+ }
128
+
129
+ rate(now = Date.now(), seconds = 600) {
130
+ return this.events.filter(e => e.ts > now - seconds * 1000 && e.ts <= now).reduce((sum, e) => sum + e.tokens, 0) * 60 / seconds;
131
+ }
132
+ }
package/src/loaders.js CHANGED
@@ -16,9 +16,10 @@
16
16
  // }
17
17
 
18
18
  import { createReadStream, readdirSync } from "node:fs";
19
- import { join } from "node:path";
19
+ import { basename, dirname, join } from "node:path";
20
20
  import { createInterface } from "node:readline";
21
21
  import { DatabaseSync } from "node:sqlite";
22
+ import { estimateUsage } from "./pricing.js";
22
23
 
23
24
  // Run fn over every item in items with at most `limit` in-flight at once.
24
25
  async function mapConcurrent(items, fn, limit) {
@@ -70,6 +71,11 @@ function parseIsoMs(s) {
70
71
  return Number.isFinite(t) ? t : null;
71
72
  }
72
73
 
74
+ function tokenCount(value) {
75
+ const n = Number(value);
76
+ return Number.isFinite(n) && n >= 0 ? n : 0;
77
+ }
78
+
73
79
  function compactHome(p) {
74
80
  if (!p) return "(unknown)";
75
81
  const home = process.env.HOME || "";
@@ -144,6 +150,7 @@ function loadOpencodeStyleSqlite(dbPath, toolKey) {
144
150
  tokensReasoning: r.tokens_reasoning || 0,
145
151
  tokensTotal: tot,
146
152
  cost: r.cost || 0,
153
+ costStatus: r.cost == null ? "unavailable" : "recorded",
147
154
  model: r.model || "",
148
155
  });
149
156
  }
@@ -156,7 +163,9 @@ function loadOpencodeStyleSqlite(dbPath, toolKey) {
156
163
 
157
164
  async function loadCodexRollouts(rootDir) {
158
165
  const errors = [];
159
- const files = walkJsonl(rootDir, "rollout-");
166
+ const roots = basename(rootDir) === "sessions" ? [rootDir, join(dirname(rootDir), "archived_sessions")] : [rootDir];
167
+ const files = [...new Set(roots.flatMap((root) => walkJsonl(root, "rollout-")))];
168
+ files.sort((a, b) => Number(a.split(/[\\/]/).includes("archived_sessions")) - Number(b.split(/[\\/]/).includes("archived_sessions")) || a.localeCompare(b));
160
169
  const results = await mapConcurrent(
161
170
  files,
162
171
  async (f) => {
@@ -169,15 +178,20 @@ async function loadCodexRollouts(rootDir) {
169
178
  },
170
179
  48,
171
180
  );
172
- return { records: results.filter(Boolean), errors };
181
+ const sessions = new Map();
182
+ for (const record of results) if (record && !sessions.has(record.sessionId)) sessions.set(record.sessionId, record);
183
+ return { records: [...sessions.values()], errors };
173
184
  }
174
185
 
175
186
  async function parseCodexRollout(path) {
176
187
  // Use a streaming reader to avoid loading huge files into memory.
177
- // The 'token_count' event accumulates usage; we take the LAST one (final snapshot).
178
188
  let sessionMeta = null;
179
- let lastUsage = null;
189
+ let previous = null;
190
+ let forkWaiting = false;
191
+ let inheritedTotal = null;
192
+ const usageEvents = [];
180
193
  let lastModel = "";
194
+ let provider = "openai";
181
195
  let lastEvTs = null;
182
196
 
183
197
  const stream = createReadStream(path, { encoding: "utf8" });
@@ -195,31 +209,73 @@ async function parseCodexRollout(path) {
195
209
  const evTs = parseIsoMs(ev.timestamp);
196
210
  if (evTs) lastEvTs = evTs;
197
211
  if (t === "session_meta") {
198
- sessionMeta = { ts: ev.timestamp, cwd: p.cwd || p.CWD || "" };
212
+ sessionMeta = { ts: ev.timestamp, cwd: p.cwd || p.CWD || "", id: typeof p.id === "string" ? p.id : null };
213
+ forkWaiting = Boolean(p.forked_from_id);
214
+ if (typeof p.model_provider === "string" && p.model_provider) provider = p.model_provider;
215
+ } else if (t === "turn_context") {
216
+ forkWaiting = false;
217
+ lastModel = p.model_info?.slug || p.model || p.model_name || lastModel;
199
218
  } else if (t === "event_msg" && p.type === "token_count") {
200
219
  const info = p.info || {};
201
- const u = info.total_token_usage;
202
- if (u) lastUsage = u;
203
- const m = info.model || p.model;
220
+ const fields = ['input_tokens', 'output_tokens', 'cached_input_tokens', 'reasoning_output_tokens'];
221
+ const normalize = (u) => u && Object.fromEntries(fields.map((field) => [field, tokenCount(u[field] ?? (field === 'cached_input_tokens' ? u.cache_read_input_tokens : 0))]));
222
+ const total = normalize(info.total_token_usage);
223
+ const last = normalize(info.last_token_usage);
224
+ if (forkWaiting) {
225
+ if (total) {
226
+ previous = total;
227
+ inheritedTotal = info.total_token_usage.total_tokens ?? null;
228
+ }
229
+ continue;
230
+ }
231
+ if (inheritedTotal !== null && info.total_token_usage?.total_tokens === inheritedTotal) {
232
+ if (total) previous = total;
233
+ inheritedTotal = null;
234
+ continue;
235
+ }
236
+ inheritedTotal = null;
237
+ let delta = null;
238
+ if (total && previous) {
239
+ if (fields.every((field) => total[field] === previous[field])) continue;
240
+ const regressed = fields.some((field) => total[field] < previous[field]);
241
+ if (regressed) {
242
+ const sum = (u) => fields.reduce((n, field) => n + u[field], 0);
243
+ if (last && sum(total) > 0 && sum(last) > 0 && (sum(total) * 100 >= sum(previous) * 98 || sum(total) + sum(last) * 2 >= sum(previous))) continue;
244
+ delta = last;
245
+ } else {
246
+ delta = last || Object.fromEntries(fields.map((field) => [field, total[field] - previous[field]]));
247
+ }
248
+ } else {
249
+ delta = last || total;
250
+ }
251
+ if (total) previous = total;
252
+ else if (last && previous) previous = Object.fromEntries(fields.map((field) => [field, previous[field] + last[field]]));
253
+ const m = info.model || info.model_name || p.model;
204
254
  if (m) lastModel = m;
255
+ if (delta) usageEvents.push({ model: lastModel, provider, aggregated: !last, usage: delta });
205
256
  }
206
257
  }
207
- if (!sessionMeta || !lastUsage) return null;
258
+ if (!sessionMeta || !usageEvents.length) return null;
208
259
 
209
260
  const ts = parseIsoMs(sessionMeta.ts);
210
261
  if (!ts) return null;
211
262
  const durationMs = lastEvTs && lastEvTs > ts ? lastEvTs - ts : 0;
212
263
 
213
- const tokensInput = lastUsage.input_tokens || 0;
214
- const tokensOutput = lastUsage.output_tokens || 0;
215
- const tokensCacheRead = lastUsage.cached_input_tokens || 0;
216
- const tokensReasoning = lastUsage.reasoning_output_tokens || 0;
264
+ let tokensInput = 0, tokensOutput = 0, tokensCacheRead = 0, tokensReasoning = 0;
265
+ for (const { usage } of usageEvents) {
266
+ const cached = Math.min(usage.input_tokens, usage.cached_input_tokens);
267
+ const reasoning = Math.min(usage.output_tokens, usage.reasoning_output_tokens);
268
+ tokensInput += usage.input_tokens - cached;
269
+ tokensOutput += usage.output_tokens - reasoning;
270
+ tokensCacheRead += cached;
271
+ tokensReasoning += reasoning;
272
+ }
217
273
  const tot = tokensInput + tokensOutput + tokensCacheRead + tokensReasoning;
218
274
  if (tot === 0) return null;
219
275
 
220
276
  return {
221
277
  tool: "codex",
222
- sessionId: path.split("-").pop().replace(".jsonl", ""),
278
+ sessionId: sessionMeta.id || basename(path).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=\.jsonl$)/i)?.[0] || basename(path, ".jsonl"),
223
279
  project: compactHome(sessionMeta.cwd),
224
280
  title: "",
225
281
  week: isoWeekKey(ts),
@@ -232,8 +288,9 @@ async function parseCodexRollout(path) {
232
288
  tokensCacheWrite: 0, // codex doesn't expose cache_write separately
233
289
  tokensReasoning,
234
290
  tokensTotal: tot,
235
- cost: 0,
291
+ ...estimateUsage("codex", usageEvents),
236
292
  model: lastModel,
293
+ usageEvents,
237
294
  };
238
295
  }
239
296
 
@@ -294,8 +351,7 @@ function walkClaudeSessions(root) {
294
351
  //
295
352
  // Each top-level file under projects/<encoded-cwd>/<UUID>.jsonl is one session.
296
353
  // Per assistant line, `message.usage` carries the per-turn token counts; we
297
- // dedupe by `message.id` (Claude Code emits streaming duplicates) and sum
298
- // across unique messages. cwd / model / timestamp come from the line itself.
354
+ // merge streamed usage by message and request, then sum unique responses. cwd / model / timestamp come from the line itself.
299
355
 
300
356
  async function loadClaudeJsonl(rootDir) {
301
357
  const errors = [];
@@ -317,7 +373,9 @@ async function loadClaudeJsonl(rootDir) {
317
373
 
318
374
  async function parseClaudeSession(path) {
319
375
  const sessionId = path.split("/").pop().replace(".jsonl", "");
320
- const seenIds = new Set();
376
+ const usages = new Map();
377
+ const usageModels = new Map();
378
+ let anonymous = 0;
321
379
  let cwd = "";
322
380
  let model = "";
323
381
  let firstTs = null;
@@ -347,15 +405,21 @@ async function parseClaudeSession(path) {
347
405
  const usage = msg.usage;
348
406
  if (!usage) continue;
349
407
  const id = msg.id;
350
- if (id) {
351
- if (seenIds.has(id)) continue;
352
- seenIds.add(id);
408
+ const key = id ? JSON.stringify([id, ev.requestId || null]) : `anonymous:${anonymous++}`;
409
+ const previous = usages.get(key) || {};
410
+ const merged = {};
411
+ for (const field of ["input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"]) {
412
+ merged[field] = Math.max(tokenCount(previous[field]), tokenCount(usage[field]));
353
413
  }
414
+ usages.set(key, merged);
415
+ if (msg.model) usageModels.set(key, msg.model);
354
416
  if (!cwd && typeof ev.cwd === "string") cwd = ev.cwd;
355
417
  if (!model && typeof msg.model === "string") model = msg.model;
356
418
  const evTs = parseIsoMs(ev.timestamp);
357
419
  if (firstTs === null && evTs !== null) firstTs = evTs;
358
420
  if (evTs !== null) lastTs = evTs;
421
+ }
422
+ for (const usage of usages.values()) {
359
423
  tokensInput += usage.input_tokens || 0;
360
424
  tokensOutput += usage.output_tokens || 0;
361
425
  tokensCacheRead += usage.cache_read_input_tokens || 0;
@@ -367,6 +431,7 @@ async function parseClaudeSession(path) {
367
431
  const ts = firstTs ?? Date.now();
368
432
  const durationMs = firstTs !== null && lastTs !== null ? lastTs - firstTs : 0;
369
433
 
434
+ const usageEvents = [...usages].map(([key, usage]) => ({ model: usageModels.get(key) || "", usage }));
370
435
  return {
371
436
  tool: "claude",
372
437
  sessionId,
@@ -382,8 +447,9 @@ async function parseClaudeSession(path) {
382
447
  tokensCacheWrite,
383
448
  tokensReasoning: 0,
384
449
  tokensTotal: tot,
385
- cost: 0,
450
+ ...estimateUsage("claude", usageEvents),
386
451
  model,
452
+ usageEvents,
387
453
  };
388
454
  }
389
455