@blackbelt-technology/pi-agent-dashboard 0.5.3 → 0.5.4

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 (212) hide show
  1. package/AGENTS.md +19 -30
  2. package/README.md +69 -1
  3. package/docs/architecture.md +89 -165
  4. package/package.json +10 -7
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/bridge-default-model-gate.test.ts +47 -0
  7. package/packages/extension/src/__tests__/bridge-followup-chat-order.test.ts +215 -0
  8. package/packages/extension/src/__tests__/bridge-followup-multi-entry.test.ts +202 -0
  9. package/packages/extension/src/__tests__/bridge-queue-update-forward.test.ts +77 -0
  10. package/packages/extension/src/__tests__/bridge-retry-ordering.test.ts +148 -0
  11. package/packages/extension/src/__tests__/bridge-shadow-queue-drain.test.ts +221 -0
  12. package/packages/extension/src/__tests__/bridge-shadow-queue-gate.test.ts +299 -0
  13. package/packages/extension/src/__tests__/bridge-shutdown-reset.test.ts +238 -0
  14. package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +127 -31
  15. package/packages/extension/src/__tests__/command-handler.test.ts +105 -3
  16. package/packages/extension/src/__tests__/fixtures/usage-limit-error-strings.ts +127 -0
  17. package/packages/extension/src/__tests__/source-detector.test.ts +15 -0
  18. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +12 -0
  19. package/packages/extension/src/bridge-default-model-gate.ts +32 -0
  20. package/packages/extension/src/bridge.ts +299 -20
  21. package/packages/extension/src/command-handler.ts +53 -7
  22. package/packages/extension/src/dashboard-default-adapter.ts +5 -0
  23. package/packages/extension/src/prompt-bus.ts +15 -0
  24. package/packages/extension/src/slash-dispatch.ts +30 -15
  25. package/packages/extension/src/source-detector.ts +13 -5
  26. package/packages/extension/src/usage-limit-orderer.ts +18 -1
  27. package/packages/server/bin/pi-dashboard.mjs +62 -14
  28. package/packages/server/package.json +9 -5
  29. package/packages/server/src/__tests__/browser-gateway-register-handler.test.ts +69 -0
  30. package/packages/server/src/__tests__/cli-env-no-clobber.test.ts +46 -0
  31. package/packages/server/src/__tests__/cli-no-bootstrap-references.test.ts +69 -0
  32. package/packages/server/src/__tests__/cli-parse.test.ts +9 -10
  33. package/packages/server/src/__tests__/cli-version.test.ts +151 -0
  34. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +9 -0
  35. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +9 -0
  36. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +9 -0
  37. package/packages/server/src/__tests__/directory-service-toctou.test.ts +9 -0
  38. package/packages/server/src/__tests__/directory-service.test.ts +9 -0
  39. package/packages/server/src/__tests__/doctor-route.test.ts +53 -0
  40. package/packages/server/src/__tests__/event-wiring-queue-state.test.ts +156 -0
  41. package/packages/server/src/__tests__/event-wiring-resume-clear.test.ts +105 -0
  42. package/packages/server/src/__tests__/health-shape.test.ts +35 -12
  43. package/packages/server/src/__tests__/installed-package-enricher.test.ts +12 -12
  44. package/packages/server/src/__tests__/is-activity-event.test.ts +4 -7
  45. package/packages/server/src/__tests__/package-routes.test.ts +6 -2
  46. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +10 -13
  47. package/packages/server/src/__tests__/pi-core-checker.test.ts +2 -2
  48. package/packages/server/src/__tests__/pi-version-skew.test.ts +3 -2
  49. package/packages/server/src/__tests__/plugin-activation-routes.test.ts +267 -0
  50. package/packages/server/src/__tests__/plugin-intent-cache.test.ts +75 -0
  51. package/packages/server/src/__tests__/preferences-store.test.ts +196 -0
  52. package/packages/server/src/__tests__/reattach-placement.test.ts +9 -0
  53. package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
  54. package/packages/server/src/__tests__/recovery-server.test.ts +203 -0
  55. package/packages/server/src/__tests__/session-action-handler-clear-queue.test.ts +153 -0
  56. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +43 -0
  57. package/packages/server/src/__tests__/session-order-manager.test.ts +9 -0
  58. package/packages/server/src/__tests__/session-order-reboot.test.ts +9 -0
  59. package/packages/server/src/__tests__/session-ordering-integration.test.ts +9 -0
  60. package/packages/server/src/browser-gateway.ts +83 -5
  61. package/packages/server/src/browser-handlers/directory-handler.ts +69 -0
  62. package/packages/server/src/browser-handlers/session-action-handler.ts +89 -0
  63. package/packages/server/src/browser-handlers/subscription-handler.ts +23 -0
  64. package/packages/server/src/changelog-parser.ts +1 -1
  65. package/packages/server/src/cli.ts +68 -250
  66. package/packages/server/src/event-status-extraction.ts +14 -62
  67. package/packages/server/src/event-wiring.ts +23 -10
  68. package/packages/server/src/memory-session-manager.ts +4 -0
  69. package/packages/server/src/pi-core-checker.ts +1 -1
  70. package/packages/server/src/pi-dev-version-check.ts +1 -1
  71. package/packages/server/src/pi-version-skew.ts +24 -46
  72. package/packages/server/src/plugin-intent-cache.ts +67 -0
  73. package/packages/server/src/preferences-store.ts +199 -13
  74. package/packages/server/src/recovery-server.ts +366 -0
  75. package/packages/server/src/routes/__tests__/manifest-route.test.ts +138 -0
  76. package/packages/server/src/routes/doctor-routes.ts +26 -21
  77. package/packages/server/src/routes/manifest-route.ts +162 -0
  78. package/packages/server/src/routes/openspec-routes.ts +4 -25
  79. package/packages/server/src/routes/pi-changelog-routes.ts +5 -24
  80. package/packages/server/src/routes/pi-core-routes.ts +3 -23
  81. package/packages/server/src/routes/plugin-activation-routes.ts +193 -0
  82. package/packages/server/src/routes/recommended-routes.ts +21 -0
  83. package/packages/server/src/routes/system-routes.ts +73 -11
  84. package/packages/server/src/server.ts +105 -307
  85. package/packages/server/src/session-api.ts +5 -63
  86. package/packages/shared/package.json +1 -1
  87. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +28 -0
  88. package/packages/shared/src/__tests__/binary-lookup-spawn-env.test.ts +61 -0
  89. package/packages/shared/src/__tests__/binary-lookup.test.ts +16 -0
  90. package/packages/shared/src/__tests__/bridge-register.test.ts +67 -0
  91. package/packages/shared/src/__tests__/ci-electron-no-side-effects.test.ts +129 -0
  92. package/packages/shared/src/__tests__/config.test.ts +40 -0
  93. package/packages/shared/src/__tests__/dashboard-paths.test.ts +81 -0
  94. package/packages/shared/src/__tests__/ensure-windows-path.test.ts +112 -0
  95. package/packages/shared/src/__tests__/intent-types.test.ts +120 -0
  96. package/packages/shared/src/__tests__/jiti-packages-parity.test.ts +85 -0
  97. package/packages/shared/src/__tests__/legacy-managed-dir.test.ts +59 -0
  98. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +12 -0
  99. package/packages/shared/src/__tests__/no-electron-execpath-spawn.test.ts +149 -0
  100. package/packages/shared/src/__tests__/no-flow-command-route-claims.test.ts +71 -0
  101. package/packages/shared/src/__tests__/no-flow-references-in-shell.test.ts +221 -0
  102. package/packages/shared/src/__tests__/no-managed-dir-reference.test.ts +134 -0
  103. package/packages/shared/src/__tests__/no-pi-dashboard-version-jiti-gate.test.ts +41 -0
  104. package/packages/shared/src/__tests__/no-primitive-direct-import.test.ts +235 -0
  105. package/packages/shared/src/__tests__/no-server-imports-in-resolver.test.ts +53 -0
  106. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +54 -101
  107. package/packages/shared/src/__tests__/node-spawn.test.ts +29 -13
  108. package/packages/shared/src/__tests__/pi-package-resolver.test.ts +300 -0
  109. package/packages/shared/src/__tests__/plugin-activation-contracts.test.ts +74 -0
  110. package/packages/shared/src/__tests__/plugin-bridge-classify-source.test.ts +73 -0
  111. package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +17 -5
  112. package/packages/shared/src/__tests__/plugin-bridge-register-packages.test.ts +233 -0
  113. package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +19 -9
  114. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +154 -15
  115. package/packages/shared/src/__tests__/recommended-extensions.test.ts +28 -10
  116. package/packages/shared/src/__tests__/resolver-parity-with-scanner.test.ts +76 -0
  117. package/packages/shared/src/__tests__/server-identity.test.ts +127 -0
  118. package/packages/shared/src/__tests__/server-launcher.test.ts +35 -0
  119. package/packages/shared/src/__tests__/source-matching.test.ts +5 -5
  120. package/packages/shared/src/__tests__/sync-versions-spec.test.ts +76 -0
  121. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +50 -2
  122. package/packages/shared/src/bridge-register.ts +35 -2
  123. package/packages/shared/src/browser-protocol.ts +176 -2
  124. package/packages/shared/src/config.ts +12 -0
  125. package/packages/shared/src/dashboard-paths.ts +69 -0
  126. package/packages/shared/src/dashboard-plugin/index.ts +2 -0
  127. package/packages/shared/src/dashboard-plugin/intent-types.ts +93 -0
  128. package/packages/shared/src/dashboard-plugin/manifest-types.ts +55 -1
  129. package/packages/shared/src/dashboard-plugin/plugin-status.ts +82 -0
  130. package/packages/shared/src/dashboard-plugin/slot-props.ts +11 -0
  131. package/packages/shared/src/dashboard-plugin/slot-types.ts +16 -2
  132. package/packages/shared/src/dashboard-plugin/ui-primitives.ts +287 -0
  133. package/packages/shared/src/dashboard-starter.ts +22 -0
  134. package/packages/shared/src/doctor-core.ts +49 -27
  135. package/packages/shared/src/launch-source-types.ts +9 -9
  136. package/packages/shared/src/legacy-managed-dir.ts +97 -0
  137. package/packages/shared/src/mdns-discovery.ts +4 -1
  138. package/packages/shared/src/pi-package-resolver.ts +388 -0
  139. package/packages/shared/src/platform/binary-lookup.ts +27 -3
  140. package/packages/shared/src/platform/ensure-windows-path.ts +95 -0
  141. package/packages/shared/src/platform/exec.ts +22 -0
  142. package/packages/shared/src/platform/node-spawn.ts +42 -41
  143. package/packages/shared/src/plugin-bridge-register.ts +275 -18
  144. package/packages/shared/src/protocol.ts +94 -2
  145. package/packages/shared/src/recommended-extensions.ts +34 -10
  146. package/packages/shared/src/server-identity.ts +74 -5
  147. package/packages/shared/src/server-launcher.ts +20 -0
  148. package/packages/shared/src/source-matching.ts +1 -1
  149. package/packages/shared/src/tool-registry/__tests__/node-script-toargv-fallback.test.ts +84 -0
  150. package/packages/shared/src/tool-registry/definitions.ts +91 -7
  151. package/packages/shared/src/types.ts +12 -8
  152. package/scripts/maybe-patch-package.cjs +44 -0
  153. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +0 -263
  154. package/packages/server/src/__tests__/bootstrap-queue.test.ts +0 -120
  155. package/packages/server/src/__tests__/bootstrap-routes.test.ts +0 -125
  156. package/packages/server/src/__tests__/bootstrap-state.test.ts +0 -119
  157. package/packages/server/src/__tests__/cli-bootstrap.test.ts +0 -36
  158. package/packages/server/src/__tests__/event-status-extraction-flow.test.ts +0 -55
  159. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +0 -149
  160. package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +0 -180
  161. package/packages/server/src/__tests__/post-install-rescan.test.ts +0 -134
  162. package/packages/server/src/__tests__/system-routes-reextract.test.ts +0 -91
  163. package/packages/server/src/bootstrap-install-from-list.ts +0 -232
  164. package/packages/server/src/bootstrap-queue.ts +0 -130
  165. package/packages/server/src/bootstrap-state.ts +0 -159
  166. package/packages/server/src/legacy-pi-cleanup.ts +0 -151
  167. package/packages/server/src/routes/bootstrap-routes.ts +0 -125
  168. package/packages/shared/src/__tests__/bootstrap/README.md +0 -133
  169. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +0 -378
  170. package/packages/shared/src/__tests__/bootstrap/assertions.ts +0 -136
  171. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +0 -47
  172. package/packages/shared/src/__tests__/bootstrap/cube.ts +0 -66
  173. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +0 -84
  174. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +0 -90
  175. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +0 -34
  176. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +0 -20
  177. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +0 -62
  178. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +0 -34
  179. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +0 -49
  180. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +0 -12
  181. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +0 -156
  182. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +0 -157
  183. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +0 -102
  184. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +0 -76
  185. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +0 -94
  186. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +0 -87
  187. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +0 -143
  188. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +0 -64
  189. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +0 -77
  190. package/packages/shared/src/__tests__/bootstrap/families/index.ts +0 -19
  191. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +0 -61
  192. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +0 -50
  193. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +0 -272
  194. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +0 -58
  195. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +0 -84
  196. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +0 -9
  197. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +0 -85
  198. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +0 -122
  199. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +0 -36
  200. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +0 -39
  201. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +0 -220
  202. package/packages/shared/src/__tests__/bootstrap/harness.ts +0 -413
  203. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +0 -125
  204. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +0 -132
  205. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +0 -72
  206. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +0 -68
  207. package/packages/shared/src/__tests__/install-managed-node.test.ts +0 -192
  208. package/packages/shared/src/__tests__/installable-list.test.ts +0 -130
  209. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +0 -52
  210. package/packages/shared/src/bootstrap-install.ts +0 -406
  211. package/packages/shared/src/installable-list.ts +0 -152
  212. package/packages/shared/src/launch-source-flag.ts +0 -14
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Recovery HTTP server.
3
+ *
4
+ * Spun up by `cli.ts` when the main server can't start because a top-level
5
+ * runtime dependency is missing (`fastify`, `toad-cache`, etc.). Binds to
6
+ * the same port the real server would have used, so a user pointing their
7
+ * browser at http://localhost:8000 sees a status page instead of a refused
8
+ * connection.
9
+ *
10
+ * STRICT CONSTRAINT: this module imports ONLY node built-ins. If it
11
+ * imported a third-party module, that module could be the one that's
12
+ * missing — and the recovery server itself would fail to load. Keep it
13
+ * dependency-free.
14
+ */
15
+ import http from "node:http";
16
+ import { spawn } from "node:child_process";
17
+ import { fileURLToPath } from "node:url";
18
+ import path from "node:path";
19
+ import os from "node:os";
20
+ import fs from "node:fs";
21
+
22
+ export interface RecoveryInfo {
23
+ /** Port to bind. */
24
+ port: number;
25
+ /** The original error that prevented startup. */
26
+ error: Error;
27
+ /** Optional: extracted missing module identifier. */
28
+ missingModule?: string | null;
29
+ /** Optional: suggested reinstall command. */
30
+ suggestedFix?: string;
31
+ }
32
+
33
+ /**
34
+ * Extract the missing-module identifier from an `ERR_MODULE_NOT_FOUND` or
35
+ * legacy `MODULE_NOT_FOUND` error. Returns null if the error isn't of that
36
+ * shape.
37
+ *
38
+ * Examples it handles:
39
+ * "Cannot find module 'fastify'"
40
+ * "Cannot find module '/abs/path/foo.cjs'"
41
+ * "Cannot find package 'toad-cache' imported from /..."
42
+ * "Cannot find module 'file:///.../server.js' imported from /.../cli.ts"
43
+ */
44
+ export function parseModuleNotFoundError(err: unknown): string | null {
45
+ if (!err || typeof err !== "object") return null;
46
+ const e = err as { code?: string; message?: string };
47
+ const code = e.code;
48
+ const msg = e.message ?? "";
49
+ const isModuleErr =
50
+ code === "ERR_MODULE_NOT_FOUND" ||
51
+ code === "MODULE_NOT_FOUND" ||
52
+ /Cannot find (module|package)/.test(msg);
53
+ if (!isModuleErr) return null;
54
+
55
+ // Try "Cannot find module 'X'" / "Cannot find package 'X'"
56
+ const m1 = msg.match(/Cannot find (?:module|package) ['"]([^'"]+)['"]/);
57
+ if (m1) return m1[1];
58
+ return null;
59
+ }
60
+
61
+ /**
62
+ * Return true iff `err` looks like a top-level module-resolution failure
63
+ * (the class of error this recovery server exists to handle).
64
+ */
65
+ export function isModuleNotFoundError(err: unknown): boolean {
66
+ if (!err || typeof err !== "object") return false;
67
+ const e = err as { code?: string; message?: string };
68
+ return (
69
+ e.code === "ERR_MODULE_NOT_FOUND" ||
70
+ e.code === "MODULE_NOT_FOUND" ||
71
+ (typeof e.message === "string" && /Cannot find (module|package)/.test(e.message))
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Best-effort install-layout detection used to suggest the right reinstall
77
+ * command. Returns "electron" when running inside the packaged Electron
78
+ * resources tree, "npm-global" when running from a global npm install of
79
+ * @blackbelt-technology/pi-agent-dashboard, or "monorepo" / "unknown".
80
+ */
81
+ export function detectInstallLayout(scriptPath?: string): "electron" | "npm-global" | "monorepo" | "unknown" {
82
+ const p = scriptPath ?? (process.argv[1] ?? "");
83
+ if (/[/\\]Contents[/\\]Resources[/\\]/.test(p)) return "electron"; // macOS app bundle
84
+ if (/[/\\]resources[/\\]/.test(p) && /Electron/i.test(p)) return "electron";
85
+ if (/[/\\]node_modules[/\\]@blackbelt-technology[/\\]pi-agent-dashboard[/\\]/.test(p)) return "npm-global";
86
+ if (/[/\\]packages[/\\]server[/\\]src[/\\]cli\.ts$/.test(p)) return "monorepo";
87
+ return "unknown";
88
+ }
89
+
90
+ /**
91
+ * Suggested reinstall command for the detected layout.
92
+ */
93
+ export function suggestedReinstallCommand(layout: ReturnType<typeof detectInstallLayout>): string {
94
+ switch (layout) {
95
+ case "npm-global":
96
+ return "npm install -g @blackbelt-technology/pi-agent-dashboard";
97
+ case "electron":
98
+ return "Reinstall the Pi Dashboard application from your installer.";
99
+ case "monorepo":
100
+ return "npm install (from the repo root)";
101
+ default:
102
+ return "npm install -g @blackbelt-technology/pi-agent-dashboard";
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Build the HTML page served at `/`. Pure function — exported for testing.
108
+ */
109
+ export function buildRecoveryHtml(info: RecoveryInfo): string {
110
+ const escape = (s: string) =>
111
+ s
112
+ .replace(/&/g, "&amp;")
113
+ .replace(/</g, "&lt;")
114
+ .replace(/>/g, "&gt;")
115
+ .replace(/"/g, "&quot;");
116
+
117
+ const missing = info.missingModule ? escape(info.missingModule) : "(unknown)";
118
+ const fix = escape(info.suggestedFix ?? "");
119
+ const stack = escape(info.error.stack ?? info.error.message ?? String(info.error));
120
+
121
+ return `<!doctype html>
122
+ <html lang="en">
123
+ <head>
124
+ <meta charset="utf-8" />
125
+ <title>Pi Dashboard — Recovery Mode</title>
126
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
127
+ <style>
128
+ body { font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
129
+ background: #fef2f2; color: #1f2937; margin: 0; padding: 2rem; }
130
+ main { max-width: 720px; margin: 0 auto; background: white; border-radius: 12px;
131
+ box-shadow: 0 4px 16px rgba(0,0,0,.08); padding: 2rem; }
132
+ h1 { color: #b91c1c; margin: 0 0 .5rem; font-size: 1.5rem; }
133
+ .badge { display: inline-block; background: #fecaca; color: #991b1b;
134
+ padding: .15rem .5rem; border-radius: 4px; font-size: .75rem;
135
+ font-weight: 600; letter-spacing: .05em; text-transform: uppercase; }
136
+ code { background: #f3f4f6; padding: .1rem .35rem; border-radius: 3px;
137
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; }
138
+ pre { background: #1f2937; color: #f9fafb; padding: 1rem; border-radius: 6px;
139
+ overflow-x: auto; font-size: .8em; line-height: 1.4; max-height: 280px; }
140
+ button { background: #2563eb; color: white; border: 0; border-radius: 6px;
141
+ padding: .55rem 1rem; font-size: .9em; cursor: pointer; margin-right: .5rem; }
142
+ button:hover { background: #1d4ed8; }
143
+ button.secondary { background: #6b7280; }
144
+ button.secondary:hover { background: #4b5563; }
145
+ button:disabled { background: #9ca3af; cursor: not-allowed; }
146
+ #status { margin-top: 1rem; font-size: .9em; color: #4b5563; }
147
+ </style>
148
+ </head>
149
+ <body>
150
+ <main>
151
+ <span class="badge">Recovery Mode</span>
152
+ <h1>Dashboard failed to start</h1>
153
+ <p>The server could not load a required dependency:
154
+ <code>${missing}</code></p>
155
+ <p><strong>Suggested fix:</strong> <code>${fix}</code></p>
156
+ <div>
157
+ <button id="retry">Retry start</button>
158
+ <button id="reinstall" class="secondary">Reinstall dependencies</button>
159
+ </div>
160
+ <div id="status"></div>
161
+ <h3 style="margin-top: 1.5rem;">Error details</h3>
162
+ <pre>${stack}</pre>
163
+ </main>
164
+ <script>
165
+ const $ = (id) => document.getElementById(id);
166
+ const status = $("status");
167
+ function setBusy(b, msg) {
168
+ $("retry").disabled = b; $("reinstall").disabled = b;
169
+ status.textContent = msg;
170
+ }
171
+ async function post(path) {
172
+ const res = await fetch(path, { method: "POST" });
173
+ const text = await res.text();
174
+ return { ok: res.ok, text };
175
+ }
176
+ $("retry").addEventListener("click", async () => {
177
+ setBusy(true, "Retrying…");
178
+ const r = await post("/api/recovery/retry");
179
+ status.textContent = r.text;
180
+ if (r.ok) setTimeout(() => location.reload(), 1500);
181
+ else setBusy(false, "Retry failed: " + r.text);
182
+ });
183
+ $("reinstall").addEventListener("click", async () => {
184
+ setBusy(true, "Reinstalling… this may take a minute.");
185
+ const r = await post("/api/recovery/reinstall");
186
+ status.textContent = r.text;
187
+ setBusy(false, r.text);
188
+ });
189
+ </script>
190
+ </body>
191
+ </html>`;
192
+ }
193
+
194
+ /**
195
+ * Run `npm install -g ...` (or the per-layout equivalent) and stream
196
+ * progress lines to a callback. Resolves with the exit code.
197
+ */
198
+ function runReinstall(
199
+ layout: ReturnType<typeof detectInstallLayout>,
200
+ onLine: (s: string) => void,
201
+ ): Promise<number> {
202
+ return new Promise((resolve) => {
203
+ let cmd: string;
204
+ let args: string[];
205
+ if (layout === "monorepo") {
206
+ cmd = "npm";
207
+ args = ["install"];
208
+ } else {
209
+ cmd = "npm";
210
+ args = ["install", "-g", "@blackbelt-technology/pi-agent-dashboard"];
211
+ }
212
+ onLine(`> ${cmd} ${args.join(" ")}`);
213
+ const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], shell: false });
214
+ child.stdout?.on("data", (b: Buffer) => onLine(b.toString("utf8").trimEnd()));
215
+ child.stderr?.on("data", (b: Buffer) => onLine(b.toString("utf8").trimEnd()));
216
+ child.on("error", (e: Error) => {
217
+ onLine(`spawn error: ${e.message}`);
218
+ resolve(1);
219
+ });
220
+ child.on("exit", (code) => resolve(code ?? 1));
221
+ });
222
+ }
223
+
224
+ /**
225
+ * Start the recovery HTTP server. Does not return — the server stays bound
226
+ * to the port until the process exits (typically after `/api/recovery/retry`
227
+ * respawns the CLI and `process.exit`s).
228
+ *
229
+ * If the port is already bound (something else listening), this will log
230
+ * and exit with code 2 — better than silent infinite-recovery loops.
231
+ */
232
+ export async function startRecoveryServer(info: RecoveryInfo): Promise<void> {
233
+ const scriptPath = process.argv[1] ?? "";
234
+ const layout = detectInstallLayout(scriptPath);
235
+ const enrichedInfo: RecoveryInfo = {
236
+ ...info,
237
+ suggestedFix: info.suggestedFix ?? suggestedReinstallCommand(layout),
238
+ };
239
+
240
+ // Log a clear banner so log-tailers see what's happening.
241
+ console.error("");
242
+ console.error("══════════════════════════════════════════════════════════════");
243
+ console.error(" Pi Dashboard — entering RECOVERY MODE");
244
+ console.error(` reason: ${enrichedInfo.error.message}`);
245
+ console.error(` missing: ${enrichedInfo.missingModule ?? "(unknown)"}`);
246
+ console.error(` suggested: ${enrichedInfo.suggestedFix}`);
247
+ console.error(` serving recovery UI at http://localhost:${info.port}/`);
248
+ console.error("══════════════════════════════════════════════════════════════");
249
+ console.error("");
250
+
251
+ // Persist a snapshot of the failure under ~/.pi/dashboard/last-recovery.json
252
+ // so tooling/diagnostics can see why the server is in recovery mode.
253
+ try {
254
+ const dir = path.join(os.homedir(), ".pi", "dashboard");
255
+ fs.mkdirSync(dir, { recursive: true });
256
+ fs.writeFileSync(
257
+ path.join(dir, "last-recovery.json"),
258
+ JSON.stringify(
259
+ {
260
+ at: new Date().toISOString(),
261
+ port: info.port,
262
+ missingModule: enrichedInfo.missingModule ?? null,
263
+ error: enrichedInfo.error.message,
264
+ stack: enrichedInfo.error.stack ?? null,
265
+ layout,
266
+ scriptPath,
267
+ },
268
+ null,
269
+ 2,
270
+ ),
271
+ );
272
+ } catch {
273
+ // Non-fatal — recovery still works without the snapshot.
274
+ }
275
+
276
+ const server = http.createServer((req, res) => {
277
+ const url = req.url ?? "/";
278
+ if (req.method === "GET" && (url === "/" || url === "/index.html")) {
279
+ res.writeHead(200, {
280
+ "content-type": "text/html; charset=utf-8",
281
+ "cache-control": "no-cache, no-store, must-revalidate",
282
+ });
283
+ res.end(buildRecoveryHtml(enrichedInfo));
284
+ return;
285
+ }
286
+ if (req.method === "GET" && url === "/api/health") {
287
+ res.writeHead(200, { "content-type": "application/json" });
288
+ res.end(
289
+ JSON.stringify({
290
+ ok: false,
291
+ mode: "recovery",
292
+ missingModule: enrichedInfo.missingModule ?? null,
293
+ error: enrichedInfo.error.message,
294
+ suggestedFix: enrichedInfo.suggestedFix,
295
+ layout,
296
+ }),
297
+ );
298
+ return;
299
+ }
300
+ if (req.method === "POST" && url === "/api/recovery/retry") {
301
+ // Respawn ourselves detached, then exit.
302
+ try {
303
+ const cliPath = scriptPath || fileURLToPath(import.meta.url);
304
+ const child = spawn(process.execPath, [cliPath, ...process.argv.slice(2)], {
305
+ detached: true,
306
+ stdio: "ignore",
307
+ env: process.env,
308
+ });
309
+ child.unref();
310
+ res.writeHead(200, { "content-type": "text/plain" });
311
+ res.end("Respawning… give it a few seconds, then reload.");
312
+ // Defer exit so the response actually flushes.
313
+ setTimeout(() => process.exit(0), 250);
314
+ } catch (e) {
315
+ const msg = e instanceof Error ? e.message : String(e);
316
+ res.writeHead(500, { "content-type": "text/plain" });
317
+ res.end("Failed to respawn: " + msg);
318
+ }
319
+ return;
320
+ }
321
+ if (req.method === "POST" && url === "/api/recovery/reinstall") {
322
+ // Stream isn't easy via simple text response; just buffer and return.
323
+ const lines: string[] = [];
324
+ runReinstall(layout, (s) => {
325
+ lines.push(s);
326
+ console.log("[recovery-install] " + s);
327
+ }).then((code) => {
328
+ if (res.writableEnded) return;
329
+ if (code === 0) {
330
+ res.writeHead(200, { "content-type": "text/plain" });
331
+ res.end("Reinstall complete. Click Retry start.");
332
+ } else {
333
+ res.writeHead(500, { "content-type": "text/plain" });
334
+ res.end("Reinstall failed (exit " + code + ").\n\n" + lines.slice(-30).join("\n"));
335
+ }
336
+ });
337
+ return;
338
+ }
339
+
340
+ // Everything else: serve the same HTML so SPA-style links still work.
341
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
342
+ res.end(buildRecoveryHtml(enrichedInfo));
343
+ });
344
+
345
+ return new Promise<void>((resolve, reject) => {
346
+ server.once("error", (err: NodeJS.ErrnoException) => {
347
+ if (err.code === "EADDRINUSE") {
348
+ console.error(
349
+ `[recovery] port ${info.port} already in use — cannot bind recovery server. ` +
350
+ `Another process (possibly an older dashboard) is holding the port. ` +
351
+ `Run \`pi-dashboard stop\` or kill the holder, then retry.`,
352
+ );
353
+ process.exit(2);
354
+ }
355
+ reject(err);
356
+ });
357
+ server.listen(info.port, () => {
358
+ console.error(`[recovery] listening on http://localhost:${info.port}`);
359
+ // Never resolve — recovery server runs until the process exits.
360
+ // This promise stays pending so the caller `await`s forever.
361
+ // The caller may also choose to short-circuit with `return` after
362
+ // invoking us; either way is safe.
363
+ resolve();
364
+ });
365
+ });
366
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Tests for pure helpers in `manifest-route.ts`.
3
+ * See change: add-dynamic-pwa-manifest-naming.
4
+ */
5
+ import { describe, it, expect } from "vitest";
6
+ import {
7
+ stripPort,
8
+ resolveManifestSource,
9
+ buildManifestBody,
10
+ } from "../manifest-route.js";
11
+
12
+ describe("stripPort", () => {
13
+ it("returns empty for null/undefined/empty/whitespace", () => {
14
+ expect(stripPort(undefined)).toBe("");
15
+ expect(stripPort(null)).toBe("");
16
+ expect(stripPort("")).toBe("");
17
+ expect(stripPort(" ")).toBe("");
18
+ });
19
+
20
+ it("returns bare hostname unchanged (lower-cased)", () => {
21
+ expect(stripPort("mybox.local")).toBe("mybox.local");
22
+ expect(stripPort("MyBox.Local")).toBe("mybox.local");
23
+ });
24
+
25
+ it("strips port from host:port", () => {
26
+ expect(stripPort("mybox.local:8000")).toBe("mybox.local");
27
+ expect(stripPort("example.com:443")).toBe("example.com");
28
+ });
29
+
30
+ it("strips port from bracketed IPv6 with port", () => {
31
+ expect(stripPort("[::1]:8000")).toBe("::1");
32
+ expect(stripPort("[fe80::1]:443")).toBe("fe80::1");
33
+ });
34
+
35
+ it("handles bracketed IPv6 without port", () => {
36
+ expect(stripPort("[::1]")).toBe("::1");
37
+ });
38
+
39
+ it("leaves unbracketed IPv6 untouched (multiple colons)", () => {
40
+ // Non-conformant Host header — Node parses this as "::1" with no port.
41
+ // We don't try to be clever; pass through verbatim.
42
+ expect(stripPort("::1")).toBe("::1");
43
+ expect(stripPort("fe80::1")).toBe("fe80::1");
44
+ });
45
+
46
+ it("trims leading/trailing whitespace before parsing", () => {
47
+ expect(stripPort(" mybox:8000 ")).toBe("mybox");
48
+ });
49
+ });
50
+
51
+ describe("resolveManifestSource", () => {
52
+ const HOSTNAME = "macbook-pro";
53
+
54
+ it("returns config override when set", () => {
55
+ expect(resolveManifestSource("foo:8000", "Home NAS", HOSTNAME)).toBe("Home NAS");
56
+ expect(resolveManifestSource(undefined, "Home NAS", HOSTNAME)).toBe("Home NAS");
57
+ });
58
+
59
+ it("trims config override and treats whitespace-only as unset", () => {
60
+ expect(resolveManifestSource("foo:8000", " ", HOSTNAME)).toBe("foo");
61
+ expect(resolveManifestSource("foo:8000", "", HOSTNAME)).toBe("foo");
62
+ expect(resolveManifestSource("foo:8000", " Home NAS ", HOSTNAME)).toBe("Home NAS");
63
+ });
64
+
65
+ it("falls back to Host header (port stripped) when no override", () => {
66
+ expect(resolveManifestSource("mybox.local:8000", undefined, HOSTNAME)).toBe(
67
+ "mybox.local",
68
+ );
69
+ expect(resolveManifestSource("[::1]:8000", null, HOSTNAME)).toBe("::1");
70
+ });
71
+
72
+ it("falls back to os.hostname() when override and Host header are empty", () => {
73
+ expect(resolveManifestSource(undefined, undefined, HOSTNAME)).toBe(HOSTNAME);
74
+ expect(resolveManifestSource("", "", HOSTNAME)).toBe(HOSTNAME);
75
+ });
76
+
77
+ it("falls back to 'Pi-Dash' when everything is empty", () => {
78
+ expect(resolveManifestSource(undefined, undefined, "")).toBe("Pi-Dash");
79
+ expect(resolveManifestSource("", "", " ")).toBe("Pi-Dash");
80
+ });
81
+
82
+ it("override wins over Host header even when Host is non-empty", () => {
83
+ expect(resolveManifestSource("anything.local:8000", "Override", HOSTNAME)).toBe(
84
+ "Override",
85
+ );
86
+ });
87
+ });
88
+
89
+ describe("buildManifestBody", () => {
90
+ const BASE = {
91
+ icons: [{ src: "/icon-192.png", sizes: "192x192", type: "image/png" }],
92
+ theme_color: "#3b82f6",
93
+ background_color: "#0f172a",
94
+ display: "standalone",
95
+ start_url: "/",
96
+ name: "should-be-overridden",
97
+ short_name: "should-be-overridden",
98
+ };
99
+
100
+ it("spreads static base then overrides name/short_name/id", () => {
101
+ const body = buildManifestBody(BASE, "mybox.local");
102
+ expect(body.icons).toEqual(BASE.icons);
103
+ expect(body.theme_color).toBe("#3b82f6");
104
+ expect(body.background_color).toBe("#0f172a");
105
+ expect(body.display).toBe("standalone");
106
+ expect(body.start_url).toBe("/");
107
+ expect(body.id).toBe("/");
108
+ expect(body.name).toBe("Pi-Dash \u00b7 mybox.local");
109
+ expect(body.short_name).toBe("mybox.local");
110
+ });
111
+
112
+ it("truncates short_name to 12 characters", () => {
113
+ const body = buildManifestBody(BASE, "abc123.share.zrok.io");
114
+ expect(body.short_name).toBe("abc123.share");
115
+ expect((body.short_name as string).length).toBe(12);
116
+ // Full name keeps the untruncated source
117
+ expect(body.name).toBe("Pi-Dash \u00b7 abc123.share.zrok.io");
118
+ });
119
+
120
+ it("falls back to 'Pi-Dash' for short_name when source is empty", () => {
121
+ const body = buildManifestBody(BASE, "");
122
+ expect(body.short_name).toBe("Pi-Dash");
123
+ expect(body.name).toBe("Pi-Dash \u00b7 ");
124
+ });
125
+
126
+ it("does not mutate the input base object", () => {
127
+ const base = { ...BASE };
128
+ const frozen = JSON.stringify(base);
129
+ buildManifestBody(base, "foo");
130
+ expect(JSON.stringify(base)).toBe(frozen);
131
+ });
132
+
133
+ it("preserves arbitrary extra fields from the static base", () => {
134
+ const body = buildManifestBody({ ...BASE, scope: "/", lang: "en" }, "x");
135
+ expect(body.scope).toBe("/");
136
+ expect(body.lang).toBe("en");
137
+ });
138
+ });
@@ -70,27 +70,32 @@ function buildDefaultDeps(): SharedChecksDeps {
70
70
  detectOpenSpec: () => detectOnPath("openspec"),
71
71
  isApiKeyConfigured,
72
72
  probeServer: async () => {
73
- const r = safeExec("curl -sf http://localhost:8000/api/health", { timeoutMs: 3000 });
74
- if (!r.ok || !r.stdout.trim()) return { running: false };
75
- try {
76
- const h = JSON.parse(r.stdout);
77
- return {
78
- running: true,
79
- version: typeof h.version === "string" ? h.version : undefined,
80
- mode: typeof h.mode === "string" ? h.mode : undefined,
81
- starter: typeof h.starter === "string" ? h.starter : null,
82
- installable:
83
- h.installable && typeof h.installable === "object"
84
- ? {
85
- total: h.installable.total ?? 0,
86
- installed: h.installable.installed ?? 0,
87
- failed: Array.isArray(h.installable.failed) ? h.installable.failed : [],
88
- }
89
- : null,
90
- };
91
- } catch {
92
- return { running: true };
93
- }
73
+ // CRITICAL: do NOT shell out to `curl http://localhost:8000/api/health`
74
+ // here. `safeExec` uses synchronous `execSync`, which blocks the Node
75
+ // event loop until the child exits. The child is curl, talking back
76
+ // to *this same Node process* — a self-deadlock. curl waits for the
77
+ // server to respond, server is blocked in execSync, after 3 s the
78
+ // timeout kills curl and the probe falsely reports "Not running".
79
+ //
80
+ // Since we are currently handling an HTTP request, by definition the
81
+ // server IS running. Read process-resident health data directly
82
+ // instead of round-tripping through HTTP.
83
+ //
84
+ // See change: harvest-bootstrap-survivor-fixes (cherry-pick 5).
85
+ const installable =
86
+ process.env.DASHBOARD_INSTALLABLE_TOTAL !== undefined
87
+ ? {
88
+ total: Number(process.env.DASHBOARD_INSTALLABLE_TOTAL ?? 0),
89
+ installed: Number(process.env.DASHBOARD_INSTALLABLE_INSTALLED ?? 0),
90
+ failed: [] as string[],
91
+ }
92
+ : null;
93
+ return {
94
+ running: true,
95
+ starter: process.env.DASHBOARD_STARTER ?? null,
96
+ mode: process.env.NODE_ENV === "development" ? "dev" : "production",
97
+ installable,
98
+ };
94
99
  },
95
100
  };
96
101
  }