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