pi-mega-compact 0.8.7 → 0.8.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/dist/extensions/dashboard-server/api-contracts/core.js +7 -0
  2. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +104 -0
  3. package/dist/extensions/dashboard-server/api-contracts/game.js +9 -0
  4. package/dist/extensions/dashboard-server/api-contracts/index.js +9 -0
  5. package/dist/extensions/dashboard-server/api-contracts/infrastructure.js +10 -0
  6. package/dist/extensions/dashboard-server/api-contracts/multi-repo.js +9 -0
  7. package/dist/extensions/dashboard-server/api-contracts/snapshot.js +8 -0
  8. package/dist/extensions/dashboard-server/api-contracts.js +8 -0
  9. package/dist/extensions/dashboard-server/api-contracts.test.js +869 -0
  10. package/dist/extensions/dashboard-server/auth.js +18 -0
  11. package/dist/extensions/dashboard-server/helpers.js +37 -0
  12. package/dist/extensions/dashboard-server/html/all-repos-tab.js +26 -0
  13. package/dist/extensions/dashboard-server/html/body-open.js +23 -0
  14. package/dist/extensions/dashboard-server/html/current-repo-tab.js +130 -0
  15. package/dist/extensions/dashboard-server/html/head-open.js +16 -0
  16. package/dist/extensions/dashboard-server/html/high-score-tab.js +25 -0
  17. package/dist/extensions/dashboard-server/html/repo-detail-modal.js +26 -0
  18. package/dist/extensions/dashboard-server/html/script.js +259 -0
  19. package/dist/extensions/dashboard-server/html/styles.js +103 -0
  20. package/dist/extensions/dashboard-server/html/summary-tab.js +19 -0
  21. package/dist/extensions/dashboard-server/html-template.js +41 -0
  22. package/dist/extensions/dashboard-server/html.js +46 -1
  23. package/dist/extensions/dashboard-server/perf-server.test.js +80 -0
  24. package/dist/extensions/dashboard-server/server.js +269 -30
  25. package/dist/extensions/dashboard-server/tailscale.js +8 -0
  26. package/dist/extensions/dashboard-server/types.js +4 -0
  27. package/dist/extensions/mega-dashboard.js +9 -0
  28. package/dist/extensions/mega-events/perf-handler.js +71 -0
  29. package/dist/extensions/mega-events/register.js +2 -0
  30. package/dist/extensions/mega-events.js +1 -0
  31. package/dist/extensions/mega-runtime/state.js +59 -1
  32. package/dist/src/store/sqlite/connection.js +35 -0
  33. package/dist/src/store/sqlite/index-store.js +167 -0
  34. package/dist/src/store/sqlite/memory.js +54 -0
  35. package/dist/src/store/sqlite/minhash-lsh.js +47 -0
  36. package/dist/src/store/sqlite/perf-samples.js +81 -0
  37. package/dist/src/store/sqlite/perf-samples.test.js +54 -0
  38. package/dist/src/store/sqlite/schema.js +14 -0
  39. package/dist/src/store/sqlite/sessions.js +39 -0
  40. package/dist/src/store/sqlite/transaction.js +19 -0
  41. package/dist/src/store/sqlite.js +1 -0
  42. package/dist/src/vectorStore/add.js +260 -0
  43. package/dist/src/vectorStore/dedup.js +52 -0
  44. package/dist/src/vectorStore/index.js +10 -0
  45. package/dist/src/vectorStore/queries.js +83 -0
  46. package/dist/src/vectorStore/search.js +95 -0
  47. package/dist/src/vectorStore/session.js +19 -0
  48. package/dist/src/vectorStore/store.js +105 -0
  49. package/dist/src/vectorStore/types.js +6 -0
  50. package/dist/src/vectorStore/utils.js +23 -0
  51. package/extensions/dashboard-client/package-lock.json +1771 -0
  52. package/extensions/dashboard-client/package.json +27 -0
  53. package/extensions/dashboard-client/src/App.tsx +82 -0
  54. package/extensions/dashboard-client/src/api/client.ts +145 -0
  55. package/extensions/dashboard-client/src/components/CacheStatusPerModel.tsx +44 -0
  56. package/extensions/dashboard-client/src/components/CompressionCard.tsx +61 -0
  57. package/extensions/dashboard-client/src/components/ContextGauge.tsx +59 -0
  58. package/extensions/dashboard-client/src/components/DataSafetyCard.tsx +49 -0
  59. package/extensions/dashboard-client/src/components/ErrorBoundary.tsx +53 -0
  60. package/extensions/dashboard-client/src/components/EventCategoryFilter.tsx +16 -0
  61. package/extensions/dashboard-client/src/components/EventStream.tsx +152 -0
  62. package/extensions/dashboard-client/src/components/LoadingSpinner.tsx +7 -0
  63. package/extensions/dashboard-client/src/components/MemoryStatusCard.tsx +24 -0
  64. package/extensions/dashboard-client/src/components/ModelBadge.tsx +41 -0
  65. package/extensions/dashboard-client/src/components/PerfChart.tsx +160 -0
  66. package/extensions/dashboard-client/src/components/RepoDetailModal.tsx +126 -0
  67. package/extensions/dashboard-client/src/components/RepoTable.tsx +150 -0
  68. package/extensions/dashboard-client/src/components/SessionInfo.tsx +65 -0
  69. package/extensions/dashboard-client/src/components/SummaryTiles.tsx +52 -0
  70. package/extensions/dashboard-client/src/components/TabBar.tsx +34 -0
  71. package/extensions/dashboard-client/src/components/TriggerStatus.tsx +62 -0
  72. package/extensions/dashboard-client/src/hooks/useApi.ts +77 -0
  73. package/extensions/dashboard-client/src/hooks/useSSE.ts +87 -0
  74. package/extensions/dashboard-client/src/index.html +12 -0
  75. package/extensions/dashboard-client/src/main.tsx +23 -0
  76. package/extensions/dashboard-client/src/styles/base.css +121 -0
  77. package/extensions/dashboard-client/src/styles/overview-events.css +318 -0
  78. package/extensions/dashboard-client/src/styles/repos-metrics.css +307 -0
  79. package/extensions/dashboard-client/src/tabs/ConfigTab.tsx +16 -0
  80. package/extensions/dashboard-client/src/tabs/EventsTab.tsx +37 -0
  81. package/extensions/dashboard-client/src/tabs/MetricsTab.tsx +49 -0
  82. package/extensions/dashboard-client/src/tabs/OverviewTab.tsx +80 -0
  83. package/extensions/dashboard-client/src/tabs/ReposTab.tsx +59 -0
  84. package/extensions/dashboard-client/tsconfig.json +28 -0
  85. package/extensions/dashboard-client/vite.config.ts +38 -0
  86. package/extensions/dashboard-server/api-contracts/core.ts +302 -0
  87. package/extensions/dashboard-server/api-contracts/endpoints.ts +476 -0
  88. package/extensions/dashboard-server/api-contracts/game.ts +145 -0
  89. package/extensions/dashboard-server/api-contracts/index.ts +159 -0
  90. package/extensions/dashboard-server/api-contracts/infrastructure.ts +465 -0
  91. package/extensions/dashboard-server/api-contracts/multi-repo.ts +328 -0
  92. package/extensions/dashboard-server/api-contracts/snapshot.ts +386 -0
  93. package/extensions/dashboard-server/api-contracts.test.ts +1050 -0
  94. package/extensions/dashboard-server/api-contracts.ts +96 -0
  95. package/extensions/dashboard-server/auth.ts +20 -0
  96. package/extensions/dashboard-server/html.ts +46 -1
  97. package/extensions/dashboard-server/perf-server.test.ts +101 -0
  98. package/extensions/dashboard-server/server.ts +862 -503
  99. package/extensions/dashboard-server/tailscale.ts +7 -0
  100. package/extensions/dashboard-server/types.ts +23 -114
  101. package/extensions/mega-dashboard.ts +17 -0
  102. package/extensions/mega-events/perf-handler.ts +113 -0
  103. package/extensions/mega-events/register.ts +2 -0
  104. package/extensions/mega-events.ts +1 -0
  105. package/extensions/mega-runtime/state.ts +57 -0
  106. package/package.json +2 -1
  107. package/src/store/sqlite/perf-samples.test.ts +65 -0
  108. package/src/store/sqlite/perf-samples.ts +125 -0
  109. package/src/store/sqlite/schema.ts +14 -0
  110. package/src/store/sqlite.ts +1 -0
@@ -2,8 +2,19 @@
2
2
  * dashboard-server/server.ts — HTTP server creation + launch + CLI entry point.
3
3
  */
4
4
 
5
- import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
6
- import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs";
5
+ import {
6
+ createServer,
7
+ type IncomingMessage,
8
+ type ServerResponse,
9
+ } from "node:http";
10
+ import {
11
+ existsSync,
12
+ mkdirSync,
13
+ readFileSync,
14
+ unlinkSync,
15
+ watch,
16
+ writeFileSync,
17
+ } from "node:fs";
7
18
  import { join, dirname } from "node:path";
8
19
  import { fileURLToPath } from "node:url";
