@blackbelt-technology/pi-agent-dashboard 0.2.9 โ†’ 0.4.0

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 (238) hide show
  1. package/AGENTS.md +64 -8
  2. package/README.md +308 -101
  3. package/docs/architecture.md +515 -16
  4. package/package.json +14 -7
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
  7. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  8. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
  9. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  10. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  11. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  12. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  13. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  14. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  15. package/packages/extension/src/ask-user-tool.ts +289 -20
  16. package/packages/extension/src/bridge.ts +107 -6
  17. package/packages/extension/src/command-handler.ts +34 -39
  18. package/packages/extension/src/dev-build.ts +1 -1
  19. package/packages/extension/src/git-info.ts +9 -19
  20. package/packages/extension/src/pi-env.d.ts +1 -0
  21. package/packages/extension/src/process-scanner.ts +72 -38
  22. package/packages/extension/src/prompt-expander.ts +25 -4
  23. package/packages/extension/src/provider-register.ts +304 -16
  24. package/packages/extension/src/server-auto-start.ts +27 -1
  25. package/packages/extension/src/server-launcher.ts +71 -27
  26. package/packages/server/package.json +17 -2
  27. package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
  28. package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
  29. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  30. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  31. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  32. package/packages/server/src/__tests__/browse-endpoint.test.ts +246 -10
  33. package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
  34. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  35. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  36. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  37. package/packages/server/src/__tests__/cors.test.ts +34 -2
  38. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  39. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  40. package/packages/server/src/__tests__/editor-manager-pid-registry.test.ts +168 -0
  41. package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
  42. package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
  43. package/packages/server/src/__tests__/editor-registry.test.ts +29 -15
  44. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  45. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  46. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  47. package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
  48. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  49. package/packages/server/src/__tests__/git-operations.test.ts +9 -7
  50. package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
  51. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  52. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  53. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  54. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  55. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  56. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
  57. package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
  58. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +126 -0
  59. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  60. package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
  61. package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
  62. package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
  63. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  64. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  65. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  66. package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
  67. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  68. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  69. package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
  70. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  71. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  72. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  74. package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
  75. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
  76. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
  77. package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
  78. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  79. package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
  80. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  81. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  82. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  83. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  84. package/packages/server/src/__tests__/tunnel.test.ts +103 -6
  85. package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
  86. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  87. package/packages/server/src/bootstrap-queue.ts +130 -0
  88. package/packages/server/src/bootstrap-state.ts +131 -0
  89. package/packages/server/src/browse.ts +108 -9
  90. package/packages/server/src/browser-gateway.ts +16 -3
  91. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  92. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  93. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  94. package/packages/server/src/cli.ts +256 -32
  95. package/packages/server/src/config-api.ts +16 -0
  96. package/packages/server/src/directory-service.ts +270 -39
  97. package/packages/server/src/editor-detection.ts +12 -9
  98. package/packages/server/src/editor-manager.ts +39 -5
  99. package/packages/server/src/editor-pid-registry.ts +199 -0
  100. package/packages/server/src/editor-registry.ts +22 -25
  101. package/packages/server/src/fix-pty-permissions.ts +44 -0
  102. package/packages/server/src/git-operations.ts +1 -1
  103. package/packages/server/src/headless-pid-registry.ts +16 -20
  104. package/packages/server/src/home-lock-release.ts +72 -0
  105. package/packages/server/src/home-lock.ts +389 -0
  106. package/packages/server/src/node-guard.ts +52 -0
  107. package/packages/server/src/npm-search-proxy.ts +71 -0
  108. package/packages/server/src/openspec-tasks.ts +158 -0
  109. package/packages/server/src/package-manager-wrapper.ts +225 -34
  110. package/packages/server/src/pi-core-checker.ts +290 -0
  111. package/packages/server/src/pi-core-updater.ts +172 -0
  112. package/packages/server/src/pi-gateway.ts +7 -0
  113. package/packages/server/src/pi-resource-scanner.ts +5 -8
  114. package/packages/server/src/pi-version-skew.ts +196 -0
  115. package/packages/server/src/preferences-store.ts +17 -3
  116. package/packages/server/src/process-manager.ts +403 -222
  117. package/packages/server/src/provider-probe.ts +234 -0
  118. package/packages/server/src/restart-helper.ts +130 -0
  119. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  120. package/packages/server/src/routes/file-routes.ts +30 -3
  121. package/packages/server/src/routes/openspec-routes.ts +107 -1
  122. package/packages/server/src/routes/pi-core-routes.ts +140 -0
  123. package/packages/server/src/routes/provider-auth-routes.ts +12 -10
  124. package/packages/server/src/routes/provider-routes.ts +55 -2
  125. package/packages/server/src/routes/recommended-routes.ts +225 -0
  126. package/packages/server/src/routes/system-routes.ts +30 -34
  127. package/packages/server/src/routes/tool-routes.ts +153 -0
  128. package/packages/server/src/server-pid.ts +5 -9
  129. package/packages/server/src/server.ts +363 -26
  130. package/packages/server/src/session-api.ts +77 -8
  131. package/packages/server/src/session-bootstrap.ts +17 -3
  132. package/packages/server/src/session-diff.ts +21 -21
  133. package/packages/server/src/terminal-manager.ts +65 -20
  134. package/packages/server/src/test-env-guard.ts +26 -0
  135. package/packages/server/src/test-support/test-server.ts +63 -0
  136. package/packages/server/src/tunnel.ts +172 -34
  137. package/packages/shared/package.json +10 -3
  138. package/packages/shared/src/__tests__/{tool-resolver.test.ts โ†’ binary-lookup.test.ts} +32 -12
  139. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  140. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  141. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  142. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  143. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  144. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  145. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  146. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  147. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  148. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  149. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  150. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  151. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  152. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  153. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  154. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  155. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  156. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  157. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  158. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  159. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  160. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  161. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  162. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  163. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  164. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  165. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  166. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  167. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  168. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  169. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  170. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  172. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  173. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  174. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  175. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  176. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  177. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  178. package/packages/shared/src/__tests__/config.test.ts +59 -3
  179. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  180. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  181. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  182. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  183. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  184. package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
  185. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  186. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  187. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  188. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  189. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  190. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  191. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  192. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  193. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  194. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  195. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  196. package/packages/shared/src/__tests__/recommended-extensions.test.ts +156 -0
  197. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  198. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  199. package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
  200. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  201. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  202. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  203. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  204. package/packages/shared/src/bootstrap-install.ts +212 -0
  205. package/packages/shared/src/bridge-register.ts +87 -20
  206. package/packages/shared/src/browser-protocol.ts +93 -1
  207. package/packages/shared/src/config.ts +87 -15
  208. package/packages/shared/src/managed-paths.ts +31 -4
  209. package/packages/shared/src/openspec-poller.ts +71 -49
  210. package/packages/shared/src/{tool-resolver.ts โ†’ platform/binary-lookup.ts} +125 -25
  211. package/packages/shared/src/platform/commands.ts +100 -0
  212. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  213. package/packages/shared/src/platform/exec.ts +220 -0
  214. package/packages/shared/src/platform/git.ts +155 -0
  215. package/packages/shared/src/platform/index.ts +15 -0
  216. package/packages/shared/src/platform/npm.ts +162 -0
  217. package/packages/shared/src/platform/openspec.ts +91 -0
  218. package/packages/shared/src/platform/paths.ts +276 -0
  219. package/packages/shared/src/platform/process-identify.ts +126 -0
  220. package/packages/shared/src/platform/process-scan.ts +94 -0
  221. package/packages/shared/src/platform/process.ts +168 -0
  222. package/packages/shared/src/platform/runner.ts +369 -0
  223. package/packages/shared/src/platform/shell.ts +44 -0
  224. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  225. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  226. package/packages/shared/src/recommended-extensions.ts +196 -0
  227. package/packages/shared/src/resolve-jiti.ts +62 -3
  228. package/packages/shared/src/rest-api.ts +97 -0
  229. package/packages/shared/src/semaphore.ts +83 -0
  230. package/packages/shared/src/source-matching.ts +126 -0
  231. package/packages/shared/src/test-support/setup-home.ts +74 -0
  232. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  233. package/packages/shared/src/tool-registry/index.ts +56 -0
  234. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  235. package/packages/shared/src/tool-registry/registry.ts +262 -0
  236. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  237. package/packages/shared/src/tool-registry/types.ts +180 -0
  238. package/packages/shared/src/types.ts +7 -0
