@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
@@ -158,13 +158,74 @@ pi-flows runs multi-agent workflows in-process. Subagent sessions use `SessionMa
158
158
  - Abort: browser sends `flow_control { action: "abort" }` → server → bridge → `pi.events.emit("flow:abort")` → `flowManager.abort()`
159
159
  - Autonomous toggle: browser sends `flow_control { action: "toggle_autonomous" }` → same path → `setAutonomousMode()`
160
160
 
161
+ ### Bootstrap & First Run
162
+
163
+ The dashboard has three install paths that all converge on the shared
164
+ `bootstrapInstall` in `packages/shared/src/bootstrap-install.ts`:
165
+
166
+ 1. **Electron wizard** (first-run in the desktop app) —
167
+ `packages/electron/src/lib/dependency-installer.ts installStandalone`
168
+ wraps the shared installer with Electron-specific concerns
169
+ (bundled Node + `npm-cli.js`, offline npm cacache bundle extracted
170
+ from `resourcesPath/offline-packages/`, bundled-extension activation
171
+ into pi's git cache). The registry-install loop itself is the shared
172
+ function.
173
+
174
+ 2. **`pi-dashboard` CLI first-run** (degraded-mode) — when
175
+ `pi-dashboard` (or `pi-dashboard start`) launches and
176
+ `ToolRegistry.resolve("pi")` fails, `cli.ts runDegradedModeBootstrap`
177
+ flips `bootstrapState.status` to `"installing"`, kicks off
178
+ `bootstrapInstall({ packages: ["@mariozechner/pi-coding-agent", "@fission-ai/openspec", "tsx"] })`
179
+ asynchronously, and returns immediately so the server's
180
+ `fastify.listen` remains responsive. The UI renders `BootstrapBanner`
181
+ above the main layout. `session-api.ts gateOrEnqueue` queues
182
+ `POST /api/session/spawn` requests while installing; the
183
+ `server.ts` subscribe hook flushes the queue on transition to
184
+ `"ready"`. On success, `registerBridgeExtension(findBundledExtension())`
185
+ auto-wires the bridge so no manual step is required.
186
+
187
+ 3. **`pi-dashboard upgrade-pi` CLI subcommand** — runs
188
+ `bootstrapInstall({ packages: ["@mariozechner/pi-coding-agent"] })`
189
+ either directly (when no dashboard is listening) or via
190
+ `POST /api/bootstrap/upgrade-pi` (when one is). The REST path flips
191
+ state through the existing broadcast hook so open dashboard tabs
192
+ see the progress; on completion, `/reload` is broadcast to all
193
+ connected bridges, matching the pi-core-update session-reload
194
+ pattern.
195
+
196
+ Compatibility skew is checked on every ready transition via
197
+ `updateBootstrapCompatibility` which reads `piCompatibility` from
198
+ `packages/server/package.json` and populates `bootstrapState.compatibility`
199
+ with `upgradeRecommended` / `upgradeDashboard` flags consumed by
200
+ `BootstrapBanner`. Versions below `minimum` set a blocking `error`
201
+ message that `session-api gateOrEnqueue` translates to 503 responses.
202
+
203
+ See change: `unified-bootstrap-install`.
204
+
161
205
  ### Force Kill Escalation
162
206
  The Stop button supports two-click escalation for stuck sessions:
163
207
  1. **Click 1 (Abort)**: Sends `abort` → bridge → `ctx.abort()`. Button transitions to orange pulsing "Force Stop".
164
- 2. **Click 2 (Force Kill)**: Sends `force_kill` → server kills the process via SIGTERM 2s wait → SIGKILL (with PID safety check). Session marked "ended" (not removed), resumable via fork/continue.
208
+ 2. **Click 2 (Force Kill)**: Sends `force_kill` → server delegates termination to the **platform layer** (`packages/shared/src/platform/process.ts::killProcess(pid, { timeoutMs: 2000 })`), which:
209
+ - on **Windows** runs `taskkill /F /T /PID <pid>` (genuine tree kill — descendant `node.exe`, pi children, tmux panes, `wt` tabs, code-server subtrees all die together),
210
+ - on **POSIX** sends `SIGTERM`, polls liveness every 200ms for up to 2s, then escalates to `SIGKILL` if the process is still alive.
211
+
212
+ Session marked "ended" (not removed), resumable via fork/continue.
165
213
 
166
214
  The bridge includes `process.pid` in `session_register` so the server can kill the process. The server also force-closes the bridge WebSocket and uses the headless PID registry as a fallback. If no PID is available, only the WebSocket is closed.
167
215
 
216
+ ### Platform-routed kill paths
217
+ All process termination across the codebase goes through `packages/shared/src/platform/process.ts`. No code outside that module may call `process.kill(...)` directly — enforcement is handled by `packages/shared/src/__tests__/no-direct-process-kill.test.ts`, a repo-level lint that scans every `.ts` file under `packages/*/src/` and fails CI if a direct call slips in. The three canonical helpers are:
218
+
219
+ | Helper | POSIX | Windows |
220
+ |--------|-------|---------|
221
+ | `isProcessAlive(pid)` | `kill(pid, 0)` | same |
222
+ | `killProcess(pid, {timeoutMs})` | SIGTERM → wait → SIGKILL (tree via pgroup) | `taskkill /F /T /PID <pid>` |
223
+ | `killPidWithGroup(pid, sig)` | `kill(-pid, sig)` (process group) | `kill(pid, sig)` (leaf) |
224
+
225
+ Sites routed through these helpers: `session-action-handler.ts::handleForceKill`, `process-scanner.ts::killProcessByPgid`, `tunnel.ts::cleanupStaleZrok` + `deleteTunnel`, `editor-manager.ts::stop`, `headless-pid-registry.ts`, `server-pid.ts`. See specs: [`command-executor`](../openspec/specs/command-executor/spec.md), [`force-kill-handler`](../openspec/specs/force-kill-handler/spec.md).
226
+
227
+ `taskkill` is invoked via the platform's `execSync` wrapper (`platform/exec.ts`) so it inherits `windowsHide: true` — no console flash — and stays consistent with the `no-direct-child_process-import` invariant.
228
+
168
229
  Inline stop buttons also appear on running tool cards in `ToolCallStep`, providing contextual abort access right where the stuck command is visible.
169
230
 
170
231
  ### Repeated Tool Call Collapsing
@@ -183,6 +244,30 @@ Consecutive tool calls with the same name and identical args (e.g. health check
183
244
  - `index.ts`: `flow:abort` and `flow:toggle-autonomous` event listeners added
184
245
  - `flow-tui.ts`: `autonomousMode` included in `flow:flow-started` event data
185
246
 
247
+ ### `/reload` Flow (two code paths)
248
+ Reload from the dashboard (via `npm run reload`, the reload button, or `/reload` typed into the chat composer) follows one of two paths depending on how the pi session was spawned. The server transparently selects the right path:
249
+
250
+ ```mermaid
251
+ flowchart TD
252
+ A[Browser sends send_prompt text="/reload"] --> B[server handleSendPrompt]
253
+ B --> C{shouldInterceptReload?<br/>text === "/reload"<br/>no images<br/>headlessPidRegistry.getPid defined}
254
+ C -->|Yes — headless session| D[handleHeadlessReload]
255
+ D --> D1[Emit command_feedback 'started']
256
+ D1 --> D2[headlessPidRegistry.killBySessionId<br/>SIGTERMs old pi]
257
+ D2 --> D3[spawnPiSession with<br/>sessionFile+mode:'continue'<br/>strategy:'headless']
258
+ D3 --> D4[headlessPidRegistry.register new PID]
259
+ D4 --> D5[Emit command_feedback 'completed']
260
+ D5 --> D6[New pi bridge re-registers<br/>with same sessionId —<br/>sessionManager preserves<br/>tokens/cost/context/attachedProposal]
261
+ C -->|No — tmux/wt/wsl-tmux| E[piGateway.sendToSession→bridge]
262
+ E --> F[bridge command-handler parses /reload]
263
+ F --> G[Calls globalThis-RELOAD_KEY fn]
264
+ G --> H{Was /__dashboard_reload<br/>typed in TUI first?}
265
+ H -->|Yes| I[session.reload in-place]
266
+ H -->|No| J[Error logged to bridge stderr<br/>User must bootstrap via TUI]
267
+ ```
268
+
269
+ **Why two paths?** pi-coding-agent's `ExtensionContext` (delivered to `session_start` handlers) has no `reload()` method — only `ExtensionCommandContext` (given to command handlers) does. The bridge works around this by registering `__dashboard_reload` as a command and capturing `ctx.reload` into `globalThis[RELOAD_KEY]` when a user first invokes it in pi's TUI. Headless sessions have no TUI, so the capture never happens. The server-side interception is a transparent kill-and-respawn that achieves the same user-visible outcome (fresh settings, fresh extensions, fresh skills/prompts/themes) without needing an in-process reload. Since `memorySessionManager.register` carries accumulated state when the same `sessionId` re-registers, the user sees a brief reconnect flicker but keeps their tokens, cost, context usage, and attached proposal. See change: headless-reload-via-respawn.
270
+
186
271
  ### Auto-Resume on Prompt
187
272
  When a user sends a prompt to an ended session, the server automatically resumes it:
188
273
  1. Server detects `send_prompt` for a session with `status === "ended"` and a valid `sessionFile`
@@ -229,13 +314,32 @@ When a user sends a prompt to an ended session, the server automatically resumes
229
314
  7. Session cards display processes with elapsed time and a kill button (sends SIGTERM to process group)
230
315
 
231
316
  ### OpenSpec Polling (Server-Side)
232
- 1. Server's DirectoryService polls `openspec` CLI every 30s for each known directory (union of pinned dirs + session cwds)
233
- 2. OpenSpec data is keyed by directory (cwd), not by session — one poll per directory regardless of session count
234
- 3. Changes are broadcast to all connected browsers via `openspec_update { cwd, data }`
235
- 4. Browsers can request immediate refresh via `openspec_refresh { cwd }`
236
- 5. New directories (pinned or from new sessions) trigger immediate discovery + polling
317
+ 1. Server's DirectoryService polls `openspec` CLI for each known directory (union of pinned dirs + session cwds) at a **configurable interval** (`DashboardConfig.openspec.pollIntervalSeconds`, default 30 s, range 5–3600 s).
318
+ 2. OpenSpec data is keyed by directory (cwd), not by session — one poll per directory regardless of session count.
319
+ 3. Changes are broadcast to all connected browsers via `openspec_update { cwd, data }`.
320
+ 4. Browsers can request immediate refresh via `openspec_refresh { cwd }`. Force-refresh **bypasses the mtime gate** but still respects the concurrency cap.
321
+ 5. New directories (pinned or from new sessions) trigger immediate discovery + polling (eager; bypasses jitter + mtime gate).
237
322
  6. Each `OpenSpecChange` carries an optional `isComplete?: boolean` field forwarded straight through from `openspec status --change <name> --json`. It indicates artifact-authoring completeness only — orthogonal to the task tally — and never feeds `deriveChangeState`. The dashboard uses it solely to gate the **Archive anyway** escape hatch (see “OpenSpec session card”).
238
323
 
324
+ #### OpenSpec polling cost model
325
+
326
+ A naive `for each cwd: list + for each change: status` fan-out explodes quickly: 4 pinned dirs with 63 total active changes → **67 `openspec` CLI spawns per 30 s tick**, each costing ~0.5 s user CPU just for Node + module load. On an 8-core host that produces a rectangular ~10 s plateau at 100 % CPU every cycle.
327
+
328
+ The scheduler in `packages/server/src/directory-service.ts` applies four layers of throttling (all configurable under `DashboardConfig.openspec`):
329
+
330
+ 1. **mtime gate** (`changeDetection: "mtime" | "always"`, default `mtime`) — skips `openspec list` when `fs.stat(openspec/changes).mtimeMs` is unchanged since the last successful poll, and skips `openspec status --change X` when the per-change directory mtime is unchanged. A `stat` is ~10 µs vs. ~500 ms per CLI spawn; in steady state this drops 67 spawns/tick to 0–2.
331
+ 2. **Concurrency cap** (`maxConcurrentSpawns`, default 3, range 1–16) — an in-repo semaphore (`packages/shared/src/semaphore.ts`) serializes CLI spawns across all directories. Burst-work spreads uniformly over the interval instead of pinning every core.
332
+ 3. **Per-cwd jitter** (`jitterSeconds`, default 5) — each known directory is assigned a deterministic phase offset `fnv1a32(cwd) % (jitterSeconds * 1000)` within the interval so polls don't all align on the same scheduling boundary.
333
+ 4. **Split pi-resources timer** — `scanPiResources(cwd)` no longer rides the openspec tick; it has its own interval at 5× the openspec cadence (pi extensions/skills change far less often than OpenSpec artifacts).
334
+
335
+ Cache shape (per cwd): `{ listMtimeMs, listResult, changes: Map<name, { mtimeMs, change }>, data }`. Cache is updated atomically per directory — a partial failure leaves the previous snapshot intact and the next tick retries.
336
+
337
+ Force-refresh paths (`refreshOpenSpec(cwd)`, `openspec_refresh` WS, `onDirectoryAdded(cwd)`) bypass the mtime gate but **still go through the semaphore**, so a refresh-button storm cannot overload the host.
338
+
339
+ Live reconfiguration: `PUT /api/config` with an `openspec` block calls `directoryService.reconfigurePolling(cfg)` — the timer cadence and semaphore max are updated without a server restart; in-flight polls finish on their old config.
340
+
341
+ Observability: `DEBUG=pi-dashboard:openspec-poll` (or any `DEBUG=...pi-dashboard...`) emits one line per tick with dir count, queue size, and wall time. Any tick over 5 s logs a WARN hinting at `pollIntervalSeconds` / `maxConcurrentSpawns` as knobs.
342
+
239
343
  ### OpenSpec session card UI
240
344
 
241
345
  The attached-change row on every session card has four affordances driven by the polled `OpenSpecChange`:
@@ -364,7 +468,7 @@ The server has a two-layer access model:
364
468
 
365
469
  **Layer 1: Network Guard (`createNetworkGuard`)** — Fastify `preHandler` on all sensitive routes. Allows requests via three paths:
366
470
  1. **Loopback** — `127.0.0.1`, `::1`, `::ffff:127.0.0.1` (always allowed)
367
- 2. **Trusted networks** — IPs matching `resolvedTrustedNetworks` (CIDR, wildcard, exact). Configured via top-level `trustedNetworks` in config, merged with `auth.bypassHosts` at load time.
471
+ 2. **Trusted networks** — IPs matching `resolvedTrustedNetworks` (CIDR, wildcard, exact). `resolvedTrustedNetworks` is computed at load time by merging two config sources: the Settings UI writes new entries to `auth.bypassHosts` (canonical path on the Security tab, surfaced as the "Trusted Networks" section), while the legacy top-level `trustedNetworks` field remains readable for backward compatibility with hand-edited `config.json` files. Both honor the same matching logic; the UI does not modify the legacy field. **Both fields work independently of whether `auth.providers` is configured** — a config with `auth: { providers: {}, bypassHosts: [...] }` is honored as-is; the auth plugin no-ops when the provider registry is empty and the network guard serves the bypass path directly. See `openspec/changes/archive/` for `fix-trusted-networks-no-oauth` which restored this behavior after it regressed in `consolidate-trusted-networks`.
368
472
  3. **Authenticated** — `request.isAuthenticated === true` (set by auth `onRequest` hook via `decorateRequest`)
369
473
 
370
474
  Otherwise → 403. The guard strips `::ffff:` IPv4-mapped prefixes before matching.
@@ -396,7 +500,7 @@ Optional OAuth2 authentication protects the dashboard when accessed remotely.
396
500
  ### Settings Panel
397
501
  The web client includes a Settings panel (gear icon in sidebar header → `/settings` route) that lets users view and edit all dashboard configuration. The panel:
398
502
  1. Loads config via `GET /api/config` (secrets redacted as `***`)
399
- 2. Renders grouped form fields: Server, Sessions, Tunnel, Trusted Networks, Authentication, Developer
503
+ 2. Renders grouped form fields per tab — General: Server, Sessions, Tunnel, Developer; Security: Authentication (OAuth providers, Allowed Users, Bypass URL Prefixes) and Trusted Networks (writes `auth.bypassHosts`, with "+ Add Local Network" auto-detect + manual IP/wildcard/CIDR entry)
400
504
  3. Sends only changed fields via `PUT /api/config` (partial merge)
401
505
  4. Server preserves `***` secrets (doesn't overwrite real values), writes to disk, and applies runtime-safe changes
402
506
  5. Port/piPort changes flag `restartRequired` in the response
@@ -552,6 +656,88 @@ The `POST /api/restart` endpoint and `pi-dashboard restart` command perform faul
552
656
 
553
657
  The restart endpoint accepts `{ dev: boolean }` to switch between dev/production mode.
554
658
 
659
+ ### Cross-Platform Server Launch
660
+
661
+ The dashboard server is spawned via `node --import <loader> <cli.ts>` from three call sites (`packages/server/src/cli.ts` `cmdStart`, `packages/extension/src/server-launcher.ts` `launchServer`, `packages/electron/src/lib/server-lifecycle.ts` `launchServer`). On Node ≥ 20, Windows rejects raw absolute paths passed to `--import` because it parses the drive-letter prefix (e.g. `B:`) as a URL scheme (`ERR_UNSUPPORTED_ESM_URL_SCHEME`). Every resolver therefore returns a `file://` URL, not a raw path:
662
+
663
+ - `packages/shared/src/resolve-jiti.ts` — `resolveJitiImport()` (anchor = `process.argv[1]`) and `resolveJitiFromAnchor(anchorPath)` (anchor supplied explicitly) both return `pathToFileURL(registerPath).href`
664
+ - `packages/electron/src/lib/server-lifecycle.ts` — `resolveJitiFromPi()` now imports `resolveJitiFromAnchor` from shared (previously duplicated; consolidated in the `consolidate-platform-handlers` change)
665
+ - `packages/server/src/cli.ts` — the tsx fallback wraps `esm/index.mjs` the same way
666
+
667
+ The URL form is cross-platform safe (Linux/macOS accept both raw paths and `file://` URLs) so no platform gating is needed in the resolvers.
668
+
669
+ #### stdout + stderr capture parity
670
+
671
+ Both server-launch call sites (`packages/server/src/cli.ts` and `packages/extension/src/server-launcher.ts`) capture **both** stdout and stderr into `~/.pi/dashboard/server.log`. The CLI uses `stdio: ["ignore", logFd, logFd]` on its direct `spawn()` call; the bridge uses `spawnDetached({ stdoutFd: logFd, logFd })`. Without this parity, crash diagnostics from jiti / Fastify / ajv-compiler that reach stdout would be invisible via the bridge path while remaining visible via the CLI path. See change: `fix-bridge-autostart-diagnostics`.
672
+
673
+ #### CJS preload for Fastify (nodejs/node#58515 mitigation)
674
+
675
+ Every server-spawn call site injects `--require <preload-fastify.cjs>` BEFORE `--import <jiti-loader>` in the child's argv, as long as the resolver `resolvePreloadFastifyPath()` in `packages/shared/src/platform/preload-fastify.ts` finds the preload file. The order matters: Node processes `--require` before `--import`, so the preload runs through Node's **legacy synchronous CJS loader** (which predates and bypasses the ESM→CJS translator). The preload synchronously `require()`s `@fastify/ajv-compiler/standalone`, `@fastify/ajv-compiler`, and `fastify` — populating `require.cache` with those modules in `kEvaluated` state.
676
+
677
+ When jiti's ESM hook later resolves an `import "fastify"`, Node's translator finds the modules already cached and short-circuits — it never enters the recursive require chain that triggers the `Unexpected module status 3` assertion on Node <22.18 / 24.1–24.2.
678
+
679
+ This is a **race-independent fix**: it doesn't try to close the timing window, it removes the racy code path from the execution trace. All four spawn sites (CLI daemon, bridge auto-start, Electron, restart orchestrator) share the resolver and the same injection pattern. See change: `preload-fastify-cjs`.
680
+
681
+ #### Node-version preflight
682
+
683
+ `packages/shared/src/platform/node-version-check.ts` exports `isKnownBadNode(version)` — a pure predicate flagging Node builds affected by [nodejs/node#58515](https://github.com/nodejs/node/issues/58515) (ESM loader assertion when Fastify's `@fastify/ajv-compiler` requires CJS modules). Affected ranges: `>=22.0.0 <22.18.0` and `>=24.1.0 <24.3.0`. Three consumers share the predicate:
684
+
685
+ - **CLI** (`cmdStart`) — emits a warning to stderr and appends it to `server.log` before spawning. Advisory only; CLI still proceeds.
686
+ - **Bridge auto-start** (`server-launcher.ts`) — `buildReadyTimeoutMessage()` includes an issue-#58515 upgrade hint in the failure notification when `waitForReady` times out on an affected Node.
687
+ - **Electron doctor** (`doctor.ts`) — "Node runtime compatibility" row shows `warning` with upgrade guidance.
688
+
689
+ `packages/server/package.json` declares `"engines": { "node": ">=22.18.0 <23 || >=24.3.0" }` as an npm-level advisory.
690
+
691
+ ### Cross-OS Platform Primitives
692
+
693
+ Cross-OS behavior (`process.platform === "win32"` branches) is centralized in `packages/shared/src/platform/` (pure Node, consumed by server + extension + Electron). The module has an `index.ts` barrel plus per-concern files:
694
+
695
+ | File | Concerns |
696
+ |---|---|
697
+ | `binary-lookup.ts` | `where`/`which` dispatch, `.cmd` extension on Windows, managed-bin search, login-shell fallback. Exports `ToolResolver` class + pi/tsx/node resolve helpers. |
698
+ | `process.ts` | `findPortHolders` (netstat vs lsof), `killProcess` (taskkill tree on Windows, SIGTERM→SIGKILL on Unix), `isProcessAlive`, `killPidWithGroup` (negative-pid on Unix, positive on Windows). |
699
+ | `process-scan.ts` | `isProcessRunning` (tasklist vs pgrep), pure `parseEtime`. |
700
+ | `shell.ts` | `detectShell` (COMSPEC on Windows, SHELL on Unix, with fallbacks), `getTerminalEnvHints` (TERM=cygwin hint for node-pty on Windows). |
701
+ | `commands.ts` | `openBrowser` (`open`/`start`/`xdg-open`), `isVirtualMachine` (`sysctl`/`systemd-detect-virt`/`wmic`). |
702
+ | `detached-spawn.ts` | `spawnDetached` (libuv-correct detached defaults on every OS — on Windows, `detached: true` excludes the child from the parent's kill-on-close job for PGID-equivalent lifecycle), `waitForNoCrash` (short window: did the child survive?), `waitForReady` (positive probe: is it serving HTTP yet?). |
703
+ | `spawn-mechanism.ts` | `SpawnMechanism` enum (`tmux`/`wt`/`wsl-tmux`/`headless`) and pure `selectMechanism` selector. `buildWtArgs` builds argv for Windows Terminal `new-tab`. `sessionFlagsToArgv` is the uniform `--session`/`--fork` builder every mechanism MUST use so no branch drops options. |
704
+ | `process-identify.ts` | `findPidByMarker` + `isProcessLikePi` + `isPiCommandLine`. Unix implementations run `ps`/`/proc`; Windows stubs return empty/true because command-line lookup is delegated to `headlessPidRegistry`. |
705
+
706
+ Every exported helper that depends on OS takes an optional `platform: NodeJS.Platform` parameter (and usually `exec`/`kill`/`env` for full injection). Tests exercise both branches via these parameters rather than mutating `process.platform`. This is the pattern to follow for any new cross-OS primitives.
707
+
708
+ **Invariant guard:** `packages/shared/src/__tests__/no-direct-platform-branch.test.ts` scans all `packages/**/src/` for `process.platform === "<os>"` branches. Every violation must either move into a platform primitive or be listed in the documented allowlist (seeded with extension's process-scanner, Electron's dependency-detector/main/doctor/forge.config, server's process-manager/editor-registry/tunnel/browse, and the inference-comment in client's session-grouping).
709
+
710
+ Electron-bound presentation concerns (tray icons, menu template, dock behavior, bundled Node path) remain in `packages/electron/src/lib/` because they import from the `electron` package and cannot live in shared.
711
+
712
+ ### Session spawn dispatch
713
+
714
+ Session spawning uses a two-tier type system:
715
+
716
+ - **`SpawnStrategy`** (user-visible, in `shared/config.ts`): `"tmux" | "headless"`. What the user wrote in their config.
717
+ - **`SpawnMechanism`** (internal, in `platform/spawn-mechanism.ts`): `"tmux" | "wt" | "wsl-tmux" | "headless"`. What the system actually runs on this platform given availability.
718
+
719
+ `selectMechanism({ platform, userStrategy, electronMode, available })` is the single pure function that maps (config, platform, availability) → mechanism. Rules:
720
+
721
+ 1. `electronMode` → `headless`.
722
+ 2. `userStrategy === "headless"` → `headless`.
723
+ 3. Unix with tmux → `tmux`; Unix without → `headless`.
724
+ 4. Windows: `wt` if available, else `wsl-tmux` if available, else `headless`.
725
+
726
+ Every mechanism branch forwards `sessionFile` + `mode` via the shared `sessionFlagsToArgv` helper; no branch may silently drop them. This was the root cause of the Windows fork/continue bugs fixed in `consolidate-windows-spawn-and-platform-handlers` — the WSL/cmd fallback paths in the old code invoked pi without `--fork`/`--session`, silently downgrading to a fresh session.
727
+
728
+ On Windows, `spawnDetached` uses `detached: true` which (via libuv's `src/win/process.c`) emits `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` and critically does NOT call `AssignProcessToJobObject` on the parent's global Job Object. This excludes the child from the parent's `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` job, so pi sessions survive when the dashboard server exits — matching Unix PGID behavior. The `headlessPidRegistry` reconciles these survivors on server restart.
729
+
730
+ ### Server Log Hygiene
731
+
732
+ The daemon log at `~/.pi/dashboard/server.log` is opened in **append mode** (`"a"`) so crash output from prior start attempts survives subsequent retries — essential for diagnosing silent failures. Each attempt writes a timestamped header to distinguish runs:
733
+
734
+ ```
735
+ [2026-04-18T14:30:00.000Z] pi-dashboard start (parent pid 12345, port 8000)
736
+ [2026-04-18T14:30:02.000Z] bridge auto-start (parent pid 23456, port 8000)
737
+ ```
738
+
739
+ Both `pi-dashboard start` (CLI) and the bridge extension's `launchServer` write to this file. Previously the extension used `stdio: "ignore"` (losing all error output) and the CLI opened the log with `"w"` (truncating prior runs); both were fixed in `fix-windows-server-parity`. On auto-start failure, the bridge now surfaces the log path in its `ui.notify` message so users can open the file directly.
740
+
555
741
  ### Auto-Start Flow
556
742
 
557
743
  When `autoStart` is `true` (default), the bridge extension automatically starts the dashboard server:
@@ -633,7 +819,23 @@ The dashboard supports browser-based authentication with pi's LLM providers, ena
633
819
  3. **Device-code flow** (GitHub Copilot): server requests device code → UI shows user code + verification URL → server polls until authorized
634
820
  4. **API key flow**: user pastes key in Settings → saved directly
635
821
  5. All credentials written to `~/.pi/agent/auth.json` with lockfile + atomic write (`0600` permissions)
636
- 6. Server broadcasts `credentials_updated` to all connected bridges → bridges call `authStorage.reload()` so running pi sessions pick up new tokens immediately
822
+ 6. Server broadcasts `credentials_updated` to all connected bridges → bridges call `reloadProviders(pi)` (to hot-register any newly-added custom providers from `~/.pi/agent/providers.json`) then `authStorage.reload()` and `modelRegistry.refresh()` so running pi sessions pick up new tokens and new providers immediately without a session restart
823
+
824
+ ### Model metadata enrichment for custom providers
825
+
826
+ Custom-provider `/v1/models` endpoints only advertise `{id, owned_by}` — they do not expose `context_window`, `max_tokens`, `cost`, or `reasoning`. Rather than hardcode a flat 200k / 16k / $0 / no-reasoning on every discovered model (which was silently wrong for proxied frontier models like `proxy/cc/claude-opus-4-7` → Opus 4.7's 1M window), the bridge's `registerEntry()` runs each discovered id through a pure `enrichModelMetadata(id, api, probe)` helper. The helper (a) strips common proxy prefixes (`cc/`, `anthropic/`, `openrouter/openai/…`) so the bare id is tried, (b) probes pi's `modelRegistry.find(provider, id)` via an ordered api-appropriate candidate list (`anthropic-messages` → `["anthropic", "opencode"]`, `google-generative-ai` → `["google", "google-vertex"]`, `openai-completions` → `["openai", "openrouter", "groq", "xai", "mistral"]`), and (c) returns the registry's full metadata when a match is found. The registry reference is captured from `ctx.modelRegistry` the first time pi fires `session_start` on the extension (with `model_select` as a fallback capture point) — no direct `@mariozechner/pi-ai` import. Because `activate()` registers providers before any event handler fires, the first pass uses fallback defaults; the `session_start` handler then re-registers all providers with the enriched metadata, relying on `pi.registerProvider`'s idempotent "replace" semantics. When the registry never becomes available or has no match for an id, the fallback path keeps `input: ["text","image"]` so the image-capable-by-default contract is preserved. Built-in and OAuth providers bypass this path entirely — their metadata still comes from pi's bundled `models.generated.js`. See `packages/extension/src/provider-register.ts` and change `enrich-custom-provider-model-metadata`.
827
+
828
+ ### Testing a custom provider (Test button)
829
+
830
+ The Settings → Providers → LLM Providers card exposes a **Test** button that posts the unsaved `{ baseUrl, apiKey, api }` combination to `POST /api/providers/test`. The server performs a per-API-type probe:
831
+
832
+ | API type | Probe |
833
+ |----------|-------|
834
+ | `openai-completions` / `openai-responses` | `GET {baseUrl}/models` with `Authorization: Bearer <apiKey>` |
835
+ | `anthropic-messages` | `GET {baseUrl}/v1/models` with `x-api-key` + `anthropic-version: 2023-06-01` |
836
+ | `google-generative-ai` | `GET {baseUrl}/models?key=<apiKey>` |
837
+
838
+ The endpoint resolves `$ENV_VAR` references and the `***` REDACTED sentinel (for already-saved entries, by `name`) server-side — the response never echoes the resolved api key. An 8 s timeout protects against hanging upstreams. The UI renders a green `✓ Connected · N models` pill on success or a red `✗ <status> — <error>` pill on failure; any edit to the card's fields clears the pill.
637
839
 
638
840
  ### Key Files
639
841
 
@@ -679,6 +881,16 @@ This is separate from the main JSON dashboard WebSocket (`/ws`).
679
881
 
680
882
  1. **Postinstall** — `packages/server/scripts/fix-pty-permissions.cjs` (wired at workspace-root `postinstall`) uses `require.resolve("node-pty/package.json")` to locate the dependency wherever npm placed it and sets mode `0o755` on every `prebuilds/*/spawn-helper` and `prebuilds/*/pty.node`.
681
883
  2. **Electron bundle** — `packages/electron/scripts/bundle-server.sh` runs `find … -name spawn-helper -exec chmod +x` after `npm install` and removes macOS quarantine flags (`xattr -d com.apple.quarantine`) from native binaries.
884
+
885
+ ### Bundled first-party extensions (Electron installer)
886
+
887
+ The Electron installer can optionally ship a curated subset of recommended pi extensions inside `resources/bundled-extensions/<id>/` so first-run works with zero network access. The set is declared by `BUNDLED_EXTENSION_IDS` in `packages/shared/src/recommended-extensions.ts` (currently `pi-anthropic-messages`, `pi-flows`) — a strict subset of `RECOMMENDED_EXTENSIONS`, enforced by a unit test.
888
+
889
+ **Build time** (`packages/electron/scripts/bundle-recommended-extensions.sh`): gated on `BUNDLE_RECOMMENDED_EXTENSIONS=1` (set in `.github/workflows/publish.yml`, unset everywhere else). Clones each id shallow, records the commit SHA to `.bundled-sha`, validates the SPDX identifier against a fixed allowlist (MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC), and fails the build if the combined bundle exceeds 15 MB. `forge.config.ts` conditionally appends `./resources/bundled-extensions` to `extraResource` when the directory exists.
890
+
891
+ **First launch** (`installBundledExtensions()` in `dependency-installer.ts`): enumerates bundled subdirectories, and for each id whose `manager.getInstalledPath(source, "user")` is **not** already populated, copies the bundled tree into pi's git cache location (`~/.pi/agent/git/<host>/<path>/`), runs `npm install --omit=dev` if the package declares runtime dependencies, then calls `manager.addSourceToSettings(gitUrl)` + `settingsManager.flush()` so the original git URL is persisted in `~/.pi/agent/settings.json`. The function runs before `installRecommendedExtensions`, and its return value seeds that call's `skipPackages` set so already-bundled ids are reported with `output: "Already installed (bundled)"`. The wizard renders a distinct "Bundled ✓" badge for those rows and an "Installed" badge for entries that were already present from a prior CLI install (logic factored into the pure helper `wizard-badge.ts`).
892
+
893
+ **Why not simply `installAndPersist("local:")`?** Investigated in `packages/electron/scripts/spike-local-install.mjs`: pi has no `local:` scheme, and `installAndPersist(source)` always persists the exact source string it receives. Installing from a local path therefore persists the local path (breaking `manager.update()`) rather than the git URL. The copy-into-cache + `addSourceToSettings(gitUrl)` approach produces the same on-disk shape as a normal `installGit` run, so pi's later `update()` naturally replaces the bundled copy with upstream via `git fetch && reset --hard`. See design.md of change `bundle-first-party-extensions` for details.
682
894
  3. **Runtime** — `packages/server/src/fix-pty-permissions.ts` runs once when `createTerminalManager()` is called. Uses `createRequire().resolve("node-pty")` to find the actual install location and fixes any non-executable `spawn-helper`.
683
895
 
684
896
  A regression test (`packages/server/src/__tests__/fix-pty-permissions.test.ts`) asserts the current platform's helper is executable after install.
@@ -797,3 +1009,190 @@ These call the same internal methods as the browser-gateway WebSocket handlers
797
1009
  - `references/api-reference.md` — Complete REST API documentation
798
1010
  - `references/recipes.md` — Multi-step orchestration patterns (spawn→prompt→monitor, batch operations, health checks)
799
1011
  - `scripts/dashboard-api.sh` — curl wrapper with port detection, optional auth token, graceful jq fallback
1012
+
1013
+ ## Tool Resolution (`ToolRegistry`)
1014
+
1015
+ Every external binary, module, and directory the dashboard depends on is resolved through a single `ToolRegistry` service in `packages/shared/src/tool-registry/`. Previously, resolution logic was scattered across `ToolResolver` (low-level PATH search), `runner.ts`'s private `resolverCache`, `npm.ts`'s `cachedGlobalRoot`, and two copies of `loadPiPackageManager()` (server + electron). The registry consolidates all of that behind one API, adds user-facing overrides, and records a diagnostic trail so "tool not found" is never a silent failure.
1016
+
1017
+ ### Registered tools
1018
+
1019
+ | Tool | Kind | Strategy chain |
1020
+ |---|---|---|
1021
+ | `pi` | binary | override → managed (`MANAGED_BIN/pi[.cmd]`) → where |
1022
+ | `pi-coding-agent` | module | override → bare-import → managed (`MANAGED_DIR/node_modules/.../dist/index.js`) → npm-global; probes both `@mariozechner/*` and `@oh-my-pi/*` aliases |
1023
+ | `openspec`, `npm`, `node`, `tsx`, `git`, `zrok` | binary | override → managed → where |
1024
+ | `pi-dashboard` | module | override → managed → npm-global (presence of `package.json` is enough) |
1025
+
1026
+ ### Resolution record
1027
+
1028
+ `registry.resolve(name)` returns a `Resolution` with:
1029
+
1030
+ - `ok` — whether any strategy succeeded
1031
+ - `path` / `source` — winning path and its classification (`override`, `managed`, `system`, `npm-global`, `bare-import`)
1032
+ - `tried[]` — ordered trail: `[{ strategy, result }]` where `result` is `"ok"` on success or the strategy's failure reason
1033
+ - `resolvedAt` — epoch ms
1034
+
1035
+ ### Overrides
1036
+
1037
+ User-set overrides live at `~/.pi/dashboard/tool-overrides.json`:
1038
+
1039
+ ```json
1040
+ {
1041
+ "version": 1,
1042
+ "overrides": {
1043
+ "pi": { "path": "C:\custom\pi.cmd" },
1044
+ "pi-coding-agent": { "path": "D:\dev\pi-coding-agent\dist\index.js" }
1045
+ }
1046
+ }
1047
+ ```
1048
+
1049
+ The file is machine-local (deliberately separate from `config.json` so dotfile syncs don't follow paths across machines). Invalid overrides (path doesn't exist) are recorded as `invalid: <reason>` in `tried[]` and the registry falls through to the next strategy.
1050
+
1051
+ ### Caching
1052
+
1053
+ - One `Resolution` per tool, cached in the registry instance.
1054
+ - Loaded ES modules (for `kind: "module"`) cached alongside.
1055
+ - `registry.rescan(name?)` invalidates one or all entries + re-reads the overrides file.
1056
+ - The runner's old `resolverCache` and `npm.ts`'s old `cachedGlobalRoot` are gone — the registry owns caching now.
1057
+
1058
+ ### REST API (`/api/tools`)
1059
+
1060
+ Guarded by the same network guard as `/api/config`.
1061
+
1062
+ | Endpoint | Purpose |
1063
+ |---|---|
1064
+ | `GET /api/tools` | Snapshot of every registered tool's Resolution |
1065
+ | `GET /api/tools/:name` | Single Resolution (404 for unregistered) |
1066
+ | `POST /api/tools/rescan` | Invalidate all caches (body empty) or one (`{ name }`) + return refreshed list |
1067
+ | `PUT /api/tools/:name` | Set an override (`{ path }`) + return refreshed Resolution |
1068
+ | `DELETE /api/tools/:name` | Clear the override + return refreshed Resolution |
1069
+ | `POST /api/tools/diagnostics` | Plain-text export — one block per tool with the full `tried[]` trail, for bug reports |
1070
+
1071
+ ### Settings UI
1072
+
1073
+ Settings → General → **Tools** renders one row per registered tool: status badge, source, truncated path, expand-to-trail, override input, per-row rescan. The header has **Rescan all**, **Reset overrides**, **Export diagnostics**.
1074
+
1075
+ ### Migration path
1076
+
1077
+ `ToolResolver` remains the low-level PATH search primitive. The registry calls `ToolResolver.which()` from its `where` strategy. Unregistered binary names (e.g., ad-hoc `code-server` detection) still flow through `ToolResolver` directly. This keeps `ToolResolver` useful for one-off lookups and lets the registry focus on tools the dashboard formally depends on.
1078
+
1079
+ See change: `consolidate-tool-resolution`.
1080
+
1081
+ ### Testing the bootstrap state space
1082
+
1083
+ Resolution behavior intersects with HOME, platform, install layout, and pi's `settings.json` state across ~1000 combinations. Rather than hope CI on three runners plus manual QA cover all of them, the dashboard ships an in-memory harness at `packages/shared/src/__tests__/bootstrap/` that models the full cube:
1084
+
1085
+ ```
1086
+ 3 platforms (win32, darwin, linux)
1087
+ × 5 dash-locations (electron, npm-g, dev, managed, absent)
1088
+ × 6 pi-states (absent, present-no-ext, present-stale-ext, present-valid, malformed, appimage-tmp)
1089
+ × 4 settings-states (missing, empty, valid, malformed)
1090
+ × 3 env-states (normal, spaces-unicode, home-drift)
1091
+ = 1080 cells
1092
+ ```
1093
+
1094
+ Each cell is **either** a registered test (writing a trail snapshot via `snapshotTrail`) **or** an explicit skip with a documented reason (in `scenarios-skipped.ts`). `cube.test.ts` fails CI when any cell is neither — a forcing function so that adding a new platform, a new install mechanic, or a new pi-state silently never happens.
1095
+
1096
+ The harness is memfs-backed (no real fs, no subprocesses, no network) and runs in ~2 seconds via `npm run test:bootstrap`. The primary assertion is a normalized trail snapshot that captures strategy order, failure reasons, and `toArgv` output — which catches most bootstrap regressions before CI even reaches a real OS.
1097
+
1098
+ Key locked-in invariants (from current snapshots):
1099
+
1100
+ - Unix pi chain: `override → managed-bin → where` (no bare-import, no npm-g — a real limitation for GUI-launched minimal-PATH scenarios).
1101
+ - Win32 pi chain: 5-level fallback including the no-cmd-flash `.cmd` probe and `node.exe` prepend for `.js` targets.
1102
+ - Override strategy is first in every chain; invalid overrides fall through with `invalid: ...` reason.
1103
+ - Path normalization cross-OS via `<HOME>` / `<NPM_ROOT>` placeholders — snapshots stable on macOS and Linux CI.
1104
+ - **Windows bug captured**: `npm i -g pi-dashboard` + no pi → pi unresolved. Trail snapshot locks in the current broken state; `unified-bootstrap-install` will update it when the fix lands.
1105
+
1106
+ See change: `bootstrap-resolution-harness`. Full walkthrough in `packages/shared/src/__tests__/bootstrap/README.md`.
1107
+
1108
+ ## Path Handling (`platform/paths.ts`)
1109
+
1110
+ Filesystem paths are OS-aware, and the dashboard touches them in three user-visible places: pin-directory storage (server), session-grouping (client), and the path picker UI (client). All three go through a single module — `packages/shared/src/platform/paths.ts` — rather than inventing their own logic.
1111
+
1112
+ ### Primitives
1113
+
1114
+ | Function | Purpose |
1115
+ |---|---|
1116
+ | `normalizePath(p, platform?)` | Canonical form for storage/comparison: OS-native separator, trailing sep stripped (except roots), `..`/`.` resolved, case preserved. |
1117
+ | `samePath(a, b, platform?)` | Filesystem equality — case-insensitive on Win/macOS, case-sensitive on Linux, tolerant of trailing/separator drift. Different Windows drives (`A:\` vs `B:\`) NEVER match. |
1118
+ | `parsePathInput(value, platform?)` | Split user-typed input into `{ parent, partial }` — handles Windows drive-letter roots, UNC roots, Unix roots, mixed separators. |
1119
+ | `withTrailingSep(p, platform?)` | Append OS-native separator if not already terminated. |
1120
+ | `isFilesystemRoot(p, platform?)` | True for `/`, `C:\`, `\server\share\` uniformly — replaces `resolved === "/"` checks that only recognized Unix roots. |
1121
+
1122
+ ### Platform injection pattern
1123
+
1124
+ Every OS-dependent function takes an optional trailing `platform: NodeJS.Platform` parameter defaulting to `process.platform`. Tests exercise both branches on any host (Windows tests run on Linux CI and vice versa) without mutating `process.platform`. Client code uses `inferPlatform(samples)` (in `client/src/lib/session-grouping.ts`) to sniff the server's platform from observed path shapes — backslash or drive-letter prefix → Windows, leading `/` → POSIX.
1125
+
1126
+ ### Windows multi-drive invariants
1127
+
1128
+ | Drive letter | Contract |
1129
+ |---|---|
1130
+ | A:, B:, C:, …, Z: | each a distinct filesystem root |
1131
+ | `B:\` vs `b:\` | case-insensitive (match) |
1132
+ | `A:\Foo` vs `B:\Foo` | never match (different drives) |
1133
+ | `\server\share` vs `B:` | never match |
1134
+ | Bare `B:` input | treated as `B:\`, not cwd-relative |
1135
+ | `B:Dev` input | drive root + partial (defensive) |
1136
+ | `B:/Dev/BB` (fwd slash) | canonicalizes to `B:\Dev\BB` |
1137
+ | Browse at `B:\` | `parent: null` (root is its own dead-end) |
1138
+
1139
+ ### Protocol extension
1140
+
1141
+ `BrowseResult` includes an optional `platform` field (`"win32" | "darwin" | "linux"`) populated from `process.platform` on the server. Path picker prefers this server-issued value and falls back to client-side inference when absent (for backward compatibility with older servers).
1142
+
1143
+ ### Common gotcha: `Array.prototype.map(normalizePath)`
1144
+
1145
+ `Array.prototype.map` passes `(element, index, array)`. When a function takes `platform` as an optional second argument, the index (a number) gets passed as `platform`, silently failing the `=== "win32"` check and taking the POSIX branch. Always wrap: `.map((p) => normalizePath(p))` instead of `.map(normalizePath)`.
1146
+
1147
+ See change: `platform-path-normalization`.
1148
+
1149
+ ## Chat Input State (drafts & history recall)
1150
+
1151
+ ### Per-session draft persistence
1152
+
1153
+ The chat input (`CommandInput.tsx`) is a **controlled** component — its text value is driven by the `draft` prop passed from `App.tsx`. App owns a `drafts: Map<sessionId, string>` state that is:
1154
+
1155
+ 1. **Hydrated** once at mount from `localStorage` via `readAllDrafts()` (scans for the `chat-draft:` key prefix).
1156
+ 2. **Persisted** (debounced ~300 ms) on change: new / changed keys go through `writeDraft(sid, text)`, removed keys and empty values go through `deleteDraft(sid)`.
1157
+ 3. **Cleared eagerly on send** (`wrappedHandleSend` → `clearDraftForSession(selectedId)`) so a reload immediately after sending does not resurrect the sent prompt.
1158
+
1159
+ ```
1160
+ localStorage
1161
+ ├── chat-draft:<sessionId-A> "half-typed foo"
1162
+ ├── chat-draft:<sessionId-B> "another draft"
1163
+ └── ...
1164
+ ```
1165
+
1166
+ This solves two bugs at once:
1167
+ - **Lost drafts on navigation**: `CommandInput` unmounts when the user opens Settings, file diff view, OpenSpec preview, etc. The lifted state in `App.tsx` survives the unmount, and the draft reappears when the user returns to the chat branch.
1168
+ - **Draft leakage between sessions**: keying by `sessionId` means each session has its own draft cell; switching flips the `draft` prop, never bleeding text across.
1169
+
1170
+ Pasted images (`useImagePaste` → `pendingImages`) are **intentionally not persisted** — base64 blobs blow through `localStorage` quotas and the transient in-memory behavior is unchanged from pre-change.
1171
+
1172
+ ### History recall (ArrowUp / ArrowDown)
1173
+
1174
+ History source is **derived**, not stored: `extractUserPromptHistory(state.messages)` filters the session's in-memory `ChatMessage[]` to `role === "user"`, drops empty/whitespace content, collapses consecutive duplicates, and returns newest-first. Since messages are replayed from the server on subscribe, history is available as soon as the session is subscribed — no new protocol, no new persistence.
1175
+
1176
+ Inside `CommandInput`, history navigation uses a small state machine:
1177
+
1178
+ ```
1179
+ historyIndex: number | null — null = not in history mode
1180
+ savedDraftRef: useRef<string> — in-progress draft captured when history mode is first entered
1181
+
1182
+ ArrowUp (caret on first line, no dropdown, no pending, history.length > 0)
1183
+ null ─────────────────────────────────────────▶ 0 (save current text first)
1184
+ k ─────────────────────────────────────────▶ min(k+1, len-1)
1185
+ ArrowDown (caret on last line, no dropdown, historyIndex != null)
1186
+ k > 0 ─────────────────────────────────────────▶ k - 1
1187
+ 0 ─────────────────────────────────────────▶ null (restore savedDraftRef)
1188
+ Escape (historyIndex != null)
1189
+ k ─────────────────────────────────────────▶ null (restore savedDraftRef)
1190
+ any text edit while historyIndex != null
1191
+ k ─────────────────────────────────────────▶ null (user now editing; no restore)
1192
+ sessionId change
1193
+ null, savedDraftRef = ""
1194
+ ```
1195
+
1196
+ **Bash-style caret gating** is critical: `ArrowUp` only triggers history when `selectionStart` is at or before the first `\n` (the textarea's native "ArrowUp" would have nowhere to go); `ArrowDown` only when `selectionStart` is at or after the last `\n`. Non-empty selections are excluded. This guarantees multiline editing (moving between rows with arrow keys) is never broken.
1197
+
1198
+ See change: `chat-input-draft-and-history`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-agent-dashboard",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Web dashboard for monitoring and interacting with pi agent sessions",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,6 +51,8 @@
51
51
  "build": "npm run build --workspace=@blackbelt-technology/pi-dashboard-web",
52
52
  "test": "HOME=$(mktemp -d -t pi-test-XXXXXX) vitest run",
53
53
  "test:watch": "HOME=$(mktemp -d -t pi-test-XXXXXX) vitest",
54
+ "test:bootstrap": "HOME=$(mktemp -d -t pi-test-XXXXXX) vitest run packages/shared/src/__tests__/bootstrap",
55
+ "test:bootstrap:watch": "HOME=$(mktemp -d -t pi-test-XXXXXX) vitest packages/shared/src/__tests__/bootstrap",
54
56
  "lint": "tsc --noEmit",
55
57
  "reload": "./scripts/reload-all.sh",
56
58
  "reload:check": "./scripts/reload-all.sh --check",
@@ -64,9 +66,9 @@
64
66
  "screenshots": "npm --prefix site run screenshots"
65
67
  },
66
68
  "dependencies": {
67
- "@blackbelt-technology/pi-dashboard-extension": "*",
68
- "@blackbelt-technology/pi-dashboard-server": "*",
69
- "@blackbelt-technology/pi-dashboard-web": "*"
69
+ "@blackbelt-technology/pi-dashboard-extension": "^0.4.0",
70
+ "@blackbelt-technology/pi-dashboard-server": "^0.4.0",
71
+ "@blackbelt-technology/pi-dashboard-web": "^0.4.0"
70
72
  },
71
73
  "devDependencies": {
72
74
  "jsdom": "^29.0.2",
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-extension",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Pi bridge extension for pi-dashboard",
5
5
  "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
6
9
  "pi": {
7
10
  "extensions": [
8
11
  "src/bridge.ts"
@@ -16,18 +19,23 @@
16
19
  ".pi/skills/pi-dashboard/"
17
20
  ],
18
21
  "dependencies": {
19
- "@blackbelt-technology/pi-dashboard-shared": "*",
22
+ "@blackbelt-technology/pi-dashboard-shared": "^0.4.0",
20
23
  "ws": "^8.18.0"
21
24
  },
22
25
  "peerDependencies": {
23
- "@mariozechner/pi-coding-agent": "*"
26
+ "@mariozechner/pi-coding-agent": "*",
27
+ "@mariozechner/pi-tui": "*"
24
28
  },
25
29
  "peerDependenciesMeta": {
26
30
  "@mariozechner/pi-coding-agent": {
27
31
  "optional": true
32
+ },
33
+ "@mariozechner/pi-tui": {
34
+ "optional": true
28
35
  }
29
36
  },
30
37
  "devDependencies": {
38
+ "@mariozechner/pi-tui": "*",
31
39
  "@types/ws": "^8.18.1"
32
40
  }
33
41
  }