9
20
  import { createRequire } from "node:module";
@@ -16,498 +27,846 @@ import { ACTIVE_WINDOW_SEC } from "./types.js";
16
27
  import type { IndexIndex, Snapshot, LiveSnapshot } from "./types.js";
17
28
  import type { GameMetric } from "../../src/game/scoring.js";
18
29
 
19
- export async function launchDashboardServer(stateDir: string): Promise<{ port: number; url: string }> {
20
- // Our own package version — exposed at /api/version so the launcher can
21
- // detect a stale server (started by an older build) and replace it on
22
- // upgrade instead of reuse it.
23
- let SERVER_VERSION = "0.0.0";
24
- try {
25
- // Since v0.7.9 (8821ef3) dashboard-server.js lives at
26
- // <pkg>/dist/extensions/dashboard-server/, so package.json is THREE levels
27
- // up. Keep the two- and one-level-up candidates as fallbacks for flatter
28
- // dev-checkout layouts. Guard each candidate so a missing file is skipped.
29
- const here = dirname(fileURLToPath(import.meta.url));
30
- const candidates = [
31
- join(here, "..", "..", "..", "package.json"),
32
- join(here, "..", "..", "package.json"),
33
- join(here, "..", "package.json"),
34
- ];
35
- for (const p of candidates) {
36
- if (!existsSync(p)) continue;
37
- const pkg = JSON.parse(readFileSync(p, "utf-8"));
38
- if (pkg.version) { SERVER_VERSION = pkg.version; setDashboardServerVersion(pkg.version); break; }
39
- }
40
- } catch { /* non-fatal */ }
41
-
42
- // Lazy-loaded via require so the dashboard stays cheap to boot and we don't
43
- // need a top-level await in the handler.
44
- const driftReq = createRequire(import.meta.url);
45
- const detectCrossRepoDrift = (idxDir: string) =>
46
- (driftReq("../../src/driftDetection.js") as typeof import("../../src/driftDetection.js"))
47
- .detectCrossRepoDrift(idxDir);
48
- const portFile = join(stateDir, "port.pid");
49
- const snapshotPath = join(stateDir, "dashboard.json");
50
- const eventsPath = join(stateDir, "events.log");
51
- setLogPath(join(stateDir, "dashboard.log"));
52
- log("launch invoked", { stateDir });
53
-
54
- // ── Existing server? ───────────────────────────────────────────────────────
55
- // A stale port.pid pointing at a dead/competing process is the classic cause
56
- // of "dashboard failed to start" — we return a port that is NOT actually
57
- // serving. Probe for a live server on that port first; only reuse the marker
58
- // when something real answers /api/version. Otherwise drop it and start fresh.
59
- if (existsSync(portFile)) {
60
- try {
61
- const info = JSON.parse(readFileSync(portFile, "utf-8"));
62
- if (info && info.port) {
63
- let live = false;
64
- try {
65
- const probe = await fetch(`http://localhost:${info.port}/api/version`, { signal: AbortSignal.timeout(800) }); // guardrails-allow PREVENT-PI-004: optional localhost dashboard server probe (loopback-only)
66
- live = probe.ok;
67
- } catch {
68
- live = false;
69
- }
70
- if (live) {
71
- log("reusing live server from port.pid", { port: info.port });
72
- return { port: info.port, url: `http://localhost:${info.port}` }; // guardrails-allow PREVENT-PI-004: localhost dashboard URL (loopback-only)
73
- }
74
- log("port.pid present but no live server — treating as stale", { port: info.port });
75
- }
76
- } catch {
77
- log("port.pid unparseable — treating as stale");
78
- }
79
- // stale file, remove so the fresh bind does not collide with a lingering
80
- // process that still holds the port
81
- try { unlinkSync(portFile); } catch { /* ignore */ }
82
- }
83
-
84
- // ── New server ────────────────────────────────────────────────────────────
85
- mkdirSync(stateDir, { recursive: true });
86
-
87
- let eventOffset = 0;
88
-
89
- // Overlay the live current-repo snapshot (snapshot.json, rewritten every
90
- // context event) onto its registry row so the All-repos / Summary views stay
91
- // in sync with the live menu bar + Current-repo card in real time. The
92
- // registry (index.sqlite) is only written on repo-switch (bindRepo), so
93
- // without this the current repo's row freezes between switches. Read-only —
94
- // no extra writes to index.sqlite. Matched by stateDir, which equals the
95
- // value this server was launched with (runtime.currentStateDir).
96
- function overlayCurrentRepo(idx: IndexIndex | null): void {
97
- if (!idx || !idx.repos.length) return;
98
- let snap: Snapshot | null = null;
99
- try { snap = readSnapshot(snapshotPath); } catch { return; }
100
- if (!snap || !snap.repo) return;
101
- const cur = idx.repos.find((r) => r.stateDir === stateDir);
102
- if (!cur) return;
103
- const prevSaved = cur.tokensSaved;
104
- const prevCp = cur.checkpointCount;
105
- const prevBytes = cur.compressedOriginalBytes;
106
- const comp = snap.compression?.repo;
107
- const liveSaved = comp ? comp.tokensFreed : (snap.repo.tokensSaved ?? prevSaved);
108
- const liveCp = snap.repo.checkpointCount ?? prevCp;
109
- const liveBytes = snap.integrity?.compressedOriginalBytes ?? prevBytes;
110
- cur.tokensSaved = liveSaved;
111
- cur.checkpointCount = liveCp;
112
- cur.compressedOriginalBytes = liveBytes;
113
- if (idx.summary) {
114
- idx.summary.totalTokensSaved += liveSaved - prevSaved;
115
- idx.summary.totalCheckpoints += liveCp - prevCp;
116
- idx.summary.totalCompressedOriginalBytes += liveBytes - prevBytes;
117
- }
118
- idx.updatedAt = snap.updatedAt ?? idx.updatedAt;
119
- }
120
-
121
- const server = createServer((req: IncomingMessage, res: ServerResponse) => {
122
- // guardrails-allow PREVENT-PI-004: optional, user-triggered /dashboard localhost server (loopback-only) CORS open for local browser access
123
- // CORS for local access
124
- res.setHeader("Access-Control-Allow-Origin", "*");
125
- res.setHeader("Access-Control-Allow-Methods", "GET, PUT, OPTIONS");
126
- res.setHeader("Access-Control-Allow-Headers", "Content-Type");
127
-
128
- if (req.method === "OPTIONS") {
129
- res.writeHead(204);
130
- res.end();
131
- return;
132
- }
133
-
134
- if (req.url === "/" || req.url === "/index.html") {
135
- const tier = readSnapshot(snapshotPath).tier;
136
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
137
- res.end(dashboardHtml(tier));
138
- return;
139
- }
140
-
141
- if (req.url === "/api/snapshot") {
142
- const snap = readSnapshot(snapshotPath);
143
- res.writeHead(200, { "Content-Type": "application/json" });
144
- res.end(JSON.stringify(snap));
145
- return;
146
- }
147
-
148
- // Server version lets the /dashboard launcher detect a stale server from
149
- // an older build and replace it on upgrade rather than reuse it.
150
- if (req.url === "/api/version") {
151
- res.writeHead(200, { "Content-Type": "application/json" });
152
- res.end(JSON.stringify({ version: SERVER_VERSION }));
153
- return;
154
- }
155
-
156
- // Multi-repo aggregate (Phase 5b): the machine-wide repo registry read
157
- // directly from SQLite (index.sqlite). Lets one dashboard show every repo's
158
- // checkpoints, tokens saved, and active model. Read-only.
159
- if (req.url === "/api/index") {
160
- const idx = readIndex();
161
- if (idx) overlayCurrentRepo(idx);
162
- res.writeHead(200, { "Content-Type": "application/json" });
163
- res.end(JSON.stringify(idx ?? { updatedAt: null, summary: null, repos: [] }));
164
- return;
165
- }
166
-
167
- // /api/repos — registry list. Optional `?active=24h` filters to repos
168
- // seen within the last N hours (default: all). The dashboard uses this to
169
- // drive its "active vs archived" badge without refetching /api/index.
170
- if (req.url?.startsWith("/api/repos")) {
171
- const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
172
- const activeParam = url.searchParams.get("active");
173
- const idx = readIndex();
174
- if (idx) overlayCurrentRepo(idx);
175
- let repos = idx?.repos ?? [];
176
- if (activeParam) {
177
- const m = /^(\d+)h$/.exec(activeParam);
178
- if (m) {
179
- const cutoffSec = Math.floor(Date.now() / 1000) - Number(m[1]) * 3600;
180
- repos = repos.filter((r) => (r.lastSeen ?? 0) >= cutoffSec);
181
- }
182
- }
183
- res.writeHead(200, { "Content-Type": "application/json" });
184
- res.end(JSON.stringify({ updatedAt: idx?.updatedAt ?? null, repos, count: repos.length }));
185
- return;
186
- }
187
-
188
- // /api/summary — header tiles without the full repo list (keeps payload
189
- // small for embed scenarios). activeRepos mirrors the /api/repos?active=24h
190
- // count so the dashboard can render the active badge alongside totals.
191
- if (req.url?.startsWith("/api/summary")) {
192
- const idx = readIndex();
193
- if (idx) overlayCurrentRepo(idx);
194
- const repos = idx?.repos ?? [];
195
- const cutoffSec = Math.floor(Date.now() / 1000) - 24 * 3600;
196
- const activeRepos = repos.filter((r) => (r.lastSeen ?? 0) >= cutoffSec).length;
197
- res.writeHead(200, { "Content-Type": "application/json" });
198
- res.end(JSON.stringify({
199
- updatedAt: idx?.updatedAt ?? null,
200
- summary: idx?.summary ?? null,
201
- activeRepos,
202
- totalRepos: repos.length,
203
- }));
204
- return;
205
- }
206
-
207
- // /api/drift R4: cross-repo drift report over repo_registry. Flags stale
208
- // repos (>30d idle), compaction lag (active but >24h since last
209
- // compaction), and recent model churn. Read-only.
210
- if (req.url?.startsWith("/api/drift")) {
211
- const report = detectCrossRepoDrift(getIndexDir());
212
- res.writeHead(200, { "Content-Type": "application/json" });
213
- res.end(JSON.stringify(report));
214
- return;
215
- }
216
-
217
- if (req.url === "/api/servers") {
218
- try {
219
- const idx = readIndex();
220
- const nowSec = Math.floor(Date.now() / 1000);
221
- const servers = (idx?.repos ?? []).filter((r) => (r.lastSeen ?? 0) >= nowSec - ACTIVE_WINDOW_SEC).map((r) => {
222
- const out: Record<string, unknown> = { repoRoot: r.repoRoot, displayName: r.displayName, model: r.modelName, provider: r.providerName, lastSeen: r.lastSeen, lastCompactedAt: r.lastCompactedAt };
223
- try { const p = join(r.stateDir, "dashboard.json"); if (existsSync(p)) { const snap = JSON.parse(readFileSync(p, "utf-8")) as LiveSnapshot; out.tier = snap.tier ?? null; out.contextPct = (snap.context && snap.context.percent != null) ? snap.context.percent : null; out.state = (snap.session && snap.session.state) || null; out.cacheHits = snap.cacheHits ?? null; out.compacts = snap.compacts ?? null; out.timeSaved = snap.timeSaved ?? null; out.updatedAt = snap.updatedAt ?? null; } } catch { /* best-effort */ }
224
- return out;
225
- }).sort((a, b) => (b.lastSeen as number) - (a.lastSeen as number));
226
- res.writeHead(200, { "Content-Type": "application/json" });
227
- res.end(JSON.stringify({ updatedAt: new Date().toISOString(), servers }));
228
- } catch {
229
- res.writeHead(500, { "Content-Type": "application/json" });
230
- res.end(JSON.stringify({ error: "servers_unavailable" }));
231
- }
232
- return;
233
- }
234
-
235
- if (req.url === "/api/events") {
236
- res.writeHead(200, {
237
- "Content-Type": "text/event-stream",
238
- "Cache-Control": "no-cache",
239
- "Connection": "keep-alive",
240
- });
241
-
242
- // Drain existing events so the client starts with history
243
- const { data: existing, offset: initialOffset } = readFrom(eventsPath, 0);
244
- eventOffset = initialOffset;
245
- const lines = existing.split("\n").filter((l: string) => l.trim());
246
- for (const line of lines) {
247
- res.write(`data: ${line}\n\n`);
248
- }
249
-
250
- // Tail new events via fs.watch (coalesced with 100ms debounce)
251
- let watchTimer: ReturnType<typeof setTimeout> | null = null;
252
- const onWatch = () => {
253
- if (watchTimer) return;
254
- watchTimer = setTimeout(() => {
255
- watchTimer = null;
256
- const { data, offset } = readFrom(eventsPath, eventOffset);
257
- eventOffset = offset;
258
- const newLines = data.split("\n").filter((l: string) => l.trim());
259
- for (const line of newLines) {
260
- res.write(`data: ${line}\n\n`);
261
- }
262
- }, 100);
263
- };
264
-
265
- // Set up file watching: if file exists, watch it directly;
266
- // otherwise poll for creation every 1s then switch to fs.watch.
267
- let watcher: ReturnType<typeof watch> | null = null;
268
- let pollInterval: ReturnType<typeof setInterval> | null = null;
269
-
270
- function startFileWatch(): void {
271
- try {
272
- watcher = watch(eventsPath, onWatch);
273
- } catch { /* give up */ }
274
- }
275
-
276
- if (existsSync(eventsPath)) {
277
- startFileWatch();
278
- } else {
279
- pollInterval = setInterval(() => {
280
- if (existsSync(eventsPath)) {
281
- if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
282
- startFileWatch();
283
- }
284
- }, 1000);
285
- }
286
-
287
- req.on("close", () => {
288
- if (watchTimer) clearTimeout(watchTimer);
289
- if (pollInterval) clearInterval(pollInterval);
290
- watcher?.close();
291
- });
292
- return;
293
- }
294
-
295
- // /api/game-state S32 game-mode settings (game_mode_on / theme /
296
- // tui_display_mode). GET returns the current row; PUT applies a partial
297
- // patch (validated) and returns the post-write row. The dashboard server is
298
- // a detached child with no MegaRuntime ref, so it reads/writes the
299
- // game_state SQLite row directly; the in-process MegaRuntime picks up the
300
- // change via its fs.watch cache-eviction watcher. PREVENT-PI-004: loopback.
301
- if (req.url?.startsWith("/api/game-state")) {
302
- const gsReq = createRequire(import.meta.url);
303
- const { getGameState, setGameState } = gsReq("../../src/store/sqlite.js") as typeof import("../../src/store/sqlite.js");
304
- const { isValidTheme } = gsReq("../../src/config/themes.js") as typeof import("../../src/config/themes.js");
305
- if (req.method === "GET") {
306
- try {
307
- const gs = getGameState(stateDir); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
308
- res.writeHead(200, { "Content-Type": "application/json" });
309
- res.end(JSON.stringify(gs));
310
- } catch (e) {
311
- res.writeHead(500, { "Content-Type": "application/json" });
312
- res.end(JSON.stringify({ error: "game_state_unavailable", detail: String(e) }));
313
- }
314
- return;
315
- }
316
- if (req.method === "PUT") {
317
- // Read + parse the JSON body (capped — the patch is tiny). The handler
318
- // is sync, so drain the stream via data/end listeners then continue.
319
- let body = "";
320
- let tooBig = false;
321
- req.on("data", (chunk: Buffer) => { // guardrails-allow PREVENT-PI-004: loopback dashboard request body (local)
322
- if (body.length > 65536) { tooBig = true; return; }
323
- body += chunk.toString();
324
- });
325
- req.on("end", () => {
326
- if (tooBig) {
327
- res.writeHead(413, { "Content-Type": "application/json" });
328
- res.end(JSON.stringify({ error: "body_too_large" }));
329
- return;
330
- }
331
- let patch: Record<string, unknown> = {};
332
- try { patch = body ? JSON.parse(body) : {}; } catch {
333
- res.writeHead(400, { "Content-Type": "application/json" });
334
- res.end(JSON.stringify({ error: "invalid_json" }));
335
- return;
336
- }
337
- // Reject valid-but-non-object JSON (null/[]/42) — dereferencing
338
- // patch.game_mode_on would throw an unhandled TypeError inside this
339
- // 'end' listener and crash the detached server (audit P1: loopback DoS).
340
- if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
341
- res.writeHead(400, { "Content-Type": "application/json" });
342
- res.end(JSON.stringify({ error: "invalid_patch_object" }));
343
- return;
344
- }
345
- // Validate the patch fields (unknown keys ignored; invalid values -> 400).
346
- const clean: { game_mode_on?: boolean; theme?: string; tui_display_mode?: "full" | "minimal" } = {};
347
- let bad = false;
348
- if (patch.game_mode_on != null) {
349
- if (typeof patch.game_mode_on !== "boolean") bad = true;
350
- else clean.game_mode_on = patch.game_mode_on;
351
- }
352
- if (patch.theme != null) {
353
- if (typeof patch.theme !== "string" || !isValidTheme(patch.theme)) bad = true;
354
- else clean.theme = patch.theme;
355
- }
356
- if (patch.tui_display_mode != null) {
357
- if (patch.tui_display_mode !== "full" && patch.tui_display_mode !== "minimal") bad = true;
358
- else clean.tui_display_mode = patch.tui_display_mode;
359
- }
360
- if (bad) {
361
- res.writeHead(400, { "Content-Type": "application/json" });
362
- res.end(JSON.stringify({ error: "invalid_patch" }));
363
- return;
364
- }
365
- try {
366
- const gs = setGameState(clean, stateDir); // guardrails-allow PREVENT-PI-004: local SQLite write (loopback dashboard)
367
- res.writeHead(200, { "Content-Type": "application/json" });
368
- res.end(JSON.stringify(gs));
369
- } catch (e) {
370
- res.writeHead(500, { "Content-Type": "application/json" });
371
- res.end(JSON.stringify({ error: "game_state_write_failed", detail: String(e) }));
372
- }
373
- });
374
- return;
375
- }
376
- // Any other method on /api/game-state 405.
377
- res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
378
- res.end(JSON.stringify({ error: "method_not_allowed" }));
379
- return;
380
- }
381
-
382
- // /api/game-scores — S34 high-score leaderboards. GET returns the leaderboard
383
- // for a metric (?metric=<m>&limit=<n>). `metric` is validated against the
384
- // METRICS allow-list from src/game/scoring (re-exported via the sqlite barrel);
385
- // default limit 10, clamped to [1,100]. The dashboard server is a detached
386
- // child with no MegaRuntime ref, so it reads the game_scores SQLite table
387
- // directly. Unknown metric -> 400, non-GET -> 405. PREVENT-PI-004: loopback.
388
- if (req.url?.startsWith("/api/game-scores")) {
389
- const gsReq = createRequire(import.meta.url);
390
- const { leaderboard, METRICS } = gsReq("../../src/store/sqlite.js") as typeof import("../../src/store/sqlite.js");
391
- if (req.method !== "GET") {
392
- res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
393
- res.end(JSON.stringify({ error: "method_not_allowed" }));
394
- return;
395
- }
396
- try {
397
- const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
398
- const metricParam = url.searchParams.get("metric") ?? "cache";
399
- if (!(METRICS as readonly string[]).includes(metricParam)) {
400
- res.writeHead(400, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
401
- res.end(JSON.stringify({ error: "unknown_metric", metric: metricParam }));
402
- return;
403
- }
404
- const metric = metricParam as GameMetric; // validated against METRICS above
405
- let limit = Number(url.searchParams.get("limit") ?? "10");
406
- if (!Number.isFinite(limit) || limit <= 0) limit = 10;
407
- limit = Math.min(Math.max(limit, 1), 100); // clamp to [1,100]
408
- const rows = leaderboard(stateDir, metric, { limit }); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
409
- res.writeHead(200, { "Content-Type": "application/json" });
410
- res.end(JSON.stringify(rows));
411
- } catch (e) {
412
- res.writeHead(500, { "Content-Type": "application/json" });
413
- res.end(JSON.stringify({ error: "game_scores_unavailable", detail: String(e) }));
414
- }
415
- return;
416
- }
417
-
418
- // /api/achievements — S35 achievement tiles. GET returns the 9 seeded rows
419
- // {id,title,description,icon,hidden,unlocked_at}. The dashboard server is a
420
- // detached child with no MegaRuntime ref, so it reads game_achievements via
421
- // listAchievements(stateDir) directly. Non-GET -> 405. PREVENT-PI-004: loopback.
422
- if (req.url?.startsWith("/api/achievements")) {
423
- const achReq = createRequire(import.meta.url);
424
- const { listAchievements } = achReq("../../src/store/sqlite.js") as typeof import("../../src/store/sqlite.js");
425
- if (req.method !== "GET") {
426
- res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
427
- res.end(JSON.stringify({ error: "method_not_allowed" }));
428
- return;
429
- }
430
- try {
431
- const rows = listAchievements(stateDir); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
432
- res.writeHead(200, { "Content-Type": "application/json" });
433
- res.end(JSON.stringify(rows));
434
- } catch (e) {
435
- res.writeHead(500, { "Content-Type": "application/json" });
436
- res.end(JSON.stringify({ error: "achievements_unavailable", detail: String(e) }));
437
- }
438
- return;
439
- }
440
-
441
- // Fallback serve the dashboard
442
- const tier = readSnapshot(snapshotPath).tier;
443
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
444
- res.end(dashboardHtml(tier));
445
- });
446
-
447
- // Bind base + range are env-configurable so tests can use a private,
448
- // non-colliding range (parallel runs / leftover servers from killed runs
449
- // would otherwise EADDRINUSE on the machine-global 9320 range). Default
450
- // MEGACOMPACT_DASHBOARD_PORT=9320 (10-port range 9320–9329) preserves the
451
- // production behavior.
452
- const TARGET_PORT = Number(process.env.MEGACOMPACT_DASHBOARD_PORT ?? "9320");
453
- const PORT_RANGE = 10; // TARGET_PORT..TARGET_PORT+9
454
-
455
- return new Promise((resolve, reject) => {
456
- function tryPort(port: number) {
457
- server.once("error", (err: NodeJS.ErrnoException) => {
458
- if (err.code === "EADDRINUSE" && port < TARGET_PORT + PORT_RANGE - 1) {
459
- log("port in use, trying next", { port });
460
- tryPort(port + 1);
461
- } else {
462
- log("listen failed", { port, code: err.code, message: err.message });
463
- reject(err);
464
- }
465
- });
466
-
467
- server.listen(port, "127.0.0.1", () => {
468
- const url = `http://localhost:${port}`; // guardrails-allow PREVENT-PI-004: localhost dashboard URL (loopback-only)
469
- log("server running", { url });
470
- // eslint-disable-next-line no-console
471
- console.log(`[mega-compact] dashboard server running: ${url}`);
472
-
473
- // v0.8.2: also bind the IPv6 loopback (::1). On many systems `localhost`
474
- // resolves to ::1 first (see /etc/hosts), so an IPv4-only bind makes the
475
- // browser hit ::1:port and get connection refused. PREVENT-PI-004
476
- // (loopback-only) means BOTH 127.0.0.1 and ::1. Non-fatal: IPv4-only
477
- // hosts or a ::1 already in use just skip the mirror.
478
- let v6: ReturnType<typeof createServer> | undefined;
479
- const v4Handler = server.listeners("request")[0];
480
- if (v4Handler) {
481
- v6 = createServer((r, s) => (v4Handler as (a: IncomingMessage, b: ServerResponse) => void).call(server, r, s));
482
- v6.on("error", (e: NodeJS.ErrnoException) =>
483
- log("ipv6 loopback bind skipped", { port, code: e.code, message: e.message }),
484
- );
485
- v6.listen(port, "::1", () => log("ipv6 loopback bound", { port })); // guardrails-allow PREVENT-PI-004: IPv6 loopback (::1) mirror of the localhost dashboard server
486
- }
487
-
488
- // Write port.pid
489
- try {
490
- writeFileSync(portFile, JSON.stringify({ port, pid: process.pid }));
491
- } catch (e) {
492
- log("could not write port.pid", { error: String(e) });
493
- }
494
-
495
- // Graceful cleanup
496
- const cleanup = () => {
497
- try { unlinkSync(portFile); } catch { /* already gone */ }
498
- server.close();
499
- try { v6?.close(); } catch { /* not bound */ }
500
- process.exit(0);
501
- };
502
- process.on("SIGTERM", cleanup);
503
- process.on("SIGINT", cleanup);
504
-
505
- resolve({ port, url });
506
- });
507
- }
508
-
509
- tryPort(TARGET_PORT);
510
- });
30
+ export async function launchDashboardServer(
31
+ stateDir: string,
32
+ ): Promise<{ port: number; url: string }> {
33
+ // Our own package version — exposed at /api/version so the launcher can
34
+ // detect a stale server (started by an older build) and replace it on
35
+ // upgrade instead of reuse it.
36
+ let SERVER_VERSION = "0.0.0";
37
+ // `here` is hoisted out of the version-detection try block so the
38
+ // dashboard-client dist path (Sprint B1) can reuse it without recompute.
39
+ const here = dirname(fileURLToPath(import.meta.url));
40
+ try {
41
+ // Since v0.7.9 (8821ef3) dashboard-server.js lives at
42
+ // <pkg>/dist/extensions/dashboard-server/, so package.json is THREE levels
43
+ // up. Keep the two- and one-level-up candidates as fallbacks for flatter
44
+ // dev-checkout layouts. Guard each candidate so a missing file is skipped.
45
+ const candidates = [
46
+ join(here, "..", "..", "..", "package.json"),
47
+ join(here, "..", "..", "package.json"),
48
+ join(here, "..", "package.json"),
49
+ ];
50
+ for (const p of candidates) {
51
+ if (!existsSync(p)) continue;
52
+ const pkg = JSON.parse(readFileSync(p, "utf-8"));
53
+ if (pkg.version) {
54
+ SERVER_VERSION = pkg.version;
55
+ setDashboardServerVersion(pkg.version);
56
+ break;
57
+ }
58
+ }
59
+ } catch {
60
+ /* non-fatal */
61
+ }
62
+
63
+ // Lazy-loaded via require so the dashboard stays cheap to boot and we don't
64
+ // need a top-level await in the handler.
65
+ const driftReq = createRequire(import.meta.url);
66
+ const detectCrossRepoDrift = (idxDir: string) =>
67
+ (
68
+ driftReq(
69
+ "../../src/driftDetection.js",
70
+ ) as typeof import("../../src/driftDetection.js")
71
+ ).detectCrossRepoDrift(idxDir);
72
+ const portFile = join(stateDir, "port.pid");
73
+ const snapshotPath = join(stateDir, "dashboard.json");
74
+ const eventsPath = join(stateDir, "events.log");
75
+ setLogPath(join(stateDir, "dashboard.log"));
76
+
77
+ // ── React client build (Sprint B1) ────────────────────────────────────
78
+ // If the Vite-built dashboard-client bundle is present, serve it as the
79
+ // dashboard UI (SPA fallback for all non-/api/* routes). If absent, fall
80
+ // back to the legacy inline html.ts template. Candidate paths cover both
81
+ // the dist/ build layout and a flat dev checkout (mirrors the package.json
82
+ // candidate pattern above).
83
+ const clientDistCandidates = [
84
+ join(here, "..", "dashboard-client", "dist"), // dist/extensions/dashboard-client/dist
85
+ join(here, "..", "..", "dashboard-client", "dist"), // dist/dashboard-client/dist (flat)
86
+ join(here, "..", "..", "..", "extensions", "dashboard-client", "dist"), // repo-root extensions/dashboard-client/dist (dist build)
87
+ join(here, "..", "dashboard-client", "dist"), // dev: extensions/dashboard-server/../dashboard-client/dist
88
+ ];
89
+ const clientDist =
90
+ clientDistCandidates.find((p) => existsSync(join(p, "index.html"))) ??
91
+ clientDistCandidates[0];
92
+ const clientIndexHtml = join(clientDist, "index.html");
93
+ const hasClientBuild = existsSync(clientIndexHtml);
94
+ if (hasClientBuild) log("client build present", { clientDist });
95
+
96
+ // guardrails-allow PREVENT-PI-004: read-only static file serving from the local dashboard-client/dist bundle (loopback-only UI).
97
+ const serveClientAsset = (reqPath: string, res: ServerResponse): boolean => {
98
+ if (!hasClientBuild) return false;
99
+ // Normalize: strip query, prevent path traversal, map "/" to index.html.
100
+ const clean = reqPath.split("?")[0];
101
+ if (clean.includes("..")) return false;
102
+ const rel =
103
+ clean === "/" || clean === "" ? "index.html" : clean.replace(/^\//, "");
104
+ const file = join(clientDist, rel);
105
+ if (!file.startsWith(clientDist) || !existsSync(file)) {
106
+ // SPA fallback: unknown non-asset routes serve index.html (client-side routing).
107
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
108
+ res.end(readFileSync(clientIndexHtml));
109
+ return true;
110
+ }
111
+ const ext = rel.slice(rel.lastIndexOf(".") + 1);
112
+ const types: Record<string, string> = {
113
+ html: "text/html; charset=utf-8",
114
+ js: "text/javascript",
115
+ css: "text/css",
116
+ json: "application/json",
117
+ svg: "image/svg+xml",
118
+ png: "image/png",
119
+ ico: "image/x-icon",
120
+ map: "application/json",
121
+ };
122
+ res.writeHead(200, {
123
+ "Content-Type": types[ext] ?? "application/octet-stream",
124
+ });
125
+ res.end(readFileSync(file));
126
+ return true;
127
+ };
128
+
129
+ log("launch invoked", { stateDir });
130
+
131
+ // ── Existing server? ───────────────────────────────────────────────────────
132
+ // A stale port.pid pointing at a dead/competing process is the classic cause
133
+ // of "dashboard failed to start" we return a port that is NOT actually
134
+ // serving. Probe for a live server on that port first; only reuse the marker
135
+ // when something real answers /api/version. Otherwise drop it and start fresh.
136
+ if (existsSync(portFile)) {
137
+ try {
138
+ const info = JSON.parse(readFileSync(portFile, "utf-8"));
139
+ if (info && info.port) {
140
+ let live = false;
141
+ try {
142
+ const probe = await fetch(
143
+ `http://localhost:${info.port}/api/version`,
144
+ { signal: AbortSignal.timeout(800) },
145
+ ); // guardrails-allow PREVENT-PI-004: optional localhost dashboard server probe (loopback-only)
146
+ live = probe.ok;
147
+ } catch {
148
+ live = false;
149
+ }
150
+ if (live) {
151
+ log("reusing live server from port.pid", { port: info.port });
152
+ return { port: info.port, url: `http://localhost:${info.port}` }; // guardrails-allow PREVENT-PI-004: localhost dashboard URL (loopback-only)
153
+ }
154
+ log("port.pid present but no live server — treating as stale", {
155
+ port: info.port,
156
+ });
157
+ }
158
+ } catch {
159
+ log("port.pid unparseabletreating as stale");
160
+ }
161
+ // stale file, remove so the fresh bind does not collide with a lingering
162
+ // process that still holds the port
163
+ try {
164
+ unlinkSync(portFile);
165
+ } catch {
166
+ /* ignore */
167
+ }
168
+ }
169
+
170
+ // ── New server ────────────────────────────────────────────────────────────
171
+ mkdirSync(stateDir, { recursive: true });
172
+
173
+ let eventOffset = 0;
174
+
175
+ // Overlay the live current-repo snapshot (snapshot.json, rewritten every
176
+ // context event) onto its registry row so the All-repos / Summary views stay
177
+ // in sync with the live menu bar + Current-repo card in real time. The
178
+ // registry (index.sqlite) is only written on repo-switch (bindRepo), so
179
+ // without this the current repo's row freezes between switches. Read-only
180
+ // no extra writes to index.sqlite. Matched by stateDir, which equals the
181
+ // value this server was launched with (runtime.currentStateDir).
182
+ function overlayCurrentRepo(idx: IndexIndex | null): void {
183
+ if (!idx || !idx.repos.length) return;
184
+ let snap: Snapshot | null = null;
185
+ try {
186
+ snap = readSnapshot(snapshotPath);
187
+ } catch {
188
+ return;
189
+ }
190
+ if (!snap || !snap.repo) return;
191
+ const cur = idx.repos.find((r) => r.stateDir === stateDir);
192
+ if (!cur) return;
193
+ const prevSaved = cur.tokensSaved;
194
+ const prevCp = cur.checkpointCount;
195
+ const prevBytes = cur.compressedOriginalBytes;
196
+ const comp = snap.compression?.repo;
197
+ const liveSaved = comp
198
+ ? comp.tokensFreed
199
+ : (snap.repo.tokensSaved ?? prevSaved);
200
+ const liveCp = snap.repo.checkpointCount ?? prevCp;
201
+ const liveBytes = snap.integrity?.compressedOriginalBytes ?? prevBytes;
202
+ cur.tokensSaved = liveSaved;
203
+ cur.checkpointCount = liveCp;
204
+ cur.compressedOriginalBytes = liveBytes;
205
+ if (idx.summary) {
206
+ idx.summary.totalTokensSaved += liveSaved - prevSaved;
207
+ idx.summary.totalCheckpoints += liveCp - prevCp;
208
+ idx.summary.totalCompressedOriginalBytes += liveBytes - prevBytes;
209
+ }
210
+ idx.updatedAt = snap.updatedAt ?? idx.updatedAt;
211
+ }
212
+
213
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
214
+ // guardrails-allow PREVENT-PI-004: optional, user-triggered /dashboard localhost server (loopback-only) — CORS restricted to same-origin localhost browsers.
215
+ // CORS for local access — restricted to loopback origins (the dashboard server only binds to localhost).
216
+ const origin = req.headers.origin;
217
+ if (
218
+ typeof origin === "string" &&
219
+ /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)
220
+ ) {
221
+ res.setHeader("Access-Control-Allow-Origin", origin);
222
+ res.setHeader("Vary", "Origin");
223
+ }
224
+ res.setHeader("Access-Control-Allow-Methods", "GET, PUT, OPTIONS");
225
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
226
+
227
+ if (req.method === "OPTIONS") {
228
+ res.writeHead(204);
229
+ res.end();
230
+ return;
231
+ }
232
+
233
+ if (req.url === "/" || req.url === "/index.html") {
234
+ // Sprint B1: prefer the React client build when present; fall back to the
235
+ // legacy inline html.ts template when the client dist is absent.
236
+ if (serveClientAsset("/", res)) return;
237
+ const tier = readSnapshot(snapshotPath).tier;
238
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
239
+ res.end(dashboardHtml(tier));
240
+ return;
241
+ }
242
+
243
+ if (req.url === "/api/snapshot") {
244
+ const snap = readSnapshot(snapshotPath);
245
+ res.writeHead(200, { "Content-Type": "application/json" });
246
+ res.end(JSON.stringify(snap));
247
+ return;
248
+ }
249
+
250
+ // Server version — lets the /dashboard launcher detect a stale server from
251
+ // an older build and replace it on upgrade rather than reuse it.
252
+ if (req.url === "/api/version") {
253
+ res.writeHead(200, { "Content-Type": "application/json" });
254
+ res.end(JSON.stringify({ version: SERVER_VERSION }));
255
+ return;
256
+ }
257
+
258
+ // Multi-repo aggregate (Phase 5b): the machine-wide repo registry read
259
+ // directly from SQLite (index.sqlite). Lets one dashboard show every repo's
260
+ // checkpoints, tokens saved, and active model. Read-only.
261
+ if (req.url === "/api/index") {
262
+ const idx = readIndex();
263
+ if (idx) overlayCurrentRepo(idx);
264
+ res.writeHead(200, { "Content-Type": "application/json" });
265
+ res.end(
266
+ JSON.stringify(idx ?? { updatedAt: null, summary: null, repos: [] }),
267
+ );
268
+ return;
269
+ }
270
+
271
+ // /api/repos — registry list. Optional `?active=24h` filters to repos
272
+ // seen within the last N hours (default: all). The dashboard uses this to
273
+ // drive its "active vs archived" badge without refetching /api/index.
274
+ if (req.url?.startsWith("/api/repos")) {
275
+ const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
276
+ const activeParam = url.searchParams.get("active");
277
+ const idx = readIndex();
278
+ if (idx) overlayCurrentRepo(idx);
279
+ let repos = idx?.repos ?? [];
280
+ if (activeParam) {
281
+ const m = /^(\d+)h$/.exec(activeParam);
282
+ if (m) {
283
+ const cutoffSec = Math.floor(Date.now() / 1000) - Number(m[1]) * 3600;
284
+ repos = repos.filter((r) => (r.lastSeen ?? 0) >= cutoffSec);
285
+ }
286
+ }
287
+ res.writeHead(200, { "Content-Type": "application/json" });
288
+ res.end(
289
+ JSON.stringify({
290
+ updatedAt: idx?.updatedAt ?? null,
291
+ repos,
292
+ count: repos.length,
293
+ }),
294
+ );
295
+ return;
296
+ }
297
+
298
+ // /api/summary — header tiles without the full repo list (keeps payload
299
+ // small for embed scenarios). activeRepos mirrors the /api/repos?active=24h
300
+ // count so the dashboard can render the active badge alongside totals.
301
+ if (req.url?.startsWith("/api/summary")) {
302
+ const idx = readIndex();
303
+ if (idx) overlayCurrentRepo(idx);
304
+ const repos = idx?.repos ?? [];
305
+ const cutoffSec = Math.floor(Date.now() / 1000) - 24 * 3600;
306
+ const activeRepos = repos.filter(
307
+ (r) => (r.lastSeen ?? 0) >= cutoffSec,
308
+ ).length;
309
+ res.writeHead(200, { "Content-Type": "application/json" });
310
+ res.end(
311
+ JSON.stringify({
312
+ updatedAt: idx?.updatedAt ?? null,
313
+ summary: idx?.summary ?? null,
314
+ activeRepos,
315
+ totalRepos: repos.length,
316
+ }),
317
+ );
318
+ return;
319
+ }
320
+
321
+ // /api/drift R4: cross-repo drift report over repo_registry. Flags stale
322
+ // repos (>30d idle), compaction lag (active but >24h since last
323
+ // compaction), and recent model churn. Read-only.
324
+ if (req.url?.startsWith("/api/drift")) {
325
+ const report = detectCrossRepoDrift(getIndexDir());
326
+ res.writeHead(200, { "Content-Type": "application/json" });
327
+ res.end(JSON.stringify(report));
328
+ return;
329
+ }
330
+
331
+ if (req.url === "/api/servers") {
332
+ try {
333
+ const idx = readIndex();
334
+ const nowSec = Math.floor(Date.now() / 1000);
335
+ const servers = (idx?.repos ?? [])
336
+ .filter((r) => (r.lastSeen ?? 0) >= nowSec - ACTIVE_WINDOW_SEC)
337
+ .map((r) => {
338
+ const out: Record<string, unknown> = {
339
+ repoRoot: r.repoRoot,
340
+ displayName: r.displayName,
341
+ model: r.modelName,
342
+ provider: r.providerName,
343
+ lastSeen: r.lastSeen,
344
+ lastCompactedAt: r.lastCompactedAt,
345
+ };
346
+ try {
347
+ const p = join(r.stateDir, "dashboard.json");
348
+ if (existsSync(p)) {
349
+ const snap = JSON.parse(
350
+ readFileSync(p, "utf-8"),
351
+ ) as LiveSnapshot;
352
+ out.tier = snap.tier ?? null;
353
+ out.contextPct =
354
+ snap.context && snap.context.percent != null
355
+ ? snap.context.percent
356
+ : null;
357
+ out.state = (snap.session && snap.session.state) || null;
358
+ out.cacheHits = snap.cacheHits ?? null;
359
+ out.compacts = snap.compacts ?? null;
360
+ out.timeSaved = snap.timeSaved ?? null;
361
+ out.updatedAt = snap.updatedAt ?? null;
362
+ }
363
+ } catch {
364
+ /* best-effort */
365
+ }
366
+ return out;
367
+ })
368
+ .sort((a, b) => (b.lastSeen as number) - (a.lastSeen as number));
369
+ res.writeHead(200, { "Content-Type": "application/json" });
370
+ res.end(
371
+ JSON.stringify({ updatedAt: new Date().toISOString(), servers }),
372
+ );
373
+ } catch {
374
+ res.writeHead(500, { "Content-Type": "application/json" });
375
+ res.end(JSON.stringify({ error: "servers_unavailable" }));
376
+ }
377
+ return;
378
+ }
379
+
380
+ if (req.url === "/api/events") {
381
+ res.writeHead(200, {
382
+ "Content-Type": "text/event-stream",
383
+ "Cache-Control": "no-cache",
384
+ Connection: "keep-alive",
385
+ });
386
+
387
+ // Drain existing events so the client starts with history
388
+ const { data: existing, offset: initialOffset } = readFrom(eventsPath, 0);
389
+ eventOffset = initialOffset;
390
+ const lines = existing.split("\n").filter((l: string) => l.trim());
391
+ for (const line of lines) {
392
+ res.write(`data: ${line}\n\n`);
393
+ }
394
+
395
+ // Tail new events via fs.watch (coalesced with 100ms debounce)
396
+ let watchTimer: ReturnType<typeof setTimeout> | null = null;
397
+ const onWatch = () => {
398
+ if (watchTimer) return;
399
+ watchTimer = setTimeout(() => {
400
+ watchTimer = null;
401
+ const { data, offset } = readFrom(eventsPath, eventOffset);
402
+ eventOffset = offset;
403
+ const newLines = data.split("\n").filter((l: string) => l.trim());
404
+ for (const line of newLines) {
405
+ res.write(`data: ${line}\n\n`);
406
+ }
407
+ }, 100);
408
+ };
409
+
410
+ // Set up file watching: if file exists, watch it directly;
411
+ // otherwise poll for creation every 1s then switch to fs.watch.
412
+ let watcher: ReturnType<typeof watch> | null = null;
413
+ let pollInterval: ReturnType<typeof setInterval> | null = null;
414
+
415
+ function startFileWatch(): void {
416
+ try {
417
+ watcher = watch(eventsPath, onWatch);
418
+ } catch {
419
+ /* give up */
420
+ }
421
+ }
422
+
423
+ if (existsSync(eventsPath)) {
424
+ startFileWatch();
425
+ } else {
426
+ pollInterval = setInterval(() => {
427
+ if (existsSync(eventsPath)) {
428
+ if (pollInterval) {
429
+ clearInterval(pollInterval);
430
+ pollInterval = null;
431
+ }
432
+ startFileWatch();
433
+ }
434
+ }, 1000);
435
+ }
436
+
437
+ req.on("close", () => {
438
+ if (watchTimer) clearTimeout(watchTimer);
439
+ if (pollInterval) clearInterval(pollInterval);
440
+ watcher?.close();
441
+ });
442
+ return;
443
+ }
444
+
445
+ // /api/game-state — S32 game-mode settings (game_mode_on / theme /
446
+ // tui_display_mode). GET returns the current row; PUT applies a partial
447
+ // patch (validated) and returns the post-write row. The dashboard server is
448
+ // a detached child with no MegaRuntime ref, so it reads/writes the
449
+ // game_state SQLite row directly; the in-process MegaRuntime picks up the
450
+ // change via its fs.watch cache-eviction watcher. PREVENT-PI-004: loopback.
451
+ if (req.url?.startsWith("/api/game-state")) {
452
+ const gsReq = createRequire(import.meta.url);
453
+ const { getGameState, setGameState } = gsReq(
454
+ "../../src/store/sqlite.js",
455
+ ) as typeof import("../../src/store/sqlite.js");
456
+ const { isValidTheme } = gsReq(
457
+ "../../src/config/themes.js",
458
+ ) as typeof import("../../src/config/themes.js");
459
+ if (req.method === "GET") {
460
+ try {
461
+ const gs = getGameState(stateDir); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
462
+ res.writeHead(200, { "Content-Type": "application/json" });
463
+ res.end(JSON.stringify(gs));
464
+ } catch (e) {
465
+ res.writeHead(500, { "Content-Type": "application/json" });
466
+ res.end(
467
+ JSON.stringify({
468
+ error: "game_state_unavailable",
469
+ detail: String(e),
470
+ }),
471
+ );
472
+ }
473
+ return;
474
+ }
475
+ if (req.method === "PUT") {
476
+ // Read + parse the JSON body (capped — the patch is tiny). The handler
477
+ // is sync, so drain the stream via data/end listeners then continue.
478
+ let body = "";
479
+ let tooBig = false;
480
+ req.on("data", (chunk: Buffer) => {
481
+ // guardrails-allow PREVENT-PI-004: loopback dashboard request body (local)
482
+ if (body.length > 65536) {
483
+ tooBig = true;
484
+ return;
485
+ }
486
+ body += chunk.toString();
487
+ });
488
+ req.on("end", () => {
489
+ if (tooBig) {
490
+ res.writeHead(413, { "Content-Type": "application/json" });
491
+ res.end(JSON.stringify({ error: "body_too_large" }));
492
+ return;
493
+ }
494
+ let patch: Record<string, unknown> = {};
495
+ try {
496
+ patch = body ? JSON.parse(body) : {};
497
+ } catch {
498
+ res.writeHead(400, { "Content-Type": "application/json" });
499
+ res.end(JSON.stringify({ error: "invalid_json" }));
500
+ return;
501
+ }
502
+ // Reject valid-but-non-object JSON (null/[]/42) — dereferencing
503
+ // patch.game_mode_on would throw an unhandled TypeError inside this
504
+ // 'end' listener and crash the detached server (audit P1: loopback DoS).
505
+ if (
506
+ typeof patch !== "object" ||
507
+ patch === null ||
508
+ Array.isArray(patch)
509
+ ) {
510
+ res.writeHead(400, { "Content-Type": "application/json" });
511
+ res.end(JSON.stringify({ error: "invalid_patch_object" }));
512
+ return;
513
+ }
514
+ // Validate the patch fields (unknown keys ignored; invalid values -> 400).
515
+ const clean: {
516
+ game_mode_on?: boolean;
517
+ theme?: string;
518
+ tui_display_mode?: "full" | "minimal";
519
+ } = {};
520
+ let bad = false;
521
+ if (patch.game_mode_on != null) {
522
+ if (typeof patch.game_mode_on !== "boolean") bad = true;
523
+ else clean.game_mode_on = patch.game_mode_on;
524
+ }
525
+ if (patch.theme != null) {
526
+ if (typeof patch.theme !== "string" || !isValidTheme(patch.theme))
527
+ bad = true;
528
+ else clean.theme = patch.theme;
529
+ }
530
+ if (patch.tui_display_mode != null) {
531
+ if (
532
+ patch.tui_display_mode !== "full" &&
533
+ patch.tui_display_mode !== "minimal"
534
+ )
535
+ bad = true;
536
+ else clean.tui_display_mode = patch.tui_display_mode;
537
+ }
538
+ if (bad) {
539
+ res.writeHead(400, { "Content-Type": "application/json" });
540
+ res.end(JSON.stringify({ error: "invalid_patch" }));
541
+ return;
542
+ }
543
+ try {
544
+ const gs = setGameState(clean, stateDir); // guardrails-allow PREVENT-PI-004: local SQLite write (loopback dashboard)
545
+ res.writeHead(200, { "Content-Type": "application/json" });
546
+ res.end(JSON.stringify(gs));
547
+ } catch (e) {
548
+ res.writeHead(500, { "Content-Type": "application/json" });
549
+ res.end(
550
+ JSON.stringify({
551
+ error: "game_state_write_failed",
552
+ detail: String(e),
553
+ }),
554
+ );
555
+ }
556
+ });
557
+ return;
558
+ }
559
+ // Any other method on /api/game-state → 405.
560
+ res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
561
+ res.end(JSON.stringify({ error: "method_not_allowed" }));
562
+ return;
563
+ }
564
+
565
+ // /api/game-scores — S34 high-score leaderboards. GET returns the leaderboard
566
+ // for a metric (?metric=<m>&limit=<n>). `metric` is validated against the
567
+ // METRICS allow-list from src/game/scoring (re-exported via the sqlite barrel);
568
+ // default limit 10, clamped to [1,100]. The dashboard server is a detached
569
+ // child with no MegaRuntime ref, so it reads the game_scores SQLite table
570
+ // directly. Unknown metric -> 400, non-GET -> 405. PREVENT-PI-004: loopback.
571
+ if (req.url?.startsWith("/api/game-scores")) {
572
+ const gsReq = createRequire(import.meta.url);
573
+ const { leaderboard, METRICS } = gsReq(
574
+ "../../src/store/sqlite.js",
575
+ ) as typeof import("../../src/store/sqlite.js");
576
+ if (req.method !== "GET") {
577
+ res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
578
+ res.end(JSON.stringify({ error: "method_not_allowed" }));
579
+ return;
580
+ }
581
+ try {
582
+ const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
583
+ const metricParam = url.searchParams.get("metric") ?? "cache";
584
+ if (!(METRICS as readonly string[]).includes(metricParam)) {
585
+ res.writeHead(400, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
586
+ res.end(
587
+ JSON.stringify({ error: "unknown_metric", metric: metricParam }),
588
+ );
589
+ return;
590
+ }
591
+ const metric = metricParam as GameMetric; // validated against METRICS above
592
+ let limit = Number(url.searchParams.get("limit") ?? "10");
593
+ if (!Number.isFinite(limit) || limit <= 0) limit = 10;
594
+ limit = Math.min(Math.max(limit, 1), 100); // clamp to [1,100]
595
+ const rows = leaderboard(stateDir, metric, { limit }); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
596
+ res.writeHead(200, { "Content-Type": "application/json" });
597
+ res.end(JSON.stringify(rows));
598
+ } catch (e) {
599
+ res.writeHead(500, { "Content-Type": "application/json" });
600
+ res.end(
601
+ JSON.stringify({
602
+ error: "game_scores_unavailable",
603
+ detail: String(e),
604
+ }),
605
+ );
606
+ }
607
+ return;
608
+ }
609
+
610
+ // /api/perf — v0.8.8 Perf dashboard tab. GET returns rolling-window
611
+ // aggregates over perf_samples: per-kind p50/p95 (turn/provider latency,
612
+ // tps avg, db recompute, disk write), latest rss/heap, cpu user/sys delta,
613
+ // cache hit %, plus the diag recompute/skip/replay counts (read from
614
+ // dashboard.json snapshot if available). The dashboard server is a detached
615
+ // child with no MegaRuntime ref, so it reads perf_samples via a require()'d
616
+ // sqlite helper (same pattern as /api/game-scores). Unknown/invalid params
617
+ // are clamped (never throw). Non-GET -> 405. PREVENT-PI-004: loopback.
618
+ if (req.url?.startsWith("/api/perf")) {
619
+ const pfReq = createRequire(import.meta.url);
620
+ const { readPerfSamples } = pfReq(
621
+ "../../src/store/sqlite.js",
622
+ ) as typeof import("../../src/store/sqlite.js");
623
+ if (req.method !== "GET") {
624
+ res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
625
+ res.end(JSON.stringify({ error: "method_not_allowed" }));
626
+ return;
627
+ }
628
+ try {
629
+ const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
630
+ let minutes = Number(url.searchParams.get("minutes") ?? "30");
631
+ if (!Number.isFinite(minutes) || minutes <= 0) minutes = 30;
632
+ minutes = Math.min(minutes, 1440); // cap at 24h
633
+ const sinceTs = Date.now() - minutes * 60_000;
634
+ const rows = readPerfSamples(stateDir, sinceTs); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
635
+ const byKind = new Map<string, number[]>();
636
+ for (const r of rows) {
637
+ let arr = byKind.get(r.kind);
638
+ if (!arr) {
639
+ arr = [];
640
+ byKind.set(r.kind, arr);
641
+ }
642
+ arr.push(r.value);
643
+ }
644
+ // Nearest-rank percentile (ceil(p/100*n)-1, clamped). Code-controlled,
645
+ // never user input (PREVENT-002 safe).
646
+ function pct(arr: number[], p: number): number {
647
+ if (!arr.length) return 0;
648
+ const s = [...arr].sort((a, b) => a - b);
649
+ const idx = Math.min(
650
+ s.length - 1,
651
+ Math.max(0, Math.ceil((p / 100) * s.length) - 1),
652
+ );
653
+ return s[idx];
654
+ }
655
+ function avg(arr: number[]): number {
656
+ if (!arr.length) return 0;
657
+ return arr.reduce((a, b) => a + b, 0) / arr.length;
658
+ }
659
+ // rows are ASC by ts, so the last pushed value is the most recent.
660
+ function latest(arr: number[]): number {
661
+ return arr.length ? arr[arr.length - 1] : 0;
662
+ }
663
+ const get = (k: string): number[] => byKind.get(k) ?? [];
664
+ // diag counters live in the runtime-written dashboard.json (the server is
665
+ // a detached child with no MegaRuntime ref). Read defensively — absent
666
+ // until the first snapshot() write (PREVENT-001: assign before access).
667
+ let diag: {
668
+ ctxFastGate: number;
669
+ liveTrimFires: number;
670
+ liveTrimReplays: number;
671
+ } | null = null;
672
+ try {
673
+ const raw = readFileSync(snapshotPath, "utf-8");
674
+ const parsed = JSON.parse(raw) as {
675
+ diag?: {
676
+ ctxFastGate: number;
677
+ liveTrimFires: number;
678
+ liveTrimReplays: number;
679
+ };
680
+ };
681
+ if (parsed && typeof parsed === "object" && parsed.diag)
682
+ diag = parsed.diag;
683
+ } catch {
684
+ /* dashboard.json not written yet */
685
+ }
686
+ res.writeHead(200, { "Content-Type": "application/json" });
687
+ res.end(
688
+ JSON.stringify({
689
+ updatedAt: new Date().toISOString(),
690
+ windowMinutes: minutes,
691
+ sampleCount: rows.length,
692
+ turn_latency_ms: {
693
+ p50: pct(get("turn_latency_ms"), 50),
694
+ p95: pct(get("turn_latency_ms"), 95),
695
+ n: get("turn_latency_ms").length,
696
+ },
697
+ provider_latency_ms: {
698
+ p50: pct(get("provider_latency_ms"), 50),
699
+ p95: pct(get("provider_latency_ms"), 95),
700
+ n: get("provider_latency_ms").length,
701
+ },
702
+ tps: { avg: avg(get("tps")), n: get("tps").length },
703
+ cache_hit_pct: {
704
+ avg: avg(get("cache_hit_pct")),
705
+ latest: latest(get("cache_hit_pct")),
706
+ n: get("cache_hit_pct").length,
707
+ },
708
+ db_recompute_ms: {
709
+ p50: pct(get("db_recompute_ms"), 50),
710
+ p95: pct(get("db_recompute_ms"), 95),
711
+ n: get("db_recompute_ms").length,
712
+ },
713
+ disk_write_ms: {
714
+ p50: pct(get("disk_write_ms"), 50),
715
+ p95: pct(get("disk_write_ms"), 95),
716
+ n: get("disk_write_ms").length,
717
+ },
718
+ rss_mb: { latest: latest(get("rss_mb")), n: get("rss_mb").length },
719
+ heap_mb: {
720
+ latest: latest(get("heap_mb")),
721
+ n: get("heap_mb").length,
722
+ },
723
+ cpu_user_ms: {
724
+ latest: latest(get("cpu_user_ms")),
725
+ n: get("cpu_user_ms").length,
726
+ },
727
+ cpu_sys_ms: {
728
+ latest: latest(get("cpu_sys_ms")),
729
+ n: get("cpu_sys_ms").length,
730
+ },
731
+ diag,
732
+ }),
733
+ );
734
+ } catch (e) {
735
+ res.writeHead(500, { "Content-Type": "application/json" });
736
+ res.end(
737
+ JSON.stringify({ error: "perf_unavailable", detail: String(e) }),
738
+ );
739
+ }
740
+ return;
741
+ }
742
+
743
+ // /api/achievements — S35 achievement tiles. GET returns the 9 seeded rows
744
+ // {id,title,description,icon,hidden,unlocked_at}. The dashboard server is a
745
+ // detached child with no MegaRuntime ref, so it reads game_achievements via
746
+ // listAchievements(stateDir) directly. Non-GET -> 405. PREVENT-PI-004: loopback.
747
+ if (req.url?.startsWith("/api/achievements")) {
748
+ const achReq = createRequire(import.meta.url);
749
+ const { listAchievements } = achReq(
750
+ "../../src/store/sqlite.js",
751
+ ) as typeof import("../../src/store/sqlite.js");
752
+ if (req.method !== "GET") {
753
+ res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
754
+ res.end(JSON.stringify({ error: "method_not_allowed" }));
755
+ return;
756
+ }
757
+ try {
758
+ const rows = listAchievements(stateDir); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
759
+ res.writeHead(200, { "Content-Type": "application/json" });
760
+ res.end(JSON.stringify(rows));
761
+ } catch (e) {
762
+ res.writeHead(500, { "Content-Type": "application/json" });
763
+ res.end(
764
+ JSON.stringify({
765
+ error: "achievements_unavailable",
766
+ detail: String(e),
767
+ }),
768
+ );
769
+ }
770
+ return;
771
+ }
772
+
773
+ // Fallback — serve the React client build (SPA route) or legacy dashboard.
774
+ // Non-/api/* GETs hit here: serve client assets if built, else inline HTML.
775
+ if (
776
+ req.method === "GET" &&
777
+ req.url &&
778
+ !req.url.startsWith("/api/") &&
779
+ serveClientAsset(req.url, res)
780
+ ) {
781
+ return;
782
+ }
783
+ const tier = readSnapshot(snapshotPath).tier;
784
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
785
+ res.end(dashboardHtml(tier));
786
+ });
787
+
788
+ // Bind base + range are env-configurable so tests can use a private,
789
+ // non-colliding range (parallel runs / leftover servers from killed runs
790
+ // would otherwise EADDRINUSE on the machine-global 9320 range). Default
791
+ // MEGACOMPACT_DASHBOARD_PORT=9320 (10-port range 9320–9329) preserves the
792
+ // production behavior.
793
+ const TARGET_PORT = Number(process.env.MEGACOMPACT_DASHBOARD_PORT ?? "9320");
794
+ const PORT_RANGE = 10; // TARGET_PORT..TARGET_PORT+9
795
+
796
+ return new Promise((resolve, reject) => {
797
+ function tryPort(port: number) {
798
+ server.once("error", (err: NodeJS.ErrnoException) => {
799
+ if (err.code === "EADDRINUSE" && port < TARGET_PORT + PORT_RANGE - 1) {
800
+ log("port in use, trying next", { port });
801
+ tryPort(port + 1);
802
+ } else {
803
+ log("listen failed", { port, code: err.code, message: err.message });
804
+ reject(err);
805
+ }
806
+ });
807
+
808
+ server.listen(port, "127.0.0.1", () => {
809
+ const url = `http://localhost:${port}`; // guardrails-allow PREVENT-PI-004: localhost dashboard URL (loopback-only)
810
+ log("server running", { url });
811
+ // eslint-disable-next-line no-console
812
+ console.log(`[mega-compact] dashboard server running: ${url}`);
813
+
814
+ // v0.8.2: also bind the IPv6 loopback (::1). On many systems `localhost`
815
+ // resolves to ::1 first (see /etc/hosts), so an IPv4-only bind makes the
816
+ // browser hit ::1:port and get connection refused. PREVENT-PI-004
817
+ // (loopback-only) means BOTH 127.0.0.1 and ::1. Non-fatal: IPv4-only
818
+ // hosts or a ::1 already in use just skip the mirror.
819
+ let v6: ReturnType<typeof createServer> | undefined;
820
+ const v4Handler = server.listeners("request")[0];
821
+ if (v4Handler) {
822
+ v6 = createServer((r, s) =>
823
+ (v4Handler as (a: IncomingMessage, b: ServerResponse) => void).call(
824
+ server,
825
+ r,
826
+ s,
827
+ ),
828
+ );
829
+ v6.on("error", (e: NodeJS.ErrnoException) =>
830
+ log("ipv6 loopback bind skipped", {
831
+ port,
832
+ code: e.code,
833
+ message: e.message,
834
+ }),
835
+ );
836
+ v6.listen(port, "::1", () => log("ipv6 loopback bound", { port })); // guardrails-allow PREVENT-PI-004: IPv6 loopback (::1) mirror of the localhost dashboard server
837
+ }
838
+
839
+ // Write port.pid
840
+ try {
841
+ writeFileSync(portFile, JSON.stringify({ port, pid: process.pid }));
842
+ } catch (e) {
843
+ log("could not write port.pid", { error: String(e) });
844
+ }
845
+
846
+ // Graceful cleanup
847
+ const cleanup = () => {
848
+ try {
849
+ unlinkSync(portFile);
850
+ } catch {
851
+ /* already gone */
852
+ }
853
+ server.close();
854
+ try {
855
+ v6?.close();
856
+ } catch {
857
+ /* not bound */
858
+ }
859
+ process.exit(0);
860
+ };
861
+ process.on("SIGTERM", cleanup);
862
+ process.on("SIGINT", cleanup);
863
+
864
+ resolve({ port, url });
865
+ });
866
+ }
867
+
868
+ tryPort(TARGET_PORT);
869
+ });
511
870
  }
512
871
 
513
872
  // ---------------------------------------------------------------------------
@@ -515,13 +874,13 @@ export async function launchDashboardServer(stateDir: string): Promise<{ port: n
515
874
  // ---------------------------------------------------------------------------
516
875
 
517
876
  if (process.argv[1] && process.argv[1].includes("dashboard-server")) {
518
- const stateDir = process.argv[2];
519
- if (!stateDir) {
520
- console.error("Usage: node dashboard-server.js <stateDir>");
521
- process.exit(1);
522
- }
523
- launchDashboardServer(stateDir).catch((err) => {
524
- console.error("[mega-compact] dashboard server failed:", err);
525
- process.exit(1);
526
- });
877
+ const stateDir = process.argv[2];
878
+ if (!stateDir) {
879
+ console.error("Usage: node dashboard-server.js <stateDir>");
880
+ process.exit(1);
881
+ }
882
+ launchDashboardServer(stateDir).catch((err) => {
883
+ console.error("[mega-compact] dashboard server failed:", err);
884
+ process.exit(1);
885
+ });
527
886
  }