pi-mega-compact 0.8.8 → 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 (88) 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/server.js +191 -39
  23. package/dist/extensions/dashboard-server/tailscale.js +8 -0
  24. package/dist/extensions/dashboard-server/types.js +4 -0
  25. package/dist/src/store/sqlite/connection.js +35 -0
  26. package/dist/src/store/sqlite/index-store.js +167 -0
  27. package/dist/src/store/sqlite/memory.js +54 -0
  28. package/dist/src/store/sqlite/minhash-lsh.js +47 -0
  29. package/dist/src/store/sqlite/sessions.js +39 -0
  30. package/dist/src/store/sqlite/transaction.js +19 -0
  31. package/dist/src/vectorStore/add.js +260 -0
  32. package/dist/src/vectorStore/dedup.js +52 -0
  33. package/dist/src/vectorStore/index.js +10 -0
  34. package/dist/src/vectorStore/queries.js +83 -0
  35. package/dist/src/vectorStore/search.js +95 -0
  36. package/dist/src/vectorStore/session.js +19 -0
  37. package/dist/src/vectorStore/store.js +105 -0
  38. package/dist/src/vectorStore/types.js +6 -0
  39. package/dist/src/vectorStore/utils.js +23 -0
  40. package/extensions/dashboard-client/package-lock.json +1771 -0
  41. package/extensions/dashboard-client/package.json +27 -0
  42. package/extensions/dashboard-client/src/App.tsx +82 -0
  43. package/extensions/dashboard-client/src/api/client.ts +145 -0
  44. package/extensions/dashboard-client/src/components/CacheStatusPerModel.tsx +44 -0
  45. package/extensions/dashboard-client/src/components/CompressionCard.tsx +61 -0
  46. package/extensions/dashboard-client/src/components/ContextGauge.tsx +59 -0
  47. package/extensions/dashboard-client/src/components/DataSafetyCard.tsx +49 -0
  48. package/extensions/dashboard-client/src/components/ErrorBoundary.tsx +53 -0
  49. package/extensions/dashboard-client/src/components/EventCategoryFilter.tsx +16 -0
  50. package/extensions/dashboard-client/src/components/EventStream.tsx +152 -0
  51. package/extensions/dashboard-client/src/components/LoadingSpinner.tsx +7 -0
  52. package/extensions/dashboard-client/src/components/MemoryStatusCard.tsx +24 -0
  53. package/extensions/dashboard-client/src/components/ModelBadge.tsx +41 -0
  54. package/extensions/dashboard-client/src/components/PerfChart.tsx +160 -0
  55. package/extensions/dashboard-client/src/components/RepoDetailModal.tsx +126 -0
  56. package/extensions/dashboard-client/src/components/RepoTable.tsx +150 -0
  57. package/extensions/dashboard-client/src/components/SessionInfo.tsx +65 -0
  58. package/extensions/dashboard-client/src/components/SummaryTiles.tsx +52 -0
  59. package/extensions/dashboard-client/src/components/TabBar.tsx +34 -0
  60. package/extensions/dashboard-client/src/components/TriggerStatus.tsx +62 -0
  61. package/extensions/dashboard-client/src/hooks/useApi.ts +77 -0
  62. package/extensions/dashboard-client/src/hooks/useSSE.ts +87 -0
  63. package/extensions/dashboard-client/src/index.html +12 -0
  64. package/extensions/dashboard-client/src/main.tsx +23 -0
  65. package/extensions/dashboard-client/src/styles/base.css +121 -0
  66. package/extensions/dashboard-client/src/styles/overview-events.css +318 -0
  67. package/extensions/dashboard-client/src/styles/repos-metrics.css +307 -0
  68. package/extensions/dashboard-client/src/tabs/ConfigTab.tsx +16 -0
  69. package/extensions/dashboard-client/src/tabs/EventsTab.tsx +37 -0
  70. package/extensions/dashboard-client/src/tabs/MetricsTab.tsx +49 -0
  71. package/extensions/dashboard-client/src/tabs/OverviewTab.tsx +80 -0
  72. package/extensions/dashboard-client/src/tabs/ReposTab.tsx +59 -0
  73. package/extensions/dashboard-client/tsconfig.json +28 -0
  74. package/extensions/dashboard-client/vite.config.ts +38 -0
  75. package/extensions/dashboard-server/api-contracts/core.ts +302 -0
  76. package/extensions/dashboard-server/api-contracts/endpoints.ts +476 -0
  77. package/extensions/dashboard-server/api-contracts/game.ts +145 -0
  78. package/extensions/dashboard-server/api-contracts/index.ts +159 -0
  79. package/extensions/dashboard-server/api-contracts/infrastructure.ts +465 -0
  80. package/extensions/dashboard-server/api-contracts/multi-repo.ts +328 -0
  81. package/extensions/dashboard-server/api-contracts/snapshot.ts +386 -0
  82. package/extensions/dashboard-server/api-contracts.test.ts +1050 -0
  83. package/extensions/dashboard-server/api-contracts.ts +96 -0
  84. package/extensions/dashboard-server/auth.ts +20 -0
  85. package/extensions/dashboard-server/server.ts +862 -582
  86. package/extensions/dashboard-server/tailscale.ts +7 -0
  87. package/extensions/dashboard-server/types.ts +23 -114
  88. package/package.json +2 -1
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Inline CSS for the dashboard.
3
+ *
4
+ * Extracted verbatim from the original monolithic `html-template.ts`.
5
+ * Kept as a single cohesive stylesheet — splitting CSS into per-tab files
6
+ * would fragment the design system (shared tokens, e.g. `.card`, `.stat-grid`,
7
+ * `.meter-*` are reused across all tabs).
8
+ */
9
+ export function styles() {
10
+ return `<style>
11
+ * { margin: 0; padding: 0; box-sizing: border-box; }
12
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #0d1117; color: #c9d1d9; padding: 24px; line-height: 1.5; }
13
+ h1 { font-size: 20px; font-weight: 600; margin-bottom: 20px; display: flex; align-items: center; gap: 10px; color: #f0f6fc; }
14
+ h1 .tier { background: #1f6feb; color: #fff; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; text-transform: uppercase; letter-spacing: .5px; }
15
+ .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
16
+ .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
17
+ .card.safe { border-color: #238636; }
18
+ .card.safe h2 { color: #3fb950; }
19
+ .safe-note { font-size: 12px; color: #8b949e; margin: 12px 0 0; line-height: 1.5; }
20
+ .value.ok { color: #3fb950; }
21
+ .label {
22
+ cursor: help;
23
+ border-bottom: 1px dotted #484f58;
24
+ }
25
+ .card.legend { grid-column: 1 / -1; }
26
+ .legend-list { margin: 0; padding-left: 18px; color: #c9d1d9; }
27
+ .legend-list li { margin-bottom: 8px; line-height: 1.5; }
28
+ .legend-list b { color: #f0f6fc; }
29
+ .legend-note { font-size: 12px; color: #8b949e; margin: 12px 0 0; font-style: italic; }
30
+ .card h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .5px; color: #8b949e; margin-bottom: 12px; font-weight: 600; }
31
+ .meter-track { background: #21262d; border-radius: 4px; height: 20px; overflow: hidden; margin: 8px 0; }
32
+ .meter-fill { height: 100%; border-radius: 4px; transition: width .6s ease; min-width: 2px; }
33
+ .meter-green { background: #238636; }
34
+ .meter-yellow { background: #d29922; }
35
+ .meter-red { background: #f85149; }
36
+ .meter-label { font-size: 24px; font-weight: 700; color: #f0f6fc; }
37
+ .meter-sub { font-size: 12px; color: #8b949e; }
38
+ .status-row { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-size: 14px; }
39
+ .status-row .bullet { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
40
+ .bullet-on { background: #3fb950; box-shadow: 0 0 6px #3fb95088; }
41
+ .bullet-off { background: #484f58; }
42
+ .bullet-na { background: #d29922; }
43
+ .state-text { font-size: 13px; color: #8b949e; margin-top: 8px; font-family: monospace; }
44
+ .stat-grid { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 14px; }
45
+ .stat-grid .label { color: #8b949e; }
46
+ .stat-grid .value { color: #f0f6fc; font-weight: 600; font-family: monospace; }
47
+ .conf-grid { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; font-size: 14px; }
48
+ .conf-grid .label { color: #8b949e; }
49
+ .conf-grid .value { color: #f0f6fc; font-family: monospace; }
50
+ .events { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
51
+ .events h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .5px; color: #8b949e; margin-bottom: 12px; font-weight: 600; }
52
+ .events-wrap { max-height: 240px; overflow-y: auto; font-family: monospace; font-size: 12px; }
53
+ .ev { padding: 3px 0; border-bottom: 1px solid #21262d; display: flex; gap: 8px; align-items: baseline; }
54
+ .ev:last-child { border-bottom: none; }
55
+ .ev-type { font-weight: 700; min-width: 70px; text-align: right; }
56
+ .ev-type-compact { color: #3fb950; }
57
+ .ev-type-recall { color: #a371f7; }
58
+ .ev-time { color: #484f58; font-size: 10px; min-width: 80px; }
59
+ .ev-detail { color: #8b949e; flex: 1; }
60
+ .updated { font-size: 11px; color: #484f58; margin-top: 16px; text-align: right; }
61
+ .empty { color: #484f58; font-style: italic; font-size: 13px; padding: 8px 0; }
62
+ .offline-banner { background: #f8514922; border: 1px solid #f85149; border-radius: 6px; padding: 10px 16px; margin-bottom: 16px; font-size: 13px; color: #f85149; display: none; }
63
+ .tabs { display: flex; gap: 8px; margin-bottom: 20px; }
64
+ .tab { background: #161b22; color: #8b949e; border: 1px solid #30363d; border-radius: 6px; padding: 8px 16px; font-size: 13px; font-weight: 600; cursor: pointer; transition: all .15s ease; }
65
+ .tab:hover { color: #c9d1d9; border-color: #484f58; }
66
+ .tab.active { background: #1f6feb; color: #fff; border-color: #1f6feb; }
67
+ .tab.future { opacity: .7; }
68
+ .tab .soon { font-size: 9px; font-weight: 700; background: #6e40c9; color: #fff; padding: 1px 6px; border-radius: 8px; margin-left: 6px; text-transform: uppercase; letter-spacing: .5px; }
69
+ .tab-panel { display: none; }
70
+ .tab-panel.active { display: block; }
71
+ .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin-bottom: 20px; }
72
+ .summary-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
73
+ .summary-card .num { font-size: 24px; font-weight: 700; color: #f0f6fc; }
74
+ .summary-card .lbl { font-size: 12px; color: #8b949e; text-transform: uppercase; letter-spacing: .5px; margin-top: 4px; }
75
+ table.repos { width: 100%; border-collapse: collapse; background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
76
+ table.repos th, table.repos td { text-align: left; padding: 10px 14px; font-size: 13px; border-bottom: 1px solid #21262d; }
77
+ table.repos th { color: #8b949e; text-transform: uppercase; letter-spacing: .5px; font-size: 11px; background: #0d1117; }
78
+ table.repos td.num { font-family: monospace; color: #f0f6fc; text-align: right; }
79
+ table.repos tr:last-child td { border-bottom: none; }
80
+ table.repos tr:hover td { background: #1c2128; }
81
+ .repo-model { color: #a371f7; }
82
+ .repo-none { color: #484f58; font-style: italic; }
83
+ .updated { font-size: 11px; color: #484f58; margin-top: 16px; text-align: right; }
84
+ .model-pill { background: #6e40c9; color: #fff; font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 10px; text-transform: uppercase; letter-spacing: .5px; }
85
+ .card.cost h2 { color: #a371f7; }
86
+ .cost-usd { font-size: 22px; font-weight: 700; color: #3fb950; }
87
+ .cost-sub { font-size: 12px; color: #8b949e; margin-top: 4px; }
88
+ .repo-link { cursor: pointer; }
89
+ .repo-link:hover td { color: #58a6ff; }
90
+ .repo-detail { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: none; align-items: center; justify-content: center; z-index: 50; }
91
+ .repo-detail.open { display: flex; }
92
+ .repo-detail-box { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 24px; width: 560px; max-width: 92vw; max-height: 86vh; overflow-y: auto; }
93
+ .repo-detail-box h2 { font-size: 14px; color: #f0f6fc; margin-bottom: 14px; display: flex; justify-content: space-between; align-items: center; }
94
+ .repo-close { cursor: pointer; color: #8b949e; font-size: 20px; line-height: 1; border: none; background: none; padding: 0 4px; }
95
+ .repo-close:hover { color: #f0f6fc; }
96
+ .repo-path { font-size: 11px; color: #484f58; word-break: break-all; margin: -8px 0 12px; }
97
+ .placeholder { text-align: center; padding: 48px 16px; color: #8b949e; }
98
+ .placeholder .em { font-size: 40px; margin-bottom: 12px; }
99
+ .placeholder h2 { font-size: 16px; color: #f0f6fc; margin-bottom: 8px; }
100
+ .placeholder p { font-size: 13px; line-height: 1.6; max-width: 420px; margin: 0 auto; }
101
+ </style>
102
+ `;
103
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * "Summary" tab panel — aggregate counts across all registered repos.
3
+ *
4
+ * Populated by the index poller in `script.ts`.
5
+ */
6
+ export function summaryTab() {
7
+ return `<!-- Summary (aggregate across all repos) -->
8
+ <div class="tab-panel" id="panel-summary">
9
+ <div class="summary-grid">
10
+ <div class="summary-card"><div class="num" id="sm-repos">0</div><div class="lbl">Repositories</div></div>
11
+ <div class="summary-card"><div class="num" id="sm-checkpoints">0</div><div class="lbl">Total Checkpoints</div></div>
12
+ <div class="summary-card"><div class="num" id="sm-saved">0</div><div class="lbl">Total Tokens Saved</div></div>
13
+ <div class="summary-card"><div class="num" id="sm-bytes">0 B</div><div class="lbl">Compressed-Original</div></div>
14
+ </div>
15
+ <div class="updated" id="sm-updated"></div>
16
+ </div>
17
+
18
+ `;
19
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * HTML template for the dashboard server — composer.
3
+ *
4
+ * Originally a single 518-line monolithic file returning one large HTML page
5
+ * template. Split into per-concern fragments under `./html/` (each tab is its
6
+ * own file; plus shared styles, header chrome, the repo-detail modal, and the
7
+ * client script). This file composes them back into the full document.
8
+ *
9
+ * `tierName` is threaded into the heading badge and the Configuration card.
10
+ *
11
+ * Fragment layout:
12
+ * headOpen() — <!DOCTYPE html>…<title>
13
+ * styles() — <style>…</style> (shared CSS, design-system level)
14
+ * bodyOpen(tier) — </head><body>…<h1>…</nav> (incl. High Score future tab)
15
+ * currentRepoTab(tier) — panel-current (cards + event stream + in-panel table)
16
+ * allReposTab() — panel-all (machine-wide repo table)
17
+ * summaryTab() — panel-summary (aggregate cards)
18
+ * highScoreTab() — panel-highscore (FUTURE placeholder, next milestone)
19
+ * repoDetailModal() — per-repo overlay (position-independent)
20
+ * script() — <script>…</script></body></html>
21
+ */
22
+ import { headOpen } from "./html/head-open.js";
23
+ import { styles } from "./html/styles.js";
24
+ import { bodyOpen } from "./html/body-open.js";
25
+ import { currentRepoTab } from "./html/current-repo-tab.js";
26
+ import { allReposTab } from "./html/all-repos-tab.js";
27
+ import { summaryTab } from "./html/summary-tab.js";
28
+ import { highScoreTab } from "./html/high-score-tab.js";
29
+ import { repoDetailModal } from "./html/repo-detail-modal.js";
30
+ import { script } from "./html/script.js";
31
+ export function dashboardHtml(tierName) {
32
+ return (headOpen() +
33
+ styles() +
34
+ bodyOpen(tierName) +
35
+ currentRepoTab(tierName) +
36
+ repoDetailModal() +
37
+ allReposTab() +
38
+ summaryTab() +
39
+ highScoreTab() +
40
+ script());
41
+ }
@@ -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,10 @@ 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
+ }));
444
547
  }