@@ -4,6 +4,7 @@
4
4
  import Fastify from "fastify";
5
5
  import fastifyStatic from "@fastify/static";
6
6
  import cors from "@fastify/cors";
7
+ import compress from "@fastify/compress";
7
8
  import path from "node:path";
8
9
  import { fileURLToPath } from "node:url";
9
10
  import os from "node:os";
@@ -29,7 +30,7 @@ import { createIdleTimer } from "./idle-timer.js";
29
30
  import { discoverAndBroadcastSessions } from "./session-bootstrap.js";
30
31
  import { scanAllSessions } from "./session-scanner.js";
31
32
  import { needsMigration, runMigration } from "./migrate-persistence.js";
32
- import { detectZrokBinary, cleanupStaleZrok, createTunnel, deleteTunnel } from "./tunnel.js";
33
+ import { detectZrokBinary, cleanupStaleZrok, createTunnel, deleteTunnel, scavengeOrphanZrokProcesses, getTunnelUrl } from "./tunnel.js";
33
34
  import { registerAuthPlugin, validateWsUpgrade } from "./auth-plugin.js";
34
35
  import { findBundledExtension, registerBridgeExtension } from "@blackbelt-technology/pi-dashboard-shared/bridge-register.js";
35
36
  import { createNetworkGuard, isLoopback, isBypassedHost } from "./localhost-guard.js";
@@ -42,9 +43,20 @@ import { registerOpenSpecRoutes } from "./routes/openspec-routes.js";
42
43
  import { registerSystemRoutes } from "./routes/system-routes.js";
43
44
  import { registerProviderAuthRoutes } from "./routes/provider-auth-routes.js";
44
45
  import { registerPackageRoutes } from "./routes/package-routes.js";
