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
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * dashboard-server/server.ts — HTTP server creation + launch + CLI entry point.
3
3
  */
4
- import { createServer } from "node:http";
5
- import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs";
4
+ import { createServer, } from "node:http";
5
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync, } from "node:fs";
6
6
  import { join, dirname } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { createRequire } from "node:module";
@@ -16,12 +16,14 @@ export async function launchDashboardServer(stateDir) {
16
16
  // detect a stale server (started by an older build) and replace it on
17
17
  // upgrade instead of reuse it.
18
18
  let SERVER_VERSION = "0.0.0";
19
+ // `here` is hoisted out of the version-detection try block so the
20
+ // dashboard-client dist path (Sprint B1) can reuse it without recompute.
21
+ const here = dirname(fileURLToPath(import.meta.url));
19
22
  try {
20
23
  // Since v0.7.9 (8821ef3) dashboard-server.js lives at
21
24
  // <pkg>/dist/extensions/dashboard-server/, so package.json is THREE levels
22
25
  // up. Keep the two- and one-level-up candidates as fallbacks for flatter
23
26
  // dev-checkout layouts. Guard each candidate so a missing file is skipped.
24
- const here = dirname(fileURLToPath(import.meta.url));
25
27
  const candidates = [
26
28
  join(here, "..", "..", "..", "package.json"),
27
29
  join(here, "..", "..", "package.json"),
@@ -38,16 +40,68 @@ export async function launchDashboardServer(stateDir) {
38
40
  }
39
41
  }
40
42
  }
41
- catch { /* non-fatal */ }
43
+ catch {
44
+ /* non-fatal */
45
+ }
42
46
  // Lazy-loaded via require so the dashboard stays cheap to boot and we don't
43
47
  // need a top-level await in the handler.
44
48
  const driftReq = createRequire(import.meta.url);
45
- const detectCrossRepoDrift = (idxDir) => driftReq("../../src/driftDetection.js")
46
- .detectCrossRepoDrift(idxDir);
49
+ const detectCrossRepoDrift = (idxDir) => driftReq("../../src/driftDetection.js").detectCrossRepoDrift(idxDir);
47
50
  const portFile = join(stateDir, "port.pid");
48
51
  const snapshotPath = join(stateDir, "dashboard.json");
49
52
  const eventsPath = join(stateDir, "events.log");
50
53
  setLogPath(join(stateDir, "dashboard.log"));
54
+ // ── React client build (Sprint B1) ────────────────────────────────────
55
+ // If the Vite-built dashboard-client bundle is present, serve it as the
56
+ // dashboard UI (SPA fallback for all non-/api/* routes). If absent, fall
57
+ // back to the legacy inline html.ts template. Candidate paths cover both
58
+ // the dist/ build layout and a flat dev checkout (mirrors the package.json
59
+ // candidate pattern above).
60
+ const clientDistCandidates = [
61
+ join(here, "..", "dashboard-client", "dist"), // dist/extensions/dashboard-client/dist
62
+ join(here, "..", "..", "dashboard-client", "dist"), // dist/dashboard-client/dist (flat)
63
+ join(here, "..", "..", "..", "extensions", "dashboard-client", "dist"), // repo-root extensions/dashboard-client/dist (dist build)
64
+ join(here, "..", "dashboard-client", "dist"), // dev: extensions/dashboard-server/../dashboard-client/dist
65
+ ];
66
+ const clientDist = clientDistCandidates.find((p) => existsSync(join(p, "index.html"))) ??
67
+ clientDistCandidates[0];
68
+ const clientIndexHtml = join(clientDist, "index.html");
69
+ const hasClientBuild = existsSync(clientIndexHtml);
70
+ if (hasClientBuild)
71
+ log("client build present", { clientDist });
72
+ // guardrails-allow PREVENT-PI-004: read-only static file serving from the local dashboard-client/dist bundle (loopback-only UI).
73
+ const serveClientAsset = (reqPath, res) => {
74
+ if (!hasClientBuild)
75
+ return false;
76
+ // Normalize: strip query, prevent path traversal, map "/" to index.html.
77
+ const clean = reqPath.split("?")[0];
78
+ if (clean.includes(".."))
79
+ return false;
80
+ const rel = clean === "/" || clean === "" ? "index.html" : clean.replace(/^\//, "");
81
+ const file = join(clientDist, rel);
82
+ if (!file.startsWith(clientDist) || !existsSync(file)) {
83
+ // SPA fallback: unknown non-asset routes serve index.html (client-side routing).
84
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
85
+ res.end(readFileSync(clientIndexHtml));
86
+ return true;
87
+ }
88
+ const ext = rel.slice(rel.lastIndexOf(".") + 1);
89
+ const types = {
90
+ html: "text/html; charset=utf-8",
91
+ js: "text/javascript",
92
+ css: "text/css",
93
+ json: "application/json",
94
+ svg: "image/svg+xml",
95
+ png: "image/png",
96
+ ico: "image/x-icon",
97
+ map: "application/json",
98
+ };
99
+ res.writeHead(200, {
100
+ "Content-Type": types[ext] ?? "application/octet-stream",
101
+ });
102
+ res.end(readFileSync(file));
103
+ return true;
104
+ };
51
105
  log("launch invoked", { stateDir });
52
106
  // ── Existing server? ───────────────────────────────────────────────────────
53
107
  // A stale port.pid pointing at a dead/competing process is the classic cause
@@ -70,7 +124,9 @@ export async function launchDashboardServer(stateDir) {
70
124
  log("reusing live server from port.pid", { port: info.port });
71
125
  return { port: info.port, url: `http://localhost:${info.port}` }; // guardrails-allow PREVENT-PI-004: localhost dashboard URL (loopback-only)
72
126
  }
73
- log("port.pid present but no live server — treating as stale", { port: info.port });
127
+ log("port.pid present but no live server — treating as stale", {
128
+ port: info.port,
129
+ });
74
130
  }
75
131
  }
