@blackbelt-technology/pi-agent-dashboard 0.2.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/AGENTS.md +64 -8
  2. package/README.md +308 -101
  3. package/docs/architecture.md +515 -16
  4. package/package.json +14 -7
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
  7. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  8. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
  9. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  10. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  11. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  12. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  13. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  14. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  15. package/packages/extension/src/ask-user-tool.ts +289 -20
  16. package/packages/extension/src/bridge.ts +107 -6
  17. package/packages/extension/src/command-handler.ts +34 -39
  18. package/packages/extension/src/dev-build.ts +1 -1
  19. package/packages/extension/src/git-info.ts +9 -19
  20. package/packages/extension/src/pi-env.d.ts +1 -0
  21. package/packages/extension/src/process-scanner.ts +72 -38
  22. package/packages/extension/src/prompt-expander.ts +25 -4
  23. package/packages/extension/src/provider-register.ts +304 -16
  24. package/packages/extension/src/server-auto-start.ts +27 -1
  25. package/packages/extension/src/server-launcher.ts +71 -27
  26. package/packages/server/package.json +17 -2
  27. package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
  28. package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
  29. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  30. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  31. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  32. package/packages/server/src/__tests__/browse-endpoint.test.ts +246 -10
  33. package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
  34. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  35. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  36. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  37. package/packages/server/src/__tests__/cors.test.ts +34 -2
  38. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  39. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  40. package/packages/server/src/__tests__/editor-manager-pid-registry.test.ts +168 -0
  41. package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
  42. package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
  43. package/packages/server/src/__tests__/editor-registry.test.ts +29 -15
  44. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  45. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  46. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  47. package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
  48. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  49. package/packages/server/src/__tests__/git-operations.test.ts +9 -7
  50. package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
  51. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  52. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  53. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  54. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  55. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  56. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
  57. package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
  58. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +126 -0
  59. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  60. package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
  61. package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
  62. package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
  63. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  64. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  65. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  66. package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
  67. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  68. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  69. package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
  70. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  71. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  72. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  74. package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
  75. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
  76. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
  77. package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
  78. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  79. package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
  80. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  81. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  82. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  83. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  84. package/packages/server/src/__tests__/tunnel.test.ts +103 -6
  85. package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
  86. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  87. package/packages/server/src/bootstrap-queue.ts +130 -0
  88. package/packages/server/src/bootstrap-state.ts +131 -0
  89. package/packages/server/src/browse.ts +108 -9
  90. package/packages/server/src/browser-gateway.ts +16 -3
  91. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  92. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  93. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  94. package/packages/server/src/cli.ts +256 -32
  95. package/packages/server/src/config-api.ts +16 -0
  96. package/packages/server/src/directory-service.ts +270 -39
  97. package/packages/server/src/editor-detection.ts +12 -9
  98. package/packages/server/src/editor-manager.ts +39 -5
  99. package/packages/server/src/editor-pid-registry.ts +199 -0
  100. package/packages/server/src/editor-registry.ts +22 -25
  101. package/packages/server/src/fix-pty-permissions.ts +44 -0
  102. package/packages/server/src/git-operations.ts +1 -1
  103. package/packages/server/src/headless-pid-registry.ts +16 -20
  104. package/packages/server/src/home-lock-release.ts +72 -0
  105. package/packages/server/src/home-lock.ts +389 -0
  106. package/packages/server/src/node-guard.ts +52 -0
  107. package/packages/server/src/npm-search-proxy.ts +71 -0
  108. package/packages/server/src/openspec-tasks.ts +158 -0
  109. package/packages/server/src/package-manager-wrapper.ts +225 -34
  110. package/packages/server/src/pi-core-checker.ts +290 -0
  111. package/packages/server/src/pi-core-updater.ts +172 -0
  112. package/packages/server/src/pi-gateway.ts +7 -0
  113. package/packages/server/src/pi-resource-scanner.ts +5 -8
  114. package/packages/server/src/pi-version-skew.ts +196 -0
  115. package/packages/server/src/preferences-store.ts +17 -3
  116. package/packages/server/src/process-manager.ts +403 -222
  117. package/packages/server/src/provider-probe.ts +234 -0
  118. package/packages/server/src/restart-helper.ts +130 -0
  119. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  120. package/packages/server/src/routes/file-routes.ts +30 -3
  121. package/packages/server/src/routes/openspec-routes.ts +107 -1
  122. package/packages/server/src/routes/pi-core-routes.ts +140 -0
  123. package/packages/server/src/routes/provider-auth-routes.ts +12 -10
  124. package/packages/server/src/routes/provider-routes.ts +55 -2
  125. package/packages/server/src/routes/recommended-routes.ts +225 -0
  126. package/packages/server/src/routes/system-routes.ts +30 -34
  127. package/packages/server/src/routes/tool-routes.ts +153 -0
  128. package/packages/server/src/server-pid.ts +5 -9
  129. package/packages/server/src/server.ts +363 -26
  130. package/packages/server/src/session-api.ts +77 -8
  131. package/packages/server/src/session-bootstrap.ts +17 -3
  132. package/packages/server/src/session-diff.ts +21 -21
  133. package/packages/server/src/terminal-manager.ts +65 -20
  134. package/packages/server/src/test-env-guard.ts +26 -0
  135. package/packages/server/src/test-support/test-server.ts +63 -0
  136. package/packages/server/src/tunnel.ts +172 -34
  137. package/packages/shared/package.json +10 -3
  138. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  139. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  140. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  141. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  142. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  143. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  144. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  145. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  146. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  147. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  148. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  149. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  150. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  151. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  152. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  153. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  154. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  155. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  156. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  157. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  158. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  159. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  160. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  161. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  162. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  163. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  164. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  165. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  166. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  167. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  168. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  169. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  170. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  172. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  173. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  174. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  175. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  176. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  177. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  178. package/packages/shared/src/__tests__/config.test.ts +59 -3
  179. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  180. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  181. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  182. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  183. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  184. package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
  185. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  186. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  187. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  188. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  189. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  190. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  191. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  192. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  193. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  194. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  195. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  196. package/packages/shared/src/__tests__/recommended-extensions.test.ts +156 -0
  197. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  198. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  199. package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
  200. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  201. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  202. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  203. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  204. package/packages/shared/src/bootstrap-install.ts +212 -0
  205. package/packages/shared/src/bridge-register.ts +87 -20
  206. package/packages/shared/src/browser-protocol.ts +93 -1
  207. package/packages/shared/src/config.ts +87 -15
  208. package/packages/shared/src/managed-paths.ts +31 -4
  209. package/packages/shared/src/openspec-poller.ts +71 -49
  210. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  211. package/packages/shared/src/platform/commands.ts +100 -0
  212. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  213. package/packages/shared/src/platform/exec.ts +220 -0
  214. package/packages/shared/src/platform/git.ts +155 -0
  215. package/packages/shared/src/platform/index.ts +15 -0
  216. package/packages/shared/src/platform/npm.ts +162 -0
  217. package/packages/shared/src/platform/openspec.ts +91 -0
  218. package/packages/shared/src/platform/paths.ts +276 -0
  219. package/packages/shared/src/platform/process-identify.ts +126 -0
  220. package/packages/shared/src/platform/process-scan.ts +94 -0
  221. package/packages/shared/src/platform/process.ts +168 -0
  222. package/packages/shared/src/platform/runner.ts +369 -0
  223. package/packages/shared/src/platform/shell.ts +44 -0
  224. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  225. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  226. package/packages/shared/src/recommended-extensions.ts +196 -0
  227. package/packages/shared/src/resolve-jiti.ts +62 -3
  228. package/packages/shared/src/rest-api.ts +97 -0
  229. package/packages/shared/src/semaphore.ts +83 -0
  230. package/packages/shared/src/source-matching.ts +126 -0
  231. package/packages/shared/src/test-support/setup-home.ts +74 -0
  232. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  233. package/packages/shared/src/tool-registry/index.ts +56 -0
  234. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  235. package/packages/shared/src/tool-registry/registry.ts +262 -0
  236. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  237. package/packages/shared/src/tool-registry/types.ts +180 -0
  238. package/packages/shared/src/types.ts +7 -0