46
+ import { registerRecommendedRoutes, invalidateRecommendedCache } from "./routes/recommended-routes.js";
47
+ import { registerPiCoreRoutes } from "./routes/pi-core-routes.js";
48
+ import { PiCoreChecker } from "./pi-core-checker.js";
49
+ import { PiCoreUpdater } from "./pi-core-updater.js";
50
+ import { registerToolRoutes } from "./routes/tool-routes.js";
51
+ import { registerBootstrapRoutes } from "./routes/bootstrap-routes.js";
52
+ import { createBootstrapState, type BootstrapStateStore } from "./bootstrap-state.js";
53
+ import { createBootstrapQueue } from "./bootstrap-queue.js";
54
+ import { bootstrapInstall } from "@blackbelt-technology/pi-dashboard-shared/bootstrap-install.js";
55
+ import { getDefaultRegistry } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js";
45
56
  import { registerProviderRoutes } from "./routes/provider-routes.js";
46
57
  import { PackageManagerWrapper } from "./package-manager-wrapper.js";
47
58
  import { createEditorManager, type EditorManager } from "./editor-manager.js";
59
+ import { createEditorPidRegistry } from "./editor-pid-registry.js";
48
60
  import { registerEditorRoutes } from "./routes/editor-routes.js";
49
61
  import { registerKnownServersRoutes } from "./routes/known-servers-routes.js";
50
62
  import { registerEditorProxy, handleEditorUpgrade } from "./editor-proxy.js";
@@ -67,6 +79,8 @@ export interface ServerConfig {
67
79
  maxWsBufferBytes?: number;
68
80
  /** Editor (code-server) config */
69
81
  editor: import("@blackbelt-technology/pi-dashboard-shared/config.js").EditorConfig;
82
+ /** OpenSpec polling config (interval, concurrency, change detection, jitter) */
83
+ openspec?: import("@blackbelt-technology/pi-dashboard-shared/config.js").OpenSpecPollConfig;
70
84
  /** Merged trusted networks from config */
71
85
  resolvedTrustedNetworks?: string[];
72
86
  /** CORS allowed origins from config */
@@ -79,14 +93,37 @@ export interface DashboardServer {
79
93
  sessionManager: SessionManager;
80
94
  eventStore: EventStore;
81
95
  browserGateway: BrowserGateway;
96
+ /**
97
+ * Bootstrap state store. Exposed so the CLI can flip status during
98
+ * degraded-mode first-run (`pi-dashboard` without pi installed) and
99
+ * so the REST handler for `/api/bootstrap/upgrade-pi` can orchestrate
100
+ * async installs without reaching back through closures.
101
+ * See change: unified-bootstrap-install.
102
+ */
103
+ bootstrapState: BootstrapStateStore;
104
+ /** Resolved HTTP port after start() (useful for port:0 in tests). Returns null if not listening. */
105
+ httpPort(): number | null;
106
+ /** Resolved pi gateway port after start(). Returns null if not listening. */
107
+ piPort(): number | null;
82
108
  }
83
109
 
84
110
  export async function createServer(config: ServerConfig): Promise<DashboardServer> {
85
111
  // Ensure bridge extension is registered in pi's global settings
86
112
  // (needed for bundled installs where pi can't discover it from package.json)
113
+ //
114
+ // __serverDir = <repo>/packages/server/src
115
+ // baseDir MUST be <repo>/ so findBundledExtension resolves
116
+ // <repo>/packages/extension. Three levels up, not two.
87
117
  const __serverDir = path.dirname(fileURLToPath(import.meta.url));
88
- const extPath = findBundledExtension(path.resolve(__serverDir, "..", ".."));
89
- if (extPath) registerBridgeExtension(extPath);
118
+ const extPath = findBundledExtension(path.resolve(__serverDir, "..", "..", ".."));
119
+ if (extPath) {
120
+ registerBridgeExtension(extPath);
121
+ console.log(`[dashboard] Bridge extension registered: ${extPath}`);
122
+ } else {
123
+ console.warn(`[dashboard] Bridge extension NOT found (searched from ${__serverDir}). ` +
124
+ `Sessions will spawn but never connect to the gateway. ` +
125
+ `Manually add the extension path to ~/.pi/agent/settings.json packages[] as a workaround.`);
126
+ }
90
127
 
91
128
  // Run migration from sessions.json + state.json if needed
92
129
  if (needsMigration()) {
@@ -151,7 +188,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
151
188
  knownSessionIds.add(s.id);
152
189
  }
153
190
 
154
- const directoryService = createDirectoryService(preferencesStore, sessionManager);
191
+ const directoryService = createDirectoryService(preferencesStore, sessionManager, config.openspec);
155
192
 
156
193
  // mDNS peer discovery state
157
194
  let mdnsBrowser: DashboardBrowser | null = null;
@@ -190,9 +227,11 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
190
227
 
191
228
  // Create editor manager for code-server instances
192
229
  const editorDetection = detectCodeServerBinary(config.editor);
230
+ const editorPidRegistry = createEditorPidRegistry();
193
231
  const editorManager = createEditorManager({
194
232
  config: config.editor,
195
233
  detection: editorDetection,
234
+ pidRegistry: editorPidRegistry,
196
235
  onStatusChange: (cwd, id, status) => {
197
236
  browserGateway.broadcastToAll({ type: "editor_status", cwd, id, status });
198
237
  },
@@ -243,23 +282,62 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
243
282
  connectionTimeout: 10_000,
244
283
  });
245
284
 
246
- // CORS: allow localhost by default + configured origins
285
+ // Compression: gzip/deflate for HTTP responses. Critical for large client
286
+ // bundles (~3 MB JS) served over tunnels like zrok which abort big transfers.
287
+ // Brotli is intentionally disabled โ€” zrok's free public proxy has been
288
+ // observed to truncate/stream-reset `content-encoding: br` responses under
289
+ // parallel browser load (curl succeeds, Chrome reports ERR_ABORTED 500).
290
+ // gzip is universally supported and round-trips cleanly through zrok.
291
+ // threshold=1024 skips tiny responses; global=true compresses all routes.
292
+ await fastify.register(compress, {
293
+ global: true,
294
+ threshold: 1024,
295
+ encodings: ["gzip", "deflate"],
296
+ });
297
+
298
+ // CORS: allow localhost, the active zrok tunnel URL, any *.share.zrok.io
299
+ // host (so tunnel URL rotation doesn't break loads), and configured origins.
300
+ //
301
+ // Two critical correctness notes:
302
+ // (1) Vite emits `<script type="module" crossorigin>` tags, which browsers
303
+ // always request in CORS mode โ€” even when same-origin. If the server
304
+ // doesn't emit `Access-Control-Allow-Origin` for the request's own
305
+ // origin, the browser aborts the script with ERR_ABORTED 500. So when
306
+ // accessed via a tunnel URL, that URL MUST be in the allow list or all
307
+ // asset loads fail in the browser (while curl โ€” which sends no Origin
308
+ // header โ€” works fine). This is the exact failure mode that looked
309
+ // like a zrok problem for hours of debugging.
310
+ // (2) On origin mismatch, return `cb(null, false)` (no CORS headers) rather
311
+ // than `cb(new Error(โ€ฆ), false)`. The latter causes @fastify/cors to
312
+ // surface the error as HTTP 500 on every asset โ€” far worse than
313
+ // silently omitting CORS headers and letting the browser enforce its
314
+ // own same-origin policy.
247
315
  const corsAllowedOrigins = config.corsAllowedOrigins ?? [];
248
316
  await fastify.register(cors, {
249
317
  origin: (origin, cb) => {
250
- // Same-origin (no Origin header) โ€” always allow
318
+ // Same-origin navigation (no Origin header) โ€” always allow.
251
319
  if (!origin) return cb(null, true);
252
- // Localhost / 127.0.0.1 / [::1] โ€” any port
253
320
  try {
254
321
  const u = new URL(origin);
255
322
  const host = u.hostname;
323
+ // Loopback โ€” any port.
256
324
  if (host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1") {
257
325
  return cb(null, true);
258
326
  }
259
- } catch { /* ignore parse errors */ }
260
- // Configured origins
327
+ // Active zrok tunnel URL โ€” checked dynamically so URL rotation is
328
+ // picked up without a server restart.
329
+ const tunnelUrl = getTunnelUrl();
330
+ if (tunnelUrl && origin === tunnelUrl) return cb(null, true);
331
+ // Any *.share.zrok.io host โ€” covers the brief window between a new
332
+ // reservation being created and the in-memory `activeTunnelUrl`
333
+ // being populated, plus any other zrok share the user points at us.
334
+ if (host.endsWith(".share.zrok.io")) return cb(null, true);
335
+ } catch { /* ignore URL parse errors */ }
336
+ // Explicitly configured origins.
261
337
  if (corsAllowedOrigins.includes(origin)) return cb(null, true);
262
- cb(new Error("CORS origin not allowed"), false);
338
+ // Unknown cross-origin request โ€” don't emit CORS headers, but don't
339
+ // 500 either. Browser will block the request for us.
340
+ cb(null, false);
263
341
  },
264
342
  credentials: true,
265
343
  });
@@ -278,6 +356,33 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
278
356
  fastify.get("/auth/status", async () => ({ authenticated: true, authEnabled: false }));
279
357
  }
