@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.
- package/AGENTS.md +339 -190
- package/README.md +31 -0
- package/docs/architecture.md +238 -23
- package/package.json +14 -4
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
- package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
- package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
- package/packages/extension/src/bridge.ts +110 -1
- package/packages/extension/src/command-handler.ts +6 -0
- package/packages/extension/src/markdown-image-inliner.ts +268 -0
- package/packages/extension/src/prompt-expander.ts +50 -2
- package/packages/extension/src/provider-register.ts +117 -0
- package/packages/extension/src/server-launcher.ts +18 -1
- package/packages/extension/src/session-sync.ts +5 -0
- package/packages/server/package.json +4 -4
- package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
- package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
- package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
- package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
- package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
- package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
- package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
- package/packages/server/src/__tests__/health-shape.test.ts +43 -0
- package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
- package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
- package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
- package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
- package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
- package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
- package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
- package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
- package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
- package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
- package/packages/server/src/bootstrap-install-from-list.ts +232 -0
- package/packages/server/src/bootstrap-state.ts +18 -0
- package/packages/server/src/browser-gateway.ts +58 -21
- package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
- package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
- package/packages/server/src/cli.ts +21 -0
- package/packages/server/src/directory-service.ts +31 -0
- package/packages/server/src/event-wiring.ts +48 -2
- package/packages/server/src/home-lock.d.ts +124 -0
- package/packages/server/src/home-lock.js +330 -0
- package/packages/server/src/home-lock.js.map +1 -0
- package/packages/server/src/idle-timer.ts +15 -1
- package/packages/server/src/pi-core-updater.ts +65 -9
- package/packages/server/src/pi-gateway.ts +6 -0
- package/packages/server/src/process-manager.ts +62 -11
- package/packages/server/src/provider-auth-handlers.ts +9 -0
- package/packages/server/src/provider-auth-storage.ts +83 -51
- package/packages/server/src/provider-catalogue-cache.ts +41 -0
- package/packages/server/src/routes/doctor-routes.ts +140 -0
- package/packages/server/src/routes/provider-auth-routes.ts +9 -0
- package/packages/server/src/routes/system-routes.ts +38 -1
- package/packages/server/src/server.ts +8 -7
- package/packages/server/src/session-bootstrap.ts +27 -12
- package/packages/server/src/session-discovery.ts +10 -3
- package/packages/server/src/session-scanner.ts +4 -2
- package/packages/server/src/spawn-failure-log.ts +130 -0
- package/packages/server/src/spawn-preflight.ts +82 -0
- package/packages/server/src/spawn-register-watchdog.ts +236 -0
- package/packages/server/src/terminal-manager.ts +12 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
- package/packages/shared/src/__tests__/config.test.ts +48 -0
- package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
- package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
- package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
- package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
- package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
- package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
- package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
- package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
- package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
- package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
- package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
- package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
- package/packages/shared/src/bootstrap-install.ts +196 -2
- package/packages/shared/src/browser-protocol.ts +112 -1
- package/packages/shared/src/config.ts +15 -0
- package/packages/shared/src/dashboard-starter.ts +33 -0
- package/packages/shared/src/doctor-core.ts +821 -0
- package/packages/shared/src/index.ts +9 -0
- package/packages/shared/src/installable-list.ts +152 -0
- package/packages/shared/src/launch-source-flag.ts +14 -0
- package/packages/shared/src/launch-source-types.ts +18 -0
- package/packages/shared/src/openspec-activity-detector.ts +25 -7
- package/packages/shared/src/platform/detached-spawn.ts +13 -2
- package/packages/shared/src/platform/managed-node-path.ts +77 -0
- package/packages/shared/src/protocol.ts +46 -2
- package/packages/shared/src/rest-api.ts +4 -0
- package/packages/shared/src/skill-block-parser.ts +115 -0
- package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
- package/packages/shared/src/tool-registry/definitions.ts +18 -5
- package/packages/shared/src/tool-registry/strategies.ts +42 -0
- 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 `` or `` 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
|
package/docs/architecture.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
## Overview
|
|
11
11
|
|
|
12
|
-
|
|
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
|
-
|
|
31
|
-
- Detects session source (TUI, Zed, tmux, dashboard-spawned) via `.meta.json` sidecar files
|
|
32
|
-
- Forwards all pi events to
|
|
33
|
-
- Relays commands from
|
|
34
|
-
- Handles reconnection with exponential backoff
|
|
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
|
|
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
|
-
|
|
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**:
|
|
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
|
-
|
|
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):
|
|
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
|
|
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 (`` or ``) 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: " 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: " 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.
|
|
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
|
|
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
|
|
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`.
|
|
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
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
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}` —
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
70
|
-
"@blackbelt-technology/pi-dashboard-server": "^0.
|
|
71
|
-
"@blackbelt-technology/pi-dashboard-web": "^0.
|
|
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.
|
|
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.
|
|
27
|
+
"@blackbelt-technology/pi-dashboard-shared": "^0.5.0",
|
|
28
28
|
"ws": "^8.18.0"
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|