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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/AGENTS.md +87 -114
  2. package/README.md +408 -430
  3. package/docs/architecture.md +465 -12
  4. package/package.json +10 -5
  5. package/packages/extension/package.json +14 -4
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +40 -8
  7. package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
  8. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  9. package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
  10. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
  11. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  12. package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
  13. package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
  14. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  15. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  16. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  17. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  18. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  19. package/packages/extension/src/ask-user-tool.ts +5 -4
  20. package/packages/extension/src/bridge.ts +171 -17
  21. package/packages/extension/src/dev-build.ts +1 -1
  22. package/packages/extension/src/git-info.ts +9 -19
  23. package/packages/extension/src/multiselect-list.ts +146 -0
  24. package/packages/extension/src/multiselect-polyfill.ts +43 -0
  25. package/packages/extension/src/pi-env.d.ts +1 -0
  26. package/packages/extension/src/process-scanner.ts +72 -38
  27. package/packages/extension/src/provider-register.ts +304 -16
  28. package/packages/extension/src/server-auto-start.ts +27 -1
  29. package/packages/extension/src/server-launcher.ts +83 -27
  30. package/packages/server/package.json +16 -2
  31. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  32. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  33. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  34. package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
  35. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  36. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  37. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  38. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  39. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  40. package/packages/server/src/__tests__/editor-registry.test.ts +28 -15
  41. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  42. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  43. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  44. package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
  45. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  46. package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
  47. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  48. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  49. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  50. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  51. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  52. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
  53. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  54. package/packages/server/src/__tests__/pi-version-skew.test.ts +237 -0
  55. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  56. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  57. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  58. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  59. package/packages/server/src/__tests__/restart-helper.test.ts +111 -0
  60. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  61. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  62. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  63. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  64. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  65. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  66. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  67. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  68. package/packages/server/src/__tests__/tunnel.test.ts +13 -7
  69. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  70. package/packages/server/src/bootstrap-queue.ts +130 -0
  71. package/packages/server/src/bootstrap-state.ts +131 -0
  72. package/packages/server/src/browse.ts +8 -3
  73. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  74. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  75. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  76. package/packages/server/src/cli.ts +310 -39
  77. package/packages/server/src/config-api.ts +16 -0
  78. package/packages/server/src/directory-service.ts +270 -39
  79. package/packages/server/src/editor-detection.ts +12 -9
  80. package/packages/server/src/editor-manager.ts +19 -4
  81. package/packages/server/src/editor-pid-registry.ts +9 -8
  82. package/packages/server/src/editor-registry.ts +22 -25
  83. package/packages/server/src/git-operations.ts +1 -1
  84. package/packages/server/src/headless-pid-registry.ts +7 -20
  85. package/packages/server/src/home-lock-release.ts +72 -0
  86. package/packages/server/src/home-lock.ts +389 -0
  87. package/packages/server/src/node-guard.ts +52 -0
  88. package/packages/server/src/package-manager-wrapper.ts +207 -47
  89. package/packages/server/src/pi-core-checker.ts +1 -1
  90. package/packages/server/src/pi-core-updater.ts +7 -1
  91. package/packages/server/src/pi-resource-scanner.ts +5 -8
  92. package/packages/server/src/pi-version-skew.ts +207 -0
  93. package/packages/server/src/preferences-store.ts +17 -3
  94. package/packages/server/src/process-manager.ts +403 -222
  95. package/packages/server/src/provider-probe.ts +234 -0
  96. package/packages/server/src/restart-helper.ts +141 -0
  97. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  98. package/packages/server/src/routes/openspec-routes.ts +25 -1
  99. package/packages/server/src/routes/pi-core-routes.ts +24 -1
  100. package/packages/server/src/routes/provider-auth-routes.ts +8 -8
  101. package/packages/server/src/routes/provider-routes.ts +43 -0
  102. package/packages/server/src/routes/recommended-routes.ts +10 -12
  103. package/packages/server/src/routes/system-routes.ts +20 -33
  104. package/packages/server/src/routes/tool-routes.ts +153 -0
  105. package/packages/server/src/server-pid.ts +5 -9
  106. package/packages/server/src/server.ts +211 -10
  107. package/packages/server/src/session-api.ts +77 -8
  108. package/packages/server/src/session-bootstrap.ts +17 -3
  109. package/packages/server/src/session-diff.ts +21 -21
  110. package/packages/server/src/terminal-manager.ts +61 -20
  111. package/packages/server/src/tunnel.ts +42 -28
  112. package/packages/shared/package.json +10 -3
  113. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  114. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  115. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  116. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  117. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  118. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  119. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  120. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  121. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  122. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  123. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  124. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  125. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  126. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  127. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  128. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  129. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  130. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  131. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  132. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  133. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  134. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  135. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  136. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  137. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  138. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  139. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  140. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  141. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  142. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  143. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  144. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  145. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  146. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  147. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  148. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  149. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  150. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  151. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  152. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  153. package/packages/shared/src/__tests__/config.test.ts +56 -0
  154. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  155. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  156. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  157. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  158. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  159. package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
  160. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
  161. package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
  162. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  163. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  164. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  165. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  166. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  167. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  168. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  169. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  170. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  171. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  172. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  173. package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
  174. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  175. package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
  176. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  177. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  178. package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
  179. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  180. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  181. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  182. package/packages/shared/src/bootstrap-install.ts +212 -0
  183. package/packages/shared/src/bridge-register.ts +87 -20
  184. package/packages/shared/src/browser-protocol.ts +71 -1
  185. package/packages/shared/src/config.ts +87 -15
  186. package/packages/shared/src/managed-paths.ts +31 -4
  187. package/packages/shared/src/openspec-poller.ts +63 -46
  188. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  189. package/packages/shared/src/platform/commands.ts +100 -0
  190. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  191. package/packages/shared/src/platform/exec.ts +220 -0
  192. package/packages/shared/src/platform/git.ts +155 -0
  193. package/packages/shared/src/platform/index.ts +16 -0
  194. package/packages/shared/src/platform/node-spawn.ts +154 -0
  195. package/packages/shared/src/platform/npm.ts +162 -0
  196. package/packages/shared/src/platform/openspec.ts +91 -0
  197. package/packages/shared/src/platform/paths.ts +276 -0
  198. package/packages/shared/src/platform/process-identify.ts +126 -0
  199. package/packages/shared/src/platform/process-scan.ts +94 -0
  200. package/packages/shared/src/platform/process.ts +168 -0
  201. package/packages/shared/src/platform/runner.ts +369 -0
  202. package/packages/shared/src/platform/shell.ts +44 -0
  203. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  204. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  205. package/packages/shared/src/protocol.ts +23 -0
  206. package/packages/shared/src/recommended-extensions.ts +18 -2
  207. package/packages/shared/src/resolve-jiti.ts +62 -3
  208. package/packages/shared/src/rest-api.ts +26 -0
  209. package/packages/shared/src/semaphore.ts +83 -0
  210. package/packages/shared/src/state-replay.ts +9 -0
  211. package/packages/shared/src/tool-registry/definitions.ts +434 -0
  212. package/packages/shared/src/tool-registry/index.ts +56 -0
  213. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  214. package/packages/shared/src/tool-registry/registry.ts +262 -0
  215. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  216. package/packages/shared/src/tool-registry/types.ts +180 -0