@@ -1,5 +1,12 @@
1
1
  # PI Dashboard Architecture
2
2
 
3
+ > **Adjacent artifact:** the public marketing site lives at `/site` and is
4
+ > product-adjacent, not part of the dashboard runtime. It has its own Astro
5
+ > build, its own Playwright screenshot pipeline, and its own GitHub Pages
6
+ > deploy workflow (`.github/workflows/deploy-site.yml`). See
7
+ > `/site/README.md` for details.
8
+
9
+
3
10
  ## Overview
4
11
 
5
12
  The PI Dashboard is a web-based dashboard for monitoring and interacting with pi agent sessions. It consists of three components:
@@ -75,6 +82,7 @@ A React-based responsive web UI that:
75
82
  - Provides command autocomplete with `/` prefix
76
83
  - Supports bidirectional interaction (send prompts, run commands)
77
84
  - Works on mobile with responsive layout and swipe gestures
85
+ - Shows an onboarding `LandingPage` whenever the main pane is empty, narrating the three steps needed to go from install → first running session (Setup credentials → Add folder → Start session). Each step is a card in **pending**, **done**, or **locked** state, derived purely from client state: `useProvidersReady()` (from `GET /api/providers`), `pinnedDirectories.length`, and `sessions.size`. Satisfied steps collapse to single-line ✔ rows, so returning users see a compact status strip rather than a full onboarding wall. The `PinDirectoryDialog` used by Step ② is mounted once at the app root in `App.tsx` and shared with the sidebar "Add folder" button via a single `onOpenPinDialog` callback.
78
86
 
