@blackbelt-technology/pi-agent-dashboard 0.3.0 → 0.4.1

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 (216) hide show
  1. package/AGENTS.md +87 -114
  2. package/README.md +408 -430
  3. package/docs/architecture.md +465 -12
  4. package/package.json +10 -5
  5. package/packages/extension/package.json +14 -4
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +40 -8
  7. package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
  8. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  9. package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
  10. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
  11. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  12. package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
  13. package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
  14. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  15. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  16. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  17. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  18. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  19. package/packages/extension/src/ask-user-tool.ts +5 -4
  20. package/packages/extension/src/bridge.ts +171 -17
  21. package/packages/extension/src/dev-build.ts +1 -1
  22. package/packages/extension/src/git-info.ts +9 -19
  23. package/packages/extension/src/multiselect-list.ts +146 -0
  24. package/packages/extension/src/multiselect-polyfill.ts +43 -0
  25. package/packages/extension/src/pi-env.d.ts +1 -0
  26. package/packages/extension/src/process-scanner.ts +72 -38
  27. package/packages/extension/src/provider-register.ts +304 -16
  28. package/packages/extension/src/server-auto-start.ts +27 -1
  29. package/packages/extension/src/server-launcher.ts +83 -27
  30. package/packages/server/package.json +16 -2
  31. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  32. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  33. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  34. package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
  35. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  36. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  37. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  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-registry.test.ts +28 -15
  41. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  42. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  43. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  44. package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
  45. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  46. package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
  47. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  48. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  49. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  50. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  51. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  52. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
  53. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  54. package/packages/server/src/__tests__/pi-version-skew.test.ts +237 -0
  55. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  56. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  57. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  58. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  59. package/packages/server/src/__tests__/restart-helper.test.ts +111 -0
  60. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  61. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  62. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  63. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  64. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  65. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  66. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  67. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  68. package/packages/server/src/__tests__/tunnel.test.ts +13 -7
  69. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  70. package/packages/server/src/bootstrap-queue.ts +130 -0
  71. package/packages/server/src/bootstrap-state.ts +131 -0
  72. package/packages/server/src/browse.ts +8 -3
  73. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  74. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  75. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  76. package/packages/server/src/cli.ts +310 -39
  77. package/packages/server/src/config-api.ts +16 -0
  78. package/packages/server/src/directory-service.ts +270 -39
  79. package/packages/server/src/editor-detection.ts +12 -9
  80. package/packages/server/src/editor-manager.ts +19 -4
  81. package/packages/server/src/editor-pid-registry.ts +9 -8
  82. package/packages/server/src/editor-registry.ts +22 -25
  83. package/packages/server/src/git-operations.ts +1 -1
  84. package/packages/server/src/headless-pid-registry.ts +7 -20
  85. package/packages/server/src/home-lock-release.ts +72 -0
  86. package/packages/server/src/home-lock.ts +389 -0
  87. package/packages/server/src/node-guard.ts +52 -0
  88. package/packages/server/src/package-manager-wrapper.ts +207 -47
  89. package/packages/server/src/pi-core-checker.ts +1 -1
  90. package/packages/server/src/pi-core-updater.ts +7 -1
  91. package/packages/server/src/pi-resource-scanner.ts +5 -8
  92. package/packages/server/src/pi-version-skew.ts +207 -0
  93. package/packages/server/src/preferences-store.ts +17 -3
  94. package/packages/server/src/process-manager.ts +403 -222
  95. package/packages/server/src/provider-probe.ts +234 -0
  96. package/packages/server/src/restart-helper.ts +141 -0
  97. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  98. package/packages/server/src/routes/openspec-routes.ts +25 -1
  99. package/packages/server/src/routes/pi-core-routes.ts +24 -1
  100. package/packages/server/src/routes/provider-auth-routes.ts +8 -8
  101. package/packages/server/src/routes/provider-routes.ts +43 -0
  102. package/packages/server/src/routes/recommended-routes.ts +10 -12
  103. package/packages/server/src/routes/system-routes.ts +20 -33
  104. package/packages/server/src/routes/tool-routes.ts +153 -0
  105. package/packages/server/src/server-pid.ts +5 -9
  106. package/packages/server/src/server.ts +211 -10
  107. package/packages/server/src/session-api.ts +77 -8
  108. package/packages/server/src/session-bootstrap.ts +17 -3
  109. package/packages/server/src/session-diff.ts +21 -21
  110. package/packages/server/src/terminal-manager.ts +61 -20
  111. package/packages/server/src/tunnel.ts +42 -28
  112. package/packages/shared/package.json +10 -3
  113. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  114. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  115. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  116. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  117. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  118. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  119. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  120. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  121. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  122. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  123. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  124. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  125. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  126. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  127. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  128. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  129. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  130. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  131. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  132. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  133. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  134. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  135. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  136. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  137. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  138. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  139. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  140. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  141. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  142. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  143. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  144. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  145. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  146. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  147. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  148. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  149. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  150. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  151. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  152. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  153. package/packages/shared/src/__tests__/config.test.ts +56 -0
  154. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  155. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  156. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  157. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  158. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  159. package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
  160. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
  161. package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
  162. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  163. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  164. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  165. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  166. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  167. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  168. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  169. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  170. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  171. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  172. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  173. package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
  174. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  175. package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
  176. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  177. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  178. package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
  179. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  180. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  181. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  182. package/packages/shared/src/bootstrap-install.ts +212 -0
  183. package/packages/shared/src/bridge-register.ts +87 -20
  184. package/packages/shared/src/browser-protocol.ts +71 -1
  185. package/packages/shared/src/config.ts +87 -15
  186. package/packages/shared/src/managed-paths.ts +31 -4
  187. package/packages/shared/src/openspec-poller.ts +63 -46
  188. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  189. package/packages/shared/src/platform/commands.ts +100 -0
  190. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  191. package/packages/shared/src/platform/exec.ts +220 -0
  192. package/packages/shared/src/platform/git.ts +155 -0
  193. package/packages/shared/src/platform/index.ts +16 -0
  194. package/packages/shared/src/platform/node-spawn.ts +154 -0
  195. package/packages/shared/src/platform/npm.ts +162 -0
  196. package/packages/shared/src/platform/openspec.ts +91 -0
  197. package/packages/shared/src/platform/paths.ts +276 -0
  198. package/packages/shared/src/platform/process-identify.ts +126 -0
  199. package/packages/shared/src/platform/process-scan.ts +94 -0
  200. package/packages/shared/src/platform/process.ts +168 -0
  201. package/packages/shared/src/platform/runner.ts +369 -0
  202. package/packages/shared/src/platform/shell.ts +44 -0
  203. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  204. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  205. package/packages/shared/src/protocol.ts +23 -0
  206. package/packages/shared/src/recommended-extensions.ts +18 -2
  207. package/packages/shared/src/resolve-jiti.ts +62 -3
  208. package/packages/shared/src/rest-api.ts +26 -0
  209. package/packages/shared/src/semaphore.ts +83 -0
  210. package/packages/shared/src/state-replay.ts +9 -0
  211. package/packages/shared/src/tool-registry/definitions.ts +434 -0
  212. package/packages/shared/src/tool-registry/index.ts +56 -0
  213. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  214. package/packages/shared/src/tool-registry/registry.ts +262 -0
  215. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  216. package/packages/shared/src/tool-registry/types.ts +180 -0