445
548
  return;
446
549
  }
@@ -506,22 +609,53 @@ export async function launchDashboardServer(stateDir) {
506
609
  if (parsed && typeof parsed === "object" && parsed.diag)
507
610
  diag = parsed.diag;
508
611
  }
509
- catch { /* dashboard.json not written yet */ }
612
+ catch {
613
+ /* dashboard.json not written yet */
614
+ }
510
615
  res.writeHead(200, { "Content-Type": "application/json" });
511
616
  res.end(JSON.stringify({
512
617
  updatedAt: new Date().toISOString(),
513
618
  windowMinutes: minutes,
514
619
  sampleCount: rows.length,
515
- turn_latency_ms: { p50: pct(get("turn_latency_ms"), 50), p95: pct(get("turn_latency_ms"), 95), n: get("turn_latency_ms").length },
516
- provider_latency_ms: { p50: pct(get("provider_latency_ms"), 50), p95: pct(get("provider_latency_ms"), 95), n: get("provider_latency_ms").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
+ },
517
630
  tps: { avg: avg(get("tps")), n: get("tps").length },
518
- cache_hit_pct: { avg: avg(get("cache_hit_pct")), latest: latest(get("cache_hit_pct")), n: get("cache_hit_pct").length },
519
- db_recompute_ms: { p50: pct(get("db_recompute_ms"), 50), p95: pct(get("db_recompute_ms"), 95), n: get("db_recompute_ms").length },
520
- disk_write_ms: { p50: pct(get("disk_write_ms"), 50), p95: pct(get("disk_write_ms"), 95), n: get("disk_write_ms").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
+ },
521
646
  rss_mb: { latest: latest(get("rss_mb")), n: get("rss_mb").length },
522
- heap_mb: { latest: latest(get("heap_mb")), n: get("heap_mb").length },
523
- cpu_user_ms: { latest: latest(get("cpu_user_ms")), n: get("cpu_user_ms").length },
524
- cpu_sys_ms: { latest: latest(get("cpu_sys_ms")), n: get("cpu_sys_ms").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
+ },
525
659
  diag,
526
660
  }));
