@blackbelt-technology/pi-agent-dashboard 0.4.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/AGENTS.md +339 -190
  2. package/README.md +31 -0
  3. package/docs/architecture.md +238 -23
  4. package/package.json +14 -4
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
  7. package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
  8. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
  9. package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
  10. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  11. package/packages/extension/src/bridge.ts +110 -1
  12. package/packages/extension/src/command-handler.ts +6 -0
  13. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  14. package/packages/extension/src/prompt-expander.ts +50 -2
  15. package/packages/extension/src/provider-register.ts +117 -0
  16. package/packages/extension/src/server-launcher.ts +18 -1
  17. package/packages/extension/src/session-sync.ts +5 -0
  18. package/packages/server/package.json +4 -4
  19. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  20. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  21. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  22. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  23. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  24. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  25. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
  26. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  27. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  28. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  29. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  30. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
  31. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  32. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  33. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  34. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
  35. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  36. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  37. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  38. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  39. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  40. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
  41. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  42. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  43. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  44. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  45. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  46. package/packages/server/src/bootstrap-state.ts +18 -0
  47. package/packages/server/src/browser-gateway.ts +58 -21
  48. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  49. package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
  50. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  51. package/packages/server/src/cli.ts +21 -0
  52. package/packages/server/src/directory-service.ts +31 -0
  53. package/packages/server/src/event-wiring.ts +48 -2
  54. package/packages/server/src/home-lock.d.ts +124 -0
  55. package/packages/server/src/home-lock.js +330 -0
  56. package/packages/server/src/home-lock.js.map +1 -0
  57. package/packages/server/src/idle-timer.ts +15 -1
  58. package/packages/server/src/pi-core-updater.ts +65 -9
  59. package/packages/server/src/pi-gateway.ts +6 -0
  60. package/packages/server/src/process-manager.ts +62 -11
  61. package/packages/server/src/provider-auth-handlers.ts +9 -0
  62. package/packages/server/src/provider-auth-storage.ts +83 -51
  63. package/packages/server/src/provider-catalogue-cache.ts +41 -0
  64. package/packages/server/src/routes/doctor-routes.ts +140 -0
  65. package/packages/server/src/routes/provider-auth-routes.ts +9 -0
  66. package/packages/server/src/routes/system-routes.ts +38 -1
  67. package/packages/server/src/server.ts +8 -7
  68. package/packages/server/src/session-bootstrap.ts +27 -12
  69. package/packages/server/src/session-discovery.ts +10 -3
  70. package/packages/server/src/session-scanner.ts +4 -2
  71. package/packages/server/src/spawn-failure-log.ts +130 -0
  72. package/packages/server/src/spawn-preflight.ts +82 -0
  73. package/packages/server/src/spawn-register-watchdog.ts +236 -0
  74. package/packages/server/src/terminal-manager.ts +12 -1
  75. package/packages/shared/package.json +1 -1
  76. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
  77. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
  78. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  79. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  80. package/packages/shared/src/__tests__/config.test.ts +48 -0
  81. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  82. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  83. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  84. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  85. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  86. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  87. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  88. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  89. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  90. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  91. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  92. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  93. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  94. package/packages/shared/src/bootstrap-install.ts +196 -2
  95. package/packages/shared/src/browser-protocol.ts +112 -1
  96. package/packages/shared/src/config.ts +15 -0
  97. package/packages/shared/src/dashboard-starter.ts +33 -0
  98. package/packages/shared/src/doctor-core.ts +821 -0
  99. package/packages/shared/src/index.ts +9 -0
  100. package/packages/shared/src/installable-list.ts +152 -0
  101. package/packages/shared/src/launch-source-flag.ts +14 -0
  102. package/packages/shared/src/launch-source-types.ts +18 -0
  103. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  104. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  105. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  106. package/packages/shared/src/protocol.ts +46 -2
  107. package/packages/shared/src/rest-api.ts +4 -0
  108. package/packages/shared/src/skill-block-parser.ts +115 -0
  109. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  110. package/packages/shared/src/tool-registry/definitions.ts +18 -5
  111. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  112. package/packages/shared/src/types.ts +57 -0
