@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,1964 @@
1
+ /**
2
+ * REST API route handlers for the legio web UI server.
3
+ *
4
+ * Single exported function `handleApiRequest` dispatches all /api/* routes
5
+ * to the appropriate store. Each handler opens and closes its store within
6
+ * the request — per-request store lifecycle with no shared state.
7
+ */
8
+
9
+ import { spawn } from "node:child_process";
10
+ import { randomUUID } from "node:crypto";
11
+ import { constants } from "node:fs";
12
+ import { access, readFile, writeFile } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+ import { createBeadsClient } from "../beads/client.ts";
15
+ import { gatherInspectData } from "../commands/inspect.ts";
16
+ import { gatherStatus } from "../commands/status.ts";
17
+ import { loadConfig } from "../config.ts";
18
+ import { createEventStore } from "../events/store.ts";
19
+ import { createMailStore } from "../mail/store.ts";
20
+ import { createMergeQueue } from "../merge/queue.ts";
21
+ import { createMetricsStore } from "../metrics/store.ts";
22
+ import { openSessionStore } from "../sessions/compat.ts";
23
+ import { createRunStore, createSessionStore } from "../sessions/store.ts";
24
+ import type {
25
+ EventLevel,
26
+ HeadlessCoordinatorConfig,
27
+ MailAudience,
28
+ MailMessage,
29
+ MergeEntry,
30
+ RunStatus,
31
+ } from "../types.ts";
32
+ import { MAIL_MESSAGE_TYPES } from "../types.ts";
33
+ import { isSessionAlive, readTerminalLog, sendKeys } from "../worktree/tmux.ts";
34
+ import { createAuditStore } from "./audit-store.ts";
35
+ import { HeadlessCoordinator } from "./headless.ts";
36
+
37
+ // TODO: move Idea and IdeasFile to types.ts
38
+ interface Idea {
39
+ id: string; // "idea-" + randomUUID().slice(0, 8)
40
+ title: string;
41
+ body: string;
42
+ status: "active" | "dispatched" | "backlog";
43
+ createdAt: string;
44
+ updatedAt: string;
45
+ }
46
+
47
+ interface IdeasFile {
48
+ ideas: Idea[];
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Module-level cache for /api/issues (default requests only)
53
+ // ---------------------------------------------------------------------------
54
+
55
+ let issuesCacheData: unknown = null;
56
+ let issuesCacheAt = 0;
57
+ const ISSUES_CACHE_TTL = 3000;
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // File helpers
61
+ // ---------------------------------------------------------------------------
62
+
63
+ async function fileExists(p: string): Promise<boolean> {
64
+ try {
65
+ await access(p, constants.F_OK);
66
+ return true;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Route helpers
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Match a URL path against a pattern with named params (e.g. `/api/agents/:name`).
78
+ * Returns a Record of captured param values, or null if not matched.
79
+ */
80
+ function matchRoute(path: string, pattern: string): Record<string, string> | null {
81
+ const regexStr = pattern.replace(/:(\w+)/g, "(?<$1>[^/]+)");
82
+ const match = new RegExp(`^${regexStr}$`).exec(path);
83
+ return match?.groups ?? null;
84
+ }
85
+
86
+ function jsonResponse(data: unknown, status = 200): Response {
87
+ return new Response(JSON.stringify(data), {
88
+ status,
89
+ headers: { "Content-Type": "application/json" },
90
+ });
91
+ }
92
+
93
+ function errorResponse(message: string, status = 500): Response {
94
+ return jsonResponse({ error: message }, status);
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Terminal helpers
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /**
102
+ * Load the orchestrator's registered tmux session name from orchestrator-tmux.json.
103
+ * Written by `legio prime` at SessionStart when running inside tmux.
104
+ */
105
+ async function loadOrchestratorTmuxSession(projectRoot: string): Promise<string | null> {
106
+ const regPath = join(projectRoot, ".legio", "orchestrator-tmux.json");
107
+ if (!(await fileExists(regPath))) {
108
+ return null;
109
+ }
110
+ try {
111
+ const text = await readFile(regPath, "utf-8");
112
+ const reg = JSON.parse(text) as { tmuxSession?: string };
113
+ return reg.tmuxSession ?? null;
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Resolve the tmux session name for an agent.
121
+ *
122
+ * For regular agents, looks up the SessionStore.
123
+ * For "coordinator" or "orchestrator", falls back to orchestrator-tmux.json.
124
+ */
125
+ async function resolveTerminalSession(
126
+ legioDir: string,
127
+ projectRoot: string,
128
+ agentName: string,
129
+ ): Promise<string | null> {
130
+ const dbPath = join(legioDir, "sessions.db");
131
+ if (await fileExists(dbPath)) {
132
+ const { store } = openSessionStore(legioDir);
133
+ try {
134
+ const session = store.getByName(agentName);
135
+ if (session && session.state !== "zombie" && session.state !== "completed") {
136
+ return session.tmuxSession;
137
+ }
138
+ } finally {
139
+ store.close();
140
+ }
141
+ }
142
+
143
+ // Fallback for coordinator/orchestrator: check orchestrator-tmux.json
144
+ if (agentName === "coordinator" || agentName === "orchestrator") {
145
+ return await loadOrchestratorTmuxSession(projectRoot);
146
+ }
147
+
148
+ return null;
149
+ }
150
+
151
+ /**
152
+ * Capture the output of a tmux pane.
153
+ *
154
+ * @param sessionName - Tmux session name
155
+ * @param lines - Number of history lines to capture
156
+ * @returns Captured pane output, or null if capture failed
157
+ */
158
+ async function captureTmuxPane(sessionName: string, lines: number): Promise<string | null> {
159
+ return new Promise((resolve) => {
160
+ const proc = spawn("tmux", ["capture-pane", "-t", sessionName, "-p", "-S", `-${lines}`], {
161
+ stdio: ["ignore", "pipe", "pipe"],
162
+ });
163
+ let out = "";
164
+ proc.stdout.on("data", (chunk: Buffer) => {
165
+ out += chunk;
166
+ });
167
+ proc.on("close", (code: number | null) => {
168
+ resolve(code === 0 ? out.trim() : null);
169
+ });
170
+ });
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // runLegio helper
175
+ // ---------------------------------------------------------------------------
176
+
177
+ /**
178
+ * Spawn `legio <args>` as a subprocess, capture stdout/stderr, and return
179
+ * parsed JSON output on success.
180
+ */
181
+ async function runLegio(
182
+ args: string[],
183
+ projectRoot: string,
184
+ ): Promise<{ ok: true; data: unknown } | { ok: false; error: string }> {
185
+ return new Promise((resolve) => {
186
+ const proc = spawn("legio", args, {
187
+ cwd: projectRoot,
188
+ stdio: ["ignore", "pipe", "pipe"],
189
+ });
190
+ let stdout = "";
191
+ let stderr = "";
192
+ let resolved = false;
193
+ const doResolve = (result: { ok: true; data: unknown } | { ok: false; error: string }) => {
194
+ if (!resolved) {
195
+ resolved = true;
196
+ resolve(result);
197
+ }
198
+ };
199
+ proc.stdout?.on("data", (chunk: Buffer) => {
200
+ stdout += chunk;
201
+ });
202
+ proc.stderr?.on("data", (chunk: Buffer) => {
203
+ stderr += chunk;
204
+ });
205
+ // Use 'exit' instead of 'close' — 'close' waits for all stdio pipes
206
+ // to drain, which can hang if child processes (e.g., tmux sessions)
207
+ // inherit the pipe file descriptors.
208
+ proc.on("exit", (code: number | null) => {
209
+ if (code === 0) {
210
+ try {
211
+ doResolve({ ok: true, data: JSON.parse(stdout) });
212
+ } catch {
213
+ doResolve({ ok: true, data: stdout.trim() });
214
+ }
215
+ } else {
216
+ const raw = stderr.trim() || stdout.trim() || "Command failed";
217
+ const errorText = raw.split("\n")[0] ?? "Command failed";
218
+ doResolve({ ok: false, error: errorText });
219
+ }
220
+ });
221
+ proc.on("error", (err: Error) => {
222
+ doResolve({ ok: false, error: err.message });
223
+ });
224
+ // Safety timeout: resolve after 10s even if the process hasn't exited
225
+ setTimeout(() => {
226
+ if (stdout.trim().length > 0) {
227
+ try {
228
+ doResolve({ ok: true, data: JSON.parse(stdout) });
229
+ } catch {
230
+ doResolve({ ok: true, data: stdout.trim() });
231
+ }
232
+ } else {
233
+ doResolve({ ok: false, error: "Command timed out" });
234
+ }
235
+ }, 10_000);
236
+ });
237
+ }
238
+
239
+ /**
240
+ * Parse and validate a JSON request body as a plain object.
241
+ * Returns the parsed object or an error Response if invalid.
242
+ */
243
+ async function parseJsonBody(request: Request): Promise<Record<string, unknown> | Response> {
244
+ let body: unknown;
245
+ try {
246
+ body = await request.json();
247
+ } catch {
248
+ return errorResponse("Invalid JSON body", 400);
249
+ }
250
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
251
+ return errorResponse("Request body must be a JSON object", 400);
252
+ }
253
+ return body as Record<string, unknown>;
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Main dispatcher
258
+ // ---------------------------------------------------------------------------
259
+
260
+ export async function handleApiRequest(
261
+ request: Request,
262
+ legioDir: string,
263
+ projectRoot: string,
264
+ wsManager?: { broadcastEvent(event: { type: string; data?: unknown }): void } | null,
265
+ headless?: {
266
+ coordinator: HeadlessCoordinator | null;
267
+ setCoordinator: (c: HeadlessCoordinator | null) => void;
268
+ } | null,
269
+ ): Promise<Response> {
270
+ const url = new URL(request.url);
271
+ const path = url.pathname;
272
+
273
+ // -------------------------------------------------------------------------
274
+ // POST /api/mail/send
275
+ // -------------------------------------------------------------------------
276
+
277
+ if (request.method === "POST" && path === "/api/mail/send") {
278
+ let body: unknown;
279
+ try {
280
+ body = await request.json();
281
+ } catch {
282
+ return errorResponse("Invalid JSON body", 400);
283
+ }
284
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
285
+ return errorResponse("Request body must be a JSON object", 400);
286
+ }
287
+ const obj = body as Record<string, unknown>;
288
+
289
+ if (typeof obj.from !== "string" || !obj.from) {
290
+ return errorResponse("Missing required field: from", 400);
291
+ }
292
+ if (typeof obj.to !== "string" || !obj.to) {
293
+ return errorResponse("Missing required field: to", 400);
294
+ }
295
+ if (typeof obj.subject !== "string" || !obj.subject) {
296
+ return errorResponse("Missing required field: subject", 400);
297
+ }
298
+ if (typeof obj.body !== "string") {
299
+ return errorResponse("Missing required field: body", 400);
300
+ }
301
+
302
+ const typeRaw = typeof obj.type === "string" ? obj.type : "status";
303
+ const mailType: MailMessage["type"] = (MAIL_MESSAGE_TYPES as readonly string[]).includes(
304
+ typeRaw,
305
+ )
306
+ ? (typeRaw as MailMessage["type"])
307
+ : "status";
308
+
309
+ const priorityRaw = typeof obj.priority === "string" ? obj.priority : "normal";
310
+ const validPriorities: readonly string[] = ["low", "normal", "high", "urgent"];
311
+ const priority: MailMessage["priority"] = validPriorities.includes(priorityRaw)
312
+ ? (priorityRaw as MailMessage["priority"])
313
+ : "normal";
314
+
315
+ const threadId = typeof obj.threadId === "string" ? obj.threadId : null;
316
+
317
+ const isHumanSender = obj.from === "orchestrator" || obj.from === "coordinator";
318
+ const audienceDefault = isHumanSender ? "both" : "agent";
319
+ const audienceRaw = typeof obj.audience === "string" ? obj.audience : audienceDefault;
320
+ const validAudiences: readonly string[] = ["human", "agent", "both"];
321
+ const audience = (
322
+ validAudiences.includes(audienceRaw) ? audienceRaw : audienceDefault
323
+ ) as MailAudience;
324
+
325
+ const dbPath = join(legioDir, "mail.db");
326
+ const store = createMailStore(dbPath);
327
+ try {
328
+ const message = store.insert({
329
+ id: "",
330
+ from: obj.from,
331
+ to: obj.to,
332
+ subject: obj.subject,
333
+ body: obj.body,
334
+ type: mailType,
335
+ priority,
336
+ threadId,
337
+ audience,
338
+ });
339
+ wsManager?.broadcastEvent({ type: "mail_new", data: message });
340
+ return jsonResponse(message, 201);
341
+ } catch (err) {
342
+ return errorResponse(
343
+ `Failed to send message: ${err instanceof Error ? err.message : String(err)}`,
344
+ );
345
+ } finally {
346
+ store.close();
347
+ }
348
+ }
349
+
350
+ // -------------------------------------------------------------------------
351
+ // POST /api/terminal/send
352
+ // -------------------------------------------------------------------------
353
+
354
+ if (request.method === "POST" && path === "/api/terminal/send") {
355
+ let body: unknown;
356
+ try {
357
+ body = await request.json();
358
+ } catch {
359
+ return errorResponse("Invalid JSON body", 400);
360
+ }
361
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
362
+ return errorResponse("Request body must be a JSON object", 400);
363
+ }
364
+ const obj = body as Record<string, unknown>;
365
+
366
+ if (typeof obj.text !== "string" || obj.text.trim().length === 0) {
367
+ return errorResponse("Missing or empty required field: text", 400);
368
+ }
369
+
370
+ const agentName = typeof obj.agent === "string" && obj.agent ? obj.agent : "coordinator";
371
+
372
+ // If a headless coordinator is running for the coordinator agent, write to stdin
373
+ if (
374
+ (agentName === "coordinator" || agentName === "headless") &&
375
+ headless?.coordinator?.isRunning()
376
+ ) {
377
+ try {
378
+ headless.coordinator.write(`${obj.text}\n`);
379
+ } catch (err) {
380
+ return errorResponse(
381
+ `Failed to write to headless coordinator: ${err instanceof Error ? err.message : String(err)}`,
382
+ );
383
+ }
384
+ return jsonResponse({ ok: true });
385
+ }
386
+
387
+ const tmuxSession = await resolveTerminalSession(legioDir, projectRoot, agentName);
388
+ if (!tmuxSession) {
389
+ return errorResponse(`Cannot resolve tmux session for agent "${agentName}"`, 404);
390
+ }
391
+
392
+ if (!(await isSessionAlive(tmuxSession))) {
393
+ return errorResponse(`Tmux session "${tmuxSession}" is not alive`, 404);
394
+ }
395
+
396
+ try {
397
+ await sendKeys(tmuxSession, obj.text);
398
+ // Follow-up Enter after a short delay to ensure Claude Code's TUI submits.
399
+ // Same pattern as nudge.ts line 168-169.
400
+ await new Promise((resolve) => setTimeout(resolve, 500));
401
+ await sendKeys(tmuxSession, "");
402
+ } catch (err) {
403
+ return errorResponse(
404
+ `Failed to send keys: ${err instanceof Error ? err.message : String(err)}`,
405
+ );
406
+ }
407
+
408
+ return jsonResponse({ ok: true });
409
+ }
410
+
411
+ // -------------------------------------------------------------------------
412
+ // Audit — POST route (before the GET-only guard)
413
+ // -------------------------------------------------------------------------
414
+
415
+ if (request.method === "POST" && path === "/api/audit") {
416
+ let body: unknown;
417
+ try {
418
+ body = await request.json();
419
+ } catch {
420
+ return errorResponse("Invalid JSON body", 400);
421
+ }
422
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
423
+ return errorResponse("Request body must be a JSON object", 400);
424
+ }
425
+ const obj = body as Record<string, unknown>;
426
+
427
+ if (typeof obj.type !== "string" || !obj.type) {
428
+ return errorResponse("Missing required field: type", 400);
429
+ }
430
+ if (typeof obj.summary !== "string" || !obj.summary) {
431
+ return errorResponse("Missing required field: summary", 400);
432
+ }
433
+
434
+ const source = typeof obj.source === "string" ? obj.source : "web_ui";
435
+ const agent = typeof obj.agent === "string" ? obj.agent : null;
436
+ const detail = typeof obj.detail === "string" ? obj.detail : null;
437
+ const sessionId = typeof obj.sessionId === "string" ? obj.sessionId : null;
438
+
439
+ const auditDbPath = join(legioDir, "audit.db");
440
+ const store = createAuditStore(auditDbPath);
441
+ try {
442
+ const id = store.insert({
443
+ type: obj.type,
444
+ agent,
445
+ source,
446
+ summary: obj.summary,
447
+ detail,
448
+ sessionId,
449
+ });
450
+ // Fetch the inserted event back from the database to return the full record
451
+ const created = store.getAll().find((e) => e.id === id);
452
+ return jsonResponse(created ?? { id }, 201);
453
+ } catch (err) {
454
+ return errorResponse(
455
+ `Failed to record audit event: ${err instanceof Error ? err.message : String(err)}`,
456
+ );
457
+ } finally {
458
+ store.close();
459
+ }
460
+ }
461
+
462
+ // -------------------------------------------------------------------------
463
+ // Setup — POST route (before the GET-only guard)
464
+ // -------------------------------------------------------------------------
465
+
466
+ if (request.method === "POST" && path === "/api/setup/init") {
467
+ let force = false;
468
+ try {
469
+ const body = await request.json();
470
+ if (typeof body === "object" && body !== null && !Array.isArray(body)) {
471
+ const obj = body as Record<string, unknown>;
472
+ force = obj.force === true;
473
+ }
474
+ } catch {
475
+ // ignore — force defaults to false
476
+ }
477
+ const args = force ? ["init", "--force"] : ["init"];
478
+ const result = await runLegio(args, projectRoot);
479
+ if (result.ok) {
480
+ return jsonResponse({ success: true, message: "Project initialized successfully" });
481
+ }
482
+ return jsonResponse({ success: false, error: result.error });
483
+ }
484
+
485
+ // -------------------------------------------------------------------------
486
+ // Ideas — POST/PUT/DELETE routes (before the GET-only guard)
487
+ // -------------------------------------------------------------------------
488
+
489
+ if (request.method === "POST" && path === "/api/ideas") {
490
+ const parsed = await parseJsonBody(request);
491
+ if (parsed instanceof Response) return parsed;
492
+
493
+ const title = typeof parsed.title === "string" ? parsed.title.trim() : "";
494
+ if (!title) return errorResponse("Missing required field: title", 400);
495
+
496
+ const body = typeof parsed.body === "string" ? parsed.body : "";
497
+ const now = new Date().toISOString();
498
+ const idea: Idea = {
499
+ id: `idea-${randomUUID().slice(0, 8)}`,
500
+ title,
501
+ body,
502
+ status: "active",
503
+ createdAt: now,
504
+ updatedAt: now,
505
+ };
506
+
507
+ const ideasPath = join(legioDir, "ideas.json");
508
+ let data: IdeasFile = { ideas: [] };
509
+ if (await fileExists(ideasPath)) {
510
+ try {
511
+ data = JSON.parse(await readFile(ideasPath, "utf-8")) as IdeasFile;
512
+ } catch {
513
+ // start fresh on corrupt file
514
+ }
515
+ }
516
+ data.ideas.push(idea);
517
+ await writeFile(ideasPath, JSON.stringify(data, null, 2));
518
+ return jsonResponse(idea, 201);
519
+ }
520
+
521
+ {
522
+ const params = matchRoute(path, "/api/ideas/:id");
523
+ if (request.method === "PUT" && params) {
524
+ const { id } = params;
525
+ if (!id) return errorResponse("Missing idea ID", 400);
526
+
527
+ const ideasPath = join(legioDir, "ideas.json");
528
+ if (!(await fileExists(ideasPath))) {
529
+ return errorResponse(`Idea not found: ${id}`, 404);
530
+ }
531
+
532
+ const parsed = await parseJsonBody(request);
533
+ if (parsed instanceof Response) return parsed;
534
+
535
+ try {
536
+ const data = JSON.parse(await readFile(ideasPath, "utf-8")) as IdeasFile;
537
+ const idea = data.ideas.find((i) => i.id === id);
538
+ if (!idea) return errorResponse(`Idea not found: ${id}`, 404);
539
+
540
+ if (typeof parsed.title === "string") idea.title = parsed.title;
541
+ if (typeof parsed.body === "string") idea.body = parsed.body;
542
+ idea.updatedAt = new Date().toISOString();
543
+
544
+ await writeFile(ideasPath, JSON.stringify(data, null, 2));
545
+ return jsonResponse(idea);
546
+ } catch (err) {
547
+ return errorResponse(
548
+ `Failed to update idea: ${err instanceof Error ? err.message : String(err)}`,
549
+ );
550
+ }
551
+ }
552
+
553
+ if (request.method === "DELETE" && params) {
554
+ const { id } = params;
555
+ if (!id) return errorResponse("Missing idea ID", 400);
556
+
557
+ const ideasPath = join(legioDir, "ideas.json");
558
+ if (!(await fileExists(ideasPath))) {
559
+ return errorResponse(`Idea not found: ${id}`, 404);
560
+ }
561
+
562
+ try {
563
+ const data = JSON.parse(await readFile(ideasPath, "utf-8")) as IdeasFile;
564
+ const idx = data.ideas.findIndex((i) => i.id === id);
565
+ if (idx === -1) return errorResponse(`Idea not found: ${id}`, 404);
566
+
567
+ data.ideas.splice(idx, 1);
568
+ await writeFile(ideasPath, JSON.stringify(data, null, 2));
569
+ return jsonResponse({ success: true });
570
+ } catch (err) {
571
+ return errorResponse(
572
+ `Failed to delete idea: ${err instanceof Error ? err.message : String(err)}`,
573
+ );
574
+ }
575
+ }
576
+ }
577
+
578
+ {
579
+ const params = matchRoute(path, "/api/ideas/:id/dispatch");
580
+ if (request.method === "POST" && params) {
581
+ const { id } = params;
582
+ if (!id) return errorResponse("Missing idea ID", 400);
583
+
584
+ const ideasPath = join(legioDir, "ideas.json");
585
+ if (!(await fileExists(ideasPath))) {
586
+ return errorResponse(`Idea not found: ${id}`, 404);
587
+ }
588
+
589
+ try {
590
+ const data = JSON.parse(await readFile(ideasPath, "utf-8")) as IdeasFile;
591
+ const idea = data.ideas.find((i) => i.id === id);
592
+ if (!idea) return errorResponse(`Idea not found: ${id}`, 404);
593
+
594
+ const store = createMailStore(join(legioDir, "mail.db"));
595
+ const messageId = `idea-dispatch-${randomUUID().slice(0, 8)}`;
596
+ store.insert({
597
+ id: messageId,
598
+ from: "human",
599
+ to: "coordinator",
600
+ subject: idea.title,
601
+ body: idea.body ? `${idea.title}\n\n${idea.body}` : idea.title,
602
+ type: "dispatch",
603
+ priority: "normal",
604
+ threadId: null,
605
+ audience: "agent",
606
+ });
607
+ store.close();
608
+
609
+ idea.status = "dispatched";
610
+ idea.updatedAt = new Date().toISOString();
611
+ await writeFile(ideasPath, JSON.stringify(data, null, 2));
612
+
613
+ return jsonResponse({ idea, messageId });
614
+ } catch (err) {
615
+ return errorResponse(
616
+ `Failed to dispatch idea: ${err instanceof Error ? err.message : String(err)}`,
617
+ );
618
+ }
619
+ }
620
+ }
621
+
622
+ {
623
+ const params = matchRoute(path, "/api/ideas/:id/backlog");
624
+ if (request.method === "POST" && params) {
625
+ const { id } = params;
626
+ if (!id) return errorResponse("Missing idea ID", 400);
627
+
628
+ const ideasPath = join(legioDir, "ideas.json");
629
+ if (!(await fileExists(ideasPath))) {
630
+ return errorResponse(`Idea not found: ${id}`, 404);
631
+ }
632
+
633
+ try {
634
+ const data = JSON.parse(await readFile(ideasPath, "utf-8")) as IdeasFile;
635
+ const idea = data.ideas.find((i) => i.id === id);
636
+ if (!idea) return errorResponse(`Idea not found: ${id}`, 404);
637
+
638
+ const client = createBeadsClient(projectRoot);
639
+ const issueId = await client.create(idea.title, { description: idea.body });
640
+
641
+ idea.status = "backlog";
642
+ idea.updatedAt = new Date().toISOString();
643
+ await writeFile(ideasPath, JSON.stringify(data, null, 2));
644
+
645
+ return jsonResponse({ idea, issueId });
646
+ } catch (err) {
647
+ return errorResponse(
648
+ `Failed to add idea to backlog: ${err instanceof Error ? err.message : String(err)}`,
649
+ );
650
+ }
651
+ }
652
+ }
653
+
654
+ // -------------------------------------------------------------------------
655
+ // Coordinator — POST routes (before the GET-only guard)
656
+ // -------------------------------------------------------------------------
657
+
658
+ if (request.method === "POST" && path === "/api/coordinator/start") {
659
+ const parsed = await parseJsonBody(request);
660
+ if (parsed instanceof Response) return parsed;
661
+
662
+ if (parsed.headless === true) {
663
+ // Start headless coordinator in-process
664
+ if (headless?.coordinator?.isRunning()) {
665
+ return errorResponse("Headless coordinator is already running", 409);
666
+ }
667
+
668
+ // Build the command (same as CLI path but we can't read full config here,
669
+ // so we use a sensible default and let the caller configure via env)
670
+ const claudeCmd =
671
+ typeof parsed.command === "string" && parsed.command
672
+ ? parsed.command
673
+ : "claude --dangerously-skip-permissions";
674
+
675
+ const config: HeadlessCoordinatorConfig = {
676
+ command: claudeCmd,
677
+ cwd: projectRoot,
678
+ env:
679
+ typeof parsed.env === "object" && parsed.env !== null
680
+ ? (parsed.env as Record<string, string>)
681
+ : { LEGIO_AGENT_NAME: "coordinator" },
682
+ ringBufferSize: typeof parsed.ringBufferSize === "number" ? parsed.ringBufferSize : 500,
683
+ };
684
+
685
+ const coordinator = new HeadlessCoordinator(config);
686
+
687
+ // Wire output events to WebSocket broadcast
688
+ coordinator.on("output", (chunk: string) => {
689
+ wsManager?.broadcastEvent({ type: "coordinator_output", data: { text: chunk } });
690
+ });
691
+
692
+ coordinator.on("exit", (code: number) => {
693
+ wsManager?.broadcastEvent({ type: "coordinator_stop", data: { headless: true, code } });
694
+ headless?.setCoordinator(null);
695
+ });
696
+
697
+ coordinator.start();
698
+ headless?.setCoordinator(coordinator);
699
+
700
+ const responseData = { headless: true, pid: coordinator.getPid() };
701
+ wsManager?.broadcastEvent({ type: "coordinator_start", data: responseData });
702
+ return jsonResponse(responseData);
703
+ }
704
+
705
+ const args = ["coordinator", "start", "--no-attach", "--json"];
706
+ if (parsed.watchdog === true) args.push("--watchdog");
707
+ if (parsed.monitor === true) args.push("--monitor");
708
+ const result = await runLegio(args, projectRoot);
709
+ if (result.ok) {
710
+ wsManager?.broadcastEvent({ type: "coordinator_start", data: result.data });
711
+ return jsonResponse(result.data);
712
+ }
713
+ return errorResponse(result.error);
714
+ }
715
+
716
+ if (request.method === "POST" && path === "/api/coordinator/stop") {
717
+ // If headless coordinator is running, stop it
718
+ if (headless?.coordinator?.isRunning()) {
719
+ await headless.coordinator.stop();
720
+ headless.setCoordinator(null);
721
+ const responseData = { stopped: true, headless: true };
722
+ wsManager?.broadcastEvent({ type: "coordinator_stop", data: responseData });
723
+ return jsonResponse(responseData);
724
+ }
725
+
726
+ const parsed = await parseJsonBody(request);
727
+ if (parsed instanceof Response) return parsed;
728
+ const result = await runLegio(["coordinator", "stop", "--json"], projectRoot);
729
+ if (result.ok) {
730
+ wsManager?.broadcastEvent({ type: "coordinator_stop", data: result.data });
731
+ return jsonResponse(result.data);
732
+ }
733
+ return errorResponse(result.error);
734
+ }
735
+
736
+ // -------------------------------------------------------------------------
737
+ // Merge — POST route (before the GET-only guard)
738
+ // -------------------------------------------------------------------------
739
+
740
+ if (request.method === "POST" && path === "/api/merge") {
741
+ const parsed = await parseJsonBody(request);
742
+ if (parsed instanceof Response) return parsed;
743
+
744
+ const branch = typeof parsed.branch === "string" ? parsed.branch : null;
745
+ const all = parsed.all === true;
746
+
747
+ if (!branch && !all) {
748
+ return errorResponse("Must specify either branch or all", 400);
749
+ }
750
+
751
+ const args = ["merge", "--json"];
752
+ if (branch) args.push("--branch", branch);
753
+ if (all) args.push("--all");
754
+ if (typeof parsed.into === "string") args.push("--into", parsed.into);
755
+ if (parsed.dryRun === true) args.push("--dry-run");
756
+
757
+ const result = await runLegio(args, projectRoot);
758
+ if (result.ok) {
759
+ wsManager?.broadcastEvent({ type: "merge_complete", data: result.data });
760
+ return jsonResponse(result.data);
761
+ }
762
+ return errorResponse(result.error);
763
+ }
764
+
765
+ // -------------------------------------------------------------------------
766
+ // Nudge — POST route (before the GET-only guard)
767
+ // -------------------------------------------------------------------------
768
+
769
+ if (request.method === "POST" && path === "/api/nudge") {
770
+ const parsed = await parseJsonBody(request);
771
+ if (parsed instanceof Response) return parsed;
772
+
773
+ const agent = typeof parsed.agent === "string" ? parsed.agent : null;
774
+ if (!agent) return errorResponse("Missing required field: agent", 400);
775
+
776
+ const message = typeof parsed.message === "string" ? parsed.message : null;
777
+ const args = ["nudge", agent];
778
+ if (message) args.push(message);
779
+ args.push("--json");
780
+
781
+ const result = await runLegio(args, projectRoot);
782
+ if (result.ok) {
783
+ wsManager?.broadcastEvent({ type: "agent_nudge", data: { agent, message } });
784
+ return jsonResponse(result.data);
785
+ }
786
+ return errorResponse(result.error);
787
+ }
788
+
789
+ // -------------------------------------------------------------------------
790
+ // Gateway — POST routes (before the GET-only guard)
791
+ // -------------------------------------------------------------------------
792
+
793
+ if (request.method === "POST" && path === "/api/gateway/start") {
794
+ const parsed = await parseJsonBody(request);
795
+ if (parsed instanceof Response) return parsed;
796
+ const result = await runLegio(["gateway", "start", "--no-attach", "--json"], projectRoot);
797
+ if (result.ok) {
798
+ wsManager?.broadcastEvent({ type: "gateway_start", data: result.data });
799
+ return jsonResponse(result.data);
800
+ }
801
+ return errorResponse(result.error);
802
+ }
803
+
804
+ if (request.method === "POST" && path === "/api/gateway/stop") {
805
+ const parsed = await parseJsonBody(request);
806
+ if (parsed instanceof Response) return parsed;
807
+ const result = await runLegio(["gateway", "stop", "--json"], projectRoot);
808
+ if (result.ok) {
809
+ wsManager?.broadcastEvent({ type: "gateway_stop", data: result.data });
810
+ return jsonResponse(result.data);
811
+ }
812
+ return errorResponse(result.error);
813
+ }
814
+
815
+ if (request.method === "POST" && path === "/api/gateway/chat") {
816
+ const parsed = await parseJsonBody(request);
817
+ if (parsed instanceof Response) return parsed;
818
+
819
+ const text = typeof parsed.text === "string" ? parsed.text.trim() : null;
820
+ if (!text) return errorResponse("Missing or empty required field: text", 400);
821
+
822
+ const tmuxSession = await resolveTerminalSession(legioDir, projectRoot, "gateway");
823
+ if (!tmuxSession) {
824
+ return errorResponse("Gateway is not running", 404);
825
+ }
826
+
827
+ if (!(await isSessionAlive(tmuxSession))) {
828
+ return errorResponse(`Gateway tmux session "${tmuxSession}" is not alive`, 404);
829
+ }
830
+
831
+ const mailStore = createMailStore(join(legioDir, "mail.db"));
832
+ const savedMessage = mailStore.insert({
833
+ id: "",
834
+ from: "human",
835
+ to: "gateway",
836
+ subject: "chat",
837
+ body: text,
838
+ type: "status",
839
+ priority: "normal",
840
+ threadId: null,
841
+ audience: "human",
842
+ });
843
+ mailStore.close();
844
+ wsManager?.broadcastEvent({ type: "mail_new", data: savedMessage });
845
+
846
+ try {
847
+ await sendKeys(tmuxSession, text);
848
+ } catch (err) {
849
+ return errorResponse(
850
+ `Failed to send keys: ${err instanceof Error ? err.message : String(err)}`,
851
+ );
852
+ }
853
+
854
+ return jsonResponse(savedMessage, 201);
855
+ }
856
+
857
+ // -------------------------------------------------------------------------
858
+ // Coordinator Chat — POST /api/coordinator/chat (before GET-only guard)
859
+ // -------------------------------------------------------------------------
860
+
861
+ if (request.method === "POST" && path === "/api/coordinator/chat") {
862
+ const parsed = await parseJsonBody(request);
863
+ if (parsed instanceof Response) return parsed;
864
+
865
+ const text = typeof parsed.text === "string" ? parsed.text.trim() : null;
866
+ if (!text) return errorResponse("Missing or empty required field: text", 400);
867
+
868
+ const mailStore = createMailStore(join(legioDir, "mail.db"));
869
+ const savedMessage = mailStore.insert({
870
+ id: "",
871
+ from: "human",
872
+ to: "coordinator",
873
+ subject: "chat",
874
+ body: text,
875
+ type: "status",
876
+ priority: "normal",
877
+ threadId: null,
878
+ audience: "human",
879
+ });
880
+ mailStore.close();
881
+
882
+ // Forward to terminal: headless coordinator first, then tmux fallback
883
+ const agentName = "coordinator";
884
+ if (
885
+ (agentName === "coordinator" || agentName === "headless") &&
886
+ headless?.coordinator?.isRunning()
887
+ ) {
888
+ try {
889
+ headless.coordinator.write(`${text}\n`);
890
+ } catch (err) {
891
+ return errorResponse(
892
+ `Failed to write to headless coordinator: ${err instanceof Error ? err.message : String(err)}`,
893
+ );
894
+ }
895
+ return jsonResponse(savedMessage, 201);
896
+ }
897
+
898
+ const tmuxSession = await resolveTerminalSession(legioDir, projectRoot, agentName);
899
+ if (!tmuxSession) {
900
+ return errorResponse(`Cannot resolve tmux session for agent "${agentName}"`, 404);
901
+ }
902
+
903
+ if (!(await isSessionAlive(tmuxSession))) {
904
+ return errorResponse(`Tmux session "${tmuxSession}" is not alive`, 404);
905
+ }
906
+
907
+ try {
908
+ await sendKeys(tmuxSession, text);
909
+ await new Promise((resolve) => setTimeout(resolve, 500));
910
+ await sendKeys(tmuxSession, "");
911
+ } catch (err) {
912
+ return errorResponse(
913
+ `Failed to send keys: ${err instanceof Error ? err.message : String(err)}`,
914
+ );
915
+ }
916
+
917
+ return jsonResponse(savedMessage, 201);
918
+ }
919
+
920
+ // -------------------------------------------------------------------------
921
+ // Agent Chat — POST /api/agents/:name/chat (before GET-only guard)
922
+ // -------------------------------------------------------------------------
923
+
924
+ {
925
+ const params = matchRoute(path, "/api/agents/:name/chat");
926
+ if (request.method === "POST" && params) {
927
+ const agentName = params.name;
928
+ if (!agentName) return errorResponse("Missing agent name", 400);
929
+
930
+ const parsed = await parseJsonBody(request);
931
+ if (parsed instanceof Response) return parsed;
932
+
933
+ const text = typeof parsed.text === "string" ? parsed.text.trim() : null;
934
+ if (!text) return errorResponse("Missing or empty required field: text", 400);
935
+
936
+ const mailStore = createMailStore(join(legioDir, "mail.db"));
937
+ const savedMessage = mailStore.insert({
938
+ id: "",
939
+ from: "human",
940
+ to: agentName,
941
+ subject: "chat",
942
+ body: text,
943
+ type: "status",
944
+ priority: "normal",
945
+ threadId: null,
946
+ audience: "human",
947
+ });
948
+ mailStore.close();
949
+
950
+ // Forward to terminal: headless coordinator first (when agent is coordinator), then tmux
951
+ if (
952
+ (agentName === "coordinator" || agentName === "headless") &&
953
+ headless?.coordinator?.isRunning()
954
+ ) {
955
+ try {
956
+ headless.coordinator.write(`${text}\n`);
957
+ } catch (err) {
958
+ return errorResponse(
959
+ `Failed to write to headless coordinator: ${err instanceof Error ? err.message : String(err)}`,
960
+ );
961
+ }
962
+ return jsonResponse(savedMessage, 201);
963
+ }
964
+
965
+ const tmuxSession = await resolveTerminalSession(legioDir, projectRoot, agentName);
966
+ if (!tmuxSession) {
967
+ return errorResponse(`Cannot resolve tmux session for agent "${agentName}"`, 404);
968
+ }
969
+
970
+ if (!(await isSessionAlive(tmuxSession))) {
971
+ return errorResponse(`Tmux session "${tmuxSession}" is not alive`, 404);
972
+ }
973
+
974
+ try {
975
+ await sendKeys(tmuxSession, text);
976
+ await new Promise((resolve) => setTimeout(resolve, 500));
977
+ await sendKeys(tmuxSession, "");
978
+ } catch (err) {
979
+ return errorResponse(
980
+ `Failed to send keys: ${err instanceof Error ? err.message : String(err)}`,
981
+ );
982
+ }
983
+
984
+ return jsonResponse(savedMessage, 201);
985
+ }
986
+ }
987
+
988
+ // -------------------------------------------------------------------------
989
+ // Transcript Sync — POST /api/chat/transcript-sync
990
+ // Triggers on-demand transcript sync for a persistent agent.
991
+ // -------------------------------------------------------------------------
992
+
993
+ if (request.method === "POST" && path === "/api/chat/transcript-sync") {
994
+ const parsed = await parseJsonBody(request);
995
+ if (parsed instanceof Response) return parsed;
996
+
997
+ const agentName = typeof parsed.agent === "string" ? parsed.agent.trim() : null;
998
+ if (!agentName) return errorResponse("Missing or empty required field: agent", 400);
999
+
1000
+ if (!(await fileExists(join(legioDir, "sessions.db")))) {
1001
+ return errorResponse("No sessions database found", 404);
1002
+ }
1003
+
1004
+ const { store: sessionStore } = openSessionStore(legioDir);
1005
+ try {
1006
+ const sessions = sessionStore.getActive();
1007
+ const agentSession = sessions.find((s) => s.agentName === agentName);
1008
+ if (!agentSession) {
1009
+ return errorResponse(`No active session found for agent "${agentName}"`, 404);
1010
+ }
1011
+
1012
+ const logsBase = join(legioDir, "logs");
1013
+ const cachePath = join(logsBase, agentName, ".transcript-path");
1014
+ let transcriptPath: string | null = null;
1015
+ try {
1016
+ const cached = (await readFile(cachePath, "utf-8")).trim();
1017
+ if (cached.length > 0) {
1018
+ await access(cached, constants.R_OK);
1019
+ transcriptPath = cached;
1020
+ }
1021
+ } catch {
1022
+ // No cached transcript path
1023
+ }
1024
+
1025
+ if (!transcriptPath) {
1026
+ return errorResponse(`No transcript found for agent "${agentName}"`, 404);
1027
+ }
1028
+
1029
+ const { parseTranscriptTexts } = await import("../metrics/transcript.ts");
1030
+ const offsetPath = join(logsBase, agentName, ".chat-transcript-offset");
1031
+ const mailStore = createMailStore(join(legioDir, "mail.db"));
1032
+ try {
1033
+ let fromLine = 0;
1034
+ try {
1035
+ const savedOffset = await readFile(offsetPath, "utf-8");
1036
+ const parsedOffset = Number.parseInt(savedOffset.trim(), 10);
1037
+ if (!Number.isNaN(parsedOffset) && parsedOffset >= 0) {
1038
+ fromLine = parsedOffset;
1039
+ }
1040
+ } catch {
1041
+ // No offset file yet — start from beginning.
1042
+ // If messages already exist for this agent, we're in a restart scenario.
1043
+ // Skip to the end to avoid replaying old messages.
1044
+ const existingMessages = mailStore.getAll({ from: agentName, to: "human" });
1045
+ if (existingMessages.length > 0) {
1046
+ const { nextLine: endLine } = await parseTranscriptTexts(transcriptPath, 0);
1047
+ await writeFile(offsetPath, String(endLine));
1048
+ return jsonResponse({ synced: 0, skipped: true, reason: "offset_recovered" });
1049
+ }
1050
+ }
1051
+
1052
+ const { messages, nextLine } = await parseTranscriptTexts(transcriptPath, fromLine);
1053
+ const assistantMessages = messages.filter((m) => m.role === "assistant");
1054
+
1055
+ let synced = 0;
1056
+ if (assistantMessages.length > 0) {
1057
+ // Dedup: build a set of recent message bodies to skip replays.
1058
+ const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
1059
+ const recentBodies = new Set(
1060
+ mailStore
1061
+ .getAll({ from: agentName, to: "human" })
1062
+ .filter((m) => m.createdAt > oneDayAgo)
1063
+ .map((m) => m.body),
1064
+ );
1065
+
1066
+ for (const msg of assistantMessages) {
1067
+ if (recentBodies.has(msg.text)) continue;
1068
+ const savedMsg = mailStore.insert({
1069
+ id: "",
1070
+ from: agentName,
1071
+ to: "human",
1072
+ subject: "chat",
1073
+ body: msg.text,
1074
+ type: "status",
1075
+ priority: "normal",
1076
+ threadId: null,
1077
+ audience: "human",
1078
+ });
1079
+ wsManager?.broadcastEvent({ type: "mail_new", data: savedMsg });
1080
+ synced++;
1081
+ }
1082
+ }
1083
+
1084
+ await writeFile(offsetPath, String(nextLine));
1085
+
1086
+ return jsonResponse({
1087
+ synced,
1088
+ nextLine,
1089
+ agent: agentName,
1090
+ });
1091
+ } finally {
1092
+ mailStore.close();
1093
+ }
1094
+ } finally {
1095
+ sessionStore.close();
1096
+ }
1097
+ }
1098
+
1099
+ // -------------------------------------------------------------------------
1100
+ // Issues — POST routes (before the GET-only guard)
1101
+ // -------------------------------------------------------------------------
1102
+
1103
+ {
1104
+ const params = matchRoute(path, "/api/issues/:id/dispatch");
1105
+ if (request.method === "POST" && params) {
1106
+ const { id } = params;
1107
+ if (!id) return errorResponse("Missing issue ID", 400);
1108
+ try {
1109
+ const client = createBeadsClient(projectRoot);
1110
+ const issue = await client.show(id);
1111
+ const body = issue.description ? `${issue.title}\n\n${issue.description}` : issue.title;
1112
+ const store = createMailStore(join(legioDir, "mail.db"));
1113
+ const messageId = `issue-dispatch-${randomUUID().slice(0, 8)}`;
1114
+ store.insert({
1115
+ id: messageId,
1116
+ from: "human",
1117
+ to: "coordinator",
1118
+ subject: `dispatch: ${issue.title}`,
1119
+ body,
1120
+ type: "dispatch",
1121
+ priority: "normal",
1122
+ threadId: null,
1123
+ audience: "agent",
1124
+ });
1125
+ store.close();
1126
+ return jsonResponse({ success: true, message: "Dispatched" });
1127
+ } catch (err) {
1128
+ if (err instanceof Error && err.message.toLowerCase().includes("not found")) {
1129
+ return errorResponse(`Issue not found: ${id}`, 404);
1130
+ }
1131
+ return errorResponse(
1132
+ `Failed to dispatch issue: ${err instanceof Error ? err.message : String(err)}`,
1133
+ );
1134
+ }
1135
+ }
1136
+ }
1137
+
1138
+ {
1139
+ const params = matchRoute(path, "/api/issues/:id/close");
1140
+ if (request.method === "POST" && params) {
1141
+ const { id } = params;
1142
+ if (!id) return errorResponse("Missing issue ID", 400);
1143
+ try {
1144
+ const body = await request.json().catch(() => ({})) as { reason?: string };
1145
+ const reason = typeof body.reason === "string" ? body.reason : "Closed from dashboard";
1146
+ const client = createBeadsClient(projectRoot);
1147
+ await client.close(id, reason);
1148
+ return jsonResponse({ success: true, id });
1149
+ } catch (err) {
1150
+ if (err instanceof Error && err.message.toLowerCase().includes("not found")) {
1151
+ return errorResponse(`Issue not found: ${id}`, 404);
1152
+ }
1153
+ return errorResponse(
1154
+ `Failed to close issue: ${err instanceof Error ? err.message : String(err)}`,
1155
+ );
1156
+ }
1157
+ }
1158
+ }
1159
+
1160
+ // Only handle GET requests for all other routes
1161
+ if (request.method !== "GET") {
1162
+ return errorResponse("Method not allowed", 405);
1163
+ }
1164
+
1165
+ // -------------------------------------------------------------------------
1166
+ // Core
1167
+ // -------------------------------------------------------------------------
1168
+
1169
+ if (path === "/api/health") {
1170
+ return jsonResponse({ ok: true, timestamp: new Date().toISOString() });
1171
+ }
1172
+
1173
+ if (path === "/api/setup/status") {
1174
+ const configPath = join(legioDir, "config.yaml");
1175
+ const initialized = await fileExists(configPath);
1176
+ let projectName: string | null = null;
1177
+ if (initialized) {
1178
+ try {
1179
+ const config = await loadConfig(projectRoot);
1180
+ projectName = config.project.name;
1181
+ } catch {
1182
+ // ignore — return initialized: true with null name
1183
+ }
1184
+ }
1185
+ return jsonResponse({ initialized, projectName, projectRoot });
1186
+ }
1187
+
1188
+ if (path === "/api/status") {
1189
+ try {
1190
+ const data = await gatherStatus(projectRoot, "orchestrator", true);
1191
+ return jsonResponse(data);
1192
+ } catch (err) {
1193
+ return errorResponse(
1194
+ `Failed to gather status: ${err instanceof Error ? err.message : String(err)}`,
1195
+ );
1196
+ }
1197
+ }
1198
+
1199
+ if (path === "/api/coordinator/status") {
1200
+ // Check headless coordinator first
1201
+ const headlessRunning = headless?.coordinator?.isRunning() ?? false;
1202
+ if (headlessRunning) {
1203
+ return jsonResponse({
1204
+ running: true,
1205
+ headless: true,
1206
+ tmuxSession: undefined,
1207
+ });
1208
+ }
1209
+
1210
+ const tmuxSession = await resolveTerminalSession(legioDir, projectRoot, "coordinator");
1211
+ return jsonResponse({
1212
+ running: tmuxSession !== null,
1213
+ headless: false,
1214
+ tmuxSession: tmuxSession ?? undefined,
1215
+ });
1216
+ }
1217
+
1218
+ if (path === "/api/gateway/status") {
1219
+ const tmuxSession = await resolveTerminalSession(legioDir, projectRoot, "gateway");
1220
+ return jsonResponse({
1221
+ running: tmuxSession !== null,
1222
+ tmuxSession: tmuxSession ?? undefined,
1223
+ });
1224
+ }
1225
+
1226
+ if (path === "/api/config") {
1227
+ try {
1228
+ const config = await loadConfig(projectRoot);
1229
+ return jsonResponse(config);
1230
+ } catch (err) {
1231
+ return errorResponse(
1232
+ `Failed to load config: ${err instanceof Error ? err.message : String(err)}`,
1233
+ );
1234
+ }
1235
+ }
1236
+
1237
+ // -------------------------------------------------------------------------
1238
+ // Agents — specific routes before parameterized
1239
+ // -------------------------------------------------------------------------
1240
+
1241
+ if (path === "/api/agents") {
1242
+ const dbPath = join(legioDir, "sessions.db");
1243
+ if (!(await fileExists(dbPath))) {
1244
+ return jsonResponse([]);
1245
+ }
1246
+ const { store } = openSessionStore(legioDir);
1247
+ try {
1248
+ return jsonResponse(store.getAll());
1249
+ } finally {
1250
+ store.close();
1251
+ }
1252
+ }
1253
+
1254
+ if (path === "/api/agents/active") {
1255
+ const dbPath = join(legioDir, "sessions.db");
1256
+ if (!(await fileExists(dbPath))) {
1257
+ return jsonResponse([]);
1258
+ }
1259
+ const { store } = openSessionStore(legioDir);
1260
+ try {
1261
+ return jsonResponse(store.getActive());
1262
+ } finally {
1263
+ store.close();
1264
+ }
1265
+ }
1266
+
1267
+ // /api/agents/:name/chat/history
1268
+ {
1269
+ const params = matchRoute(path, "/api/agents/:name/chat/history");
1270
+ if (params) {
1271
+ const agentName = params.name;
1272
+ if (!agentName) return errorResponse("Missing agent name", 400);
1273
+ const dbPath = join(legioDir, "mail.db");
1274
+ if (!(await fileExists(dbPath))) {
1275
+ return jsonResponse([]);
1276
+ }
1277
+ const limitParam = url.searchParams.get("limit");
1278
+ const limit = limitParam !== null ? Math.max(1, parseInt(limitParam, 10) || 100) : 100;
1279
+ const store = createMailStore(dbPath);
1280
+ try {
1281
+ const humanToAgent = store.getAll({ from: "human", to: agentName });
1282
+ const agentToHuman = store.getAll({ from: agentName, to: "human" });
1283
+ const combined = [...humanToAgent, ...agentToHuman];
1284
+ const seen = new Set<string>();
1285
+ const relevant = combined.filter((m) => {
1286
+ if (seen.has(m.id)) return false;
1287
+ seen.add(m.id);
1288
+ return m.audience === "human" || m.audience === "both";
1289
+ });
1290
+ relevant.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1291
+ return jsonResponse(relevant.slice(-limit));
1292
+ } finally {
1293
+ store.close();
1294
+ }
1295
+ }
1296
+ }
1297
+
1298
+ // /api/agents/:name/inspect
1299
+ {
1300
+ const params = matchRoute(path, "/api/agents/:name/inspect");
1301
+ if (params) {
1302
+ const { name } = params;
1303
+ if (!name) return errorResponse("Missing agent name", 400);
1304
+ try {
1305
+ const data = await gatherInspectData(projectRoot, name, { noTmux: true });
1306
+ return jsonResponse(data);
1307
+ } catch {
1308
+ return errorResponse(`Agent not found: ${name}`, 404);
1309
+ }
1310
+ }
1311
+ }
1312
+
1313
+ // /api/agents/:name/events
1314
+ {
1315
+ const params = matchRoute(path, "/api/agents/:name/events");
1316
+ if (params) {
1317
+ const { name } = params;
1318
+ if (!name) return errorResponse("Missing agent name", 400);
1319
+ const dbPath = join(legioDir, "events.db");
1320
+ if (!(await fileExists(dbPath))) {
1321
+ return jsonResponse([]);
1322
+ }
1323
+ const since = url.searchParams.get("since") ?? undefined;
1324
+ const until = url.searchParams.get("until") ?? undefined;
1325
+ const limitStr = url.searchParams.get("limit");
1326
+ const limit = limitStr ? Number.parseInt(limitStr, 10) : undefined;
1327
+ const levelParam = url.searchParams.get("level");
1328
+ const level = levelParam ? (levelParam as EventLevel) : undefined;
1329
+ const store = createEventStore(dbPath);
1330
+ try {
1331
+ return jsonResponse(store.getByAgent(name, { since, until, limit, level }));
1332
+ } finally {
1333
+ store.close();
1334
+ }
1335
+ }
1336
+ }
1337
+
1338
+ // /api/agents/:name
1339
+ {
1340
+ const params = matchRoute(path, "/api/agents/:name");
1341
+ if (params) {
1342
+ const { name } = params;
1343
+ if (!name) return errorResponse("Missing agent name", 400);
1344
+ const dbPath = join(legioDir, "sessions.db");
1345
+ if (!(await fileExists(dbPath))) {
1346
+ return errorResponse(`Agent not found: ${name}`, 404);
1347
+ }
1348
+ const { store } = openSessionStore(legioDir);
1349
+ try {
1350
+ const session = store.getByName(name);
1351
+ if (!session) return errorResponse(`Agent not found: ${name}`, 404);
1352
+ return jsonResponse(session);
1353
+ } finally {
1354
+ store.close();
1355
+ }
1356
+ }
1357
+ }
1358
+
1359
+ // -------------------------------------------------------------------------
1360
+ // Mail — specific routes before parameterized
1361
+ // -------------------------------------------------------------------------
1362
+
1363
+ if (path === "/api/mail") {
1364
+ const dbPath = join(legioDir, "mail.db");
1365
+ if (!(await fileExists(dbPath))) {
1366
+ return jsonResponse([]);
1367
+ }
1368
+ const from = url.searchParams.get("from") ?? undefined;
1369
+ const to = url.searchParams.get("to") ?? undefined;
1370
+ const unreadStr = url.searchParams.get("unread");
1371
+ const unread = unreadStr !== null ? unreadStr === "true" : undefined;
1372
+ const audience = url.searchParams.get("audience") ?? undefined;
1373
+ const store = createMailStore(dbPath);
1374
+ try {
1375
+ return jsonResponse(store.getAll({ from, to, unread, audience }));
1376
+ } finally {
1377
+ store.close();
1378
+ }
1379
+ }
1380
+
1381
+ if (path === "/api/mail/unread") {
1382
+ const agent = url.searchParams.get("agent");
1383
+ if (!agent) return errorResponse("Missing required query param: agent", 400);
1384
+ const dbPath = join(legioDir, "mail.db");
1385
+ if (!(await fileExists(dbPath))) {
1386
+ return jsonResponse([]);
1387
+ }
1388
+ const store = createMailStore(dbPath);
1389
+ try {
1390
+ return jsonResponse(store.getUnread(agent));
1391
+ } finally {
1392
+ store.close();
1393
+ }
1394
+ }
1395
+
1396
+ if (path === "/api/mail/conversations") {
1397
+ const dbPath = join(legioDir, "mail.db");
1398
+ if (!(await fileExists(dbPath))) {
1399
+ return jsonResponse([]);
1400
+ }
1401
+ const agentFilter = url.searchParams.get("agent") ?? undefined;
1402
+ const audience = url.searchParams.get("audience") ?? undefined;
1403
+ const store = createMailStore(dbPath);
1404
+ try {
1405
+ const allMessages = store.getAll();
1406
+ const messages =
1407
+ audience !== undefined ? allMessages.filter((m) => m.audience === audience) : allMessages;
1408
+
1409
+ // Group messages by normalized agent pair (sorted alphabetically)
1410
+ const groups = new Map<string, { participants: [string, string]; messages: MailMessage[] }>();
1411
+ for (const msg of messages) {
1412
+ const sorted = [msg.from, msg.to].sort();
1413
+ const a = sorted[0];
1414
+ const b = sorted[1];
1415
+ if (!a || !b) continue;
1416
+ const pair: [string, string] = [a, b];
1417
+ const key = `${a}:${b}`;
1418
+ let group = groups.get(key);
1419
+ if (!group) {
1420
+ group = { participants: pair, messages: [] };
1421
+ groups.set(key, group);
1422
+ }
1423
+ group.messages.push(msg);
1424
+ }
1425
+
1426
+ // Build conversation objects
1427
+ const conversations: Array<{
1428
+ participants: [string, string];
1429
+ lastMessage: MailMessage;
1430
+ messageCount: number;
1431
+ unreadCount: number;
1432
+ }> = [];
1433
+ for (const { participants, messages: msgs } of groups.values()) {
1434
+ if (agentFilter && !participants.includes(agentFilter)) {
1435
+ continue;
1436
+ }
1437
+ const sorted = [...msgs].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
1438
+ const lastMessage = sorted[0];
1439
+ if (!lastMessage) continue;
1440
+ conversations.push({
1441
+ participants,
1442
+ lastMessage,
1443
+ messageCount: msgs.length,
1444
+ unreadCount: msgs.filter((m) => !m.read).length,
1445
+ });
1446
+ }
1447
+
1448
+ // Sort conversations by most recent message first
1449
+ conversations.sort((a, b) => b.lastMessage.createdAt.localeCompare(a.lastMessage.createdAt));
1450
+ return jsonResponse(conversations);
1451
+ } finally {
1452
+ store.close();
1453
+ }
1454
+ }
1455
+
1456
+ // /api/mail/thread/:threadId — before /api/mail/:id
1457
+ {
1458
+ const params = matchRoute(path, "/api/mail/thread/:threadId");
1459
+ if (params) {
1460
+ const { threadId } = params;
1461
+ if (!threadId) return errorResponse("Missing thread ID", 400);
1462
+ const dbPath = join(legioDir, "mail.db");
1463
+ if (!(await fileExists(dbPath))) {
1464
+ return jsonResponse([]);
1465
+ }
1466
+ const store = createMailStore(dbPath);
1467
+ try {
1468
+ return jsonResponse(store.getByThread(threadId));
1469
+ } finally {
1470
+ store.close();
1471
+ }
1472
+ }
1473
+ }
1474
+
1475
+ // /api/mail/:id
1476
+ {
1477
+ const params = matchRoute(path, "/api/mail/:id");
1478
+ if (params) {
1479
+ const { id } = params;
1480
+ if (!id) return errorResponse("Missing message ID", 400);
1481
+ const dbPath = join(legioDir, "mail.db");
1482
+ if (!(await fileExists(dbPath))) {
1483
+ return errorResponse(`Message not found: ${id}`, 404);
1484
+ }
1485
+ const store = createMailStore(dbPath);
1486
+ try {
1487
+ const message = store.getById(id);
1488
+ if (!message) return errorResponse(`Message not found: ${id}`, 404);
1489
+ return jsonResponse(message);
1490
+ } finally {
1491
+ store.close();
1492
+ }
1493
+ }
1494
+ }
1495
+
1496
+ // -------------------------------------------------------------------------
1497
+ // Events — specific routes before parameterized
1498
+ // -------------------------------------------------------------------------
1499
+
1500
+ if (path === "/api/events") {
1501
+ const since = url.searchParams.get("since");
1502
+ if (!since) return errorResponse("Missing required query param: since", 400);
1503
+ const dbPath = join(legioDir, "events.db");
1504
+ if (!(await fileExists(dbPath))) {
1505
+ return jsonResponse([]);
1506
+ }
1507
+ const until = url.searchParams.get("until") ?? undefined;
1508
+ const limitStr = url.searchParams.get("limit");
1509
+ const limit = limitStr ? Number.parseInt(limitStr, 10) : undefined;
1510
+ const levelParam = url.searchParams.get("level");
1511
+ const level = levelParam ? (levelParam as EventLevel) : undefined;
1512
+ const store = createEventStore(dbPath);
1513
+ try {
1514
+ return jsonResponse(store.getTimeline({ since, until, limit, level }));
1515
+ } finally {
1516
+ store.close();
1517
+ }
1518
+ }
1519
+
1520
+ if (path === "/api/events/errors") {
1521
+ const dbPath = join(legioDir, "events.db");
1522
+ if (!(await fileExists(dbPath))) {
1523
+ return jsonResponse([]);
1524
+ }
1525
+ const since = url.searchParams.get("since") ?? undefined;
1526
+ const until = url.searchParams.get("until") ?? undefined;
1527
+ const limitStr = url.searchParams.get("limit");
1528
+ const limit = limitStr ? Number.parseInt(limitStr, 10) : undefined;
1529
+ const store = createEventStore(dbPath);
1530
+ try {
1531
+ return jsonResponse(store.getErrors({ since, until, limit }));
1532
+ } finally {
1533
+ store.close();
1534
+ }
1535
+ }
1536
+
1537
+ if (path === "/api/events/tools") {
1538
+ const dbPath = join(legioDir, "events.db");
1539
+ if (!(await fileExists(dbPath))) {
1540
+ return jsonResponse([]);
1541
+ }
1542
+ const agentName = url.searchParams.get("agent") ?? undefined;
1543
+ const since = url.searchParams.get("since") ?? undefined;
1544
+ const store = createEventStore(dbPath);
1545
+ try {
1546
+ return jsonResponse(store.getToolStats({ agentName, since }));
1547
+ } finally {
1548
+ store.close();
1549
+ }
1550
+ }
1551
+
1552
+ // -------------------------------------------------------------------------
1553
+ // Metrics — specific routes before parameterized
1554
+ // -------------------------------------------------------------------------
1555
+
1556
+ if (path === "/api/metrics") {
1557
+ const dbPath = join(legioDir, "metrics.db");
1558
+ if (!(await fileExists(dbPath))) {
1559
+ return jsonResponse([]);
1560
+ }
1561
+ const limitStr = url.searchParams.get("limit");
1562
+ const limit = limitStr ? Number.parseInt(limitStr, 10) : 100;
1563
+ const since = url.searchParams.get("since") ?? undefined;
1564
+ const until = url.searchParams.get("until") ?? undefined;
1565
+ const store = createMetricsStore(dbPath);
1566
+ try {
1567
+ if (since !== undefined || until !== undefined) {
1568
+ return jsonResponse(store.getSessionsFiltered({ since, until, limit }));
1569
+ }
1570
+ return jsonResponse(store.getRecentSessions(limit));
1571
+ } finally {
1572
+ store.close();
1573
+ }
1574
+ }
1575
+
1576
+ if (path === "/api/metrics/by-model") {
1577
+ const dbPath = join(legioDir, "metrics.db");
1578
+ if (!(await fileExists(dbPath))) {
1579
+ return jsonResponse([]);
1580
+ }
1581
+ const since = url.searchParams.get("since") ?? undefined;
1582
+ const until = url.searchParams.get("until") ?? undefined;
1583
+ const store = createMetricsStore(dbPath);
1584
+ try {
1585
+ return jsonResponse(store.getSessionsByModel({ since, until }));
1586
+ } finally {
1587
+ store.close();
1588
+ }
1589
+ }
1590
+
1591
+ if (path === "/api/metrics/by-date") {
1592
+ const dbPath = join(legioDir, "metrics.db");
1593
+ if (!(await fileExists(dbPath))) {
1594
+ return jsonResponse([]);
1595
+ }
1596
+ const since = url.searchParams.get("since") ?? undefined;
1597
+ const until = url.searchParams.get("until") ?? undefined;
1598
+ const store = createMetricsStore(dbPath);
1599
+ try {
1600
+ return jsonResponse(store.getSessionsByDate({ since, until }));
1601
+ } finally {
1602
+ store.close();
1603
+ }
1604
+ }
1605
+
1606
+ if (path === "/api/metrics/snapshots") {
1607
+ const dbPath = join(legioDir, "metrics.db");
1608
+ if (!(await fileExists(dbPath))) {
1609
+ return jsonResponse([]);
1610
+ }
1611
+ const store = createMetricsStore(dbPath);
1612
+ try {
1613
+ return jsonResponse(store.getLatestSnapshots());
1614
+ } finally {
1615
+ store.close();
1616
+ }
1617
+ }
1618
+
1619
+ // -------------------------------------------------------------------------
1620
+ // Runs — specific routes before parameterized
1621
+ // -------------------------------------------------------------------------
1622
+
1623
+ if (path === "/api/runs") {
1624
+ const dbPath = join(legioDir, "sessions.db");
1625
+ if (!(await fileExists(dbPath))) {
1626
+ return jsonResponse([]);
1627
+ }
1628
+ const limitStr = url.searchParams.get("limit");
1629
+ const limit = limitStr ? Number.parseInt(limitStr, 10) : undefined;
1630
+ const statusParam = url.searchParams.get("status");
1631
+ const status = statusParam as RunStatus | undefined;
1632
+ const store = createRunStore(dbPath);
1633
+ try {
1634
+ return jsonResponse(store.listRuns({ limit, status: status ?? undefined }));
1635
+ } finally {
1636
+ store.close();
1637
+ }
1638
+ }
1639
+
1640
+ if (path === "/api/runs/active") {
1641
+ const dbPath = join(legioDir, "sessions.db");
1642
+ if (!(await fileExists(dbPath))) {
1643
+ return jsonResponse(null);
1644
+ }
1645
+ const store = createRunStore(dbPath);
1646
+ try {
1647
+ return jsonResponse(store.getActiveRun());
1648
+ } finally {
1649
+ store.close();
1650
+ }
1651
+ }
1652
+
1653
+ // /api/runs/:id
1654
+ {
1655
+ const params = matchRoute(path, "/api/runs/:id");
1656
+ if (params) {
1657
+ const { id } = params;
1658
+ if (!id) return errorResponse("Missing run ID", 400);
1659
+ const dbPath = join(legioDir, "sessions.db");
1660
+ if (!(await fileExists(dbPath))) {
1661
+ return errorResponse(`Run not found: ${id}`, 404);
1662
+ }
1663
+ const runStore = createRunStore(dbPath);
1664
+ const sessionStore = createSessionStore(dbPath);
1665
+ try {
1666
+ const run = runStore.getRun(id);
1667
+ if (!run) return errorResponse(`Run not found: ${id}`, 404);
1668
+ const agents = sessionStore.getByRun(id);
1669
+ return jsonResponse({ run, agents });
1670
+ } finally {
1671
+ runStore.close();
1672
+ sessionStore.close();
1673
+ }
1674
+ }
1675
+ }
1676
+
1677
+ // -------------------------------------------------------------------------
1678
+ // Merge Queue
1679
+ // -------------------------------------------------------------------------
1680
+
1681
+ if (path === "/api/merge-queue") {
1682
+ const dbPath = join(legioDir, "merge-queue.db");
1683
+ if (!(await fileExists(dbPath))) {
1684
+ return jsonResponse([]);
1685
+ }
1686
+ const statusParam = url.searchParams.get("status");
1687
+ const queue = createMergeQueue(dbPath);
1688
+ try {
1689
+ const status = statusParam ? (statusParam as MergeEntry["status"]) : undefined;
1690
+ return jsonResponse(queue.list(status));
1691
+ } finally {
1692
+ queue.close();
1693
+ }
1694
+ }
1695
+
1696
+ // -------------------------------------------------------------------------
1697
+ // Issues (beads)
1698
+ // -------------------------------------------------------------------------
1699
+
1700
+ if (path === "/api/issues") {
1701
+ const statusParam = url.searchParams.get("status") ?? undefined;
1702
+ const allParam = url.searchParams.get("all");
1703
+ const all = allParam !== "false"; // default true
1704
+ const limitStr = url.searchParams.get("limit");
1705
+ const limit = limitStr ? Number.parseInt(limitStr, 10) : undefined;
1706
+
1707
+ // Cache key: only use cache for default requests (no filters)
1708
+ const isDefaultRequest = !statusParam && all && !limit;
1709
+ if (isDefaultRequest && issuesCacheData && Date.now() - issuesCacheAt < ISSUES_CACHE_TTL) {
1710
+ return jsonResponse(issuesCacheData);
1711
+ }
1712
+
1713
+ try {
1714
+ const client = createBeadsClient(projectRoot);
1715
+ const issues = await client.list({ status: statusParam, limit, all });
1716
+ if (isDefaultRequest) {
1717
+ issuesCacheData = issues;
1718
+ issuesCacheAt = Date.now();
1719
+ }
1720
+ return jsonResponse(issues);
1721
+ } catch {
1722
+ return jsonResponse([]);
1723
+ }
1724
+ }
1725
+
1726
+ if (path === "/api/issues/ready") {
1727
+ try {
1728
+ const client = createBeadsClient(projectRoot);
1729
+ const issues = await client.ready();
1730
+ return jsonResponse(issues);
1731
+ } catch {
1732
+ return jsonResponse([]);
1733
+ }
1734
+ }
1735
+
1736
+ // /api/issues/:id
1737
+ {
1738
+ const params = matchRoute(path, "/api/issues/:id");
1739
+ if (params) {
1740
+ const { id } = params;
1741
+ if (!id) return errorResponse("Missing issue ID", 400);
1742
+ try {
1743
+ const client = createBeadsClient(projectRoot);
1744
+ const issue = await client.show(id);
1745
+ return jsonResponse(issue);
1746
+ } catch {
1747
+ return errorResponse(`Issue not found: ${id}`, 404);
1748
+ }
1749
+ }
1750
+ }
1751
+
1752
+ // -------------------------------------------------------------------------
1753
+ // Terminal
1754
+ // -------------------------------------------------------------------------
1755
+
1756
+ if (path === "/api/terminal/capture") {
1757
+ const agentName = url.searchParams.get("agent") ?? "coordinator";
1758
+ const linesStr = url.searchParams.get("lines");
1759
+ const lines = linesStr ? Math.max(1, Number.parseInt(linesStr, 10)) : 100;
1760
+
1761
+ // If headless coordinator is running for this agent, return its ring buffer
1762
+ if (
1763
+ (agentName === "coordinator" || agentName === "headless") &&
1764
+ headless?.coordinator?.isRunning()
1765
+ ) {
1766
+ return jsonResponse({
1767
+ output: headless.coordinator.getOutput(),
1768
+ agent: agentName,
1769
+ headless: true,
1770
+ timestamp: new Date().toISOString(),
1771
+ });
1772
+ }
1773
+
1774
+ // Try terminal log file first (pipe-pane streaming)
1775
+ let output: string | null = null;
1776
+ const { store: captureStore } = openSessionStore(legioDir);
1777
+ try {
1778
+ const agentSession = captureStore.getByName(agentName);
1779
+ if (agentSession?.terminalLogPath) {
1780
+ output = await readTerminalLog(agentSession.terminalLogPath, lines);
1781
+ }
1782
+ } finally {
1783
+ captureStore.close();
1784
+ }
1785
+
1786
+ // Fall back to capture-pane if no terminal log available
1787
+ if (output === null) {
1788
+ const tmuxSession = await resolveTerminalSession(legioDir, projectRoot, agentName);
1789
+ if (!tmuxSession) {
1790
+ return errorResponse(`Cannot resolve tmux session for agent "${agentName}"`, 404);
1791
+ }
1792
+
1793
+ output = await captureTmuxPane(tmuxSession, lines);
1794
+ if (output === null) {
1795
+ return errorResponse(`Failed to capture tmux pane for session "${tmuxSession}"`);
1796
+ }
1797
+ }
1798
+
1799
+ return jsonResponse({
1800
+ output,
1801
+ agent: agentName,
1802
+ timestamp: new Date().toISOString(),
1803
+ });
1804
+ }
1805
+
1806
+ // -------------------------------------------------------------------------
1807
+ // Audit
1808
+ // -------------------------------------------------------------------------
1809
+
1810
+ if (path === "/api/audit/timeline") {
1811
+ const auditDbPath = join(legioDir, "audit.db");
1812
+ if (!(await fileExists(auditDbPath))) {
1813
+ return jsonResponse([]);
1814
+ }
1815
+ const sinceParam = url.searchParams.get("since");
1816
+ // Default since to 24h ago if not provided
1817
+ const since = sinceParam ?? new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
1818
+ const until = url.searchParams.get("until") ?? undefined;
1819
+ const limitStr = url.searchParams.get("limit");
1820
+ const limit = limitStr ? Number.parseInt(limitStr, 10) : undefined;
1821
+ const store = createAuditStore(auditDbPath);
1822
+ try {
1823
+ return jsonResponse(store.getTimeline({ since, until, limit }));
1824
+ } finally {
1825
+ store.close();
1826
+ }
1827
+ }
1828
+
1829
+ if (path === "/api/audit") {
1830
+ const auditDbPath = join(legioDir, "audit.db");
1831
+ if (!(await fileExists(auditDbPath))) {
1832
+ return jsonResponse([]);
1833
+ }
1834
+ const since = url.searchParams.get("since") ?? undefined;
1835
+ const until = url.searchParams.get("until") ?? undefined;
1836
+ const agent = url.searchParams.get("agent") ?? undefined;
1837
+ const type = url.searchParams.get("type") ?? undefined;
1838
+ const source = url.searchParams.get("source") ?? undefined;
1839
+ const limitStr = url.searchParams.get("limit");
1840
+ const limit = limitStr ? Number.parseInt(limitStr, 10) : undefined;
1841
+ const store = createAuditStore(auditDbPath);
1842
+ try {
1843
+ return jsonResponse(store.getAll({ since, until, agent, type, source, limit }));
1844
+ } finally {
1845
+ store.close();
1846
+ }
1847
+ }
1848
+
1849
+ // -------------------------------------------------------------------------
1850
+ // Ideas
1851
+ // -------------------------------------------------------------------------
1852
+
1853
+ if (path === "/api/ideas") {
1854
+ const ideasPath = join(legioDir, "ideas.json");
1855
+ if (!(await fileExists(ideasPath))) {
1856
+ return jsonResponse([]);
1857
+ }
1858
+ try {
1859
+ const raw = await readFile(ideasPath, "utf-8");
1860
+ const data = JSON.parse(raw) as IdeasFile;
1861
+ return jsonResponse(data.ideas ?? []);
1862
+ } catch (err) {
1863
+ return errorResponse(
1864
+ `Failed to read ideas.json: ${err instanceof Error ? err.message : String(err)}`,
1865
+ );
1866
+ }
1867
+ }
1868
+
1869
+ // -------------------------------------------------------------------------
1870
+ // Coordinator Chat — GET /api/coordinator/chat/history
1871
+ // -------------------------------------------------------------------------
1872
+
1873
+ if (path === "/api/coordinator/chat/history") {
1874
+ const limitParam = url.searchParams.get("limit");
1875
+ const limit = limitParam !== null ? Math.max(1, parseInt(limitParam, 10) || 100) : 100;
1876
+ const dbPath = join(legioDir, "mail.db");
1877
+ if (!(await fileExists(dbPath))) {
1878
+ return jsonResponse([]);
1879
+ }
1880
+ const store = createMailStore(dbPath);
1881
+ try {
1882
+ const humanToCoord = store.getAll({ from: "human", to: "coordinator" });
1883
+ const coordToHuman = store.getAll({ from: "coordinator", to: "human" });
1884
+ const combined = [...humanToCoord, ...coordToHuman];
1885
+ const seen = new Set<string>();
1886
+ const relevant = combined.filter((m) => {
1887
+ if (seen.has(m.id)) return false;
1888
+ seen.add(m.id);
1889
+ return m.audience === "human" || m.audience === "both";
1890
+ });
1891
+ relevant.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1892
+ return jsonResponse(relevant.slice(-limit));
1893
+ } finally {
1894
+ store.close();
1895
+ }
1896
+ }
1897
+
1898
+ // -------------------------------------------------------------------------
1899
+ // Gateway Chat — GET /api/gateway/chat/history
1900
+ // -------------------------------------------------------------------------
1901
+
1902
+ if (path === "/api/gateway/chat/history") {
1903
+ const limitParam = url.searchParams.get("limit");
1904
+ const limit = limitParam !== null ? Math.max(1, parseInt(limitParam, 10) || 100) : 100;
1905
+ const dbPath = join(legioDir, "mail.db");
1906
+ if (!(await fileExists(dbPath))) {
1907
+ return jsonResponse([]);
1908
+ }
1909
+ const store = createMailStore(dbPath);
1910
+ try {
1911
+ const humanToGateway = store.getAll({ from: "human", to: "gateway" });
1912
+ const gatewayToHuman = store.getAll({ from: "gateway", to: "human" });
1913
+ const combined = [...humanToGateway, ...gatewayToHuman];
1914
+ const seen = new Set<string>();
1915
+ const relevant = combined.filter((m) => {
1916
+ if (seen.has(m.id)) return false;
1917
+ seen.add(m.id);
1918
+ return m.audience === "human" || m.audience === "both";
1919
+ });
1920
+ relevant.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1921
+ return jsonResponse(relevant.slice(-limit));
1922
+ } finally {
1923
+ store.close();
1924
+ }
1925
+ }
1926
+
1927
+ // -------------------------------------------------------------------------
1928
+ // Unified Chat History — GET /api/chat/unified/history
1929
+ // Returns all human-audience messages across all agents in chronological order.
1930
+ // Bidirectional: includes messages from human AND messages to human,
1931
+ // filtered by audience (human or both).
1932
+ // -------------------------------------------------------------------------
1933
+
1934
+ if (path === "/api/chat/unified/history") {
1935
+ const limitParam = url.searchParams.get("limit");
1936
+ const limit = limitParam !== null ? Math.max(1, parseInt(limitParam, 10) || 200) : 200;
1937
+ const dbPath = join(legioDir, "mail.db");
1938
+ if (!(await fileExists(dbPath))) {
1939
+ return jsonResponse([]);
1940
+ }
1941
+ const store = createMailStore(dbPath);
1942
+ try {
1943
+ const fromHuman = store.getAll({ from: "human" });
1944
+ const toHuman = store.getAll({ to: "human" });
1945
+ const combined = [...fromHuman, ...toHuman];
1946
+ const seen = new Set<string>();
1947
+ const relevant = combined.filter((m) => {
1948
+ if (seen.has(m.id)) return false;
1949
+ seen.add(m.id);
1950
+ return m.audience === "human" || m.audience === "both";
1951
+ });
1952
+ relevant.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
1953
+ return jsonResponse(relevant.slice(-limit));
1954
+ } finally {
1955
+ store.close();
1956
+ }
1957
+ }
1958
+
1959
+ // -------------------------------------------------------------------------
1960
+ // Catch-all for unmatched /api/* paths
1961
+ // -------------------------------------------------------------------------
1962
+
1963
+ return errorResponse("Not found", 404);
1964
+ }