527
661
  }
@@ -550,11 +684,21 @@ export async function launchDashboardServer(stateDir) {
550
684
  }
551
685
  catch (e) {
552
686
  res.writeHead(500, { "Content-Type": "application/json" });
553
- res.end(JSON.stringify({ error: "achievements_unavailable", detail: String(e) }));
687
+ res.end(JSON.stringify({
688
+ error: "achievements_unavailable",
689
+ detail: String(e),
690
+ }));
554
691
  }
555
692
  return;
556
693
  }
557
- // 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
+ }
558
702
  const tier = readSnapshot(snapshotPath).tier;
559
703
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
560
704
  res.end(dashboardHtml(tier));
@@ -592,7 +736,11 @@ export async function launchDashboardServer(stateDir) {
592
736
  const v4Handler = server.listeners("request")[0];
593
737
  if (v4Handler) {
594
738
  v6 = createServer((r, s) => v4Handler.call(server, r, s));
595
- 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
+ }));
596
744
  v6.listen(port, "::1", () => log("ipv6 loopback bound", { port })); // guardrails-allow PREVENT-PI-004: IPv6 loopback (::1) mirror of the localhost dashboard server
597
745
  }
598
746
  // Write port.pid
@@ -607,12 +755,16 @@ export async function launchDashboardServer(stateDir) {
607
755
  try {
608
756
  unlinkSync(portFile);
609
757
  }
610
- catch { /* already gone */ }
758
+ catch {
759
+ /* already gone */
760
+ }
611
761
  server.close();
612
762
  try {
613
763
  v6?.close();
614
764
  }
615
- catch { /* not bound */ }
765
+ catch {
766
+ /* not bound */
767
+ }
616
768
  process.exit(0);
617
769
  };