@@ -158,13 +158,85 @@ 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
+ The pinned range is `minimum: "0.70.0"`, `recommended: "0.70.0"`,
204
+ `maximum: null` — deliberately in lockstep. The dashboard does NOT carry
205
+ backward-compatibility shims for older pi releases; one supported pi
206
+ means no conditional code paths in the bridge and no dual-import
207
+ fallbacks (e.g. `@sinclair/typebox` vs `typebox`). Bumping `recommended`
208
+ in a future change SHOULD be matched by an equal bump to `minimum` and a
209
+ lockstep bump of the offline-bundled pi version in
210
+ `packages/electron/offline-packages.json`.
211
+
212
+ The CLI also surfaces skew on stderr at startup: `cli.ts::logCompatibilityWarning` emits a three-line red block on below-minimum (including the exact `pi-dashboard upgrade-pi` remediation command) and a single advisory line on below-recommended. Silent when in range. This is in addition to the browser banner and the 503 gating, so terminal-only users (headless servers, CI) don't miss the signal. Note: `readCurrentPiVersion` uses `fs.realpathSync` on the registry-resolved bin path so the common npm-global symlink layout (`~/.nvm/.../bin/pi` → `../lib/node_modules/@mariozechner/pi-coding-agent/dist/cli.js`) resolves to the real `package.json` — without this, `compatibility.current` was silently `undefined` in every response.
213
+
214
+ See changes: `unified-bootstrap-install`, `pi-zero-seventy-compat`, `warn-pi-version-skew-in-cli`.
215
+
161
216
  ### Force Kill Escalation
162
217
  The Stop button supports two-click escalation for stuck sessions:
163
218
  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.
219
+ 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:
220
+ - 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),
221
+ - on **POSIX** sends `SIGTERM`, polls liveness every 200ms for up to 2s, then escalates to `SIGKILL` if the process is still alive.
222
+
223
+ Session marked "ended" (not removed), resumable via fork/continue.
165
224
 
166
225
  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
226
 
