@blackbelt-technology/pi-agent-dashboard 0.3.0 → 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 (197) hide show
  1. package/AGENTS.md +67 -116
  2. package/README.md +93 -7
  3. package/docs/architecture.md +408 -9
  4. package/package.json +6 -4
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  7. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  8. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  9. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  10. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  11. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  12. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  13. package/packages/extension/src/bridge.ts +69 -2
  14. package/packages/extension/src/dev-build.ts +1 -1
  15. package/packages/extension/src/git-info.ts +9 -19
  16. package/packages/extension/src/pi-env.d.ts +1 -0
  17. package/packages/extension/src/process-scanner.ts +72 -38
  18. package/packages/extension/src/provider-register.ts +304 -16
  19. package/packages/extension/src/server-auto-start.ts +27 -1
  20. package/packages/extension/src/server-launcher.ts +71 -27
  21. package/packages/server/package.json +16 -2
  22. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  23. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  24. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  25. package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
  26. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  27. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  28. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  29. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  30. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  31. package/packages/server/src/__tests__/editor-registry.test.ts +28 -15
  32. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  33. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  34. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  35. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  36. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  37. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  38. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  39. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  40. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  41. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
  42. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  43. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  44. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  45. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  46. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  47. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  48. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  49. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  50. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  51. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  52. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  53. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  54. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  55. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  56. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  57. package/packages/server/src/__tests__/tunnel.test.ts +13 -7
  58. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  59. package/packages/server/src/bootstrap-queue.ts +130 -0
  60. package/packages/server/src/bootstrap-state.ts +131 -0
  61. package/packages/server/src/browse.ts +8 -3
  62. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  63. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  64. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  65. package/packages/server/src/cli.ts +256 -32
  66. package/packages/server/src/config-api.ts +16 -0
  67. package/packages/server/src/directory-service.ts +270 -39
  68. package/packages/server/src/editor-detection.ts +12 -9
  69. package/packages/server/src/editor-manager.ts +19 -4
  70. package/packages/server/src/editor-pid-registry.ts +9 -8
  71. package/packages/server/src/editor-registry.ts +22 -25
  72. package/packages/server/src/git-operations.ts +1 -1
  73. package/packages/server/src/headless-pid-registry.ts +7 -20
  74. package/packages/server/src/home-lock-release.ts +72 -0
  75. package/packages/server/src/home-lock.ts +389 -0
  76. package/packages/server/src/node-guard.ts +52 -0
  77. package/packages/server/src/package-manager-wrapper.ts +207 -47
  78. package/packages/server/src/pi-core-checker.ts +1 -1
  79. package/packages/server/src/pi-core-updater.ts +7 -1
  80. package/packages/server/src/pi-resource-scanner.ts +5 -8
  81. package/packages/server/src/pi-version-skew.ts +196 -0
  82. package/packages/server/src/preferences-store.ts +17 -3
  83. package/packages/server/src/process-manager.ts +403 -222
  84. package/packages/server/src/provider-probe.ts +234 -0
  85. package/packages/server/src/restart-helper.ts +130 -0
  86. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  87. package/packages/server/src/routes/openspec-routes.ts +25 -1
  88. package/packages/server/src/routes/pi-core-routes.ts +24 -1
  89. package/packages/server/src/routes/provider-auth-routes.ts +8 -8
  90. package/packages/server/src/routes/provider-routes.ts +43 -0
  91. package/packages/server/src/routes/recommended-routes.ts +10 -12
  92. package/packages/server/src/routes/system-routes.ts +20 -33
  93. package/packages/server/src/routes/tool-routes.ts +153 -0
  94. package/packages/server/src/server-pid.ts +5 -9
  95. package/packages/server/src/server.ts +211 -10
  96. package/packages/server/src/session-api.ts +77 -8
  97. package/packages/server/src/session-bootstrap.ts +17 -3
  98. package/packages/server/src/session-diff.ts +21 -21
  99. package/packages/server/src/terminal-manager.ts +61 -20
  100. package/packages/server/src/tunnel.ts +42 -28
  101. package/packages/shared/package.json +10 -3
  102. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  103. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  104. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  105. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  106. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  107. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  108. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  109. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  110. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  111. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  112. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  113. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  114. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  115. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  116. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  117. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  118. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  119. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  120. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  121. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  122. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  123. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  124. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  125. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  126. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  127. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  128. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  129. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  130. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  131. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  132. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  133. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  134. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  135. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  136. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  137. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  138. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  139. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  140. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  141. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  142. package/packages/shared/src/__tests__/config.test.ts +56 -0
  143. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  144. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  145. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  146. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  147. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  148. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  149. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  150. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  151. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  152. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  153. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  154. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  155. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  156. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  157. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  158. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  159. package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
  160. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  161. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  162. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  163. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  164. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  165. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  166. package/packages/shared/src/bootstrap-install.ts +212 -0
  167. package/packages/shared/src/bridge-register.ts +87 -20
  168. package/packages/shared/src/browser-protocol.ts +71 -1
  169. package/packages/shared/src/config.ts +87 -15
  170. package/packages/shared/src/managed-paths.ts +31 -4
  171. package/packages/shared/src/openspec-poller.ts +63 -46
  172. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  173. package/packages/shared/src/platform/commands.ts +100 -0
  174. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  175. package/packages/shared/src/platform/exec.ts +220 -0
  176. package/packages/shared/src/platform/git.ts +155 -0
  177. package/packages/shared/src/platform/index.ts +15 -0
  178. package/packages/shared/src/platform/npm.ts +162 -0
  179. package/packages/shared/src/platform/openspec.ts +91 -0
  180. package/packages/shared/src/platform/paths.ts +276 -0
  181. package/packages/shared/src/platform/process-identify.ts +126 -0
  182. package/packages/shared/src/platform/process-scan.ts +94 -0
  183. package/packages/shared/src/platform/process.ts +168 -0
  184. package/packages/shared/src/platform/runner.ts +369 -0
  185. package/packages/shared/src/platform/shell.ts +44 -0
  186. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  187. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  188. package/packages/shared/src/recommended-extensions.ts +18 -2
  189. package/packages/shared/src/resolve-jiti.ts +62 -3
  190. package/packages/shared/src/rest-api.ts +26 -0
  191. package/packages/shared/src/semaphore.ts +83 -0
  192. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  193. package/packages/shared/src/tool-registry/index.ts +56 -0
  194. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  195. package/packages/shared/src/tool-registry/registry.ts +262 -0
  196. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  197. package/packages/shared/src/tool-registry/types.ts +180 -0
