@tokentop/ttop 0.6.1 → 0.8.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.
Files changed (38) hide show
  1. package/README.md +3 -2
  2. package/package.json +9 -6
  3. package/src/agents/aggregator.test.ts +269 -1
  4. package/src/agents/aggregator.ts +196 -8
  5. package/src/agents/costing.test.ts +584 -0
  6. package/src/agents/costing.ts +144 -30
  7. package/src/agents/types.ts +7 -0
  8. package/src/demo/simulator.test.ts +92 -0
  9. package/src/demo/simulator.ts +579 -211
  10. package/src/plugins/agents/index.ts +1 -0
  11. package/src/plugins/plugin-context-factory.ts +9 -1
  12. package/src/plugins/providers/anthropic.test.ts +145 -0
  13. package/src/plugins/providers/anthropic.ts +18 -12
  14. package/src/plugins/providers/chutes.ts +2 -0
  15. package/src/plugins/providers/github-copilot.ts +3 -0
  16. package/src/plugins/providers/minimax.ts +2 -0
  17. package/src/plugins/providers/openai-api.ts +4 -0
  18. package/src/plugins/providers/perplexity.test.ts +24 -0
  19. package/src/plugins/providers/perplexity.ts +1 -0
  20. package/src/plugins/providers/zai.ts +2 -0
  21. package/src/pricing/estimator.ts +7 -0
  22. package/src/pricing/index.ts +5 -0
  23. package/src/pricing/models-dev.test.ts +117 -0
  24. package/src/pricing/models-dev.ts +22 -0
  25. package/src/storage/persistence-service.test.ts +201 -0
  26. package/src/storage/persistence-service.ts +54 -5
  27. package/src/storage/repos/agentSessions.ts +49 -0
  28. package/src/tui/App.tsx +4 -4
  29. package/src/tui/components/SessionDetailsDrawer.tsx +29 -1
  30. package/src/tui/components/SessionsTable.tsx +25 -7
  31. package/src/tui/contexts/AgentSessionContext.tsx +59 -21
  32. package/src/tui/contexts/PluginContext.tsx +31 -10
  33. package/src/tui/utils/scrollFollow.test.ts +71 -0
  34. package/src/tui/utils/scrollFollow.ts +12 -0
  35. package/src/tui/views/Dashboard.tsx +0 -6
  36. package/src/tui/views/RealTimeDashboard.tsx +105 -38
  37. package/src/version.ts +1 -1
  38. package/tsconfig.json +43 -0
package/README.md CHANGED
@@ -46,7 +46,7 @@ That's tokentop.
46
46
 
47
47
  - **Real-time dashboard** — Live token counts, costs, burn rate, and activity sparklines
48
48
  - **11 providers** — Anthropic, OpenAI, Google Gemini, GitHub Copilot, Codex, Perplexity, Antigravity, MiniMax, Zai, OpenCode Zen, Chutes
49
- - **7 coding agents** — Claude Code, OpenCode, Cursor, Copilot CLI, Gemini CLI, Antigravity, and Windsurf. See every session with model, tokens, cost, and duration
49
+ - **8 coding agents** — Claude Code, OpenCode, Cursor, Copilot CLI, Gemini CLI, Antigravity, Windsurf, and Pi. See every session with model, tokens, cost, and duration
50
50
  - **Budget guardrails** — Daily, weekly, and monthly limits with visual warnings at limit percentages you set
51
51
  - **Smart sidebar** — Adaptive panel that breaks down spending by model, project, or agent
52
52
  - **Efficiency insights** — Cache leverage, output verbosity, and cost-per-request analysis to help you spend less
@@ -144,7 +144,7 @@ tokentop has 4 main views, switchable with `1`–`4`:
144
144
 
145
145
  ## Agents
146
146
 
147
- tokentop tracks sessions from 7 coding agents. Each agent is a standalone plugin — built-in agents ship with the app, community agents install from npm.
147
+ tokentop tracks sessions from 8 coding agents. Each agent is a standalone plugin — built-in agents ship with the app, community agents install from npm.
148
148
 