280
358
 
359
+ // โ”€โ”€ Bootstrap state + queue โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
360
+ // Declared here (before session-api registration) so the session
361
+ // routes can gate spawn operations on bootstrap status.
362
+ // See change: unified-bootstrap-install.
363
+ const bootstrapState = createBootstrapState();
364
+ const bootstrapQueue = createBootstrapQueue();
365
+ let lastBootstrapStatus: "ready" | "installing" | "failed" = "ready";
366
+ const unsubscribeBootstrap = bootstrapState.subscribe((snapshot) => {
367
+ browserGateway.broadcastToAll({
368
+ type: "bootstrap_status_update",
369
+ state: snapshot,
370
+ });
371
+ // Flush queued pi-dependent operations on ready transition.
372
+ if (lastBootstrapStatus !== "ready" && snapshot.status === "ready") {
373
+ void bootstrapQueue.flushAll();
374
+ }
375
+ lastBootstrapStatus = snapshot.status;
376
+ });
377
+ const unsubscribeQueueComplete = bootstrapQueue.onTicketComplete((evt) => {
378
+ browserGateway.broadcastToAll({
379
+ type: "bootstrap_ticket_complete",
380
+ ticketId: evt.ticketId,
381
+ success: evt.success,
382
+ error: evt.error,
383
+ });
384
+ });
385
+
281
386
  // Session control REST API (wraps WebSocket-only operations)
