@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.
- package/AGENTS.md +64 -8
- package/README.md +308 -101
- package/docs/architecture.md +515 -16
- package/package.json +14 -7
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
- package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
- package/packages/extension/src/__tests__/git-info.test.ts +67 -55
- package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
- package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
- package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
- package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
- package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
- package/packages/extension/src/ask-user-tool.ts +289 -20
- package/packages/extension/src/bridge.ts +107 -6
- package/packages/extension/src/command-handler.ts +34 -39
- package/packages/extension/src/dev-build.ts +1 -1
- package/packages/extension/src/git-info.ts +9 -19
- package/packages/extension/src/pi-env.d.ts +1 -0
- package/packages/extension/src/process-scanner.ts +72 -38
- package/packages/extension/src/prompt-expander.ts +25 -4
- package/packages/extension/src/provider-register.ts +304 -16
- package/packages/extension/src/server-auto-start.ts +27 -1
- package/packages/extension/src/server-launcher.ts +71 -27
- package/packages/server/package.json +17 -2
- package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
- package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
- package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
- package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
- package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
- package/packages/server/src/__tests__/browse-endpoint.test.ts +246 -10
- package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
- package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
- package/packages/server/src/__tests__/config-api.test.ts +68 -0
- package/packages/server/src/__tests__/cors.test.ts +34 -2
- package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
- package/packages/server/src/__tests__/directory-service.test.ts +234 -8
- package/packages/server/src/__tests__/editor-manager-pid-registry.test.ts +168 -0
- package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
- package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
- package/packages/server/src/__tests__/editor-registry.test.ts +29 -15
- package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
- package/packages/server/src/__tests__/extension-register.test.ts +3 -1
- package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
- package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
- package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
- package/packages/server/src/__tests__/git-operations.test.ts +9 -7
- package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
- package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
- package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
- package/packages/server/src/__tests__/home-lock.test.ts +308 -0
- package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
- package/packages/server/src/__tests__/node-guard.test.ts +85 -0
- package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
- package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +126 -0
- package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
- package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
- package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
- package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
- package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
- package/packages/server/src/__tests__/process-manager.test.ts +45 -18
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
- package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
- package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
- package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
- package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
- package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
- package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
- package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
- package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
- package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
- package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
- package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
- package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
- package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
- package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
- package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
- package/packages/server/src/__tests__/tunnel.test.ts +103 -6
- package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
- package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
- package/packages/server/src/bootstrap-queue.ts +130 -0
- package/packages/server/src/bootstrap-state.ts +131 -0
- package/packages/server/src/browse.ts +108 -9
- package/packages/server/src/browser-gateway.ts +16 -3
- package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
- package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
- package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
- package/packages/server/src/cli.ts +256 -32
- package/packages/server/src/config-api.ts +16 -0
- package/packages/server/src/directory-service.ts +270 -39
- package/packages/server/src/editor-detection.ts +12 -9
- package/packages/server/src/editor-manager.ts +39 -5
- package/packages/server/src/editor-pid-registry.ts +199 -0
- package/packages/server/src/editor-registry.ts +22 -25
- package/packages/server/src/fix-pty-permissions.ts +44 -0
- package/packages/server/src/git-operations.ts +1 -1
- package/packages/server/src/headless-pid-registry.ts +16 -20
- package/packages/server/src/home-lock-release.ts +72 -0
- package/packages/server/src/home-lock.ts +389 -0
- package/packages/server/src/node-guard.ts +52 -0
- package/packages/server/src/npm-search-proxy.ts +71 -0
- package/packages/server/src/openspec-tasks.ts +158 -0
- package/packages/server/src/package-manager-wrapper.ts +225 -34
- package/packages/server/src/pi-core-checker.ts +290 -0
- package/packages/server/src/pi-core-updater.ts +172 -0
- package/packages/server/src/pi-gateway.ts +7 -0
- package/packages/server/src/pi-resource-scanner.ts +5 -8
- package/packages/server/src/pi-version-skew.ts +196 -0
- package/packages/server/src/preferences-store.ts +17 -3
- package/packages/server/src/process-manager.ts +403 -222
- package/packages/server/src/provider-probe.ts +234 -0
- package/packages/server/src/restart-helper.ts +130 -0
- package/packages/server/src/routes/bootstrap-routes.ts +88 -0
- package/packages/server/src/routes/file-routes.ts +30 -3
- package/packages/server/src/routes/openspec-routes.ts +107 -1
- package/packages/server/src/routes/pi-core-routes.ts +140 -0
- package/packages/server/src/routes/provider-auth-routes.ts +12 -10
- package/packages/server/src/routes/provider-routes.ts +55 -2
- package/packages/server/src/routes/recommended-routes.ts +225 -0
- package/packages/server/src/routes/system-routes.ts +30 -34
- package/packages/server/src/routes/tool-routes.ts +153 -0
- package/packages/server/src/server-pid.ts +5 -9
- package/packages/server/src/server.ts +363 -26
- package/packages/server/src/session-api.ts +77 -8
- package/packages/server/src/session-bootstrap.ts +17 -3
- package/packages/server/src/session-diff.ts +21 -21
- package/packages/server/src/terminal-manager.ts +65 -20
- package/packages/server/src/test-env-guard.ts +26 -0
- package/packages/server/src/test-support/test-server.ts +63 -0
- package/packages/server/src/tunnel.ts +172 -34
- package/packages/shared/package.json +10 -3
- package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
- package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
- package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
- package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
- package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
- package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
- package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
- package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
- package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
- package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
- package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
- package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
- package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
- package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
- package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
- package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
- package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
- package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
- package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
- package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
- package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
- package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
- package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
- package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
- package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
- package/packages/shared/src/__tests__/config.test.ts +59 -3
- package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
- package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
- package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
- package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
- package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
- package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
- package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
- package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
- package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
- package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
- package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
- package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
- package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
- package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
- package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
- package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
- package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +156 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
- package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
- package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
- package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
- package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
- package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
- package/packages/shared/src/bootstrap-install.ts +212 -0
- package/packages/shared/src/bridge-register.ts +87 -20
- package/packages/shared/src/browser-protocol.ts +93 -1
- package/packages/shared/src/config.ts +87 -15
- package/packages/shared/src/managed-paths.ts +31 -4
- package/packages/shared/src/openspec-poller.ts +71 -49
- package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
- package/packages/shared/src/platform/commands.ts +100 -0
- package/packages/shared/src/platform/detached-spawn.ts +305 -0
- package/packages/shared/src/platform/exec.ts +220 -0
- package/packages/shared/src/platform/git.ts +155 -0
- package/packages/shared/src/platform/index.ts +15 -0
- package/packages/shared/src/platform/npm.ts +162 -0
- package/packages/shared/src/platform/openspec.ts +91 -0
- package/packages/shared/src/platform/paths.ts +276 -0
- package/packages/shared/src/platform/process-identify.ts +126 -0
- package/packages/shared/src/platform/process-scan.ts +94 -0
- package/packages/shared/src/platform/process.ts +168 -0
- package/packages/shared/src/platform/runner.ts +369 -0
- package/packages/shared/src/platform/shell.ts +44 -0
- package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
- package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
- package/packages/shared/src/recommended-extensions.ts +196 -0
- package/packages/shared/src/resolve-jiti.ts +62 -3
- package/packages/shared/src/rest-api.ts +97 -0
- package/packages/shared/src/semaphore.ts +83 -0
- package/packages/shared/src/source-matching.ts +126 -0
- package/packages/shared/src/test-support/setup-home.ts +74 -0
- package/packages/shared/src/tool-registry/definitions.ts +342 -0
- package/packages/shared/src/tool-registry/index.ts +56 -0
- package/packages/shared/src/tool-registry/overrides.ts +118 -0
- package/packages/shared/src/tool-registry/registry.ts +262 -0
- package/packages/shared/src/tool-registry/strategies.ts +198 -0
- package/packages/shared/src/tool-registry/types.ts +180 -0
- package/packages/shared/src/types.ts +7 -0
package/docs/architecture.md
CHANGED
|
@@ -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
|
|
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
|
|
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).
|
|
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,
|
|
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** —
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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()`
|
|
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
|
|
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`.
|