@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,317 @@
1
+ import { spawn } from "node:child_process";
2
+ import { access, readFile } from "node:fs/promises";
3
+ import * as http from "node:http";
4
+ import { dirname, extname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { WebSocketServer } from "ws";
7
+ import type { HeadlessCoordinator } from "./headless.ts";
8
+ import { handleApiRequest } from "./routes.ts";
9
+ import { createWebSocketManager, type WebSocketData } from "./websocket.ts";
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+
13
+ export interface ServerOptions {
14
+ port: number;
15
+ host: string;
16
+ root: string; // Project root directory
17
+ shouldOpen?: boolean; // Auto-open browser
18
+ autoStartCoordinator?: boolean; // Auto-start coordinator with --watchdog on server start
19
+ }
20
+
21
+ /** Dependency injection for testing. */
22
+ export interface ServerDeps {
23
+ /** Inject a custom coordinator start function (for testing). */
24
+ _tryStartCoordinator?: (root: string) => Promise<void>;
25
+ }
26
+
27
+ export interface ServerInstance {
28
+ port: number;
29
+ stop(force?: boolean): void;
30
+ }
31
+
32
+ const MIME_TYPES: Record<string, string> = {
33
+ ".html": "text/html",
34
+ ".js": "application/javascript",
35
+ ".css": "text/css",
36
+ ".json": "application/json",
37
+ ".png": "image/png",
38
+ ".svg": "image/svg+xml",
39
+ ".ico": "image/x-icon",
40
+ ".txt": "text/plain",
41
+ };
42
+
43
+ function fileExists(filePath: string): Promise<boolean> {
44
+ return access(filePath).then(
45
+ () => true,
46
+ () => false,
47
+ );
48
+ }
49
+
50
+ function collectBody(req: http.IncomingMessage): Promise<Buffer> {
51
+ return new Promise((resolve, reject) => {
52
+ const chunks: Buffer[] = [];
53
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
54
+ req.on("end", () => resolve(Buffer.concat(chunks)));
55
+ req.on("error", reject);
56
+ });
57
+ }
58
+
59
+ async function sendWebResponse(webRes: Response, res: http.ServerResponse): Promise<void> {
60
+ res.statusCode = webRes.status;
61
+ webRes.headers.forEach((value, key) => {
62
+ res.setHeader(key, value);
63
+ });
64
+ const body = await webRes.arrayBuffer();
65
+ res.end(Buffer.from(body));
66
+ }
67
+
68
+ /**
69
+ * Check if coordinator is running and start it if not.
70
+ * Fire-and-forget: caller does not await.
71
+ */
72
+ async function tryStartCoordinator(root: string): Promise<void> {
73
+ // Check if coordinator is already running
74
+ const statusProc = spawn("legio", ["coordinator", "status", "--json"], {
75
+ cwd: root,
76
+ stdio: ["ignore", "pipe", "pipe"],
77
+ });
78
+
79
+ const chunks: Buffer[] = [];
80
+ statusProc.stdout?.on("data", (chunk: Buffer) => chunks.push(chunk));
81
+
82
+ const statusCode = await new Promise<number>((resolve) => {
83
+ statusProc.on("close", (code) => resolve(code ?? 1));
84
+ });
85
+
86
+ if (statusCode === 0) {
87
+ const output = Buffer.concat(chunks).toString();
88
+ try {
89
+ const status = JSON.parse(output) as { running?: boolean };
90
+ if (status.running) {
91
+ process.stdout.write("[legio] Coordinator already running\n");
92
+ return;
93
+ }
94
+ } catch {
95
+ // Cannot parse status — fall through to start
96
+ }
97
+ }
98
+
99
+ // Start coordinator detached so the server doesn't wait on it
100
+ const startProc = spawn("legio", ["coordinator", "start", "--watchdog", "--no-attach"], {
101
+ cwd: root,
102
+ detached: true,
103
+ stdio: "ignore",
104
+ });
105
+ startProc.unref();
106
+ process.stdout.write("[legio] Coordinator started\n");
107
+ }
108
+
109
+ /**
110
+ * Create and return a server instance without blocking.
111
+ * Exported for testing; production code should use startServer().
112
+ */
113
+ export async function createServer(
114
+ options: ServerOptions,
115
+ deps?: ServerDeps,
116
+ ): Promise<ServerInstance> {
117
+ const { port, host, root } = options;
118
+ const legioDir = join(root, ".legio");
119
+ process.stdout.write(`[legio] Server legioDir: ${legioDir}\n`);
120
+ const publicDir = join(__dirname, "public");
121
+
122
+ const wsManager = createWebSocketManager(legioDir);
123
+
124
+ let firstRequest = true;
125
+
126
+ // Headless coordinator state — shared across all requests
127
+ let headlessCoordinator: HeadlessCoordinator | null = null;
128
+ const headlessState = {
129
+ get coordinator(): HeadlessCoordinator | null {
130
+ return headlessCoordinator;
131
+ },
132
+ setCoordinator(c: HeadlessCoordinator | null): void {
133
+ headlessCoordinator = c;
134
+ },
135
+ };
136
+
137
+ const httpServer = http.createServer(async (req, res) => {
138
+ try {
139
+ const urlStr = `http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`;
140
+ const url = new URL(urlStr);
141
+ const pathname = url.pathname;
142
+
143
+ // WebSocket upgrade requests are handled by the upgrade event, not here
144
+ if (pathname === "/ws") {
145
+ res.writeHead(400);
146
+ res.end("WebSocket upgrade required");
147
+ return;
148
+ }
149
+
150
+ // API routes
151
+ if (pathname.startsWith("/api/")) {
152
+ if (firstRequest) {
153
+ firstRequest = false;
154
+ process.stdout.write(`[legio] First API request — legioDir: ${legioDir}\n`);
155
+ }
156
+ try {
157
+ const body = await collectBody(req);
158
+ const headers = new Headers();
159
+ for (const [key, value] of Object.entries(req.headers)) {
160
+ if (value !== undefined) {
161
+ if (Array.isArray(value)) {
162
+ for (const v of value) headers.append(key, v);
163
+ } else {
164
+ headers.set(key, value);
165
+ }
166
+ }
167
+ }
168
+
169
+ const webReq = new Request(urlStr, {
170
+ method: req.method ?? "GET",
171
+ headers,
172
+ body: body.length > 0 ? body.toString("utf8") : undefined,
173
+ });
174
+
175
+ const webRes = await handleApiRequest(webReq, legioDir, root, wsManager, headlessState);
176
+ await sendWebResponse(webRes, res);
177
+ } catch (err) {
178
+ const message = err instanceof Error ? err.message : "Internal server error";
179
+ res.writeHead(500, { "Content-Type": "application/json" });
180
+ res.end(JSON.stringify({ error: message }));
181
+ }
182
+ return;
183
+ }
184
+
185
+ // Static files
186
+ const filePath =
187
+ pathname === "/" ? join(publicDir, "index.html") : join(publicDir, pathname.slice(1));
188
+
189
+ if (await fileExists(filePath)) {
190
+ const content = await readFile(filePath);
191
+ const ext = extname(filePath);
192
+ const mimeType = MIME_TYPES[ext] ?? "application/octet-stream";
193
+ res.writeHead(200, { "Content-Type": mimeType, "Cache-Control": "no-cache" });
194
+ res.end(content);
195
+ return;
196
+ }
197
+
198
+ // SPA fallback — serve index.html for non-file paths (hash routing)
199
+ const indexPath = join(publicDir, "index.html");
200
+ if (await fileExists(indexPath)) {
201
+ const content = await readFile(indexPath);
202
+ res.writeHead(200, { "Content-Type": "text/html", "Cache-Control": "no-cache" });
203
+ res.end(content);
204
+ return;
205
+ }
206
+
207
+ res.writeHead(404);
208
+ res.end("Not found");
209
+ } catch (err) {
210
+ const message = err instanceof Error ? err.message : "Server error";
211
+ res.writeHead(500);
212
+ res.end(message);
213
+ }
214
+ });
215
+
216
+ const wss = new WebSocketServer({ noServer: true });
217
+
218
+ httpServer.on("upgrade", (req, socket, head) => {
219
+ const urlStr = `http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`;
220
+ const url = new URL(urlStr);
221
+ if (url.pathname === "/ws") {
222
+ wss.handleUpgrade(req, socket as import("node:net").Socket, head, (ws) => {
223
+ wss.emit("connection", ws, req);
224
+ });
225
+ } else {
226
+ socket.destroy();
227
+ }
228
+ });
229
+
230
+ wss.on("connection", (ws) => {
231
+ const _data: WebSocketData = { connectedAt: new Date().toISOString() };
232
+ wsManager.addClient(ws);
233
+ ws.on("message", (message) => {
234
+ wsManager.handleMessage(ws, message);
235
+ });
236
+ ws.on("close", () => {
237
+ wsManager.removeClient(ws);
238
+ });
239
+ });
240
+
241
+ // Start WebSocket polling
242
+ wsManager.startPolling();
243
+
244
+ // Start listening and wait for the port to be assigned
245
+ await new Promise<void>((resolve, reject) => {
246
+ httpServer.once("listening", resolve);
247
+ httpServer.once("error", reject);
248
+ httpServer.listen(port, host);
249
+ });
250
+
251
+ const address = httpServer.address();
252
+ const actualPort = typeof address === "object" && address !== null ? address.port : port;
253
+
254
+ // Fire-and-forget coordinator auto-start (after server is listening)
255
+ if (options.autoStartCoordinator) {
256
+ const coordinatorFn = deps?._tryStartCoordinator ?? tryStartCoordinator;
257
+ coordinatorFn(root).catch((err) => {
258
+ process.stderr.write(
259
+ `[legio] Failed to start coordinator: ${err instanceof Error ? err.message : String(err)}\n`,
260
+ );
261
+ });
262
+ }
263
+
264
+ return {
265
+ port: actualPort,
266
+ stop(_force?: boolean) {
267
+ wsManager.stopPolling();
268
+ wss.close();
269
+ httpServer.close();
270
+ // Stop headless coordinator if running
271
+ if (headlessCoordinator?.isRunning()) {
272
+ headlessCoordinator.stop().catch(() => {
273
+ // ignore stop errors during server shutdown
274
+ });
275
+ }
276
+ },
277
+ };
278
+ }
279
+
280
+ export async function startServer(options: ServerOptions): Promise<void> {
281
+ const { host, shouldOpen } = options;
282
+
283
+ const server = await createServer(options);
284
+
285
+ // When running as a daemon, the PID file was written by the parent with the
286
+ // shim wrapper's PID. The shim re-execs via spawnSync, so the actual server
287
+ // runs in a grandchild process with a different PID. Overwrite the PID file
288
+ // with our real PID so that `legio server stop` sends SIGTERM to the right
289
+ // process.
290
+ if (process.env.LEGIO_SERVER_DAEMON === "1") {
291
+ const { writeServerPid } = await import("../commands/server.ts");
292
+ await writeServerPid(options.root, process.pid);
293
+ }
294
+
295
+ const url = `http://${host}:${server.port}`;
296
+ process.stdout.write(`Legio web UI running at ${url}\n`);
297
+
298
+ if (shouldOpen) {
299
+ // Open browser (macOS: open, Linux: xdg-open)
300
+ const cmd = process.platform === "darwin" ? "open" : "xdg-open";
301
+ const child = spawn(cmd, [url], { detached: true, stdio: "ignore" });
302
+ child.unref();
303
+ }
304
+
305
+ // Graceful shutdown
306
+ const shutdown = () => {
307
+ process.stdout.write("\nShutting down server...\n");
308
+ server.stop(true);
309
+ process.exit(0);
310
+ };
311
+
312
+ process.on("SIGINT", shutdown);
313
+ process.on("SIGTERM", shutdown);
314
+
315
+ // http.createServer + listen keeps the process alive naturally via the event loop.
316
+ // No need to await an infinite promise.
317
+ }
@@ -0,0 +1,187 @@
1
+ // Legio Web UI — Core Application (Preact + HTM + Tailwind)
2
+ // ES module: imports from lib/ siblings and all views from views/.
3
+
4
+ import { html } from "htm/preact";
5
+ import { render } from "preact";
6
+ import { useEffect, useState } from "preact/hooks";
7
+ import { fetchJson } from "./lib/api.js";
8
+ import { appState, setLastUpdated } from "./lib/state.js";
9
+ import { timeAgo } from "./lib/utils.js";
10
+ import { connectWS } from "./lib/ws.js";
11
+ import { CostsView } from "./views/costs.js";
12
+ import { DashboardView } from "./views/dashboard.js";
13
+ import { InspectView } from "./views/inspect.js";
14
+ import { IssuesView } from "./views/issues.js";
15
+ import { SetupView } from "./views/setup.js";
16
+ import { TaskDetailView } from "./views/task-detail.js";
17
+
18
+ // ===== Initial Data Fetch =====
19
+
20
+ export async function initData() {
21
+ const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
22
+ try {
23
+ const [status, mail, agents, events, metrics, mergeQueue, issues] = await Promise.all([
24
+ fetchJson("/api/status").catch(() => null),
25
+ fetchJson("/api/mail").catch(() => []),
26
+ fetchJson("/api/agents").catch(() => []),
27
+ fetchJson(`/api/events?since=${encodeURIComponent(since24h)}&limit=200`).catch(() => []),
28
+ fetchJson("/api/metrics").catch(() => []),
29
+ fetchJson("/api/merge-queue").catch(() => []),
30
+ fetchJson("/api/issues").catch(() => []),
31
+ ]);
32
+
33
+ if (status !== null) appState.status.value = status;
34
+ appState.agents.value = agents ?? [];
35
+ appState.mail.value = Array.isArray(mail) ? mail : (mail?.recent ?? []);
36
+ appState.events.value = events ?? [];
37
+ appState.metrics.value = metrics ?? [];
38
+ appState.mergeQueue.value = mergeQueue ?? [];
39
+ appState.issues.value = issues ?? [];
40
+ setLastUpdated();
41
+ } catch (e) {
42
+ console.error("[legio] initData error:", e);
43
+ }
44
+ }
45
+
46
+ // ===== Hash Router Helpers =====
47
+
48
+ function parseHash(hash) {
49
+ const withoutHash = (hash || "#dashboard").replace(/^#\/?/, "");
50
+ const parts = withoutHash.split("/");
51
+ return { view: parts[0] || "dashboard", param: parts[1] ?? null };
52
+ }
53
+
54
+ // ===== Router =====
55
+
56
+ function Router({ view, param }) {
57
+ switch (view) {
58
+ case "dashboard":
59
+ return html`<${DashboardView} />`;
60
+ case "costs":
61
+ return html`<${CostsView} metrics=${appState.metrics.value} snapshots=${appState.snapshots.value} />`;
62
+ case "tasks":
63
+ return html`<${IssuesView} />`;
64
+ case "task":
65
+ return html`<${TaskDetailView} taskId=${param} />`;
66
+ case "inspect":
67
+ return html`<${InspectView} agentName=${param} />`;
68
+ default:
69
+ return html`<${DashboardView} />`;
70
+ }
71
+ }
72
+
73
+ // ===== Layout =====
74
+
75
+ const NAV_LINKS = [
76
+ { href: "#dashboard", label: "Dashboard", view: "dashboard" },
77
+ { href: "#costs", label: "Costs", view: "costs" },
78
+ { href: "#tasks", label: "Tasks", view: "tasks" },
79
+ ];
80
+
81
+ function Layout({ view, param }) {
82
+ const connected = appState.connected.value;
83
+ const lastUpdated = appState.lastUpdated.value;
84
+
85
+ return html`
86
+ <div class="flex flex-col h-screen bg-[#0f0f0f]">
87
+ <nav class="flex items-center justify-between px-4 border-b border-[#2a2a2a] bg-[#1a1a1a] shrink-0">
88
+ <div class="flex items-center">
89
+ ${NAV_LINKS.map((link) => {
90
+ const isActive = link.view === view;
91
+ return html`
92
+ <a
93
+ key=${link.view}
94
+ href=${link.href}
95
+ class=${
96
+ "px-4 py-3 text-sm font-medium transition-colors border-b-2 " +
97
+ (isActive
98
+ ? "text-white border-[#E64415]"
99
+ : "text-[#888] border-transparent hover:text-[#ccc]")
100
+ }
101
+ >
102
+ ${link.label}
103
+ </a>
104
+ `;
105
+ })}
106
+ </div>
107
+ <div class="flex items-center gap-3 pr-2">
108
+ <span
109
+ class=${`w-2 h-2 rounded-full ${connected ? "bg-green-500" : "bg-[#444]"}`}
110
+ title=${connected ? "WebSocket connected" : "WebSocket disconnected"}
111
+ ></span>
112
+ ${
113
+ lastUpdated
114
+ ? html`
115
+ <span class="text-[#555] text-xs font-mono">${timeAgo(lastUpdated)}</span>
116
+ `
117
+ : null
118
+ }
119
+ </div>
120
+ </nav>
121
+ <main class="flex-1 overflow-auto min-h-0">
122
+ <${Router} key=${view} view=${view} param=${param} />
123
+ </main>
124
+ </div>
125
+ `;
126
+ }
127
+
128
+ // ===== App =====
129
+
130
+ function App() {
131
+ const [route, setRoute] = useState(() => parseHash(location.hash));
132
+ const [setupChecked, setSetupChecked] = useState(false);
133
+ const [isInitialized, setIsInitialized] = useState(true); // assume initialized until checked
134
+ const [setupStatus, setSetupStatus] = useState(null);
135
+
136
+ useEffect(() => {
137
+ const onHashChange = () => {
138
+ const hash = location.hash;
139
+ if (hash === "#issues" || hash === "issues") {
140
+ window.location.hash = "#tasks";
141
+ return; // will re-trigger the hash change handler
142
+ }
143
+ setRoute(parseHash(hash));
144
+ };
145
+ // Redirect legacy #issues hash on initial load
146
+ if (location.hash === "#issues" || location.hash === "issues") {
147
+ window.location.hash = "#tasks";
148
+ }
149
+ window.addEventListener("hashchange", onHashChange);
150
+ return () => window.removeEventListener("hashchange", onHashChange);
151
+ }, []);
152
+
153
+ useEffect(() => {
154
+ connectWS();
155
+ fetchJson("/api/setup/status")
156
+ .then((data) => {
157
+ if (data.projectName) document.title = `Legio \u2014 ${data.projectName}`;
158
+ setIsInitialized(data.initialized);
159
+ setSetupStatus(data);
160
+ setSetupChecked(true);
161
+ if (data.initialized) initData(); // Only load data if initialized
162
+ })
163
+ .catch(() => {
164
+ setSetupChecked(true);
165
+ initData(); // Fallback: try loading data anyway
166
+ });
167
+ }, []);
168
+
169
+ if (!setupChecked)
170
+ return html`<div class="flex items-center justify-center h-screen bg-[#0f0f0f] text-[#555] text-sm">Loading...</div>`;
171
+ if (!isInitialized)
172
+ return html`<${SetupView}
173
+ onInitialized=${() => {
174
+ setIsInitialized(true);
175
+ initData();
176
+ }}
177
+ projectRoot=${setupStatus?.projectRoot ?? null}
178
+ />`;
179
+
180
+ return html`<${Layout} view=${route.view} param=${route.param} />`;
181
+ }
182
+
183
+ // ===== Mount =====
184
+
185
+ document.addEventListener("DOMContentLoaded", () => {
186
+ render(html`<${App} />`, document.getElementById("app"));
187
+ });
@@ -0,0 +1,37 @@
1
+ // Legio Web UI — AgentBadge component
2
+ // Inline badge: agent name + state color dot + capability label.
3
+ // No npm dependencies — uses CDN imports. Served as a static ES module.
4
+
5
+ import htm from "https://esm.sh/htm@latest";
6
+ import { h } from "https://esm.sh/preact@latest";
7
+
8
+ const html = htm.bind(h);
9
+
10
+ // State dot color classes (Tailwind utility classes, Spiegel dark theme)
11
+ const STATE_COLORS = {
12
+ working: "text-green-500",
13
+ booting: "text-yellow-500",
14
+ stalled: "text-red-500",
15
+ completed: "text-gray-500",
16
+ zombie: "text-orange-500",
17
+ };
18
+
19
+ /**
20
+ * AgentBadge — inline badge showing agent name, state, and capability.
21
+ *
22
+ * @param {object} props
23
+ * @param {string} props.name - Agent name
24
+ * @param {string} props.state - Agent state: working | booting | stalled | completed | zombie
25
+ * @param {string} props.capability - Agent capability label (e.g. "builder", "scout")
26
+ */
27
+ export function AgentBadge({ name, state, capability }) {
28
+ const dotColor = STATE_COLORS[state] || "text-gray-500";
29
+
30
+ return html`
31
+ <span class="inline-flex items-center gap-1.5">
32
+ <span class=${`${dotColor} leading-none`}>●</span>
33
+ <span class="font-medium text-[#e5e5e5] text-sm">${name}</span>
34
+ ${capability && html`<span class="text-xs text-gray-500">${capability}</span>`}
35
+ </span>
36
+ `;
37
+ }
@@ -0,0 +1,114 @@
1
+ // Legio Web UI — DataTable component
2
+ // Reusable sortable table: column definitions, sort state, click-to-sort headers.
3
+ // No npm dependencies — uses CDN imports. Served as a static ES module.
4
+
5
+ import htm from "https://esm.sh/htm@latest";
6
+ import { h } from "https://esm.sh/preact@latest";
7
+ import { useState } from "https://esm.sh/preact@latest/hooks";
8
+
9
+ const html = htm.bind(h);
10
+
11
+ /**
12
+ * DataTable — sortable table with configurable columns and row rendering.
13
+ *
14
+ * @param {object} props
15
+ * @param {Array<{key: string, label: string, render?: function, sortable?: boolean}>} props.columns
16
+ * Column definitions. `render(value, row)` is called if provided; otherwise raw value is shown.
17
+ * @param {Array<object>} props.data - Array of row data objects
18
+ * @param {string} [props.defaultSort] - Key of the column to sort by initially
19
+ * @param {function} [props.onRowClick] - Called with row object when a row is clicked
20
+ */
21
+ export function DataTable({ columns, data, defaultSort, onRowClick }) {
22
+ const [sortKey, setSortKey] = useState(defaultSort || null);
23
+ const [sortDir, setSortDir] = useState("asc");
24
+
25
+ function handleHeaderClick(col) {
26
+ if (!col.sortable) return;
27
+ if (sortKey === col.key) {
28
+ setSortDir((d) => (d === "asc" ? "desc" : "asc"));
29
+ } else {
30
+ setSortKey(col.key);
31
+ setSortDir("asc");
32
+ }
33
+ }
34
+
35
+ const sortedData = [...(data || [])].sort((a, b) => {
36
+ if (!sortKey) return 0;
37
+ const av = a[sortKey];
38
+ const bv = b[sortKey];
39
+ if (av == null && bv == null) return 0;
40
+ if (av == null) return 1;
41
+ if (bv == null) return -1;
42
+ const cmp = String(av).localeCompare(String(bv), undefined, { numeric: true });
43
+ return sortDir === "asc" ? cmp : -cmp;
44
+ });
45
+
46
+ return html`
47
+ <div class="w-full overflow-x-auto">
48
+ <table class="w-full border-collapse text-sm">
49
+ <thead>
50
+ <tr class="bg-[#1a1a1a] border-b border-[#2a2a2a]">
51
+ ${columns.map(
52
+ (col) => html`
53
+ <th
54
+ key=${col.key}
55
+ class=${[
56
+ "px-3 py-2 text-left text-xs uppercase tracking-wide text-gray-500 select-none",
57
+ col.sortable ? "cursor-pointer hover:text-[#e5e5e5]" : "",
58
+ ].join(" ")}
59
+ onClick=${() => handleHeaderClick(col)}
60
+ >
61
+ <span class="flex items-center gap-1">
62
+ ${col.label}
63
+ ${
64
+ col.sortable &&
65
+ sortKey === col.key &&
66
+ html`<span class="text-[#E64415]">
67
+ ${sortDir === "asc" ? "↑" : "↓"}
68
+ </span>`
69
+ }
70
+ </span>
71
+ </th>
72
+ `,
73
+ )}
74
+ </tr>
75
+ </thead>
76
+ <tbody>
77
+ ${sortedData.map(
78
+ (row, i) => html`
79
+ <tr
80
+ key=${i}
81
+ class=${[
82
+ "border-b border-[#1a1a1a] transition-colors",
83
+ onRowClick ? "cursor-pointer hover:bg-[#222]" : "hover:bg-[#222]",
84
+ ].join(" ")}
85
+ onClick=${onRowClick ? () => onRowClick(row) : undefined}
86
+ >
87
+ ${columns.map(
88
+ (col) => html`
89
+ <td key=${col.key} class="px-3 py-2 text-[#e5e5e5]">
90
+ ${col.render ? col.render(row[col.key], row) : (row[col.key] ?? "")}
91
+ </td>
92
+ `,
93
+ )}
94
+ </tr>
95
+ `,
96
+ )}
97
+ ${
98
+ sortedData.length === 0 &&
99
+ html`
100
+ <tr>
101
+ <td
102
+ colspan=${columns.length}
103
+ class="px-3 py-6 text-center text-gray-500 text-sm"
104
+ >
105
+ No data
106
+ </td>
107
+ </tr>
108
+ `
109
+ }
110
+ </tbody>
111
+ </table>
112
+ </div>
113
+ `;
114
+ }