@@ -5,6 +5,7 @@ import type { FastifyInstance } from "fastify";
5
5
  import type { SessionManager } from "../memory-session-manager.js";
6
6
  import type { PreferencesStore } from "../preferences-store.js";
7
7
  import type { MetaPersistence } from "../meta-persistence.js";
8
+ import type { DirectoryService } from "../directory-service.js";
8
9
  import type { ServerConfig } from "../server.js";
9
10
  import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/types.js";
10
11
  import type { NetworkGuard } from "./route-deps.js";
@@ -12,7 +13,8 @@ import { detectEditors, EDITORS } from "../editor-registry.js";
12
13
  import { detectCodeServerBinary, resetDetectionCache } from "../editor-detection.js";
13
14
  import { readConfigRedacted, writeConfigPartial } from "../config-api.js";
14
15
  import { createTunnel, deleteTunnel, getTunnelStatus } from "../tunnel.js";
15
- import { spawn } from "node:child_process";
16
+ import { spawnRestart } from "../restart-helper.js";
17
+ import { spawn } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
16
18
  import path from "node:path";
17
19
  import os from "node:os";
18
20
  import { localhostGuard, netmaskToCidrBits, networkAddress } from "../localhost-guard.js";
@@ -27,9 +29,10 @@ export function registerSystemRoutes(
27
29
  config: ServerConfig;
28
30
  networkGuard: NetworkGuard;
29
31
  version?: string;
32
+ directoryService?: DirectoryService;
30
33
  },
