@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.
- package/README.md +7 -6
- package/package.json +12 -5
- package/src/agents/aggregator.test.ts +306 -0
- package/src/agents/aggregator.ts +44 -2
- package/src/agents/types.ts +3 -0
- package/src/cli.ts +16 -2
- package/src/demo/simulator.ts +2 -2
- package/src/plugins/agents/index.ts +3 -0
- package/src/plugins/auth-sources.ts +2 -3
- package/src/plugins/lifecycle.ts +2 -2
- package/src/plugins/loader.test.ts +1 -1
- package/src/plugins/loader.ts +15 -7
- package/src/plugins/notification-bus.ts +5 -2
- package/src/plugins/notifications/terminal-bell.ts +1 -1
- package/src/plugins/notifications/visual-flash.ts +1 -1
- package/src/plugins/plugin-context-factory.ts +4 -19
- package/src/plugins/plugin-host.test.ts +197 -0
- package/src/plugins/plugin-host.ts +45 -1
- package/src/plugins/providers/anthropic.test.ts +202 -0
- package/src/plugins/providers/anthropic.ts +21 -2
- package/src/plugins/providers/antigravity.ts +17 -1
- package/src/plugins/providers/chutes.ts +205 -0
- package/src/plugins/providers/codex.ts +17 -1
- package/src/plugins/providers/gemini.ts +17 -1
- package/src/plugins/providers/github-copilot.ts +17 -1
- package/src/plugins/providers/index.ts +1 -0
- package/src/plugins/providers/minimax.ts +17 -1
- package/src/plugins/providers/openai-api.ts +53 -1
- package/src/plugins/providers/opencode-zen.ts +1 -1
- package/src/plugins/providers/perplexity.ts +11 -2
- package/src/plugins/providers/zai.ts +17 -1
- package/src/plugins/registry.ts +9 -9
- package/src/plugins/sandbox-guard.test.ts +1 -1
- package/src/plugins/sandbox-guard.ts +1 -1
- package/src/plugins/sandbox.ts +1 -1
- package/src/plugins/themes/catppuccin-latte.ts +1 -1
- package/src/plugins/themes/catppuccin-mocha.ts +1 -1
- package/src/plugins/themes/claude-code.ts +1 -1
- package/src/plugins/themes/dracula.ts +1 -1
- package/src/plugins/themes/github-light.ts +1 -1
- package/src/plugins/themes/gruvbox-dark.ts +1 -1
- package/src/plugins/themes/gruvbox-light.ts +1 -1
- package/src/plugins/themes/kanagawa.ts +1 -1
- package/src/plugins/themes/nord.ts +1 -1
- package/src/plugins/themes/one-dark.ts +1 -1
- package/src/plugins/themes/opencode.ts +1 -1
- package/src/plugins/themes/rose-pine-dawn.ts +1 -1
- package/src/plugins/themes/rose-pine.ts +1 -1
- package/src/plugins/themes/solarized-light.ts +1 -1
- package/src/plugins/themes/tokyo-night.ts +1 -1
- package/src/plugins/update-checker.ts +2 -2
- package/src/pricing/estimator.test.ts +1 -1
- package/src/pricing/estimator.ts +1 -1
- package/src/pricing/fallback.ts +5 -1
- package/src/pricing/index.ts +1 -1
- package/src/pricing/models-dev.ts +1 -1
- package/src/storage/index.ts +6 -0
- package/src/storage/migrations/index.ts +17 -0
- package/src/storage/repos/pluginStorage.ts +35 -0
- package/src/tui/App.tsx +17 -9
- package/src/tui/components/HelpOverlay.tsx +1 -1
- package/src/tui/components/LimitGauge.tsx +12 -4
- package/src/tui/components/ProviderCard.tsx +39 -23
- package/src/tui/components/ProviderDetailPanel.tsx +1 -1
- package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
- package/src/tui/components/SessionDetailsDrawer.tsx +10 -0
- package/src/tui/components/SessionsTable.tsx +62 -37
- package/src/tui/components/SettingsModal.tsx +1 -1
- package/src/tui/components/Skeleton.tsx +9 -6
- package/src/tui/components/SortOverlay.tsx +167 -0
- package/src/tui/components/Spinner.tsx +16 -11
- package/src/tui/components/ThemePicker.tsx +1 -1
- package/src/tui/contexts/AgentSessionContext.tsx +70 -17
- package/src/tui/contexts/PluginContext.tsx +72 -14
- package/src/tui/contexts/RealTimeActivityContext.tsx +33 -20
- package/src/tui/contexts/ThemeContext.tsx +7 -4
- package/src/tui/createApp.tsx +2 -0
- package/src/tui/hooks/index.ts +1 -0
- package/src/tui/hooks/useAnimatedValue.ts +29 -26
- package/src/tui/hooks/useAnimationTick.ts +93 -0
- package/src/tui/hooks/useDashboardKeyboard.ts +11 -4
- package/src/tui/hooks/useEntranceAnimation.ts +16 -10
- package/src/tui/hooks/usePulse.ts +13 -7
- package/src/tui/hooks/useSessionEnrichedProviders.ts +1 -1
- package/src/tui/hooks/useValueFlash.ts +25 -33
- package/src/tui/index.tsx +4 -0
- package/src/tui/types/projectSort.ts +35 -0
- package/src/tui/types/sort.ts +61 -0
- package/src/tui/views/ProjectsView.tsx +104 -38
- package/src/tui/views/RealTimeDashboard.tsx +91 -8
- package/src/tui/views/SettingsView.tsx +1 -1
- package/src/utils/__tests__/error-category.test.ts +147 -0
- package/src/utils/currency.ts +4 -2
- package/src/utils/error-category.ts +91 -0
- package/src/version.ts +1 -1
- package/src/plugins/types/agent.ts +0 -108
- package/src/plugins/types/base.ts +0 -278
- package/src/plugins/types/index.ts +0 -19
- package/src/plugins/types/notification.ts +0 -51
- package/src/plugins/types/provider.ts +0 -237
- package/src/plugins/types/theme.ts +0 -78
- 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
|
[](https://github.com/tokentopapp/tokentop/releases)
|
|
17
17
|
[](https://www.typescriptlang.org/)
|
|
18
18
|
[](https://bun.sh)
|
|
19
|
+
[](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 **
|
|
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
|
-
- **
|
|
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.
|
|
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.
|
|
37
|
-
"@opentui/react": "0.1.
|
|
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.
|
|
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
|
+
});
|
package/src/agents/aggregator.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SessionUsageData } from "
|
|
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
|
+
}
|
package/src/agents/types.ts
CHANGED
|
@@ -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();
|
package/src/demo/simulator.ts
CHANGED
|
@@ -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
|
|
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");
|
package/src/plugins/lifecycle.ts
CHANGED
|
@@ -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
|
|
156
|
+
Promise.resolve(plugin.onConfigChange?.(newConfig, ctx)),
|
|
157
157
|
),
|
|
158
158
|
);
|
|
159
159
|
}
|
package/src/plugins/loader.ts
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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;
|