282
387
  registerSessionApi(fastify, {
283
388
  sessionManager,
@@ -285,6 +390,8 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
285
390
  browserGateway,
286
391
  pendingForkRegistry,
287
392
  pendingDashboardSpawns,
393
+ bootstrapState,
394
+ bootstrapQueue,
288
395
  });
289
396
 
290
397
  // Register route modules
@@ -294,8 +401,123 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
294
401
  registerSessionRoutes(fastify, { sessionManager, eventStore, networkGuard });
295
402
  registerGitRoutes(fastify, { networkGuard });
296
403
  registerFileRoutes(fastify, { sessionManager, preferencesStore, networkGuard });
297
- registerOpenSpecRoutes(fastify, { sessionManager, preferencesStore, directoryService, networkGuard });
298
- registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version: pkgVersion });
404
+ registerOpenSpecRoutes(fastify, {
405
+ sessionManager,
406
+ preferencesStore,
407
+ directoryService,
408
+ networkGuard,
409
+ bootstrapState,
410
+ onOpenSpecChanged: (cwd) => {
411
+ const data = directoryService.getOpenSpecData(cwd);
412
+ if (data) browserGateway.broadcastToAll({ type: "openspec_update", cwd, data });
413
+ },
414
+ });
415
+ registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version: pkgVersion, directoryService });
416
+ registerToolRoutes(fastify, { registry: getDefaultRegistry(), networkGuard });
417
+
418
+ // โ”€โ”€ Bootstrap REST routes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
419
+ // The routes module is registered here; state + queue are declared
420
+ // above (before session-api) so session routes can see them.
421
+ registerBootstrapRoutes(fastify, {
422
+ bootstrapState,
423
+ networkGuard,
424
+ triggerUpgradePi: async () => {
425
+ const packages = ["@mariozechner/pi-coding-agent"];
426
+ bootstrapState.setLastInstallPackages(packages);
427
+ bootstrapState.set({
428
+ status: "installing",
429
+ progress: { step: "@mariozechner/pi-coding-agent", output: "starting upgradeโ€ฆ" },
430
+ error: undefined,
431
+ });
432
+ try {
433
+ const res = await bootstrapInstall({
434
+ packages,
435
+ progress: (p) => {
436
+ bootstrapState.set({
437
+ progress: { step: p.step, output: p.output },
438
+ });
439
+ },
440
+ });
441
+ if (res.ok) {
442
+ bootstrapState.set({
443
+ status: "ready",
444
+ progress: undefined,
445
+ error: undefined,
446
+ });
447
+ // Broadcast /reload to connected sessions so they pick up the
448
+ // new pi version. Mirrors the pi-core update pattern above.
449
+ const connectedIds = piGateway.getConnectedSessionIds();
450
+ for (const sid of connectedIds) {
451
+ const session = sessionManager.get(sid);
452
+ if (session && session.status !== "ended") {
453
+ piGateway.sendToSession(sid, {
454
+ type: "send_prompt",
455
+ sessionId: sid,
456
+ text: "/reload",
457
+ });
458
+ }
459
+ }
460
+ } else {
461
+ bootstrapState.set({
462
+ status: "failed",
463
+ error: { message: res.error },
464
+ progress: undefined,
465
+ });
466
+ }
467
+ } catch (err) {
468
+ const message = err instanceof Error ? err.message : String(err);
469
+ bootstrapState.set({
470
+ status: "failed",
471
+ error: { message },
472
+ progress: undefined,
473
+ });
474
+ }
475
+ },
476
+ triggerRetry: async () => {
477
+ // Retry re-runs the EXACT package set from the last failed install.
478
+ // Falls back to the default first-run set if no prior install was
479
+ // recorded (edge case: manual retry before any install attempt).
480
+ const prev = bootstrapState.getLastInstallPackages();
481
+ const packages = prev.length > 0
482
+ ? prev
483
+ : ["@mariozechner/pi-coding-agent", "@fission-ai/openspec", "tsx"];
484
+ bootstrapState.set({
485
+ status: "installing",
486
+ progress: { step: "retry", output: `restarting install (${packages.length} pkg${packages.length === 1 ? "" : "s"})โ€ฆ` },
487
+ error: undefined,
488
+ });
489
+ try {
490
+ const res = await bootstrapInstall({
491
+ packages,
492
+ progress: (p) => {
493
+ bootstrapState.set({
494
+ progress: { step: p.step, output: p.output },
495
+ });
496
+ },
497
+ });
498
+ if (res.ok) {
499
+ bootstrapState.set({
500
+ status: "ready",
501
+ progress: undefined,
502
+ error: undefined,
503
+ });
504
+ } else {
505
+ bootstrapState.set({
506
+ status: "failed",
507
+ error: { message: res.error },
508
+ progress: undefined,
509
+ });
510
+ }
511
+ } catch (err) {
512
+ const message = err instanceof Error ? err.message : String(err);
513
+ bootstrapState.set({
514
+ status: "failed",
515
+ error: { message },
516
+ progress: undefined,
517
+ });
518
+ }
519
+ },
520
+ });
299
521
  // Package management
300
522
  const packageManagerWrapper = new PackageManagerWrapper();
