taskplane 0.1.6 → 0.1.7

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.
@@ -1,638 +1,638 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Orchestrator Web Dashboard — Local HTTP server with SSE live updates.
4
- *
5
- * Reads .pi/batch-state.json + STATUS.md files and streams state to the
6
- * browser via Server-Sent Events. Zero external dependencies.
7
- *
8
- * Usage:
9
- * node dashboard/server.cjs [--port 8099] [--root /path/to/project]
10
- */
11
-
12
- const http = require("http");
13
- const fs = require("fs");
14
- const path = require("path");
15
- const { execFileSync, exec } = require("child_process");
16
- // url module not needed — we parse with new URL() below
17
-
18
- // ─── Configuration ──────────────────────────────────────────────────────────
19
-
20
- const PUBLIC_DIR = path.join(__dirname, "public");
21
- const DEFAULT_PORT = 8100;
22
- const MAX_PORT_ATTEMPTS = 20;
23
- const POLL_INTERVAL = 2000; // ms between state checks
24
-
25
- // REPO_ROOT is resolved after parseArgs() — see initialization below.
26
- let REPO_ROOT;
27
- let BATCH_STATE_PATH;
28
- let BATCH_HISTORY_PATH;
29
-
30
- // ─── CLI Args ───────────────────────────────────────────────────────────────
31
-
32
- function parseArgs() {
33
- const args = process.argv.slice(2);
34
- const opts = { port: DEFAULT_PORT, open: true, root: "" };
35
- for (let i = 0; i < args.length; i++) {
36
- if (args[i] === "--port" && args[i + 1]) {
37
- opts.port = parseInt(args[i + 1]) || DEFAULT_PORT;
38
- i++;
39
- } else if (args[i] === "--root" && args[i + 1]) {
40
- opts.root = args[i + 1];
41
- i++;
42
- } else if (args[i] === "--no-open") {
43
- opts.open = false;
44
- } else if (args[i] === "--help" || args[i] === "-h") {
45
- console.log(`
46
- Orchestrator Web Dashboard
47
-
48
- Usage:
49
- node dashboard/server.cjs [options]
50
-
51
- Options:
52
- --port <number> Port to listen on (default: ${DEFAULT_PORT})
53
- --root <path> Project root directory (default: current directory)
54
- --no-open Don't auto-open browser
55
- -h, --help Show this help
56
- `);
57
- process.exit(0);
58
- }
59
- }
60
- return opts;
61
- }
62
-
63
- // ─── Data Loading (ported from orch-dashboard.cjs) ──────────────────────────
64
-
65
- function loadBatchState() {
66
- try {
67
- const raw = fs.readFileSync(BATCH_STATE_PATH, "utf-8");
68
- return JSON.parse(raw);
69
- } catch {
70
- return null;
71
- }
72
- }
73
-
74
- function resolveTaskFolder(task, state) {
75
- if (!task || !task.taskFolder) return null;
76
- const laneNum = task.laneNumber;
77
- const lane = (state?.lanes || []).find((l) => l.laneNumber === laneNum);
78
- if (!lane || !lane.worktreePath) return task.taskFolder;
79
- const taskFolderAbs = path.resolve(task.taskFolder);
80
- const repoRootAbs = path.resolve(REPO_ROOT);
81
- const rel = path.relative(repoRootAbs, taskFolderAbs);
82
- if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return task.taskFolder;
83
- return path.join(lane.worktreePath, rel);
84
- }
85
-
86
- function parseStatusMd(taskFolder) {
87
- const candidates = [taskFolder];
88
- const taskId = path.basename(taskFolder);
89
- const archiveBase = taskFolder.replace(/[/\\]tasks[/\\][^/\\]+$/, "/tasks/archive/" + taskId);
90
- if (archiveBase !== taskFolder) candidates.push(archiveBase);
91
-
92
- for (const folder of candidates) {
93
- const statusPath = path.join(folder, "STATUS.md");
94
- try {
95
- const content = fs.readFileSync(statusPath, "utf-8");
96
- const stepMatch = content.match(/\*\*Current Step:\*\*\s*(.+)/);
97
- const statusMatch = content.match(/\*\*Status:\*\*\s*(.+)/);
98
- const iterMatch = content.match(/\*\*Iteration:\*\*\s*(\d+)/);
99
- const reviewMatch = content.match(/\*\*Review Counter:\*\*\s*(\d+)/);
100
- const checked = (content.match(/- \[x\]/gi) || []).length;
101
- const unchecked = (content.match(/- \[ \]/g) || []).length;
102
- const total = checked + unchecked;
103
- return {
104
- currentStep: stepMatch ? stepMatch[1].trim() : "Unknown",
105
- status: statusMatch ? statusMatch[1].trim() : "Unknown",
106
- iteration: iterMatch ? parseInt(iterMatch[1]) : 0,
107
- reviews: reviewMatch ? parseInt(reviewMatch[1]) : 0,
108
- checked,
109
- total,
110
- progress: total > 0 ? Math.round((checked / total) * 100) : 0,
111
- };
112
- } catch {
113
- continue;
114
- }
115
- }
116
- return null;
117
- }
118
-
119
- function getTmuxSessions() {
120
- try {
121
- const output = execFileSync('tmux list-sessions -F "#{session_name}"', {
122
- encoding: "utf-8",
123
- timeout: 5000,
124
- shell: true,
125
- stdio: ["ignore", "pipe", "ignore"],
126
- }).trim();
127
- return output ? output.split("\n").map((s) => s.trim()).filter(Boolean) : [];
128
- } catch {
129
- return [];
130
- }
131
- }
132
-
133
- function checkDoneFile(taskFolder) {
134
- const candidates = [taskFolder];
135
- const taskId = path.basename(taskFolder);
136
- const archiveBase = taskFolder.replace(/[/\\]tasks[/\\][^/\\]+$/, "/tasks/archive/" + taskId);
137
- if (archiveBase !== taskFolder) candidates.push(archiveBase);
138
- for (const folder of candidates) {
139
- if (fs.existsSync(path.join(folder, ".DONE"))) return true;
140
- }
141
- return false;
142
- }
143
-
144
- /** Read lane state sidecar JSON files written by the task-runner. */
145
- function loadLaneStates() {
146
- const piDir = path.join(REPO_ROOT, ".pi");
147
- const states = {};
148
- try {
149
- const files = fs.readdirSync(piDir).filter(f => f.startsWith("lane-state-") && f.endsWith(".json"));
150
- for (const file of files) {
151
- try {
152
- const raw = fs.readFileSync(path.join(piDir, file), "utf-8").trim();
153
- if (!raw) continue;
154
- const data = JSON.parse(raw);
155
- if (data.prefix) states[data.prefix] = data;
156
- } catch { continue; }
157
- }
158
- } catch { /* .pi dir may not exist */ }
159
- return states;
160
- }
161
-
162
- /** Build full dashboard state object for the frontend. */
163
- function buildDashboardState() {
164
- const state = loadBatchState();
165
- const tmuxSessions = getTmuxSessions();
166
- const laneStates = loadLaneStates();
167
-
168
- if (!state) {
169
- return { batch: null, tmuxSessions, laneStates: {}, timestamp: Date.now() };
170
- }
171
-
172
- const tasks = (state.tasks || []).map((task) => {
173
- const effectiveFolder = resolveTaskFolder(task, state);
174
- let statusData = null;
175
- if (effectiveFolder) {
176
- statusData = parseStatusMd(effectiveFolder);
177
- }
178
- if (!task.doneFileFound && effectiveFolder) {
179
- task.doneFileFound = checkDoneFile(effectiveFolder);
180
- }
181
- return { ...task, statusData };
182
- });
183
-
184
- return {
185
- laneStates,
186
- batch: {
187
- batchId: state.batchId,
188
- phase: state.phase,
189
- startedAt: state.startedAt,
190
- updatedAt: state.updatedAt,
191
- currentWaveIndex: state.currentWaveIndex || 0,
192
- totalWaves: state.totalWaves || (state.wavePlan ? state.wavePlan.length : 0),
193
- wavePlan: state.wavePlan || [],
194
- lanes: state.lanes || [],
195
- tasks,
196
- mergeResults: state.mergeResults || [],
197
- errors: state.errors || [],
198
- lastError: state.lastError || null,
199
- },
200
- tmuxSessions,
201
- timestamp: Date.now(),
202
- };
203
- }
204
-
205
- // ─── Static File Serving ────────────────────────────────────────────────────
206
-
207
- const MIME_TYPES = {
208
- ".html": "text/html; charset=utf-8",
209
- ".css": "text/css; charset=utf-8",
210
- ".js": "application/javascript; charset=utf-8",
211
- ".json": "application/json; charset=utf-8",
212
- ".svg": "image/svg+xml",
213
- ".png": "image/png",
214
- ".ico": "image/x-icon",
215
- };
216
-
217
- function serveStatic(req, res) {
218
- let filePath = new URL(req.url, "http://localhost").pathname;
219
- if (filePath === "/") filePath = "/index.html";
220
-
221
- const fullPath = path.join(PUBLIC_DIR, filePath);
222
- // Prevent directory traversal
223
- if (!fullPath.startsWith(PUBLIC_DIR)) {
224
- res.writeHead(403);
225
- res.end("Forbidden");
226
- return;
227
- }
228
-
229
- const ext = path.extname(fullPath);
230
- const contentType = MIME_TYPES[ext] || "application/octet-stream";
231
-
232
- try {
233
- const content = fs.readFileSync(fullPath);
234
- res.writeHead(200, {
235
- "Content-Type": contentType,
236
- "Cache-Control": "no-cache, no-store, must-revalidate",
237
- });
238
- res.end(content);
239
- } catch {
240
- res.writeHead(404, { "Content-Type": "text/plain" });
241
- res.end("Not Found");
242
- }
243
- }
244
-
245
- // ─── SSE Stream ─────────────────────────────────────────────────────────────
246
-
247
- const sseClients = new Set();
248
-
249
- // ─── Pane Capture SSE ───────────────────────────────────────────────────
250
-
251
- const paneClients = new Map(); // sessionName → Set<res>
252
-
253
- function handlePaneSSE(req, res, sessionName) {
254
- // Validate session name (alphanumeric, dashes, underscores only)
255
- if (!/^[\w-]+$/.test(sessionName)) {
256
- res.writeHead(400, { "Content-Type": "text/plain" });
257
- res.end("Invalid session name");
258
- return;
259
- }
260
-
261
- res.writeHead(200, {
262
- "Content-Type": "text/event-stream",
263
- "Cache-Control": "no-cache",
264
- Connection: "keep-alive",
265
- "Access-Control-Allow-Origin": "*",
266
- });
267
-
268
- if (!paneClients.has(sessionName)) {
269
- paneClients.set(sessionName, new Set());
270
- }
271
- paneClients.get(sessionName).add(res);
272
-
273
- // Send initial capture immediately
274
- const initial = captureTmuxPane(sessionName);
275
- if (initial !== null) {
276
- res.write(`data: ${JSON.stringify({ output: initial, session: sessionName })}\n\n`);
277
- } else {
278
- res.write(`data: ${JSON.stringify({ error: "Session not found or not accessible", session: sessionName })}\n\n`);
279
- }
280
-
281
- req.on("close", () => {
282
- const clients = paneClients.get(sessionName);
283
- if (clients) {
284
- clients.delete(res);
285
- if (clients.size === 0) paneClients.delete(sessionName);
286
- }
287
- });
288
- }
289
-
290
- function captureTmuxPane(sessionName) {
291
- try {
292
- // Capture with ANSI escape sequences (-e), full scrollback visible area (-p)
293
- const output = execFileSync(
294
- `tmux capture-pane -t "${sessionName}" -p -e`,
295
- { encoding: "utf-8", timeout: 3000, shell: true, stdio: ["ignore", "pipe", "ignore"] }
296
- );
297
- return output;
298
- } catch {
299
- return null;
300
- }
301
- }
302
-
303
- function broadcastPaneCaptures() {
304
- for (const [sessionName, clients] of paneClients) {
305
- if (clients.size === 0) continue;
306
- const output = captureTmuxPane(sessionName);
307
- if (output === null) continue;
308
- const payload = `data: ${JSON.stringify({ output, session: sessionName })}\n\n`;
309
- for (const client of clients) {
310
- try {
311
- client.write(payload);
312
- } catch {
313
- clients.delete(client);
314
- }
315
- }
316
- }
317
- }
318
-
319
- // ─── Conversation JSONL ─────────────────────────────────────────────────
320
-
321
- function serveConversation(req, res, prefix) {
322
- if (!/^[\w-]+$/.test(prefix)) {
323
- res.writeHead(400, { "Content-Type": "text/plain" });
324
- res.end("Invalid prefix");
325
- return;
326
- }
327
-
328
- const filePath = path.join(REPO_ROOT, ".pi", `worker-conversation-${prefix}.jsonl`);
329
- try {
330
- const content = fs.readFileSync(filePath, "utf-8");
331
- res.writeHead(200, {
332
- "Content-Type": "application/x-ndjson",
333
- "Access-Control-Allow-Origin": "*",
334
- });
335
- res.end(content);
336
- } catch {
337
- res.writeHead(200, {
338
- "Content-Type": "application/x-ndjson",
339
- "Access-Control-Allow-Origin": "*",
340
- });
341
- res.end(""); // empty — no conversation yet
342
- }
343
- }
344
-
345
- // ─── Dashboard SSE ──────────────────────────────────────────────────────
346
-
347
- function handleSSE(req, res) {
348
- res.writeHead(200, {
349
- "Content-Type": "text/event-stream",
350
- "Cache-Control": "no-cache",
351
- Connection: "keep-alive",
352
- "Access-Control-Allow-Origin": "*",
353
- });
354
-
355
- // Send initial state immediately
356
- const state = buildDashboardState();
357
- res.write(`data: ${JSON.stringify(state)}\n\n`);
358
-
359
- sseClients.add(res);
360
- req.on("close", () => sseClients.delete(res));
361
- }
362
-
363
- function broadcastState() {
364
- if (sseClients.size === 0) return;
365
- const state = buildDashboardState();
366
- const payload = `data: ${JSON.stringify(state)}\n\n`;
367
- for (const client of sseClients) {
368
- try {
369
- client.write(payload);
370
- } catch {
371
- sseClients.delete(client);
372
- }
373
- }
374
- }
375
-
376
- // ─── Batch History API ──────────────────────────────────────────────────────
377
-
378
- // BATCH_HISTORY_PATH is initialized in main() alongside REPO_ROOT.
379
-
380
- function loadHistory() {
381
- try {
382
- if (!fs.existsSync(BATCH_HISTORY_PATH)) return [];
383
- const raw = fs.readFileSync(BATCH_HISTORY_PATH, "utf-8");
384
- return JSON.parse(raw);
385
- } catch {
386
- return [];
387
- }
388
- }
389
-
390
- /** GET /api/history — return list of batch summaries (compact: no per-task detail). */
391
- function serveHistory(req, res) {
392
- const history = loadHistory();
393
- // Return compact list for the dropdown (no per-task details)
394
- const compact = history.map(h => ({
395
- batchId: h.batchId,
396
- status: h.status,
397
- startedAt: h.startedAt,
398
- endedAt: h.endedAt,
399
- durationMs: h.durationMs,
400
- totalWaves: h.totalWaves,
401
- totalTasks: h.totalTasks,
402
- succeededTasks: h.succeededTasks,
403
- failedTasks: h.failedTasks,
404
- tokens: h.tokens,
405
- }));
406
- res.writeHead(200, {
407
- "Content-Type": "application/json",
408
- "Access-Control-Allow-Origin": "*",
409
- });
410
- res.end(JSON.stringify(compact));
411
- }
412
-
413
- /** GET /api/history/:batchId — return full detail for one batch. */
414
- function serveHistoryEntry(req, res, batchId) {
415
- const history = loadHistory();
416
- const entry = history.find(h => h.batchId === batchId);
417
- if (!entry) {
418
- res.writeHead(404, { "Content-Type": "application/json" });
419
- res.end(JSON.stringify({ error: "Batch not found" }));
420
- return;
421
- }
422
- res.writeHead(200, {
423
- "Content-Type": "application/json",
424
- "Access-Control-Allow-Origin": "*",
425
- });
426
- res.end(JSON.stringify(entry));
427
- }
428
-
429
- /** GET /api/status-md/:taskId — return raw STATUS.md content for a task. */
430
- function serveStatusMd(req, res, taskId) {
431
- if (!/^[\w-]+$/.test(taskId)) {
432
- res.writeHead(400, { "Content-Type": "text/plain" });
433
- res.end("Invalid task ID");
434
- return;
435
- }
436
-
437
- const state = loadBatchState();
438
- if (!state) {
439
- res.writeHead(404, { "Content-Type": "application/json" });
440
- res.end(JSON.stringify({ error: "No batch state" }));
441
- return;
442
- }
443
-
444
- const task = (state.tasks || []).find(t => t.taskId === taskId);
445
- if (!task) {
446
- res.writeHead(404, { "Content-Type": "application/json" });
447
- res.end(JSON.stringify({ error: "Task not found" }));
448
- return;
449
- }
450
-
451
- const effectiveFolder = resolveTaskFolder(task, state);
452
- if (!effectiveFolder) {
453
- res.writeHead(404, { "Content-Type": "application/json" });
454
- res.end(JSON.stringify({ error: "Cannot resolve task folder" }));
455
- return;
456
- }
457
-
458
- // Try effective folder, then archive
459
- const candidates = [effectiveFolder];
460
- const archiveBase = effectiveFolder.replace(/[/\\]tasks[/\\][^/\\]+$/, "/tasks/archive/" + taskId);
461
- if (archiveBase !== effectiveFolder) candidates.push(archiveBase);
462
-
463
- for (const folder of candidates) {
464
- const statusPath = path.join(folder, "STATUS.md");
465
- try {
466
- const content = fs.readFileSync(statusPath, "utf-8");
467
- res.writeHead(200, {
468
- "Content-Type": "text/plain; charset=utf-8",
469
- "Access-Control-Allow-Origin": "*",
470
- });
471
- res.end(content);
472
- return;
473
- } catch { continue; }
474
- }
475
-
476
- res.writeHead(404, { "Content-Type": "application/json" });
477
- res.end(JSON.stringify({ error: "STATUS.md not found" }));
478
- }
479
-
480
- // ─── HTTP Server ────────────────────────────────────────────────────────────
481
-
482
- function createServer() {
483
- const server = http.createServer((req, res) => {
484
- const pathname = new URL(req.url, "http://localhost").pathname;
485
-
486
- if (pathname === "/api/stream" && req.method === "GET") {
487
- handleSSE(req, res);
488
- } else if (pathname.startsWith("/api/pane/") && req.method === "GET") {
489
- const sessionName = pathname.slice("/api/pane/".length);
490
- handlePaneSSE(req, res, sessionName);
491
- } else if (pathname.startsWith("/api/conversation/") && req.method === "GET") {
492
- const prefix = pathname.slice("/api/conversation/".length);
493
- serveConversation(req, res, prefix);
494
- } else if (pathname === "/api/state" && req.method === "GET") {
495
- const state = buildDashboardState();
496
- res.writeHead(200, {
497
- "Content-Type": "application/json",
498
- "Access-Control-Allow-Origin": "*",
499
- });
500
- res.end(JSON.stringify(state));
501
- } else if (pathname === "/api/history" && req.method === "GET") {
502
- serveHistory(req, res);
503
- } else if (pathname.startsWith("/api/history/") && req.method === "GET") {
504
- const batchId = decodeURIComponent(pathname.slice("/api/history/".length));
505
- serveHistoryEntry(req, res, batchId);
506
- } else if (pathname.startsWith("/api/status-md/") && req.method === "GET") {
507
- const taskId = decodeURIComponent(pathname.slice("/api/status-md/".length));
508
- serveStatusMd(req, res, taskId);
509
- } else {
510
- serveStatic(req, res);
511
- }
512
- });
513
-
514
- return server;
515
- }
516
-
517
- // ─── Browser Auto-Open ─────────────────────────────────────────────────────
518
-
519
- function openBrowser(url) {
520
- const cmd = process.platform === "win32" ? "start"
521
- : process.platform === "darwin" ? "open" : "xdg-open";
522
- exec(`${cmd} ${url}`, () => {}); // fire-and-forget
523
- }
524
-
525
- // ─── Main ───────────────────────────────────────────────────────────────────
526
-
527
- /** Try to listen on a port. Resolves with the port on success, rejects on EADDRINUSE. */
528
- function tryListen(server, port) {
529
- return new Promise((resolve, reject) => {
530
- const onError = (err) => {
531
- server.removeListener("listening", onListening);
532
- reject(err);
533
- };
534
- const onListening = () => {
535
- server.removeListener("error", onError);
536
- resolve(port);
537
- };
538
- server.once("error", onError);
539
- server.once("listening", onListening);
540
- server.listen(port);
541
- });
542
- }
543
-
544
- /** Find an available port starting from `start`, trying up to MAX_PORT_ATTEMPTS. */
545
- async function findPort(server, start, explicit) {
546
- // If the user explicitly passed --port, only try that one
547
- if (explicit) {
548
- try {
549
- return await tryListen(server, start);
550
- } catch (err) {
551
- if (err.code === "EADDRINUSE") {
552
- console.error(`\n Port ${start} is already in use.`);
553
- console.error(` Try: taskplane dashboard --port ${start + 1}\n`);
554
- process.exit(1);
555
- }
556
- throw err;
557
- }
558
- }
559
- // Auto-scan for an available port
560
- for (let port = start; port < start + MAX_PORT_ATTEMPTS; port++) {
561
- try {
562
- return await tryListen(server, port);
563
- } catch (err) {
564
- if (err.code === "EADDRINUSE") {
565
- // Close the server so we can retry on the next port
566
- server.close();
567
- server = createServer();
568
- continue;
569
- }
570
- throw err;
571
- }
572
- }
573
- console.error(`\n No available port found in range ${start}-${start + MAX_PORT_ATTEMPTS - 1}.\n`);
574
- process.exit(1);
575
- }
576
-
577
- async function main() {
578
- const opts = parseArgs();
579
-
580
- // Resolve project root: --root flag > cwd
581
- REPO_ROOT = path.resolve(opts.root || process.cwd());
582
- BATCH_STATE_PATH = path.join(REPO_ROOT, ".pi", "batch-state.json");
583
- BATCH_HISTORY_PATH = path.join(REPO_ROOT, ".pi", "batch-history.json");
584
-
585
- const server = createServer();
586
- const explicitPort = process.argv.slice(2).includes("--port");
587
- const port = await findPort(server, opts.port, explicitPort);
588
-
589
- console.log(`\n Orchestrator Dashboard → http://localhost:${port}\n`);
590
-
591
- // Broadcast state to all SSE clients on interval
592
- const pollTimer = setInterval(broadcastState, POLL_INTERVAL);
593
-
594
- // Broadcast pane captures more frequently (1s) for smooth terminal viewing
595
- const paneTimer = setInterval(broadcastPaneCaptures, 1000);
596
-
597
- // Also watch batch-state.json for immediate push on change
598
- try {
599
- const batchDir = path.dirname(BATCH_STATE_PATH);
600
- if (fs.existsSync(batchDir)) {
601
- let debounce = null;
602
- fs.watch(batchDir, (eventType, filename) => {
603
- if (filename === "batch-state.json") {
604
- clearTimeout(debounce);
605
- debounce = setTimeout(broadcastState, 200);
606
- }
607
- });
608
- }
609
- } catch {
610
- // fs.watch not supported — polling is sufficient
611
- }
612
-
613
- // Auto-open browser
614
- if (opts.open) {
615
- setTimeout(() => openBrowser(`http://localhost:${port}`), 500);
616
- }
617
-
618
- // Graceful shutdown
619
- function cleanup() {
620
- clearInterval(pollTimer);
621
- clearInterval(paneTimer);
622
- for (const [, clients] of paneClients) {
623
- for (const client of clients) {
624
- try { client.end(); } catch {}
625
- }
626
- }
627
- for (const client of sseClients) {
628
- try { client.end(); } catch {}
629
- }
630
- server.close();
631
- process.exit(0);
632
- }
633
-
634
- process.on("SIGINT", cleanup);
635
- process.on("SIGTERM", cleanup);
636
- }
637
-
638
- main();
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Orchestrator Web Dashboard — Local HTTP server with SSE live updates.
4
+ *
5
+ * Reads .pi/batch-state.json + STATUS.md files and streams state to the
6
+ * browser via Server-Sent Events. Zero external dependencies.
7
+ *
8
+ * Usage:
9
+ * node dashboard/server.cjs [--port 8099] [--root /path/to/project]
10
+ */
11
+
12
+ const http = require("http");
13
+ const fs = require("fs");
14
+ const path = require("path");
15
+ const { execFileSync, exec } = require("child_process");
16
+ // url module not needed — we parse with new URL() below
17
+
18
+ // ─── Configuration ──────────────────────────────────────────────────────────
19
+
20
+ const PUBLIC_DIR = path.join(__dirname, "public");
21
+ const DEFAULT_PORT = 8100;
22
+ const MAX_PORT_ATTEMPTS = 20;
23
+ const POLL_INTERVAL = 2000; // ms between state checks
24
+
25
+ // REPO_ROOT is resolved after parseArgs() — see initialization below.
26
+ let REPO_ROOT;
27
+ let BATCH_STATE_PATH;
28
+ let BATCH_HISTORY_PATH;
29
+
30
+ // ─── CLI Args ───────────────────────────────────────────────────────────────
31
+
32
+ function parseArgs() {
33
+ const args = process.argv.slice(2);
34
+ const opts = { port: DEFAULT_PORT, open: true, root: "" };
35
+ for (let i = 0; i < args.length; i++) {
36
+ if (args[i] === "--port" && args[i + 1]) {
37
+ opts.port = parseInt(args[i + 1]) || DEFAULT_PORT;
38
+ i++;
39
+ } else if (args[i] === "--root" && args[i + 1]) {
40
+ opts.root = args[i + 1];
41
+ i++;
42
+ } else if (args[i] === "--no-open") {
43
+ opts.open = false;
44
+ } else if (args[i] === "--help" || args[i] === "-h") {
45
+ console.log(`
46
+ Orchestrator Web Dashboard
47
+
48
+ Usage:
49
+ node dashboard/server.cjs [options]
50
+
51
+ Options:
52
+ --port <number> Port to listen on (default: ${DEFAULT_PORT})
53
+ --root <path> Project root directory (default: current directory)
54
+ --no-open Don't auto-open browser
55
+ -h, --help Show this help
56
+ `);
57
+ process.exit(0);
58
+ }
59
+ }
60
+ return opts;
61
+ }
62
+
63
+ // ─── Data Loading (ported from orch-dashboard.cjs) ──────────────────────────
64
+
65
+ function loadBatchState() {
66
+ try {
67
+ const raw = fs.readFileSync(BATCH_STATE_PATH, "utf-8");
68
+ return JSON.parse(raw);
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ function resolveTaskFolder(task, state) {
75
+ if (!task || !task.taskFolder) return null;
76
+ const laneNum = task.laneNumber;
77
+ const lane = (state?.lanes || []).find((l) => l.laneNumber === laneNum);
78
+ if (!lane || !lane.worktreePath) return task.taskFolder;
79
+ const taskFolderAbs = path.resolve(task.taskFolder);
80
+ const repoRootAbs = path.resolve(REPO_ROOT);
81
+ const rel = path.relative(repoRootAbs, taskFolderAbs);
82
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return task.taskFolder;
83
+ return path.join(lane.worktreePath, rel);
84
+ }
85
+
86
+ function parseStatusMd(taskFolder) {
87
+ const candidates = [taskFolder];
88
+ const taskId = path.basename(taskFolder);
89
+ const archiveBase = taskFolder.replace(/[/\\]tasks[/\\][^/\\]+$/, "/tasks/archive/" + taskId);
90
+ if (archiveBase !== taskFolder) candidates.push(archiveBase);
91
+
92
+ for (const folder of candidates) {
93
+ const statusPath = path.join(folder, "STATUS.md");
94
+ try {
95
+ const content = fs.readFileSync(statusPath, "utf-8");
96
+ const stepMatch = content.match(/\*\*Current Step:\*\*\s*(.+)/);
97
+ const statusMatch = content.match(/\*\*Status:\*\*\s*(.+)/);
98
+ const iterMatch = content.match(/\*\*Iteration:\*\*\s*(\d+)/);
99
+ const reviewMatch = content.match(/\*\*Review Counter:\*\*\s*(\d+)/);
100
+ const checked = (content.match(/- \[x\]/gi) || []).length;
101
+ const unchecked = (content.match(/- \[ \]/g) || []).length;
102
+ const total = checked + unchecked;
103
+ return {
104
+ currentStep: stepMatch ? stepMatch[1].trim() : "Unknown",
105
+ status: statusMatch ? statusMatch[1].trim() : "Unknown",
106
+ iteration: iterMatch ? parseInt(iterMatch[1]) : 0,
107
+ reviews: reviewMatch ? parseInt(reviewMatch[1]) : 0,
108
+ checked,
109
+ total,
110
+ progress: total > 0 ? Math.round((checked / total) * 100) : 0,
111
+ };
112
+ } catch {
113
+ continue;
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
119
+ function getTmuxSessions() {
120
+ try {
121
+ const output = execFileSync('tmux list-sessions -F "#{session_name}"', {
122
+ encoding: "utf-8",
123
+ timeout: 5000,
124
+ shell: true,
125
+ stdio: ["ignore", "pipe", "ignore"],
126
+ }).trim();
127
+ return output ? output.split("\n").map((s) => s.trim()).filter(Boolean) : [];
128
+ } catch {
129
+ return [];
130
+ }
131
+ }
132
+
133
+ function checkDoneFile(taskFolder) {
134
+ const candidates = [taskFolder];
135
+ const taskId = path.basename(taskFolder);
136
+ const archiveBase = taskFolder.replace(/[/\\]tasks[/\\][^/\\]+$/, "/tasks/archive/" + taskId);
137
+ if (archiveBase !== taskFolder) candidates.push(archiveBase);
138
+ for (const folder of candidates) {
139
+ if (fs.existsSync(path.join(folder, ".DONE"))) return true;
140
+ }
141
+ return false;
142
+ }
143
+
144
+ /** Read lane state sidecar JSON files written by the task-runner. */
145
+ function loadLaneStates() {
146
+ const piDir = path.join(REPO_ROOT, ".pi");
147
+ const states = {};
148
+ try {
149
+ const files = fs.readdirSync(piDir).filter(f => f.startsWith("lane-state-") && f.endsWith(".json"));
150
+ for (const file of files) {
151
+ try {
152
+ const raw = fs.readFileSync(path.join(piDir, file), "utf-8").trim();
153
+ if (!raw) continue;
154
+ const data = JSON.parse(raw);
155
+ if (data.prefix) states[data.prefix] = data;
156
+ } catch { continue; }
157
+ }
158
+ } catch { /* .pi dir may not exist */ }
159
+ return states;
160
+ }
161
+
162
+ /** Build full dashboard state object for the frontend. */
163
+ function buildDashboardState() {
164
+ const state = loadBatchState();
165
+ const tmuxSessions = getTmuxSessions();
166
+ const laneStates = loadLaneStates();
167
+
168
+ if (!state) {
169
+ return { batch: null, tmuxSessions, laneStates: {}, timestamp: Date.now() };
170
+ }
171
+
172
+ const tasks = (state.tasks || []).map((task) => {
173
+ const effectiveFolder = resolveTaskFolder(task, state);
174
+ let statusData = null;
175
+ if (effectiveFolder) {
176
+ statusData = parseStatusMd(effectiveFolder);
177
+ }
178
+ if (!task.doneFileFound && effectiveFolder) {
179
+ task.doneFileFound = checkDoneFile(effectiveFolder);
180
+ }
181
+ return { ...task, statusData };
182
+ });
183
+
184
+ return {
185
+ laneStates,
186
+ batch: {
187
+ batchId: state.batchId,
188
+ phase: state.phase,
189
+ startedAt: state.startedAt,
190
+ updatedAt: state.updatedAt,
191
+ currentWaveIndex: state.currentWaveIndex || 0,
192
+ totalWaves: state.totalWaves || (state.wavePlan ? state.wavePlan.length : 0),
193
+ wavePlan: state.wavePlan || [],
194
+ lanes: state.lanes || [],
195
+ tasks,
196
+ mergeResults: state.mergeResults || [],
197
+ errors: state.errors || [],
198
+ lastError: state.lastError || null,
199
+ },
200
+ tmuxSessions,
201
+ timestamp: Date.now(),
202
+ };
203
+ }
204
+
205
+ // ─── Static File Serving ────────────────────────────────────────────────────
206
+
207
+ const MIME_TYPES = {
208
+ ".html": "text/html; charset=utf-8",
209
+ ".css": "text/css; charset=utf-8",
210
+ ".js": "application/javascript; charset=utf-8",
211
+ ".json": "application/json; charset=utf-8",
212
+ ".svg": "image/svg+xml",
213
+ ".png": "image/png",
214
+ ".ico": "image/x-icon",
215
+ };
216
+
217
+ function serveStatic(req, res) {
218
+ let filePath = new URL(req.url, "http://localhost").pathname;
219
+ if (filePath === "/") filePath = "/index.html";
220
+
221
+ const fullPath = path.join(PUBLIC_DIR, filePath);
222
+ // Prevent directory traversal
223
+ if (!fullPath.startsWith(PUBLIC_DIR)) {
224
+ res.writeHead(403);
225
+ res.end("Forbidden");
226
+ return;
227
+ }
228
+
229
+ const ext = path.extname(fullPath);
230
+ const contentType = MIME_TYPES[ext] || "application/octet-stream";
231
+
232
+ try {
233
+ const content = fs.readFileSync(fullPath);
234
+ res.writeHead(200, {
235
+ "Content-Type": contentType,
236
+ "Cache-Control": "no-cache, no-store, must-revalidate",
237
+ });
238
+ res.end(content);
239
+ } catch {
240
+ res.writeHead(404, { "Content-Type": "text/plain" });
241
+ res.end("Not Found");
242
+ }
243
+ }
244
+
245
+ // ─── SSE Stream ─────────────────────────────────────────────────────────────
246
+
247
+ const sseClients = new Set();
248
+
249
+ // ─── Pane Capture SSE ───────────────────────────────────────────────────
250
+
251
+ const paneClients = new Map(); // sessionName → Set<res>
252
+
253
+ function handlePaneSSE(req, res, sessionName) {
254
+ // Validate session name (alphanumeric, dashes, underscores only)
255
+ if (!/^[\w-]+$/.test(sessionName)) {
256
+ res.writeHead(400, { "Content-Type": "text/plain" });
257
+ res.end("Invalid session name");
258
+ return;
259
+ }
260
+
261
+ res.writeHead(200, {
262
+ "Content-Type": "text/event-stream",
263
+ "Cache-Control": "no-cache",
264
+ Connection: "keep-alive",
265
+ "Access-Control-Allow-Origin": "*",
266
+ });
267
+
268
+ if (!paneClients.has(sessionName)) {
269
+ paneClients.set(sessionName, new Set());
270
+ }
271
+ paneClients.get(sessionName).add(res);
272
+
273
+ // Send initial capture immediately
274
+ const initial = captureTmuxPane(sessionName);
275
+ if (initial !== null) {
276
+ res.write(`data: ${JSON.stringify({ output: initial, session: sessionName })}\n\n`);
277
+ } else {
278
+ res.write(`data: ${JSON.stringify({ error: "Session not found or not accessible", session: sessionName })}\n\n`);
279
+ }
280
+
281
+ req.on("close", () => {
282
+ const clients = paneClients.get(sessionName);
283
+ if (clients) {
284
+ clients.delete(res);
285
+ if (clients.size === 0) paneClients.delete(sessionName);
286
+ }
287
+ });
288
+ }
289
+
290
+ function captureTmuxPane(sessionName) {
291
+ try {
292
+ // Capture with ANSI escape sequences (-e), full scrollback visible area (-p)
293
+ const output = execFileSync(
294
+ `tmux capture-pane -t "${sessionName}" -p -e`,
295
+ { encoding: "utf-8", timeout: 3000, shell: true, stdio: ["ignore", "pipe", "ignore"] }
296
+ );
297
+ return output;
298
+ } catch {
299
+ return null;
300
+ }
301
+ }
302
+
303
+ function broadcastPaneCaptures() {
304
+ for (const [sessionName, clients] of paneClients) {
305
+ if (clients.size === 0) continue;
306
+ const output = captureTmuxPane(sessionName);
307
+ if (output === null) continue;
308
+ const payload = `data: ${JSON.stringify({ output, session: sessionName })}\n\n`;
309
+ for (const client of clients) {
310
+ try {
311
+ client.write(payload);
312
+ } catch {
313
+ clients.delete(client);
314
+ }
315
+ }
316
+ }
317
+ }
318
+
319
+ // ─── Conversation JSONL ─────────────────────────────────────────────────
320
+
321
+ function serveConversation(req, res, prefix) {
322
+ if (!/^[\w-]+$/.test(prefix)) {
323
+ res.writeHead(400, { "Content-Type": "text/plain" });
324
+ res.end("Invalid prefix");
325
+ return;
326
+ }
327
+
328
+ const filePath = path.join(REPO_ROOT, ".pi", `worker-conversation-${prefix}.jsonl`);
329
+ try {
330
+ const content = fs.readFileSync(filePath, "utf-8");
331
+ res.writeHead(200, {
332
+ "Content-Type": "application/x-ndjson",
333
+ "Access-Control-Allow-Origin": "*",
334
+ });
335
+ res.end(content);
336
+ } catch {
337
+ res.writeHead(200, {
338
+ "Content-Type": "application/x-ndjson",
339
+ "Access-Control-Allow-Origin": "*",
340
+ });
341
+ res.end(""); // empty — no conversation yet
342
+ }
343
+ }
344
+
345
+ // ─── Dashboard SSE ──────────────────────────────────────────────────────
346
+
347
+ function handleSSE(req, res) {
348
+ res.writeHead(200, {
349
+ "Content-Type": "text/event-stream",
350
+ "Cache-Control": "no-cache",
351
+ Connection: "keep-alive",
352
+ "Access-Control-Allow-Origin": "*",
353
+ });
354
+
355
+ // Send initial state immediately
356
+ const state = buildDashboardState();
357
+ res.write(`data: ${JSON.stringify(state)}\n\n`);
358
+
359
+ sseClients.add(res);
360
+ req.on("close", () => sseClients.delete(res));
361
+ }
362
+
363
+ function broadcastState() {
364
+ if (sseClients.size === 0) return;
365
+ const state = buildDashboardState();
366
+ const payload = `data: ${JSON.stringify(state)}\n\n`;
367
+ for (const client of sseClients) {
368
+ try {
369
+ client.write(payload);
370
+ } catch {
371
+ sseClients.delete(client);
372
+ }
373
+ }
374
+ }
375
+
376
+ // ─── Batch History API ──────────────────────────────────────────────────────
377
+
378
+ // BATCH_HISTORY_PATH is initialized in main() alongside REPO_ROOT.
379
+
380
+ function loadHistory() {
381
+ try {
382
+ if (!fs.existsSync(BATCH_HISTORY_PATH)) return [];
383
+ const raw = fs.readFileSync(BATCH_HISTORY_PATH, "utf-8");
384
+ return JSON.parse(raw);
385
+ } catch {
386
+ return [];
387
+ }
388
+ }
389
+
390
+ /** GET /api/history — return list of batch summaries (compact: no per-task detail). */
391
+ function serveHistory(req, res) {
392
+ const history = loadHistory();
393
+ // Return compact list for the dropdown (no per-task details)
394
+ const compact = history.map(h => ({
395
+ batchId: h.batchId,
396
+ status: h.status,
397
+ startedAt: h.startedAt,
398
+ endedAt: h.endedAt,
399
+ durationMs: h.durationMs,
400
+ totalWaves: h.totalWaves,
401
+ totalTasks: h.totalTasks,
402
+ succeededTasks: h.succeededTasks,
403
+ failedTasks: h.failedTasks,
404
+ tokens: h.tokens,
405
+ }));
406
+ res.writeHead(200, {
407
+ "Content-Type": "application/json",
408
+ "Access-Control-Allow-Origin": "*",
409
+ });
410
+ res.end(JSON.stringify(compact));
411
+ }
412
+
413
+ /** GET /api/history/:batchId — return full detail for one batch. */
414
+ function serveHistoryEntry(req, res, batchId) {
415
+ const history = loadHistory();
416
+ const entry = history.find(h => h.batchId === batchId);
417
+ if (!entry) {
418
+ res.writeHead(404, { "Content-Type": "application/json" });
419
+ res.end(JSON.stringify({ error: "Batch not found" }));
420
+ return;
421
+ }
422
+ res.writeHead(200, {
423
+ "Content-Type": "application/json",
424
+ "Access-Control-Allow-Origin": "*",
425
+ });
426
+ res.end(JSON.stringify(entry));
427
+ }
428
+
429
+ /** GET /api/status-md/:taskId — return raw STATUS.md content for a task. */
430
+ function serveStatusMd(req, res, taskId) {
431
+ if (!/^[\w-]+$/.test(taskId)) {
432
+ res.writeHead(400, { "Content-Type": "text/plain" });
433
+ res.end("Invalid task ID");
434
+ return;
435
+ }
436
+
437
+ const state = loadBatchState();
438
+ if (!state) {
439
+ res.writeHead(404, { "Content-Type": "application/json" });
440
+ res.end(JSON.stringify({ error: "No batch state" }));
441
+ return;
442
+ }
443
+
444
+ const task = (state.tasks || []).find(t => t.taskId === taskId);
445
+ if (!task) {
446
+ res.writeHead(404, { "Content-Type": "application/json" });
447
+ res.end(JSON.stringify({ error: "Task not found" }));
448
+ return;
449
+ }
450
+
451
+ const effectiveFolder = resolveTaskFolder(task, state);
452
+ if (!effectiveFolder) {
453
+ res.writeHead(404, { "Content-Type": "application/json" });
454
+ res.end(JSON.stringify({ error: "Cannot resolve task folder" }));
455
+ return;
456
+ }
457
+
458
+ // Try effective folder, then archive
459
+ const candidates = [effectiveFolder];
460
+ const archiveBase = effectiveFolder.replace(/[/\\]tasks[/\\][^/\\]+$/, "/tasks/archive/" + taskId);
461
+ if (archiveBase !== effectiveFolder) candidates.push(archiveBase);
462
+
463
+ for (const folder of candidates) {
464
+ const statusPath = path.join(folder, "STATUS.md");
465
+ try {
466
+ const content = fs.readFileSync(statusPath, "utf-8");
467
+ res.writeHead(200, {
468
+ "Content-Type": "text/plain; charset=utf-8",
469
+ "Access-Control-Allow-Origin": "*",
470
+ });
471
+ res.end(content);
472
+ return;
473
+ } catch { continue; }
474
+ }
475
+
476
+ res.writeHead(404, { "Content-Type": "application/json" });
477
+ res.end(JSON.stringify({ error: "STATUS.md not found" }));
478
+ }
479
+
480
+ // ─── HTTP Server ────────────────────────────────────────────────────────────
481
+
482
+ function createServer() {
483
+ const server = http.createServer((req, res) => {
484
+ const pathname = new URL(req.url, "http://localhost").pathname;
485
+
486
+ if (pathname === "/api/stream" && req.method === "GET") {
487
+ handleSSE(req, res);
488
+ } else if (pathname.startsWith("/api/pane/") && req.method === "GET") {
489
+ const sessionName = pathname.slice("/api/pane/".length);
490
+ handlePaneSSE(req, res, sessionName);
491
+ } else if (pathname.startsWith("/api/conversation/") && req.method === "GET") {
492
+ const prefix = pathname.slice("/api/conversation/".length);
493
+ serveConversation(req, res, prefix);
494
+ } else if (pathname === "/api/state" && req.method === "GET") {
495
+ const state = buildDashboardState();
496
+ res.writeHead(200, {
497
+ "Content-Type": "application/json",
498
+ "Access-Control-Allow-Origin": "*",
499
+ });
500
+ res.end(JSON.stringify(state));
501
+ } else if (pathname === "/api/history" && req.method === "GET") {
502
+ serveHistory(req, res);
503
+ } else if (pathname.startsWith("/api/history/") && req.method === "GET") {
504
+ const batchId = decodeURIComponent(pathname.slice("/api/history/".length));
505
+ serveHistoryEntry(req, res, batchId);
506
+ } else if (pathname.startsWith("/api/status-md/") && req.method === "GET") {
507
+ const taskId = decodeURIComponent(pathname.slice("/api/status-md/".length));
508
+ serveStatusMd(req, res, taskId);
509
+ } else {
510
+ serveStatic(req, res);
511
+ }
512
+ });
513
+
514
+ return server;
515
+ }
516
+
517
+ // ─── Browser Auto-Open ─────────────────────────────────────────────────────
518
+
519
+ function openBrowser(url) {
520
+ const cmd = process.platform === "win32" ? "start"
521
+ : process.platform === "darwin" ? "open" : "xdg-open";
522
+ exec(`${cmd} ${url}`, () => {}); // fire-and-forget
523
+ }
524
+
525
+ // ─── Main ───────────────────────────────────────────────────────────────────
526
+
527
+ /** Try to listen on a port. Resolves with the port on success, rejects on EADDRINUSE. */
528
+ function tryListen(server, port) {
529
+ return new Promise((resolve, reject) => {
530
+ const onError = (err) => {
531
+ server.removeListener("listening", onListening);
532
+ reject(err);
533
+ };
534
+ const onListening = () => {
535
+ server.removeListener("error", onError);
536
+ resolve(port);
537
+ };
538
+ server.once("error", onError);
539
+ server.once("listening", onListening);
540
+ server.listen(port);
541
+ });
542
+ }
543
+
544
+ /** Find an available port starting from `start`, trying up to MAX_PORT_ATTEMPTS. */
545
+ async function findPort(server, start, explicit) {
546
+ // If the user explicitly passed --port, only try that one
547
+ if (explicit) {
548
+ try {
549
+ return await tryListen(server, start);
550
+ } catch (err) {
551
+ if (err.code === "EADDRINUSE") {
552
+ console.error(`\n Port ${start} is already in use.`);
553
+ console.error(` Try: taskplane dashboard --port ${start + 1}\n`);
554
+ process.exit(1);
555
+ }
556
+ throw err;
557
+ }
558
+ }
559
+ // Auto-scan for an available port
560
+ for (let port = start; port < start + MAX_PORT_ATTEMPTS; port++) {
561
+ try {
562
+ return await tryListen(server, port);
563
+ } catch (err) {
564
+ if (err.code === "EADDRINUSE") {
565
+ // Close the server so we can retry on the next port
566
+ server.close();
567
+ server = createServer();
568
+ continue;
569
+ }
570
+ throw err;
571
+ }
572
+ }
573
+ console.error(`\n No available port found in range ${start}-${start + MAX_PORT_ATTEMPTS - 1}.\n`);
574
+ process.exit(1);
575
+ }
576
+
577
+ async function main() {
578
+ const opts = parseArgs();
579
+
580
+ // Resolve project root: --root flag > cwd
581
+ REPO_ROOT = path.resolve(opts.root || process.cwd());
582
+ BATCH_STATE_PATH = path.join(REPO_ROOT, ".pi", "batch-state.json");
583
+ BATCH_HISTORY_PATH = path.join(REPO_ROOT, ".pi", "batch-history.json");
584
+
585
+ const server = createServer();
586
+ const explicitPort = process.argv.slice(2).includes("--port");
587
+ const port = await findPort(server, opts.port, explicitPort);
588
+
589
+ console.log(`\n Orchestrator Dashboard → http://localhost:${port}\n`);
590
+
591
+ // Broadcast state to all SSE clients on interval
592
+ const pollTimer = setInterval(broadcastState, POLL_INTERVAL);
593
+
594
+ // Broadcast pane captures more frequently (1s) for smooth terminal viewing
595
+ const paneTimer = setInterval(broadcastPaneCaptures, 1000);
596
+
597
+ // Also watch batch-state.json for immediate push on change
598
+ try {
599
+ const batchDir = path.dirname(BATCH_STATE_PATH);
600
+ if (fs.existsSync(batchDir)) {
601
+ let debounce = null;
602
+ fs.watch(batchDir, (eventType, filename) => {
603
+ if (filename === "batch-state.json") {
604
+ clearTimeout(debounce);
605
+ debounce = setTimeout(broadcastState, 200);
606
+ }
607
+ });
608
+ }
609
+ } catch {
610
+ // fs.watch not supported — polling is sufficient
611
+ }
612
+
613
+ // Auto-open browser
614
+ if (opts.open) {
615
+ setTimeout(() => openBrowser(`http://localhost:${port}`), 500);
616
+ }
617
+
618
+ // Graceful shutdown
619
+ function cleanup() {
620
+ clearInterval(pollTimer);
621
+ clearInterval(paneTimer);
622
+ for (const [, clients] of paneClients) {
623
+ for (const client of clients) {
624
+ try { client.end(); } catch {}
625
+ }
626
+ }
627
+ for (const client of sseClients) {
628
+ try { client.end(); } catch {}
629
+ }
630
+ server.close();
631
+ process.exit(0);
632
+ }
633
+
634
+ process.on("SIGINT", cleanup);
635
+ process.on("SIGTERM", cleanup);
636
+ }
637
+
638
+ main();