@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
@@ -0,0 +1,140 @@
1
+ /**
2
+ * REST routes for pi core version check and update.
3
+ *
4
+ * GET /api/pi-core/versions[?refresh=true]
5
+ * POST /api/pi-core/update { packages?: string[] }
6
+ *
7
+ * Complements /api/packages/* (extension management): this endpoint covers
8
+ * globally-installed pi CLI packages like @mariozechner/pi-coding-agent,
9
+ * pi-dashboard itself, pi-model-proxy, etc.
10
+ */
11
+ import type { FastifyInstance } from "fastify";
12
+ import type {
13
+ ApiResponse,
14
+ PiCoreStatus,
15
+ PiCoreUpdateRequest,
16
+ PiCoreUpdateResponse,
17
+ } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
18
+ import type { PiCoreChecker } from "../pi-core-checker.js";
19
+ import type { PiCoreUpdater } from "../pi-core-updater.js";
20
+ import { PackageOperationBusyError } from "../package-manager-wrapper.js";
21
+ import type { BootstrapStateStore } from "../bootstrap-state.js";
22
+
23
+ export interface PiCoreRouteDeps {
24
+ piCoreChecker: PiCoreChecker;
25
+ piCoreUpdater: PiCoreUpdater;
26
+ /**
27
+ * When provided, pi-core endpoints return 503 unless bootstrap
28
+ * status is "ready". See change: unified-bootstrap-install §5.5.
29
+ */
30
+ bootstrapState?: BootstrapStateStore;
31
+ /**
32
+ * Called after the updater finishes a batch (success or per-package failure).
33
+ * The server wires this to broadcast a `pi_core_update_complete` WS message
34
+ * so listeners (PiUpdateBadge, PiCoreVersionsSection, usePiCoreVersions
35
+ * hook instances in other open tabs) refetch their state.
36
+ */
37
+ onUpdateComplete?: (payload: {
38
+ results: Array<{ name: string; success: boolean; error?: string }>;
39
+ sessionsReloaded: number;
40
+ }) => void;
41
+ }
42
+
43
+ export function registerPiCoreRoutes(
44
+ fastify: FastifyInstance,
45
+ deps: PiCoreRouteDeps,
46
+ ): void {
47
+ const { piCoreChecker, piCoreUpdater, bootstrapState } = deps;
48
+
49
+ /** Gate: 503 unless bootstrap is ready. Returns undefined when OK to proceed. */
50
+ const bootstrapGate = async (
51
+ _req: unknown,
52
+ reply: { code: (n: number) => { send: (body: unknown) => unknown } },
53
+ ): Promise<unknown> => {
54
+ if (!bootstrapState) return undefined;
55
+ const status = bootstrapState.get().status;
56
+ if (status === "ready") return undefined;
57
+ return reply.code(503).send({
58
+ success: false,
59
+ error: `pi not yet installed (bootstrap status: ${status})`,
60
+ bootstrap: status,
61
+ });
62
+ };
63
+
64
+ // ── GET /api/pi-core/versions ──────────────────────────────────
65
+
66
+ fastify.get<{ Querystring: { refresh?: string } }>(
67
+ "/api/pi-core/versions",
68
+ { preHandler: bootstrapGate as any },
69
+ async (request) => {
70
+ const refresh = request.query.refresh === "true";
71
+ try {
72
+ const status = await piCoreChecker.getStatus(refresh);
73
+ return { success: true, data: status } satisfies ApiResponse<PiCoreStatus>;
74
+ } catch (err: any) {
75
+ return { success: false, error: err?.message ?? String(err) } satisfies ApiResponse;
76
+ }
77
+ },
78
+ );
79
+
80
+ // ── POST /api/pi-core/update ───────────────────────────────────
81
+
82
+ fastify.post<{ Body: PiCoreUpdateRequest }>(
83
+ "/api/pi-core/update",
84
+ { preHandler: bootstrapGate as any },
85
+ async (request, reply) => {
86
+ const requested = request.body?.packages ?? [];
87
+
88
+ // Load current status to determine install source and eligibility.
89
+ const status = await piCoreChecker.getStatus();
90
+ const allByName = new Map(status.packages.map((p) => [p.name, p]));
91
+
92
+ const targetNames =
93
+ requested.length > 0
94
+ ? requested
95
+ : status.packages.filter((p) => p.updateAvailable).map((p) => p.name);
96
+
97
+ const resolved = [];
98
+ const unknown: string[] = [];
99
+ for (const name of targetNames) {
100
+ const pkg = allByName.get(name);
101
+ if (!pkg) {
102
+ unknown.push(name);
103
+ continue;
104
+ }
105
+ resolved.push(pkg);
106
+ }
107
+
108
+ if (unknown.length > 0) {
109
+ reply.code(400);
110
+ return {
111
+ success: false,
112
+ error: `Unknown package(s): ${unknown.join(", ")}`,
113
+ } satisfies ApiResponse;
114
+ }
115
+
116
+ if (resolved.length === 0) {
117
+ return {
118
+ success: true,
119
+ data: { results: [], sessionsReloaded: 0 },
120
+ } satisfies ApiResponse<PiCoreUpdateResponse>;
121
+ }
122
+
123
+ try {
124
+ const out = await piCoreUpdater.update(resolved);
125
+ // Invalidate cache so next version check reflects new versions.
126
+ piCoreChecker.invalidate();
127
+ // Notify other browser tabs / the header badge hook instance so
128
+ // their independent usePiCoreVersions state refetches.
129
+ deps.onUpdateComplete?.(out);
130
+ return { success: true, data: out } satisfies ApiResponse<PiCoreUpdateResponse>;
131
+ } catch (err: any) {
132
+ if (err instanceof PackageOperationBusyError) {
133
+ reply.code(409);
134
+ return { success: false, error: err.message } satisfies ApiResponse;
135
+ }
136
+ return { success: false, error: err?.message ?? String(err) } satisfies ApiResponse;
137
+ }
138
+ },
139
+ );
140
+ }
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * REST routes for browser-based pi provider authentication.
3
3
  */