@@ -17,17 +17,74 @@
17
17
  */
18
18
  import { createServer, type ServerConfig } from "./server.js";
19
19
  import { loadConfig, ensureConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
20
- import { spawn } from "node:child_process";
20
+ import { spawn } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
21
+ import { spawnNodeScript } from "@blackbelt-technology/pi-dashboard-shared/platform/node-spawn.js";
21
22
  import { createRequire } from "node:module";
22
- import { fileURLToPath } from "node:url";
23
+ import { fileURLToPath, pathToFileURL } from "node:url";
23
24
  import fs from "node:fs";
25
+ import os from "node:os";
24
26
  import path from "node:path";
25
- import { readPid, isProcessAlive, removePid, isServerRunning } from "./server-pid.js";
27
+ import { readPid, removePid, isServerRunning } from "./server-pid.js";
28
+ import {
29
+ findPortHolders as platformFindPortHolders,
30
+ isProcessAlive as platformIsProcessAlive,
31
+ killProcess as platformKillProcess,
32
+ parseNetstatListeners as platformParseNetstatListeners,
33
+ } from "@blackbelt-technology/pi-dashboard-shared/platform/process.js";
34
+
35
+ // Re-exports for back-compat — other modules / tests may import these from cli.
36
+ export const parseNetstatListeners = platformParseNetstatListeners;
37
+ export function findPortHolders(
38
+ port: number,
39
+ execImpl?: (cmd: string, opts: { encoding: "utf-8" }) => string,
40
+ ): number[] {
41
+ return platformFindPortHolders(port, execImpl ? { exec: execImpl } : undefined);
42
+ }
26
43
  import { isDashboardRunning } from "@blackbelt-technology/pi-dashboard-shared/server-identity.js";
27
44
  import { discoverDashboard } from "@blackbelt-technology/pi-dashboard-shared/mdns-discovery.js";
28
45
  import { resolveJitiImport } from "@blackbelt-technology/pi-dashboard-shared/resolve-jiti.js";
46
+ import { assertNodeVersionSupported } from "./node-guard.js";
47
+ import { getDefaultRegistry } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js";
48
+ import { bootstrapInstall } from "@blackbelt-technology/pi-dashboard-shared/bootstrap-install.js";
49
+ import {
50
+ findBundledExtension,
51
+ registerBridgeExtension,
52
+ } from "@blackbelt-technology/pi-dashboard-shared/bridge-register.js";
53
+ import type { DashboardServer } from "./server.js";
54
+ import { updateBootstrapCompatibility } from "./pi-version-skew.js";
55
+ import type { BootstrapStateStore } from "./bootstrap-state.js";
29
56
 
30
- const SUBCOMMANDS = ["start", "stop", "restart", "status"] as const;
57
+ /**
58
+ * Emit a stderr warning at CLI startup when the resolved pi version is
59
+ * below `piCompatibility.minimum` (blocking) or below `.recommended`
60
+ * (advisory). Reads from the already-populated `bootstrapState` so no
61
+ * additional I/O happens here. See change: warn-pi-version-skew-in-cli.
62
+ */
63
+ function logCompatibilityWarning(store: BootstrapStateStore): void {
64
+ const s = store.get();
65
+ const c = s.compatibility;
66
+ if (!c || !c.current) return;
67
+ // Below minimum: `updateBootstrapCompatibility` sets `error.message`.
68
+ // We treat the presence of a blocking error + upgradeRecommended as the
69
+ // below-minimum signal; `upgradeRecommended` alone means below-recommended.
70
+ if (s.error?.message && c.upgradeRecommended) {
71
+ console.error(
72
+ `[bootstrap] ⚠ pi ${c.current} is below the required minimum ${c.minimum}.`,
73
+ );
74
+ console.error(
75
+ `[bootstrap] All pi-dependent features (sessions, resources, openspec) will return 503.`,
76
+ );
77
+ console.error(`[bootstrap] Run: pi-dashboard upgrade-pi`);
78
+ return;
79
+ }
80
+ if (c.upgradeRecommended) {
81
+ console.warn(
82
+ `[bootstrap] pi ${c.current} is below the recommended ${c.recommended} — consider running \`pi-dashboard upgrade-pi\``,
83
+ );
84
+ }
85
+ }
86
+
87
+ const SUBCOMMANDS = ["start", "stop", "restart", "status", "upgrade-pi"] as const;
31
88
  type Subcommand = (typeof SUBCOMMANDS)[number];
32
89
 
33
90
  export interface ParsedArgs {
@@ -87,6 +144,7 @@ export function buildConfig(flags: Partial<ServerConfig>): ServerConfig {
87
144
  maxStringFieldSize: fileConfig.memoryLimits.maxStringFieldSize,
88
145
  maxWsBufferBytes: fileConfig.memoryLimits.maxWsBufferBytes,
89
146
  editor: fileConfig.editor,
147
+ openspec: fileConfig.openspec,
90
148
  resolvedTrustedNetworks: fileConfig.resolvedTrustedNetworks,
91
149
  corsAllowedOrigins: fileConfig.cors.allowedOrigins,
92
150
  };
@@ -94,8 +152,17 @@ export function buildConfig(flags: Partial<ServerConfig>): ServerConfig {
94
152
 
95
153
  /**
96
154
  * Run the server in the foreground (original behavior).
155
+ *
156
+ * After the server starts listening, the degraded-mode bootstrap kicks
157
+ * off: if `pi` is not resolvable via the ToolRegistry, the server flips
158
+ * `bootstrapState` to "installing" and begins a background
159
+ * `bootstrapInstall`. Session-spawn and other pi-dependent endpoints
160
+ * queue or 503 during this window (see change tasks §5).
161
+ *
162
+ * See change: unified-bootstrap-install.
97
163
  */
98
164
  async function runForeground(config: ServerConfig): Promise<void> {
165
+ assertNodeVersionSupported();
99
166
  const server = await createServer(config);
100
167
 
101
168
  let shuttingDown = false;
@@ -114,12 +181,141 @@ async function runForeground(config: ServerConfig): Promise<void> {
114
181
  process.on("SIGTERM", shutdown);
115
182
 
116
183
  await server.start();
184
+
185
+ // Kick off the degraded-mode first-run bootstrap if pi is unresolvable.
186
+ // Runs async — server is already listening, so UI + non-pi endpoints
187
+ // remain fully operational during the ~30s install window.
188
+ // TODO(single-dashboard-per-home): when home-lock wiring lands, wrap
189
+ // this inside the acquired lock to serialize concurrent first-run
190
+ // installs from multiple dashboard invocations on the same HOME.
191
+ runDegradedModeBootstrap(server).catch((err) => {
192
+ console.error("[bootstrap] unexpected failure in bootstrap orchestrator:", err);
193
+ });
194
+ }
195
+
196
+ /**
197
+ * Orchestrate the first-run bootstrap flow.
198
+ *
199
+ * - If pi is already resolvable → leave `bootstrapState` at the default
200
+ * "ready" and return immediately.
201
+ * - Otherwise flip to "installing", run `bootstrapInstall`, then:
202
+ * • on success, rescan the registry, attempt bridge registration
203
+ * (failures are non-fatal and land in `bridgeRegistrationError`),
204
+ * flip to "ready".
205
+ * • on failure, flip to "failed" with the error.
206
+ *
207
+ * Structured log lines at each transition aid diagnosis in daemon-mode
208
+ * (stdout goes to ~/.pi/dashboard/server.log).
209
+ */
210
+ async function runDegradedModeBootstrap(server: DashboardServer): Promise<void> {
211
+ const registry = getDefaultRegistry();
212
+ const initial = registry.resolve("pi");
213
+
214
+ if (initial.ok) {
215
+ // Default state is "ready" — no change needed. Log once for clarity.
216
+ console.log(`[bootstrap] ready (pi resolved via ${initial.source})`);
217
+ // Populate version-skew compatibility info even when no install was
218
+ // needed — the UI banner renders upgradeRecommended hints.
219
+ try {
220
+ const serverPkg = path.resolve(
221
+ path.dirname(fileURLToPath(import.meta.url)),
222
+ "..",
223
+ "package.json",
224
+ );
225
+ updateBootstrapCompatibility(server.bootstrapState, serverPkg);
226
+ logCompatibilityWarning(server.bootstrapState);
227
+ } catch (err) {
228
+ console.warn("[bootstrap] version-skew check failed (non-fatal):", err);
229
+ }
230
+ return;
231
+ }
232
+
233
+ const installPackages = ["@mariozechner/pi-coding-agent", "@fission-ai/openspec", "tsx"];
234
+ server.bootstrapState.setLastInstallPackages(installPackages);
235
+ console.log("[bootstrap] installing (pi unresolved, running background install)");
236
+ server.bootstrapState.set({
237
+ status: "installing",
238
+ progress: { step: "pi", output: "starting install…" },
239
+ error: undefined,
240
+ });
241
+
242
+ try {
243
+ const res = await bootstrapInstall({
244
+ packages: installPackages,
245
+ progress: (p) => {
246
+ server.bootstrapState.set({
247
+ progress: { step: p.step, output: p.output },
248
+ });
249
+ },
250
+ });
251
+
252
+ if (!res.ok) {
253
+ console.error(`[bootstrap] failed: ${res.error}`);
254
+ server.bootstrapState.set({
255
+ status: "failed",
256
+ error: { message: res.error },
257
+ progress: undefined,
258
+ });
259
+ return;
260
+ }
261
+
262
+ // Rescan registry so pi is re-resolved after the fresh install.
263
+ // If `rescan` is not exposed, the resolver's strategy chain re-runs
264
+ // on the next `resolve()` call anyway; we just want fresh timing.
265
+ type Rescannable = { rescan?: (name: string) => void };
266
+ const maybeRescan = (registry as unknown as Rescannable).rescan;
267
+ if (typeof maybeRescan === "function") maybeRescan.call(registry, "pi");
268
+
269
+ // Attempt bridge registration. Failures are non-fatal per spec §10.3.
270
+ let bridgeErr: string | undefined;
271
+ try {
272
+ const extPath = findBundledExtension(process.cwd());
273
+ if (extPath) {
274
+ registerBridgeExtension(extPath);
275
+ } else {
276
+ bridgeErr = "bundled extension not found after install";
277
+ }
278
+ } catch (err) {
279
+ bridgeErr = err instanceof Error ? err.message : String(err);
280
+ }
281
+
282
+ server.bootstrapState.set({
283
+ status: "ready",
284
+ progress: undefined,
285
+ error: undefined,
286
+ bridgeRegistrationError: bridgeErr,
287
+ });
288
+ // Populate compatibility info after a successful install.
289
+ try {
290
+ const serverPkg = path.resolve(
291
+ path.dirname(fileURLToPath(import.meta.url)),
292
+ "..",
293
+ "package.json",
294
+ );
295
+ updateBootstrapCompatibility(server.bootstrapState, serverPkg);
296
+ logCompatibilityWarning(server.bootstrapState);
297
+ } catch (err) {
298
+ console.warn("[bootstrap] version-skew check failed (non-fatal):", err);
299
+ }
300
+ console.log(
301
+ `[bootstrap] ready (installed ${res.installed.join(", ")}${bridgeErr ? `; bridge warning: ${bridgeErr}` : ""})`,
302
+ );
303
+ } catch (err) {
304
+ const message = err instanceof Error ? err.message : String(err);
305
+ console.error(`[bootstrap] failed: ${message}`);
306
+ server.bootstrapState.set({
307
+ status: "failed",
308
+ error: { message },
309
+ progress: undefined,
310
+ });
311
+ }
117
312
  }
118
313
 
119
314
  /**
120
315
  * Start the server as a detached background daemon.
121
316
  */
122
317
  async function cmdStart(config: ServerConfig): Promise<void> {
318
+ assertNodeVersionSupported();
123
319
  const running = await isServerRunning(config.port);
124
320
  if (running) {
125
321
  console.log(`Dashboard server is already running (pid ${running})`);
@@ -146,10 +342,14 @@ async function cmdStart(config: ServerConfig): Promise<void> {
146
342
  try {
147
343
  tsLoader = resolveJitiImport();
148
344
  } catch {
149
- // Fallback to tsx when jiti is not available (e.g. running outside pi)
345
+ // Fallback to tsx when jiti is not available (e.g. running outside pi).
346
+ // The loader is passed to `node --import`; on Windows, Node >= 20 rejects
347
+ // raw absolute paths with a drive letter (parsed as URL scheme), so we
348
+ // return a file:// URL. See change: fix-windows-server-parity.
150
349
  try {
151
350
  const tsxMain = createRequire(cliPath).resolve("tsx");
152
- tsLoader = path.join(path.dirname(tsxMain), "esm", "index.mjs");
351
+ const tsxLoaderPath = path.join(path.dirname(tsxMain), "esm", "index.mjs");
352
+ tsLoader = pathToFileURL(tsxLoaderPath).href;
153
353
  } catch {
154
354
  console.error(
155
355
  "[pi-dashboard] Cannot find TypeScript loader. " +
@@ -159,22 +359,45 @@ async function cmdStart(config: ServerConfig): Promise<void> {
159
359
  }
160
360
  }
161
361
 
162
- // Redirect daemon stdout/stderr to a log file for crash diagnosis
163
- const logDir = path.join(process.env.HOME ?? "~", ".pi", "dashboard");
362
+ // Redirect daemon stdout/stderr to a log file for crash diagnosis.
363
+ // Log is opened in append mode ("a") so output from prior start attempts
364
+ // is preserved across retries — critical for diagnosing intermittent or
365
+ // silent launch failures. A timestamped header line distinguishes runs.
366
+ // See change: fix-windows-server-parity.
367
+ const logDir = path.join(os.homedir(), ".pi", "dashboard");
164
368
  fs.mkdirSync(logDir, { recursive: true });
165
- const logFd = fs.openSync(path.join(logDir, "server.log"), "w");
166
-
167
- const child = spawn(process.execPath, ["--import", tsLoader, cliPath, ...args], {
168
- detached: true,
169
- stdio: ["ignore", logFd, logFd],
170
- env: { ...process.env },
369
+ const logPath = path.join(logDir, "server.log");
370
+ const logFd = fs.openSync(logPath, "a");
371
+ fs.writeSync(
372
+ logFd,
373
+ `\n[${new Date().toISOString()}] pi-dashboard start (parent pid ${process.pid}, port ${config.port})\n`,
374
+ );
375
+
376
+ // Both tsLoader and cliPath are wrapped as file:// URLs by spawnNodeScript.
377
+ // Required on Windows for node --import (see change: fix-windows-entry-script-url).
378
+ const child = spawnNodeScript({
379
+ loader: tsLoader,
380
+ entry: cliPath,
381
+ args,
382
+ spawnOptions: {
383
+ detached: true,
384
+ stdio: ["ignore", logFd, logFd],
385
+ env: { ...process.env },
386
+ },
171
387
  });
172
388
  child.unref();
173
-
174
- // Wait for dashboard to become available (up to 5 seconds)
175
- const deadline = Date.now() + 5000;
389
+ // Close the parent's copy of the fd — child has its own via stdio inheritance.
390
+ try { fs.closeSync(logFd); } catch { /* ignore */ }
391
+
392
+ // Wait for dashboard to become available. Windows + jiti cold-start can
393
+ // take 10s+ (TS compile on first boot, native module loads). 30s is the
394
+ // outer bound — if the server isn't up by then, something's genuinely wrong.
395
+ const READINESS_TIMEOUT_MS = 30_000;
396
+ const deadline = Date.now() + READINESS_TIMEOUT_MS;
176
397
  let started = false;
177
398
  while (Date.now() < deadline) {
399
+ // Also bail if the child has already exited (fast-path crash detection).
400
+ if (child.exitCode !== null) break;
178
401
  await new Promise((r) => setTimeout(r, 300));
179
402
  const status = await isDashboardRunning(config.port);
180
403
  if (status.running) {
@@ -187,7 +410,10 @@ async function cmdStart(config: ServerConfig): Promise<void> {
187
410
  const pid = readPid();
188
411
  console.log(`Dashboard server started (pid ${pid ?? child.pid}) at http://localhost:${config.port}`);
189
412
  } else {
190
- console.error("Failed to start dashboard server (timed out after 5s)");
413
+ const reason = child.exitCode !== null
414
+ ? `child process exited with code ${child.exitCode}`
415
+ : `timed out after ${READINESS_TIMEOUT_MS / 1000}s`;
416
+ console.error(`Failed to start dashboard server (${reason})`);
191
417
  console.error(`Check logs at ${path.join(logDir, "server.log")}`);
192
418
  process.exit(1);
193
419
  }
@@ -196,31 +422,21 @@ async function cmdStart(config: ServerConfig): Promise<void> {
196
422
  /**
197
423
  * Stop the running server daemon.
198
424
  */
199
- /** Kill a process by PID, wait for exit, force-kill if needed. */
425
+ /**
426
+ * Kill a process by PID with logging. Delegates to the shared platform
427
+ * primitive (`packages/shared/src/platform/process.ts`) which handles the
428
+ * Windows (taskkill) vs Unix (SIGTERM→SIGKILL) split.
429
+ * See change: consolidate-platform-handlers.
430
+ */
200
431
  async function killProcess(pid: number, label: string): Promise<boolean> {
201
- if (!isProcessAlive(pid)) return false;
202
- try { process.kill(pid, "SIGTERM"); } catch { return false; }
203
- const deadline = Date.now() + 5000;
204
- while (Date.now() < deadline) {
205
- await new Promise((r) => setTimeout(r, 200));
206
- if (!isProcessAlive(pid)) {
207
- console.log(`${label} stopped (pid ${pid})`);
208
- return true;
209
- }
210
- }
211
- try { process.kill(pid, "SIGKILL"); } catch { /* already dead */ }
212
- console.log(`${label} stopped (forced, pid ${pid})`);
432
+ const result = await platformKillProcess(pid);
433
+ if (!result.ok) return false;
434
+ console.log(`${label} stopped${result.forced ? " (forced)" : ""} (pid ${pid})`);
213
435
  return true;
214
436
  }
215
437
 
216
- /** Find PIDs holding a port via lsof (macOS/Linux). */
217
- function findPortHolders(port: number): number[] {
218
- try {
219
- const { execSync } = require("node:child_process");
220
- const output = execSync(`lsof -t -i :${port} -sTCP:LISTEN 2>/dev/null`, { encoding: "utf-8" });
221
- return output.trim().split("\n").map(Number).filter((n: number) => n > 0 && n !== process.pid);
222
- } catch { return []; }
223
- }
438
+ // Local alias to preserve prior internal references.
439
+ const isProcessAlive = (pid: number) => platformIsProcessAlive(pid);
224
440
 
225
441
  async function cmdStop(): Promise<void> {
226
442
  const config = loadConfig();
@@ -255,6 +471,58 @@ async function cmdStop(): Promise<void> {
255
471
  /**
256
472
  * Show server status.
257
473
  */
474
+ /**
475
+ * `pi-dashboard upgrade-pi` — upgrade pi-coding-agent via bootstrap.
476
+ *
477
+ * If a dashboard is currently running, POST to /api/bootstrap/upgrade-pi
478
+ * (so the running server owns the install, broadcasts state, and reloads
479
+ * connected sessions). Otherwise run `bootstrapInstall` directly with a
480
+ * streaming progress formatter and exit when done.
481
+ *
482
+ * See change: unified-bootstrap-install §8.
483
+ */
484
+ async function cmdUpgradePi(config: ServerConfig): Promise<void> {
485
+ const status = await isDashboardRunning(config.port);
486
+ if (status.running) {
487
+ console.log(
488
+ `[upgrade-pi] dashboard running at http://localhost:${config.port}, delegating to server`,
489
+ );
490
+ try {
491
+ const res = await fetch(`http://localhost:${config.port}/api/bootstrap/upgrade-pi`, {
492
+ method: "POST",
493
+ });
494
+ if (!res.ok) {
495
+ const body = await res.text();
496
+ console.error(`[upgrade-pi] server rejected upgrade: HTTP ${res.status} ${body}`);
497
+ process.exit(1);
498
+ }
499
+ const body = (await res.json()) as { ticketId?: string };
500
+ console.log(`[upgrade-pi] queued (ticketId=${body.ticketId ?? "?"})`);
501
+ console.log("[upgrade-pi] progress is streamed to open dashboard tabs; CLI exits now.");
502
+ return;
503
+ } catch (err) {
504
+ console.error("[upgrade-pi] failed to reach server:", err);
505
+ process.exit(1);
506
+ }
507
+ }
508
+
509
+ console.log("[upgrade-pi] no dashboard running — installing directly");
510
+ const res = await bootstrapInstall({
511
+ packages: ["@mariozechner/pi-coding-agent"],
512
+ progress: (p) => {
513
+ const line = p.output
514
+ ? `[upgrade-pi] ${p.step} ${p.status}: ${p.output}`
515
+ : `[upgrade-pi] ${p.step} ${p.status}`;
516
+ console.log(line);
517
+ },
518
+ });
519
+ if (!res.ok) {
520
+ console.error(`[upgrade-pi] failed: ${res.error}`);
521
+ process.exit(1);
522
+ }
523
+ console.log(`[upgrade-pi] ✓ installed ${res.installed.join(", ")}`);
524
+ }
525
+
258
526
  async function cmdStatus(port: number): Promise<void> {
259
527
  // 1. Try mDNS discovery first
260
528
  try {
@@ -328,6 +596,9 @@ async function main() {
328
596
  case "status":
329
597
  await cmdStatus(config.port);
330
598
  break;
599
+ case "upgrade-pi":
600
+ await cmdUpgradePi(config);
601
+ break;
331
602
  default:
332
603
  // No subcommand — run in foreground (backward compatible)
333
604
  await runForeground(config);
@@ -100,6 +100,17 @@ export function writeConfigPartial(partial: Record<string, any>): WriteConfigRes
100
100
  mergedAuth.allowedUsers = partial.auth.allowedUsers;
101
101
  }
102
102
 
103
+ // fix-trusted-networks-no-oauth: propagate bypassHosts / bypassUrls
104
+ // from the incoming partial. Without these, the UI's Trusted Networks
105
+ // save path silently dropped every entry on disk. `!== undefined`
106
+ // (not truthiness) lets an empty array clear all entries.
107
+ if (partial.auth.bypassHosts !== undefined) {
108
+ mergedAuth.bypassHosts = partial.auth.bypassHosts;
109
+ }
110
+ if (partial.auth.bypassUrls !== undefined) {
111
+ mergedAuth.bypassUrls = partial.auth.bypassUrls;
112
+ }
113
+
103
114
  partial.auth = mergedAuth;
104
115
  }
105
116
 
@@ -114,6 +125,11 @@ export function writeConfigPartial(partial: Record<string, any>): WriteConfigRes
114
125
  restartRequired = true;
115
126
  }
116
127
 
128
+ // Merge openspec sub-object (no restart required — live-reconfigured)
129
+ if (partial.openspec) {
130
+ partial.openspec = { ...existing.openspec, ...partial.openspec };
131
+ }
132
+
117
133
  const merged = { ...existing, ...partial };
118
134
 
119
135
  // Remove computed fields that shouldn't be persisted