package/README.md CHANGED
@@ -21,6 +21,7 @@ A web-based dashboard for monitoring and interacting with [pi](https://github.co
21
21
  - [Configuration](#configuration)
22
22
  - [Usage](#usage)
23
23
  - [Recommended extensions](#recommended-extensions)
24
+ - [Authoring a dashboard plugin](#authoring-a-dashboard-plugin)
24
25
  - [Troubleshooting](#troubleshooting)
25
26
  - [Architecture](#architecture)
26
27
  - [Monitoring](#monitoring)
@@ -102,6 +103,7 @@ Remove with `pi remove /path/to/pi-agent-dashboard`. Alternatively, add the pack
102
103
  - **Command autocomplete** — `/` prefix triggers a filtering dropdown
103
104
  - **Mobile-friendly** — responsive layout with swipe drawer, touch targets, and mobile action menus
104
105
  - **Markdown preview** — rendered markdown views with search, mermaid diagrams, syntax highlighting, and safe handling for raw HTML `ref` attributes
106
+ - **Local-image inlining + LaTeX math in chat** — agents can reference local screenshots inline as `![alt](/abs/path.png)` or `![alt](./relative.png)` and they render in chat (the bridge inlines bytes via a streaming-safe `pi-asset:<hash>` channel — each unique image's bytes ride exactly once per session, no matter how many streaming chunks repeat the token). Math expressions — inline `$x = \beta$` and display `$$\sum_i^n i$$` (block-level) — are typeset via KaTeX. PNG / JPEG / GIF / WebP / SVG / AVIF / BMP supported with a 5 MB-per-image, 20 MB-per-message cap; oversized or unreadable references render as a visible placeholder rather than a broken-image glyph. The dashboard server adds zero new HTTP routes.
105
107
  - **Searchable select dialogs** — keyboard-navigable picker with real-time filtering (OpenSpec changes, flow commands)
106
108
 
107
109
  **Integrations**
@@ -220,6 +222,8 @@ OAuth2 authentication guards external (tunnel) access. Localhost is always ungua
220
222
 
221
223
  **Callback URL:** register `https://<tunnel-url>/auth/callback/<provider>` in your OAuth provider settings. The tunnel URL is stable across restarts (reserved shares are auto-created).
222
224
 
225
+ > **Security note:** `/api/spawn-failures` is reachable to any caller on deployments without auth; entries contain `cwd` paths. Enable auth before exposing via tunnel.
226
+
223
227
  ### Tunnel (zrok)
224
228
 
225
229
  The dashboard auto-connects a [zrok](https://zrok.io/) tunnel on start when `tunnel.enabled` is `true`. Install with `brew install zrok` (macOS) and run `zrok enable <token>` to enrol — the dashboard reads zrok's own config (`~/.zrok2/environment.json`), no keys are stored in the dashboard. Reserved shares provide persistent URLs across restarts.
@@ -265,6 +269,10 @@ The file is deliberately separate from `config.json` so machine-specific paths d
265
269
 
266
270
  The bridge extension **automatically starts the dashboard server** when pi launches if it's not already running. Disable with `"autoStart": false` in `~/.pi/dashboard/config.json`.
267
271
 
272
+ In the Electron app, if the initial launch attempts fail (or the server is stopped externally), the **loading page exposes a Start server button**, an **Open Doctor link**, and a collapsible **Server log** panel showing the last 20 lines of `~/.pi/dashboard/server.log`. The system tray menu also includes a **Start server / Restart server** item that reflects current server state. All entry points share a single idempotent launch routine in the Electron main process.
273
+
274
+ **Doctor diagnostics.** Help → Doctor (or the loading-page link) opens a styled `BrowserWindow` (`doctor.html`) that runs the same checks the Electron app already performed — grouped into sections (Runtime, Pi, Server, Bundles, Diagnostics) with status pills, paths, and per-row suggestion callouts; toolbar offers Re-run, Copy as Markdown / Plain, Open server log, Open doctor log, Run setup wizard. The web client exposes the portable subset at **Settings → Diagnostics**, which fetches `/api/doctor` and renders the same sections (Electron-only rows omitted). Both surfaces share `packages/shared/src/doctor-core.ts`, so a check defined once shows up everywhere.
275
+
268
276
  ### Daemon mode
269
277
 
270
278
  ```bash
@@ -367,6 +375,29 @@ Authoritative source: `packages/shared/src/recommended-extensions.ts`. Descripti
367
375
 
368
376
  ---
369
377
 
378
+ ## Authoring a dashboard plugin
379
+
380
+ The dashboard's UI is composed of named **slots** that plugins claim with React components. To create a new plugin, install the scaffolding skill:
381
+
382
+ ```bash
383
+ npm i -g @blackbelt-technology/pi-dashboard-plugin-skill
384
+ ```
385
+
386
+ Then, from any pi session:
387
+
388
+ ```
389
+ /skill dashboard-plugin-scaffold
390
+ ```
391
+
392
+ The skill has two modes:
393
+
394
+ - **`new`** — scaffold a fresh `packages/<id>-plugin/` inside this monorepo. Pick which of the 10 React slots to claim (`session-card-badge`, `content-view`, `settings-section`, `tool-renderer`, …); the skill renders package.json (with `pi-dashboard-plugin` manifest), `src/client.tsx` with stubs, optional `src/server/index.ts`, optional `src/bridge/index.ts`, `configSchema.json`, and tests.
395
+ - **`augment`** — retrofit an existing pi-extension project on disk. The skill greps for TUI surface (`ctx.ui.*`, `pi.registerTool`, …), drives the agent through a canonical mapping table, asks per-callsite what to port, then injects a manifest field into `package.json` and adds `src/dashboard/`. Purely additive — your existing TUI keeps working in pure-pi sessions.
396
+
397
+ For the slot taxonomy, the manifest schema, and the plugin context API, see the skill's reference docs (or the runtime: [`@blackbelt-technology/dashboard-plugin-runtime`](https://www.npmjs.com/package/@blackbelt-technology/dashboard-plugin-runtime)). The reference fixture is [`packages/demo-plugin/`](packages/demo-plugin/).
398
+
399
+ ---
400
+
370
401
  ## Troubleshooting
371
402
 
372
403
  ### Dashboard server doesn't start
@@ -9,7 +9,7 @@
9
9
 
10
10
  ## Overview
11
11
 
12
- The PI Dashboard is a web-based dashboard for monitoring and interacting with pi agent sessions. It consists of three components:
12
+ PI Dashboard: web-based dashboard for monitoring + interacting with pi agent sessions. Three components:
13
13
 
14
14
  ```
15
15
  ┌─────────────┐ WebSocket ┌──────────────┐ WebSocket ┌─────────────┐
@@ -27,14 +27,14 @@ The PI Dashboard is a web-based dashboard for monitoring and interacting with pi
27
27
  ## Components
28
28
 
29
29
  ### 1. Bridge Extension (`src/extension/`)
30
- A global pi extension that runs in every pi session. It:
31
- - Detects session source (TUI, Zed, tmux, dashboard-spawned) via `.meta.json` sidecar files and environment variables
32
- - Forwards all pi events to the dashboard server via WebSocket
33
- - Relays commands from the dashboard back to pi
34
- - Handles reconnection with exponential backoff and event buffering
30
+ Global pi extension running in every pi session. It:
31
+ - Detects session source (TUI, Zed, tmux, dashboard-spawned) via `.meta.json` sidecar files + env vars
32
+ - Forwards all pi events to dashboard server via WebSocket
33
+ - Relays commands from dashboard back to pi
34
+ - Handles reconnection with exponential backoff + event buffering
35
35
  - Sends heartbeats every 15s with process metrics (CPU%, RSS, heap, event loop max delay, load average); server responds with `heartbeat_ack`
36
36
  - Server liveness watchdog: forces reconnect if no message received for 60s
37
- - Server-side WS ping/pong (60s interval) detects dead TCP connections; requires 2 consecutive missed pongs before killing (tolerates long-running bash commands that block the event loop)
37
+ - Server-side WS ping/pong (60s interval) detects dead TCP connections; requires 2 consecutive missed pongs before killing (tolerates long-running bash commands blocking event loop)
38
38
  - Detects OpenSpec activity (phase/change) from tool events; server auto-attaches the change when `changeName` is detected (phase is not required — skills loaded via prompt templates don't emit a SKILL.md read event). The session card's OpenSpec activity badge displays when either `openspecPhase` or `openspecChange` is detected (not just phase).
39
39
  - **Attached-proposal artifact summary** in the content-window header (`SessionHeader.tsx`, both desktop branch and `MobileHeader`): when `session.attachedProposal` matches an entry in the polled `openspecChanges` list, the header renders the `ArtifactLettersButton` (P/D/T/S letters colored by per-artifact status, single button → opens the proposal artifact) plus a `(completedTasks/totalTasks)` counter. Surface is gated on the explicit user attach only — auto-detected `openspecChange` does not trigger it. Wired via the new `onReadArtifact` prop, threaded from `App.tsx` (`handleReadArtifact` from `useContentViews`). See change: add-attached-proposal-header-summary.
40
40
  - **Duplicate bridge prevention**: Uses `process`-level shared state (not `globalThis`) with a monotonic generation counter. When the extension is loaded multiple times (e.g., local + global npm package), only the latest instance's event handlers are active — stale listeners bail out immediately. All previous connections and timers are tracked and cleaned up on re-init.
@@ -48,7 +48,7 @@ A global pi extension that runs in every pi session. It:
48
48
  - Protocol messages: `prompt_request`, `prompt_dismiss`, `prompt_cancel`, `prompt_response`
49
49
 
50
50
  ### 2. Dashboard Server (`src/server/`)
51
- A Node.js HTTP + WebSocket server that:
51
+ Node.js HTTP + WebSocket server that:
52
52
  - Accepts connections from bridge extensions (Pi Gateway, port 9999)
53
53
  - Accepts connections from web browsers (Browser Gateway, port 8000)
54
54
  - Stores events in an in-memory buffer with LRU eviction (max 100 sessions, 5000 events per session)
@@ -59,7 +59,7 @@ A Node.js HTTP + WebSocket server that:
59
59
  - Discovers historical sessions directly from disk via `SessionManager.list()` (DirectoryService)
60
60
  - Loads session events on demand directly from disk via `SessionManager.open()` (DirectoryService)
61
61
  - Polls OpenSpec CLI per directory every 30s, broadcasting changes to browsers (DirectoryService).
62
- - **Design-artifact override**: After receiving the CLI's per-change `status`, `buildOpenSpecData` post-processes the `design` artifact: when CLI says `design: ready`, the dashboard checks local file-system evidence (R1: `^design.*\.md$` present; R2: `design/*.md` present; R3: `tasks.md` contains a Markdown checkbox) and promotes `design.status` to `"done"` if any rule fires. The override is **promote-only and design-only** — never demotes, never touches other artifact ids, never promotes from `"blocked"`. Change-level `isComplete` is re-derived locally; CLI `isComplete: true` is never demoted. The same R1/R2/R3 rules are mirrored in `.pi/skills/openspec-shared/scripts/effective-status.sh` so OpenSpec workflow skills and dashboard session-card buttons cannot disagree about a change's next-ready artifact. See change: fix-openspec-design-detection.
62
+ - **Design-artifact override**: after CLI's per-change `status`, `buildOpenSpecData` post-processes `design` artifact: when CLI says `design: ready`, dashboard checks local fs evidence (R1: `^design.*\.md$` present; R2: `design/*.md` present; R3: `tasks.md` contains Markdown checkbox) + promotes `design.status` to `"done"` if any rule fires. **Promote-only + design-only** — never demotes, never touches other artifact ids, never promotes from `"blocked"`. Change-level `isComplete` re-derived locally; CLI `isComplete: true` never demoted. Same R1/R2/R3 mirrored in `.pi/skills/openspec-shared/scripts/effective-status.sh` so OpenSpec workflow skills + dashboard session-card buttons cannot disagree about next-ready artifact. See change: fix-openspec-design-detection.
63
63
  - Serves the built web client as static files (production) or proxies to Vite dev server (dev mode)
64
64
  - Writes per-session `.meta.json` sidecar files with dashboard state and cached stats
65
65
  - Exposes REST API for session management, event content fetch, pinned directories, and file reading
@@ -75,7 +75,7 @@ A Node.js HTTP + WebSocket server that:
75
75
  - `browser-handlers/` — Browser WebSocket message handlers by domain (subscription, session-actions, session-meta, terminal, directory)
76
76
 
77
77
  ### 3. Web Client (`src/client/`)
78
- A React-based responsive web UI that:
78
+ React-based responsive web UI that:
79
79
  - Shows all active sessions organized by directory, with pinned directories always visible at the top
80
80
  - Renders chat messages with markdown, syntax highlighting, streaming, and a small raw-HTML pass that strips React-only `ref` attributes before render
81
81
  - Persists scroll position per session — switching sessions restores exact scroll position if locked, or scrolls to bottom if following
@@ -88,6 +88,7 @@ A React-based responsive web UI that:
88
88
 
89
89
  ### 4. Shared Types (`src/shared/`)
90
90
  TypeScript type definitions shared across all components:
91
+
91
92
  - `protocol.ts` - Extension↔Server WebSocket messages
92
93
  - `browser-protocol.ts` - Server↔Browser WebSocket messages (includes PromptBus messages: `prompt_request`, `prompt_dismiss`, `prompt_cancel`)
93
94
  - `types.ts` - Data models (Session, Workspace, Event, etc.)
@@ -101,7 +102,7 @@ TypeScript type definitions shared across all components:
101
102
  4. Server broadcasts to all subscribed browsers via `event` message
102
103
  5. Browser's event reducer processes event, React renders update
103
104
 
104
- **Last-activity stamping** (change: session-card-last-activity-badge): inside step 3, before any other event-derived updates, the server checks `isActivityEvent(eventType)` against a curated allowlist (`prompt_send`, `message_*`, `turn_end`, `tool_execution_*`, `agent_*`, `bash_output`, `flow_*`, `architect_*`). On a match — and only when the session is NOT in replay — it stamps `session.lastActivityAt = Date.now()`. The in-memory write is unconditional; the `session_updated` broadcast is throttled to **at most one per 30 s per session** via `lastActivityBroadcastAt: Map<sessionId, ms>`. The map entry is dropped on `session_unregister` so a fast re-register cannot lose its first broadcast. Heartbeat / metrics / UI-state events (`process_metrics`, `git_info_update`, `model_select`, `ui_data_list`, `ext_ui_decorator`, …) are excluded so an idle pi process emitting periodic metrics does not keep the badge artificially fresh. At server boot, `session-scanner.ts` cold-start-seeds `lastActivityAt` from the `events.jsonl` file mtime so existing idle sessions retain a meaningful relative-time label across restarts. The client's `selectBadgeTimestamp(session)` (in `packages/client/src/lib/session-card-time.ts`) renders `endedAt ?? lastActivityAt ?? startedAt` for ended sessions and `lastActivityAt ?? startedAt` for active ones.
105
+ **Last-activity stamping** (change: session-card-last-activity-badge): in step 3, before other event-derived updates, server checks `isActivityEvent(eventType)` against curated allowlist (`prompt_send`, `message_*`, `turn_end`, `tool_execution_*`, `agent_*`, `bash_output`, `flow_*`, `architect_*`). On match — only when session NOT in replay — stamps `session.lastActivityAt = Date.now()`. In-memory write unconditional; `session_updated` broadcast throttled to **≤ 1×/30 s/session** via `lastActivityBroadcastAt: Map<sessionId, ms>`. Map entry dropped on `session_unregister` so fast re-register can't lose first broadcast. Heartbeat/metrics/UI-state events (`process_metrics`, `git_info_update`, `model_select`, `ui_data_list`, `ext_ui_decorator`, …) excluded so idle pi process emitting periodic metrics doesn't keep badge artificially fresh. At server boot, `session-scanner.ts` cold-start-seeds `lastActivityAt` from `events.jsonl` mtime so idle sessions retain meaningful relative-time label across restarts. Client `selectBadgeTimestamp(session)` (`packages/client/src/lib/session-card-time.ts`) renders `endedAt ?? lastActivityAt ?? startedAt` for ended sessions, `lastActivityAt ?? startedAt` for active.
105
106
 
106
107
  **Unread state machine** (change: session-card-unread-stripes): every session carries a `unread: boolean` field that flips to `true` when an attention-worthy event fires while no browser has the session displayed, and clears to `false` when any browser opens the session. The visual is cyan scrolling stripes (`card-unread-pulse`, Tailwind `cyan-400`) on the session card, lower priority than the yellow streaming and purple ask_user pulses.
107
108
 
@@ -128,7 +129,7 @@ TypeScript type definitions shared across all components:
128
129
  7. User responds in browser → `prompt_response` sent to server → routed to bridge
129
130
  8. Bus resolves the original dialog promise and calls `onResponse()` on all adapters for cleanup
130
131
 
131
- **Multiselect note:** pi's upstream `ExtensionUIContext` has no native `multiselect` method, so the bridge attaches `ctx.ui.multiselect` during `session_start`. `ask_user` dispatches multiselect through `polyfillMultiselect`, which delegates to that patched PromptBus method when present and falls back to `ctx.ui.custom` + `MultiSelectList` for legacy / non-bridge contexts (the fallback is a no-op in pi 0.70 RPC mode — dashboard headless — because pi-coding-agent defines `custom` as `async () => undefined` there). The bridge intentionally registers NO TUI adapter arm for multiselect; routing is bus-only. Browser responses encode `{ values: string[] }` as `JSON.stringify(values)` in `prompt_response.answer`, preserving `[]` as a real empty selection distinct from cancellation.
132
+ **Multiselect note:** pi's upstream `ExtensionUIContext` has no native `multiselect`, so bridge attaches `ctx.ui.multiselect` during `session_start`. `ask_user` dispatches multiselect through `polyfillMultiselect`, which delegates to that patched PromptBus method when present + falls back to `ctx.ui.custom` + `MultiSelectList` for legacy / non-bridge contexts (fallback is no-op in pi 0.70 RPC mode — dashboard headless — because pi-coding-agent defines `custom` as `async () => undefined` there). Bridge intentionally registers NO TUI adapter arm for multiselect; routing bus-only. Browser responses encode `{ values: string[] }` as `JSON.stringify(values)` in `prompt_response.answer`, preserving `[]` as real empty selection distinct from cancellation.
132
133
 
133
134
  **First-response-wins (multi-adapter):**
134
135
  - Multiple adapters can claim the same prompt (e.g. TUI + dashboard)
@@ -379,6 +380,8 @@ The fix gates the JSX element on a registry claim count *before* construction:
379
380
 
380
381
  A repository-level lint (`packages/client/src/__tests__/no-jsx-slot-nullish-fallback.test.ts`) scans the dashboard shell entry points for the anti-pattern and fails CI with the offending file:line. The lint is enforced for `packages/client/src/App.tsx` today; downstream changes that wire new slot consumers (`extract-flows-as-plugin`, `extract-openspec-as-plugin`, `extract-subagents-as-plugin`, `extract-git-as-plugin`) MUST add their shell file to the lint's `SCAN_FILES` allowlist. See change `fix-slot-fallback-masks-content` for the rationale, regression test, and the exact production bug shape (encountered during deployment of `add-extension-ui-decorations`).
381
382
 
383
+ **Authoring on-ramp:** Skill package `packages/dashboard-plugin-skill/` ships `@blackbelt-technology/pi-dashboard-plugin-skill` (publishable). Skill name `dashboard-plugin-scaffold`. Hybrid contract: `ask_user` batch up front, prescriptive steps after. Two modes. `new` mode: scaffolds `packages/<id>-plugin/` matching `packages/demo-plugin/` layout. Per-slot stubs for 10 React slots. Optional server entry. Optional bridge entry, default off. `augment` mode: runs in pi session at cwd of existing pi-extension. Grep prelude scans `ctx.ui.*`, `pi.registerTool`, banned `ctx.fork`. LLM analysis vs canonical TUI→dashboard mapping table. Per-callsite `ask_user` multiselect. Injects `pi-dashboard-plugin` field into `package.json`. Writes `src/dashboard/`. Purely additive: no existing source modified. SDK = runtime + shared package exports. No separate SDK package. Skill adds both as deps. Forward-compat contract enforced at scaffold time: top-level manifest field, package-relative paths, no `workspace:*`, exports subpaths match, `requiredApi` set. Augmented external extensions resolve under future `node_modules` discovery scan. Canonical on-ramp. `demo-plugin` = runtime fixture. Skill = authoring fixture.
384
+
382
385
  ### Bootstrap & First Run
383
386
 
384
387
  The dashboard has three install paths that all converge on the shared
@@ -476,6 +479,10 @@ actually changed since boot.
476
479
 
477
480
  See changes: `unified-bootstrap-install`, `pi-zero-seventy-compat`, `warn-pi-version-skew-in-cli`, `fix-openspec-buttons-after-bootstrap-install`.
478
481
 
482
+ #### Managed Node runtime
483
+
484
+ Electron resources ship bundled Node under `resources/node/` (Windows: `node.exe` + `npm.cmd` + `npx.cmd` at root; Unix: `bin/node` + `bin/npm` + `bin/npx`). `installManagedNode` (`packages/shared/src/bootstrap-install.ts`) `fs.cp`-copies bundle into `<managedDir>/node/` (default `~/.pi-dashboard/node/`), writes `.version` marker for idempotency. `installStandalone` calls it BEFORE first `bootstrapInstall` so npm shims exist when registry install runs; Doctor calls it unconditionally on every launch as a self-repair step. ToolRegistry `node` + `npm` strategy chains prefer `<managedDir>/node/` ahead of system PATH; `prependManagedNodeToPath(env, managedDir)` (`packages/shared/src/platform/managed-node-path.ts`) injects same dir at HEAD of every spawned child's `PATH` (pi-session, pi-core-updater, headless RPC, server-launcher) so `npm.cmd`/`npx.cmd` resolve without `where npm` returning empty on Windows. Standalone CLI / dev / builds without `resources/node/`: bundled source absent, both helpers no-op, resolver falls through to system PATH. See change: embed-managed-node-runtime.
485
+
479
486
  ### Force Kill Escalation
480
487
  The Stop button supports two-click escalation for stuck sessions:
481
488
  1. **Click 1 (Abort)**: Sends `abort` → bridge → `ctx.abort()`. Button transitions to orange pulsing "Force Stop".
@@ -505,6 +512,48 @@ Inline stop buttons also appear on running tool cards in `ToolCallStep`, providi
505
512
  ### Repeated Tool Call Collapsing
506
513
  Consecutive tool calls with the same name and identical args (e.g. health check polling loops) are collapsed into a single expandable group showing a count badge (e.g. "×24"). Implemented via `groupConsecutiveToolCalls()` in the chat rendering pipeline. Groups require 3+ calls; running tools are never grouped.
507
514
 
515
+ ### Local-image inlining + LaTeX math in chat
516
+
517
+ Assistant messages containing markdown image references to local files (`![alt](/abs/path.png)` or `![alt](./relative.png)`) are inlined by the bridge before the text leaves the agent process; LaTeX math (`$x = \beta$` and block-level `$$\n…\n$$`) is typeset client-side via KaTeX. Both behaviors live entirely in the chat-rendering pipeline — the dashboard server adds zero new HTTP routes.
518
+
519
+ ```mermaid
520
+ sequenceDiagram
521
+ participant pi as pi (agent)
522
+ participant bridge as Bridge (extension)
523
+ participant server as Dashboard server
524
+ participant client as Browser (MarkdownContent)
525
+
526
+ pi->>bridge: message_update / message_end<br/>{ message.content: "![pic](/home/me/shot.png) and …" }
527
+ bridge->>bridge: parseImageTokens → isLocalSrc → readFile<br/>(5MB/image, 20MB/message caps; MIME allowlist)<br/>hash = sha256(bytes).slice(0,16)
528
+ bridge->>server: asset_register { sessionId, hash, mimeType, data:base64 }<br/>(only if hash not yet emitted this session)
529
+ bridge->>server: message_update / message_end<br/>{ message.content: "![pic](pi-asset:abc1234567890123) and …" }
530
+ server->>server: asset_register → Session.assets[hash]<br/>= { data, mimeType }
531
+ server->>client: asset_register (broadcast to subscribers)
532
+ server->>client: event_forward (rewritten message text)
533
+ client->>client: useMessageHandler.asset_register →<br/>setSessions → DashboardSession.assets[hash]<br/>SessionAssetsContext re-renders descendants
534
+ client->>client: MarkdownContent.PiAssetImg resolves<br/>pi-asset:abc1234567890123 →<br/>data:image/png;base64,… → <img>
535
+ ```
536
+
537
+ Key invariants:
538
+
539
+ - **Server adds no new HTTP route.** No `/api/file/raw`. Image bytes ride inside the existing event/asset stream, mirroring how Read-tool images already work.
540
+ - **Bandwidth-bounded streaming.** Each unique image's bytes are sent exactly once per session via `asset_register`. Subsequent `message_update` chunks only re-ship the short `pi-asset:<hash>` token (~25 chars) in the streaming text.
541
+ - **Asset registry lives on `Session.assets`** (in-memory, not in the rolling event buffer). Subscription replay re-emits one `asset_register` per entry BEFORE the events array, so reconnecting browsers see the registry populated by the time their `message_update` events are reduced. Cold-start full-server-restart loses bytes; older `pi-asset:` tokens render as a placeholder until a fresh assistant message references the same file.
542
+ - **Math plugin chain.** `MarkdownContent.tsx` registers `remarkPlugins: [remarkGfm, remarkMath]` and `rehypePlugins: [rehypeRaw, [rehypeKatex, { throwOnError: false }], stripReactRefAttributes]`. `rehypeRaw` runs FIRST (so embedded HTML is parsed before KaTeX emits its own). `throwOnError:false` keeps streaming half-formed expressions like `$x = 10 +` from crashing the markdown render. `urlTransform={(v)=>v}` disables ReactMarkdown's default scheme-stripping so `pi-asset:` and `data:` srcs reach the `img` override intact.
543
+
544
+ Failure modes (placeholders are visible, not silent):
545
+
546
+ | Condition | Placeholder text |
547
+ |---|---|
548
+ | File missing or unreadable (ENOENT/EACCES) | `[image not found: <originalSrc>]` |
549
+ | Path resolves to a directory or other non-file | `[image read failed: <originalSrc>]` |
550
+ | Extension not in image allowlist | `[unsupported image type: <originalSrc>]` |
551
+ | File > 5 MB | `[image too large: <originalSrc> (<sizeInMB> MB)]` |
552
+ | Per-message budget (20 MB new bytes) exhausted | `[message asset budget exhausted: <originalSrc>]` |
553
+ | `pi-asset:<hash>` arrived before its `asset_register` | dashed-bordered `⦿ <alt> (loading…)` span; auto-swaps when bytes arrive |
554
+
555
+ See change: `chat-markdown-local-images-and-math`.
556
+
508
557
  ### Edit Tool Diff Rendering (desktop vs mobile)
509
558
  `ToolCallStep` gates renderer mounting with `{expanded && <Renderer />}` — Edit cards default to collapsed, so no diff tokenization runs until the user expands. On expand, `EditToolRenderer` branches on `useMobile()` (the project-wide `width < 768px OR height < 600px` predicate):
510
559
  - **Desktop** (`!isMobile`): renders `<RichDiff oldText newText filePath maxHeight="20rem" />` — syntax-highlighted via `@git-diff-view/react` + lowlight, matching `FileDiffView` quality; height capped for chat scroll UX.
@@ -546,7 +595,7 @@ flowchart TD
546
595
  H -->|No| J[Error logged to bridge stderr<br/>User must bootstrap via TUI]
547
596
  ```
548
597
 
549
- **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.
598
+ **Why two paths?** pi-coding-agent's `ExtensionContext` (delivered to `session_start` handlers) has no `reload()` method — only `ExtensionCommandContext` (given to command handlers) does. Bridge workaround: registers `__dashboard_reload` as command, captures `ctx.reload` into `globalThis[RELOAD_KEY]` when user first invokes in pi's TUI. Headless sessions have no TUI, so capture never happens. Server-side interception is transparent kill-and-respawn achieving same user-visible outcome (fresh settings, extensions, skills/prompts/themes) without in-process reload. `memorySessionManager.register` carries accumulated state when same `sessionId` re-registers, so user sees brief reconnect flicker but keeps tokens, cost, context usage, attached proposal. See change: headless-reload-via-respawn.
550
599
 
551
600
  ### Server Restart (single-orchestrator path)
552
601
 
@@ -579,7 +628,7 @@ When a user sends a prompt to an ended session, the server automatically resumes
579
628
  ### Sidebar session ordering: top-of-tier on status change
580
629
  The sidebar splits each folder's session cards into two tiers (alive on top, ended at the bottom). Cards within each tier sort independently:
581
630
 
582
- - **Alive tier** uses the persisted `sessionOrder` per cwd (drag-reorder, prepend on new spawn). On user-intent resume (Resume button, drag-to-resume, REST resume), the server calls `sessionOrderManager.moveToFront(cwd, sessionId)` so the just-resumed card surfaces at index 0 of the alive tier — even on repeated `end → resume → end → resume` cycles where the id might already be in the order list. **Bridge auto-reattach after a dashboard restart** is governed by the `reattachPlacement` config (`"always"` default / `"streaming-only"` / `"preserve"`): the bridge tags every `session_register` after its first call as `registerReason: "reattach"`, and `server.ts onChange` routes those into `reattach-placement.ts::applyReattachPolicy` to `moveToFront` according to policy. The `"preserve"` setting reproduces the legacy behavior of leaving order untouched. Registry intents (`pendingResumeIntents.consume()` returning `"front"` or `"keep"`) always override the reattach policy. See change: reattach-move-to-front.
631
+ - **Alive tier** uses persisted `sessionOrder` per cwd (drag-reorder, prepend on new spawn). On user-intent resume (Resume button, drag-to-resume, REST resume), server calls `sessionOrderManager.moveToFront(cwd, sessionId)` so just-resumed card surfaces at index 0 of alive tier — even on repeated `end → resume → end → resume` where id may already be in order list. **Bridge auto-reattach after dashboard restart** governed by `reattachPlacement` config (`"always"` default / `"streaming-only"` / `"preserve"`): bridge tags every `session_register` after first call as `registerReason: "reattach"`, `server.ts onChange` routes into `reattach-placement.ts::applyReattachPolicy` to `moveToFront` per policy. `"preserve"` reproduces legacy behavior of leaving order untouched. Registry intents (`pendingResumeIntents.consume()` returning `"front"` / `"keep"`) always override reattach policy. See change: reattach-move-to-front.
583
632
  - **Ended tier** sorts by `(endedAt ?? startedAt)` descending, computed at render time inside `SessionList.renderGroup` (no persisted `endedSessionOrder` list — pure function of session timestamps). The most-recently-ended card surfaces at the top of the ended bucket regardless of cause (✕ shutdown, natural pi exit, force-kill). Legacy sessions without a recorded `endedAt` fall back to `startedAt` so pre-migration entries keep their previous ordering.
584
633
 
585
634
  Both halves share one mental model: "the session you just acted on appears at the top of its new tier." No protocol changes — the existing `sessions_reordered` broadcast carries the new order. See change `top-of-tier-on-status-change`.
@@ -661,7 +710,7 @@ A naive `for each cwd: list + for each change: status` fan-out explodes quickly:
661
710
 
662
711
  The scheduler in `packages/server/src/directory-service.ts` applies four layers of throttling (all configurable under `DashboardConfig.openspec`):
663
712
 
664
- 1. **mtime gate** (`changeDetection: "mtime" | "always"`, default `mtime`) — skips `openspec list` and `openspec status --change X` when no tracked artifact has changed since the last successful poll. The gate uses **file-aware effective mtime** (the max over a fixed file set) rather than directory mtime alone, because POSIX directory mtime advances only on entry create/delete/rename and misses in-place file edits. The list-step signal unions `<changes>/` with each known `<change>/tasks.md`; the per-change signal unions `<change>/` with `tasks.md`, `proposal.md`, `design.md`, **plus the entire `specs/**` subtree** (the `specs/` directory itself, every immediate `specs/<cap>/` directory, and every `specs/<cap>/spec.md`). Missing files or directories (e.g. a change with no `design.md` or no `specs/` yet) are skipped, not zero — `readdirSync` on `specs/` is try/catch-wrapped so absence yields an empty fan-out. A `stat` is ~10 µs vs. ~500 ms per CLI spawn; in steady state this drops 67 spawns/tick to 0–2. The gate is **TOCTOU-safe**: each per-change iteration captures `preCallMtime` before awaiting `runOpenSpecStatus` and stamps THAT value into the cache; if the post-call effective mtime differs, the entry is racy and the cache is left untouched (the next gated tick re-polls because the post-write mtime no longer matches the preserved cached value). Without this guard, a write landing during the CLI call would stamp `{ mtimeMs: post-write, status: pre-write }` and latch the stale status indefinitely — trivially triggered by `/opsx:ff` mid-poll. **Defense in depth**: `buildOpenSpecData` also accepts a `SpecsProbeFactory` (parallel to the existing `DesignProbeFactory`) that promotes `specs: ready → done` whenever any `specs/**/*.md` file is found locally — promote-only, never demote, never `blocked → done`. So even if a future blind spot creeps into the gate, the dashboard cannot under-report `specs` as ready when at least one spec file exists. See changes: `fix-openspec-specs-mtime-gate-blind-spot`, `fix-openspec-mtime-gate-toctou`, `fix-openspec-mtime-gate-blind-spots`.
713
+ 1. **mtime gate** (`changeDetection: "mtime" | "always"`, default `mtime`) — skips `openspec list` and `openspec status --change X` when no tracked artifact changed since last successful poll. Uses **file-aware effective mtime** (max over fixed file set) rather than directory mtime alone, because POSIX directory mtime advances only on entry create/delete/rename + misses in-place file edits. List-step signal unions `<changes>/` with each known `<change>/tasks.md`; per-change signal unions `<change>/` with `tasks.md`, `proposal.md`, `design.md`, **plus entire `specs/**` subtree** (`specs/` itself, every immediate `specs/<cap>/`, every `specs/<cap>/spec.md`). Missing files/dirs (e.g. change with no `design.md` or no `specs/` yet) skipped, not zero — `readdirSync` on `specs/` try/catch-wrapped so absence yields empty fan-out. `stat` ~10 µs vs. ~500 ms per CLI spawn; steady state drops 67 spawns/tick to 0–2. **TOCTOU-safe**: each per-change iteration captures `preCallMtime` before awaiting `runOpenSpecStatus` + stamps THAT value into cache; if post-call effective mtime differs, entry racy + cache left untouched (next gated tick re-polls because post-write mtime no longer matches preserved cached value). Without guard, write landing during CLI call would stamp `{ mtimeMs: post-write, status: pre-write }` + latch stale status indefinitely — trivially triggered by `/opsx:ff` mid-poll. **Defense in depth**: `buildOpenSpecData` also accepts `SpecsProbeFactory` (parallel to existing `DesignProbeFactory`) that promotes `specs: ready → done` whenever any `specs/**/*.md` found locally — promote-only, never demote, never `blocked → done`. So even if future blind spot creeps in, dashboard cannot under-report `specs` as ready when ≥1 spec file exists. See changes: `fix-openspec-specs-mtime-gate-blind-spot`, `fix-openspec-mtime-gate-toctou`, `fix-openspec-mtime-gate-blind-spots`.
665
714
  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.
666
715
  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.
667
716
  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).
@@ -703,7 +752,7 @@ The dashboard's reusable directory chooser (`PathPicker`) is backed by three loc
703
752
  - `GET /api/browse?path=<dir>&q=<query>&detect=<0|1>` — lists subdirectories of `<dir>` (or `$HOME` when omitted). By default this is a single-`readdir` enumeration with no per-entry filesystem probes; `isGit` / `isPi` are absent from each `BrowseEntry`. Pass `detect=1` (only the literal string `"1"` is truthy) to opt into eager `.git` / `.pi` classification on every entry — useful for skill recipes that consumed the legacy shape. When `q` is non-empty, entries are case-insensitive substring-filtered and ranked:
704
753
  - **Tier 0** exact match → **Tier 1** prefix → **Tier 2** word-boundary substring (after `-`, `_`, `.`, space, `/`) → **Tier 3** plain substring.
705
754
  - Alphabetical within each tier. The 200-entry cap is applied **after** filter+rank so best matches always survive truncation. See change: split-browse-flags.
706
- - `GET /api/browse/flags?paths=<json-array>` — bulk classifier for paths produced by `/api/browse`. The `paths` query is a URL-encoded JSON array of absolute path strings (length ≤ 100). Returns `{ flags: { [path]: { isGit, isPi } } }`. Per-path probe failures (ENOENT, EACCES, ELOOP, race-on-deletion, anything) map to `{ isGit: false, isPi: false }` for that key — only malformed input or over-cap arrays produce a top-level error (`invalid paths` / `too many paths`, both HTTP 400). Internal `fs.access` fan-out is bounded at 32 in-flight calls. The `PathPicker` calls this lazily after each `/api/browse` enumeration and merges the flag map into the rendered rows so badges fade in without blocking the initial paint. See change: split-browse-flags.
755
+ - `GET /api/browse/flags?paths=<json-array>` — bulk classifier for paths produced by `/api/browse`. `paths` query: URL-encoded JSON array of absolute path strings (length ≤ 100). Returns `{ flags: { [path]: { isGit, isPi } } }`. Per-path probe failures (ENOENT, EACCES, ELOOP, race-on-deletion, anything) map to `{ isGit: false, isPi: false }` for that key — only malformed input or over-cap arrays produce top-level error (`invalid paths` / `too many paths`, both HTTP 400). Internal `fs.access` fan-out bounded at 32 in-flight. `PathPicker` calls this lazily after each `/api/browse` enumeration + merges flag map into rendered rows so badges fade in without blocking initial paint. See change: split-browse-flags.
707
756
  - `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`).
708
757
 
709
758
  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:
@@ -748,7 +797,18 @@ Package operations use pi's `DefaultPackageManager` API on the server, serialize
748
797
 
749
798
  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.
750
799
 
751
- 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.
800
+ Core update progress delivered via typed `pi_core_update_progress` / `pi_core_update_complete` browser-protocol messages (not `package_progress` channel). Fanned out to `UnifiedPackagesSection` + `PiUpdateBadge` via `pi-core-event` DOM event. Successful core update triggers `/reload` to connected pi sessions, same as extension updates.
801
+
802
+ ### Settings → Packages tab
803
+
804
+ - Settings tab renders single `<UnifiedPackagesSection>`.
805
+ - Three sub-groups in priority order: Core → Recommended → Other.
806
+ - **Core**: strict whitelist from `pi-core-checker.ts#CORE_PACKAGE_NAMES`. Update via `/api/pi-core/update`. No Uninstall.
807
+ - **Recommended Extensions**: rows where `isRecommended` true on `/api/packages/installed` response (server-side cross-reference against `RECOMMENDED_EXTENSIONS` manifest).
808
+ - **Other Packages**: every remaining installed row.
809
+ - Each package classified into exactly one group. Core wins over Recommended wins over Other (dedupe).
810
+ - All three groups render with shared `<PackageRow>` — same visual language, same affordances modulo sub-group rules.
811
+ - See change: consolidate-packages-settings-ui.
752
812
 
753
813
  **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`.
754
814
 
@@ -808,7 +868,7 @@ The server has a two-layer access model:
808
868
 
809
869
  **Layer 1: Network Guard (`createNetworkGuard`)** — Fastify `preHandler` on all sensitive routes. Allows requests via three paths:
810
870
  1. **Loopback** — `127.0.0.1`, `::1`, `::ffff:127.0.0.1` (always allowed)
811
- 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`.
871
+ 2. **Trusted networks** — IPs matching `resolvedTrustedNetworks` (CIDR, wildcard, exact). `resolvedTrustedNetworks` computed at load time by merging two config sources: Settings UI writes new entries to `auth.bypassHosts` (canonical path on Security tab, surfaced as "Trusted Networks" section); legacy top-level `trustedNetworks` field remains readable for back-compat with hand-edited `config.json`. Both honor same matching logic; UI does not modify legacy field. **Both fields work independently of whether `auth.providers` is configured** — config with `auth: { providers: {}, bypassHosts: [...] }` honored as-is; auth plugin no-ops when provider registry empty + network guard serves bypass path directly. See `openspec/changes/archive/` for `fix-trusted-networks-no-oauth` which restored this after regression in `consolidate-trusted-networks`.
812
872
  3. **Authenticated** — `request.isAuthenticated === true` (set by auth `onRequest` hook via `decorateRequest`)
813
873
 
814
874
  Otherwise → 403. The guard strips `::ffff:` IPv4-mapped prefixes before matching.
@@ -1026,7 +1086,7 @@ The restart endpoint accepts `{ dev: boolean }` to switch between dev/production
1026
1086
 
1027
1087
  ### Cross-Platform Server Launch
1028
1088
 
1029
- 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.
1089
+ Dashboard server spawned via `node --import <loader> <cli.ts>` from 4 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** `--import` loader position AND entry-script position as URLs. Raw Windows path like `B:\Dev\cli.ts` parses with scheme `b:` (not in ESM loader's `file`/`data`/`node` allowlist) + crashes with `ERR_UNSUPPORTED_ESM_URL_SCHEME`. Node has drive-letter heuristic that auto-wraps common Windows paths with `file://` before URL parse in entry-script position, but heuristic has known gaps for less-common drives (`A:`, `B:`, ), so reliance unsafe.
1030
1090
 
1031
1091
  Both positions are wrapped as `file://` URLs universally:
1032
1092
 
@@ -1061,7 +1121,7 @@ This is a **race-independent fix**: it doesn't try to close the timing window, i
1061
1121
 
1062
1122
  #### AppImage CLI self-recursion guard (Linux power-user mode)
1063
1123
 
1064
- The Electron app's power-user launch path (`ensureServer()` → `detectPiDashboardCli()` → `launchViaCli()`) prefers an already-installed `pi-dashboard` CLI on PATH. On Linux **AppImage** builds, the AppImage runtime prepends its squashfs mount directory (e.g. `/tmp/.mount_PI-Das.../`) to `PATH` of the Electron child. That mount contains a binary literally named `pi-dashboard` because `forge.config.ts` declares `packagerConfig.executableName: "pi-dashboard"` for user-facing branding consistency. Without a guard, `which pi-dashboard` returns the AppImage's own launcher first; `launchViaCli()` then spawns the Electron app recursively as if it were the dashboard CLI, the recursive child silently ignores `start --port 8000`, never opens the dashboard port, and `waitForReady` polls until its 15-second deadline expires — user sees an indefinite loading screen.
1124
+ Electron's power-user launch path (`ensureServer()` → `detectPiDashboardCli()` → `launchViaCli()`) prefers already-installed `pi-dashboard` CLI on PATH. On Linux **AppImage** builds, AppImage runtime prepends its squashfs mount dir (e.g. `/tmp/.mount_PI-Das.../`) to `PATH` of Electron child. Mount contains binary literally named `pi-dashboard` because `forge.config.ts` declares `packagerConfig.executableName: "pi-dashboard"` for branding consistency. Without guard, `which pi-dashboard` returns AppImage's own launcher first; `launchViaCli()` spawns Electron app recursively as if it were dashboard CLI; recursive child silently ignores `start --port 8000`, never opens dashboard port, `waitForReady` polls until 15s deadline expires — user sees indefinite loading screen.
1065
1125
 
1066
1126
  The fix lives at two layers:
1067
1127
 
@@ -1227,7 +1287,7 @@ The dashboard supports browser-based authentication with pi's LLM providers, ena
1227
1287
 
1228
1288
  ### Model metadata enrichment for custom providers
1229
1289
 
1230
- 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`.
1290
+ Custom-provider `/v1/models` endpoints only advertise `{id, owned_by}` — do not expose `context_window`, `max_tokens`, `cost`, `reasoning`. Rather than hardcode flat 200k / 16k / $0 / no-reasoning on every discovered model (silently wrong for proxied frontier models like `proxy/cc/claude-opus-4-7` → Opus 4.7's 1M window), bridge's `registerEntry()` runs each discovered id through pure `enrichModelMetadata(id, api, probe)` helper. Helper: (a) strips common proxy prefixes (`cc/`, `anthropic/`, `openrouter/openai/…`) so bare id tried; (b) probes pi's `modelRegistry.find(provider, id)` via ordered api-appropriate candidate list (`anthropic-messages` → `["anthropic", "opencode"]`, `google-generative-ai` → `["google", "google-vertex"]`, `openai-completions` → `["openai", "openrouter", "groq", "xai", "mistral"]`); (c) returns registry's full metadata when matched. Registry reference captured from `ctx.modelRegistry` first time pi fires `session_start` on extension (`model_select` as fallback capture point) — no direct `@mariozechner/pi-ai` import. Since `activate()` registers providers before any event handler fires, first pass uses fallback defaults; `session_start` handler re-registers all providers with enriched metadata via `pi.registerProvider`'s idempotent "replace" semantics. When registry never available or no match, fallback keeps `input: ["text","image"]` so image-capable-by-default contract preserved. Built-in + OAuth providers bypass entirely — metadata comes from pi's bundled `models.generated.js`. See `packages/extension/src/provider-register.ts` + change `enrich-custom-provider-model-metadata`.
1231
1291
 
1232
1292
  ### Testing a custom provider (Test button)
1233
1293
 
@@ -1331,7 +1391,7 @@ The Electron installer can optionally ship a curated subset of recommended pi ex
1331
1391
 
1332
1392
  **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.
1333
1393
 
1334
- **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`).
1394
+ **First launch** (`installBundledExtensions()` in `dependency-installer.ts`): enumerates bundled subdirectories; for each id whose `manager.getInstalledPath(source, "user")` is **not** already populated, copies bundled tree into pi's git cache location (`~/.pi/agent/git/<host>/<path>/`), runs `npm install --omit=dev` if package declares runtime deps, then calls `manager.addSourceToSettings(gitUrl)` + `settingsManager.flush()` so original git URL persisted in `~/.pi/agent/settings.json`. Runs before `installRecommendedExtensions`; return value seeds that call's `skipPackages` set so already-bundled ids reported with `output: "Already installed (bundled)"`. Wizard renders distinct "Bundled ✓" badge for those rows + "Installed" badge for entries already present from prior CLI install (logic in pure helper `wizard-badge.ts`).
1335
1395
 
1336
1396
  **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.
1337
1397
  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`.
@@ -1666,7 +1726,25 @@ See change: `eliminate-bash-on-windows-runners`.
1666
1726
 
1667
1727
  ### Power-user-mode managed install (Defect 1 fix)
1668
1728
 
1669
- The Electron app's first-launch flow has three branches:
1729
+ ### LaunchSource V2 Resolution (Phase C default)
1730
+
1731
+ `selectLaunchSource()` in `packages/electron/src/lib/launch-source.ts` replaces the legacy `mode.json` + `isFirstRun` branching. Resolver walks five probe-based sources in priority order:
1732
+
1733
+ 1. `attach` — health probe returns 200 within 3s on the configured port.
1734
+ 2. `devMonorepo` — `!app.isPackaged AND existsSync(cwd/packages/server/src/cli.ts)`.
1735
+ 3. `piExtension` — `~/.pi/agent/settings.json` has a bridge extension with resolvable server package >= `bundledMinVersion`.
1736
+ 4. `npmGlobal` — `which pi-dashboard` returns a real-path not under `process.resourcesPath`, version >= `bundledMinVersion`.
1737
+ 5. `extracted` — always succeeds (fallback). May trigger bundle extraction from `process.resourcesPath` when version marker mismatches.
1738
+
1739
+ All probes are injectable (tests inject fakes). Override via `DASHBOARD_PREFER_SOURCE=<kind>` env var.
1740
+
1741
+ The spawned server receives `DASHBOARD_STARTER=Electron`. Lifecycle ownership rule: Electron calls `/api/shutdown` on quit ONLY when `health.starter === "Electron" AND health.pid === storedSpawnedPid`.
1742
+
1743
+ The `LAUNCH_SOURCE_V2=false` escape hatch reverts to the legacy `mode.json` path (documented below). The flag and its legacy path will be removed in a follow-up change.
1744
+
1745
+ ### Legacy first-launch flow (LAUNCH_SOURCE_V2=false)
1746
+
1747
+ The Electron app's first-launch flow has three branches (escape-hatch only):
1670
1748
 
1671
1749
  ```
1672
1750
  firstRun?
@@ -1783,3 +1861,140 @@ savedDraftRef: useRef<string> — in-progress draft captured when history mode
1783
1861
  **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.
1784
1862
 
1785
1863
  See change: `chat-input-draft-and-history`.
1864
+
1865
+ ## Git is required
1866
+
1867
+ The Electron app treats git as a hard runtime dependency. Several core code
1868
+ paths assume git is reachable (recommended-extension installs via pi's
1869
+ `DefaultPackageManager`, Settings → Packages git/HTTPS sources, BranchPicker /
1870
+ git status in session cards, attach-proposal workflow). Before the
1871
+ `require-git-on-boot` change, missing git silently degraded these features
1872
+ with cryptic errors. The change makes git a first-class detected, prompted,
1873
+ and installable dependency on every platform.
1874
+
1875
+ ### Boot-time gate
1876
+
1877
+ ```mermaid
1878
+ flowchart TD
1879
+ A[app.whenReady] --> B{First run?}
1880
+ B -- yes --> C[Wizard window]
1881
+ C --> D{Wizard tools step\nelse fall-through}
1882
+ D -- git missing --> C
1883
+ D -- git ok --> E[mode.json written]
1884
+ B -- no --> F{detectGit found?}
1885
+ F -- yes --> H[createMainWindow]
1886
+ F -- no --> G{escape-hatch set?\n--skip-git-gate or PI_DASHBOARD_SKIP_GIT_GATE=1}
1887
+ G -- yes --> H
1888
+ G -- no --> I[Open git-required.html window]
1889
+ I -- user installs/locates --> F
1890
+ I -- user closes window --> J[app.quit]
1891
+ E --> H
1892
+ ```
1893
+
1894
+ The pure decision lives in `packages/electron/src/lib/git-gate.ts`
1895
+ (`evaluateGitGate(detection, { argv, env, wizardWillRun })`), which honors
1896
+ both escape hatches and defers to the wizard on the first-run path
1897
+ (D11 — wizard↔gate handoff). Side effects live in
1898
+ `git-gate-window.ts::openGitRequiredWindow()` and the gate orchestration
1899
+ block in `main.ts`.
1900
+
1901
+ ### Platform install dispatch
1902
+
1903
+ `installTool(toolName, action, options)` in
1904
+ `packages/electron/src/lib/system-toolchain-installer.ts` dispatches to the
1905
+ OS-native package manager:
1906
+
1907
+ | Platform | git: install | node: install / upgrade |
1908
+ |---|---|---|
1909
+ | `win32` | `winget install --id Git.Git -e --source winget --accept-…` | `winget install/upgrade --id OpenJS.NodeJS …` |
1910
+ | `darwin` (brew present) | `brew install git` | `brew install/upgrade node` |
1911
+ | `darwin` (no brew) | `xcode-select --install` (Apple's Command Line Tools GUI) | manual link to nodejs.org |
1912
+ | `linux` (pkexec + pm) | `pkexec apt-get/dnf/zypper install -y git` (or `pacman -S --noconfirm git`, `apk add git`) | `pkexec <pm> install nodejs npm` |
1913
+ | `linux` (no pkexec) | manual `sudo …` snippet | manual snippet |
1914
+
1915
+ Pure command builders are exported and unit-tested without spawning anything
1916
+ (`buildWingetArgs`, `buildBrewArgs`, `buildXcodeSelectArgs`,
1917
+ `buildLinuxInstallArgs`, `buildSudoSnippet`); the Linux PM probe order is
1918
+ `apt-get → dnf → pacman → zypper → apk` (D10).
1919
+
1920
+ ### Single-flight + cancellation
1921
+
1922
+ A module-level `inFlight: Map<"git" | "node", …>` enforces at most one
1923
+ in-flight install per tool. A second invocation kills the first spawn
1924
+ (`platform/process.ts::killProcess`, idempotent across OS) and settles the
1925
+ prior promise to `{ kind: "cancelled", reason: "replaced-by-new-attempt" }`.
1926
+ The renderer exposes `[Cancel] (running…)` while a spawn is alive
1927
+ (`cancelInstall(tool)` settles to `{ kind: "cancelled", reason: "user-requested" }`).
1928
+
1929
+ ### Error formatting and fault tolerance (D8a)
1930
+
1931
+ Every installer surface returns a tagged `InstallResult` instead of throwing.
1932
+ The renderer never sees a raw stack trace — every non-`ok` result is rendered
1933
+ through `formatInstallError(result, platform)` which produces:
1934
+ - a plain-English headline (no error codes; e.g. "winget could not reach its
1935
+ package source" rather than "exit code 1978335212")
1936
+ - the failing command verbatim in a copy-button code block
1937
+ - the last 20 lines of stdout/stderr with `… (N earlier lines)` prefix
1938
+ - 1–3 actionable next-step bullets
1939
+
1940
+ The same formatter is used by:
1941
+ - the Electron wizard's "Last attempt" panel under the Git/Node rows
1942
+ - the boot-time `git-required.html` gate window
1943
+ - the dashboard CLI (`runInstallErrorPlainText` for non-markdown surfaces in
1944
+ `pi-dashboard upgrade-pi` and `runDegradedModeBootstrap`)
1945
+ - bootstrap-install.ts failures (the rich `installResult` field is attached
1946
+ to `BootstrapInstallFailure` so the CLI can render it)
1947
+
1948
+ ### Persistent log
1949
+
1950
+ Every gate-related I/O event is appended as a single-line JSON record to
1951
+ `~/.pi-dashboard/git-gate.log` (1 MB rotation cap, one historical
1952
+ `.log.1`). Top-level `uncaughtException` and `unhandledRejection` handlers
1953
+ in `main.ts` append a `{ event: "uncaught", level: "fatal", error, stack }`
1954
+ record BEFORE Electron's default crash dialog fires, ensuring all fatal
1955
+ boot-time crashes leave a forensic trail. The dashboard server's
1956
+ `pi-core-checker` failures also append (`level: "warn"`) via the shared
1957
+ `packages/shared/src/git-gate-log.ts` mirror.
1958
+
1959
+ See `docs/troubleshooting-windows-installer.md` §9 for the log format
1960
+ reference and grep recipes.
1961
+
1962
+ ### Escape hatches
1963
+
1964
+ Headless / SSH-only Linux server scenarios bypass the gate via
1965
+ `--skip-git-gate` (CLI flag) or `PI_DASHBOARD_SKIP_GIT_GATE=1` (env var),
1966
+ both checked unconditionally by `evaluateGitGate(...)`. The wizard's git
1967
+ row also honors the flag — when set, the row shows an amber "skipped" pill
1968
+ instead of a red blocker and Continue is enabled.
1969
+
1970
+ See change: require-git-on-boot.
1971
+
1972
+ ## Doctor Diagnostics
1973
+
1974
+ Single rich-output diagnostic surface. Three consumers wrap one shared core.
1975
+
1976
+ ```
1977
+ Electron lib (packages/electron/src/lib/doctor.ts)
1978
+
1979
+ Shared core (packages/shared/src/doctor-core.ts) ← runSharedChecks(deps)
1980
+
1981
+ Server route (packages/server/src/routes/doctor-routes.ts) GET /api/doctor
1982
+
1983
+ Web client (packages/client/src/components/DiagnosticsSection.tsx)
1984
+ ```
1985
+
1986
+ `doctor-core.ts` exports types (`DoctorCheck`, `DoctorReport`, `DoctorSection`, `ExecFailureKind`), the `SECTION_OF` + `SUGGESTIONS` lookup maps, helper primitives, and `runSharedChecks(deps)` (portable rows: pi binary + version, openspec binary + version, tsx binary, Node runtime compatibility, managed-dir layout, server health). Each consumer post-stamps section + suggestion via the shared maps so labels stay consistent across surfaces.
1987
+
1988
+ - **Electron**: `lib/doctor.ts` runs `runSharedChecks` plus Electron-only rows (Electron version, bundled Node, bundled npm, server-code path, offline-packages bundle, server-launch sanity test). `lib/doctor-window.ts` opens a `BrowserWindow` (1000×720, single-instance focus-reuse) that loads `renderer/doctor.html` through `preload/doctor-preload.ts`. IPC channels (`doctor:run`, `doctor:open-log`, `doctor:open-doctor-log`, `doctor:run-setup`, `doctor:copy`, `doctor:open-managed-dir`) defined as a frozen `DOCTOR_IPC_CHANNELS` map in `lib/doctor-bridge-contract.ts` so preload + renderer share one symbol — channel-name drift fails type-check.
1989
+ - **Server**: `routes/doctor-routes.ts` exposes `GET /api/doctor` returning `{checks, summary, generatedAt}`. Auth-gated identically to `/api/config`. Top-level `try/catch` returns 200 with a fallback row on internal throw — never 500. Omits Electron-only rows.
1990
+ - **Web client**: `lib/doctor-api.ts` exports `fetchDoctorReport()` returning a typed envelope (`DoctorFetchError` on non-200 / shape mismatch). `components/DiagnosticsSection.tsx` renders sections in fixed order, omits empty sections, shows status pill + message + truncated path + `<MarkdownContent>` suggestion. Toolbar Re-run + Copy as Markdown / Plain (textarea-modal fallback when `navigator.clipboard.writeText` rejects, e.g. non-secure-context).
1991
+
1992
+ ### Fault-tolerance contract
1993
+
1994
+ Diagnostics MUST never crash the app. `doctor-core.ts` enforces this with three primitives:
1995
+
1996
+ - **`safeCheck(name, section, fn)`** — per-check fault isolation. Wraps each individual check; swallows synchronous + async throws and returns an `error`-status row pinned to the named section. One broken check never blanks the report.
1997
+ - **`safeExec(cmd, opts)`** — bounded `execSync` wrapper. Classifies failure into `ExecFailureKind` (`not-found` | `permission-denied` | `timeout` | `non-zero-exit` | `unknown`) so callers render targeted suggestions instead of leaking raw stderr. `timeoutMs` defaults to a sane value; cold-start probes (e.g. server launch sanity) override to 15000.
1998
+ - **`assumedMandatory(label, fn, deps)`** — wraps "should-never-fail" ops (e.g. reading bundled-Node version, listing `~/.pi-dashboard/`). On throw it appends a structured entry to `<managedDir>/doctor.log` (1MB ring rotation) AND surfaces a row in the `Diagnostics` section so the user sees something went wrong instead of a silent gap. The log is opened from the toolbar (`Open doctor log`); the IPC handler returns `{exists:false}` when the file is absent so the renderer can show "no entries yet" instead of erroring.
1999
+
2000
+ See change: `doctor-rich-output`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-agent-dashboard",
3
- "version": "0.4.6",
3
+ "version": "0.5.0",
4
4
  "description": "Web dashboard for monitoring and interacting with pi agent sessions",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,15 +60,25 @@
60
60
  "electron:start": "npm run start --workspace=@blackbelt-technology/pi-dashboard-electron",
61
61
  "electron:make": "npm run make --workspace=@blackbelt-technology/pi-dashboard-electron",
62
62
  "electron:build": "bash packages/electron/scripts/build-installer.sh",
63
+ "electron:zip-windows": "bash packages/electron/scripts/build-windows-zip.sh",
64
+ "electron:zip-windows-docker": "bash packages/electron/scripts/build-installer.sh --windows-zip",
65
+ "electron:bundle-server": "node packages/electron/scripts/bundle-server.mjs",
66
+ "electron:bundle-server:source-only": "node packages/electron/scripts/bundle-server.mjs --source-only",
63
67
  "site:dev": "npm --prefix site run dev",
64
68
  "site:build": "npm --prefix site run build",
65
69
  "site:preview": "npm --prefix site run preview",
66
70
  "screenshots": "npm --prefix site run screenshots"
67
71
  },
72
+ "engines": {
73
+ "node": ">=22.12.0 <25"
74
+ },
68
75
  "dependencies": {
69
- "@blackbelt-technology/pi-dashboard-extension": "^0.4.6",
70
- "@blackbelt-technology/pi-dashboard-server": "^0.4.6",
71
- "@blackbelt-technology/pi-dashboard-web": "^0.4.6"
76
+ "@blackbelt-technology/pi-dashboard-extension": "^0.5.0",
77
+ "@blackbelt-technology/pi-dashboard-server": "^0.5.0",
78
+ "@blackbelt-technology/pi-dashboard-web": "^0.5.0"
79
+ },
80
+ "optionalDependencies": {
81
+ "appdmg": "^0.6.6"
72
82
  },
73
83
  "devDependencies": {
74
84
  "jsdom": "^29.0.2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-extension",
3
- "version": "0.4.6",
3
+ "version": "0.5.0",
4
4
  "description": "Pi bridge extension for pi-dashboard",
5
5
  "type": "module",
6
6
  "repository": {
@@ -24,7 +24,7 @@
24
24
  ".pi/skills/pi-dashboard/"
25
25
  ],
26
26
  "dependencies": {
27
- "@blackbelt-technology/pi-dashboard-shared": "^0.4.6",
27
+ "@blackbelt-technology/pi-dashboard-shared": "^0.5.0",
28
28
  "ws": "^8.18.0"
29
29
  },
30
30
  "peerDependencies": {