@blackbelt-technology/pi-agent-dashboard 0.4.5 → 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 +342 -267
- package/README.md +51 -2
- package/docs/architecture.md +266 -25
- 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-bus.test.ts +44 -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/__tests__/vcs-info-jj.test.ts +145 -0
- package/packages/extension/src/__tests__/{git-info.test.ts → vcs-info.test.ts} +6 -6
- package/packages/extension/src/bridge-context.ts +7 -0
- package/packages/extension/src/bridge.ts +142 -4
- package/packages/extension/src/command-handler.ts +6 -0
- package/packages/extension/src/markdown-image-inliner.ts +268 -0
- package/packages/extension/src/model-tracker.ts +35 -1
- package/packages/extension/src/prompt-bus.ts +4 -3
- 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 +6 -1
- package/packages/extension/src/vcs-info.ts +184 -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__/is-unread-trigger.test.ts +4 -2
- package/packages/server/src/__tests__/jj-routes.test.ts +93 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
- package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +114 -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-diff-vcs.test.ts +61 -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-restart.test.ts +4 -4
- 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 +22 -0
- package/packages/server/src/directory-service.ts +31 -0
- package/packages/server/src/event-wiring.ts +57 -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/openspec-tasks.ts +50 -19
- 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/jj-routes.ts +386 -0
- package/packages/server/src/routes/provider-auth-routes.ts +9 -0
- package/packages/server/src/routes/session-routes.ts +12 -3
- package/packages/server/src/routes/system-routes.ts +38 -1
- package/packages/server/src/server.ts +16 -9
- package/packages/server/src/session-bootstrap.ts +27 -12
- package/packages/server/src/session-diff.ts +118 -1
- 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__/platform-jj.test.ts +339 -0
- package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +18 -2
- package/packages/shared/src/bootstrap-install.ts +196 -2
- package/packages/shared/src/browser-protocol.ts +112 -1
- package/packages/shared/src/config.ts +29 -0
- package/packages/shared/src/dashboard-starter.ts +33 -0
- package/packages/shared/src/diff-types.ts +17 -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/jj.ts +405 -0
- package/packages/shared/src/platform/managed-node-path.ts +77 -0
- package/packages/shared/src/protocol.ts +60 -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 +19 -5
- package/packages/shared/src/tool-registry/strategies.ts +42 -0
- package/packages/shared/src/types.ts +91 -0
- package/packages/extension/src/git-info.ts +0 -55
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,54 @@ 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
|
+
|
|
557
|
+
### Edit Tool Diff Rendering (desktop vs mobile)
|
|
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):
|
|
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.
|
|
560
|
+
- **Mobile**: renders the homegrown CSS-colored unified patch (`createTwoFilesPatch` from `diff`, no syntax highlighting) — cheap and narrow-viewport-friendly.
|
|
561
|
+
The shared `<RichDiff>` component is also consumed by `DiffPanel` (Path A / change-derived diffs), centralising the `EXT_LANG_MAP`, `generateDiffFile` call, and `<DiffView>` prop set. See change: rich-diff-in-chat.
|
|
562
|
+
|
|
508
563
|
**Fork decisions and subagent ask_user:**
|
|
509
564
|
- Work through PromptBus — `TuiFlowIOAdapter` calls `ctx.ui.select/confirm/input` which the bridge routes through the bus to registered adapters (dashboard, TUI, or custom)
|
|
510
565
|
|
|
@@ -540,7 +595,7 @@ flowchart TD
|
|
|
540
595
|
H -->|No| J[Error logged to bridge stderr<br/>User must bootstrap via TUI]
|
|
541
596
|
```
|
|
542
597
|
|
|
543
|
-
**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.
|
|
544
599
|
|
|
545
600
|
### Server Restart (single-orchestrator path)
|
|
546
601
|
|
|
@@ -573,7 +628,7 @@ When a user sends a prompt to an ended session, the server automatically resumes
|
|
|
573
628
|
### Sidebar session ordering: top-of-tier on status change
|
|
574
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:
|
|
575
630
|
|
|
576
|
-
- **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.
|
|
577
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.
|
|
578
633
|
|
|
579
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`.
|
|
@@ -607,8 +662,28 @@ The priority chain (alive on click): `archiveBrowserCwd → specsBrowserCwd →
|
|
|
607
662
|
7. Client's event reducer stores `contextUsage` from `stats_update` events; `App.tsx` falls back to `session.contextTokens/contextWindow` for sessions without live reducer state
|
|
608
663
|
8. When real data is unavailable (e.g., old sessions without persisted context data), `state-replay.ts` and `session-stats-reader.ts` use `inferContextWindow()` to estimate context window from the model name
|
|
609
664
|
|
|
610
|
-
### Git
|
|
611
|
-
1. Bridge polls
|
|
665
|
+
### VCS Polling (Git + Jujutsu)
|
|
666
|
+
1. Bridge polls VCS info every 30s (`vcs-info.ts`, was `git-info.ts`): branch, remote URL, PR number, plus jj workspace state when `.jj/` is present.
|
|
667
|
+
2. Git half (`gatherGitInfo`): unchanged — emits `git_info_update` only when branch/PR change.
|
|
668
|
+
3. Jj half (`gatherJjInfo`): emits `jj_state_update` only when the serialized `JjState` changes. **Fast path**: a single `fs.existsSync("<cwd>/.jj")` check runs before any subprocess. Sessions outside a jj repo pay zero subprocess cost. The probe also short-circuits when the tool registry can't resolve `jj` (cached at module level after first miss).
|
|
669
|
+
4. Server forwards both update types via `session_updated` to subscribed browsers.
|
|
670
|
+
|
|
671
|
+
#### Jujutsu workspaces
|
|
672
|
+
|
|
673
|
+
The jj-plugin (`packages/jj-plugin/`) renders UI slots gated by predicates that read `Session.jjState`. When the bridge probe never populates `jjState` — because `jj` isn't installed or `.jj/` doesn't exist — every predicate returns `false` and the plugin contributes nothing to the UI. Activation is silent.
|
|
674
|
+
|
|
675
|
+
Server-side jj routes (`packages/server/src/routes/jj-routes.ts`):
|
|
676
|
+
- `POST /api/jj/workspace/add` — reuses the existing `pendingAttachRegistry` + `spawnPiSession` lever (same code path as openspec attach-and-spawn). The new session boots inside the workspace cwd and the bridge probe populates its `jjState.workspaceName` on the next tick.
|
|
677
|
+
- `POST /api/jj/workspace/forget` — two-step contract: first request returns 409 `UNFOLDED_WORK` listing the unfolded commits; only an explicit `force:true` re-issue actually deletes (and `rm -rf`'s the directory).
|
|
678
|
+
- `POST /api/jj/init-colocated` — refuses 409 `DIRTY_INDEX` only on staged changes; allows working-tree dirt (jj snapshots unstaged edits as the new `@` non-destructively).
|
|
679
|
+
- `GET /api/jj/workspace/list?cwd=` — enumerates workspaces.
|
|
680
|
+
|
|
681
|
+
The `/api/session-diff` route is **regime-aware**: when `jjState.isJjRepo` is true, it routes through `enrichWithJjDiff` which uses `fork_point(@, trunk())` as the diff base for non-default workspaces (cumulative diff across every agent commit) and `@-` for the default workspace. Older clients that don't read `vcsKind`/`baseLabel`/`diffBase` continue to work unchanged.
|
|
682
|
+
|
|
683
|
+
Fold-back is **a skill, not a server route**. The dashboard's `JjFoldBackDialog` builds a skill-invocation prompt; the agent's bash tool then drives `.pi/skills/jj-workspace-fold-back/SKILL.md`, which never invokes mutating git commands and uses `jj op restore` to roll back on conflicts.
|
|
684
|
+
|
|
685
|
+
### Git Polling (legacy entry, see VCS Polling above)
|
|
686
|
+
1. Bridge polls git info every 30s (`vcs-info.ts`): branch, remote URL, PR number
|
|
612
687
|
2. Changes are sent to the server only when values differ from last poll
|
|
613
688
|
3. Server broadcasts updates to subscribed browsers
|
|
614
689
|
|
|
@@ -635,7 +710,7 @@ A naive `for each cwd: list + for each change: status` fan-out explodes quickly:
|
|
|
635
710
|
|
|
636
711
|
The scheduler in `packages/server/src/directory-service.ts` applies four layers of throttling (all configurable under `DashboardConfig.openspec`):
|
|
637
712
|
|
|
638
|
-
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`.
|
|
639
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.
|
|
640
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.
|
|
641
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).
|
|
@@ -677,7 +752,7 @@ The dashboard's reusable directory chooser (`PathPicker`) is backed by three loc
|
|
|
677
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:
|
|
678
753
|
- **Tier 0** exact match → **Tier 1** prefix → **Tier 2** word-boundary substring (after `-`, `_`, `.`, space, `/`) → **Tier 3** plain substring.
|
|
679
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.
|
|
680
|
-
- `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.
|
|
681
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`).
|
|
682
757
|
|
|
683
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:
|
|
@@ -722,7 +797,18 @@ Package operations use pi's `DefaultPackageManager` API on the server, serialize
|
|
|
722
797
|
|
|
723
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.
|
|
724
799
|
|
|
725
|
-
|
|
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.
|
|
726
812
|
|
|
727
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`.
|
|
728
814
|
|
|
@@ -782,7 +868,7 @@ The server has a two-layer access model:
|
|
|
782
868
|
|
|
783
869
|
**Layer 1: Network Guard (`createNetworkGuard`)** — Fastify `preHandler` on all sensitive routes. Allows requests via three paths:
|
|
784
870
|
1. **Loopback** — `127.0.0.1`, `::1`, `::ffff:127.0.0.1` (always allowed)
|
|
785
|
-
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`.
|
|
786
872
|
3. **Authenticated** — `request.isAuthenticated === true` (set by auth `onRequest` hook via `decorateRequest`)
|
|
787
873
|
|
|
788
874
|
Otherwise → 403. The guard strips `::ffff:` IPv4-mapped prefixes before matching.
|
|
@@ -1000,7 +1086,7 @@ The restart endpoint accepts `{ dev: boolean }` to switch between dev/production
|
|
|
1000
1086
|
|
|
1001
1087
|
### Cross-Platform Server Launch
|
|
1002
1088
|
|
|
1003
|
-
|
|
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.
|
|
1004
1090
|
|
|
1005
1091
|
Both positions are wrapped as `file://` URLs universally:
|
|
1006
1092
|
|
|
@@ -1035,7 +1121,7 @@ This is a **race-independent fix**: it doesn't try to close the timing window, i
|
|
|
1035
1121
|
|
|
1036
1122
|
#### AppImage CLI self-recursion guard (Linux power-user mode)
|
|
1037
1123
|
|
|
1038
|
-
|
|
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.
|
|
1039
1125
|
|
|
1040
1126
|
The fix lives at two layers:
|
|
1041
1127
|
|
|
@@ -1201,7 +1287,7 @@ The dashboard supports browser-based authentication with pi's LLM providers, ena
|
|
|
1201
1287
|
|
|
1202
1288
|
### Model metadata enrichment for custom providers
|
|
1203
1289
|
|
|
1204
|
-
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`.
|
|
1205
1291
|
|
|
1206
1292
|
### Testing a custom provider (Test button)
|
|
1207
1293
|
|
|
@@ -1305,7 +1391,7 @@ The Electron installer can optionally ship a curated subset of recommended pi ex
|
|
|
1305
1391
|
|
|
1306
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.
|
|
1307
1393
|
|
|
1308
|
-
**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`).
|
|
1309
1395
|
|
|
1310
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.
|
|
1311
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`.
|
|
@@ -1640,7 +1726,25 @@ See change: `eliminate-bash-on-windows-runners`.
|
|
|
1640
1726
|
|
|
1641
1727
|
### Power-user-mode managed install (Defect 1 fix)
|
|
1642
1728
|
|
|
1643
|
-
|
|
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):
|
|
1644
1748
|
|
|
1645
1749
|
```
|
|
1646
1750
|
firstRun?
|
|
@@ -1757,3 +1861,140 @@ savedDraftRef: useRef<string> — in-progress draft captured when history mode
|
|
|
1757
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.
|
|
1758
1862
|
|
|
1759
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": {
|