@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.
Files changed (219) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +21 -0
  3. package/README.md +555 -0
  4. package/agents/builder.md +141 -0
  5. package/agents/coordinator.md +351 -0
  6. package/agents/cto.md +196 -0
  7. package/agents/gateway.md +276 -0
  8. package/agents/lead.md +281 -0
  9. package/agents/merger.md +156 -0
  10. package/agents/monitor.md +212 -0
  11. package/agents/reviewer.md +142 -0
  12. package/agents/scout.md +131 -0
  13. package/agents/supervisor.md +416 -0
  14. package/bin/legio.mjs +38 -0
  15. package/package.json +77 -0
  16. package/src/agents/checkpoint.test.ts +88 -0
  17. package/src/agents/checkpoint.ts +102 -0
  18. package/src/agents/hooks-deployer.test.ts +1820 -0
  19. package/src/agents/hooks-deployer.ts +574 -0
  20. package/src/agents/identity.test.ts +614 -0
  21. package/src/agents/identity.ts +385 -0
  22. package/src/agents/lifecycle.test.ts +202 -0
  23. package/src/agents/lifecycle.ts +184 -0
  24. package/src/agents/manifest.test.ts +558 -0
  25. package/src/agents/manifest.ts +297 -0
  26. package/src/agents/overlay.test.ts +592 -0
  27. package/src/agents/overlay.ts +316 -0
  28. package/src/beads/client.test.ts +210 -0
  29. package/src/beads/client.ts +227 -0
  30. package/src/beads/molecules.test.ts +320 -0
  31. package/src/beads/molecules.ts +209 -0
  32. package/src/commands/agents.test.ts +325 -0
  33. package/src/commands/agents.ts +286 -0
  34. package/src/commands/clean.test.ts +730 -0
  35. package/src/commands/clean.ts +653 -0
  36. package/src/commands/completions.test.ts +346 -0
  37. package/src/commands/completions.ts +950 -0
  38. package/src/commands/coordinator.test.ts +1524 -0
  39. package/src/commands/coordinator.ts +880 -0
  40. package/src/commands/costs.test.ts +1015 -0
  41. package/src/commands/costs.ts +473 -0
  42. package/src/commands/dashboard.test.ts +94 -0
  43. package/src/commands/dashboard.ts +607 -0
  44. package/src/commands/doctor.test.ts +295 -0
  45. package/src/commands/doctor.ts +213 -0
  46. package/src/commands/down.test.ts +308 -0
  47. package/src/commands/down.ts +124 -0
  48. package/src/commands/errors.test.ts +648 -0
  49. package/src/commands/errors.ts +255 -0
  50. package/src/commands/feed.test.ts +579 -0
  51. package/src/commands/feed.ts +368 -0
  52. package/src/commands/gateway.test.ts +698 -0
  53. package/src/commands/gateway.ts +419 -0
  54. package/src/commands/group.test.ts +262 -0
  55. package/src/commands/group.ts +539 -0
  56. package/src/commands/hooks.test.ts +292 -0
  57. package/src/commands/hooks.ts +210 -0
  58. package/src/commands/init.test.ts +211 -0
  59. package/src/commands/init.ts +622 -0
  60. package/src/commands/inspect.test.ts +670 -0
  61. package/src/commands/inspect.ts +455 -0
  62. package/src/commands/log.test.ts +1556 -0
  63. package/src/commands/log.ts +752 -0
  64. package/src/commands/logs.test.ts +379 -0
  65. package/src/commands/logs.ts +544 -0
  66. package/src/commands/mail.test.ts +1726 -0
  67. package/src/commands/mail.ts +926 -0
  68. package/src/commands/merge.test.ts +676 -0
  69. package/src/commands/merge.ts +374 -0
  70. package/src/commands/metrics.test.ts +444 -0
  71. package/src/commands/metrics.ts +150 -0
  72. package/src/commands/monitor.test.ts +151 -0
  73. package/src/commands/monitor.ts +394 -0
  74. package/src/commands/nudge.test.ts +230 -0
  75. package/src/commands/nudge.ts +373 -0
  76. package/src/commands/prime.test.ts +467 -0
  77. package/src/commands/prime.ts +386 -0
  78. package/src/commands/replay.test.ts +742 -0
  79. package/src/commands/replay.ts +367 -0
  80. package/src/commands/run.test.ts +443 -0
  81. package/src/commands/run.ts +365 -0
  82. package/src/commands/server.test.ts +626 -0
  83. package/src/commands/server.ts +298 -0
  84. package/src/commands/sling.test.ts +810 -0
  85. package/src/commands/sling.ts +700 -0
  86. package/src/commands/spec.test.ts +206 -0
  87. package/src/commands/spec.ts +171 -0
  88. package/src/commands/status.test.ts +276 -0
  89. package/src/commands/status.ts +339 -0
  90. package/src/commands/stop.test.ts +357 -0
  91. package/src/commands/stop.ts +119 -0
  92. package/src/commands/supervisor.test.ts +186 -0
  93. package/src/commands/supervisor.ts +544 -0
  94. package/src/commands/trace.test.ts +746 -0
  95. package/src/commands/trace.ts +332 -0
  96. package/src/commands/up.test.ts +597 -0
  97. package/src/commands/up.ts +275 -0
  98. package/src/commands/watch.test.ts +152 -0
  99. package/src/commands/watch.ts +238 -0
  100. package/src/commands/worktree.test.ts +648 -0
  101. package/src/commands/worktree.ts +266 -0
  102. package/src/config.test.ts +496 -0
  103. package/src/config.ts +616 -0
  104. package/src/doctor/agents.test.ts +448 -0
  105. package/src/doctor/agents.ts +396 -0
  106. package/src/doctor/config-check.test.ts +184 -0
  107. package/src/doctor/config-check.ts +185 -0
  108. package/src/doctor/consistency.test.ts +645 -0
  109. package/src/doctor/consistency.ts +294 -0
  110. package/src/doctor/databases.test.ts +284 -0
  111. package/src/doctor/databases.ts +211 -0
  112. package/src/doctor/dependencies.test.ts +150 -0
  113. package/src/doctor/dependencies.ts +179 -0
  114. package/src/doctor/logs.test.ts +244 -0
  115. package/src/doctor/logs.ts +295 -0
  116. package/src/doctor/merge-queue.test.ts +210 -0
  117. package/src/doctor/merge-queue.ts +144 -0
  118. package/src/doctor/structure.test.ts +285 -0
  119. package/src/doctor/structure.ts +195 -0
  120. package/src/doctor/types.ts +37 -0
  121. package/src/doctor/version.test.ts +130 -0
  122. package/src/doctor/version.ts +131 -0
  123. package/src/e2e/chat-flow.test.ts +346 -0
  124. package/src/e2e/init-sling-lifecycle.test.ts +288 -0
  125. package/src/errors.test.ts +21 -0
  126. package/src/errors.ts +246 -0
  127. package/src/events/store.test.ts +660 -0
  128. package/src/events/store.ts +344 -0
  129. package/src/events/tool-filter.test.ts +330 -0
  130. package/src/events/tool-filter.ts +126 -0
  131. package/src/global-setup.ts +14 -0
  132. package/src/index.ts +339 -0
  133. package/src/insights/analyzer.test.ts +466 -0
  134. package/src/insights/analyzer.ts +203 -0
  135. package/src/logging/color.test.ts +118 -0
  136. package/src/logging/color.ts +71 -0
  137. package/src/logging/logger.test.ts +812 -0
  138. package/src/logging/logger.ts +266 -0
  139. package/src/logging/reporter.test.ts +258 -0
  140. package/src/logging/reporter.ts +109 -0
  141. package/src/logging/sanitizer.test.ts +190 -0
  142. package/src/logging/sanitizer.ts +57 -0
  143. package/src/mail/broadcast.test.ts +203 -0
  144. package/src/mail/broadcast.ts +92 -0
  145. package/src/mail/client.test.ts +873 -0
  146. package/src/mail/client.ts +236 -0
  147. package/src/mail/store.test.ts +815 -0
  148. package/src/mail/store.ts +402 -0
  149. package/src/merge/queue.test.ts +449 -0
  150. package/src/merge/queue.ts +262 -0
  151. package/src/merge/resolver.test.ts +1453 -0
  152. package/src/merge/resolver.ts +759 -0
  153. package/src/metrics/store.test.ts +1167 -0
  154. package/src/metrics/store.ts +511 -0
  155. package/src/metrics/summary.test.ts +397 -0
  156. package/src/metrics/summary.ts +178 -0
  157. package/src/metrics/transcript.test.ts +643 -0
  158. package/src/metrics/transcript.ts +351 -0
  159. package/src/mulch/client.test.ts +547 -0
  160. package/src/mulch/client.ts +416 -0
  161. package/src/server/audit-store.test.ts +384 -0
  162. package/src/server/audit-store.ts +257 -0
  163. package/src/server/headless.test.ts +180 -0
  164. package/src/server/headless.ts +151 -0
  165. package/src/server/index.test.ts +241 -0
  166. package/src/server/index.ts +317 -0
  167. package/src/server/public/app.js +187 -0
  168. package/src/server/public/apple-touch-icon.png +0 -0
  169. package/src/server/public/components/agent-badge.js +37 -0
  170. package/src/server/public/components/data-table.js +114 -0
  171. package/src/server/public/components/gateway-chat.js +256 -0
  172. package/src/server/public/components/issue-card.js +96 -0
  173. package/src/server/public/components/layout.js +88 -0
  174. package/src/server/public/components/message-bubble.js +120 -0
  175. package/src/server/public/components/stat-card.js +26 -0
  176. package/src/server/public/components/terminal-panel.js +140 -0
  177. package/src/server/public/favicon-16.png +0 -0
  178. package/src/server/public/favicon-32.png +0 -0
  179. package/src/server/public/favicon.ico +0 -0
  180. package/src/server/public/favicon.png +0 -0
  181. package/src/server/public/index.html +64 -0
  182. package/src/server/public/lib/api.js +35 -0
  183. package/src/server/public/lib/markdown.js +8 -0
  184. package/src/server/public/lib/preact-setup.js +8 -0
  185. package/src/server/public/lib/state.js +99 -0
  186. package/src/server/public/lib/utils.js +309 -0
  187. package/src/server/public/lib/ws.js +79 -0
  188. package/src/server/public/views/chat.js +983 -0
  189. package/src/server/public/views/costs.js +692 -0
  190. package/src/server/public/views/dashboard.js +781 -0
  191. package/src/server/public/views/gateway-chat.js +622 -0
  192. package/src/server/public/views/inspect.js +399 -0
  193. package/src/server/public/views/issues.js +470 -0
  194. package/src/server/public/views/setup.js +94 -0
  195. package/src/server/public/views/task-detail.js +422 -0
  196. package/src/server/routes.test.ts +3816 -0
  197. package/src/server/routes.ts +1964 -0
  198. package/src/server/websocket.test.ts +288 -0
  199. package/src/server/websocket.ts +196 -0
  200. package/src/sessions/compat.test.ts +109 -0
  201. package/src/sessions/compat.ts +17 -0
  202. package/src/sessions/store.test.ts +969 -0
  203. package/src/sessions/store.ts +480 -0
  204. package/src/test-helpers.test.ts +97 -0
  205. package/src/test-helpers.ts +143 -0
  206. package/src/types.ts +708 -0
  207. package/src/watchdog/daemon.test.ts +1233 -0
  208. package/src/watchdog/daemon.ts +533 -0
  209. package/src/watchdog/health.test.ts +371 -0
  210. package/src/watchdog/health.ts +248 -0
  211. package/src/watchdog/triage.test.ts +162 -0
  212. package/src/watchdog/triage.ts +193 -0
  213. package/src/worktree/manager.test.ts +444 -0
  214. package/src/worktree/manager.ts +224 -0
  215. package/src/worktree/tmux.test.ts +1238 -0
  216. package/src/worktree/tmux.ts +644 -0
  217. package/templates/CLAUDE.md.tmpl +89 -0
  218. package/templates/hooks.json.tmpl +132 -0
  219. 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
+ });