149
149
  | Agent | What it tracks | Plugin |
150
150
  |-------|---------------|--------|
@@ -155,6 +155,7 @@ tokentop tracks sessions from 7 coding agents. Each agent is a standalone plugin
155
155
  | [Gemini CLI](https://tokentop.app/docs/agents/gemini-cli/) | Sessions, Google OAuth | [`@tokentop/agent-gemini`](https://github.com/tokentopapp/agent-gemini) |
156
156
  | [Antigravity](https://tokentop.app/docs/agents/antigravity/) | Sessions, Google OAuth | [`@tokentop/agent-gemini`](https://github.com/tokentopapp/agent-gemini) |
157
157
  | [Windsurf](https://tokentop.app/docs/agents/windsurf/) | Sessions, Codeium auth | [`@tokentop/agent-windsurf`](https://github.com/tokentopapp/agent-windsurf) |
158
+ | [Pi](https://github.com/tokentopapp/agent-pi) | Sessions, subagent usage, Pi-based agents such as Prime | [`@tokentop/agent-pi`](https://github.com/tokentopapp/agent-pi) |
158
159
 
159
160
  All agents are auto-discovered — if you have the tool installed, tokentop finds it. See the [agent docs](https://tokentop.app/docs/agents/) for details.
160
161
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokentop/ttop",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "Real-time AI token usage monitor - htop for your API costs",
5
5
  "author": "Nigel Bazzeghin <nbazzeghin@gmail.com>",
6
6
  "license": "MIT",
@@ -11,6 +11,7 @@
11
11
  "files": [
12
12
  "bin/",
13
13
  "src/",
14
+ "tsconfig.json",
14
15
  "package.json",
15
16
  "README.md",
16
17
  "LICENSE"
@@ -34,16 +35,18 @@
34
35
  "analytics": "bun .opencode/skills/analytics/scripts/analytics.ts"
35
36
  },
36
37
  "dependencies": {
37
- "@opentui/core": "0.1.87",
38
- "@opentui/react": "0.1.87",
38
+ "@opentui/core": "0.5.14",
39
+ "@opentui/react": "0.5.14",
39
40
  "@tokentop/agent-claude-code": "^1.1.0",
40
41
  "@tokentop/agent-copilot-cli": "^1.0.0",
41
42
  "@tokentop/agent-cursor": "^1.2.0",
42
43
  "@tokentop/agent-gemini": "^1.0.0",
43
44
  "@tokentop/agent-opencode": "^1.0.0",
44
- "@tokentop/plugin-sdk": "^1.4.0",
45
+ "@tokentop/agent-pi": "^0.2.0",
46
+ "@tokentop/plugin-sdk": "^1.5.0",
45
47
  "react": "^19.0.0",
46
- "react-devtools-core": "^7.0.1",
48
+ "react-devtools-core": "^8.0.0",
49
+ "web-tree-sitter": "0.25.10",
47
50
  "zod": "^4.0.0"
48
51
  },
49
52
  "devDependencies": {
@@ -73,6 +76,6 @@
73
76
  "bun": ">=1.0.0"
74
77
  },
75
78
  "overrides": {
76
- "@types/node": "^25.0.0"
79
+ "@types/node": "^26.0.0"
77
80
  }
78
81
  }
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import type { SessionUsageData } from "@tokentop/plugin-sdk";
2
3
  import { aggregateSessionUsage, deduplicateAggregates } from "./aggregator.ts";
3
4
  import type { AgentSessionAggregate } from "./types.ts";
4
5
 
@@ -201,7 +202,40 @@ describe("deduplicateAggregates", () => {
201
202
  // aggregateSessionUsage — basic sanity
202
203
  // ---------------------------------------------------------------------------
203
204
  describe("aggregateSessionUsage", () => {
204
- const NOW = Date.now();
205
+ const NOW = new Date(2026, 3, 15, 12, 0, 0, 0).getTime();
206
+
207
+ function makeUsageRow(
208
+ overrides: Partial<SessionUsageData> &
209
+ Pick<SessionUsageData, "sessionId" | "tokens" | "timestamp">,
210
+ ): SessionUsageData {
211
+ return {
212
+ providerId: "anthropic",
213
+ modelId: "claude-sonnet-4",
214
+ ...overrides,
215
+ };
216
+ }
217
+
218
+ function aggregateSingleSession(rows: SessionUsageData[]) {
219
+ const result = aggregateSessionUsage({
220
+ agentId: "test",
221
+ agentName: "Test",
222
+ now: NOW,
223
+ rows,
224
+ });
225
+
226
+ expect(result).toHaveLength(1);
227
+ return result[0]!;
228
+ }
229
+
230
+ function getOnlyStream(rows: SessionUsageData[]) {
231
+ const session = aggregateSingleSession(rows);
232
+ expect(session.streams).toHaveLength(1);
233
+ return {
234
+ session,
235
+ stream: session.streams[0]!,
236
+ windowed: session._streamWindowedTokens?.get("anthropic::claude-sonnet-4"),
237
+ };
238
+ }
205
239
 
206
240
  test("groups rows by sessionId", () => {
207
241
  const result = aggregateSessionUsage({
@@ -303,4 +337,238 @@ describe("aggregateSessionUsage", () => {
303
337
  expect(active!.status).toBe("active");
304
338
  expect(idle!.status).toBe("idle");
305
339
  });
340
+
341
+ test("keeps all requests in the base bucket when every request is at or under 200K context", () => {
342
+ const { stream, session, windowed } = getOnlyStream([
343
+ makeUsageRow({ sessionId: "s1", timestamp: NOW, tokens: { input: 120_000, output: 1_000 } }),
344
+ makeUsageRow({
345
+ sessionId: "s1",
346
+ timestamp: NOW - 1_000,
347
+ tokens: { input: 80_000, output: 2_000 },
348
+ }),
349
+ makeUsageRow({
350
+ sessionId: "s1",
351
+ timestamp: NOW - 2_000,
352
+ tokens: { input: 200_000, output: 3_000 },
353
+ }),
354
+ ]);
355
+
356
+ expect(stream.tokens).toEqual({ input: 400_000, output: 6_000 });
357
+ expect(stream.longContextTokens).toBeUndefined();
358
+ expect(stream.longContextRequestCount).toBeUndefined();
359
+ expect(stream.hasLongContext).toBeUndefined();
360
+ expect(session.totals).toEqual({ input: 400_000, output: 6_000 });
361
+ expect(windowed).toEqual({
362
+ dayTokens: 406_000,
363
+ weekTokens: 406_000,
364
+ monthTokens: 406_000,
365
+ totalTokens: 406_000,
366
+ });
367
+ });
368
+
369
+ test("moves all requests into long-context bucket when every request exceeds 200K context", () => {
370
+ const { stream, windowed } = getOnlyStream([
371
+ makeUsageRow({
372
+ sessionId: "s1",
373
+ timestamp: NOW,
374
+ tokens: { input: 210_000, output: 1_000, cacheRead: 5_000, cacheWrite: 2_000 },
375
+ }),
376
+ makeUsageRow({
377
+ sessionId: "s1",
378
+ timestamp: NOW - 1_000,
379
+ tokens: { input: 220_000, output: 2_000, cacheRead: 10_000, cacheWrite: 4_000 },
380
+ }),
381
+ ]);
382
+
383
+ expect(stream.tokens).toEqual({ input: 0, output: 0 });
384
+ expect(stream.requestCount).toBe(2);
385
+ expect(stream.longContextTokens).toEqual({
386
+ input: 430_000,
387
+ output: 3_000,
388
+ cacheRead: 15_000,
389
+ cacheWrite: 6_000,
390
+ });
391
+ expect(stream.longContextRequestCount).toBe(2);
392
+ expect(stream.hasLongContext).toBe(true);
393
+ expect(windowed).toEqual({
394
+ dayTokens: 454_000,
395
+ weekTokens: 454_000,
396
+ monthTokens: 454_000,
397
+ totalTokens: 454_000,
398
+ longContextDayTokens: 454_000,
399
+ longContextWeekTokens: 454_000,
400
+ longContextMonthTokens: 454_000,
401
+ longContextTotalTokens: 454_000,
402
+ });
403
+ });
404
+
405
+ test("splits mixed requests into base and long-context buckets", () => {
406
+ const { stream } = getOnlyStream([
407
+ makeUsageRow({ sessionId: "s1", timestamp: NOW, tokens: { input: 100_000, output: 1_000 } }),
408
+ makeUsageRow({
409
+ sessionId: "s1",
410
+ timestamp: NOW - 1_000,
411
+ tokens: { input: 120_000, output: 2_000 },
412
+ }),
413
+ makeUsageRow({
414
+ sessionId: "s1",
415
+ timestamp: NOW - 2_000,
416
+ tokens: { input: 180_000, output: 3_000 },
417
+ }),
418
+ makeUsageRow({
419
+ sessionId: "s1",
420
+ timestamp: NOW - 3_000,
421
+ tokens: { input: 210_000, output: 4_000 },
422
+ }),
423
+ makeUsageRow({
424
+ sessionId: "s1",
425
+ timestamp: NOW - 4_000,
426
+ tokens: { input: 190_000, output: 5_000, cacheRead: 20_000 },
427
+ }),
428
+ ]);
429
+
430
+ expect(stream.tokens).toEqual({ input: 400_000, output: 6_000 });
431
+ expect(stream.longContextTokens).toEqual({ input: 400_000, output: 9_000, cacheRead: 20_000 });
432
+ expect(stream.requestCount).toBe(5);
433
+ expect(stream.longContextRequestCount).toBe(2);
434
+ expect(stream.hasLongContext).toBe(true);
435
+ });
436
+
437
+ test("treats exactly 200,000 context tokens as base bucket", () => {
438
+ const { stream, windowed } = getOnlyStream([
439
+ makeUsageRow({
440
+ sessionId: "s1",
441
+ timestamp: NOW,
442
+ tokens: { input: 150_000, output: 7_000, cacheRead: 30_000, cacheWrite: 20_000 },
443
+ }),
444
+ ]);
445
+
446
+ expect(stream.tokens).toEqual({
447
+ input: 150_000,
448
+ output: 7_000,
449
+ cacheRead: 30_000,
450
+ cacheWrite: 20_000,
451
+ });
452
+ expect(stream.longContextTokens).toBeUndefined();
453
+ expect(stream.longContextRequestCount).toBeUndefined();
454
+ expect(stream.hasLongContext).toBeUndefined();
455
+ expect(windowed?.longContextTotalTokens).toBeUndefined();
456
+ });
457
+
458
+ test("moves 200,001 context tokens into long-context bucket", () => {
459
+ const { stream, windowed } = getOnlyStream([
460
+ makeUsageRow({
461
+ sessionId: "s1",
462
+ timestamp: NOW,
463
+ tokens: { input: 150_000, output: 7_000, cacheRead: 30_001, cacheWrite: 20_000 },
464
+ }),
465
+ ]);
466
+
467
+ expect(stream.tokens).toEqual({ input: 0, output: 0 });
468
+ expect(stream.longContextTokens).toEqual({
469
+ input: 150_000,
470
+ output: 7_000,
471
+ cacheRead: 30_001,
472
+ cacheWrite: 20_000,
473
+ });
474
+ expect(stream.longContextRequestCount).toBe(1);
475
+ expect(stream.hasLongContext).toBe(true);
476
+ expect(windowed?.longContextTotalTokens).toBe(207_001);
477
+ });
478
+
479
+ test("uses cache tokens when cache-only growth crosses the threshold", () => {
480
+ const { stream } = getOnlyStream([
481
+ makeUsageRow({
482
+ sessionId: "s1",
483
+ timestamp: NOW,
484
+ tokens: { input: 50_000, output: 1_000, cacheRead: 180_000 },
485
+ }),
486
+ ]);
487
+
488
+ expect(stream.tokens).toEqual({ input: 0, output: 0 });
489
+ expect(stream.longContextTokens).toEqual({ input: 50_000, output: 1_000, cacheRead: 180_000 });
490
+ expect(stream.longContextRequestCount).toBe(1);
491
+ });
492
+
493
+ test("treats missing cacheRead and cacheWrite as zero when computing context size", () => {
494
+ const { stream } = getOnlyStream([
495
+ makeUsageRow({ sessionId: "s1", timestamp: NOW, tokens: { input: 199_999, output: 1_000 } }),
496
+ makeUsageRow({
497
+ sessionId: "s1",
498
+ timestamp: NOW - 1_000,
499
+ tokens: { input: 200_001, output: 2_000 },
500
+ }),
501
+ ]);
502
+
503
+ expect(stream.tokens).toEqual({ input: 199_999, output: 1_000 });
504
+ expect(stream.longContextTokens).toEqual({ input: 200_001, output: 2_000 });
505
+ expect(stream.longContextRequestCount).toBe(1);
506
+ });
507
+
508
+ test("splits windowed tokens correctly between total and long-context buckets", () => {
509
+ const { windowed } = getOnlyStream([
510
+ makeUsageRow({
511
+ sessionId: "s1",
512
+ timestamp: NOW - 1_000,
513
+ tokens: { input: 210_000, output: 1_000 },
514
+ }),
515
+ makeUsageRow({
516
+ sessionId: "s1",
517
+ timestamp: NOW - 60 * 60 * 1000,
518
+ tokens: { input: 90_000, output: 2_000 },
519
+ }),
520
+ makeUsageRow({
521
+ sessionId: "s1",
522
+ timestamp: NOW - 2 * 24 * 60 * 60 * 1000,
523
+ tokens: { input: 205_000, output: 3_000 },
524
+ }),
525
+ makeUsageRow({
526
+ sessionId: "s1",
527
+ timestamp: NOW - 10 * 24 * 60 * 60 * 1000,
528
+ tokens: { input: 80_000, output: 4_000 },
529
+ }),
530
+ makeUsageRow({
531
+ sessionId: "s1",
532
+ timestamp: new Date(
533
+ new Date(NOW).getFullYear(),
534
+ new Date(NOW).getMonth() - 1,
535
+ 15,
536
+ ).getTime(),
537
+ tokens: { input: 220_000, output: 5_000 },
538
+ }),
539
+ ]);
540
+
541
+ expect(windowed).toEqual({
542
+ dayTokens: 303_000,
543
+ weekTokens: 511_000,
544
+ monthTokens: 595_000,
545
+ totalTokens: 820_000,
546
+ longContextDayTokens: 211_000,
547
+ longContextWeekTokens: 419_000,
548
+ longContextMonthTokens: 419_000,
549
+ longContextTotalTokens: 644_000,
550
+ });
551
+ });
552
+
553
+ test("does not produce division-by-zero or NaN artifacts when every request is long-context", () => {
554
+ const { stream, session, windowed } = getOnlyStream([
555
+ makeUsageRow({ sessionId: "s1", timestamp: NOW, tokens: { input: 300_000, output: 5_000 } }),
556
+ makeUsageRow({
557
+ sessionId: "s1",
558
+ timestamp: NOW - 1_000,
559
+ tokens: { input: 250_000, output: 6_000 },
560
+ }),
561
+ ]);
562
+
563
+ expect(stream.tokens).toEqual({ input: 0, output: 0 });
564
+ expect(stream.longContextTokens).toEqual({ input: 550_000, output: 11_000 });
565
+ expect(stream.longContextRequestCount).toBe(2);
566
+ expect(stream.hasLongContext).toBe(true);
567
+ expect(Number.isNaN(stream.tokens.input)).toBe(false);
568
+ expect(Number.isNaN(stream.tokens.output)).toBe(false);
569
+ expect(Number.isNaN(session.totals.input)).toBe(false);
570
+ expect(Number.isNaN(session.totals.output)).toBe(false);
571
+ expect(Number.isNaN(windowed!.totalTokens)).toBe(false);
572
+ expect(Number.isNaN(windowed!.longContextTotalTokens!)).toBe(false);
573
+ });
306
574
  });
@@ -1,4 +1,5 @@
1
1
  import type { SessionUsageData } from "@tokentop/plugin-sdk";
2
+ import { LONG_CONTEXT_THRESHOLD } from "../pricing/estimator.ts";
2
3
  import {
3
4
  type AgentId,
4
5
  type AgentName,
@@ -64,9 +65,35 @@ interface StreamAccumulator {
64
65
  key: StreamKey;
65
66
  tokens: TokenCounts;
66
67
  requestCount: number;
68
+ longContextTokens?: TokenCounts;
69
+ longContextRequestCount?: number;
67
70
  windowed: StreamWindowedTokens;
68
71
  }
69
72
 
73
+ function zeroTokens(): TokenCounts {
74
+ return { input: 0, output: 0 };
75
+ }
76
+
77
+ function normalizeTokens(tokens: TokenCounts): TokenCounts {
78
+ const normalized: TokenCounts = {
79
+ input: tokens.input,
80
+ output: tokens.output,
81
+ };
82
+
83
+ const cacheRead = tokens.cacheRead ?? 0;
84
+ const cacheWrite = tokens.cacheWrite ?? 0;
85
+
86
+ if (cacheRead > 0) normalized.cacheRead = cacheRead;
87
+ if (cacheWrite > 0) normalized.cacheWrite = cacheWrite;
88
+
89
+ return normalized;
90
+ }
91
+
92
+ function getContextSize(tokens: TokenCounts): number {
93
+ // contextSize = input + cacheRead + cacheWrite — the full prompt context sent to the model
94
+ return tokens.input + (tokens.cacheRead ?? 0) + (tokens.cacheWrite ?? 0);
95
+ }
96
+
70
97
  export function aggregateSessionUsage(options: AggregateOptions): AgentSessionAggregate[] {
71
98
  const {
72
99
  agentId,
@@ -134,21 +161,52 @@ export function aggregateSessionUsage(options: AggregateOptions): AgentSessionAg
134
161
  if (!stream) {
135
162
  stream = {
136
163
  key: streamKey,
137
- tokens: { input: 0, output: 0 },
164
+ tokens: zeroTokens(),
138
165
  requestCount: 0,
139
166
  windowed: { dayTokens: 0, weekTokens: 0, monthTokens: 0, totalTokens: 0 },
140
167
  };
141
168
  session.streamMap.set(streamKeyStr, stream);
142
169
  }
143
170
 
144
- stream.tokens = sumTokens(stream.tokens, row.tokens);
171
+ const contextSize = getContextSize(row.tokens);
172
+ const bucketTokens = normalizeTokens(row.tokens);
173
+
174
+ if (contextSize > LONG_CONTEXT_THRESHOLD) {
175
+ stream.longContextTokens = sumTokens(stream.longContextTokens ?? zeroTokens(), bucketTokens);
176
+ stream.longContextRequestCount = (stream.longContextRequestCount ?? 0) + 1;
177
+ } else {
178
+ stream.tokens = sumTokens(stream.tokens, bucketTokens);
179
+ }
180
+
145
181
  stream.requestCount += 1;
146
182
 
147
183
  const msgTokens = totalTokenCount(row.tokens);
148
184
  stream.windowed.totalTokens += msgTokens;
149
- if (row.timestamp >= startOfDay) stream.windowed.dayTokens += msgTokens;
150
- if (row.timestamp >= startOfWeek) stream.windowed.weekTokens += msgTokens;
151
- if (row.timestamp >= startOfMonth) stream.windowed.monthTokens += msgTokens;
185
+ if (contextSize > LONG_CONTEXT_THRESHOLD) {
186
+ stream.windowed.longContextTotalTokens =
187
+ (stream.windowed.longContextTotalTokens ?? 0) + msgTokens;
188
+ }
189
+ if (row.timestamp >= startOfDay) {
190
+ stream.windowed.dayTokens += msgTokens;
191
+ if (contextSize > LONG_CONTEXT_THRESHOLD) {
192
+ stream.windowed.longContextDayTokens =
193
+ (stream.windowed.longContextDayTokens ?? 0) + msgTokens;
194
+ }
195
+ }
196
+ if (row.timestamp >= startOfWeek) {
197
+ stream.windowed.weekTokens += msgTokens;
198
+ if (contextSize > LONG_CONTEXT_THRESHOLD) {
199
+ stream.windowed.longContextWeekTokens =
200
+ (stream.windowed.longContextWeekTokens ?? 0) + msgTokens;
201
+ }
202
+ }
203
+ if (row.timestamp >= startOfMonth) {
204
+ stream.windowed.monthTokens += msgTokens;
205
+ if (contextSize > LONG_CONTEXT_THRESHOLD) {
206
+ stream.windowed.longContextMonthTokens =
207
+ (stream.windowed.longContextMonthTokens ?? 0) + msgTokens;
208
+ }
209
+ }
152
210
  if ((row.metadata?.isEstimated as boolean) === true) {
153
211
  session.hasEstimated = true;
154
212
  }
@@ -168,13 +226,24 @@ export function aggregateSessionUsage(options: AggregateOptions): AgentSessionAg
168
226
  let totalRequestCount = 0;
169
227
 
170
228
  for (const [streamKeyStr, stream] of session.streamMap) {
171
- streams.push({
229
+ const totalStreamTokens = sumTokens(stream.tokens, stream.longContextTokens ?? zeroTokens());
230
+ const streamAggregate: AgentSessionStream = {
172
231
  providerId: stream.key.providerId,
173
232
  modelId: stream.key.modelId,
174
233
  tokens: stream.tokens,
175
234
  requestCount: stream.requestCount,
176
- });
177
- totals = sumTokens(totals, stream.tokens);
235
+ };
236
+
237
+ if (stream.longContextTokens) {
238
+ streamAggregate.longContextTokens = stream.longContextTokens;
239
+ }
240
+ if (stream.longContextRequestCount) {
241
+ streamAggregate.longContextRequestCount = stream.longContextRequestCount;
242
+ streamAggregate.hasLongContext = true;
243
+ }
244
+
245
+ streams.push(streamAggregate);
246
+ totals = sumTokens(totals, totalStreamTokens);
178
247
  totalRequestCount += stream.requestCount;
179
248
  streamWindowedTokens.set(streamKeyStr, stream.windowed);
180
249
  }
@@ -236,3 +305,122 @@ export function deduplicateAggregates(
236
305
  }
237
306
  return Array.from(deduped.values());
238
307
  }
308
+
309
+ export interface CrossAgentShadowMetadata {
310
+ kind: "cross-agent-shadow";
311
+ shadowOfAgentId: string;
312
+ shadowOfSessionId: string;
313
+ reason: "proxy-sdk-session-id" | "heuristic";
314
+ }
315
+
316
+ export function isCrossAgentShadow(session: AgentSessionAggregate): boolean {
317
+ const dedup = session.metadata?.dedup as { kind?: string } | undefined;
318
+ return dedup?.kind === "cross-agent-shadow";
319
+ }
320
+
321
+ export function selectEffectiveSessions(
322
+ sessions: AgentSessionAggregate[],
323
+ ): AgentSessionAggregate[] {
324
+ return sessions.filter((s) => !isCrossAgentShadow(s));
325
+ }
326
+
327
+ const CROSS_AGENT_TIMESTAMP_TOLERANCE_MS = 120_000;
328
+
329
+ function normalizeProjectPath(p: string | undefined): string {
330
+ if (!p) return "";
331
+ return p.replace(/\/+$/, "");
332
+ }
333
+
334
+ export function markCrossAgentShadows(
335
+ aggregates: AgentSessionAggregate[],
336
+ ): AgentSessionAggregate[] {
337
+ const opencodeSessions: AgentSessionAggregate[] = [];
338
+ const claudeCodeById = new Map<string, AgentSessionAggregate>();
339
+ const claudeCodeByPath = new Map<string, AgentSessionAggregate[]>();
340
+ const ccStreamKeys = new Map<string, string[]>();
341
+
342
+ for (const agg of aggregates) {
343
+ if (agg.agentId === "opencode") {
344
+ opencodeSessions.push(agg);
345
+ } else if (agg.agentId === "claude-code") {
346
+ claudeCodeById.set(agg.sessionId, agg);
347
+ const path = normalizeProjectPath(agg.projectPath);
348
+ if (path) {
349
+ const bucket = claudeCodeByPath.get(path);
350
+ if (bucket) bucket.push(agg);
351
+ else claudeCodeByPath.set(path, [agg]);
352
+ }
353
+ const keys: string[] = new Array(agg.streams.length);
354
+ for (let i = 0; i < agg.streams.length; i++) {
355
+ const s = agg.streams[i]!;
356
+ keys[i] = `${s.providerId}::${s.modelId}`;
357
+ }
358
+ ccStreamKeys.set(agg.sessionId, keys);
359
+ }
360
+ }
361
+
362
+ if (opencodeSessions.length === 0 || claudeCodeById.size === 0) {
363
+ return aggregates;
364
+ }
365
+
366
+ const claimed = new Set<string>();
367
+
368
+ for (const oc of opencodeSessions) {
369
+ const proxy = oc.metadata?.proxy as { sdkSessionId?: string } | undefined;
370
+ if (!proxy?.sdkSessionId) continue;
371
+ const cc = claudeCodeById.get(proxy.sdkSessionId);
372
+ if (!cc || claimed.has(cc.sessionId)) continue;
373
+ cc.metadata = {
374
+ ...cc.metadata,
375
+ dedup: {
376
+ kind: "cross-agent-shadow",
377
+ shadowOfAgentId: "opencode",
378
+ shadowOfSessionId: oc.sessionId,
379
+ reason: "proxy-sdk-session-id",
380
+ } satisfies CrossAgentShadowMetadata,
381
+ };
382
+ claimed.add(cc.sessionId);
383
+ }
384
+
385
+ for (const oc of opencodeSessions) {
386
+ if (oc.streams.length === 0) continue;
387
+ const ocPath = normalizeProjectPath(oc.projectPath);
388
+ if (!ocPath) continue;
389
+ const bucket = claudeCodeByPath.get(ocPath);
390
+ if (!bucket) continue;
391
+
392
+ const ocKeys = new Set<string>();
393
+ for (const s of oc.streams) ocKeys.add(`${s.providerId}::${s.modelId}`);
394
+
395
+ for (const cc of bucket) {
396
+ if (claimed.has(cc.sessionId)) continue;
397
+ if (Math.abs(oc.lastActivityAt - cc.lastActivityAt) > CROSS_AGENT_TIMESTAMP_TOLERANCE_MS) {
398
+ continue;
399
+ }
400
+ const ccKeys = ccStreamKeys.get(cc.sessionId);
401
+ if (!ccKeys || ccKeys.length === 0) continue;
402
+ let overlap = false;
403
+ for (const k of ccKeys) {
404
+ if (ocKeys.has(k)) {
405
+ overlap = true;
406
+ break;
407
+ }
408
+ }
409
+ if (!overlap) continue;
410
+
411
+ cc.metadata = {
412
+ ...cc.metadata,
413
+ dedup: {
414
+ kind: "cross-agent-shadow",
415
+ shadowOfAgentId: "opencode",
416
+ shadowOfSessionId: oc.sessionId,
417
+ reason: "heuristic",
418
+ } satisfies CrossAgentShadowMetadata,
419
+ };
420
+ claimed.add(cc.sessionId);
421
+ break;
422
+ }
423
+ }
424
+
425
+ return aggregates;
426
+ }