@katyella/legio 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +422 -0
- package/LICENSE +21 -0
- package/README.md +555 -0
- package/agents/builder.md +141 -0
- package/agents/coordinator.md +351 -0
- package/agents/cto.md +196 -0
- package/agents/gateway.md +276 -0
- package/agents/lead.md +281 -0
- package/agents/merger.md +156 -0
- package/agents/monitor.md +212 -0
- package/agents/reviewer.md +142 -0
- package/agents/scout.md +131 -0
- package/agents/supervisor.md +416 -0
- package/bin/legio.mjs +38 -0
- package/package.json +77 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +102 -0
- package/src/agents/hooks-deployer.test.ts +1820 -0
- package/src/agents/hooks-deployer.ts +574 -0
- package/src/agents/identity.test.ts +614 -0
- package/src/agents/identity.ts +385 -0
- package/src/agents/lifecycle.test.ts +202 -0
- package/src/agents/lifecycle.ts +184 -0
- package/src/agents/manifest.test.ts +558 -0
- package/src/agents/manifest.ts +297 -0
- package/src/agents/overlay.test.ts +592 -0
- package/src/agents/overlay.ts +316 -0
- package/src/beads/client.test.ts +210 -0
- package/src/beads/client.ts +227 -0
- package/src/beads/molecules.test.ts +320 -0
- package/src/beads/molecules.ts +209 -0
- package/src/commands/agents.test.ts +325 -0
- package/src/commands/agents.ts +286 -0
- package/src/commands/clean.test.ts +730 -0
- package/src/commands/clean.ts +653 -0
- package/src/commands/completions.test.ts +346 -0
- package/src/commands/completions.ts +950 -0
- package/src/commands/coordinator.test.ts +1524 -0
- package/src/commands/coordinator.ts +880 -0
- package/src/commands/costs.test.ts +1015 -0
- package/src/commands/costs.ts +473 -0
- package/src/commands/dashboard.test.ts +94 -0
- package/src/commands/dashboard.ts +607 -0
- package/src/commands/doctor.test.ts +295 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/down.test.ts +308 -0
- package/src/commands/down.ts +124 -0
- package/src/commands/errors.test.ts +648 -0
- package/src/commands/errors.ts +255 -0
- package/src/commands/feed.test.ts +579 -0
- package/src/commands/feed.ts +368 -0
- package/src/commands/gateway.test.ts +698 -0
- package/src/commands/gateway.ts +419 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +539 -0
- package/src/commands/hooks.test.ts +292 -0
- package/src/commands/hooks.ts +210 -0
- package/src/commands/init.test.ts +211 -0
- package/src/commands/init.ts +622 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +455 -0
- package/src/commands/log.test.ts +1556 -0
- package/src/commands/log.ts +752 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +544 -0
- package/src/commands/mail.test.ts +1726 -0
- package/src/commands/mail.ts +926 -0
- package/src/commands/merge.test.ts +676 -0
- package/src/commands/merge.ts +374 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +150 -0
- package/src/commands/monitor.test.ts +151 -0
- package/src/commands/monitor.ts +394 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +373 -0
- package/src/commands/prime.test.ts +467 -0
- package/src/commands/prime.ts +386 -0
- package/src/commands/replay.test.ts +742 -0
- package/src/commands/replay.ts +367 -0
- package/src/commands/run.test.ts +443 -0
- package/src/commands/run.ts +365 -0
- package/src/commands/server.test.ts +626 -0
- package/src/commands/server.ts +298 -0
- package/src/commands/sling.test.ts +810 -0
- package/src/commands/sling.ts +700 -0
- package/src/commands/spec.test.ts +206 -0
- package/src/commands/spec.ts +171 -0
- package/src/commands/status.test.ts +276 -0
- package/src/commands/status.ts +339 -0
- package/src/commands/stop.test.ts +357 -0
- package/src/commands/stop.ts +119 -0
- package/src/commands/supervisor.test.ts +186 -0
- package/src/commands/supervisor.ts +544 -0
- package/src/commands/trace.test.ts +746 -0
- package/src/commands/trace.ts +332 -0
- package/src/commands/up.test.ts +597 -0
- package/src/commands/up.ts +275 -0
- package/src/commands/watch.test.ts +152 -0
- package/src/commands/watch.ts +238 -0
- package/src/commands/worktree.test.ts +648 -0
- package/src/commands/worktree.ts +266 -0
- package/src/config.test.ts +496 -0
- package/src/config.ts +616 -0
- package/src/doctor/agents.test.ts +448 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +184 -0
- package/src/doctor/config-check.ts +185 -0
- package/src/doctor/consistency.test.ts +645 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +284 -0
- package/src/doctor/databases.ts +211 -0
- package/src/doctor/dependencies.test.ts +150 -0
- package/src/doctor/dependencies.ts +179 -0
- package/src/doctor/logs.test.ts +244 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +210 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +285 -0
- package/src/doctor/structure.ts +195 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +130 -0
- package/src/doctor/version.ts +131 -0
- package/src/e2e/chat-flow.test.ts +346 -0
- package/src/e2e/init-sling-lifecycle.test.ts +288 -0
- package/src/errors.test.ts +21 -0
- package/src/errors.ts +246 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +344 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/global-setup.ts +14 -0
- package/src/index.ts +339 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +118 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +812 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +258 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +873 -0
- package/src/mail/client.ts +236 -0
- package/src/mail/store.test.ts +815 -0
- package/src/mail/store.ts +402 -0
- package/src/merge/queue.test.ts +449 -0
- package/src/merge/queue.ts +262 -0
- package/src/merge/resolver.test.ts +1453 -0
- package/src/merge/resolver.ts +759 -0
- package/src/metrics/store.test.ts +1167 -0
- package/src/metrics/store.ts +511 -0
- package/src/metrics/summary.test.ts +397 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +643 -0
- package/src/metrics/transcript.ts +351 -0
- package/src/mulch/client.test.ts +547 -0
- package/src/mulch/client.ts +416 -0
- package/src/server/audit-store.test.ts +384 -0
- package/src/server/audit-store.ts +257 -0
- package/src/server/headless.test.ts +180 -0
- package/src/server/headless.ts +151 -0
- package/src/server/index.test.ts +241 -0
- package/src/server/index.ts +317 -0
- package/src/server/public/app.js +187 -0
- package/src/server/public/apple-touch-icon.png +0 -0
- package/src/server/public/components/agent-badge.js +37 -0
- package/src/server/public/components/data-table.js +114 -0
- package/src/server/public/components/gateway-chat.js +256 -0
- package/src/server/public/components/issue-card.js +96 -0
- package/src/server/public/components/layout.js +88 -0
- package/src/server/public/components/message-bubble.js +120 -0
- package/src/server/public/components/stat-card.js +26 -0
- package/src/server/public/components/terminal-panel.js +140 -0
- package/src/server/public/favicon-16.png +0 -0
- package/src/server/public/favicon-32.png +0 -0
- package/src/server/public/favicon.ico +0 -0
- package/src/server/public/favicon.png +0 -0
- package/src/server/public/index.html +64 -0
- package/src/server/public/lib/api.js +35 -0
- package/src/server/public/lib/markdown.js +8 -0
- package/src/server/public/lib/preact-setup.js +8 -0
- package/src/server/public/lib/state.js +99 -0
- package/src/server/public/lib/utils.js +309 -0
- package/src/server/public/lib/ws.js +79 -0
- package/src/server/public/views/chat.js +983 -0
- package/src/server/public/views/costs.js +692 -0
- package/src/server/public/views/dashboard.js +781 -0
- package/src/server/public/views/gateway-chat.js +622 -0
- package/src/server/public/views/inspect.js +399 -0
- package/src/server/public/views/issues.js +470 -0
- package/src/server/public/views/setup.js +94 -0
- package/src/server/public/views/task-detail.js +422 -0
- package/src/server/routes.test.ts +3816 -0
- package/src/server/routes.ts +1964 -0
- package/src/server/websocket.test.ts +288 -0
- package/src/server/websocket.ts +196 -0
- package/src/sessions/compat.test.ts +109 -0
- package/src/sessions/compat.ts +17 -0
- package/src/sessions/store.test.ts +969 -0
- package/src/sessions/store.ts +480 -0
- package/src/test-helpers.test.ts +97 -0
- package/src/test-helpers.ts +143 -0
- package/src/types.ts +708 -0
- package/src/watchdog/daemon.test.ts +1233 -0
- package/src/watchdog/daemon.ts +533 -0
- package/src/watchdog/health.test.ts +371 -0
- package/src/watchdog/health.ts +248 -0
- package/src/watchdog/triage.test.ts +162 -0
- package/src/watchdog/triage.ts +193 -0
- package/src/worktree/manager.test.ts +444 -0
- package/src/worktree/manager.ts +224 -0
- package/src/worktree/tmux.test.ts +1238 -0
- package/src/worktree/tmux.ts +644 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +132 -0
- package/templates/overlay.md.tmpl +79 -0
|
@@ -0,0 +1,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
|
+
}
|