79
87
  ### 4. Shared Types (`src/shared/`)
80
88
  TypeScript type definitions shared across all components:
@@ -150,13 +158,74 @@ pi-flows runs multi-agent workflows in-process. Subagent sessions use `SessionMa
150
158
  - Abort: browser sends `flow_control { action: "abort" }` → server → bridge → `pi.events.emit("flow:abort")` → `flowManager.abort()`
151
159
  - Autonomous toggle: browser sends `flow_control { action: "toggle_autonomous" }` → same path → `setAutonomousMode()`
152
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
+
153
205
  ### Force Kill Escalation
154
206
  The Stop button supports two-click escalation for stuck sessions:
155
207
  1. **Click 1 (Abort)**: Sends `abort` → bridge → `ctx.abort()`. Button transitions to orange pulsing "Force Stop".
156
- 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.
157
213
 
158
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.
159
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
+
160
229
  Inline stop buttons also appear on running tool cards in `ToolCallStep`, providing contextual abort access right where the stuck command is visible.
161
230
 
162
231
  ### Repeated Tool Call Collapsing
@@ -175,6 +244,30 @@ Consecutive tool calls with the same name and identical args (e.g. health check
175
244
  - `index.ts`: `flow:abort` and `flow:toggle-autonomous` event listeners added
176
245
  - `flow-tui.ts`: `autonomousMode` included in `flow:flow-started` event data
177
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
+
178
271
  ### Auto-Resume on Prompt
179
272
  When a user sends a prompt to an ended session, the server automatically resumes it:
180
273
  1. Server detects `send_prompt` for a session with `status === "ended"` and a valid `sessionFile`
@@ -221,15 +314,69 @@ When a user sends a prompt to an ended session, the server automatically resumes
221
314
  7. Session cards display processes with elapsed time and a kill button (sends SIGTERM to process group)
222
315
 
223
316
  ### OpenSpec Polling (Server-Side)
224
- 1. Server's DirectoryService polls `openspec` CLI every 30s for each known directory (union of pinned dirs + session cwds)
225
- 2. OpenSpec data is keyed by directory (cwd), not by session — one poll per directory regardless of session count
226
- 3. Changes are broadcast to all connected browsers via `openspec_update { cwd, data }`
227
- 4. Browsers can request immediate refresh via `openspec_refresh { cwd }`
228
- 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).
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”).
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
+
343
+ ### OpenSpec session card UI
344
+
345
+ The attached-change row on every session card has four affordances driven by the polled `OpenSpecChange`:
346
+
347
+ - **State pill** — `StatePill.tsx` renders `deriveChangeState(change)` as a small color-coded pill (`PLANNING`=zinc, `READY`=blue, `IMPLEMENTING`=amber, `COMPLETE`=green) next to the `📋 <name>` badge. Hidden when the attached change isn't present in OpenSpec data (e.g. archived under another name).
348
+ - **Tasks popover** — a `Tasks N/M` action button appears whenever the change has at least one parseable task. Clicking opens `TasksPopover.tsx`, a portal-rendered popover that lists every `- [ ] / - [x]` line in `tasks.md`, grouped by `## ` heading, with native checkboxes. Toggling a checkbox issues an optimistic `POST /api/openspec/tasks/toggle`; HTTP 409 (the file changed under us) refetches and surfaces a “File changed — please try again” banner. After every successful toggle the server re-polls openspec for that cwd and broadcasts the standard `openspec_update`, so card counts (`30/33` → `31/33`) refresh without a manual reload.
349
+ - **Archive anyway** — when `state === IMPLEMENTING && change.isComplete === true && allArtifactsDone`, an overflow `⋯` button appears on the action row. The single menu item opens a `ConfirmDialog` reading `"<unchecked> of <total> tasks are unchecked. Archive anyway?"`. Confirming dispatches `/opsx:archive <name>` through the normal `onSendPrompt` path. The default Apply button is unaffected; this is purely an escape hatch for changes whose remaining tasks are manual-verification items the user owns.
350
+ - **Bulk Archive relocation** — the Bulk Archive button now appears **only on unattached sessions** that have at least one folder change with `status === "complete"`. It is removed from the attached-session action row to free up space; the folder-level Bulk Archive in `FolderOpenSpecSection` is unchanged.
351
+
352
+ **Server endpoints (localhost-guarded, registered alongside the existing openspec routes in `packages/server/src/routes/openspec-routes.ts`):**
353
+
354
+ - `GET /api/openspec/tasks?cwd=<abs>&change=<name>` — parses `<cwd>/openspec/changes/<name>/tasks.md` via `parseTasksMarkdown` (top-level `- [ ] <id> <text>` / `- [x] <id> <text>` only; everything else is ignored). Returns `{ success: true, data: { tasks: OpenSpecTask[], groups: string[] } }`. 404 when the file is missing, 403 when the network guard denies.
355
+ - `POST /api/openspec/tasks/toggle` — body `{ cwd, change, id, done, line }`. Reads the file, validates that `line` still contains the requested `id` and the *opposite* state (optimistic-concurrency check), rewrites only that one line's `[ ]`/`[x]` marker, and atomic-writes via `tmp + rename` so other lines are preserved byte-for-byte. Maps typed errors to HTTP: `NotFoundError` → 404, `LineMismatchError` → 409, `NotACheckboxError` → 400. On success, fires a fire-and-forget `directoryService.refreshOpenSpec(cwd)` followed by an `openspec_update` broadcast.
229
356
 