301
523
 
@@ -304,7 +526,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
304
526
  browserGateway.broadcastToAll({ type: "package_progress", operationId, event } as any);
305
527
  });
306
528
 
307
- // On completion: broadcast to browsers
529
+ // On completion: broadcast to browsers + invalidate the recommended cache
308
530
  packageManagerWrapper.setCompleteListener((result) => {
309
531
  browserGateway.broadcastToAll({
310
532
  type: "package_operation_complete",
@@ -314,8 +536,10 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
314
536
  scope: result.scope,
315
537
  success: result.success,
316
538
  error: result.error,
539
+ diagnostics: result.diagnostics,
317
540
  sessionsReloaded: (result as any).sessionsReloaded,
318
541
  } as any);
542
+ if (result.success) invalidateRecommendedCache();
319
543
  });
320
544
 
321
545
  // Reload all active sessions after a successful package operation
@@ -337,26 +561,105 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
337
561
  });
338
562
 
339
563
  registerPackageRoutes(fastify, { packageManagerWrapper });
564
+ registerRecommendedRoutes(fastify, { packageManagerWrapper });
565
+
566
+ // Pi core version check + update (complements the extension package manager).
567
+ const piCoreChecker = new PiCoreChecker();
568
+ const piCoreUpdater = new PiCoreUpdater({
569
+ packageManagerWrapper,
570
+ onAllComplete: async () => {
571
+ const connectedIds = piGateway.getConnectedSessionIds();
572
+ let count = 0;
573
+ for (const sid of connectedIds) {
574
+ const session = sessionManager.get(sid);
575
+ if (session && session.status !== "ended") {
576
+ piGateway.sendToSession(sid, {
577
+ type: "send_prompt",
578
+ sessionId: sid,
579
+ text: "/reload",
580
+ });
581
+ count++;
582
+ }
583
+ }
584
+ return count;
585
+ },
586
+ });
587
+ piCoreUpdater.setProgressListener((event) => {
588
+ browserGateway.broadcastToAll({
589
+ type: "pi_core_update_progress",
590
+ name: event.name,
591
+ phase: event.phase,
592
+ message: event.message,
593
+ });
594
+ });
595
+ registerPiCoreRoutes(fastify, {
596
+ piCoreChecker,
597
+ piCoreUpdater,
598
+ bootstrapState,
599
+ onUpdateComplete: (payload) => {
600
+ browserGateway.broadcastToAll({
601
+ type: "pi_core_update_complete",
602
+ results: payload.results,
603
+ sessionsReloaded: payload.sessionsReloaded,
604
+ });
605
+ },
606
+ });
340
607
 
341
- // Editor (code-server) routes and proxy
608
+ // Warm pi-coding-agent module import + DefaultPackageManager instances
609
+ // on startup so the first user request to /api/packages/* doesn't pay
610
+ // the 3-5s cold-load cost. Runs in background; errors are swallowed
611
+ // (user-visible flow surfaces any real problem with the full diagnostic
612
+ // trail via the OperationResult.diagnostics field).
613
+ // See change: consolidate-tool-resolution.
614
+ void Promise.allSettled([
615
+ packageManagerWrapper.listInstalled("global"),
616
+ packageManagerWrapper.listInstalled("local"),
617
+ ]);
618
+
619
+ // Editor (code-server) routes and proxy.
620
+ // NOTE: routes are *registered* here but cannot dispatch until fastify.listen runs
621
+ // inside server.start(). The orphan sweep in editorPidRegistry.cleanupOrphans()
622
+ // runs at the top of server.start() BEFORE fastify.listen, so any
623
+ // POST /api/editor/start call is guaranteed to see a post-sweep clean state.
342
624
  registerEditorRoutes(fastify, editorManager, { networkGuard });
343
625
  registerEditorProxy(fastify, editorManager);
344
626
 
345
- registerProviderAuthRoutes(fastify, { piGateway });
627
+ registerProviderAuthRoutes(fastify, { piGateway, browserGateway });
346
628
  registerKnownServersRoutes(fastify, { networkGuard, getPeerServers: () => peerServers });
