@katyella/legio 0.1.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/CHANGELOG.md +422 -0
- package/LICENSE +21 -0
- package/README.md +555 -0
- package/agents/builder.md +141 -0
- package/agents/coordinator.md +351 -0
- package/agents/cto.md +196 -0
- package/agents/gateway.md +276 -0
- package/agents/lead.md +281 -0
- package/agents/merger.md +156 -0
- package/agents/monitor.md +212 -0
- package/agents/reviewer.md +142 -0
- package/agents/scout.md +131 -0
- package/agents/supervisor.md +416 -0
- package/bin/legio.mjs +38 -0
- package/package.json +77 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +102 -0
- package/src/agents/hooks-deployer.test.ts +1820 -0
- package/src/agents/hooks-deployer.ts +574 -0
- package/src/agents/identity.test.ts +614 -0
- package/src/agents/identity.ts +385 -0
- package/src/agents/lifecycle.test.ts +202 -0
- package/src/agents/lifecycle.ts +184 -0
- package/src/agents/manifest.test.ts +558 -0
- package/src/agents/manifest.ts +297 -0
- package/src/agents/overlay.test.ts +592 -0
- package/src/agents/overlay.ts +316 -0
- package/src/beads/client.test.ts +210 -0
- package/src/beads/client.ts +227 -0
- package/src/beads/molecules.test.ts +320 -0
- package/src/beads/molecules.ts +209 -0
- package/src/commands/agents.test.ts +325 -0
- package/src/commands/agents.ts +286 -0
- package/src/commands/clean.test.ts +730 -0
- package/src/commands/clean.ts +653 -0
- package/src/commands/completions.test.ts +346 -0
- package/src/commands/completions.ts +950 -0
- package/src/commands/coordinator.test.ts +1524 -0
- package/src/commands/coordinator.ts +880 -0
- package/src/commands/costs.test.ts +1015 -0
- package/src/commands/costs.ts +473 -0
- package/src/commands/dashboard.test.ts +94 -0
- package/src/commands/dashboard.ts +607 -0
- package/src/commands/doctor.test.ts +295 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/down.test.ts +308 -0
- package/src/commands/down.ts +124 -0
- package/src/commands/errors.test.ts +648 -0
- package/src/commands/errors.ts +255 -0
- package/src/commands/feed.test.ts +579 -0
- package/src/commands/feed.ts +368 -0
- package/src/commands/gateway.test.ts +698 -0
- package/src/commands/gateway.ts +419 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +539 -0
- package/src/commands/hooks.test.ts +292 -0
- package/src/commands/hooks.ts +210 -0
- package/src/commands/init.test.ts +211 -0
- package/src/commands/init.ts +622 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +455 -0
- package/src/commands/log.test.ts +1556 -0
- package/src/commands/log.ts +752 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +544 -0
- package/src/commands/mail.test.ts +1726 -0
- package/src/commands/mail.ts +926 -0
- package/src/commands/merge.test.ts +676 -0
- package/src/commands/merge.ts +374 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +150 -0
- package/src/commands/monitor.test.ts +151 -0
- package/src/commands/monitor.ts +394 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +373 -0
- package/src/commands/prime.test.ts +467 -0
- package/src/commands/prime.ts +386 -0
- package/src/commands/replay.test.ts +742 -0
- package/src/commands/replay.ts +367 -0
- package/src/commands/run.test.ts +443 -0
- package/src/commands/run.ts +365 -0
- package/src/commands/server.test.ts +626 -0
- package/src/commands/server.ts +298 -0
- package/src/commands/sling.test.ts +810 -0
- package/src/commands/sling.ts +700 -0
- package/src/commands/spec.test.ts +206 -0
- package/src/commands/spec.ts +171 -0
- package/src/commands/status.test.ts +276 -0
- package/src/commands/status.ts +339 -0
- package/src/commands/stop.test.ts +357 -0
- package/src/commands/stop.ts +119 -0
- package/src/commands/supervisor.test.ts +186 -0
- package/src/commands/supervisor.ts +544 -0
- package/src/commands/trace.test.ts +746 -0
- package/src/commands/trace.ts +332 -0
- package/src/commands/up.test.ts +597 -0
- package/src/commands/up.ts +275 -0
- package/src/commands/watch.test.ts +152 -0
- package/src/commands/watch.ts +238 -0
- package/src/commands/worktree.test.ts +648 -0
- package/src/commands/worktree.ts +266 -0
- package/src/config.test.ts +496 -0
- package/src/config.ts +616 -0
- package/src/doctor/agents.test.ts +448 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +184 -0
- package/src/doctor/config-check.ts +185 -0
- package/src/doctor/consistency.test.ts +645 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +284 -0
- package/src/doctor/databases.ts +211 -0
- package/src/doctor/dependencies.test.ts +150 -0
- package/src/doctor/dependencies.ts +179 -0
- package/src/doctor/logs.test.ts +244 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +210 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +285 -0
- package/src/doctor/structure.ts +195 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +130 -0
- package/src/doctor/version.ts +131 -0
- package/src/e2e/chat-flow.test.ts +346 -0
- package/src/e2e/init-sling-lifecycle.test.ts +288 -0
- package/src/errors.test.ts +21 -0
- package/src/errors.ts +246 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +344 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/global-setup.ts +14 -0
- package/src/index.ts +339 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +118 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +812 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +258 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +873 -0
- package/src/mail/client.ts +236 -0
- package/src/mail/store.test.ts +815 -0
- package/src/mail/store.ts +402 -0
- package/src/merge/queue.test.ts +449 -0
- package/src/merge/queue.ts +262 -0
- package/src/merge/resolver.test.ts +1453 -0
- package/src/merge/resolver.ts +759 -0
- package/src/metrics/store.test.ts +1167 -0
- package/src/metrics/store.ts +511 -0
- package/src/metrics/summary.test.ts +397 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +643 -0
- package/src/metrics/transcript.ts +351 -0
- package/src/mulch/client.test.ts +547 -0
- package/src/mulch/client.ts +416 -0
- package/src/server/audit-store.test.ts +384 -0
- package/src/server/audit-store.ts +257 -0
- package/src/server/headless.test.ts +180 -0
- package/src/server/headless.ts +151 -0
- package/src/server/index.test.ts +241 -0
- package/src/server/index.ts +317 -0
- package/src/server/public/app.js +187 -0
- package/src/server/public/apple-touch-icon.png +0 -0
- package/src/server/public/components/agent-badge.js +37 -0
- package/src/server/public/components/data-table.js +114 -0
- package/src/server/public/components/gateway-chat.js +256 -0
- package/src/server/public/components/issue-card.js +96 -0
- package/src/server/public/components/layout.js +88 -0
- package/src/server/public/components/message-bubble.js +120 -0
- package/src/server/public/components/stat-card.js +26 -0
- package/src/server/public/components/terminal-panel.js +140 -0
- package/src/server/public/favicon-16.png +0 -0
- package/src/server/public/favicon-32.png +0 -0
- package/src/server/public/favicon.ico +0 -0
- package/src/server/public/favicon.png +0 -0
- package/src/server/public/index.html +64 -0
- package/src/server/public/lib/api.js +35 -0
- package/src/server/public/lib/markdown.js +8 -0
- package/src/server/public/lib/preact-setup.js +8 -0
- package/src/server/public/lib/state.js +99 -0
- package/src/server/public/lib/utils.js +309 -0
- package/src/server/public/lib/ws.js +79 -0
- package/src/server/public/views/chat.js +983 -0
- package/src/server/public/views/costs.js +692 -0
- package/src/server/public/views/dashboard.js +781 -0
- package/src/server/public/views/gateway-chat.js +622 -0
- package/src/server/public/views/inspect.js +399 -0
- package/src/server/public/views/issues.js +470 -0
- package/src/server/public/views/setup.js +94 -0
- package/src/server/public/views/task-detail.js +422 -0
- package/src/server/routes.test.ts +3816 -0
- package/src/server/routes.ts +1964 -0
- package/src/server/websocket.test.ts +288 -0
- package/src/server/websocket.ts +196 -0
- package/src/sessions/compat.test.ts +109 -0
- package/src/sessions/compat.ts +17 -0
- package/src/sessions/store.test.ts +969 -0
- package/src/sessions/store.ts +480 -0
- package/src/test-helpers.test.ts +97 -0
- package/src/test-helpers.ts +143 -0
- package/src/types.ts +708 -0
- package/src/watchdog/daemon.test.ts +1233 -0
- package/src/watchdog/daemon.ts +533 -0
- package/src/watchdog/health.test.ts +371 -0
- package/src/watchdog/health.ts +248 -0
- package/src/watchdog/triage.test.ts +162 -0
- package/src/watchdog/triage.ts +193 -0
- package/src/worktree/manager.test.ts +444 -0
- package/src/worktree/manager.ts +224 -0
- package/src/worktree/tmux.test.ts +1238 -0
- package/src/worktree/tmux.ts +644 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +132 -0
- package/templates/overlay.md.tmpl +79 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: legio costs [--agent <name>] [--run <id>] [--by-capability] [--last <n>] [--json]
|
|
3
|
+
*
|
|
4
|
+
* Shows token/cost analysis and breakdown for agent sessions.
|
|
5
|
+
* Data source: metrics.db via createMetricsStore().
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { access } from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { loadConfig } from "../config.ts";
|
|
11
|
+
import { ValidationError } from "../errors.ts";
|
|
12
|
+
import { color } from "../logging/color.ts";
|
|
13
|
+
import { createMetricsStore } from "../metrics/store.ts";
|
|
14
|
+
import { openSessionStore } from "../sessions/compat.ts";
|
|
15
|
+
import type { SessionMetrics } from "../types.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse a named flag value from args.
|
|
19
|
+
*/
|
|
20
|
+
function getFlag(args: string[], flag: string): string | undefined {
|
|
21
|
+
const idx = args.indexOf(flag);
|
|
22
|
+
if (idx === -1 || idx + 1 >= args.length) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
return args[idx + 1];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function hasFlag(args: string[], flag: string): boolean {
|
|
29
|
+
return args.includes(flag);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Format a number with thousands separators (e.g., 12345 -> "12,345"). */
|
|
33
|
+
function formatNumber(n: number): string {
|
|
34
|
+
return n.toLocaleString("en-US");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Format a cost value as "$X.XX". Returns "$0.00" for null/undefined. */
|
|
38
|
+
function formatCost(cost: number | null): string {
|
|
39
|
+
if (cost === null || cost === undefined) {
|
|
40
|
+
return "$0.00";
|
|
41
|
+
}
|
|
42
|
+
return `$${cost.toFixed(2)}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Right-pad a string to the given width. */
|
|
46
|
+
function padRight(str: string, width: number): string {
|
|
47
|
+
return str.length >= width ? str : str + " ".repeat(width - str.length);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Left-pad a string to the given width. */
|
|
51
|
+
function padLeft(str: string, width: number): string {
|
|
52
|
+
return str.length >= width ? str : " ".repeat(width - str.length) + str;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Aggregate totals from a list of SessionMetrics. */
|
|
56
|
+
interface Totals {
|
|
57
|
+
inputTokens: number;
|
|
58
|
+
outputTokens: number;
|
|
59
|
+
cacheTokens: number;
|
|
60
|
+
costUsd: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function computeTotals(sessions: SessionMetrics[]): Totals {
|
|
64
|
+
let inputTokens = 0;
|
|
65
|
+
let outputTokens = 0;
|
|
66
|
+
let cacheTokens = 0;
|
|
67
|
+
let costUsd = 0;
|
|
68
|
+
for (const s of sessions) {
|
|
69
|
+
inputTokens += s.inputTokens;
|
|
70
|
+
outputTokens += s.outputTokens;
|
|
71
|
+
cacheTokens += s.cacheReadTokens + s.cacheCreationTokens;
|
|
72
|
+
costUsd += s.estimatedCostUsd ?? 0;
|
|
73
|
+
}
|
|
74
|
+
return { inputTokens, outputTokens, cacheTokens, costUsd };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Group SessionMetrics by capability. */
|
|
78
|
+
interface CapabilityGroup {
|
|
79
|
+
capability: string;
|
|
80
|
+
sessions: SessionMetrics[];
|
|
81
|
+
totals: Totals;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function groupByCapability(sessions: SessionMetrics[]): CapabilityGroup[] {
|
|
85
|
+
const groups = new Map<string, SessionMetrics[]>();
|
|
86
|
+
for (const s of sessions) {
|
|
87
|
+
const existing = groups.get(s.capability);
|
|
88
|
+
if (existing) {
|
|
89
|
+
existing.push(s);
|
|
90
|
+
} else {
|
|
91
|
+
groups.set(s.capability, [s]);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const result: CapabilityGroup[] = [];
|
|
95
|
+
for (const [capability, capSessions] of groups) {
|
|
96
|
+
result.push({
|
|
97
|
+
capability,
|
|
98
|
+
sessions: capSessions,
|
|
99
|
+
totals: computeTotals(capSessions),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
// Sort by cost descending
|
|
103
|
+
result.sort((a, b) => b.totals.costUsd - a.totals.costUsd);
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Print the standard per-agent cost summary table. */
|
|
108
|
+
function printCostSummary(sessions: SessionMetrics[]): void {
|
|
109
|
+
const w = process.stdout.write.bind(process.stdout);
|
|
110
|
+
const separator = "\u2500".repeat(70);
|
|
111
|
+
|
|
112
|
+
w(`${color.bold}Cost Summary${color.reset}\n`);
|
|
113
|
+
w(`${"=".repeat(70)}\n`);
|
|
114
|
+
|
|
115
|
+
if (sessions.length === 0) {
|
|
116
|
+
w(`${color.dim}No session data found.${color.reset}\n`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
w(
|
|
121
|
+
`${padRight("Agent", 19)}${padRight("Capability", 12)}` +
|
|
122
|
+
`${padLeft("Input", 10)}${padLeft("Output", 10)}` +
|
|
123
|
+
`${padLeft("Cache", 10)}${padLeft("Cost", 10)}\n`,
|
|
124
|
+
);
|
|
125
|
+
w(`${color.dim}${separator}${color.reset}\n`);
|
|
126
|
+
|
|
127
|
+
for (const s of sessions) {
|
|
128
|
+
const cacheTotal = s.cacheReadTokens + s.cacheCreationTokens;
|
|
129
|
+
w(
|
|
130
|
+
`${padRight(s.agentName, 19)}${padRight(s.capability, 12)}` +
|
|
131
|
+
`${padLeft(formatNumber(s.inputTokens), 10)}` +
|
|
132
|
+
`${padLeft(formatNumber(s.outputTokens), 10)}` +
|
|
133
|
+
`${padLeft(formatNumber(cacheTotal), 10)}` +
|
|
134
|
+
`${padLeft(formatCost(s.estimatedCostUsd), 10)}\n`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const totals = computeTotals(sessions);
|
|
139
|
+
w(`${color.dim}${separator}${color.reset}\n`);
|
|
140
|
+
w(
|
|
141
|
+
`${color.green}${color.bold}${padRight("Total", 31)}` +
|
|
142
|
+
`${padLeft(formatNumber(totals.inputTokens), 10)}` +
|
|
143
|
+
`${padLeft(formatNumber(totals.outputTokens), 10)}` +
|
|
144
|
+
`${padLeft(formatNumber(totals.cacheTokens), 10)}` +
|
|
145
|
+
`${padLeft(formatCost(totals.costUsd), 10)}${color.reset}\n`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Print the capability-grouped cost table. */
|
|
150
|
+
function printByCapability(sessions: SessionMetrics[]): void {
|
|
151
|
+
const w = process.stdout.write.bind(process.stdout);
|
|
152
|
+
const separator = "\u2500".repeat(70);
|
|
153
|
+
|
|
154
|
+
w(`${color.bold}Cost by Capability${color.reset}\n`);
|
|
155
|
+
w(`${"=".repeat(70)}\n`);
|
|
156
|
+
|
|
157
|
+
if (sessions.length === 0) {
|
|
158
|
+
w(`${color.dim}No session data found.${color.reset}\n`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
w(
|
|
163
|
+
`${padRight("Capability", 14)}${padLeft("Sessions", 10)}` +
|
|
164
|
+
`${padLeft("Input", 10)}${padLeft("Output", 10)}` +
|
|
165
|
+
`${padLeft("Cache", 10)}${padLeft("Cost", 10)}\n`,
|
|
166
|
+
);
|
|
167
|
+
w(`${color.dim}${separator}${color.reset}\n`);
|
|
168
|
+
|
|
169
|
+
const groups = groupByCapability(sessions);
|
|
170
|
+
|
|
171
|
+
for (const group of groups) {
|
|
172
|
+
w(
|
|
173
|
+
`${padRight(group.capability, 14)}` +
|
|
174
|
+
`${padLeft(formatNumber(group.sessions.length), 10)}` +
|
|
175
|
+
`${padLeft(formatNumber(group.totals.inputTokens), 10)}` +
|
|
176
|
+
`${padLeft(formatNumber(group.totals.outputTokens), 10)}` +
|
|
177
|
+
`${padLeft(formatNumber(group.totals.cacheTokens), 10)}` +
|
|
178
|
+
`${padLeft(formatCost(group.totals.costUsd), 10)}\n`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const totals = computeTotals(sessions);
|
|
183
|
+
w(`${color.dim}${separator}${color.reset}\n`);
|
|
184
|
+
w(
|
|
185
|
+
`${color.green}${color.bold}${padRight("Total", 14)}` +
|
|
186
|
+
`${padLeft(formatNumber(sessions.length), 10)}` +
|
|
187
|
+
`${padLeft(formatNumber(totals.inputTokens), 10)}` +
|
|
188
|
+
`${padLeft(formatNumber(totals.outputTokens), 10)}` +
|
|
189
|
+
`${padLeft(formatNumber(totals.cacheTokens), 10)}` +
|
|
190
|
+
`${padLeft(formatCost(totals.costUsd), 10)}${color.reset}\n`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const COSTS_HELP = `legio costs -- Token/cost analysis and breakdown
|
|
195
|
+
|
|
196
|
+
Usage: legio costs [options]
|
|
197
|
+
|
|
198
|
+
Options:
|
|
199
|
+
--live Show real-time token usage for active agents
|
|
200
|
+
--agent <name> Filter by agent name
|
|
201
|
+
--run <id> Filter by run ID
|
|
202
|
+
--by-capability Group results by capability with subtotals
|
|
203
|
+
--last <n> Number of recent sessions (default: 20)
|
|
204
|
+
--json Output as JSON
|
|
205
|
+
--help, -h Show this help`;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Entry point for `legio costs [--agent <name>] [--run <id>] [--by-capability] [--last <n>] [--json]`.
|
|
209
|
+
*/
|
|
210
|
+
export async function costsCommand(args: string[]): Promise<void> {
|
|
211
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
212
|
+
process.stdout.write(`${COSTS_HELP}\n`);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const json = hasFlag(args, "--json");
|
|
217
|
+
const live = hasFlag(args, "--live");
|
|
218
|
+
const byCapability = hasFlag(args, "--by-capability");
|
|
219
|
+
const agentName = getFlag(args, "--agent");
|
|
220
|
+
const runId = getFlag(args, "--run");
|
|
221
|
+
const lastStr = getFlag(args, "--last");
|
|
222
|
+
|
|
223
|
+
if (lastStr !== undefined) {
|
|
224
|
+
const parsed = Number.parseInt(lastStr, 10);
|
|
225
|
+
if (Number.isNaN(parsed) || parsed < 1) {
|
|
226
|
+
throw new ValidationError("--last must be a positive integer", {
|
|
227
|
+
field: "last",
|
|
228
|
+
value: lastStr,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const last = lastStr ? Number.parseInt(lastStr, 10) : 20;
|
|
234
|
+
|
|
235
|
+
const cwd = process.cwd();
|
|
236
|
+
const config = await loadConfig(cwd);
|
|
237
|
+
const legioDir = join(config.project.root, ".legio");
|
|
238
|
+
|
|
239
|
+
// Handle --live flag (early return for live view)
|
|
240
|
+
if (live) {
|
|
241
|
+
const metricsDbPath = join(legioDir, "metrics.db");
|
|
242
|
+
let metricsDbExists = false;
|
|
243
|
+
try {
|
|
244
|
+
await access(metricsDbPath);
|
|
245
|
+
metricsDbExists = true;
|
|
246
|
+
} catch {
|
|
247
|
+
/* not found */
|
|
248
|
+
}
|
|
249
|
+
if (!metricsDbExists) {
|
|
250
|
+
if (json) {
|
|
251
|
+
process.stdout.write(
|
|
252
|
+
`${JSON.stringify({ agents: [], totals: { inputTokens: 0, outputTokens: 0, cacheTokens: 0, costUsd: 0, burnRatePerMin: 0, tokensPerMin: 0 } })}\n`,
|
|
253
|
+
);
|
|
254
|
+
} else {
|
|
255
|
+
process.stdout.write(
|
|
256
|
+
"No live data available. Token snapshots begin after first tool call.\n",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const metricsStore = createMetricsStore(metricsDbPath);
|
|
263
|
+
const { store: sessionStore } = openSessionStore(legioDir);
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const snapshots = metricsStore.getLatestSnapshots();
|
|
267
|
+
if (snapshots.length === 0) {
|
|
268
|
+
if (json) {
|
|
269
|
+
process.stdout.write(
|
|
270
|
+
`${JSON.stringify({ agents: [], totals: { inputTokens: 0, outputTokens: 0, cacheTokens: 0, costUsd: 0, burnRatePerMin: 0, tokensPerMin: 0 } })}\n`,
|
|
271
|
+
);
|
|
272
|
+
} else {
|
|
273
|
+
process.stdout.write(
|
|
274
|
+
"No live data available. Token snapshots begin after first tool call.\n",
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Get active sessions to join with snapshots
|
|
281
|
+
const activeSessions = sessionStore.getActive();
|
|
282
|
+
|
|
283
|
+
// Filter snapshots by agent if --agent is provided
|
|
284
|
+
const filteredSnapshots = agentName
|
|
285
|
+
? snapshots.filter((s) => s.agentName === agentName)
|
|
286
|
+
: snapshots;
|
|
287
|
+
|
|
288
|
+
// Build agent data with session info
|
|
289
|
+
interface LiveAgentData {
|
|
290
|
+
agentName: string;
|
|
291
|
+
capability: string;
|
|
292
|
+
inputTokens: number;
|
|
293
|
+
outputTokens: number;
|
|
294
|
+
cacheReadTokens: number;
|
|
295
|
+
cacheCreationTokens: number;
|
|
296
|
+
estimatedCostUsd: number;
|
|
297
|
+
modelUsed: string | null;
|
|
298
|
+
snapshotAt: string;
|
|
299
|
+
sessionStartedAt: string;
|
|
300
|
+
elapsedMs: number;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const agentData: LiveAgentData[] = [];
|
|
304
|
+
const now = Date.now();
|
|
305
|
+
|
|
306
|
+
for (const snapshot of filteredSnapshots) {
|
|
307
|
+
const session = activeSessions.find((s) => s.agentName === snapshot.agentName);
|
|
308
|
+
if (!session) continue; // Skip inactive agents
|
|
309
|
+
|
|
310
|
+
const startedAt = new Date(session.startedAt).getTime();
|
|
311
|
+
const elapsedMs = now - startedAt;
|
|
312
|
+
|
|
313
|
+
agentData.push({
|
|
314
|
+
agentName: snapshot.agentName,
|
|
315
|
+
capability: session.capability,
|
|
316
|
+
inputTokens: snapshot.inputTokens,
|
|
317
|
+
outputTokens: snapshot.outputTokens,
|
|
318
|
+
cacheReadTokens: snapshot.cacheReadTokens,
|
|
319
|
+
cacheCreationTokens: snapshot.cacheCreationTokens,
|
|
320
|
+
estimatedCostUsd: snapshot.estimatedCostUsd ?? 0,
|
|
321
|
+
modelUsed: snapshot.modelUsed,
|
|
322
|
+
snapshotAt: snapshot.createdAt,
|
|
323
|
+
sessionStartedAt: session.startedAt,
|
|
324
|
+
elapsedMs,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Compute totals
|
|
329
|
+
let totalInput = 0;
|
|
330
|
+
let totalOutput = 0;
|
|
331
|
+
let totalCacheRead = 0;
|
|
332
|
+
let totalCacheCreate = 0;
|
|
333
|
+
let totalCost = 0;
|
|
334
|
+
let totalElapsedMs = 0;
|
|
335
|
+
|
|
336
|
+
for (const agent of agentData) {
|
|
337
|
+
totalInput += agent.inputTokens;
|
|
338
|
+
totalOutput += agent.outputTokens;
|
|
339
|
+
totalCacheRead += agent.cacheReadTokens;
|
|
340
|
+
totalCacheCreate += agent.cacheCreationTokens;
|
|
341
|
+
totalCost += agent.estimatedCostUsd;
|
|
342
|
+
totalElapsedMs += agent.elapsedMs;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const avgElapsedMs = agentData.length > 0 ? totalElapsedMs / agentData.length : 0;
|
|
346
|
+
const totalCacheTokens = totalCacheRead + totalCacheCreate;
|
|
347
|
+
const totalTokens = totalInput + totalOutput;
|
|
348
|
+
const burnRatePerMin = avgElapsedMs > 0 ? totalCost / (avgElapsedMs / 60_000) : 0;
|
|
349
|
+
const tokensPerMin = avgElapsedMs > 0 ? totalTokens / (avgElapsedMs / 60_000) : 0;
|
|
350
|
+
|
|
351
|
+
if (json) {
|
|
352
|
+
process.stdout.write(
|
|
353
|
+
`${JSON.stringify({
|
|
354
|
+
agents: agentData,
|
|
355
|
+
totals: {
|
|
356
|
+
inputTokens: totalInput,
|
|
357
|
+
outputTokens: totalOutput,
|
|
358
|
+
cacheTokens: totalCacheTokens,
|
|
359
|
+
costUsd: totalCost,
|
|
360
|
+
burnRatePerMin,
|
|
361
|
+
tokensPerMin,
|
|
362
|
+
},
|
|
363
|
+
})}\n`,
|
|
364
|
+
);
|
|
365
|
+
} else {
|
|
366
|
+
const w = process.stdout.write.bind(process.stdout);
|
|
367
|
+
const separator = "\u2500".repeat(70);
|
|
368
|
+
|
|
369
|
+
w(`${color.bold}Live Token Usage (${agentData.length} active agents)${color.reset}\n`);
|
|
370
|
+
w(`${"=".repeat(70)}\n`);
|
|
371
|
+
w(
|
|
372
|
+
`${padRight("Agent", 19)}${padRight("Capability", 12)}` +
|
|
373
|
+
`${padLeft("Input", 10)}${padLeft("Output", 10)}` +
|
|
374
|
+
`${padLeft("Cache", 10)}${padLeft("Cost", 10)}\n`,
|
|
375
|
+
);
|
|
376
|
+
w(`${color.dim}${separator}${color.reset}\n`);
|
|
377
|
+
|
|
378
|
+
for (const agent of agentData) {
|
|
379
|
+
const cacheTotal = agent.cacheReadTokens + agent.cacheCreationTokens;
|
|
380
|
+
w(
|
|
381
|
+
`${padRight(agent.agentName, 19)}${padRight(agent.capability, 12)}` +
|
|
382
|
+
`${padLeft(formatNumber(agent.inputTokens), 10)}` +
|
|
383
|
+
`${padLeft(formatNumber(agent.outputTokens), 10)}` +
|
|
384
|
+
`${padLeft(formatNumber(cacheTotal), 10)}` +
|
|
385
|
+
`${padLeft(formatCost(agent.estimatedCostUsd), 10)}\n`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
w(`${color.dim}${separator}${color.reset}\n`);
|
|
390
|
+
w(
|
|
391
|
+
`${color.green}${color.bold}${padRight("Total", 31)}` +
|
|
392
|
+
`${padLeft(formatNumber(totalInput), 10)}` +
|
|
393
|
+
`${padLeft(formatNumber(totalOutput), 10)}` +
|
|
394
|
+
`${padLeft(formatNumber(totalCacheTokens), 10)}` +
|
|
395
|
+
`${padLeft(formatCost(totalCost), 10)}${color.reset}\n\n`,
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
// Format elapsed time
|
|
399
|
+
const totalElapsedSec = Math.floor(avgElapsedMs / 1000);
|
|
400
|
+
const minutes = Math.floor(totalElapsedSec / 60);
|
|
401
|
+
const seconds = totalElapsedSec % 60;
|
|
402
|
+
const elapsedStr = `${minutes}m ${seconds}s`;
|
|
403
|
+
|
|
404
|
+
w(
|
|
405
|
+
`Burn rate: ${formatCost(burnRatePerMin)}/min | ` +
|
|
406
|
+
`${formatNumber(Math.floor(tokensPerMin))} tokens/min | ` +
|
|
407
|
+
`Elapsed: ${elapsedStr}\n`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
} finally {
|
|
411
|
+
metricsStore.close();
|
|
412
|
+
sessionStore.close();
|
|
413
|
+
}
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Check if metrics.db exists
|
|
418
|
+
const metricsDbPath = join(legioDir, "metrics.db");
|
|
419
|
+
let metricsDbExists = false;
|
|
420
|
+
try {
|
|
421
|
+
await access(metricsDbPath);
|
|
422
|
+
metricsDbExists = true;
|
|
423
|
+
} catch {
|
|
424
|
+
/* not found */
|
|
425
|
+
}
|
|
426
|
+
if (!metricsDbExists) {
|
|
427
|
+
if (json) {
|
|
428
|
+
process.stdout.write("[]\n");
|
|
429
|
+
} else {
|
|
430
|
+
process.stdout.write("No metrics data yet.\n");
|
|
431
|
+
}
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const metricsStore = createMetricsStore(metricsDbPath);
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
let sessions: SessionMetrics[];
|
|
439
|
+
|
|
440
|
+
if (agentName !== undefined) {
|
|
441
|
+
sessions = metricsStore.getSessionsByAgent(agentName);
|
|
442
|
+
} else if (runId !== undefined) {
|
|
443
|
+
sessions = metricsStore.getSessionsByRun(runId);
|
|
444
|
+
} else {
|
|
445
|
+
sessions = metricsStore.getRecentSessions(last);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (json) {
|
|
449
|
+
if (byCapability) {
|
|
450
|
+
const groups = groupByCapability(sessions);
|
|
451
|
+
const grouped: Record<string, { sessions: SessionMetrics[]; totals: Totals }> = {};
|
|
452
|
+
for (const group of groups) {
|
|
453
|
+
grouped[group.capability] = {
|
|
454
|
+
sessions: group.sessions,
|
|
455
|
+
totals: group.totals,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
process.stdout.write(`${JSON.stringify(grouped)}\n`);
|
|
459
|
+
} else {
|
|
460
|
+
process.stdout.write(`${JSON.stringify(sessions)}\n`);
|
|
461
|
+
}
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (byCapability) {
|
|
466
|
+
printByCapability(sessions);
|
|
467
|
+
} else {
|
|
468
|
+
printCostSummary(sessions);
|
|
469
|
+
}
|
|
470
|
+
} finally {
|
|
471
|
+
metricsStore.close();
|
|
472
|
+
}
|
|
473
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for legio dashboard command.
|
|
3
|
+
*
|
|
4
|
+
* We only test help output and validation since the dashboard runs an infinite
|
|
5
|
+
* polling loop. The actual rendering cannot be tested without complex mocking
|
|
6
|
+
* of terminal state and multiple data sources.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
13
|
+
import { ValidationError } from "../errors.ts";
|
|
14
|
+
import { dashboardCommand } from "./dashboard.ts";
|
|
15
|
+
|
|
16
|
+
describe("dashboardCommand", () => {
|
|
17
|
+
let chunks: string[];
|
|
18
|
+
let originalWrite: typeof process.stdout.write;
|
|
19
|
+
let tempDir: string;
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
chunks = [];
|
|
23
|
+
originalWrite = process.stdout.write;
|
|
24
|
+
process.stdout.write = ((chunk: string) => {
|
|
25
|
+
chunks.push(chunk);
|
|
26
|
+
return true;
|
|
27
|
+
}) as typeof process.stdout.write;
|
|
28
|
+
|
|
29
|
+
tempDir = await mkdtemp(join(tmpdir(), "dashboard-test-"));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
process.stdout.write = originalWrite;
|
|
34
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function output(): string {
|
|
38
|
+
return chunks.join("");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test("--help flag prints help text", async () => {
|
|
42
|
+
await dashboardCommand(["--help"]);
|
|
43
|
+
const out = output();
|
|
44
|
+
|
|
45
|
+
expect(out).toContain("legio dashboard");
|
|
46
|
+
expect(out).toContain("--interval");
|
|
47
|
+
expect(out).toContain("Ctrl+C");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("-h flag prints help text", async () => {
|
|
51
|
+
await dashboardCommand(["-h"]);
|
|
52
|
+
const out = output();
|
|
53
|
+
|
|
54
|
+
expect(out).toContain("legio dashboard");
|
|
55
|
+
expect(out).toContain("--interval");
|
|
56
|
+
expect(out).toContain("Ctrl+C");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("--interval with non-numeric value throws ValidationError", async () => {
|
|
60
|
+
await expect(dashboardCommand(["--interval", "abc"])).rejects.toThrow(ValidationError);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("--interval below 500 throws ValidationError", async () => {
|
|
64
|
+
await expect(dashboardCommand(["--interval", "499"])).rejects.toThrow(ValidationError);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("--interval with NaN throws ValidationError", async () => {
|
|
68
|
+
await expect(dashboardCommand(["--interval", "not-a-number"])).rejects.toThrow(ValidationError);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("--interval at exactly 500 passes validation", async () => {
|
|
72
|
+
// This test verifies that interval validation passes for the value 500.
|
|
73
|
+
// We chdir to a temp dir WITHOUT .legio/config.yaml so that loadConfig()
|
|
74
|
+
// throws BEFORE the infinite while loop starts. This proves validation passed
|
|
75
|
+
// (no ValidationError about interval) while preventing the loop from leaking.
|
|
76
|
+
|
|
77
|
+
const originalCwd = process.cwd();
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
process.chdir(tempDir);
|
|
81
|
+
await dashboardCommand(["--interval", "500"]);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
// If it's a ValidationError about interval, the test should fail
|
|
84
|
+
if (err instanceof ValidationError && err.field === "interval") {
|
|
85
|
+
throw new Error("Interval validation should have passed for value 500");
|
|
86
|
+
}
|
|
87
|
+
// Other errors (like from loadConfig) are expected - they occur after validation passed
|
|
88
|
+
} finally {
|
|
89
|
+
process.chdir(originalCwd);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// If we reach here without throwing a ValidationError about interval, validation passed
|
|
93
|
+
});
|
|
94
|
+
});
|