230
357
  ### File Read API
231
358
  The server exposes `GET /api/file?cwd=...&path=...` for reading files or listing directories from session working directories. Guards: localhost-only, cwd must match a known session, resolved path must stay inside cwd. Returns `{ type: "file", content }` or `{ type: "directory", entries }`.
232
359
 
360
+ ### Filesystem Browser (PathPicker)
361
+
362
+ The dashboard's reusable directory chooser (`PathPicker`) is backed by two localhost-only endpoints:
363
+
364
+ - `GET /api/browse?path=<dir>&q=<query>` — lists subdirectories of `<dir>` (or `$HOME` when omitted), with `.git` / `.pi` detection. When `q` is non-empty, entries are case-insensitive substring-filtered and ranked:
365
+ - **Tier 0** exact match → **Tier 1** prefix → **Tier 2** word-boundary substring (after `-`, `_`, `.`, space, `/`) → **Tier 3** plain substring.
366
+ - Alphabetical within each tier. The 200-entry cap is applied **after** filter+rank so best matches always survive truncation.
367
+ - `POST /api/browse/mkdir` body `{ parent, name }` — creates a new directory non-recursively (`fs.mkdir` without `recursive: true`). Name validation rejects `/`, `\`, `\0`, `.`, `..`, empty, and leading/trailing whitespace. Errors map to 400 (`invalid name`, `parent is not a directory`), 404 (`parent not found`), 409 (`already exists`).
368
+
369
+ Client-side, `PathPicker` debounces the `q` request at 150ms and cancels in-flight requests via `AbortController`. Enter/Select follow a strict state machine instead of confirming arbitrary input:
370
+
371
+ 1. Exact case-insensitive match against a visible entry → `onSelect(<entry.path>)` + close.
372
+ 2. Input ends with `/` and its parsed parent equals the fetched directory → `onSelect(inputValue)` + close.
373
+ 3. Exactly one filtered candidate → complete to `<path>/` (do not close).
374
+ 4. Otherwise → no-op with a 300ms red-border flash.
375
+
376
+ If a debounced query is still pending when Enter fires, the client flushes it synchronously before evaluating the rules so the freshest server result is considered.
377
+
378
+ New folders can be created from two entry points — a footer **+ New folder** button (inline name entry), or an inline **+ Create "<name>" here** row shown when the typed partial has no exact match. The create-here row is suppressed if the parsed parent differs from the last-successfully-fetched directory (prevents creating inside a stale parent after a mid-path typo). On success the picker refetches and descends into the new directory.
379
+
233
380
  ### Pi Resources Browser
234
381
 
235
382
  The dashboard can display pi extensions, skills, and prompts installed for each workspace. The server-side scanner (`pi-resource-scanner.ts`) discovers resources from three sources:
@@ -255,6 +402,16 @@ Metadata is parsed from SKILL.md YAML frontmatter (`name`, `description`), promp
255
402
 
256
403
  Package operations use pi's `DefaultPackageManager` API on the server, serialized (one at a time, 409 on concurrent). Progress events are forwarded to browsers via `package_progress` WebSocket messages. After any successful operation, the server sends `/reload` to all connected pi sessions.
257
404
 
405
+ **Pi Core Version Check (separate from extension management):**
406
+ - `GET /api/pi-core/versions[?refresh=true]` — returns `PiCoreStatus` with all discovered pi ecosystem CLI packages (pi itself, pi-dashboard, pi-model-proxy, bare `pi-*` and scoped `@x/pi-*`), their installed version, latest npm-registry version, `updateAvailable` flag, and `installSource` (`"global"` via `npm list -g --depth=0 --json` vs `"managed"` in `~/.pi-dashboard/node_modules/`). Cached 5 min.
407
+ - `POST /api/pi-core/update` with `{ packages?: string[] }` — updates the listed packages, or all packages with `updateAvailable` when omitted. Runs `npm update -g <pkg>` (global) or `npm update <pkg>` in `~/.pi-dashboard/` (managed). Shares the `PackageManagerWrapper.runExclusive()` busy-lock with extension operations — returns 409 on contention.
408
+
409
+ Why a separate system? Pi's `DefaultPackageManager` only manages packages listed in `settings.json packages[]` (extensions/skills/prompts/themes). The pi CLI binary itself and the dashboard server package are installed directly via `npm -g` (or into `~/.pi-dashboard/` in the Electron case) and are invisible to pi's manager. `PiCoreChecker` + `PiCoreUpdater` (`pi-core-checker.ts` + `pi-core-updater.ts`) fill that gap.
410
+
411
+ Progress for core updates is delivered via typed `pi_core_update_progress` / `pi_core_update_complete` browser-protocol messages (not the `package_progress` channel) and fanned out to `PiCoreVersionsSection` and `PiUpdateBadge` via a `pi-core-event` DOM event. After any successful core update the server sends `/reload` to connected pi sessions just like extension updates.
412
+
413
+ **Header badge**: `PiUpdateBadge` polls `/api/pi-core/versions` on mount + every 30 min. When `updatesAvailable > 0` it renders a small pill-shaped button next to the `ServerSelector` that navigates to `/settings?tab=packages`.
414
+
258
415
  **Client navigation stack:**
259
416
  - Puzzle icon button in folder header → PiResourcesView (content area, "Installed" / "Packages" tabs)
260
417
  - "View" button on resource → MarkdownPreviewView (`.md` as markdown, `.ts` as code block)
@@ -311,7 +468,7 @@ The server has a two-layer access model:
311
468
 
312
469
  **Layer 1: Network Guard (`createNetworkGuard`)** — Fastify `preHandler` on all sensitive routes. Allows requests via three paths:
313
470
  1. **Loopback** — `127.0.0.1`, `::1`, `::ffff:127.0.0.1` (always allowed)
314
- 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`.
315
472
  3. **Authenticated** — `request.isAuthenticated === true` (set by auth `onRequest` hook via `decorateRequest`)