227
+ ### Platform-routed kill paths
228
+ 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:
229
+
230
+ | Helper | POSIX | Windows |
231
+ |--------|-------|---------|
232
+ | `isProcessAlive(pid)` | `kill(pid, 0)` | same |
233
+ | `killProcess(pid, {timeoutMs})` | SIGTERM → wait → SIGKILL (tree via pgroup) | `taskkill /F /T /PID <pid>` |
234
+ | `killPidWithGroup(pid, sig)` | `kill(-pid, sig)` (process group) | `kill(pid, sig)` (leaf) |
235
+
236
+ 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).
237
+
238
+ `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.
239
+
168
240
  Inline stop buttons also appear on running tool cards in `ToolCallStep`, providing contextual abort access right where the stuck command is visible.
169
241
 
170
242
  ### Repeated Tool Call Collapsing
@@ -183,6 +255,30 @@ Consecutive tool calls with the same name and identical args (e.g. health check
183
255
  - `index.ts`: `flow:abort` and `flow:toggle-autonomous` event listeners added
184
256
  - `flow-tui.ts`: `autonomousMode` included in `flow:flow-started` event data
185
257
 
258
+ ### `/reload` Flow (two code paths)
259
+ 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:
260
+
261
+ ```mermaid
262
+ flowchart TD
263
+ A[Browser sends send_prompt text="/reload"] --> B[server handleSendPrompt]
264
+ B --> C{shouldInterceptReload?<br/>text === "/reload"<br/>no images<br/>headlessPidRegistry.getPid defined}
265
+ C -->|Yes — headless session| D[handleHeadlessReload]
266
+ D --> D1[Emit command_feedback 'started']
267
+ D1 --> D2[headlessPidRegistry.killBySessionId<br/>SIGTERMs old pi]
268
+ D2 --> D3[spawnPiSession with<br/>sessionFile+mode:'continue'<br/>strategy:'headless']
269
+ D3 --> D4[headlessPidRegistry.register new PID]
270
+ D4 --> D5[Emit command_feedback 'completed']
271
+ D5 --> D6[New pi bridge re-registers<br/>with same sessionId —<br/>sessionManager preserves<br/>tokens/cost/context/attachedProposal]
272
+ C -->|No — tmux/wt/wsl-tmux| E[piGateway.sendToSession→bridge]
273
+ E --> F[bridge command-handler parses /reload]
274
+ F --> G[Calls globalThis-RELOAD_KEY fn]
275
+ G --> H{Was /__dashboard_reload<br/>typed in TUI first?}
276
+ H -->|Yes| I[session.reload in-place]
277
+ H -->|No| J[Error logged to bridge stderr<br/>User must bootstrap via TUI]
278
+ ```
279
+
280
+ **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.
281
+
186
282
  ### Auto-Resume on Prompt
187
283
  When a user sends a prompt to an ended session, the server automatically resumes it:
188
284
  1. Server detects `send_prompt` for a session with `status === "ended"` and a valid `sessionFile`
@@ -229,13 +325,32 @@ When a user sends a prompt to an ended session, the server automatically resumes
229
325
  7. Session cards display processes with elapsed time and a kill button (sends SIGTERM to process group)
230
326
 
231
327
  ### 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
328
+ 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).
329
+ 2. OpenSpec data is keyed by directory (cwd), not by session — one poll per directory regardless of session count.
330
+ 3. Changes are broadcast to all connected browsers via `openspec_update { cwd, data }`.
331
+ 4. Browsers can request immediate refresh via `openspec_refresh { cwd }`. Force-refresh **bypasses the mtime gate** but still respects the concurrency cap.
332
+ 5. New directories (pinned or from new sessions) trigger immediate discovery + polling (eager; bypasses jitter + mtime gate).
237
333
  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
334
 
335
+ #### OpenSpec polling cost model
336
+
337
+ 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.
338
+
339
+ The scheduler in `packages/server/src/directory-service.ts` applies four layers of throttling (all configurable under `DashboardConfig.openspec`):
340
+
341
+ 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.
342
+ 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.
343
+ 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.
344
+ 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).
345
+
346
+ 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.
347
+
348
+ 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.
349
+
350
+ 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.
351
+
352
+ 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.
353
+
239
354
  ### OpenSpec session card UI
240
355
 
241
356
  The attached-change row on every session card has four affordances driven by the polled `OpenSpecChange`:
@@ -364,7 +479,7 @@ The server has a two-layer access model:
364
479
 
365
480
  **Layer 1: Network Guard (`createNetworkGuard`)** — Fastify `preHandler` on all sensitive routes. Allows requests via three paths:
366
481
  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.
482
+ 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
483
  3. **Authenticated** — `request.isAuthenticated === true` (set by auth `onRequest` hook via `decorateRequest`)
369
484
 
370
485
  Otherwise → 403. The guard strips `::ffff:` IPv4-mapped prefixes before matching.
@@ -396,7 +511,7 @@ Optional OAuth2 authentication protects the dashboard when accessed remotely.
396
511
  ### Settings Panel
397
512
  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
513
  1. Loads config via `GET /api/config` (secrets redacted as `***`)
399
- 2. Renders grouped form fields: Server, Sessions, Tunnel, Trusted Networks, Authentication, Developer
514
+ 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
515
  3. Sends only changed fields via `PUT /api/config` (partial merge)
401
516
  4. Server preserves `***` secrets (doesn't overwrite real values), writes to disk, and applies runtime-safe changes
402
517
  5. Port/piPort changes flag `restartRequired` in the response
@@ -447,6 +562,18 @@ When a session sends `flows_list`, the server notifies other sessions in the sam
447
562
  ### Event Broadcast During Replay
448
563
  During bridge session replay (while `replayingSessions` set contains the session), `event_forward` messages are stored but NOT broadcast individually to browser subscribers. Instead, when `replay_complete` arrives (or the 5s safety timeout fires), the server sends all accumulated events as a single `event_replay` batch to subscribers. This prevents per-event serialization overhead during replay while still delivering the full history to browsers.
449
564
 
565
+ ### Per-message entry id stamping (live vs replay)
566
+
567
+ The per-message ⤘ Fork button needs each chat bubble to carry the entry id of the entry it represents in the persisted JSONL. Two paths populate this:
568
+
569
+ - **Replay path** (`packages/shared/src/state-replay.ts`): reads from the persisted JSONL directly, so each `message_start` / `message_end` event carries the stable `entryId` from the source entry. No back-fill needed.
570
+ - **Live path** (`packages/extension/src/bridge.ts`): pi 0.69+ awaits extension handlers BEFORE calling `sessionManager.appendMessage`, which means an entry id does NOT exist at the bridge's emit time. The bridge instead:
571
+ 1. Stamps a per-event `nonce` on `message_start` / `message_end` events so the client can correlate later.
572
+ 2. Defers the `message_end` SEND via `setTimeout(0)` (a macrotask) so pi's awaited dispatcher unwinds and `appendMessage` runs in between — by the time the timeout fires, pi has mutated `event.message.id` in place.
573
+ 3. Wraps `ctx.sessionManager.appendMessage` once per session at `session_start`. After a successful append, the wrapper emits an `entry_persisted { entryId, nonce }` event so the client reducer back-fills the matching ChatMessage's `entryId` (covers user messages, whose `message_start` fires before persistence).
574
+
575
+ `queueMicrotask` was used previously but no longer works: on pi 0.69+ the microtask resolves *inside* the awaited `_emitExtensionEvent`, before persistence. See change `fix-per-message-fork`.
576
+
450
577
  ## Persistence
451
578
 
452
579
  | Data | Storage | Details |
@@ -552,6 +679,91 @@ The `POST /api/restart` endpoint and `pi-dashboard restart` command perform faul
552
679
 
553
680
  The restart endpoint accepts `{ dev: boolean }` to switch between dev/production mode.
554
681
 
682
+ ### Cross-Platform Server Launch
683
+
684
+ The dashboard server is spawned via `node --import <loader> <cli.ts>` from four call sites (`packages/server/src/cli.ts` `cmdStart`, `packages/extension/src/server-launcher.ts` `launchServer`, `packages/electron/src/lib/server-lifecycle.ts` `launchServer`, `packages/server/src/restart-helper.ts` `buildOrchestratorScript`). On Node ≥ 20, Windows's ESM loader parses **both** the `--import` loader position AND the entry-script position as URLs. A raw Windows path like `B:\Dev\cli.ts` parses with scheme `b:` (not in the ESM loader's `file`/`data`/`node` allowlist) and crashes with `ERR_UNSUPPORTED_ESM_URL_SCHEME`. Node has a drive-letter heuristic that auto-wraps common Windows paths with `file://` before the URL parse in the entry-script position, but the heuristic has known gaps for less-common drives (`A:`, `B:`, ...), so reliance on it is unsafe.
685
+
686
+ Both positions are wrapped as `file://` URLs universally:
687
+
688
+ - `packages/shared/src/platform/node-spawn.ts` — `toFileUrl(pathOrUrl)` (idempotent path → file:// URL, handles Windows drive letters on POSIX hosts) and `spawnNodeScript(opts)` (wraps both loader and entry before delegating to `platform/exec.ts::spawn`). This is the canonical chokepoint.
689
+ - `packages/shared/src/resolve-jiti.ts` — `resolveJitiImport()` and `resolveJitiFromAnchor(anchorPath)` return `pathToFileURL(registerPath).href` for the loader position.
690
+ - `packages/server/src/cli.ts` — routes through `spawnNodeScript`.
691
+ - `packages/extension/src/server-launcher.ts`, `packages/electron/src/lib/server-lifecycle.ts`, `packages/server/src/restart-helper.ts` — wrap the entry `cliPath` with `toFileUrl(cliPath)` before argv construction.
692
+
693
+ The URL form is cross-platform safe (Linux/macOS accept `file://` URLs identically to raw paths), so no platform gating is needed. A repo-level lint test (`packages/shared/src/__tests__/no-raw-node-import.test.ts`) refuses any new call site that passes a raw identifier as argv after `--import` / `--loader`, preventing regression. Mirrors the `platform/exec.ts` + `no-direct-child-process.test.ts` pattern. See changes: `fix-windows-server-parity` (loader position), `fix-windows-entry-script-url` (entry-script position).
694
+
695
+ #### stdout + stderr capture parity
696
+
697
+ 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`.
698
+
699
+ #### CJS preload for Fastify (nodejs/node#58515 mitigation)
700
+
701
+ 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.
702
+
703
+ 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.
704
+
705
+ 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`.
706
+
707
+ #### Node-version preflight
708
+
709
+ `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:
710
+
711
+ - **CLI** (`cmdStart`) — emits a warning to stderr and appends it to `server.log` before spawning. Advisory only; CLI still proceeds.
712
+ - **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.
713
+ - **Electron doctor** (`doctor.ts`) — "Node runtime compatibility" row shows `warning` with upgrade guidance.
714
+
715
+ `packages/server/package.json` declares `"engines": { "node": ">=22.18.0 <23 || >=24.3.0" }` as an npm-level advisory.
716
+
717
+ ### Cross-OS Platform Primitives
718
+
719
+ 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:
720
+
721
+ | File | Concerns |
722
+ |---|---|
723
+ | `binary-lookup.ts` | `where`/`which` dispatch, `.cmd` extension on Windows, managed-bin search, login-shell fallback. Exports `ToolResolver` class + pi/tsx/node resolve helpers. |
724
+ | `process.ts` | `findPortHolders` (netstat vs lsof), `killProcess` (taskkill tree on Windows, SIGTERM→SIGKILL on Unix), `isProcessAlive`, `killPidWithGroup` (negative-pid on Unix, positive on Windows). |
725
+ | `process-scan.ts` | `isProcessRunning` (tasklist vs pgrep), pure `parseEtime`. |
726
+ | `shell.ts` | `detectShell` (COMSPEC on Windows, SHELL on Unix, with fallbacks), `getTerminalEnvHints` (TERM=cygwin hint for node-pty on Windows). |
727
+ | `commands.ts` | `openBrowser` (`open`/`start`/`xdg-open`), `isVirtualMachine` (`sysctl`/`systemd-detect-virt`/`wmic`). |
728
+ | `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?). |
729
+ | `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. |
730
+ | `process-identify.ts` | `findPidByMarker` + `isProcessLikePi` + `isPiCommandLine`. Unix implementations run `ps`/`/proc`; Windows stubs return empty/true because command-line lookup is delegated to `headlessPidRegistry`. |
731
+
732
+ 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.
733
+
734
+ **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).
735
+
736
+ 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.
737
+
738
+ ### Session spawn dispatch
739
+
740
+ Session spawning uses a two-tier type system:
741
+
742
+ - **`SpawnStrategy`** (user-visible, in `shared/config.ts`): `"tmux" | "headless"`. What the user wrote in their config.
743
+ - **`SpawnMechanism`** (internal, in `platform/spawn-mechanism.ts`): `"tmux" | "wt" | "wsl-tmux" | "headless"`. What the system actually runs on this platform given availability.
744
+
745
+ `selectMechanism({ platform, userStrategy, electronMode, available })` is the single pure function that maps (config, platform, availability) → mechanism. Rules:
746
+
747
+ 1. `electronMode` → `headless`.
748
+ 2. `userStrategy === "headless"` → `headless`.
749
+ 3. Unix with tmux → `tmux`; Unix without → `headless`.
750
+ 4. Windows: `wt` if available, else `wsl-tmux` if available, else `headless`.
751
+
752
+ 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.
753
+
754
+ 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.
755
+
756
+ ### Server Log Hygiene
757
+
758
+ 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:
759
+
760
+ ```
761
+ [2026-04-18T14:30:00.000Z] pi-dashboard start (parent pid 12345, port 8000)
762
+ [2026-04-18T14:30:02.000Z] bridge auto-start (parent pid 23456, port 8000)
763
+ ```
764
+
765
+ 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.
766
+
555
767
  ### Auto-Start Flow
556
768
 
557
769
  When `autoStart` is `true` (default), the bridge extension automatically starts the dashboard server:
@@ -612,9 +824,21 @@ The dashboard uses mDNS (via `bonjour-service`) for zero-config server discovery
612
824
  ### Server Selector UI
613
825
  - The header dropdown shows persisted known servers (from config) plus localhost, not raw mDNS results
614
826
  - Each entry shows label (or hostname), host:port, Local/Remote badge, and availability status
615
- - Non-current servers are probed via health check when the dropdown opens
616
- - Switching closes the current WebSocket and connects to the selected server
617
- - Last-used server persisted in `localStorage` (`pi-dashboard-last-server`)
827
+ - **Probe lifecycle**: availability is probed via `/api/health` **only when the dropdown opens** — once per open. No mount probe, no timer, no probing while the dropdown is closed. Current-server status is derived from the live WebSocket state, not a separate probe.
828
+ - **Unreachable entries** are rendered with `opacity-50`, `cursor-not-allowed`, and the `disabled` attribute set; clicks are no-ops. To re-probe, close and reopen the dropdown. The transactional switch (below) still protects against races between the last probe and a click on a reachable entry.
829
+ - Last-used server persisted in `localStorage` (`pi-dashboard-last-server`) — **only after** a successful switch (see transactional switching below).
830
+
831
+ ### Transactional Server Switching
832
+ Switching servers is a two-phase transaction that never destructs state before verifying the target is reachable. Implemented by `performServerSwitch` (`packages/client/src/lib/server-switch.ts`) + `openStagingSocket` (`packages/client/src/lib/staging-socket.ts`):
833
+
834
+ 1. **Stage**: open a second ("staging") WebSocket to the target URL with a 5-second timeout. The live WebSocket stays connected.
835
+ 2. **Commit (on staging `OPEN`)**: close the staging socket, clear in-memory session/command/flow/openspec/terminal state, call `setWsUrl(newUrl)` so `useWebSocket` reconnects, and **only then** write `localStorage["pi-dashboard-last-server"]`.
836
+ 3. **Abort (on staging error/timeout)**: close the staging socket, show a toast "Couldn't reach &lt;host&gt;", leave the live connection and state untouched. localStorage is not written — so a subsequent refresh still recovers the last-known-good server.
837
+
838
+ An `inFlightSwitchKey` ref guards against duplicate clicks; the clicked dropdown entry renders a spinner while staging is in progress. The `POST /api/config { lastServer }` fire-and-forget call was removed as dead weight (no consumer read the field).
839
+
840
+ ### Connection Status Banner
841
+ `ConnectionStatusBanner` (`packages/client/src/components/ConnectionStatusBanner.tsx`) mounts above `<MobileShell>`. It shows "Disconnected from &lt;host&gt;. Retrying…" when the active WebSocket has been non-`OPEN` for more than 3 seconds continuously. The threshold is implemented via `setTimeout` cleared on any return-to-`OPEN` or unmount, so brief reconnects (laptop sleep, wifi hiccup) never flash the banner. During an in-flight staging switch the banner is suppressed — the live socket is still open, so no disconnection has actually occurred.
618
842
 
619
843
  ### Server Management (Settings Panel)
620
844
  - **Known Servers section**: lists persisted servers with remove buttons and an inline add form (host, port, label)
@@ -633,7 +857,23 @@ The dashboard supports browser-based authentication with pi's LLM providers, ena
633
857
  3. **Device-code flow** (GitHub Copilot): server requests device code → UI shows user code + verification URL → server polls until authorized
634
858
  4. **API key flow**: user pastes key in Settings → saved directly
635
859
  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
860
+ 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
861
+
862
+ ### Model metadata enrichment for custom providers
863
+
864
+ 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`.
865
+
866
+ ### Testing a custom provider (Test button)
867
+
868
+ 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:
869
+
870
+ | API type | Probe |
871
+ |----------|-------|
872
+ | `openai-completions` / `openai-responses` | `GET {baseUrl}/models` with `Authorization: Bearer <apiKey>` |
873
+ | `anthropic-messages` | `GET {baseUrl}/v1/models` with `x-api-key` + `anthropic-version: 2023-06-01` |
874
+ | `google-generative-ai` | `GET {baseUrl}/models?key=<apiKey>` |
875
+
876
+ 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
877
 