347
- registerProviderRoutes(fastify, { networkGuard });
348
-
349
- // Serve static files / SPA fallback
350
- // Search order: npm package โ†’ workspace sibling โ†’ legacy dist/client
629
+ registerProviderRoutes(fastify, { networkGuard, piGateway, browserGateway });
630
+
631
+ // Serve static files / SPA fallback.
632
+ //
633
+ // Resolution strategies, in order:
634
+ // 1. Node module resolver โ€” works in ANY install layout
635
+ // (flat `node_modules/`, scoped, nested, pnpm, whatever).
636
+ // 2. Sibling-to-server in the installed @scope layout.
637
+ // 3. Monorepo workspace sibling.
638
+ // 4. Legacy dist/client.
639
+ //
640
+ // Same class of bug as commits 40a1319 (bridge auto-registration)
641
+ // and e11f5eb (server-launcher.ts resolve): sibling-path arithmetic
642
+ // that works in the dev repo silently returns wrong paths in the
643
+ // installed node_modules layout. require.resolve identifies packages
644
+ // by name, which is the only canonical identity across layouts.
351
645
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
352
- const clientSearchPaths = [
353
- // Installed as npm dependency
354
- path.join(__dirname, "../../node_modules/@blackbelt-technology/pi-dashboard-web/dist"),
646
+ const clientSearchPaths: string[] = [];
647
+ try {
648
+ const webPkgJson = createRequire(import.meta.url).resolve("@blackbelt-technology/pi-dashboard-web/package.json");
649
+ clientSearchPaths.push(path.join(path.dirname(webPkgJson), "dist"));
650
+ } catch {
651
+ // Web package not resolvable โ€” fall through to path-based search.
652
+ }
653
+ clientSearchPaths.push(
654
+ // Installed as scoped sibling of server
655
+ path.join(__dirname, "..", "..", "pi-dashboard-web", "dist"),
656
+ // Installed in a parent node_modules (hoisted)
657
+ path.join(__dirname, "..", "..", "..", "@blackbelt-technology", "pi-dashboard-web", "dist"),
355
658
  // Monorepo workspace sibling
356
659
  path.join(__dirname, "../../client/dist"),
357
660
  // Legacy path
358
661
  path.join(__dirname, "../../dist/client"),
359
- ];
662
+ );
360
663
  const clientDir = clientSearchPaths.find(p => existsSync(path.join(p, "index.html"))) ?? "";
361
664
  const hasProductionBuild = !!clientDir;