31
34
  ) {
32
- const { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version } = deps;
35
+ const { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version, directoryService } = deps;
33
36
  const serverStartTime = Date.now();
34
37
 
35
38
  // Editor detection endpoint
@@ -127,6 +130,9 @@ export function registerSystemRoutes(
127
130
  await (fastify as any)._reloadAuth(reloaded.auth);
128
131
  }
129
132
  }
133
+ if (partial.openspec !== undefined && directoryService) {
134
+ directoryService.reconfigurePolling(reloaded.openspec);
135
+ }
130
136
 
131
137
  return { success: true, restartRequired: result.restartRequired };
132
138
  },
@@ -213,42 +219,23 @@ export function registerSystemRoutes(
213
219
 
214
220
  // Find the TypeScript loader from process.execArgv (--import <loader>)
215
221
  const importIdx = process.execArgv.indexOf("--import");
216
- const loaderArgs = importIdx >= 0 ? ["--import", process.execArgv[importIdx + 1]] : [];
222
+ const loader = importIdx >= 0 ? (process.execArgv[importIdx + 1] ?? "") : "";
217
223
 
218
224
  // Allow overriding dev mode via request body
219
225
  const useDev = request.body?.dev ?? config.dev;
220
- const args = ["start"];
221
- if (useDev) args.push("--dev");
222
-
223
- // Spawn a shell script that:
224
- // 1. Waits for the old server's port to be free (up to 10s)
225
- // 2. Starts the new server
226
- // 3. Verifies health (up to 10s)
227
- // 4. If health check fails, logs error
228
- const port = config.port;
229
- const nodeCmd = `${JSON.stringify(process.execPath)} ${loaderArgs.map(a => JSON.stringify(a)).join(" ")} ${JSON.stringify(cliPath)} ${args.join(" ")}`;
230
- const script = [
231
- // Wait for port to be free
232
- `for i in $(seq 1 20); do`,
233
- ` lsof -i :${port} -sTCP:LISTEN >/dev/null 2>&1 || break`,
234
- ` sleep 0.5`,
235
- `done`,
236
- // Start new server
237
- nodeCmd,
238
- // Verify health
239
- `for i in $(seq 1 20); do`,
240
- ` curl -sf http://localhost:${port}/api/health >/dev/null 2>&1 && exit 0`,
241
- ` sleep 0.5`,
242
- `done`,
243
- `echo "[dashboard] Restart health check failed" >&2`,
244
- ].join("\n");
226
+ const extraArgs: string[] = [];
227
+ if (useDev) extraArgs.push("--dev");
245
228
 
246
- const child = spawn("sh", ["-c", script], {
247
- detached: true,
248
- stdio: "ignore",
249
- env: { ...process.env },
229
+ // Cross-platform restart: spawns a detached Node orchestrator that
230
+ // polls the port via net, spawns the new server, polls /api/health
231
+ // via http. No dependency on sh/lsof/curl — works on Windows too.
232
+ // See change: fix-windows-server-parity.
233
+ spawnRestart({
234
+ cliPath,
235
+ loader,
236
+ port: config.port,
237
+ extraArgs,
250
238
  });
251
- child.unref();
252
239
 
253
240
  setTimeout(() => process.exit(0), 200);
254
241
  return { ok: true };
@@ -0,0 +1,153 @@
1
+ /**
2
+ * REST routes for the tool registry.
3
+ *
4
+ * GET /api/tools → list all resolutions
5
+ * GET /api/tools/:name → single resolution
6
+ * POST /api/tools/rescan → invalidate + refresh (all or one)
7
+ * PUT /api/tools/:name → set override path
8
+ * DELETE /api/tools/:name → clear override
9
+ * POST /api/tools/diagnostics → text/plain export
10
+ *
11
+ * Every route is guarded by the same network guard used by /api/config.
12
+ *
13
+ * See change: consolidate-tool-resolution (specs/tool-settings-ui).
14
+ */
15
+ import type { FastifyInstance } from "fastify";
16
+ import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/types.js";
17
+ import type {
18
+ Resolution,
19
+ ToolRegistry,
20
+ } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js";
21
+ import { UnknownToolError } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js";
22
+ import type { NetworkGuard } from "./route-deps.js";
23
+
24
+ export interface ToolRoutesDeps {
25
+ registry: ToolRegistry;
26
+ networkGuard: NetworkGuard;
27
+ }
28
+
29
+ /**
30
+ * Format a plain-text diagnostics export. One tool per block, one line
31
+ * per attempted strategy. Used by the Settings panel's "Export diagnostics"
32
+ * action and by bug-report attachments.
33
+ */
34
+ export function formatDiagnostics(tools: Resolution[]): string {
35
+ const lines: string[] = [];
36
+ lines.push(`# pi-dashboard tool diagnostics — ${new Date().toISOString()}`);
37
+ lines.push("");
38
+ for (const t of tools) {
39
+ const header = t.ok
40
+ ? `[ok] ${t.name} (${t.source}) → ${t.path}`
41
+ : `[miss] ${t.name} → not found`;
42
+ lines.push(header);
43
+ for (const entry of t.tried) {
44
+ lines.push(` - ${entry.strategy}: ${entry.result}`);
45
+ }
46
+ lines.push("");
47
+ }
48
+ return lines.join("\n");
49
+ }
50
+
51
+ export function registerToolRoutes(
52
+ fastify: FastifyInstance,
53
+ { registry, networkGuard }: ToolRoutesDeps,
54
+ ): void {
55
+ // ── GET /api/tools ─────────────────────────────────────────────────
56
+ fastify.get(
57
+ "/api/tools",
58
+ { preHandler: networkGuard },
59
+ async () => {
60
+ return { success: true, data: { tools: registry.list() } } satisfies ApiResponse<{
61
+ tools: Resolution[];
62
+ }>;
63
+ },
64
+ );
65
+
66
+ // ── GET /api/tools/:name ───────────────────────────────────────────
67
+ fastify.get<{ Params: { name: string } }>(
68
+ "/api/tools/:name",
69
+ { preHandler: networkGuard },
70
+ async (request, reply) => {
71
+ const { name } = request.params;
72
+ if (!registry.has(name)) {
73
+ reply.status(404);
74
+ return { success: false, error: `Unknown tool: ${name}` } satisfies ApiResponse;
75
+ }
76
+ return { success: true, data: registry.resolve(name) } satisfies ApiResponse<Resolution>;
77
+ },
78
+ );
79
+
80
+ // ── POST /api/tools/rescan ─────────────────────────────────────────
81
+ fastify.post<{ Body: { name?: string } }>(
82
+ "/api/tools/rescan",
83
+ { preHandler: networkGuard },
84
+ async (request, reply) => {
85
+ const name = request.body?.name;
86
+ if (name !== undefined) {
87
+ if (!registry.has(name)) {
88
+ reply.status(404);
89
+ return { success: false, error: `Unknown tool: ${name}` } satisfies ApiResponse;
90
+ }
91
+ registry.rescan(name);
92
+ } else {
93
+ registry.rescan();
94
+ }
95
+ return { success: true, data: { tools: registry.list() } } satisfies ApiResponse<{
96
+ tools: Resolution[];
97
+ }>;
98
+ },
99
+ );
100
+
101
+ // ── PUT /api/tools/:name ───────────────────────────────────────────
102
+ fastify.put<{ Params: { name: string }; Body: { path?: string } }>(
103
+ "/api/tools/:name",
104
+ { preHandler: networkGuard },
105
+ async (request, reply) => {
106
+ const { name } = request.params;
107
+ const overridePath = request.body?.path;
108
+ if (typeof overridePath !== "string" || !overridePath.trim()) {
109
+ reply.status(400);
110
+ return { success: false, error: "body.path is required (non-empty string)" } satisfies ApiResponse;
111
+ }
112
+ try {
113
+ registry.setOverride(name, overridePath.trim());
114
+ } catch (err) {
115
+ if (err instanceof UnknownToolError) {
116
+ reply.status(404);
117
+ return { success: false, error: err.message } satisfies ApiResponse;
118
+ }
119
+ throw err;
120
+ }
121
+ return { success: true, data: registry.resolve(name) } satisfies ApiResponse<Resolution>;
122
+ },
123
+ );
124
+
125
+ // ── DELETE /api/tools/:name ────────────────────────────────────────
126
+ fastify.delete<{ Params: { name: string } }>(
127
+ "/api/tools/:name",
128
+ { preHandler: networkGuard },
129
+ async (request, reply) => {
130
+ const { name } = request.params;
131
+ try {
132
+ registry.clearOverride(name);
133
+ } catch (err) {
134
+ if (err instanceof UnknownToolError) {
135
+ reply.status(404);
136
+ return { success: false, error: err.message } satisfies ApiResponse;
137
+ }
138
+ throw err;
139
+ }
140
+ return { success: true, data: registry.resolve(name) } satisfies ApiResponse<Resolution>;
141
+ },
142
+ );
143
+
144
+ // ── POST /api/tools/diagnostics ────────────────────────────────────
145
+ fastify.post(
146
+ "/api/tools/diagnostics",
147
+ { preHandler: networkGuard },
148
+ async (_request, reply) => {
149
+ reply.type("text/plain; charset=utf-8");
150
+ return formatDiagnostics(registry.list());
151
+ },
152
+ );
153
+ }
@@ -6,6 +6,7 @@ import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import os from "node:os";
8
8
  import { isDashboardRunning } from "@blackbelt-technology/pi-dashboard-shared/server-identity.js";
9
+ import { isProcessAlive } from "@blackbelt-technology/pi-dashboard-shared/platform/process.js";
9
10
 
10
11
  const DEFAULT_PID_PATH = path.join(os.homedir(), ".pi", "dashboard", "server.pid");
11
12
 
@@ -14,16 +15,11 @@ export interface ServerPidOptions {
14
15
  }
15
16
 
16
17
  /**
17
- * Check if a process with the given PID is alive.
18
+ * Re-export the platform's liveness primitive so existing importers of
19
+ * `server-pid.ts::isProcessAlive` keep working. See change:
20
+ * route-kill-paths-through-platform.
18
21
  */
19
- export function isProcessAlive(pid: number): boolean {
20
- try {
21
- process.kill(pid, 0);
22
- return true;
23
- } catch {
24
- return false;
25
- }
26
- }
22
+ export { isProcessAlive };
27
23
 
28
24
  /**
29
25
  * Write the current process PID to the PID file.
@@ -47,6 +47,12 @@ import { registerRecommendedRoutes, invalidateRecommendedCache } from "./routes/
47
47
  import { registerPiCoreRoutes } from "./routes/pi-core-routes.js";
48
48
  import { PiCoreChecker } from "./pi-core-checker.js";
49
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";
50
56
  import { registerProviderRoutes } from "./routes/provider-routes.js";
51
57
  import { PackageManagerWrapper } from "./package-manager-wrapper.js";
52
58
  import { createEditorManager, type EditorManager } from "./editor-manager.js";
@@ -73,6 +79,8 @@ export interface ServerConfig {
73
79
  maxWsBufferBytes?: number;
74
80
  /** Editor (code-server) config */
75
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;
76
84
  /** Merged trusted networks from config */
77
85
  resolvedTrustedNetworks?: string[];
78
86
  /** CORS allowed origins from config */
@@ -85,6 +93,14 @@ export interface DashboardServer {
85
93
  sessionManager: SessionManager;
86
94
  eventStore: EventStore;
87
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;
88
104
  /** Resolved HTTP port after start() (useful for port:0 in tests). Returns null if not listening. */
89
105
  httpPort(): number | null;
90
106
  /** Resolved pi gateway port after start(). Returns null if not listening. */
@@ -94,9 +110,20 @@ export interface DashboardServer {
94
110
  export async function createServer(config: ServerConfig): Promise<DashboardServer> {
95
111
  // Ensure bridge extension is registered in pi's global settings
96
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.
97
117
  const __serverDir = path.dirname(fileURLToPath(import.meta.url));
98
- const extPath = findBundledExtension(path.resolve(__serverDir, "..", ".."));
99
- 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
+ }
100
127
 
101
128
  // Run migration from sessions.json + state.json if needed
102
129
  if (needsMigration()) {
@@ -161,7 +188,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
161
188
  knownSessionIds.add(s.id);
162
189
  }
163
190
 
164
- const directoryService = createDirectoryService(preferencesStore, sessionManager);
191
+ const directoryService = createDirectoryService(preferencesStore, sessionManager, config.openspec);
165
192
 
166
193
  // mDNS peer discovery state
167
194
  let mdnsBrowser: DashboardBrowser | null = null;
@@ -329,6 +356,33 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
329
356
  fastify.get("/auth/status", async () => ({ authenticated: true, authEnabled: false }));
330
357
  }
331
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
+
332
386
  // Session control REST API (wraps WebSocket-only operations)
333
387
  registerSessionApi(fastify, {
334
388
  sessionManager,
@@ -336,6 +390,8 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
336
390
  browserGateway,
337
391
  pendingForkRegistry,
338
392
  pendingDashboardSpawns,
393
+ bootstrapState,
394
+ bootstrapQueue,
339
395
  });
340
396
 
341
397
  // Register route modules
@@ -350,12 +406,118 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
350
406
  preferencesStore,
351
407
  directoryService,
352
408
  networkGuard,
409
+ bootstrapState,
353
410
  onOpenSpecChanged: (cwd) => {
354
411
  const data = directoryService.getOpenSpecData(cwd);
355
412
  if (data) browserGateway.broadcastToAll({ type: "openspec_update", cwd, data });
356
413
  },
357
414
  });
358
- registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version: pkgVersion });
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
+ });
359
521
  // Package management
