@tokentop/ttop 0.4.0 → 0.6.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 (102) hide show
  1. package/README.md +7 -6
  2. package/package.json +12 -5
  3. package/src/agents/aggregator.test.ts +306 -0
  4. package/src/agents/aggregator.ts +44 -2
  5. package/src/agents/types.ts +3 -0
  6. package/src/cli.ts +16 -2
  7. package/src/demo/simulator.ts +2 -2
  8. package/src/plugins/agents/index.ts +3 -0
  9. package/src/plugins/auth-sources.ts +2 -3
  10. package/src/plugins/lifecycle.ts +2 -2
  11. package/src/plugins/loader.test.ts +1 -1
  12. package/src/plugins/loader.ts +15 -7
  13. package/src/plugins/notification-bus.ts +5 -2
  14. package/src/plugins/notifications/terminal-bell.ts +1 -1
  15. package/src/plugins/notifications/visual-flash.ts +1 -1
  16. package/src/plugins/plugin-context-factory.ts +4 -19
  17. package/src/plugins/plugin-host.test.ts +197 -0
  18. package/src/plugins/plugin-host.ts +45 -1
  19. package/src/plugins/providers/anthropic.test.ts +202 -0
  20. package/src/plugins/providers/anthropic.ts +21 -2
  21. package/src/plugins/providers/antigravity.ts +17 -1
  22. package/src/plugins/providers/chutes.ts +205 -0
  23. package/src/plugins/providers/codex.ts +17 -1
  24. package/src/plugins/providers/gemini.ts +17 -1
  25. package/src/plugins/providers/github-copilot.ts +17 -1
  26. package/src/plugins/providers/index.ts +1 -0
  27. package/src/plugins/providers/minimax.ts +17 -1
  28. package/src/plugins/providers/openai-api.ts +53 -1
  29. package/src/plugins/providers/opencode-zen.ts +1 -1
  30. package/src/plugins/providers/perplexity.ts +11 -2
  31. package/src/plugins/providers/zai.ts +17 -1
  32. package/src/plugins/registry.ts +9 -9
  33. package/src/plugins/sandbox-guard.test.ts +1 -1
  34. package/src/plugins/sandbox-guard.ts +1 -1
  35. package/src/plugins/sandbox.ts +1 -1
  36. package/src/plugins/themes/catppuccin-latte.ts +1 -1
  37. package/src/plugins/themes/catppuccin-mocha.ts +1 -1
  38. package/src/plugins/themes/claude-code.ts +1 -1
  39. package/src/plugins/themes/dracula.ts +1 -1
  40. package/src/plugins/themes/github-light.ts +1 -1
  41. package/src/plugins/themes/gruvbox-dark.ts +1 -1
  42. package/src/plugins/themes/gruvbox-light.ts +1 -1
  43. package/src/plugins/themes/kanagawa.ts +1 -1
  44. package/src/plugins/themes/nord.ts +1 -1
  45. package/src/plugins/themes/one-dark.ts +1 -1
  46. package/src/plugins/themes/opencode.ts +1 -1
  47. package/src/plugins/themes/rose-pine-dawn.ts +1 -1
  48. package/src/plugins/themes/rose-pine.ts +1 -1
  49. package/src/plugins/themes/solarized-light.ts +1 -1
  50. package/src/plugins/themes/tokyo-night.ts +1 -1
  51. package/src/plugins/update-checker.ts +2 -2
  52. package/src/pricing/estimator.test.ts +1 -1
  53. package/src/pricing/estimator.ts +1 -1
  54. package/src/pricing/fallback.ts +5 -1
  55. package/src/pricing/index.ts +1 -1
  56. package/src/pricing/models-dev.ts +1 -1
  57. package/src/storage/index.ts +6 -0
  58. package/src/storage/migrations/index.ts +17 -0
  59. package/src/storage/repos/pluginStorage.ts +35 -0
  60. package/src/tui/App.tsx +17 -9
  61. package/src/tui/components/HelpOverlay.tsx +1 -1
  62. package/src/tui/components/LimitGauge.tsx +12 -4
  63. package/src/tui/components/ProviderCard.tsx +39 -23
  64. package/src/tui/components/ProviderDetailPanel.tsx +1 -1
  65. package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
  66. package/src/tui/components/SessionDetailsDrawer.tsx +10 -0
  67. package/src/tui/components/SessionsTable.tsx +62 -37
  68. package/src/tui/components/SettingsModal.tsx +1 -1
  69. package/src/tui/components/Skeleton.tsx +9 -6
  70. package/src/tui/components/SortOverlay.tsx +167 -0
  71. package/src/tui/components/Spinner.tsx +16 -11
  72. package/src/tui/components/ThemePicker.tsx +1 -1
  73. package/src/tui/contexts/AgentSessionContext.tsx +70 -17
  74. package/src/tui/contexts/PluginContext.tsx +72 -14
  75. package/src/tui/contexts/RealTimeActivityContext.tsx +33 -20
  76. package/src/tui/contexts/ThemeContext.tsx +7 -4
  77. package/src/tui/createApp.tsx +2 -0
  78. package/src/tui/hooks/index.ts +1 -0
  79. package/src/tui/hooks/useAnimatedValue.ts +29 -26
  80. package/src/tui/hooks/useAnimationTick.ts +93 -0
  81. package/src/tui/hooks/useDashboardKeyboard.ts +11 -4
  82. package/src/tui/hooks/useEntranceAnimation.ts +16 -10
  83. package/src/tui/hooks/usePulse.ts +13 -7
  84. package/src/tui/hooks/useSessionEnrichedProviders.ts +1 -1
  85. package/src/tui/hooks/useValueFlash.ts +25 -33
  86. package/src/tui/index.tsx +4 -0
  87. package/src/tui/types/projectSort.ts +35 -0
  88. package/src/tui/types/sort.ts +61 -0
  89. package/src/tui/views/ProjectsView.tsx +104 -38
  90. package/src/tui/views/RealTimeDashboard.tsx +91 -8
  91. package/src/tui/views/SettingsView.tsx +1 -1
  92. package/src/utils/__tests__/error-category.test.ts +147 -0
  93. package/src/utils/currency.ts +4 -2
  94. package/src/utils/error-category.ts +91 -0
  95. package/src/version.ts +1 -1
  96. package/src/plugins/types/agent.ts +0 -108
  97. package/src/plugins/types/base.ts +0 -278
  98. package/src/plugins/types/index.ts +0 -19
  99. package/src/plugins/types/notification.ts +0 -51
  100. package/src/plugins/types/provider.ts +0 -237
  101. package/src/plugins/types/theme.ts +0 -78
  102. package/src/storage/database.ts +0 -623