362
665
  if (!hasProductionBuild) {
@@ -371,6 +674,13 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
371
674
  await fastify.register(fastifyStatic, {
372
675
  root: clientDir,
373
676
  prefix: "/",
677
+ // Serve pre-compressed sibling files (assets/foo.js.gz alongside foo.js)
678
+ // directly when the client accepts gzip. This gives every compressed
679
+ // response a stable Content-Length header โ€” dynamic compression via
680
+ // @fastify/compress streams responses without Content-Length, which
681
+ // some HTTP/2 proxy chains (notably zrok free-tier) occasionally
682
+ // stream-reset as ERR_ABORTED 500 in browsers.
683
+ preCompressed: true,
374
684
  setHeaders: (res, filePath) => {
375
685
  if (filePath.endsWith(".html")) {
376
686
  res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
@@ -434,11 +744,25 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
434
744
  sessionManager,
435
745
  eventStore,
436
746
  browserGateway,
747
+ bootstrapState,
748
+
749
+ httpPort() {
750
+ const addr = fastify.server.address();
751
+ if (addr && typeof addr === "object") return addr.port;
752
+ return null;
753
+ },
754
+ piPort() {
755
+ return piGateway.address();
756
+ },
437
757
 
438
758
  async start() {
439
759
  // Clean up orphan headless processes from a previous server instance
440
760
  browserGateway.headlessPidRegistry.cleanupOrphans();
441
761
 
762
+ // Clean up orphan code-server processes from a previous server instance.
763
+ // Runs before fastify.listen, so no editor start request can race with the sweep.
764
+ await editorPidRegistry.cleanupOrphans();
765
+
442
766
  piGateway.start(config.piPort);
443
767
 
444
768
  fastify.server.on("upgrade", (request, socket, head) => {
@@ -501,10 +825,19 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
501
825
  console.warn(`mDNS browser failed (peer discovery disabled):`, err);
502
826
  }
503
827
 
828
+ // Always sweep leftover zrok processes on startup, even when tunnel is
829
+ // disabled (--no-tunnel). Orphans from a previous run hold reservations
830
+ // on the zrok edge and keep old URLs "alive but broken" until their
831
+ // agents are killed. Scavenge runs unconditionally when the binary is
832
+ // present; the tunnel-creation branch below is gated separately.
833
+ const hasZrok = detectZrokBinary();
834
+ if (hasZrok) {
835
+ cleanupStaleZrok();
836
+ scavengeOrphanZrokProcesses(config.port);
837
+ }
838
+
504
839
  if (config.tunnel) {
505
- const hasZrok = detectZrokBinary();
506
840
  if (hasZrok) {
507
- cleanupStaleZrok();
508
841
  const tunnelUrl = await createTunnel(config.port, config.tunnelReservedToken);
509
842
  if (tunnelUrl) {
510
843
  console.log(`๐ŸŒ Tunnel: ${tunnelUrl}`);
@@ -534,7 +867,11 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
534
867
  preferencesStore.flush();
535
868
  preferencesStore.dispose();
536
869
 
537
- await deleteTunnel();
870
+ unsubscribeBootstrap();
871
+ unsubscribeQueueComplete();
872
+ bootstrapState.dispose();
873
+ bootstrapQueue.clear("server shutting down");
874
+ await deleteTunnel(config.port);
538
875
  piGateway.stop();
539
876
  for (const client of browserGateway.wss.clients) {
540
877
  client.terminate();
@@ -11,6 +11,8 @@ import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/type
11
11
  import { spawnPiSession } from "./process-manager.js";
12
12
  import { loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
13
13
  import type { PendingForkRegistry } from "./pending-fork-registry.js";
14
+ import type { BootstrapStateStore } from "./bootstrap-state.js";
15
+ import type { BootstrapQueue } from "./bootstrap-queue.js";
14
16
 
15
17
  export interface SessionApiDeps {
16
18
  sessionManager: SessionManager;
@@ -18,6 +20,13 @@ export interface SessionApiDeps {
18
20
  browserGateway: BrowserGateway;
19
21
  pendingForkRegistry?: PendingForkRegistry;
20
22
  pendingDashboardSpawns?: Map<string, number>;
23
+ /**
24
+ * Bootstrap state + queue for degraded-mode gating. When omitted,
25
+ * session operations run normally (legacy behavior for tests that
26
+ * don't exercise the bootstrap flow). See change: unified-bootstrap-install.
27
+ */
28
+ bootstrapState?: BootstrapStateStore;
29
+ bootstrapQueue?: BootstrapQueue;
21
30
  }
22
31
 
23
32
  type IdParams = { Params: { id: string } };
@@ -30,7 +39,54 @@ function getSessionOrFail(sessionManager: SessionManager, id: string): { session
30
39
  }
31
40
 
32
41
  export function registerSessionApi(fastify: FastifyInstance, deps: SessionApiDeps) {
33
- const { sessionManager, piGateway, browserGateway, pendingForkRegistry, pendingDashboardSpawns } = deps;
42
+ const { sessionManager, piGateway, browserGateway, pendingForkRegistry, pendingDashboardSpawns, bootstrapState, bootstrapQueue } = deps;
43
+
44
+ /**
45
+ * Gate pi-dependent operations on bootstrap status. Returns:
46
+ * - null when ready (proceed).
47
+ * - `{ code: 202, body: { status: "queued", ticketId } }` when installing;
48
+ * the operation is enqueued and will run once status flips to "ready".
49
+ * - `{ code: 503, body: { error } }` when failed.
50
+ * See change: unified-bootstrap-install ยง5.
51
+ */
52
+ function gateOrEnqueue<T>(handler: () => Promise<T>):
53
+ | null
54
+ | { code: 202; body: { status: "queued"; ticketId: string } }
55
+ | { code: 503; body: { error: string; bootstrap: "failed" | "version-too-old" } } {
56
+ if (!bootstrapState) return null;
57
+ const snap = bootstrapState.get();
58
+ // Block when pi version is below the configured minimum โ€”
59
+ // even when status is "ready", a too-old pi must not run sessions.
60
+ // See change: unified-bootstrap-install ยง9.3.
61
+ if (
62
+ snap.status === "ready"
63
+ && snap.error?.message?.startsWith("pi version ")
64
+ ) {
65
+ return {
66
+ code: 503,
67
+ body: { error: snap.error.message, bootstrap: "version-too-old" },
68
+ };
69
+ }
70
+ if (snap.status === "ready") return null;
71
+ if (snap.status === "installing") {
72
+ if (!bootstrapQueue) {
73
+ return {
74
+ code: 202,
75
+ body: { status: "queued", ticketId: "" },
76
+ };
77
+ }
78
+ const ticket = bootstrapQueue.enqueue(handler);
79
+ return {
80
+ code: 202,
81
+ body: { status: "queued", ticketId: ticket.ticketId },
82
+ };
83
+ }
84
+ // status === "failed"
85
+ return {
86
+ code: 503,
87
+ body: { error: "pi not installed (bootstrap failed)", bootstrap: "failed" },
88
+ };
89
+ }
34
90
 
35
91
  // POST /api/session/:id/prompt
36
92
  fastify.post<IdParams & { Body: { text?: string; images?: any[] } }>(
@@ -160,14 +216,27 @@ export function registerSessionApi(fastify: FastifyInstance, deps: SessionApiDep
160
216
  reply.code(400);
161
217
  return { success: false, error: "cwd is required" } satisfies ApiResponse;
162
218
  }
163
- const config = loadConfig();
164
- const spawnResult = await spawnPiSession(cwd, { strategy: config.spawnStrategy });
165
- if (spawnResult.process && spawnResult.pid) {
166
- browserGateway.headlessPidRegistry.register(spawnResult.pid, cwd, spawnResult.process);
167
- }
168
- if (spawnResult.dashboardSpawned && spawnResult.success) {
169
- pendingDashboardSpawns?.set(cwd, (pendingDashboardSpawns?.get(cwd) ?? 0) + 1);
219
+
220
+ const doSpawn = async () => {
221
+ const config = loadConfig();
222
+ const spawnResult = await spawnPiSession(cwd, { strategy: config.spawnStrategy });
223
+ if (spawnResult.process && spawnResult.pid) {
224
+ browserGateway.headlessPidRegistry.register(spawnResult.pid, cwd, spawnResult.process);
225
+ }
226
+ if (spawnResult.dashboardSpawned && spawnResult.success) {
227
+ pendingDashboardSpawns?.set(cwd, (pendingDashboardSpawns?.get(cwd) ?? 0) + 1);
228
+ }
229
+ return spawnResult;
230
+ };
231
+
232
+ // Bootstrap gate: if pi isn't ready, queue the spawn and return 202.
233
+ const gate = gateOrEnqueue(doSpawn);
234
+ if (gate) {
235
+ reply.code(gate.code);
236
+ return gate.body;
170
237
  }
238
+
239
+ const spawnResult = await doSpawn();
171
240
  if (!spawnResult.success) {
172
241
  reply.code(500);
173
242
  return { success: false, error: spawnResult.message } satisfies ApiResponse;