4
- import { exec } from "node:child_process";
5
4
  import type { FastifyInstance } from "fastify";
6
5
  import {
7
6
  getProviderHandler,
@@ -21,6 +20,7 @@ import {
21
20
  } from "../provider-auth-storage.js";
22
21
  import { startCallbackServer } from "../oauth-callback-server.js";
23
22
  import type { PiGateway } from "../pi-gateway.js";
23
+ import type { BrowserGateway } from "../browser-gateway.js";
24
24
 
25
25
  // ── In-memory flow store (short-lived PKCE + device code state) ──────────────
26
26
 
@@ -65,15 +65,16 @@ function makeFlowId(): string {
65
65
 
66
66
  // ── Helpers ──────────────────────────────────────────────────────────────────
67
67
 
68
+ // Delegate to the shared platform primitive. The cross-OS dispatch
69
+ // (open/start/xdg-open) and URL escaping live in
70
+ // `packages/shared/src/platform/commands.ts`.
71
+ // See change: consolidate-platform-handlers.
72
+ import { openBrowser as platformOpenBrowser } from "@blackbelt-technology/pi-dashboard-shared/platform/commands.js";
73
+
68
74
  /** Open a URL in the system's default browser */
69
75
  function openInBrowser(url: string): void {
70
- const cmd = process.platform === "darwin"
71
- ? `open ${JSON.stringify(url)}`
72
- : process.platform === "win32"
73
- ? `start "" ${JSON.stringify(url)}`
74
- : `xdg-open ${JSON.stringify(url)}`;
75
- exec(cmd, (err) => {
76
- if (err) console.error("[provider-auth] Failed to open browser:", err.message);
76
+ platformOpenBrowser(url, {
77
+ onError: (err) => console.error("[provider-auth] Failed to open browser:", err.message),
77
78
  });
78
79
  }
79
80
 
@@ -81,12 +82,13 @@ function openInBrowser(url: string): void {
81
82
 
82
83
  export function registerProviderAuthRoutes(
83
84
  fastify: FastifyInstance,
84
- deps: { piGateway: PiGateway },
85
+ deps: { piGateway: PiGateway; browserGateway: BrowserGateway },
85
86
  ) {
86
- const { piGateway } = deps;
87
+ const { piGateway, browserGateway } = deps;
87
88
 
88
89
  function notifyBridges() {
89
90
  piGateway.broadcast({ type: "credentials_updated" });
91
+ browserGateway.broadcastToAll({ type: "models_refreshed" });
90
92
  }
91
93
 
92
94
  // List OAuth providers
@@ -6,6 +6,9 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
7
  import { join, dirname } from "node:path";
8
8
  import type { NetworkGuard } from "./route-deps.js";
9
+ import type { PiGateway } from "../pi-gateway.js";
10
+ import type { BrowserGateway } from "../browser-gateway.js";
11
+ import { probeProvider, resolveProbeApiKey, type ProbeApi } from "../provider-probe.js";
9
12
 
10
13
  const REDACTED = "***";
11
14
  const CONFIG_PATH = join(homedir(), ".pi", "agent", "providers.json");
@@ -44,8 +47,8 @@ function redactProviders(
44
47
  return redacted;
45
48
  }
46
49
 
47
- export function registerProviderRoutes(fastify: FastifyInstance, deps: { networkGuard: NetworkGuard }): void {
48
- const { networkGuard } = deps;
50
+ export function registerProviderRoutes(fastify: FastifyInstance, deps: { networkGuard: NetworkGuard; piGateway?: PiGateway; browserGateway?: BrowserGateway }): void {
51
+ const { networkGuard, piGateway } = deps;
49
52
  fastify.get(
50
53
  "/api/providers",
51
54
  { preHandler: networkGuard },
@@ -95,7 +98,57 @@ export function registerProviderRoutes(fastify: FastifyInstance, deps: { network
95
98
  mkdirSync(dir, { recursive: true });
96
99
  writeFileSync(CONFIG_PATH, JSON.stringify(fileData, null, 2) + "\n", "utf-8");
97
100
 
101
+ // Broadcast credentials_updated so all sessions refresh their model registries
102
+ if (piGateway) {
103
+ piGateway.broadcast({ type: "credentials_updated" });
104
+ }
105
+ if (deps.browserGateway) {
106
+ deps.browserGateway.broadcastToAll({ type: "models_refreshed" });
107
+ }
108
+
98
109
  return { success: true };
99
110
  },
100
111
  );
112
+
113
+ // Test a provider configuration without saving it. Accepts literal api keys,
114
+ // $ENV_VAR references, or the REDACTED sentinel (***) for already-saved entries.
115
+ fastify.post(
116
+ "/api/providers/test",
117
+ { preHandler: networkGuard },
118
+ async (request, reply) => {
119
+ const body = request.body as Record<string, any> | null;
120
+ if (!body || typeof body !== "object") {
121
+ return reply.code(400).send({ ok: false, error: "Invalid body" });
122
+ }
123
+ const name = typeof body.name === "string" ? body.name : undefined;
124
+ const baseUrl = typeof body.baseUrl === "string" ? body.baseUrl.trim() : "";
125
+ const apiKey = typeof body.apiKey === "string" ? body.apiKey : "";
126
+ const api = typeof body.api === "string" ? (body.api as ProbeApi) : undefined;
127
+ if (!baseUrl) {
128
+ return reply.code(400).send({ ok: false, error: "baseUrl is required" });
129
+ }
130
+ if (!apiKey) {
131
+ return reply.code(400).send({ ok: false, error: "apiKey is required" });
132
+ }
133
+ if (!api) {
134
+ return reply.code(400).send({ ok: false, error: "api type is required" });
135
+ }
136
+
137
+ const resolved = resolveProbeApiKey({
138
+ apiKey,
139
+ name,
140
+ readProviders: readProvidersRaw,
141
+ });
142
+ if (!resolved.ok) {
143
+ return { ok: false, error: resolved.error };
144
+ }
145
+
146
+ const result = await probeProvider({
147
+ baseUrl,
148
+ apiKey: resolved.key,
149
+ api,
150
+ });
151
+ return result;
152
+ },
153
+ );
101
154
  }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * REST route for the dashboard's curated "recommended extensions" list.
3
+ *
4
+ * GET /api/packages/recommended
5
+ *
6
+ * Returns the static RECOMMENDED_EXTENSIONS manifest enriched with:
7
+ * - live description + version from npm or GitHub (falls back to
8
+ * fallbackDescription on network failure)
9
+ * - installed.scope cross-reference via packageManagerWrapper
10
+ * - activeInPi flag from ~/.pi/agent/settings.json packages[]
11
+ * - updateAvailable flag
12
+ *
13
+ * Results are cached for 60 seconds. The cache is busted when any package
14
+ * install / remove / update operation completes successfully.
15
+ */
16
+ import type { FastifyInstance } from "fastify";
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import os from "node:os";
20
+ import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/types.js";
21
+ import type { EnrichedRecommendedExtension } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
22
+ import {
23
+ RECOMMENDED_EXTENSIONS,
24
+ type RecommendedExtension,
25
+ } from "@blackbelt-technology/pi-dashboard-shared/recommended-extensions.js";
26
+ import {
27
+ parseSourceKey,
28
+ sourcesMatch,
29
+ type SourceKey,
30
+ } from "@blackbelt-technology/pi-dashboard-shared/source-matching.js";
31
+ export { parseSourceKey, sourcesMatch, type SourceKey };
32
+ import {
33
+ fetchPackageMeta,
34
+ fetchGithubPackageJson,
35
+ type PackageMeta,
36
+ } from "../npm-search-proxy.js";
37
+ import type { PackageManagerWrapper } from "../package-manager-wrapper.js";
38
+
39
+ const CACHE_TTL_MS = 60 * 1000;
40
+
41
+ interface CacheEntry {
42
+ at: number;
43
+ data: EnrichedRecommendedExtension[];
44
+ }
45
+
46
+ let cache: CacheEntry | null = null;
47
+
48
+ /** Invalidate the recommended-extensions cache. */
49
+ export function invalidateRecommendedCache(): void {
50
+ cache = null;
51
+ }
52
+
53
+ /**
54
+ * Parse a pi install source into a lookup key for matching against
55
+ * listInstalled() results.
56
+ *
57
+ * Supported forms (matches pi's DefaultPackageManager.parseSource):
58
+ * npm:<name>[@<version>]
59
+ * git@<host>:<owner>/<repo>.git
60
+ * git:<host>/<owner>/<repo>[#<ref>]
61
+ * https://<host>/<owner>/<repo>[.git][#<ref>]
62
+ *
63
+ * Returns:
64
+ * { kind: "npm", name } for npm sources
65
+ * { kind: "git", host, owner, repo } for git sources
66
+ * { kind: "raw", source } for anything else (local paths)
67
+ *
68
+ * Source-matching logic lives in
69
+ * `@blackbelt-technology/pi-dashboard-shared/source-matching.js` so the
70
+ * Electron wizard's bootstrap enricher can apply the same rules without
71
+ * depending on the server runtime. We re-export above so existing
72
+ * imports from this module keep working.
73
+ */
74
+
75
+ /** Read pi's project-local `.pi/settings.json` (if any) for the given cwd. */
76
+ function readLocalSources(cwd: string): string[] {
77
+ const settingsPath = path.join(cwd, ".pi", "settings.json");
78
+ try {
79
+ if (!fs.existsSync(settingsPath)) return [];
80
+ const raw = fs.readFileSync(settingsPath, "utf-8").trim();
81
+ if (!raw) return [];
82
+ const data = JSON.parse(raw);
83
+ const pkgs = Array.isArray(data?.packages) ? (data.packages as unknown[]) : [];
84
+ return pkgs.filter((p): p is string => typeof p === "string");
85
+ } catch {
86
+ return [];
87
+ }
88
+ }
89
+
90
+ /** Collect active package sources from both the user's global
91
+ * `~/.pi/agent/settings.json` and the project's `<cwd>/.pi/settings.json`.
92
+ * Mirrors pi's SettingsManager behavior: a package is "active" in pi if
93
+ * it appears in EITHER scope's packages[] list. */
94
+ function readActiveSources(cwd?: string): string[] {
95
+ const sources: string[] = [];
96
+
97
+ const globalPath = path.join(os.homedir(), ".pi", "agent", "settings.json");
98
+ try {
99
+ if (fs.existsSync(globalPath)) {
100
+ const raw = fs.readFileSync(globalPath, "utf-8").trim();
101
+ if (raw) {
102
+ const data = JSON.parse(raw);
103
+ const pkgs = Array.isArray(data?.packages) ? (data.packages as unknown[]) : [];
104
+ for (const p of pkgs) if (typeof p === "string") sources.push(p);
105
+ }
106
+ }
107
+ } catch {
108
+ /* ignore corrupt global settings */
109
+ }
110
+
111
+ if (cwd) {
112
+ for (const p of readLocalSources(cwd)) sources.push(p);
113
+ }
114
+
115
+ return sources;
116
+ }
117
+
118
+ function semverOlder(installed: string | undefined, latest: string | undefined): boolean {
119
+ if (!installed || !latest) return false;
120
+ if (installed === latest) return false;
121
+ // Very conservative comparison: if they differ textually, assume an
122
+ // update may be available. The Packages tab's check-updates flow can
123
+ // resolve the definitive answer.
124
+ return installed !== latest;
125
+ }
126
+
127
+ async function enrichEntry(
128
+ entry: RecommendedExtension,
129
+ installedGlobal: Array<{ source: string; installedPath?: string }>,
130
+ installedLocal: Array<{ source: string; installedPath?: string }>,
131
+ activeSources: string[],
132
+ ): Promise<EnrichedRecommendedExtension> {
133
+ const key = parseSourceKey(entry.source);
134
+ let meta: PackageMeta | null = null;
135
+
136
+ if (key.kind === "npm") {
137
+ meta = await fetchPackageMeta(key.name);
138
+ } else if (key.kind === "git" && key.host.toLowerCase() === "github.com") {
139
+ meta = await fetchGithubPackageJson(key.owner, key.repo);
140
+ }
141
+
142
+ const description = meta?.description ?? entry.fallbackDescription;
143
+ const version = meta?.version;
144
+
145
+ const inGlobal = installedGlobal.some((p) => sourcesMatch(p.source, entry.source));
146
+ const inLocal = installedLocal.some((p) => sourcesMatch(p.source, entry.source));
147
+ const installedScope: "global" | "local" | null = inGlobal
148
+ ? "global"
149
+ : inLocal
150
+ ? "local"
151
+ : null;
152
+
153
+ const activeInPi = activeSources.some((s) => sourcesMatch(s, entry.source));
154
+
155
+ // Best-effort update indicator: for npm sources, try to read the installed
156
+ // package.json version and compare to the live registry version. For git
157
+ // sources we currently don't track ref pins, so updateAvailable defaults
158
+ // to false (the Packages-tab check-updates action handles this separately).
159
+ let updateAvailable = false;
160
+ if (version && key.kind === "npm" && installedScope) {
161
+ const installed = inGlobal ? installedGlobal : installedLocal;
162
+ const match = installed.find((p) => sourcesMatch(p.source, entry.source));
163
+ if (match?.installedPath) {
164
+ try {
165
+ const pj = path.join(match.installedPath, "package.json");
166
+ if (fs.existsSync(pj)) {
167
+ const parsed = JSON.parse(fs.readFileSync(pj, "utf-8"));
168
+ updateAvailable = semverOlder(parsed?.version, version);
169
+ }
170
+ } catch {
171
+ /* ignore */
172
+ }
173
+ }
174
+ }
175
+
176
+ return {
177
+ ...entry,
178
+ description,
179
+ version,
180
+ installed: { scope: installedScope },
181
+ activeInPi,
182
+ updateAvailable,
183
+ };
184
+ }
185
+
186
+ export function registerRecommendedRoutes(
187
+ fastify: FastifyInstance,
188
+ deps: { packageManagerWrapper: PackageManagerWrapper },
189
+ ): void {
190
+ fastify.get("/api/packages/recommended", async () => {
191
+ const now = Date.now();
192
+ if (cache && now - cache.at < CACHE_TTL_MS) {
193
+ return { success: true, data: { recommended: cache.data } } satisfies ApiResponse<{
194
+ recommended: EnrichedRecommendedExtension[];
195
+ }>;
196
+ }
197
+
198
+ // Run global + local listInstalled in parallel to halve cold-start
199
+ // latency. On Windows where each call instantiates pi's
200
+ // DefaultPackageManager (1-3s cold), sequential awaits were making
201
+ // the "Loading recommended extensions" spinner stick for ~15s.
202
+ const [installedGlobalRes, installedLocalRes] = await Promise.allSettled([
203
+ deps.packageManagerWrapper.listInstalled("global"),
204
+ deps.packageManagerWrapper.listInstalled("local"),
205
+ ]);
206
+ const installedGlobal = (installedGlobalRes.status === "fulfilled" ? installedGlobalRes.value : []) as Array<{ source: string; installedPath?: string }>;
207
+ const installedLocal = (installedLocalRes.status === "fulfilled" ? installedLocalRes.value : []) as Array<{ source: string; installedPath?: string }>;
208
+
209
+ // Include both global + project-local settings.json `packages[]`.
210
+ // The server's CWD is a reasonable proxy for the active project.
211
+ const activeSources = readActiveSources(process.cwd());
212
+
213
+ const enriched = await Promise.all(
214
+ RECOMMENDED_EXTENSIONS.map((entry) =>
215
+ enrichEntry(entry, installedGlobal, installedLocal, activeSources),
216
+ ),
217
+ );
218
+
219
+ cache = { at: now, data: enriched };
220
+
221
+ return { success: true, data: { recommended: enriched } } satisfies ApiResponse<{
222
+ recommended: EnrichedRecommendedExtension[];
223
+ }>;
224
+ });
225
+ }
@@ -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
  },
@@ -147,7 +153,9 @@ export function registerSystemRoutes(
147
153
  });
148
154
 
149
155
  fastify.post("/api/tunnel-disconnect", async () => {
150
- await deleteTunnel();
156
+ // Pass port so orphan zrok processes bound to this endpoint are also
157
+ // swept (not just the one we tracked via pid-file).
158
+ await deleteTunnel(config.port);
151
159
  return { ok: true };
152
160
  });
153
161
 
@@ -186,6 +194,9 @@ export function registerSystemRoutes(
186
194
  async () => {
187
195
  metaPersistence.flushAll();
188
196
  preferencesStore.flush();
197
+ // Tear down the zrok tunnel (and sweep orphans on our port) so restarts
198
+ // don't leak reservations that leave stale URLs backed by nothing.
199
+ try { await deleteTunnel(config.port); } catch { /* best-effort */ }
189
200
  setTimeout(() => process.exit(0), 100);
190
201
  return { ok: true };
191
202
  },
@@ -199,47 +210,32 @@ export function registerSystemRoutes(
199
210
  metaPersistence.flushAll();
200
211
  preferencesStore.flush();
201
212
 
213
+ // Tear down tunnel before spawning the replacement process so the new
214
+ // server doesn't race an orphan zrok agent on the same port.
215
+ try { await deleteTunnel(config.port); } catch { /* best-effort */ }
216
+
202
217
  const cliPath = process.argv[1];
203
218
  if (!cliPath) return { ok: false, error: "Cannot determine CLI path" };
204
219
 
205
220
  // Find the TypeScript loader from process.execArgv (--import <loader>)
206
221
  const importIdx = process.execArgv.indexOf("--import");
207
- const loaderArgs = importIdx >= 0 ? ["--import", process.execArgv[importIdx + 1]] : [];
222
+ const loader = importIdx >= 0 ? (process.execArgv[importIdx + 1] ?? "") : "";
208
223
 
209
224
  // Allow overriding dev mode via request body
210
225
  const useDev = request.body?.dev ?? config.dev;
211
- const args = ["start"];
212
- if (useDev) args.push("--dev");
213
-
214
- // Spawn a shell script that:
215
- // 1. Waits for the old server's port to be free (up to 10s)
216
- // 2. Starts the new server
217
- // 3. Verifies health (up to 10s)
218
- // 4. If health check fails, logs error
219
- const port = config.port;
220
- const nodeCmd = `${JSON.stringify(process.execPath)} ${loaderArgs.map(a => JSON.stringify(a)).join(" ")} ${JSON.stringify(cliPath)} ${args.join(" ")}`;
221
- const script = [
222
- // Wait for port to be free
223
- `for i in $(seq 1 20); do`,
224
- ` lsof -i :${port} -sTCP:LISTEN >/dev/null 2>&1 || break`,
225
- ` sleep 0.5`,
226
- `done`,
227
- // Start new server
228
- nodeCmd,
229
- // Verify health
230
- `for i in $(seq 1 20); do`,
231
- ` curl -sf http://localhost:${port}/api/health >/dev/null 2>&1 && exit 0`,
232
- ` sleep 0.5`,
233
- `done`,
234
- `echo "[dashboard] Restart health check failed" >&2`,
235
- ].join("\n");
226
+ const extraArgs: string[] = [];
227
+ if (useDev) extraArgs.push("--dev");
236
228
 
237
- const child = spawn("sh", ["-c", script], {
238
- detached: true,
239
- stdio: "ignore",
240
- 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,
241
238
  });
242
- child.unref();
243
239
 
244
240
  setTimeout(() => process.exit(0), 200);
245
241
  return { ok: true };