638
878
  ### Key Files
639
879
 
@@ -679,6 +919,16 @@ This is separate from the main JSON dashboard WebSocket (`/ws`).
679
919
 
680
920
  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
921
  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.
922
+
923
+ ### Bundled first-party extensions (Electron installer)
924
+
925
+ 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.
926
+
927
+ **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.
928
+
929
+ **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`).
930
+
931
+ **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
932
  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
933
 
684
934
  A regression test (`packages/server/src/__tests__/fix-pty-permissions.test.ts`) asserts the current platform's helper is executable after install.
@@ -797,3 +1047,206 @@ These call the same internal methods as the browser-gateway WebSocket handlers
797
1047
  - `references/api-reference.md` — Complete REST API documentation
798
1048
  - `references/recipes.md` — Multi-step orchestration patterns (spawn→prompt→monitor, batch operations, health checks)
799
1049
  - `scripts/dashboard-api.sh` — curl wrapper with port detection, optional auth token, graceful jq fallback
1050
+
1051
+ ## Tool Resolution (`ToolRegistry`)
1052
+
1053
+ 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.
1054
+
1055
+ ### Registered tools
1056
+
1057
+ | Tool | Kind | Strategy chain |
1058
+ |---|---|---|
1059
+ | `pi` | binary | override → managed (`MANAGED_BIN/pi[.cmd]`) → where |
1060
+ | `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 |
1061
+ | `openspec`, `npm`, `node`, `tsx`, `git`, `zrok` | binary | override → managed → where |
1062
+ | `pi-dashboard` | module | override → managed → npm-global (presence of `package.json` is enough) |
1063
+ | `electron` | module | override → bare-import (`paths: ["packages/electron"]`) → managed; resolves the package directory containing `install.js`, hoist-aware. See change: register-build-time-tools |
1064
+ | `node-pty` | module | override → bare-import; resolves the package directory containing `prebuilds/`. See change: register-build-time-tools |
1065
+
1066
+ ### Build-time consumers (shell-callable wrapper)
1067
+
1068
+ CI workflows, Dockerfiles, and root-level postinstall scripts cannot import the shared package's TypeScript directly — those run before any TS build has fired (or, for postinstall, before the shared package is even unpacked). For these consumers, `packages/shared/bin/pi-dashboard-resolve-tool.cjs` provides a CommonJS, dependency-free shell wrapper that mirrors the registry's `override` + `bare-import` strategies for the build-time tool subset (`electron`, `node-pty`):
1069
+
1070
+ ```bash
1071
+ # Resolve a build-time tool from any shell context
1072
+ ELECTRON_DIR=$(node packages/shared/bin/pi-dashboard-resolve-tool.cjs electron)
1073
+ cd "$ELECTRON_DIR" && node install.js
1074
+ ```
1075
+
1076
+ The wrapper is used by `.github/workflows/publish.yml` (linux/arm64 native rebuild) and `packages/electron/scripts/Dockerfile.build` (Docker cross-platform native rebuild). The root postinstall `scripts/fix-pty-permissions.cjs` reimplements the same `bare-import` semantics inline (it cannot shell out because it runs DURING `npm install`).
1077
+
1078
+ Reintroduction of hardcoded `node_modules/<dep>` paths in any of these sites is blocked by the lint test at `packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts`.
1079
+
1080
+ ### Resolution record
1081
+
1082
+ `registry.resolve(name)` returns a `Resolution` with:
1083
+
1084
+ - `ok` — whether any strategy succeeded
1085
+ - `path` / `source` — winning path and its classification (`override`, `managed`, `system`, `npm-global`, `bare-import`)
1086
+ - `tried[]` — ordered trail: `[{ strategy, result }]` where `result` is `"ok"` on success or the strategy's failure reason
1087
+ - `resolvedAt` — epoch ms
1088
+
1089
+ ### Overrides
1090
+
1091
+ User-set overrides live at `~/.pi/dashboard/tool-overrides.json`:
1092
+
1093
+ ```json
1094
+ {
1095
+ "version": 1,
1096
+ "overrides": {
1097
+ "pi": { "path": "C:\custom\pi.cmd" },
1098
+ "pi-coding-agent": { "path": "D:\dev\pi-coding-agent\dist\index.js" }
1099
+ }
1100
+ }
1101
+ ```
1102
+
1103
+ 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.
1104
+
1105
+ ### Caching
1106
+
1107
+ - One `Resolution` per tool, cached in the registry instance.
1108
+ - Loaded ES modules (for `kind: "module"`) cached alongside.
1109
+ - `registry.rescan(name?)` invalidates one or all entries + re-reads the overrides file.
1110
+ - The runner's old `resolverCache` and `npm.ts`'s old `cachedGlobalRoot` are gone — the registry owns caching now.
1111
+
1112
+ ### REST API (`/api/tools`)
1113
+
1114
+ Guarded by the same network guard as `/api/config`.
1115
+
1116
+ | Endpoint | Purpose |
1117
+ |---|---|
1118
+ | `GET /api/tools` | Snapshot of every registered tool's Resolution |
1119
+ | `GET /api/tools/:name` | Single Resolution (404 for unregistered) |
1120
+ | `POST /api/tools/rescan` | Invalidate all caches (body empty) or one (`{ name }`) + return refreshed list |
1121
+ | `PUT /api/tools/:name` | Set an override (`{ path }`) + return refreshed Resolution |
1122
+ | `DELETE /api/tools/:name` | Clear the override + return refreshed Resolution |
1123
+ | `POST /api/tools/diagnostics` | Plain-text export — one block per tool with the full `tried[]` trail, for bug reports |
1124
+
1125
+ ### Settings UI
1126
+
1127
+ 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**.
1128
+
1129
+ ### Migration path
1130
+
1131
+ `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.
1132
+
1133
+ See change: `consolidate-tool-resolution`.
1134
+
1135
+ ### Testing the bootstrap state space
1136
+
1137
+ 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:
1138
+
1139
+ ```
1140
+ 3 platforms (win32, darwin, linux)
1141
+ × 5 dash-locations (electron, npm-g, dev, managed, absent)
1142
+ × 6 pi-states (absent, present-no-ext, present-stale-ext, present-valid, malformed, appimage-tmp)
1143
+ × 4 settings-states (missing, empty, valid, malformed)
1144
+ × 3 env-states (normal, spaces-unicode, home-drift)
1145
+ = 1080 cells
1146
+ ```
1147
+
1148
+ 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.
1149
+
1150
+ 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.
1151
+
1152
+ Key locked-in invariants (from current snapshots):
1153
+
1154
+ - Unix pi chain: `override → managed-bin → where` (no bare-import, no npm-g — a real limitation for GUI-launched minimal-PATH scenarios).
1155
+ - Win32 pi chain: 5-level fallback including the no-cmd-flash `.cmd` probe and `node.exe` prepend for `.js` targets.
1156
+ - Override strategy is first in every chain; invalid overrides fall through with `invalid: ...` reason.
1157
+ - Path normalization cross-OS via `<HOME>` / `<NPM_ROOT>` placeholders — snapshots stable on macOS and Linux CI.
1158
+ - **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.
1159
+
1160
+ See change: `bootstrap-resolution-harness`. Full walkthrough in `packages/shared/src/__tests__/bootstrap/README.md`.
1161
+
1162
+ ## Path Handling (`platform/paths.ts`)
1163
+
1164
+ 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.
1165
+
1166
+ ### Primitives
1167
+
1168
+ | Function | Purpose |
1169
+ |---|---|
1170
+ | `normalizePath(p, platform?)` | Canonical form for storage/comparison: OS-native separator, trailing sep stripped (except roots), `..`/`.` resolved, case preserved. |
1171
+ | `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. |
1172
+ | `parsePathInput(value, platform?)` | Split user-typed input into `{ parent, partial }` — handles Windows drive-letter roots, UNC roots, Unix roots, mixed separators. |
1173
+ | `withTrailingSep(p, platform?)` | Append OS-native separator if not already terminated. |
1174
+ | `isFilesystemRoot(p, platform?)` | True for `/`, `C:\`, `\server\share\` uniformly — replaces `resolved === "/"` checks that only recognized Unix roots. |
1175
+
1176
+ ### Platform injection pattern
1177
+
1178
+ 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.
1179
+
1180
+ ### Windows multi-drive invariants
1181
+
1182
+ | Drive letter | Contract |
1183
+ |---|---|
1184
+ | A:, B:, C:, …, Z: | each a distinct filesystem root |
1185
+ | `B:\` vs `b:\` | case-insensitive (match) |
1186
+ | `A:\Foo` vs `B:\Foo` | never match (different drives) |
1187
+ | `\server\share` vs `B:` | never match |
1188
+ | Bare `B:` input | treated as `B:\`, not cwd-relative |
1189
+ | `B:Dev` input | drive root + partial (defensive) |
1190
+ | `B:/Dev/BB` (fwd slash) | canonicalizes to `B:\Dev\BB` |
1191
+ | Browse at `B:\` | `parent: null` (root is its own dead-end) |
1192
+
1193
+ ### Protocol extension
1194
+
1195
+ `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).
1196
+
1197
+ ### Common gotcha: `Array.prototype.map(normalizePath)`
1198
+
1199
+ `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)`.
1200
+
1201
+ See change: `platform-path-normalization`.
1202
+
1203
+ ## Chat Input State (drafts & history recall)
1204
+
1205
+ ### Per-session draft persistence
1206
+
1207
+ 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:
1208
+
1209
+ 1. **Hydrated** once at mount from `localStorage` via `readAllDrafts()` (scans for the `chat-draft:` key prefix).
1210
+ 2. **Persisted** (debounced ~300 ms) on change: new / changed keys go through `writeDraft(sid, text)`, removed keys and empty values go through `deleteDraft(sid)`.
1211
+ 3. **Cleared eagerly on send** (`wrappedHandleSend` → `clearDraftForSession(selectedId)`) so a reload immediately after sending does not resurrect the sent prompt.
1212
+
1213
+ ```
1214
+ localStorage
1215
+ ├── chat-draft:<sessionId-A> "half-typed foo"
1216
+ ├── chat-draft:<sessionId-B> "another draft"
1217
+ └── ...
1218
+ ```
1219
+
1220
+ This solves two bugs at once:
1221
+ - **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.
1222
+ - **Draft leakage between sessions**: keying by `sessionId` means each session has its own draft cell; switching flips the `draft` prop, never bleeding text across.
1223
+
1224
+ 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.
1225
+
1226
+ ### History recall (ArrowUp / ArrowDown)
1227
+
1228
+ 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.
1229
+
1230
+ Inside `CommandInput`, history navigation uses a small state machine:
1231
+
1232
+ ```
1233
+ historyIndex: number | null — null = not in history mode
1234
+ savedDraftRef: useRef<string> — in-progress draft captured when history mode is first entered
1235
+
1236
+ ArrowUp (caret on first line, no dropdown, no pending, history.length > 0)
1237
+ null ─────────────────────────────────────────▶ 0 (save current text first)
1238
+ k ─────────────────────────────────────────▶ min(k+1, len-1)
1239
+ ArrowDown (caret on last line, no dropdown, historyIndex != null)
1240
+ k > 0 ─────────────────────────────────────────▶ k - 1
1241
+ 0 ─────────────────────────────────────────▶ null (restore savedDraftRef)
1242
+ Escape (historyIndex != null)
1243
+ k ─────────────────────────────────────────▶ null (restore savedDraftRef)
1244
+ any text edit while historyIndex != null
1245
+ k ─────────────────────────────────────────▶ null (user now editing; no restore)
1246
+ sessionId change
1247
+ null, savedDraftRef = ""
1248
+ ```
1249
+
1250
+ **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.
1251
+
1252
+ See change: `chat-input-draft-and-history`.