360
522
  const packageManagerWrapper = new PackageManagerWrapper();
361
523
 
@@ -374,6 +536,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
374
536
  scope: result.scope,
375
537
  success: result.success,
376
538
  error: result.error,
539
+ diagnostics: result.diagnostics,
377
540
  sessionsReloaded: (result as any).sessionsReloaded,
378
541
  } as any);
379
542
  if (result.success) invalidateRecommendedCache();
@@ -432,6 +595,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
432
595
  registerPiCoreRoutes(fastify, {
433
596
  piCoreChecker,
434
597
  piCoreUpdater,
598
+ bootstrapState,
435
599
  onUpdateComplete: (payload) => {
436
600
  browserGateway.broadcastToAll({
437
601
  type: "pi_core_update_complete",
@@ -441,6 +605,17 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
441
605
  },
442
606
  });
443
607
 
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
+
444
619
  // Editor (code-server) routes and proxy.
445
620
  // NOTE: routes are *registered* here but cannot dispatch until fastify.listen runs
446
621
  // inside server.start(). The orphan sweep in editorPidRegistry.cleanupOrphans()
@@ -453,17 +628,38 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
453
628
  registerKnownServersRoutes(fastify, { networkGuard, getPeerServers: () => peerServers });
454
629
  registerProviderRoutes(fastify, { networkGuard, piGateway, browserGateway });
455
630
 
456
- // Serve static files / SPA fallback
457
- // Search order: npm package → workspace sibling → legacy dist/client
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.
458
645
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
459
- const clientSearchPaths = [
460
- // Installed as npm dependency
461
- 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"),
462
658
  // Monorepo workspace sibling
463
659
  path.join(__dirname, "../../client/dist"),
464
660
  // Legacy path
465
661
  path.join(__dirname, "../../dist/client"),
466
- ];
662
+ );
467
663
  const clientDir = clientSearchPaths.find(p => existsSync(path.join(p, "index.html"))) ?? "";
468
664
  const hasProductionBuild = !!clientDir;
469
665
  if (!hasProductionBuild) {
@@ -548,6 +744,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
548
744
  sessionManager,
549
745
  eventStore,
550
746
  browserGateway,
747
+ bootstrapState,
551
748
 
552
749
  httpPort() {
553
750
  const addr = fastify.server.address();
@@ -670,6 +867,10 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
670
867
  preferencesStore.flush();
671
868
  preferencesStore.dispose();
672
869
 
870
+ unsubscribeBootstrap();
871
+ unsubscribeQueueComplete();
872
+ bootstrapState.dispose();
873
+ bootstrapQueue.clear("server shutting down");
673
874
  await deleteTunnel(config.port);
674
875
  piGateway.stop();
675
876
  for (const client of browserGateway.wss.clients) {