@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,692 @@
1
+ // CostsView — Usage report with charts, tables, and live snapshots
2
+ // Preact+HTM component, no build step required.
3
+
4
+ import { fetchJson } from "../lib/api.js";
5
+ import { html, useCallback, useEffect, useMemo, useState } from "../lib/preact-setup.js";
6
+ import { timeAgo } from "../lib/utils.js";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Constants
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const TIME_WINDOWS = [
13
+ { label: "All Time", value: null },
14
+ { label: "Last Hour", value: 60 * 60 * 1000 },
15
+ { label: "Last 24h", value: 24 * 60 * 60 * 1000 },
16
+ { label: "Last 7d", value: 7 * 24 * 60 * 60 * 1000 },
17
+ { label: "Last 30d", value: 30 * 24 * 60 * 60 * 1000 },
18
+ ];
19
+
20
+ const MODEL_COLORS = {
21
+ opus: "bg-blue-500",
22
+ sonnet: "bg-green-500",
23
+ haiku: "bg-yellow-500",
24
+ unknown: "bg-gray-500",
25
+ };
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Helpers
29
+ // ---------------------------------------------------------------------------
30
+
31
+ function formatNumber(n) {
32
+ if (n == null) return "—";
33
+ return Number(n).toLocaleString();
34
+ }
35
+
36
+ function formatCost(n) {
37
+ if (n == null) return "—";
38
+ return `$${Number(n).toFixed(4)}`;
39
+ }
40
+
41
+ function formatCostShort(n) {
42
+ if (n == null) return "—";
43
+ return `$${Number(n).toFixed(2)}`;
44
+ }
45
+
46
+ function formatDateShort(dateStr) {
47
+ if (!dateStr) return "";
48
+ const d = String(dateStr).split("T")[0]; // "2026-02-21"
49
+ const parts = d.split("-");
50
+ if (parts.length < 3) return d;
51
+ return `${parts[1]}/${parts[2]}`; // "02/21"
52
+ }
53
+
54
+ function modelColor(model) {
55
+ if (!model) return MODEL_COLORS.unknown;
56
+ const lower = String(model).toLowerCase();
57
+ if (lower.includes("opus")) return MODEL_COLORS.opus;
58
+ if (lower.includes("sonnet")) return MODEL_COLORS.sonnet;
59
+ if (lower.includes("haiku")) return MODEL_COLORS.haiku;
60
+ return MODEL_COLORS.unknown;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // StatCard
65
+ // ---------------------------------------------------------------------------
66
+
67
+ function StatCard({ label, value }) {
68
+ return html`
69
+ <div class="bg-surface border border-border rounded-sm p-4 flex-1 min-w-0">
70
+ <div class="text-xs text-gray-500 uppercase tracking-wider mb-1">${label}</div>
71
+ <div class="text-2xl font-mono text-white">${value}</div>
72
+ </div>
73
+ `;
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // ModelBreakdown
78
+ // ---------------------------------------------------------------------------
79
+
80
+ function ModelBreakdown({ modelData }) {
81
+ if (!modelData || modelData.length === 0) {
82
+ return html`<div class="text-center text-gray-500 py-8">No model data</div>`;
83
+ }
84
+
85
+ const sorted = [...modelData].sort(
86
+ (a, b) => (b.estimatedCostUsd || 0) - (a.estimatedCostUsd || 0),
87
+ );
88
+ const maxCost = sorted[0]?.estimatedCostUsd ?? 0;
89
+
90
+ return html`
91
+ <div class="flex flex-col gap-3">
92
+ ${sorted.map((row) => {
93
+ const ioTokens = (row.inputTokens || 0) + (row.outputTokens || 0);
94
+ const pct = maxCost > 0 ? ((row.estimatedCostUsd || 0) / maxCost) * 100 : 0;
95
+ const color = modelColor(row.model);
96
+ return html`
97
+ <div key=${row.model || "unknown"} class="flex items-center gap-3">
98
+ <div class="flex items-center gap-2 w-[140px] shrink-0">
99
+ <div class=${`${color} w-2 h-2 rounded-full shrink-0`}></div>
100
+ <span class="text-sm text-gray-300 truncate">${row.model || "unknown"}</span>
101
+ </div>
102
+ <div class="flex-1 bg-white/5 rounded-sm h-5 overflow-hidden">
103
+ <div
104
+ class=${`${color} h-full rounded-sm`}
105
+ style=${`width: ${pct.toFixed(1)}%`}
106
+ ></div>
107
+ </div>
108
+ <span class="font-mono text-xs text-gray-400 w-28 text-right shrink-0">
109
+ ${formatNumber(ioTokens)} tok
110
+ </span>
111
+ <span class="font-mono text-sm text-gray-300 w-20 text-right shrink-0">
112
+ ${formatCost(row.estimatedCostUsd)}
113
+ </span>
114
+ </div>
115
+ `;
116
+ })}
117
+ <!-- Legend details -->
118
+ <div class="flex flex-wrap gap-x-6 gap-y-1 mt-1">
119
+ ${sorted.map(
120
+ (row) => html`
121
+ <div key=${row.model} class="text-xs text-gray-500">
122
+ ${row.model || "unknown"}: ${row.sessions ?? 0} session${row.sessions === 1 ? "" : "s"}
123
+ </div>
124
+ `,
125
+ )}
126
+ </div>
127
+ </div>
128
+ `;
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // DateChart
133
+ // ---------------------------------------------------------------------------
134
+
135
+ function DateChart({ dateData }) {
136
+ if (!dateData || dateData.length === 0) {
137
+ return html`<div class="text-center text-gray-500 py-8">No date data</div>`;
138
+ }
139
+
140
+ const maxCost = Math.max(...dateData.map((d) => d.estimatedCostUsd || 0));
141
+ const maxHeight = 160; // px
142
+
143
+ return html`
144
+ <div class="overflow-x-auto">
145
+ <div class="flex items-end gap-1 min-w-0" style="min-height: ${maxHeight + 32}px">
146
+ ${dateData.map((d) => {
147
+ const cost = d.estimatedCostUsd || 0;
148
+ const barH = maxCost > 0 ? Math.max(2, Math.round((cost / maxCost) * maxHeight)) : 2;
149
+ return html`
150
+ <div
151
+ key=${d.date}
152
+ class="flex flex-col items-center gap-1 shrink-0"
153
+ style="min-width: 40px"
154
+ title=${`${d.date}: ${formatCost(cost)} (${d.sessions ?? 0} sessions)`}
155
+ >
156
+ <span class="text-xs font-mono text-gray-500">${formatCostShort(cost)}</span>
157
+ <div
158
+ class="w-6 bg-blue-500 rounded-t-sm"
159
+ style=${`height: ${barH}px`}
160
+ ></div>
161
+ <span class="text-xs text-gray-500 rotate-0">${formatDateShort(d.date)}</span>
162
+ </div>
163
+ `;
164
+ })}
165
+ </div>
166
+ </div>
167
+ `;
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // AgentBarChart
172
+ // ---------------------------------------------------------------------------
173
+
174
+ function AgentBarChart({ metrics }) {
175
+ const agentCosts = useMemo(() => {
176
+ const map = new Map();
177
+ for (const m of metrics) {
178
+ const name = m.agentName || "unknown";
179
+ const cost = m.estimatedCostUsd || 0;
180
+ map.set(name, (map.get(name) || 0) + cost);
181
+ }
182
+ return Array.from(map.entries())
183
+ .map(([name, cost]) => ({ name, cost }))
184
+ .sort((a, b) => b.cost - a.cost);
185
+ }, [metrics]);
186
+
187
+ if (agentCosts.length === 0) {
188
+ return html`<div class="text-center text-gray-500 py-8">No cost data</div>`;
189
+ }
190
+
191
+ const maxCost = agentCosts[0]?.cost ?? 0;
192
+
193
+ return html`
194
+ <div class="flex flex-col gap-2">
195
+ ${agentCosts.map(({ name, cost }) => {
196
+ const pct = maxCost > 0 ? (cost / maxCost) * 100 : 0;
197
+ return html`
198
+ <div key=${name} class="flex items-center gap-3">
199
+ <a
200
+ href=${`#inspect/${encodeURIComponent(name)}`}
201
+ class="text-blue-400 hover:text-blue-300 text-sm w-[140px] shrink-0 truncate"
202
+ >
203
+ ${name}
204
+ </a>
205
+ <div class="flex-1 bg-white/5 rounded-sm h-5 overflow-hidden">
206
+ <div
207
+ class="bg-blue-500 h-full rounded-sm"
208
+ style=${`width: ${pct.toFixed(1)}%`}
209
+ ></div>
210
+ </div>
211
+ <span class="font-mono text-sm text-gray-300 w-20 text-right shrink-0">
212
+ ${formatCost(cost)}
213
+ </span>
214
+ </div>
215
+ `;
216
+ })}
217
+ </div>
218
+ `;
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // TokenDistribution
223
+ // ---------------------------------------------------------------------------
224
+
225
+ function TokenDistribution({ totals }) {
226
+ const totalTokens = totals.input + totals.output + totals.cacheRead + totals.cacheCreated;
227
+
228
+ if (totalTokens === 0) {
229
+ return html`<div class="text-center text-gray-500 py-4">No token data</div>`;
230
+ }
231
+
232
+ const segments = [
233
+ { label: "Input", value: totals.input, color: "bg-blue-500" },
234
+ { label: "Output", value: totals.output, color: "bg-green-500" },
235
+ { label: "Cache Read", value: totals.cacheRead, color: "bg-yellow-500" },
236
+ { label: "Cache Created", value: totals.cacheCreated, color: "bg-purple-500" },
237
+ ].filter((s) => s.value > 0);
238
+
239
+ return html`
240
+ <div>
241
+ <!-- Segmented bar -->
242
+ <div class="flex h-6 rounded-sm overflow-hidden gap-px">
243
+ ${segments.map((s) => {
244
+ const pct = (s.value / totalTokens) * 100;
245
+ return html`
246
+ <div
247
+ key=${s.label}
248
+ class=${s.color}
249
+ style=${`width: ${pct.toFixed(2)}%`}
250
+ title=${`${s.label}: ${formatNumber(s.value)} (${pct.toFixed(1)}%)`}
251
+ ></div>
252
+ `;
253
+ })}
254
+ </div>
255
+ <!-- Legend -->
256
+ <div class="flex flex-wrap gap-x-6 gap-y-2 mt-3">
257
+ ${segments.map((s) => {
258
+ const pct = ((s.value / totalTokens) * 100).toFixed(1);
259
+ return html`
260
+ <div key=${s.label} class="flex items-center gap-2 text-sm">
261
+ <div class=${`${s.color} w-3 h-3 rounded-full shrink-0`}></div>
262
+ <span class="text-gray-400">${s.label}</span>
263
+ <span class="font-mono text-gray-300">${formatNumber(s.value)}</span>
264
+ <span class="text-gray-500">${pct}%</span>
265
+ </div>
266
+ `;
267
+ })}
268
+ </div>
269
+ </div>
270
+ `;
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // CapabilityChart
275
+ // ---------------------------------------------------------------------------
276
+
277
+ function CapabilityChart({ metrics }) {
278
+ const capCosts = useMemo(() => {
279
+ const map = new Map();
280
+ for (const m of metrics) {
281
+ const cap = m.capability || "unknown";
282
+ const cost = m.estimatedCostUsd || 0;
283
+ map.set(cap, (map.get(cap) || 0) + cost);
284
+ }
285
+ return Array.from(map.entries())
286
+ .map(([cap, cost]) => ({ cap, cost }))
287
+ .sort((a, b) => b.cost - a.cost);
288
+ }, [metrics]);
289
+
290
+ // Only show if more than one capability
291
+ if (capCosts.length <= 1) return null;
292
+
293
+ const maxCost = capCosts[0]?.cost ?? 0;
294
+
295
+ return html`
296
+ <div class="flex flex-col gap-2">
297
+ ${capCosts.map(({ cap, cost }) => {
298
+ const pct = maxCost > 0 ? (cost / maxCost) * 100 : 0;
299
+ return html`
300
+ <div key=${cap} class="flex items-center gap-3">
301
+ <span class="text-sm text-gray-400 w-[140px] shrink-0 truncate">${cap}</span>
302
+ <div class="flex-1 bg-white/5 rounded-sm h-5 overflow-hidden">
303
+ <div
304
+ class="bg-blue-400 h-full rounded-sm"
305
+ style=${`width: ${pct.toFixed(1)}%`}
306
+ ></div>
307
+ </div>
308
+ <span class="font-mono text-sm text-gray-300 w-20 text-right shrink-0">
309
+ ${formatCost(cost)}
310
+ </span>
311
+ </div>
312
+ `;
313
+ })}
314
+ </div>
315
+ `;
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // CostsView
320
+ // ---------------------------------------------------------------------------
321
+
322
+ export function CostsView({ metrics: initialMetrics, snapshots }) {
323
+ const [groupByCapability, setGroupByCapability] = useState(false);
324
+ const [timeWindow, setTimeWindow] = useState(null); // null = all time
325
+ const [filteredMetrics, setFilteredMetrics] = useState(null);
326
+ const [modelData, setModelData] = useState([]);
327
+ const [dateData, setDateData] = useState([]);
328
+ const [loading, setLoading] = useState(false);
329
+ const [agentExpanded, setAgentExpanded] = useState(() => {
330
+ const names = new Set(
331
+ (Array.isArray(initialMetrics) ? initialMetrics : []).map((m) => m.agentName || "unknown"),
332
+ );
333
+ return names.size <= 5;
334
+ });
335
+ const [detailExpanded, setDetailExpanded] = useState(
336
+ () => !initialMetrics || initialMetrics.length < 10,
337
+ );
338
+
339
+ const onToggleGroup = useCallback(() => setGroupByCapability((v) => !v), []);
340
+
341
+ // When timeWindow changes, fetch filtered data from all 3 endpoints
342
+ useEffect(() => {
343
+ const sinceIso = timeWindow !== null ? new Date(Date.now() - timeWindow).toISOString() : null;
344
+ const enc = sinceIso ? encodeURIComponent(sinceIso) : null;
345
+
346
+ const metricsUrl = enc ? `/api/metrics?since=${enc}&limit=1000` : "/api/metrics?limit=1000";
347
+ const modelUrl = enc ? `/api/metrics/by-model?since=${enc}` : "/api/metrics/by-model";
348
+ const dateUrl = enc ? `/api/metrics/by-date?since=${enc}` : "/api/metrics/by-date";
349
+
350
+ setLoading(true);
351
+ Promise.all([
352
+ fetchJson(metricsUrl).catch(() => []),
353
+ fetchJson(modelUrl).catch(() => []),
354
+ fetchJson(dateUrl).catch(() => []),
355
+ ]).then(([metrics, byModel, byDate]) => {
356
+ setFilteredMetrics(Array.isArray(metrics) ? metrics : []);
357
+ setModelData(Array.isArray(byModel) ? byModel : []);
358
+ setDateData(Array.isArray(byDate) ? byDate : []);
359
+ setLoading(false);
360
+ });
361
+ }, [timeWindow]);
362
+
363
+ // Use filteredMetrics (always fetched), fall back to initialMetrics before first fetch completes
364
+ const safeMetrics = Array.isArray(filteredMetrics)
365
+ ? filteredMetrics
366
+ : Array.isArray(initialMetrics)
367
+ ? initialMetrics
368
+ : [];
369
+ const safeSnapshots = snapshots || [];
370
+
371
+ // Compute overall totals
372
+ const totals = useMemo(
373
+ () =>
374
+ safeMetrics.reduce(
375
+ (acc, m) => {
376
+ acc.input += m.inputTokens || 0;
377
+ acc.output += m.outputTokens || 0;
378
+ acc.cacheRead += m.cacheReadTokens || 0;
379
+ acc.cacheCreated += m.cacheCreationTokens || 0;
380
+ if (m.estimatedCostUsd != null) {
381
+ acc.cost = (acc.cost ?? 0) + m.estimatedCostUsd;
382
+ }
383
+ return acc;
384
+ },
385
+ { input: 0, output: 0, cacheRead: 0, cacheCreated: 0, cost: null },
386
+ ),
387
+ [safeMetrics],
388
+ );
389
+
390
+ const sessionCount = safeMetrics.length;
391
+ const ioTokens = totals.input + totals.output;
392
+ const cacheTokens = totals.cacheRead + totals.cacheCreated;
393
+ const avgCost = totals.cost != null && sessionCount > 0 ? totals.cost / sessionCount : null;
394
+
395
+ // Group by capability when requested
396
+ const grouped = useMemo(() => {
397
+ if (!groupByCapability) return null;
398
+ const map = new Map();
399
+ for (const m of safeMetrics) {
400
+ const cap = m.capability || "unknown";
401
+ if (!map.has(cap)) map.set(cap, []);
402
+ map.get(cap).push(m);
403
+ }
404
+ return map;
405
+ }, [safeMetrics, groupByCapability]);
406
+
407
+ const thClass = "text-gray-500 uppercase text-xs tracking-wider text-left px-3 py-2 font-normal";
408
+ const tdClass = "px-3 py-2 border-b border-border text-sm";
409
+ const tdMono = `${tdClass} font-mono`;
410
+
411
+ function MetricRow({ m }) {
412
+ return html`
413
+ <tr class="hover:bg-white/5">
414
+ <td class=${tdClass}>
415
+ <a
416
+ href=${`#inspect/${encodeURIComponent(m.agentName || "")}`}
417
+ class="text-blue-400 hover:text-blue-300"
418
+ >
419
+ ${m.agentName || "—"}
420
+ </a>
421
+ </td>
422
+ <td class=${`text-gray-400 ${tdClass}`}>${m.capability || ""}</td>
423
+ <td class=${tdMono}>${formatNumber(m.inputTokens)}</td>
424
+ <td class=${tdMono}>${formatNumber(m.outputTokens)}</td>
425
+ <td class=${tdMono}>${formatNumber(m.cacheReadTokens)}</td>
426
+ <td class=${tdMono}>${formatNumber(m.cacheCreationTokens)}</td>
427
+ <td class=${tdMono}>${m.estimatedCostUsd != null ? formatCost(m.estimatedCostUsd) : "—"}</td>
428
+ </tr>
429
+ `;
430
+ }
431
+
432
+ function SubtotalRow({ label, rows }) {
433
+ const sub = rows.reduce(
434
+ (acc, m) => {
435
+ acc.input += m.inputTokens || 0;
436
+ acc.output += m.outputTokens || 0;
437
+ acc.cacheRead += m.cacheReadTokens || 0;
438
+ acc.cacheCreated += m.cacheCreationTokens || 0;
439
+ if (m.estimatedCostUsd != null) {
440
+ acc.cost = (acc.cost ?? 0) + m.estimatedCostUsd;
441
+ }
442
+ return acc;
443
+ },
444
+ { input: 0, output: 0, cacheRead: 0, cacheCreated: 0, cost: null },
445
+ );
446
+ return html`
447
+ <tr class="bg-white/5 text-gray-400">
448
+ <td class=${`font-medium ${tdClass}`} colspan="2">${label} subtotal</td>
449
+ <td class=${tdMono}>${formatNumber(sub.input)}</td>
450
+ <td class=${tdMono}>${formatNumber(sub.output)}</td>
451
+ <td class=${tdMono}>${formatNumber(sub.cacheRead)}</td>
452
+ <td class=${tdMono}>${formatNumber(sub.cacheCreated)}</td>
453
+ <td class=${tdMono}>${sub.cost != null ? formatCost(sub.cost) : "—"}</td>
454
+ </tr>
455
+ `;
456
+ }
457
+
458
+ function TableBody() {
459
+ if (safeMetrics.length === 0) {
460
+ return html`
461
+ <tr>
462
+ <td colspan="7" class="text-gray-500 text-center py-8">No metrics yet</td>
463
+ </tr>
464
+ `;
465
+ }
466
+ if (grouped) {
467
+ const rows = [];
468
+ for (const [cap, capMetrics] of grouped) {
469
+ for (const m of capMetrics) {
470
+ rows.push(html`<${MetricRow} key=${m.agentName + cap} m=${m} />`);
471
+ }
472
+ rows.push(html`<${SubtotalRow} key=${`sub-${cap}`} label=${cap} rows=${capMetrics} />`);
473
+ }
474
+ return html`${rows}`;
475
+ }
476
+ return html`${safeMetrics.map((m) => html`<${MetricRow} key=${m.agentName} m=${m} />`)}`;
477
+ }
478
+
479
+ // Determine if capability chart should show
480
+ const uniqueCaps = new Set(safeMetrics.map((m) => m.capability || "unknown"));
481
+ const agentCount = useMemo(
482
+ () => new Set(safeMetrics.map((m) => m.agentName || "unknown")).size,
483
+ [safeMetrics],
484
+ );
485
+
486
+ return html`
487
+ <div class="flex flex-col gap-6 p-6">
488
+ <!-- Time Window Selector -->
489
+ <div class="flex items-center justify-between">
490
+ <div class="text-gray-500 uppercase text-xs tracking-wider">Cost Analysis</div>
491
+ <div class="flex items-center gap-2">
492
+ ${loading ? html`<span class="text-xs text-gray-500">Loading...</span>` : null}
493
+ <select
494
+ class="text-sm bg-surface border border-border rounded-sm px-3 py-1.5 text-gray-300 focus:outline-none focus:border-blue-500"
495
+ value=${String(timeWindow)}
496
+ onChange=${(e) => {
497
+ const val = e.target.value;
498
+ setTimeWindow(val === "null" ? null : Number(val));
499
+ }}
500
+ >
501
+ ${TIME_WINDOWS.map(
502
+ (w) => html`
503
+ <option key=${String(w.value)} value=${String(w.value)}>
504
+ ${w.label}
505
+ </option>
506
+ `,
507
+ )}
508
+ </select>
509
+ </div>
510
+ </div>
511
+
512
+ <!-- Summary Stat Cards -->
513
+ <div class="flex gap-4">
514
+ <${StatCard}
515
+ label="Total Cost"
516
+ value=${totals.cost != null ? formatCostShort(totals.cost) : "—"}
517
+ />
518
+ <${StatCard}
519
+ label="Input/Output"
520
+ value=${formatNumber(ioTokens)}
521
+ />
522
+ <${StatCard}
523
+ label="Cache"
524
+ value=${formatNumber(cacheTokens)}
525
+ />
526
+ <${StatCard}
527
+ label="Sessions"
528
+ value=${String(sessionCount)}
529
+ />
530
+ <${StatCard}
531
+ label="Avg Cost/Session"
532
+ value=${avgCost != null ? formatCostShort(avgCost) : "—"}
533
+ />
534
+ </div>
535
+
536
+ <!-- Model Usage Breakdown (show when data available) -->
537
+ ${
538
+ modelData.length > 0
539
+ ? html`
540
+ <div class="bg-surface border border-border rounded-sm p-4">
541
+ <div class="text-gray-500 uppercase text-xs tracking-wider mb-4">Model Usage</div>
542
+ <${ModelBreakdown} modelData=${modelData} />
543
+ </div>
544
+ `
545
+ : null
546
+ }
547
+
548
+ <!-- Cost by Agent Bar Chart -->
549
+ <div class="bg-surface border border-border rounded-sm p-4">
550
+ <div
551
+ class="flex items-center justify-between cursor-pointer"
552
+ onClick=${() => setAgentExpanded((v) => !v)}
553
+ >
554
+ <div class="text-gray-500 uppercase text-xs tracking-wider">Cost by Agent</div>
555
+ <span class="text-gray-500 text-sm">${agentExpanded ? "▾" : "▸"}</span>
556
+ </div>
557
+ ${
558
+ agentExpanded
559
+ ? html`<div class="mt-4"><${AgentBarChart} metrics=${safeMetrics} /></div>`
560
+ : html`<div class="text-sm text-gray-500 mt-2">${agentCount} agent${agentCount === 1 ? "" : "s"} — ${totals.cost != null ? formatCostShort(totals.cost) : "—"} total</div>`
561
+ }
562
+ </div>
563
+
564
+ <!-- Date Chart (show when data available) -->
565
+ ${
566
+ dateData.length > 0
567
+ ? html`
568
+ <div class="bg-surface border border-border rounded-sm p-4">
569
+ <div class="text-gray-500 uppercase text-xs tracking-wider mb-4">Daily Cost Trend</div>
570
+ <${DateChart} dateData=${dateData} />
571
+ </div>
572
+ `
573
+ : null
574
+ }
575
+
576
+ <!-- Token Distribution -->
577
+ <div class="bg-surface border border-border rounded-sm p-4">
578
+ <div class="text-gray-500 uppercase text-xs tracking-wider mb-4">Token Distribution</div>
579
+ <${TokenDistribution} totals=${totals} />
580
+ </div>
581
+
582
+ <!-- Cost by Capability (only if more than one capability) -->
583
+ ${
584
+ uniqueCaps.size > 1
585
+ ? html`
586
+ <div class="bg-surface border border-border rounded-sm p-4">
587
+ <div class="text-gray-500 uppercase text-xs tracking-wider mb-4">Cost by Capability</div>
588
+ <${CapabilityChart} metrics=${safeMetrics} />
589
+ </div>
590
+ `
591
+ : null
592
+ }
593
+
594
+ <!-- Detailed Costs Table -->
595
+ <div class="bg-surface border border-border rounded-sm p-4">
596
+ <div class=${`flex items-center gap-3 ${detailExpanded ? "mb-4" : ""}`}>
597
+ <div
598
+ class="flex items-center gap-2 cursor-pointer"
599
+ onClick=${() => setDetailExpanded((v) => !v)}
600
+ >
601
+ <span class="text-gray-500 uppercase text-xs tracking-wider">Detailed Breakdown</span>
602
+ <span class="text-gray-500 text-sm">${detailExpanded ? "▾" : "▸"}</span>
603
+ </div>
604
+ ${
605
+ detailExpanded
606
+ ? html`
607
+ <button
608
+ class=${
609
+ "text-sm px-3 py-1.5 rounded-sm border border-border ml-auto " +
610
+ (groupByCapability
611
+ ? "bg-white/10 text-gray-200"
612
+ : "bg-surface text-gray-400 hover:text-gray-200")
613
+ }
614
+ onClick=${onToggleGroup}
615
+ >
616
+ ${groupByCapability ? "Ungroup" : "Group by Capability"}
617
+ </button>
618
+ `
619
+ : null
620
+ }
621
+ </div>
622
+
623
+ ${
624
+ detailExpanded
625
+ ? html`
626
+ <div class="overflow-x-auto">
627
+ <table class="w-full text-sm border-collapse">
628
+ <thead>
629
+ <tr class="border-b border-border">
630
+ <th class=${thClass}>Agent</th>
631
+ <th class=${thClass}>Capability</th>
632
+ <th class=${thClass}>Input Tokens</th>
633
+ <th class=${thClass}>Output Tokens</th>
634
+ <th class=${thClass}>Cache Read</th>
635
+ <th class=${thClass}>Cache Created</th>
636
+ <th class=${thClass}>Est. Cost</th>
637
+ </tr>
638
+ </thead>
639
+ <tbody>
640
+ <${TableBody} />
641
+ </tbody>
642
+ <tfoot class="border-t border-border">
643
+ <tr class="text-gray-300 font-medium">
644
+ <td class=${`border-b-0 ${tdClass}`} colspan="2">Total</td>
645
+ <td class=${`border-b-0 ${tdMono}`}>${formatNumber(totals.input)}</td>
646
+ <td class=${`border-b-0 ${tdMono}`}>${formatNumber(totals.output)}</td>
647
+ <td class=${`border-b-0 ${tdMono}`}>${formatNumber(totals.cacheRead)}</td>
648
+ <td class=${`border-b-0 ${tdMono}`}>${formatNumber(totals.cacheCreated)}</td>
649
+ <td class=${`border-b-0 ${tdMono}`}>
650
+ ${totals.cost != null ? formatCost(totals.cost) : "—"}
651
+ </td>
652
+ </tr>
653
+ </tfoot>
654
+ </table>
655
+ </div>
656
+ `
657
+ : null
658
+ }
659
+ </div>
660
+
661
+ <!-- Live Snapshots (only when data exists) -->
662
+ ${
663
+ safeSnapshots.length > 0
664
+ ? html`
665
+ <div class="bg-surface border border-border rounded-sm p-4">
666
+ <div class="text-gray-500 uppercase text-xs tracking-wider mb-3">
667
+ Active Agent Token Usage
668
+ </div>
669
+ <div class="flex flex-col gap-2">
670
+ ${safeSnapshots.map(
671
+ (s) => html`
672
+ <div
673
+ key=${s.agentName}
674
+ class="flex flex-row gap-4 items-baseline text-sm py-1 border-b border-border last:border-0"
675
+ >
676
+ <span class="text-gray-200 min-w-[120px]">${s.agentName || ""}</span>
677
+ <span class="font-mono text-gray-300">
678
+ ${formatNumber((s.inputTokens || 0) + (s.outputTokens || 0))} tokens
679
+ </span>
680
+ <span class="text-gray-500">${s.modelUsed || ""}</span>
681
+ <span class="text-gray-500 ml-auto">${timeAgo(s.createdAt)}</span>
682
+ </div>
683
+ `,
684
+ )}
685
+ </div>
686
+ </div>
687
+ `
688
+ : null
689
+ }
690
+ </div>
691
+ `;
692
+ }