618
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;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Per-repo SQLite connection management (open / reuse / close).
3
+ *
4
+ * In-process cache so the same stateDir reuses one connection (and so a fresh
5
+ * VectorStore over the same dir shares the open DB). Cross-process durability
6
+ * comes from reopening the same file path — proven by the integration test.
7
+ */
8
+ import { DatabaseSync } from "node:sqlite";
9
+ import { existsSync, mkdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { getStateDir } from "../../store.js";
12
+ import { initSchema } from "./schema.js";
13
+ const cache = new Map();
14
+ /** Open (or reuse) the SQLite store for a state dir. */
15
+ export function openStore(stateDir = getStateDir()) {
16
+ const existing = cache.get(stateDir);
17
+ if (existing)
18
+ return existing;
19
+ if (!existsSync(stateDir))
20
+ mkdirSync(stateDir, { recursive: true });
21
+ const db = new DatabaseSync(join(stateDir, "sqlite.db"));
22
+ db.exec("PRAGMA journal_mode = WAL");
23
+ db.exec("PRAGMA foreign_keys = ON");
24
+ initSchema(db);
25
+ cache.set(stateDir, db);
26
+ return db;
27
+ }
28
+ /** Close and evict a cached connection (test teardown only). */
29
+ export function closeStore(stateDir) {
30
+ const db = cache.get(stateDir);
31
+ if (db) {
32
+ db.close();
33
+ cache.delete(stateDir);
34
+ }
35
+ }