316
473
 
317
474
  Otherwise → 403. The guard strips `::ffff:` IPv4-mapped prefixes before matching.
@@ -343,7 +500,7 @@ Optional OAuth2 authentication protects the dashboard when accessed remotely.
343
500
  ### Settings Panel
344
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:
345
502
  1. Loads config via `GET /api/config` (secrets redacted as `***`)
346
- 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)
347
504
  3. Sends only changed fields via `PUT /api/config` (partial merge)
348
505
  4. Server preserves `***` secrets (doesn't overwrite real values), writes to disk, and applies runtime-safe changes
349
506
  5. Port/piPort changes flag `restartRequired` in the response
@@ -429,18 +586,42 @@ The tunnel is **enabled by default** (`tunnel.enabled: true`). When the server s
429
586
 
430
587
  1. **Binary detection** — `detectZrokBinary()` checks if `zrok` is on PATH via `which`/`where`
431
588
  2. **Environment check** — `loadZrokEnv()` reads zrok's own config (`~/.zrok2/environment.json` or `~/.zrok/environment.json`) to verify enrollment. The dashboard never stores zrok API keys — they live entirely in zrok's config directory, created by `zrok enable <token>`.
432
- 3. **Stale cleanup** — `cleanupStaleZrok()` reads `~/.pi/dashboard/zrok.pid`, kills orphaned zrok processes from previous crashes
433
- 4. **Reserved share** — If `tunnel.reservedToken` is not set, `zrok reserve public` is called to create a persistent share token. The token is saved to config so the URL stays the same across restarts. If a saved token fails (e.g., expired), a new reservation is created automatically.
434
- 5. **Subprocess spawn** — `createTunnel(port, reservedToken?)` spawns `zrok share reserved <token> --headless` (or `zrok share public --headless` as fallback) as a child process
435
- 6. **URL parsing** — The public URL is parsed from stdout/stderr (30s timeout)
589
+ 3. **Stale cleanup** — Runs **unconditionally on startup** whenever the zrok binary is present (even in `--no-tunnel` mode) so leftovers from a previous run are always swept:
590
+ - `cleanupStaleZrok()` reads `~/.pi/dashboard/zrok.pid` and SIGTERMs the tracked process
591
+ - `scavengeOrphanZrokProcesses(port)` scans `ps -ax` for any `zrok share --override-endpoint http://localhost:<port>` processes that escaped pid-file tracking (previous crashes, failed retries) and SIGTERMs them. Never kills the current process.
592
+ 4. **Reserved share** — If `tunnel.reservedToken` is not set, `zrok reserve public` is called to create a persistent share token. The token is saved to config so the URL stays the same across restarts. If a saved token fails (e.g., expired or orphaned on the zrok edge), `releaseShare(token)` explicitly releases it and a new reservation is created automatically (capped at 1 retry to prevent cascades).
593
+ 5. **Subprocess spawn** — `createTunnel(port, reservedToken?)` spawns `zrok share reserved <token> --headless` (or `zrok share public --headless` as fallback) as a child process. Concurrent calls are serialized via an in-flight promise (`pendingCreate`) so a UI double-click or a race between startup auto-connect and `/api/tunnel-connect` can’t create two parallel reservations.
594
+ 6. **URL parsing** — The public URL is parsed from stdout/stderr (30s timeout). On timeout: SIGTERM → SIGKILL after 2s grace, plus `releaseShare(token)` if the token was reserved just-in-time within the call (prevents leaking a dead reservation that would leave a "live but broken" URL on the zrok edge).
436
595
  7. **PID tracking** — The subprocess PID is written to `~/.pi/dashboard/zrok.pid`
437
- 8. **Shutdown** — `deleteTunnel()` kills the subprocess and removes the PID file. The reserved token is preserved for next restart.
596
+ 8. **Shutdown** — `deleteTunnel(port?)` SIGTERMs the active subprocess, removes the PID file, and (when `port` is supplied) re-runs `scavengeOrphanZrokProcesses(port)` as belt-and-braces cleanup. The reserved token is preserved for next restart. Called from graceful shutdown, `/api/shutdown`, `/api/restart`, and `/api/tunnel-disconnect`.
438
597
 
439
- To disable: set `tunnel.enabled` to `false` in `~/.pi/dashboard/config.json` or pass `--no-tunnel` on the CLI.
598
+ To disable: set `tunnel.enabled` to `false` in `~/.pi/dashboard/config.json` or pass `--no-tunnel` on the CLI. When disabled, step 3 still runs so orphan processes are cleaned up even if the tunnel is turned off.
440
599
 
441
600
  The client can query `GET /api/tunnel-status` which returns `{ status: "active"|"inactive"|"unavailable", url?, serverOs }`.
442
601
  The client can connect/disconnect the tunnel via `POST /api/tunnel-connect` and `POST /api/tunnel-disconnect`.
443
602
 
603
+
604
+
605
+ ### CORS
606
+
607
+ The Fastify CORS callback in `server.ts` allows:
608
+
609
+ - Same-origin navigations (no `Origin` header).
610
+ - `localhost`, `127.0.0.1`, `[::1]` on any port.
611
+ - The currently-active zrok tunnel URL (looked up dynamically via `getTunnelUrl()` so URL rotation picks up without a restart).
612
+ - Any `*.share.zrok.io` host (covers stale tabs, new reservations, and the brief window before `activeTunnelUrl` is populated on startup).
613
+ - Explicitly-configured `corsAllowedOrigins` from config.
614
+
615
+ On a mismatch the callback returns `cb(null, false)` — **not** `cb(new Error(…), false)`. The `Error` form causes `@fastify/cors` to surface the error as HTTP 500 on every asset response, which is exactly what caused the long-running “zrok returns 500 on assets” debugging saga: Vite emits `<script type="module" crossorigin>` entry tags, which per HTML spec browsers always fetch in CORS mode (even same-origin), so the tunnel URL appearing in `Origin` is unavoidable. Returning `cb(null, false)` simply omits CORS headers; the browser enforces same-origin policy on its own.
616
+
617
+ ### HTTP Compression
618
+
619
+ The Fastify server registers `@fastify/compress` globally with `gzip` + `deflate` encodings (threshold 1 KB). Brotli is intentionally **not** enabled — zrok’s free public proxy has been observed to truncate/stream-reset `content-encoding: br` responses under parallel browser load (curl succeeds, Chrome reports `ERR_ABORTED 500`). gzip round-trips cleanly through zrok and is universally supported.
620
+
621
+ Additionally, the client build generates `.gz` sibling files (via `packages/client/scripts/precompress.mjs`, run from the `build` / `prepare` scripts) and `@fastify/static` is registered with `preCompressed: true`. This serves pre-compressed assets directly with a stable `Content-Length` header, avoiding any streaming-compression edge cases in intermediate HTTP/2 proxies. Dynamic compression via `@fastify/compress` still handles API responses and other non-file routes.
622
+
623
+ Combined with client bundle splitting (see `packages/client/vite.config.ts` → `rollupOptions.output.manualChunks`), the main initial chunk ships at ~150 KB gzipped (down from 3.1 MB uncompressed), well under tunnel abort thresholds.
624
+
444
625
  ### PWA Support
445
626
 
446
627
  The dashboard is installable as a Progressive Web App on mobile devices:
@@ -475,6 +656,88 @@ The `POST /api/restart` endpoint and `pi-dashboard restart` command perform faul
475
656
 
476
657
  The restart endpoint accepts `{ dev: boolean }` to switch between dev/production mode.
477
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
+
478
741
  ### Auto-Start Flow
479
742
 
480
743
  When `autoStart` is `true` (default), the bridge extension automatically starts the dashboard server:
@@ -556,7 +819,23 @@ The dashboard supports browser-based authentication with pi's LLM providers, ena
556
819
  3. **Device-code flow** (GitHub Copilot): server requests device code → UI shows user code + verification URL → server polls until authorized
557
820
  4. **API key flow**: user pastes key in Settings → saved directly
558
821
  5. All credentials written to `~/.pi/agent/auth.json` with lockfile + atomic write (`0600` permissions)
559
- 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.
560
839
 
561
840
  ### Key Files
562
841
 
@@ -598,6 +877,26 @@ This is separate from the main JSON dashboard WebSocket (`/ws`).
598
877
  3. Browser opens binary WS to `/ws/terminal/:id`, attaches `xterm.js`
599
878
  4. Shell exit → PTY `onExit` → server broadcasts `terminal_removed` → card removed
600
879
 
880
+ **Native binary permissions.** `node-pty`'s prebuilt `spawn-helper` (and `pty.node`) must be executable for `pty.spawn` to succeed on macOS/Linux. Three layers of defense ensure this:
881
+
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`.
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.
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`.
895
+
896
+ A regression test (`packages/server/src/__tests__/fix-pty-permissions.test.ts`) asserts the current platform's helper is executable after install.
897
+
898
+ **Browser-gateway error visibility.** `browser-gateway.ts` distinguishes two failure modes when receiving a WebSocket frame: a `JSON.parse` error (silently dropped — garbage frames are normal on the open internet) and an exception thrown by an individual message handler (logged to stderr as `[browser-gw] handler error type=<msg.type>: <err>`). The connection stays open after handler errors so subsequent messages still flow. This stops failures like a broken `node-pty` `spawn` from manifesting as a silently dead UI button.
899
+
601
900
  ### Output Buffering
602
901
 
603
902
  Each terminal maintains a 256KB ring buffer of raw PTY output. When a new WebSocket connects (reconnect, new tab), the buffer is replayed before live streaming. Combined with client-side 10,000-line scrollback.
@@ -608,7 +907,7 @@ Terminal xterm.js instances stay mounted in the DOM (CSS hidden/shown) for insta
608
907
 
609
908
  ### Folder-Scoped View
610
909
 
611
- Terminals are displayed in a tabbed `TerminalsView` per folder, accessed via the folder action bar's `Terminals(N)` button or `+Terminal` button. Terminal cards no longer appear in the sidebar — the sidebar shows only pi session cards. The tab bar supports switching, closing, renaming, and creating new terminals.
910
+ Terminals are displayed in a tabbed `TerminalsView` per folder, accessed via the folder action bar's `Terminals(N)` button. Terminal cards no longer appear in the sidebar — the sidebar shows only pi session cards. The tab bar supports switching, closing, renaming, and creating new terminals.
612
911
 
613
912
  ## Embedded Editor (code-server)
614
913
 
@@ -638,6 +937,19 @@ Browser Dashboard Server code-server
638
937
 
639
938
  All code-server traffic is proxied through `/editor/:id/*` on the dashboard server. This provides same-origin access (no CORS/iframe issues) and works transparently through zrok tunnels.
640
939
 
940
+ ### Orphan Cleanup
941
+
942
+ `EditorManager` state is purely in-memory. On graceful shutdown, `editorManager.stopAll()` SIGTERMs every child. On non-graceful shutdown (SIGKILL, crash, OOM, force-quit), spawned code-server processes get reparented to init/launchd and continue holding their port and `--user-data-dir` lockfile.
943
+
944
+ To recover, every spawn is recorded in `~/.pi/dashboard/editor-pids.json` (`editor-pid-registry.ts`). On the next server boot, `editorPidRegistry.cleanupOrphans()` runs at the top of `server.start()` (before `fastify.listen`) and:
945
+
946
+ 1. Reads the persisted PIDs.
947
+ 2. For each entry whose PID is alive AND whose OS-reported command line contains `--user-data-dir <~/.pi/dashboard/editors/...>`, sends `SIGTERM`.
948
+ 3. After a 1 second grace period, sends `SIGKILL` to any survivor.
949
+ 4. Rewrites the file empty.
950
+
951
+ The cmdline ownership check prevents killing unrelated `code-server` instances the user may run themselves. Cleanup completes before any `POST /api/editor/start` request can be served, so a new spawn for the same folder cannot race with a surviving orphan on the same `--user-data-dir` lockfile.
952
+
641
953
  ### Configuration
642
954
 
643
955
  ```json
@@ -697,3 +1009,190 @@ These call the same internal methods as the browser-gateway WebSocket handlers
697
1009
  - `references/api-reference.md` — Complete REST API documentation
698
1010
  - `references/recipes.md` — Multi-step orchestration patterns (spawn→prompt→monitor, batch operations, health checks)
699
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`.