package/README.md CHANGED
@@ -16,6 +16,7 @@ Real-time terminal monitoring of LLM token usage and spending across providers a
16
16
  [![GitHub release](https://img.shields.io/github/v/release/tokentopapp/tokentop?style=flat-square)](https://github.com/tokentopapp/tokentop/releases)
17
17
  [![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
18
18
  [![Bun](https://img.shields.io/badge/Bun-runtime-f9f1e1?style=flat-square&logo=bun&logoColor=black)](https://bun.sh)
19
+ [![Visitors](https://api.visitorbadge.io/api/visitors?path=https%3A%2F%2Fgithub.com%2Ftokentopapp%2Ftokentop&labelColor=%23697689&countColor=%23ba68c8&style=flat-square)](https://visitorbadge.io/status?path=https://github.com/tokentopapp/tokentop)
19
20
 
20
21
  [Features](#features) · [Install](#installation) · [Quick Start](#quick-start) · [Keyboard Shortcuts](#keyboard-shortcuts) · [Configuration](#configuration) · [Plugins](#plugin-system)
21
22
 
@@ -25,26 +26,26 @@ Real-time terminal monitoring of LLM token usage and spending across providers a
25
26
 
26
27
  ## Why tokentop?
27
28
 
28
- You're coding with AI agents all day. Claude Code, OpenCode, Cursor — they're burning through tokens and you have no idea how fast.
29
+ You're coding with AI agents all day. Claude Code, OpenCode, Cursor, Copilot CLI — they're burning through tokens and you have no idea how fast.
29
30
 
30
31
  You check the Anthropic dashboard. It's hours behind. You check OpenAI. Different dashboard, different login. Want to see it all in one place? In real time? In your terminal, where you already live?
31
32
 
32
33
  That's tokentop.
33
34
 
34
35
  - See every session, every model, every dollar — **live**
35
- - Track costs across **10 providers** from a single dashboard
36
+ - Track costs across **11 providers** from a single dashboard
36
37
  - Set **budget alerts** so you don't wake up to a surprise bill
37
38
  - Runs **100% locally** — your usage data never leaves your machine
38
39
 
39
40
  ## Demo
40
41
 
41
- https://github.com/user-attachments/assets/0b0225c7-a137-454b-9b29-c858cdcbd86c
42
+ <https://github.com/user-attachments/assets/0b0225c7-a137-454b-9b29-c858cdcbd86c>
42
43
 
43
44
  ## Features
44
45
 
45
46
  - **Real-time dashboard** — Live token counts, costs, burn rate, and activity sparklines
46
- - **10 providers** — Anthropic, OpenAI, Google Gemini, GitHub Copilot, Codex, Perplexity, Antigravity, MiniMax, Zai, OpenCode Zen
47
- - **Session tracking** — See every coding agent session with model, tokens, cost, and duration
47
+ - **11 providers** — Anthropic, OpenAI, Google Gemini, GitHub Copilot, Codex, Perplexity, Antigravity, MiniMax, Zai, OpenCode Zen, Chutes
48
+ - **Session tracking** — See every coding agent session with model, tokens, cost, and duration. Built-in support for Claude Code, OpenCode, Cursor, and Copilot CLI
48
49
  - **Budget guardrails** — Daily, weekly, and monthly limits with visual warnings at limit percentages you set
49
50
  - **Smart sidebar** — Adaptive panel that breaks down spending by model, project, or agent
50
51
  - **Efficiency insights** — Cache leverage, output verbosity, and cost-per-request analysis to help you spend less
@@ -56,7 +57,7 @@ https://github.com/user-attachments/assets/0b0225c7-a137-454b-9b29-c858cdcbd86c
56
57
  - **Plugin system** — Extend with custom providers, agents, themes, and notifications
57
58
  - **Responsive layout** — Adapts to any terminal size; sidebar, KPI strip, header, and tables all reflow automatically from ultrawide to laptop-width
58
59
  - **Demo mode** — Explore the UI with synthetic data, no API keys needed
59
- - **Zero config** — Auto-discovers credentials from Claude Code, environment variables, and CLI auth files
60
+ - **Zero config** — Auto-discovers credentials from Claude Code, OpenCode, Cursor, Copilot CLI, environment variables, and CLI auth files
60
61
 
61
62
  ## Installation
62
63
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokentop/ttop",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",
@@ -30,14 +30,18 @@
30
30
  "test:watch": "bun test --watch",
31
31
  "driver": "bun src/tui/driver/cli.ts",
32
32
  "driver:help": "bun src/tui/driver/cli.ts --help",
33
- "snapshot": "bun src/tui/debug/snapshot.tsx"
33
+ "snapshot": "bun src/tui/debug/snapshot.tsx",
34
+ "analytics": "bun .opencode/skills/analytics/scripts/analytics.ts"
34
35
  },
35
36
  "dependencies": {
36
- "@opentui/core": "0.1.81",
37
- "@opentui/react": "0.1.81",
37
+ "@opentui/core": "0.1.86",
38
+ "@opentui/react": "0.1.86",
38
39
  "@tokentop/agent-claude-code": "^1.1.0",
40
+ "@tokentop/agent-copilot-cli": "^1.0.0",
41
+ "@tokentop/agent-cursor": "^1.2.0",
42
+ "@tokentop/agent-gemini": "^1.0.0",
39
43
  "@tokentop/agent-opencode": "^1.0.0",
40
- "@tokentop/plugin-sdk": "^1.1.0",
44
+ "@tokentop/plugin-sdk": "^1.4.0",
41
45
  "react": "^19.0.0",
42
46
  "react-devtools-core": "^7.0.1",
43
47
  "zod": "^4.0.0"
@@ -67,5 +71,8 @@
67
71
  },
68
72
  "engines": {
69
73
  "bun": ">=1.0.0"
74
+ },
75
+ "overrides": {
76
+ "@types/node": "^25.0.0"
70
77
  }
71
78
  }
@@ -0,0 +1,306 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { aggregateSessionUsage, deduplicateAggregates } from "./aggregator.ts";
3
+ import type { AgentSessionAggregate } from "./types.ts";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Helper: build a minimal AgentSessionAggregate for dedup tests
7
+ // ---------------------------------------------------------------------------
8
+ function makeAggregate(
9
+ overrides: Partial<AgentSessionAggregate> & Pick<AgentSessionAggregate, "sessionId" | "agentId">,
10
+ ): AgentSessionAggregate {
11
+ return {
12
+ agentName: overrides.agentId,
13
+ startedAt: 0,
14
+ lastActivityAt: 0,
15
+ status: "idle",
16
+ totals: { input: 0, output: 0 },
17
+ totalCostUsd: 0,
18
+ requestCount: 0,
19
+ streams: [],
20
+ costInDay: 0,
21
+ costInWeek: 0,
22
+ costInMonth: 0,
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // deduplicateAggregates
29
+ // ---------------------------------------------------------------------------
30
+ describe("deduplicateAggregates", () => {
31
+ test("returns empty array for empty input", () => {
32
+ expect(deduplicateAggregates([])).toEqual([]);
33
+ });
34
+
35
+ test("passes through unique sessions unchanged", () => {
36
+ const a = makeAggregate({ sessionId: "s1", agentId: "antigravity" });
37
+ const b = makeAggregate({ sessionId: "s2", agentId: "antigravity" });
38
+ const c = makeAggregate({ sessionId: "s3", agentId: "gemini-cli" });
39
+
40
+ const result = deduplicateAggregates([a, b, c]);
41
+ expect(result).toHaveLength(3);
42
+ expect(result.map((r) => r.sessionId).sort()).toEqual(["s1", "s2", "s3"]);
43
+ });
44
+
45
+ test("removes exact duplicates (same sessionId from two agents)", () => {
46
+ const fromAntigravity = makeAggregate({
47
+ sessionId: "dup-1",
48
+ agentId: "antigravity",
49
+ agentName: "Antigravity",
50
+ requestCount: 5,
51
+ streams: [
52
+ {
53
+ providerId: "google",
54
+ modelId: "gemini-3-flash",
55
+ tokens: { input: 100, output: 50 },
56
+ requestCount: 5,
57
+ },
58
+ ],
59
+ });
60
+ const fromGeminiCli = makeAggregate({
61
+ sessionId: "dup-1",
62
+ agentId: "gemini-cli",
63
+ agentName: "Gemini CLI",
64
+ requestCount: 5,
65
+ streams: [
66
+ {
67
+ providerId: "google",
68
+ modelId: "gemini-3-flash",
69
+ tokens: { input: 100, output: 50 },
70
+ requestCount: 5,
71
+ },
72
+ ],
73
+ });
74
+
75
+ const result = deduplicateAggregates([fromAntigravity, fromGeminiCli]);
76
+ expect(result).toHaveLength(1);
77
+ // First one encountered wins when equal
78
+ expect(result[0]!.agentId).toBe("antigravity");
79
+ });
80
+
81
+ test("keeps the aggregate with more streams", () => {
82
+ const fewer = makeAggregate({
83
+ sessionId: "dup-2",
84
+ agentId: "agent-a",
85
+ requestCount: 10,
86
+ streams: [
87
+ {
88
+ providerId: "google",
89
+ modelId: "gemini-3-flash",
90
+ tokens: { input: 100, output: 50 },
91
+ requestCount: 10,
92
+ },
93
+ ],
94
+ });
95
+ const more = makeAggregate({
96
+ sessionId: "dup-2",
97
+ agentId: "agent-b",
98
+ requestCount: 10,
99
+ streams: [
100
+ {
101
+ providerId: "google",
102
+ modelId: "gemini-3-flash",
103
+ tokens: { input: 80, output: 40 },
104
+ requestCount: 7,
105
+ },
106
+ {
107
+ providerId: "google",
108
+ modelId: "gemini-2.5-pro",
109
+ tokens: { input: 20, output: 10 },
110
+ requestCount: 3,
111
+ },
112
+ ],
113
+ });
114
+
115
+ // Regardless of order, the one with more streams wins
116
+ expect(deduplicateAggregates([fewer, more])[0]!.agentId).toBe("agent-b");
117
+ expect(deduplicateAggregates([more, fewer])[0]!.agentId).toBe("agent-b");
118
+ });
119
+
120
+ test("breaks stream-count tie by preferring more requests", () => {
121
+ const lessRequests = makeAggregate({
122
+ sessionId: "dup-3",
123
+ agentId: "agent-a",
124
+ requestCount: 3,
125
+ streams: [
126
+ {
127
+ providerId: "google",
128
+ modelId: "gemini-3-flash",
129
+ tokens: { input: 50, output: 25 },
130
+ requestCount: 3,
131
+ },
132
+ ],
133
+ });
134
+ const moreRequests = makeAggregate({
135
+ sessionId: "dup-3",
136
+ agentId: "agent-b",
137
+ requestCount: 7,
138
+ streams: [
139
+ {
140
+ providerId: "google",
141
+ modelId: "gemini-3-flash",
142
+ tokens: { input: 100, output: 50 },
143
+ requestCount: 7,
144
+ },
145
+ ],
146
+ });
147
+
148
+ expect(deduplicateAggregates([lessRequests, moreRequests])[0]!.agentId).toBe("agent-b");
149
+ expect(deduplicateAggregates([moreRequests, lessRequests])[0]!.agentId).toBe("agent-b");
150
+ });
151
+
152
+ test("keeps first encountered when streams and requests are equal", () => {
153
+ const first = makeAggregate({
154
+ sessionId: "dup-4",
155
+ agentId: "first",
156
+ requestCount: 5,
157
+ streams: [
158
+ { providerId: "google", modelId: "m", tokens: { input: 1, output: 1 }, requestCount: 5 },
159
+ ],
160
+ });
161
+ const second = makeAggregate({
162
+ sessionId: "dup-4",
163
+ agentId: "second",
164
+ requestCount: 5,
165
+ streams: [
166
+ { providerId: "google", modelId: "m", tokens: { input: 1, output: 1 }, requestCount: 5 },
167
+ ],
168
+ });
169
+
170
+ expect(deduplicateAggregates([first, second])[0]!.agentId).toBe("first");
171
+ expect(deduplicateAggregates([second, first])[0]!.agentId).toBe("second");
172
+ });
173
+
174
+ test("handles mix of unique and duplicate sessions", () => {
175
+ const unique1 = makeAggregate({ sessionId: "u1", agentId: "antigravity" });
176
+ const unique2 = makeAggregate({ sessionId: "u2", agentId: "opencode" });
177
+ const dupA = makeAggregate({
178
+ sessionId: "d1",
179
+ agentId: "antigravity",
180
+ requestCount: 5,
181
+ streams: [
182
+ { providerId: "google", modelId: "m", tokens: { input: 1, output: 1 }, requestCount: 5 },
183
+ ],
184
+ });
185
+ const dupB = makeAggregate({
186
+ sessionId: "d1",
187
+ agentId: "gemini-cli",
188
+ requestCount: 5,
189
+ streams: [
190
+ { providerId: "google", modelId: "m", tokens: { input: 1, output: 1 }, requestCount: 5 },
191
+ ],
192
+ });
193
+
194
+ const result = deduplicateAggregates([unique1, dupA, unique2, dupB]);
195
+ expect(result).toHaveLength(3);
196
+ expect(result.map((r) => r.sessionId).sort()).toEqual(["d1", "u1", "u2"]);
197
+ });
198
+ });
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // aggregateSessionUsage — basic sanity
202
+ // ---------------------------------------------------------------------------
203
+ describe("aggregateSessionUsage", () => {
204
+ const NOW = Date.now();
205
+
206
+ test("groups rows by sessionId", () => {
207
+ const result = aggregateSessionUsage({
208
+ agentId: "test",
209
+ agentName: "Test",
210
+ now: NOW,
211
+ rows: [
212
+ {
213
+ sessionId: "s1",
214
+ providerId: "google",
215
+ modelId: "m1",
216
+ tokens: { input: 10, output: 5 },
217
+ timestamp: NOW,
218
+ },
219
+ {
220
+ sessionId: "s1",
221
+ providerId: "google",
222
+ modelId: "m1",
223
+ tokens: { input: 20, output: 10 },
224
+ timestamp: NOW - 1000,
225
+ },
226
+ {
227
+ sessionId: "s2",
228
+ providerId: "google",
229
+ modelId: "m1",
230
+ tokens: { input: 30, output: 15 },
231
+ timestamp: NOW - 2000,
232
+ },
233
+ ],
234
+ });
235
+
236
+ expect(result).toHaveLength(2);
237
+
238
+ const s1 = result.find((r) => r.sessionId === "s1");
239
+ expect(s1).toBeDefined();
240
+ expect(s1!.requestCount).toBe(2);
241
+ expect(s1!.totals.input).toBe(30);
242
+ expect(s1!.totals.output).toBe(15);
243
+
244
+ const s2 = result.find((r) => r.sessionId === "s2");
245
+ expect(s2).toBeDefined();
246
+ expect(s2!.requestCount).toBe(1);
247
+ });
248
+
249
+ test("splits different models into separate streams", () => {
250
+ const result = aggregateSessionUsage({
251
+ agentId: "test",
252
+ agentName: "Test",
253
+ now: NOW,
254
+ rows: [
255
+ {
256
+ sessionId: "s1",
257
+ providerId: "google",
258
+ modelId: "flash",
259
+ tokens: { input: 10, output: 5 },
260
+ timestamp: NOW,
261
+ },
262
+ {
263
+ sessionId: "s1",
264
+ providerId: "google",
265
+ modelId: "pro",
266
+ tokens: { input: 20, output: 10 },
267
+ timestamp: NOW,
268
+ },
269
+ ],
270
+ });
271
+
272
+ expect(result).toHaveLength(1);
273
+ expect(result[0]!.streams).toHaveLength(2);
274
+ expect(result[0]!.requestCount).toBe(2);
275
+ });
276
+
277
+ test("marks session active when within threshold", () => {
278
+ const result = aggregateSessionUsage({
279
+ agentId: "test",
280
+ agentName: "Test",
281
+ now: NOW,
282
+ activeThresholdMs: 60_000,
283
+ rows: [
284
+ {
285
+ sessionId: "active",
286
+ providerId: "g",
287
+ modelId: "m",
288
+ tokens: { input: 1, output: 1 },
289
+ timestamp: NOW - 10_000,
290
+ },
291
+ {
292
+ sessionId: "idle",
293
+ providerId: "g",
294
+ modelId: "m",
295
+ tokens: { input: 1, output: 1 },
296
+ timestamp: NOW - 120_000,
297
+ },
298
+ ],
299
+ });
300
+
301
+ const active = result.find((r) => r.sessionId === "active");
302
+ const idle = result.find((r) => r.sessionId === "idle");
303
+ expect(active!.status).toBe("active");
304
+ expect(idle!.status).toBe("idle");
305
+ });
306
+ });
@@ -1,4 +1,4 @@
1
- import type { SessionUsageData } from "@/plugins/types/agent.ts";
1
+ import type { SessionUsageData } from "@tokentop/plugin-sdk";
2
2
  import {
3
3
  type AgentId,
4
4
  type AgentName,
@@ -85,6 +85,7 @@ export function aggregateSessionUsage(options: AggregateOptions): AgentSessionAg
85
85
  timestamps: number[];
86
86
  sessionUpdatedAt?: number;
87
87
  streamMap: Map<string, StreamAccumulator>;
88
+ hasEstimated: boolean;
88
89
  }
89
90
  >();
90
91
 
@@ -97,9 +98,11 @@ export function aggregateSessionUsage(options: AggregateOptions): AgentSessionAg
97
98
  timestamps: number[];
98
99
  sessionUpdatedAt?: number;
99
100
  streamMap: Map<string, StreamAccumulator>;
101
+ hasEstimated: boolean;
100
102
  } = {
101
103
  timestamps: [],
102
104
  streamMap: new Map(),
105
+ hasEstimated: false,
103
106
  };
104
107
  if (row.sessionName) newSession.sessionName = row.sessionName;
105
108
  if (row.projectPath) newSession.projectPath = row.projectPath;
@@ -107,7 +110,8 @@ export function aggregateSessionUsage(options: AggregateOptions): AgentSessionAg
107
110
  sessionMap.set(row.sessionId, newSession);
108
111
  }
109
112
 
110
- const session = sessionMap.get(row.sessionId)!;
113
+ const session = sessionMap.get(row.sessionId);
114
+ if (!session) continue;
111
115
 
112
116
  session.timestamps.push(row.timestamp);
113
117
  if (row.sessionName && !session.sessionName) {
@@ -145,6 +149,9 @@ export function aggregateSessionUsage(options: AggregateOptions): AgentSessionAg
145
149
  if (row.timestamp >= startOfDay) stream.windowed.dayTokens += msgTokens;
146
150
  if (row.timestamp >= startOfWeek) stream.windowed.weekTokens += msgTokens;
147
151
  if (row.timestamp >= startOfMonth) stream.windowed.monthTokens += msgTokens;
152
+ if ((row.metadata?.isEstimated as boolean) === true) {
153
+ session.hasEstimated = true;
154
+ }
148
155
  }
149
156
 
150
157
  const results: AgentSessionAggregate[] = [];
@@ -189,8 +196,43 @@ export function aggregateSessionUsage(options: AggregateOptions): AgentSessionAg
189
196
  };
190
197
  if (session.sessionName) aggregate.sessionName = session.sessionName;
191
198
  if (session.projectPath) aggregate.projectPath = session.projectPath;
199
+
200
+ // Propagate metadata — collected during the first pass (O(1) per session)
201
+ if (session.hasEstimated) {
202
+ aggregate.metadata = { isEstimated: true };
203
+ }
192
204
  results.push(aggregate);
193
205
  }
194
206
 
195
207
  return results.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
196
208
  }
209
+
210
+ /**
211
+ * Deduplicate aggregates that share the same sessionId.
212
+ *
213
+ * This happens when multiple agent plugins scan the same directory
214
+ * (e.g. both agent-antigravity and agent-gemini-cli read ~/.gemini/tmp/).
215
+ * For each duplicate sessionId, the richer aggregate is kept:
216
+ * 1. More streams (more provider/model pairs)
217
+ * 2. If tied, more requests
218
+ * 3. If still tied, the first one encountered
219
+ */
220
+ export function deduplicateAggregates(
221
+ aggregates: AgentSessionAggregate[],
222
+ ): AgentSessionAggregate[] {
223
+ const deduped = new Map<string, AgentSessionAggregate>();
224
+ for (const agg of aggregates) {
225
+ const existing = deduped.get(agg.sessionId);
226
+ if (!existing) {
227
+ deduped.set(agg.sessionId, agg);
228
+ continue;
229
+ }
230
+ if (
231
+ agg.streams.length > existing.streams.length ||
232
+ (agg.streams.length === existing.streams.length && agg.requestCount > existing.requestCount)
233
+ ) {
234
+ deduped.set(agg.sessionId, agg);
235
+ }
236
+ }
237
+ return Array.from(deduped.values());
238
+ }
@@ -60,6 +60,9 @@ export interface AgentSessionAggregate {
60
60
 
61
61
  /** @internal Keyed by `providerId::modelId`. Set by aggregator, consumed by costing. */
62
62
  _streamWindowedTokens?: Map<string, StreamWindowedTokens>;
63
+
64
+ /** Plugin-provided metadata merged from underlying SessionUsageData rows. */
65
+ metadata?: Record<string, unknown>;
63
66
  }
64
67
 
65
68
  export interface AgentInfo {
package/src/cli.ts CHANGED
@@ -19,6 +19,7 @@ Commands:
19
19
  demo Run with demo data (no real providers)
20
20
 
21
21
  Options:
22
+ -t, --theme <id> Launch with a specific theme (e.g. dracula, nord)
22
23
  -r, --refresh <ms> Refresh interval in milliseconds (default: 60000)
23
24
  -p, --plugin <path> Load a local plugin (file or directory, repeatable)
24
25
  -d, --debug Enable debug mode (verbose logging)
@@ -69,6 +70,7 @@ async function main() {
69
70
  let demoSeed: number | undefined;
70
71
  let demoPreset: DemoPreset | undefined;
71
72
  const cliPlugins: string[] = [];
73
+ let cliTheme: string | undefined;
72
74
 
73
75
  // Handle demo subcommand
74
76
  if (subcommand === "demo") {
@@ -96,6 +98,15 @@ async function main() {
96
98
  debug = true;
97
99
  }
98
100
 
101
+ if (arg === "-t" || arg === "--theme") {
102
+ const themeId = commandArgs[++i];
103
+ if (!themeId) {
104
+ console.error("Error: --theme requires a theme ID (e.g. dracula, nord, tokyo-night)");
105
+ process.exit(1);
106
+ }
107
+ cliTheme = themeId;
108
+ }
109
+
99
110
  if (arg === "-p" || arg === "--plugin") {
100
111
  const pluginPath = commandArgs[++i];
101
112
  if (!pluginPath) {
@@ -114,7 +125,7 @@ async function main() {
114
125
  }
115
126
  const seedStr = commandArgs[++i];
116
127
  const seed = seedStr ? parseInt(seedStr, 10) : NaN;
117
- if (isNaN(seed)) {
128
+ if (Number.isNaN(seed)) {
118
129
  console.error("Error: --seed requires a number");
119
130
  process.exit(1);
120
131
  }
@@ -137,7 +148,7 @@ async function main() {
137
148
  if (arg === "-r" || arg === "--refresh") {
138
149
  const intervalStr = commandArgs[++i];
139
150
  const interval = intervalStr ? parseInt(intervalStr, 10) : NaN;
140
- if (isNaN(interval) || interval < 1000) {
151
+ if (Number.isNaN(interval) || interval < 1000) {
141
152
  console.error("Error: --refresh must be a number >= 1000");
142
153
  process.exit(1);
143
154
  }
@@ -159,6 +170,9 @@ async function main() {
159
170
  if (demoPreset !== undefined) {
160
171
  launchOptions.demoPreset = demoPreset;
161
172
  }
173
+ if (cliTheme !== undefined) {
174
+ launchOptions.cliTheme = cliTheme;
175
+ }
162
176
  await startTui(launchOptions);
163
177
 
164
178
  const handleSignal = () => void triggerShutdown();
@@ -1,5 +1,5 @@
1
+ import type { ProviderUsageData } from "@tokentop/plugin-sdk";
1
2
  import type { AgentSessionAggregate, AgentSessionStream } from "@/agents/types.ts";
2
- import type { ProviderUsageData } from "@/plugins/types/provider.ts";
3
3
  import type { UsageEventInsert } from "@/storage/types.ts";
4
4
 
5
5
  /**
@@ -488,7 +488,7 @@ export class DemoSimulator {
488
488
  limitReached: newPercent > 95,
489
489
  limits: {
490
490
  primary: {
491
- ...existing.limits!.primary!,
491
+ ...existing.limits?.primary,
492
492
  usedPercent: newPercent,
493
493
  },
494
494
  },
@@ -1,2 +1,5 @@
1
1
  export { default as claudeCodeAgentPlugin } from "@tokentop/agent-claude-code";
2
+ export { default as copilotCliAgentPlugin } from "@tokentop/agent-copilot-cli";
3
+ export { default as cursorAgentPlugin } from "@tokentop/agent-cursor";
4
+ export { default as geminiAgentPlugin } from "@tokentop/agent-gemini";
2
5
  export { default as opencodeAgentPlugin } from "@tokentop/agent-opencode";
@@ -1,9 +1,8 @@
1
+ import type { AuthSources, OpenCodeAuthEntry, PluginPermissions } from "@tokentop/plugin-sdk";
2
+ import { PluginPermissionError } from "@tokentop/plugin-sdk";
1
3
  import * as fs from "fs/promises";
2
4
  import * as os from "os";
3
5
  import * as path from "path";
4
- import type { PluginPermissions } from "./types/base.ts";
5
- import { PluginPermissionError } from "./types/base.ts";
6
- import type { AuthSources, OpenCodeAuthEntry } from "./types/provider.ts";
7
6
 
8
7
  const OPENCODE_AUTH_PATH = path.join(os.homedir(), ".local/share/opencode/auth.json");
9
8
  const OPENCODE_CONFIG_PATH = path.join(os.homedir(), ".config/opencode/opencode.json");
@@ -5,11 +5,11 @@
5
5
  * with safeInvoke for error isolation and circuit breaker protection.
6
6
  */
7
7
 
8
+ import type { BasePlugin, PluginLifecycleContext, PluginLogger } from "@tokentop/plugin-sdk";
8
9
  import { safeInvoke } from "./plugin-host.ts";
9
10
  import { pluginRegistry } from "./registry.ts";
10
11
  import { createPluginLogger } from "./sandbox.ts";
11
12
  import { runInPluginGuard } from "./sandbox-guard.ts";
12
- import type { BasePlugin, PluginLifecycleContext, PluginLogger } from "./types/base.ts";
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
15
  // State machine
@@ -153,7 +153,7 @@ class PluginLifecycleManager {
153
153
  const ctx = this.makeCtx(entry);
154
154
  await safeInvoke(pluginId, "onConfigChange", () =>
155
155
  runInPluginGuard(pluginId, plugin.permissions, () =>
156
- Promise.resolve(plugin.onConfigChange!(newConfig, ctx)),
156
+ Promise.resolve(plugin.onConfigChange?.(newConfig, ctx)),
157
157
  ),
158
158
  );
159
159
  }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { CURRENT_API_VERSION } from "@tokentop/plugin-sdk";
2
3
  import { validatePlugin } from "./loader.ts";
3
- import { CURRENT_API_VERSION } from "./types/base.ts";
4
4
 
5
5
  const validPlugin = {
6
6
  id: "test-plugin",
@@ -1,14 +1,22 @@
1
+ import type { AnyPlugin, BasePlugin, PluginType } from "@tokentop/plugin-sdk";
2
+ import { CURRENT_API_VERSION } from "@tokentop/plugin-sdk";
1
3
  import * as fs from "fs/promises";
2
4
  import * as os from "os";
3
5
  import * as path from "path";
4
6
  import { PATHS } from "@/storage/paths.ts";
5
- import { CURRENT_API_VERSION } from "./types/base.ts";
6
- import type {
7
- AnyPlugin,
8
- PluginLoadResult,
9
- PluginType,
10
- PluginValidationResult,
11
- } from "./types/index.ts";
7
+
8
+ interface PluginValidationResult {
9
+ valid: boolean;
10
+ errors: string[];
11
+ warnings: string[];
12
+ }
13
+
14
+ interface PluginLoadResult<T extends BasePlugin = BasePlugin> {
15
+ success: boolean;
16
+ plugin?: T;
17
+ error?: string;
18
+ source: "builtin" | "local" | "npm";
19
+ }
12
20
 
13
21
  const CUSTOM_PLUGINS_DIR = PATHS.config.plugins;
14
22
 
@@ -1,8 +1,11 @@
1
+ import type {
2
+ NotificationEvent,
3
+ NotificationPlugin,
4
+ ProviderUsageData,
5
+ } from "@tokentop/plugin-sdk";
1
6
  import type { AppConfig } from "@/config/schema.ts";
2
7
  import { safeInvoke } from "./plugin-host.ts";
3
8
  import { createPluginLogger } from "./sandbox.ts";
4
- import type { NotificationEvent, NotificationPlugin } from "./types/notification.ts";
5
- import type { ProviderUsageData } from "./types/provider.ts";
6
9
 
7
10
  interface DedupEntry {
8
11
  key: string;
@@ -2,7 +2,7 @@ import type {
2
2
  NotificationContext,
3
3
  NotificationEvent,
4
4
  NotificationPlugin,
5
- } from "../types/notification.ts";
5
+ } from "@tokentop/plugin-sdk";
6
6
 
7
7
  export const terminalBellPlugin: NotificationPlugin = {
8
8
  apiVersion: 2,