76
132
  catch {
@@ -81,7 +137,9 @@ export async function launchDashboardServer(stateDir) {
81
137
  try {
82
138
  unlinkSync(portFile);
83
139
  }
84
- catch { /* ignore */ }
140
+ catch {
141
+ /* ignore */
142
+ }
85
143
  }
86
144
  // ── New server ────────────────────────────────────────────────────────────
87
145
  mkdirSync(stateDir, { recursive: true });
@@ -112,7 +170,9 @@ export async function launchDashboardServer(stateDir) {
112
170
  const prevCp = cur.checkpointCount;
113
171
  const prevBytes = cur.compressedOriginalBytes;
114
172
  const comp = snap.compression?.repo;
115
- const liveSaved = comp ? comp.tokensFreed : (snap.repo.tokensSaved ?? prevSaved);
173
+ const liveSaved = comp
174
+ ? comp.tokensFreed
175
+ : (snap.repo.tokensSaved ?? prevSaved);
116
176
  const liveCp = snap.repo.checkpointCount ?? prevCp;
117
177
  const liveBytes = snap.integrity?.compressedOriginalBytes ?? prevBytes;
118
178
  cur.tokensSaved = liveSaved;
@@ -126,9 +186,14 @@ export async function launchDashboardServer(stateDir) {
126
186
  idx.updatedAt = snap.updatedAt ?? idx.updatedAt;
127
187
  }
128
188
  const server = createServer((req, res) => {
129
- // guardrails-allow PREVENT-PI-004: optional, user-triggered /dashboard localhost server (loopback-only) — CORS open for local browser access
130
- // CORS for local access
131
- res.setHeader("Access-Control-Allow-Origin", "*");
189
+ // guardrails-allow PREVENT-PI-004: optional, user-triggered /dashboard localhost server (loopback-only) — CORS restricted to same-origin localhost browsers.
190
+ // CORS for local access — restricted to loopback origins (the dashboard server only binds to localhost).
191
+ const origin = req.headers.origin;
192
+ if (typeof origin === "string" &&
193
+ /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
194
+ res.setHeader("Access-Control-Allow-Origin", origin);
195
+ res.setHeader("Vary", "Origin");
196
+ }
132
197
  res.setHeader("Access-Control-Allow-Methods", "GET, PUT, OPTIONS");
133
198
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
134
199
  if (req.method === "OPTIONS") {
@@ -137,6 +202,10 @@ export async function launchDashboardServer(stateDir) {
137
202
  return;
138
203
  }
139
204
  if (req.url === "/" || req.url === "/index.html") {
205
+ // Sprint B1: prefer the React client build when present; fall back to the
206
+ // legacy inline html.ts template when the client dist is absent.
207
+ if (serveClientAsset("/", res))
208
+ return;
140
209
  const tier = readSnapshot(snapshotPath).tier;
141
210
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
142
211
  res.end(dashboardHtml(tier));
@@ -184,7 +253,11 @@ export async function launchDashboardServer(stateDir) {
184
253
  }
185
254
  }
186
255
  res.writeHead(200, { "Content-Type": "application/json" });
187
- res.end(JSON.stringify({ updatedAt: idx?.updatedAt ?? null, repos, count: repos.length }));
256
+ res.end(JSON.stringify({
257
+ updatedAt: idx?.updatedAt ?? null,
258
+ repos,
259
+ count: repos.length,
260
+ }));
188
261
  return;
189
262
  }
190
263
  // /api/summary — header tiles without the full repo list (keeps payload
@@ -219,14 +292,26 @@ export async function launchDashboardServer(stateDir) {
219
292
  try {
220
293
  const idx = readIndex();
221
294
  const nowSec = Math.floor(Date.now() / 1000);
222
- const servers = (idx?.repos ?? []).filter((r) => (r.lastSeen ?? 0) >= nowSec - ACTIVE_WINDOW_SEC).map((r) => {
223
- const out = { repoRoot: r.repoRoot, displayName: r.displayName, model: r.modelName, provider: r.providerName, lastSeen: r.lastSeen, lastCompactedAt: r.lastCompactedAt };
295
+ const servers = (idx?.repos ?? [])
296
+ .filter((r) => (r.lastSeen ?? 0) >= nowSec - ACTIVE_WINDOW_SEC)
297
+ .map((r) => {
298
+ const out = {
299
+ repoRoot: r.repoRoot,
300
+ displayName: r.displayName,
301
+ model: r.modelName,
302
+ provider: r.providerName,
303
+ lastSeen: r.lastSeen,
304
+ lastCompactedAt: r.lastCompactedAt,
305
+ };
224
306
  try {
225
307
  const p = join(r.stateDir, "dashboard.json");
226
308
  if (existsSync(p)) {
227
309
  const snap = JSON.parse(readFileSync(p, "utf-8"));
228
310
  out.tier = snap.tier ?? null;
229
- out.contextPct = (snap.context && snap.context.percent != null) ? snap.context.percent : null;
311
+ out.contextPct =
312
+ snap.context && snap.context.percent != null
313
+ ? snap.context.percent
314
+ : null;
230
315
  out.state = (snap.session && snap.session.state) || null;
231
316
  out.cacheHits = snap.cacheHits ?? null;
232
317
  out.compacts = snap.compacts ?? null;
@@ -234,9 +319,12 @@ export async function launchDashboardServer(stateDir) {
234
319
  out.updatedAt = snap.updatedAt ?? null;
235
320
  }
236
321
  }
237
- catch { /* best-effort */ }
322
+ catch {
323
+ /* best-effort */
324
+ }
238
325
  return out;
239
- }).sort((a, b) => b.lastSeen - a.lastSeen);
326
+ })
327
+ .sort((a, b) => b.lastSeen - a.lastSeen);
240
328
  res.writeHead(200, { "Content-Type": "application/json" });
241
329
  res.end(JSON.stringify({ updatedAt: new Date().toISOString(), servers }));
242
330
  }
@@ -250,7 +338,7 @@ export async function launchDashboardServer(stateDir) {
250
338
  res.writeHead(200, {
251
339
  "Content-Type": "text/event-stream",
252
340
  "Cache-Control": "no-cache",
253
- "Connection": "keep-alive",
341
+ Connection: "keep-alive",
254
342
  });
255
343
  // Drain existing events so the client starts with history
256
344
  const { data: existing, offset: initialOffset } = readFrom(eventsPath, 0);
@@ -282,7 +370,9 @@ export async function launchDashboardServer(stateDir) {
282
370
  try {
283
371
  watcher = watch(eventsPath, onWatch);
284
372
  }
285
- catch { /* give up */ }
373
+ catch {
374
+ /* give up */
375
+ }
286
376
  }
287
377
  if (existsSync(eventsPath)) {
288
378
  startFileWatch();
@@ -325,7 +415,10 @@ export async function launchDashboardServer(stateDir) {
325
415
  }
326
416
  catch (e) {
327
417
  res.writeHead(500, { "Content-Type": "application/json" });
328
- res.end(JSON.stringify({ error: "game_state_unavailable", detail: String(e) }));
418
+ res.end(JSON.stringify({
419
+ error: "game_state_unavailable",
420
+ detail: String(e),
421
+ }));
329
422
  }
330
423
  return;
331
424
  }
@@ -335,6 +428,7 @@ export async function launchDashboardServer(stateDir) {
335
428
  let body = "";
336
429
  let tooBig = false;
337
430
  req.on("data", (chunk) => {
431
+ // guardrails-allow PREVENT-PI-004: loopback dashboard request body (local)
338
432
  if (body.length > 65536) {
339
433
  tooBig = true;
340
434
  return;
@@ -359,7 +453,9 @@ export async function launchDashboardServer(stateDir) {
359
453
  // Reject valid-but-non-object JSON (null/[]/42) — dereferencing
360
454
  // patch.game_mode_on would throw an unhandled TypeError inside this
361
455
  // 'end' listener and crash the detached server (audit P1: loopback DoS).
362
- if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
456
+ if (typeof patch !== "object" ||
457
+ patch === null ||
458
+ Array.isArray(patch)) {
363
459
  res.writeHead(400, { "Content-Type": "application/json" });
364
460
  res.end(JSON.stringify({ error: "invalid_patch_object" }));
365
461
  return;
@@ -380,7 +476,8 @@ export async function launchDashboardServer(stateDir) {
380
476
  clean.theme = patch.theme;
381
477
  }
382
478
  if (patch.tui_display_mode != null) {
383
- if (patch.tui_display_mode !== "full" && patch.tui_display_mode !== "minimal")
479
+ if (patch.tui_display_mode !== "full" &&
480
+ patch.tui_display_mode !== "minimal")
384
481
  bad = true;
385
482
  else
386
483
  clean.tui_display_mode = patch.tui_display_mode;
@@ -397,7 +494,10 @@ export async function launchDashboardServer(stateDir) {
397
494
  }
398
495
  catch (e) {
399
496
  res.writeHead(500, { "Content-Type": "application/json" });
400
- res.end(JSON.stringify({ error: "game_state_write_failed", detail: String(e) }));
497
+ res.end(JSON.stringify({
498
+ error: "game_state_write_failed",
499
+ detail: String(e),
500
+ }));
401
501
  }
402
502
  });
403
503
  return;
@@ -440,7 +540,128 @@ export async function launchDashboardServer(stateDir) {
440
540
  }
441
541
  catch (e) {
442
542
  res.writeHead(500, { "Content-Type": "application/json" });
443
- res.end(JSON.stringify({ error: "game_scores_unavailable", detail: String(e) }));
543
+ res.end(JSON.stringify({
544
+ error: "game_scores_unavailable",
545
+ detail: String(e),
546
+ }));
547
+ }
548
+ return;
549
+ }
550
+ // /api/perf — v0.8.8 Perf dashboard tab. GET returns rolling-window
551
+ // aggregates over perf_samples: per-kind p50/p95 (turn/provider latency,
552
+ // tps avg, db recompute, disk write), latest rss/heap, cpu user/sys delta,
553
+ // cache hit %, plus the diag recompute/skip/replay counts (read from
554
+ // dashboard.json snapshot if available). The dashboard server is a detached
555
+ // child with no MegaRuntime ref, so it reads perf_samples via a require()'d
556
+ // sqlite helper (same pattern as /api/game-scores). Unknown/invalid params
557
+ // are clamped (never throw). Non-GET -> 405. PREVENT-PI-004: loopback.
558
+ if (req.url?.startsWith("/api/perf")) {
559
+ const pfReq = createRequire(import.meta.url);
560
+ const { readPerfSamples } = pfReq("../../src/store/sqlite.js");
561
+ if (req.method !== "GET") {
562
+ res.writeHead(405, { "Content-Type": "application/json" }); // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
563
+ res.end(JSON.stringify({ error: "method_not_allowed" }));
564
+ return;
565
+ }
566
+ try {
567
+ const url = new URL(req.url, "http://x"); // guardrails-allow PREVENT-PI-004: localhost dashboard URL base (loopback-only)
568
+ let minutes = Number(url.searchParams.get("minutes") ?? "30");
569
+ if (!Number.isFinite(minutes) || minutes <= 0)
570
+ minutes = 30;
571
+ minutes = Math.min(minutes, 1440); // cap at 24h
572
+ const sinceTs = Date.now() - minutes * 60_000;
573
+ const rows = readPerfSamples(stateDir, sinceTs); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
574
+ const byKind = new Map();
575
+ for (const r of rows) {
576
+ let arr = byKind.get(r.kind);
577
+ if (!arr) {
578
+ arr = [];
579
+ byKind.set(r.kind, arr);
580
+ }
581
+ arr.push(r.value);
582
+ }
583
+ // Nearest-rank percentile (ceil(p/100*n)-1, clamped). Code-controlled,
584
+ // never user input (PREVENT-002 safe).
585
+ function pct(arr, p) {
586
+ if (!arr.length)
587
+ return 0;
588
+ const s = [...arr].sort((a, b) => a - b);
589
+ const idx = Math.min(s.length - 1, Math.max(0, Math.ceil((p / 100) * s.length) - 1));
590
+ return s[idx];
591
+ }
592
+ function avg(arr) {
593
+ if (!arr.length)
594
+ return 0;
595
+ return arr.reduce((a, b) => a + b, 0) / arr.length;
596
+ }
597
+ // rows are ASC by ts, so the last pushed value is the most recent.
598
+ function latest(arr) {
599
+ return arr.length ? arr[arr.length - 1] : 0;
600
+ }
601
+ const get = (k) => byKind.get(k) ?? [];
602
+ // diag counters live in the runtime-written dashboard.json (the server is
603
+ // a detached child with no MegaRuntime ref). Read defensively — absent
604
+ // until the first snapshot() write (PREVENT-001: assign before access).
605
+ let diag = null;
606
+ try {
607
+ const raw = readFileSync(snapshotPath, "utf-8");
608
+ const parsed = JSON.parse(raw);
609
+ if (parsed && typeof parsed === "object" && parsed.diag)
610
+ diag = parsed.diag;
611
+ }
612
+ catch {
613
+ /* dashboard.json not written yet */
614
+ }
615
+ res.writeHead(200, { "Content-Type": "application/json" });
616
+ res.end(JSON.stringify({
617
+ updatedAt: new Date().toISOString(),
618
+ windowMinutes: minutes,
619
+ sampleCount: rows.length,
620
+ turn_latency_ms: {
621
+ p50: pct(get("turn_latency_ms"), 50),
622
+ p95: pct(get("turn_latency_ms"), 95),
623
+ n: get("turn_latency_ms").length,
624
+ },
625
+ provider_latency_ms: {
626
+ p50: pct(get("provider_latency_ms"), 50),
627
+ p95: pct(get("provider_latency_ms"), 95),
628
+ n: get("provider_latency_ms").length,
629
+ },
630
+ tps: { avg: avg(get("tps")), n: get("tps").length },
631
+ cache_hit_pct: {
632
+ avg: avg(get("cache_hit_pct")),
633
+ latest: latest(get("cache_hit_pct")),
634
+ n: get("cache_hit_pct").length,
635
+ },
636
+ db_recompute_ms: {
637
+ p50: pct(get("db_recompute_ms"), 50),
638
+ p95: pct(get("db_recompute_ms"), 95),
639
+ n: get("db_recompute_ms").length,
640
+ },
641
+ disk_write_ms: {
642
+ p50: pct(get("disk_write_ms"), 50),
643
+ p95: pct(get("disk_write_ms"), 95),
644
+ n: get("disk_write_ms").length,
645
+ },
646
+ rss_mb: { latest: latest(get("rss_mb")), n: get("rss_mb").length },
647
+ heap_mb: {
648
+ latest: latest(get("heap_mb")),
649
+ n: get("heap_mb").length,
650
+ },
651
+ cpu_user_ms: {
652
+ latest: latest(get("cpu_user_ms")),
653
+ n: get("cpu_user_ms").length,
654
+ },
655
+ cpu_sys_ms: {
656
+ latest: latest(get("cpu_sys_ms")),
657
+ n: get("cpu_sys_ms").length,
658
+ },
659
+ diag,
660
+ }));
661
+ }
662
+ catch (e) {
663
+ res.writeHead(500, { "Content-Type": "application/json" });
664
+ res.end(JSON.stringify({ error: "perf_unavailable", detail: String(e) }));
444
665
  }
445
666
  return;
446
667
  }
@@ -463,11 +684,21 @@ export async function launchDashboardServer(stateDir) {
463
684
  }
464
685
  catch (e) {
465
686
  res.writeHead(500, { "Content-Type": "application/json" });
466
- res.end(JSON.stringify({ error: "achievements_unavailable", detail: String(e) }));
687
+ res.end(JSON.stringify({
688
+ error: "achievements_unavailable",
689
+ detail: String(e),
690
+ }));
467
691
  }
468
692
  return;
469
693
  }
470
- // Fallback — serve the dashboard
694
+ // Fallback — serve the React client build (SPA route) or legacy dashboard.
695
+ // Non-/api/* GETs hit here: serve client assets if built, else inline HTML.
696
+ if (req.method === "GET" &&
697
+ req.url &&
698
+ !req.url.startsWith("/api/") &&
699
+ serveClientAsset(req.url, res)) {
700
+ return;
701
+ }
471
702
  const tier = readSnapshot(snapshotPath).tier;
472
703
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
473
704
  res.end(dashboardHtml(tier));
@@ -505,7 +736,11 @@ export async function launchDashboardServer(stateDir) {
505
736
  const v4Handler = server.listeners("request")[0];
506
737
  if (v4Handler) {
507
738
  v6 = createServer((r, s) => v4Handler.call(server, r, s));
508
- v6.on("error", (e) => log("ipv6 loopback bind skipped", { port, code: e.code, message: e.message }));
739
+ v6.on("error", (e) => log("ipv6 loopback bind skipped", {
740
+ port,
741
+ code: e.code,
742
+ message: e.message,
743
+ }));
509
744
  v6.listen(port, "::1", () => log("ipv6 loopback bound", { port })); // guardrails-allow PREVENT-PI-004: IPv6 loopback (::1) mirror of the localhost dashboard server
510
745
  }
511
746
  // Write port.pid
@@ -520,12 +755,16 @@ export async function launchDashboardServer(stateDir) {
520
755
  try {
521
756
  unlinkSync(portFile);
522
757
  }
523
- catch { /* already gone */ }
758
+ catch {
759
+ /* already gone */
760
+ }
524
761
  server.close();
525
762
  try {
526
763
  v6?.close();
527
764
  }
528
- catch { /* not bound */ }
765
+ catch {
766
+ /* not bound */
767
+ }
529
768
  process.exit(0);
530
769
  };
531
770
  process.on("SIGTERM", cleanup);
@@ -0,0 +1,8 @@
1
+ export function setupTailscaleServe(port = 3000, httpsPort = 443) {
2
+ // Tailscale serve: exposes localhost dashboard securely.
3
+ // Only activates when TAILSCALE_ENABLED=1 is set.
4
+ if (process.env.TAILSCALE_ENABLED !== "1")
5
+ return false;
6
+ console.log(`[tailscale] Serve enabled on port ${port} (https:${httpsPort})`);
7
+ return true;
8
+ }
@@ -1,5 +1,9 @@
1
1
  /**
2
2
  * dashboard-server/types.ts — shared types for the dashboard server.
3
+ *
4
+ * Re-exports shared shapes from `api-contracts/` where they overlap with
5
+ * the legacy local types, while preserving 100% backward compatibility for
6
+ * all existing consumers (server.ts, index-reader.ts, snapshot.ts).
3
7
  */
4
8
  // Active-window cutoff (seconds) for the /api/servers endpoint.
5
9
  export const ACTIVE_WINDOW_SEC = 1800;
@@ -23,9 +23,18 @@ export class Dashboard {
23
23
  this.snapshotPath = join(stateDir, "dashboard.json");
24
24
  this.eventsPath = join(stateDir, "events.log");
25
25
  }
26
+ /** v0.8.8: duration (ms) of the last dashboard.json write — read by
27
+ * MegaRuntime.snapshot() to record a `disk_write_ms` perf sample without
28
+ * wrapping the giant snapshot object literal at the call site. */
29
+ _lastWriteMs = 0;
30
+ get lastWriteMs() {
31
+ return this._lastWriteMs;
32
+ }
26
33
  /** Write a full state snapshot (atomically replaces previous). */
27
34
  snapshot(data) {
35
+ const t = performance.now();
28
36
  writeFileSync(this.snapshotPath, JSON.stringify(data, null, 2) + "\n");
37
+ this._lastWriteMs = performance.now() - t;
29
38
  }
30
39
  /** Append a timestamped JSONL event line. */
31
40
  event(type, data) {
@@ -0,0 +1,71 @@
1
+ import { recordPerfSample } from "../../src/store/sqlite.js";
2
+ /** Narrow a turn_end message to its usage block when it is an assistant msg. */
3
+ function usageOf(msg) {
4
+ if (msg.role !== "assistant" || !msg.usage)
5
+ return null;
6
+ return msg.usage;
7
+ }
8
+ /** Register perf instrumentation handlers + start the 5s cpu/mem interval. */
9
+ export function registerPerfHandler(pi, runtime) {
10
+ // turn_start: record the wall-clock start of the turn. Using Date.now() (not
11
+ // event.timestamp) so the turn_end duration is on ONE clock — mixing pi's
12
+ // timestamp with Date.now() would skew the delta. Also (re)arms the cpu/mem
13
+ // interval so a new session after a dispose() resumes sampling on its first
14
+ // turn (the interval is cleared in runtime.dispose()).
15
+ pi.on("turn_start", async () => {
16
+ try {
17
+ runtime.perfTurnStart = Date.now();
18
+ runtime.ensurePerfInterval();
19
+ }
20
+ catch {
21
+ /* non-fatal */
22
+ }
23
+ });
24
+ // turn_end: compute turn latency + TPS + cache hit % from the assistant
25
+ // message's usage block. One perf_samples row per metric per turn.
26
+ pi.on("turn_end", async (event) => {
27
+ try {
28
+ if (runtime.perfTurnStart > 0) {
29
+ const durMs = Date.now() - runtime.perfTurnStart;
30
+ recordPerfSample(runtime.currentStateDir, "turn_latency_ms", durMs, {
31
+ turnIndex: event.turnIndex,
32
+ });
33
+ const u = usageOf(event.message);
34
+ if (u) {
35
+ const durSec = Math.max(durMs / 1000, 0.001);
36
+ recordPerfSample(runtime.currentStateDir, "tps", u.output / durSec, { outputTokens: u.output });
37
+ const denom = u.cacheRead + u.input + u.cacheWrite;
38
+ const hitPct = denom > 0 ? (u.cacheRead / denom) * 100 : 0;
39
+ recordPerfSample(runtime.currentStateDir, "cache_hit_pct", hitPct, { input: u.input, cacheRead: u.cacheRead, cacheWrite: u.cacheWrite });
40
+ }
41
+ }
42
+ }
43
+ catch {
44
+ /* non-fatal: instrumentation must never break the agent loop */
45
+ }
46
+ });
47
+ // before_provider_request -> after_provider_response: raw round-trip latency
48
+ // to the model endpoint (HTTP status carried on the response event).
49
+ pi.on("before_provider_request", async () => {
50
+ try {
51
+ runtime.perfProviderStart = Date.now();
52
+ }
53
+ catch {
54
+ /* non-fatal */
55
+ }
56
+ });
57
+ pi.on("after_provider_response", async (event) => {
58
+ try {
59
+ if (runtime.perfProviderStart > 0) {
60
+ const lat = Date.now() - runtime.perfProviderStart;
61
+ recordPerfSample(runtime.currentStateDir, "provider_latency_ms", lat, { status: event.status });
62
+ }
63
+ }
64
+ catch {
65
+ /* non-fatal */
66
+ }
67
+ });
68
+ // Start the 5s cpu/mem sampling interval (one per MegaRuntime; cleared in
69
+ // runtime.dispose()). Idempotent — safe to call again after a dispose().
70
+ runtime.ensurePerfInterval();
71
+ }
@@ -2,6 +2,7 @@ import { registerSessionHandlers } from "./session-handlers.js";
2
2
  import { registerAgentHandlers } from "./agent-handlers.js";
3
3
  import { registerContextHandler } from "./context-handler.js";
4
4
  import { registerCompactHandlers } from "./compact-handlers.js";
5
+ import { registerPerfHandler } from "./perf-handler.js";
5
6
  /**
6
7
  * DIAG accessor for the headless test harness: the most recently constructed
7
8
  * MegaRuntime, so a test that loads the compiled extension via its default
@@ -18,4 +19,5 @@ export function registerEventHandlers(pi, runtime, config) {
18
19
  registerAgentHandlers(pi, runtime, config);
19
20
  registerContextHandler(pi, runtime, config);
20
21
  registerCompactHandlers(pi, runtime, config);
22
+ registerPerfHandler(pi, runtime);
21
23
  }
@@ -15,3 +15,4 @@ export * from "./mega-events/session-handlers.js";
15
15
  export * from "./mega-events/agent-handlers.js";
16
16
  export * from "./mega-events/context-handler.js";
17
17
  export * from "./mega-events/compact-handlers.js";
18
+ export * from "./mega-events/perf-handler.js";