@blackbelt-technology/pi-agent-dashboard 0.4.0 → 0.4.2
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 +104 -35
- package/README.md +390 -494
- package/docs/architecture.md +423 -20
- package/package.json +11 -8
- package/packages/extension/package.json +11 -4
- package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +91 -15
- package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
- package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
- package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
- package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
- package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
- package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
- package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
- package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
- package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
- package/packages/extension/src/ask-user-tool.ts +170 -61
- package/packages/extension/src/bridge.ts +199 -19
- package/packages/extension/src/multiselect-decode.ts +40 -0
- package/packages/extension/src/multiselect-list.ts +146 -0
- package/packages/extension/src/multiselect-polyfill.ts +73 -0
- package/packages/extension/src/server-launcher.ts +15 -3
- package/packages/extension/src/ui-modules.ts +272 -0
- package/packages/server/package.json +11 -5
- package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
- package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
- package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
- package/packages/server/src/__tests__/directory-service.test.ts +174 -0
- package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
- package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
- package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
- package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
- package/packages/server/src/__tests__/package-routes.test.ts +136 -3
- package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
- package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
- package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
- package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +72 -0
- package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
- package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
- package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
- package/packages/server/src/__tests__/restart-helper.test.ts +34 -6
- package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
- package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
- package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
- package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
- package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
- package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
- package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
- package/packages/server/src/browse.ts +118 -13
- package/packages/server/src/browser-gateway.ts +19 -0
- package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
- package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
- package/packages/server/src/browser-handlers/handler-context.ts +15 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
- package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
- package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
- package/packages/server/src/cli.ts +61 -15
- package/packages/server/src/directory-service.ts +156 -15
- package/packages/server/src/event-wiring.ts +111 -10
- package/packages/server/src/installed-package-enricher.ts +143 -0
- package/packages/server/src/package-manager-wrapper.ts +305 -8
- package/packages/server/src/package-source-helpers.ts +104 -0
- package/packages/server/src/pending-attach-registry.ts +112 -0
- package/packages/server/src/pending-resume-intent-registry.ts +107 -0
- package/packages/server/src/pi-core-checker.ts +9 -14
- package/packages/server/src/pi-gateway.ts +14 -0
- package/packages/server/src/pi-version-skew.ts +12 -1
- package/packages/server/src/proposal-attach-naming.ts +47 -0
- package/packages/server/src/restart-helper.ts +13 -2
- package/packages/server/src/routes/file-routes.ts +29 -3
- package/packages/server/src/routes/package-routes.ts +72 -3
- package/packages/server/src/routes/plugin-config-routes.ts +129 -0
- package/packages/server/src/routes/system-routes.ts +2 -0
- package/packages/server/src/server.ts +339 -10
- package/packages/server/src/session-api.ts +30 -5
- package/packages/server/src/session-order-manager.ts +22 -0
- package/packages/server/src/session-scanner.ts +10 -1
- package/packages/shared/package.json +9 -2
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
- package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
- package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
- package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
- package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
- package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
- package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
- package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
- package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
- package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
- package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
- package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
- package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
- package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
- package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
- package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
- package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
- package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
- package/packages/shared/src/browser-protocol.ts +110 -4
- package/packages/shared/src/config.ts +45 -0
- package/packages/shared/src/dashboard-plugin/index.ts +11 -0
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
- package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
- package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
- package/packages/shared/src/openspec-activity-detector.ts +18 -22
- package/packages/shared/src/openspec-design-evidence.ts +109 -0
- package/packages/shared/src/openspec-poller.ts +117 -3
- package/packages/shared/src/openspec-specs-evidence.ts +79 -0
- package/packages/shared/src/platform/binary-lookup.ts +96 -1
- package/packages/shared/src/platform/index.ts +1 -0
- package/packages/shared/src/platform/node-spawn.ts +154 -0
- package/packages/shared/src/plugin-bridge-register.ts +139 -0
- package/packages/shared/src/protocol.ts +79 -2
- package/packages/shared/src/recommended-extensions.ts +7 -1
- package/packages/shared/src/rest-api.ts +68 -3
- package/packages/shared/src/state-replay.ts +20 -1
- package/packages/shared/src/tool-registry/definitions.ts +92 -0
- package/packages/shared/src/tool-registry/strategies.ts +17 -3
- package/packages/shared/src/types.ts +160 -0
package/docs/architecture.md
CHANGED
|
@@ -36,12 +36,13 @@ A global pi extension that runs in every pi session. It:
|
|
|
36
36
|
- Server liveness watchdog: forces reconnect if no message received for 60s
|
|
37
37
|
- Server-side WS ping/pong (60s interval) detects dead TCP connections; requires 2 consecutive missed pongs before killing (tolerates long-running bash commands that block the event loop)
|
|
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
|
+
- **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.
|
|
39
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.
|
|
40
41
|
- **Subagent re-entry guard**: When pi-subagents launches an Agent tool, the subagent creates its own `AgentSession` which loads extensions (including the bridge) in the same process. Without protection, this would overwrite the parent bridge's global state, disconnect its WebSocket, and prevent `tool_execution_end`/`agent_end` from being forwarded — leaving the parent session stuck at "streaming" forever. The bridge stores a reference to its owning `pi` instance and skips initialization when called from a different instance (subagent).
|
|
41
|
-
- Routes `ctx.ui` dialog methods (confirm, select, input, editor, notify) through `PromptBus` (`prompt-bus.ts`)
|
|
42
|
+
- Routes `ctx.ui` dialog methods (confirm, select, input, editor, multiselect, notify) through `PromptBus` (`prompt-bus.ts`)
|
|
42
43
|
- Adapters register to handle prompts: `DashboardDefaultAdapter` renders generic dialogs inline; extensions (e.g. pi-flows) can register custom adapters via `prompt:register-adapter` event
|
|
43
44
|
- First-response-wins: multiple adapters (TUI, dashboard, custom) can claim a prompt; the first to respond resolves it, others are dismissed
|
|
44
|
-
- Bridge's TUI adapter is registered inline (captures original `ctx.ui` methods before patching) and presents prompts in the terminal with AbortController-based cancellation
|
|
45
|
+
- Bridge's TUI adapter is registered inline (captures original `ctx.ui` methods before patching) and presents `select`/`input`/`confirm`/`editor` prompts in the terminal with AbortController-based cancellation. Multiselect bypasses the TUI adapter entirely and uses the bus-routed `ctx.ui.multiselect` patch → `DashboardDefaultAdapter` → client `MultiselectRenderer` exclusively (pi 0.70 RPC's `ctx.ui.custom` is a no-op, so a TUI arm would auto-cancel the dashboard render in <1s). See changes: fix-multiselect-auto-cancel-on-dashboard, fix-multiselect-tui-arm-self-cancel.
|
|
45
46
|
- Patched `ctx.ui` methods forward the `message` field (from opts) via `metadata` in the PromptBus request
|
|
46
47
|
- Client-side `prompt-component-registry.ts` maps component type strings to render placement (inline, widget-bar, overlay)
|
|
47
48
|
- Protocol messages: `prompt_request`, `prompt_dismiss`, `prompt_cancel`, `prompt_response`
|
|
@@ -57,7 +58,8 @@ A Node.js HTTP + WebSocket server that:
|
|
|
57
58
|
- Persists global preferences (pinned directories, session order) in `~/.pi/dashboard/preferences.json`
|
|
58
59
|
- Discovers historical sessions directly from disk via `SessionManager.list()` (DirectoryService)
|
|
59
60
|
- Loads session events on demand directly from disk via `SessionManager.open()` (DirectoryService)
|
|
60
|
-
- Polls OpenSpec CLI per directory every 30s, broadcasting changes to browsers (DirectoryService)
|
|
61
|
+
- Polls OpenSpec CLI per directory every 30s, broadcasting changes to browsers (DirectoryService).
|
|
62
|
+
- **Design-artifact override**: After receiving the CLI's per-change `status`, `buildOpenSpecData` post-processes the `design` artifact: when CLI says `design: ready`, the dashboard checks local file-system evidence (R1: `^design.*\.md$` present; R2: `design/*.md` present; R3: `tasks.md` contains a Markdown checkbox) and promotes `design.status` to `"done"` if any rule fires. The override is **promote-only and design-only** — never demotes, never touches other artifact ids, never promotes from `"blocked"`. Change-level `isComplete` is re-derived locally; CLI `isComplete: true` is never demoted. The same R1/R2/R3 rules are mirrored in `.pi/skills/openspec-shared/scripts/effective-status.sh` so OpenSpec workflow skills and dashboard session-card buttons cannot disagree about a change's next-ready artifact. See change: fix-openspec-design-detection.
|
|
61
63
|
- Serves the built web client as static files (production) or proxies to Vite dev server (dev mode)
|
|
62
64
|
- Writes per-session `.meta.json` sidecar files with dashboard state and cached stats
|
|
63
65
|
- Exposes REST API for session management, event content fetch, pinned directories, and file reading
|
|
@@ -75,7 +77,7 @@ A Node.js HTTP + WebSocket server that:
|
|
|
75
77
|
### 3. Web Client (`src/client/`)
|
|
76
78
|
A React-based responsive web UI that:
|
|
77
79
|
- Shows all active sessions organized by directory, with pinned directories always visible at the top
|
|
78
|
-
- Renders chat messages with markdown, syntax highlighting, and
|
|
80
|
+
- Renders chat messages with markdown, syntax highlighting, streaming, and a small raw-HTML pass that strips React-only `ref` attributes before render
|
|
79
81
|
- Persists scroll position per session — switching sessions restores exact scroll position if locked, or scrolls to bottom if following
|
|
80
82
|
- Displays collapsed tool call steps with lazy-loaded content and elapsed time badges
|
|
81
83
|
- Shows live ticking elapsed counters on running operations (thinking, tool calls) and final duration on completed ones
|
|
@@ -100,7 +102,7 @@ TypeScript type definitions shared across all components:
|
|
|
100
102
|
5. Browser's event reducer processes event, React renders update
|
|
101
103
|
|
|
102
104
|
### Interactive UI Flow (PromptBus — extension dialog → browser → response)
|
|
103
|
-
1. Extension calls `ctx.ui.confirm()` / `select()` / `input()` / `editor()`
|
|
105
|
+
1. Extension calls `ctx.ui.confirm()` / `select()` / `input()` / `editor()` / bridge-patched `multiselect()`
|
|
104
106
|
2. Bridge PromptBus intercepts via patched `ctx.ui` methods, creates a `PromptRequest` with a unique `promptId` and `pipeline` tag (e.g. `"command"`, `"architect"`)
|
|
105
107
|
3. Registered adapters claim the prompt:
|
|
106
108
|
- `DashboardDefaultAdapter` (always registered) returns a `PromptClaim` with `component: { type: "generic-dialog", props }` and `placement: "inline"`
|
|
@@ -112,6 +114,8 @@ TypeScript type definitions shared across all components:
|
|
|
112
114
|
7. User responds in browser → `prompt_response` sent to server → routed to bridge
|
|
113
115
|
8. Bus resolves the original dialog promise and calls `onResponse()` on all adapters for cleanup
|
|
114
116
|
|
|
117
|
+
**Multiselect note:** pi's upstream `ExtensionUIContext` has no native `multiselect` method, so the bridge attaches `ctx.ui.multiselect` during `session_start`. `ask_user` dispatches multiselect through `polyfillMultiselect`, which delegates to that patched PromptBus method when present and falls back to `ctx.ui.custom` + `MultiSelectList` for legacy / non-bridge contexts (the fallback is a no-op in pi 0.70 RPC mode — dashboard headless — because pi-coding-agent defines `custom` as `async () => undefined` there). The bridge intentionally registers NO TUI adapter arm for multiselect; routing is bus-only. Browser responses encode `{ values: string[] }` as `JSON.stringify(values)` in `prompt_response.answer`, preserving `[]` as a real empty selection distinct from cancellation.
|
|
118
|
+
|
|
115
119
|
**First-response-wins (multi-adapter):**
|
|
116
120
|
- Multiple adapters can claim the same prompt (e.g. TUI + dashboard)
|
|
117
121
|
- The first adapter to respond wins; the bus sends `prompt_dismiss` to the server for the losing adapter's dashboard component
|
|
@@ -158,6 +162,209 @@ pi-flows runs multi-agent workflows in-process. Subagent sessions use `SessionMa
|
|
|
158
162
|
- Abort: browser sends `flow_control { action: "abort" }` → server → bridge → `pi.events.emit("flow:abort")` → `flowManager.abort()`
|
|
159
163
|
- Autonomous toggle: browser sends `flow_control { action: "toggle_autonomous" }` → same path → `setAutonomousMode()`
|
|
160
164
|
|
|
165
|
+
### Extension UI System (Phases 1 + 2 shipped)
|
|
166
|
+
|
|
167
|
+
A generalized mechanism for extensions to declare dashboard UIs as data without authoring React or importing a runtime SDK. Phase 1 (`management-modal` slot) shipped in change `add-extension-ui-modal`. Phase 2 (live in-page decorations) shipped in change `add-extension-ui-decorations`. Phase 4 RJSF is tracked in `add-extension-ui-rjsf-form`.
|
|
168
|
+
|
|
169
|
+
**Mechanism (pull-based discovery, synchronous probe):**
|
|
170
|
+
|
|
171
|
+
```mermaid
|
|
172
|
+
sequenceDiagram
|
|
173
|
+
participant Ext as Extension (e.g. pi-judo)
|
|
174
|
+
participant Bridge as Bridge (pi process)
|
|
175
|
+
participant Server as Dashboard Server
|
|
176
|
+
participant Browser as Dashboard Browser
|
|
177
|
+
|
|
178
|
+
Note over Bridge: session_start (reason ∈ {new,fork,resume})
|
|
179
|
+
Bridge->>Ext: pi.events.emit("ui:list-modules", probe)
|
|
180
|
+
Ext-->>Bridge: probe.modules.push({ kind, id, command, view, … })
|
|
181
|
+
Bridge->>Server: ui_modules_list { sessionId, modules }
|
|
182
|
+
Server->>Browser: ui_modules_list (cache + forward)
|
|
183
|
+
|
|
184
|
+
Note over Browser: user types /judo:status
|
|
185
|
+
Browser->>Server: ui_management { action: "list", event: "judo:status-rows" }
|
|
186
|
+
Server->>Bridge: ui_management
|
|
187
|
+
Bridge->>Ext: pi.events.emit("judo:status-rows", { action, _reply })
|
|
188
|
+
Ext-->>Bridge: data.items = […]
|
|
189
|
+
Bridge->>Server: ui_data_list { sessionId, event, items }
|
|
190
|
+
Server->>Browser: ui_data_list (cache + forward)
|
|
191
|
+
|
|
192
|
+
Note over Ext: state changes
|
|
193
|
+
Ext->>Bridge: pi.events.emit("ui:invalidate", { id })
|
|
194
|
+
Bridge->>Ext: pi.events.emit("ui:list-modules", probe)
|
|
195
|
+
Bridge->>Server: ui_modules_list (refreshed)
|
|
196
|
+
Server->>Browser: ui_modules_list
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Key properties:
|
|
200
|
+
1. The probe is **synchronous** — listeners push into `probe.modules` while `pi.events.emit` is running. The bridge never polls and never caches across probes; idempotent re-registration just produces a fresh probe on the next trigger.
|
|
201
|
+
2. **No SDK package** — extensions only need `pi.events` (already provided by the host). Schema types live in `@blackbelt-technology/pi-dashboard-shared`.
|
|
202
|
+
3. **Last-write-wins on duplicate `id`** within a single probe; bridge logs one warning per collision.
|
|
203
|
+
|
|
204
|
+
**Phase-1 surface (shipped):**
|
|
205
|
+
|
|
206
|
+
- `kind: "management-modal"` — slash-command-triggered modal.
|
|
207
|
+
- `view.kind` ∈ `"table" | "grid" | "form"`.
|
|
208
|
+
- `UiField.kind` ∈ `"text" | "number" | "boolean" | "select" | "code" | "datetime" | "textarea"`.
|
|
209
|
+
- `UiAction.confirm` polish via the existing Tailwind `ConfirmDialog` (no `window.confirm()`).
|
|
210
|
+
- Icons resolved against `@mdi/js` keys; unknown keys render no icon (no error).
|
|
211
|
+
- Slash-command interception in `App.tsx`'s `wrappedHandleSend`; built-in collisions (`/model`, `/compact`, `/flows`, etc.) drop the module with a `console.warn`.
|
|
212
|
+
- "Modules" entry point in `SessionHeader` shows when `session.uiModules?.length > 0`.
|
|
213
|
+
|
|
214
|
+
**Phase-1 wire protocol:**
|
|
215
|
+
|
|
216
|
+
| Direction | Type | Purpose |
|
|
217
|
+
|---|---|---|
|
|
218
|
+
| extension → server → browser | `ui_modules_list { sessionId, modules }` | Cached schemas. |
|
|
219
|
+
| extension → server → browser | `ui_data_list { sessionId, event, items }` | Row data for `table`/`grid` views. |
|
|
220
|
+
| browser → server → extension | `ui_management { sessionId, action, event, params }` | Data fetch (`action: "list"`) or user action. |
|
|
221
|
+
|
|
222
|
+
**Replay on reconnect:** Server caches `Session.uiModules` and `Session.uiDataMap` (per-event item cap = 1000, last-write-wins on overflow). The replay site is `replayUiState(ws, sessionId, ctx)` in `packages/server/src/browser-handlers/subscription-handler.ts`, called immediately after every existing `replayPendingUiRequests(ws, sessionId)` site (4 sites: stale-lastSeq full replay, delta replay, no-events path, lazy load from disk). Replay ordering: events → pending UI requests → UI module state.
|
|
223
|
+
|
|
224
|
+
**Phase-2 surface (shipped):**
|
|
225
|
+
|
|
226
|
+
Five live in-page decoration kinds reuse the same `ui:list-modules` probe primitive. Decorators carry an explicit `namespace: string` (must match `/^[a-z0-9-]+$/`) plus `id`, partitioned at the bridge and forwarded as one `ext_ui_decorator` message per descriptor. Server caches under `Session.uiDecorators[`${kind}:${namespace}:${id}`]` and replays after the Phase-1 batches.
|
|
227
|
+
|
|
228
|
+
| Kind | Mount site | Filter | Closure? |
|
|
229
|
+
|---|---|---|---|
|
|
230
|
+
| `footer-segment` | `SessionHeader.tsx`, right of git/model info | `kind === "footer-segment"` | Yes — extension supplies fresh `payload.text` per probe |
|
|
231
|
+
| `agent-metric` | Inside `FlowAgentCard.tsx` (one per card) | `kind === "agent-metric" && payload.agentId === card.agentName` | Yes |
|
|
232
|
+
| `breadcrumb` | Top of `FlowDashboard.tsx` | `kind === "breadcrumb"` (most recent wins) | No (snapshot) |
|
|
233
|
+
| `gate` | Inline in each `FlowLaunchDialog` | `kind === "gate" && payload.flowId === item.flowId` (most-restrictive aggregate) | No |
|
|
234
|
+
| `toast` | `App.tsx` (top-right fixed tray) | `kind === "toast"` (stacks; auto-dismiss; FIFO display cap = 5) | No |
|
|
235
|
+
|
|
236
|
+
Decorator removal is **explicit**: extensions push a descriptor with `removed: true` and the bridge forwards it verbatim; the server deletes the cache entry under the matching key (no-op if absent) and broadcasts the removal so client slots can unmount the matching descriptor without affecting siblings.
|
|
237
|
+
|
|
238
|
+
**Phase-2 wire protocol:**
|
|
239
|
+
|
|
240
|
+
| Direction | Type | Purpose |
|
|
241
|
+
|---|---|---|
|
|
242
|
+
| extension → server → browser | `ext_ui_decorator { sessionId, descriptor, removed? }` | Live decoration upsert (or removal when `removed: true`). |
|
|
243
|
+
|
|
244
|
+
The message is a discriminated union over `descriptor.kind`. `ExtUiDecoratorMessage` is a member of both `ExtensionToServerMessage` and `ServerToBrowserMessage` (verified by a type-level test in `packages/shared/src/__tests__/browser-protocol-types.test.ts` — esbuild silently strips switch arms whose message types are not in the production union).
|
|
245
|
+
|
|
246
|
+
**Phase-2 sequence (invalidate → probe → ext_ui_decorator → slot re-render):**
|
|
247
|
+
|
|
248
|
+
```mermaid
|
|
249
|
+
sequenceDiagram
|
|
250
|
+
participant Ext as Extension (e.g. pi-judo)
|
|
251
|
+
participant Bridge as Bridge (pi process)
|
|
252
|
+
participant Server as Dashboard Server
|
|
253
|
+
participant Browser as Dashboard Browser
|
|
254
|
+
|
|
255
|
+
Note over Ext: state changes (e.g. judo workspace mutation count incremented)
|
|
256
|
+
Ext->>Bridge: pi.events.emit("ui:invalidate", { id })
|
|
257
|
+
Bridge->>Ext: pi.events.emit("ui:list-modules", probe)
|
|
258
|
+
Ext-->>Bridge: probe.modules.push({ kind: "footer-segment", namespace, id, payload })
|
|
259
|
+
Note over Bridge: partition by kind — modal kinds → ui_modules_list,<br/>decorator kinds → one ext_ui_decorator each
|
|
260
|
+
Bridge->>Server: ext_ui_decorator { sessionId, descriptor }
|
|
261
|
+
Server->>Server: cache under `${kind}:${namespace}:${id}` on Session.uiDecorators
|
|
262
|
+
Server->>Browser: ext_ui_decorator (broadcast verbatim)
|
|
263
|
+
Browser->>Browser: per-kind slot component re-renders
|
|
264
|
+
|
|
265
|
+
Note over Ext: state cleared
|
|
266
|
+
Ext->>Bridge: probe.modules.push({ kind, namespace, id, payload, removed: true })
|
|
267
|
+
Bridge->>Server: ext_ui_decorator { ..., removed: true }
|
|
268
|
+
Server->>Server: delete cache entry; broadcast removal
|
|
269
|
+
Server->>Browser: ext_ui_decorator { ..., removed: true }
|
|
270
|
+
Browser->>Browser: slot unmounts the matching descriptor only
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
**Rate cap:** to prevent runaway extensions, the bridge throttles `ui:invalidate` re-probes per session to one probe every 50 ms (= 20/sec). Excess events coalesce into a single trailing-edge probe; a single warning is emitted per offending burst, latched until a quiet window passes.
|
|
274
|
+
|
|
275
|
+
**Replay ordering** (extended from Phase 1): events → pending UI requests → `ui_modules_list` → `ui_data_list` (per event) → `ext_ui_decorator` (per cache key). Replay decorator messages never carry `removed: true` — only live entries are replayed.
|
|
276
|
+
|
|
277
|
+
**Phase 4 (optional):** `rjsf-form` JSON-Schema escape hatch for rich forms; see `add-extension-ui-rjsf-form`.
|
|
278
|
+
|
|
279
|
+
**Relationship to existing capabilities:**
|
|
280
|
+
- `interactive-ui-dialogs` / `ui-proxy` / PromptBus — handle one-shot `ctx.ui.*` dialogs (request/response, awaited). The extension-ui-system handles persistent push-based descriptors (no awaiting). Orthogonal mechanisms; both ship.
|
|
281
|
+
- `extension-ui-forwarding` (catch-all `pi.events.emit` forwarding) — kept for arbitrary extension events; the new system is the *declarative* path for UI specifically.
|
|
282
|
+
- pi-flows: in Phase 3 pi-flows itself adopts the system to surface registered workflows (breadcrumb), gates, and cards (agent-metric) for any flow-using extension automatically.
|
|
283
|
+
|
|
284
|
+
**No-dashboard fallback:** When no bridge is connected, `ui:list-modules` is never emitted; extension listeners are dormant; slash commands fall back to existing text-output behavior. Extensions remain pi-runnable in pure-pi mode without code changes.
|
|
285
|
+
|
|
286
|
+
### Plugin Architecture (runtime implemented in `add-dashboard-shell-slots-runtime`)
|
|
287
|
+
|
|
288
|
+
A planned **two-tier rendering model** that lets first-party features (OpenSpec, pi-flows, pi-subagents tool renderers, git integration) live as standalone plugin packages instead of being baked into the dashboard core. Tracked under OpenSpec change `dashboard-plugin-architecture` (design-only umbrella); the runtime lands in `add-dashboard-shell-slots-runtime`; concrete migrations land in `extract-openspec-as-plugin`, `extract-flows-as-plugin`, `extract-subagents-as-plugin`, and `extract-git-as-plugin`.
|
|
289
|
+
|
|
290
|
+
**The two tiers, one slot contract:**
|
|
291
|
+
- **Tier 1 — first-party plugins** (this proposal): React + server contributions co-located in `packages/<name>-plugin/`. Bundled and tree-shaken into the dashboard's web build. Trusted because they live in the same repo and pass the same review.
|
|
292
|
+
- **Tier 2 — third-party extensions** (`extension-ui-system`): descriptor-only protocol over the existing pi event bus. Sandboxed, declarative, no React.
|
|
293
|
+
|
|
294
|
+
Both tiers fill the **same** named regions — the **slot taxonomy**. The shell knows about slots; only plugins/extensions know about specific features.
|
|
295
|
+
|
|
296
|
+
**Slot taxonomy (frozen for v0.x):**
|
|
297
|
+
|
|
298
|
+
First-party slots (React, possibly also descriptor):
|
|
299
|
+
- `sidebar-folder-section` — collapsible block above the per-workspace session list (replaces `FolderOpenSpecSection`).
|
|
300
|
+
- `session-card-badge` — compact info chips in the session card (replaces `OpenSpecActivityBadge`, `FlowActivityBadge`). Descriptor variant reuses `agent-metric`.
|
|
301
|
+
- `session-card-action-bar` — action buttons in the session card (replaces `SessionOpenSpecActions`, `SessionFlowActions`). React-only in v0.x.
|
|
302
|
+
- `content-view` — full-screen content area (replaces every conditional branch in `App.tsx` for `ArchiveBrowserView`, `SpecsBrowserView`, `OpenSpecPreview`, `FlowAgentDetail`, `FlowArchitectDetail`, `MarkdownPreviewView`, `FileDiffView`, `FlowYamlPreview`). Descriptor variant reuses `management-modal`.
|
|
303
|
+
- `content-header-sticky` — sticky element above content-view (replaces sticky `FlowArchitect`/`FlowDashboard`). Descriptor variant reuses `breadcrumb`.
|
|
304
|
+
- `content-inline-footer` — inline element below content-view (replaces `FlowSummary`). React-only.
|
|
305
|
+
- `anchored-popover` — popover anchored to a triggering UI element (replaces `TasksPopover`).
|
|
306
|
+
- `command-route` — maps a slash command or URL route to a `content-view` (replaces today's hand-wired routing in `App.tsx`).
|
|
307
|
+
- `settings-section` — a section in the Settings page (replaces today's hardcoded `Background polling (OpenSpec)` section). React for first-party plugins; descriptor (RJSF/UiField) for third-party extensions.
|
|
308
|
+
- `tool-renderer` — React component for a specific `tool_call` by `toolName` (replaces today's hardcoded `tool-renderers/registry.ts`).
|
|
309
|
+
|
|
310
|
+
Descriptor-only slots (existing in `extension-ui-system`): `management-modal`, `footer-segment`, `agent-metric`, `breadcrumb`, `gate`, `toast`, `rjsf-form`.
|
|
311
|
+
|
|
312
|
+
**Plugin loader (runtime):**
|
|
313
|
+
|
|
314
|
+
`packages/dashboard-plugin-runtime/` is a new workspace package containing all runtime pieces:
|
|
315
|
+
|
|
316
|
+
- **`src/slot-registry.ts`** — `createSlotRegistry()` returns a typed `Map<SlotId, ClaimEntry[]>` sorted by `(priority, pluginId)`. Filter helpers: `forSession`, `forFolder`, `forTab`, `forCommand`, `forToolName`.
|
|
317
|
+
- **`src/manifest-validator.ts`** — hand-rolled manifest validator; throws `ManifestValidationError` with `pluginId` and `reason`.
|
|
318
|
+
- **`src/plugin-context.tsx`** — `PluginContextProvider` wraps the entire app. A nested `CurrentPluginLayer` is pushed per contribution so `usePluginConfig<T>()` and `logger` resolve to the contributing plugin's id. `applyPluginConfigUpdate` updates the in-memory config store and re-renders subscribers.
|
|
319
|
+
- **`src/slot-consumers.tsx`** — one component per slot id. Each wraps contributions in a `SlotErrorBoundary` (per-claim scope). Reads registry from the provider.
|
|
320
|
+
- **`src/slot-error-boundary.tsx`** — React error boundary scoped to one claim. Logs with plugin id and slot id; renders nothing for the failing claim without suppressing siblings.
|
|
321
|
+
- **`src/vite-plugin/index.ts`** — `viteDashboardPluginsPlugin` generates `packages/client/src/generated/plugin-registry.tsx` with named imports (tree-shaking). Watches manifests during dev and triggers HMR.
|
|
322
|
+
- **`src/server/loader.ts`** — `discoverPlugins(repoRoot?)` (single module-level cache), `loadServerEntries(deps)` (per-plugin dynamic-import, failure isolated), `getPluginStatusStore()`.
|
|
323
|
+
- **`src/server/server-context.ts`** — `createServerPluginContext(deps, pluginId)` — namespaced logger, typed config accessors.
|
|
324
|
+
- **`src/server/config-validator.ts`** — Ajv JSON-Schema 7 validate + defaults.
|
|
325
|
+
|
|
326
|
+
1. **Discovery** — server globs `packages/*/package.json` on startup, parses the `pi-dashboard-plugin` field, validates against schema, sorts by `priority` (lower first; first-party = 100; default 1000).
|
|
327
|
+
2. **Server load** — dynamic-imports each plugin's `server` entry, invokes `registerPlugin(ctx)` with a typed `ServerPluginContext` (Fastify, session manager, event store, broadcast helper, scoped logger).
|
|
328
|
+
3. **Client bundle** — a Vite plugin (`vite-plugin-dashboard-plugins`) generates `packages/client/src/generated/plugin-registry.tsx` with static imports per plugin manifest; Vite tree-shakes unused exports and code-splits per plugin.
|
|
329
|
+
4. **Runtime registration** — client boot calls `getSlotRegistry()` once; slot consumer components (`<SessionCardBadgeSlot/>`, `<ContentViewSlot/>`, etc.) iterate the registry and render contributions in priority order with per-slot error boundaries.
|
|
330
|
+
5. **Bridge auto-register** — plugins declaring a `bridge` entry are auto-registered into `~/.pi/agent/settings.json` under managed `dashboard-<plugin-id>` keys; user-owned entries are never touched.
|
|
331
|
+
|
|
332
|
+
**Plugin settings persistence:**
|
|
333
|
+
- All plugin settings live under `plugins.<id>.*` in `~/.pi/dashboard/config.json`. The dashboard core never reads or writes another plugin's namespace.
|
|
334
|
+
- Each manifest may declare a `configSchema` (JSON Schema 7); the loader validates on read (with defaults applied) and on write (rejects invalid).
|
|
335
|
+
- `POST /api/config/plugins/:id` accepts a partial config for a single plugin and broadcasts `plugin_config_update { id, config }` to all subscribed browsers.
|
|
336
|
+
- The client-side `pluginContext.usePluginConfig<T>()` hook is reactive — consumers re-render within one frame of a write.
|
|
337
|
+
- Legacy top-level keys (e.g. `openspec.*`) auto-migrate to `plugins.<id>.*` on the plugin's first server boot.
|
|
338
|
+
|
|
339
|
+
**Failure isolation:**
|
|
340
|
+
- A plugin failing to load (server throw, client import error, missing entry) does NOT crash the shell.
|
|
341
|
+
- Failures are logged with full context and surfaced via `/api/health.plugins[]` (`{ id, enabled, loaded, error?, claims }`).
|
|
342
|
+
- Slot consumers wrap each contribution in a React error boundary so a runtime crash in one plugin's component doesn't take down the page.
|
|
343
|
+
|
|
344
|
+
**Bundled-by-default plugins:** The plugin loader treats all plugins identically (same manifest, same discovery, same `enabled` flag, same failure isolation). What distinguishes "bundled-by-default" plugins (initial set: `git-plugin`) is purely operational — the build pipeline always includes them in `packages/`. Their absence is a deliberate user opt-out, not a normal state. OpenSpec, Flows, and Subagents plugins are bundled in standard builds but their absence is a normal use case (e.g. a workspace without OpenSpec).
|
|
345
|
+
|
|
346
|
+
**Future Work — external plugin discovery:** Phase 1 scans `packages/*/package.json` only. The manifest format (`pi-dashboard-plugin` field in any `package.json`) is intentionally **format-compatible with arbitrary npm packages**, which unblocks an eventual progression where stable plugins can be PR'd into upstream packages (e.g. `@tintinweb/pi-subagents/dashboard/`) and discovered from `node_modules`. The deferred work (trust model, SemVer pinning of the plugin context API, build integration with `node_modules` paths) is documented in `dashboard-plugin-architecture/design.md` §"Future Work: external plugin discovery".
|
|
347
|
+
|
|
348
|
+
#### JSX slot wrappers and `??` fallback chains — anti-pattern
|
|
349
|
+
|
|
350
|
+
Slot consumer components (`<ContentViewSlot/>`, `<SessionCardBadgeSlot/>`, etc.) return `null` when no plugin claims the slot. **They MUST NOT be placed directly as the left operand of a `??` operator** in JSX route fallback chains. The `??` operator evaluates the JSX *element* (always truthy), not its rendered output, so a fallback like
|
|
351
|
+
|
|
352
|
+
```tsx
|
|
353
|
+
// BROKEN — sessionDetail and LandingPage are unreachable
|
|
354
|
+
<ContentViewSlot session={s} routeParams={p} onClose={c} /> ?? sessionDetail ?? <LandingPage />
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
renders nothing visible whenever zero plugins claim `content-view` (the slot's `null` return is masked by `??`'s value-based semantics). The bug is silent and ships fine in CI when fixture plugins are bundled — but breaks every user the moment fixtures are excluded from production.
|
|
358
|
+
|
|
359
|
+
The fix gates the JSX element on a registry claim count *before* construction:
|
|
360
|
+
|
|
361
|
+
```tsx
|
|
362
|
+
// CORRECT — ?? falls through to sessionDetail when claimCount === 0
|
|
363
|
+
(claimCount > 0 ? <ContentViewSlot …/> : null) ?? sessionDetail ?? <LandingPage />
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
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`).
|
|
367
|
+
|
|
161
368
|
### Bootstrap & First Run
|
|
162
369
|
|
|
163
370
|
The dashboard has three install paths that all converge on the shared
|
|
@@ -200,7 +407,60 @@ with `upgradeRecommended` / `upgradeDashboard` flags consumed by
|
|
|
200
407
|
`BootstrapBanner`. Versions below `minimum` set a blocking `error`
|
|
201
408
|
message that `session-api gateOrEnqueue` translates to 503 responses.
|
|
202
409
|
|
|
203
|
-
|
|
410
|
+
The pinned range is `minimum: "0.70.0"`, `recommended: "0.70.0"`,
|
|
411
|
+
`maximum: null` — deliberately in lockstep. The dashboard does NOT carry
|
|
412
|
+
backward-compatibility shims for older pi releases; one supported pi
|
|
413
|
+
means no conditional code paths in the bridge and no dual-import
|
|
414
|
+
fallbacks (e.g. `@sinclair/typebox` vs `typebox`). Bumping `recommended`
|
|
415
|
+
in a future change SHOULD be matched by an equal bump to `minimum` and a
|
|
416
|
+
lockstep bump of the offline-bundled pi version in
|
|
417
|
+
`packages/electron/offline-packages.json`.
|
|
418
|
+
|
|
419
|
+
The CLI also surfaces skew on stderr at startup: `cli.ts::logCompatibilityWarning` emits a three-line red block on below-minimum (including the exact `pi-dashboard upgrade-pi` remediation command) and a single advisory line on below-recommended. Silent when in range. This is in addition to the browser banner and the 503 gating, so terminal-only users (headless servers, CI) don't miss the signal. Note: `readCurrentPiVersion` uses `fs.realpathSync` on the registry-resolved bin path so the common npm-global symlink layout (`~/.nvm/.../bin/pi` → `../lib/node_modules/@mariozechner/pi-coding-agent/dist/cli.js`) resolves to the real `package.json` — without this, `compatibility.current` was silently `undefined` in every response.
|
|
420
|
+
|
|
421
|
+
#### Post-install repair (centralized hook)
|
|
422
|
+
|
|
423
|
+
On every `bootstrapState` transition from `"installing"` to `"ready"`,
|
|
424
|
+
`server.ts`'s subscribe callback runs a one-shot repair phase via the
|
|
425
|
+
exported helpers `makeBootstrapTransitionHandler` (gating) and
|
|
426
|
+
`runPostInstallRepair` (the work):
|
|
427
|
+
|
|
428
|
+
1. **Full `ToolRegistry.rescan()` (no arg)** — every cached `Resolution`
|
|
429
|
+
is dropped so the next `resolve(<tool>)` call re-runs the entire
|
|
430
|
+
strategy chain against the post-install filesystem. Restores the
|
|
431
|
+
literal contract from `unified-bootstrap-install` task 4.3 ("registry
|
|
432
|
+
rescan") that was previously narrowed to `rescan("pi")` and left
|
|
433
|
+
`openspec` / `tsx` cached as `not-found` forever.
|
|
434
|
+
|
|
435
|
+
2. **Force-refresh OpenSpec for every known directory** — iterates
|
|
436
|
+
`directoryService.knownDirectories()` and for each cwd calls
|
|
437
|
+
`refreshOpenSpec(cwd)` (bypasses the mtime gate per the
|
|
438
|
+
`fix-openspec-mtime-gate-toctou` design's escape-hatch contract).
|
|
439
|
+
Compares the returned `OpenSpecData` against the prior cache; emits
|
|
440
|
+
`openspec_update` to all browsers when the prior was empty or the
|
|
441
|
+
payload differs. Per-cwd failures are isolated via try/catch so one
|
|
442
|
+
cwd cannot block the others. Concurrency is bounded by the existing
|
|
443
|
+
`OpenSpecPollConfig.maxConcurrentSpawns` semaphore inside
|
|
444
|
+
`directory-service.ts` (default 4).
|
|
445
|
+
|
|
446
|
+
3. **Force-refresh pi-resources for every known directory** — same
|
|
447
|
+
iteration; silent on failure (matches
|
|
448
|
+
`directory-service.ts::schedulePiResourcesTick`).
|
|
449
|
+
|
|
450
|
+
The hook fires once per transition, fire-and-forget so the subscribe
|
|
451
|
+
callback returns synchronously. Because all three install entry points
|
|
452
|
+
(`runDegradedModeBootstrap`, REST `triggerUpgradePi`, REST
|
|
453
|
+
`triggerRetry`) flip the same state, the centralized hook covers every
|
|
454
|
+
caller — the local `registry.rescan("pi")` block in `cli.ts` was
|
|
455
|
+
removed as part of this change.
|
|
456
|
+
|
|
457
|
+
Without this hook, the OpenSpec session-card buttons (`P/D/T/S`
|
|
458
|
+
letters, attach combo, refresh) stayed hidden after a fresh first-run
|
|
459
|
+
install until either the user manually reloaded or up to 30 s elapsed
|
|
460
|
+
— and even then the mtime gate could decline to re-poll if no file
|
|
461
|
+
actually changed since boot.
|
|
462
|
+
|
|
463
|
+
See changes: `unified-bootstrap-install`, `pi-zero-seventy-compat`, `warn-pi-version-skew-in-cli`, `fix-openspec-buttons-after-bootstrap-install`.
|
|
204
464
|
|
|
205
465
|
### Force Kill Escalation
|
|
206
466
|
The Stop button supports two-click escalation for stuck sessions:
|
|
@@ -280,6 +540,24 @@ When a user sends a prompt to an ended session, the server automatically resumes
|
|
|
280
540
|
8. On timeout (30s) or spawn failure, `resuming` flag is cleared and session returns to normal ended state
|
|
281
541
|
9. If user sends another prompt while already resuming, the queued prompt is updated without spawning a second process
|
|
282
542
|
|
|
543
|
+
### Sidebar session ordering: top-of-tier on status change
|
|
544
|
+
The sidebar splits each folder's session cards into two tiers (alive on top, ended at the bottom). Cards within each tier sort independently:
|
|
545
|
+
|
|
546
|
+
- **Alive tier** uses the persisted `sessionOrder` per cwd (drag-reorder, prepend on new spawn). On user-intent resume (Resume button, drag-to-resume, REST resume), the server calls `sessionOrderManager.moveToFront(cwd, sessionId)` so the just-resumed card surfaces at index 0 of the alive tier — even on repeated `end → resume → end → resume` cycles where the id might already be in the order list. Bridge auto-reattach on reboot is gated by `pendingResumeIntents`: without a user-intent tag, the order is left untouched.
|
|
547
|
+
- **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.
|
|
548
|
+
|
|
549
|
+
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`.
|
|
550
|
+
|
|
551
|
+
### Desktop back-arrow priority chain
|
|
552
|
+
The desktop session-header back button used to call `window.history.back()`, which was a silent no-op on cold loads / hard refreshes / deep links. It also ignored the eight content-area overlay states (archive browser, specs browser, flow YAML preview, diff view, pi resource file preview, README preview, pi resources state, OpenSpec preview) owned by `App.tsx`.
|
|
553
|
+
|
|
554
|
+
The fix introduces:
|
|
555
|
+
- **`packages/client/src/lib/desktop-back.ts`** — pure helper `selectDesktopBackTarget(state) → { kind: "clear", target } | { kind: "navigate", to: "/" }` that mirrors the priority chain mobile's inline `onBack` switch already uses. Pinned by a 256-combination parity test against the mobile reference implementation so the two never drift.
|
|
556
|
+
- **`packages/client/src/hooks/useDesktopBack.ts`** — thin React hook that reads the live overlay state, calls the helper, and dispatches to the right setter or `navigate("/")`.
|
|
557
|
+
- **Sidebar overlay auto-close** — `useOpenSpecActions.handleReadArtifact`, `useContentViews.handleViewPiResourceFile`, and `useContentViews.handleViewReadme` accept `navigate`/`settingsMatch`/`tunnelSetupMatch` and call `navigate("/")` BEFORE setting overlay state when the user is on a URL-route view (Settings / Tunnel Setup) that takes over the content area. Without this, the JSX gate `!settingsMatch && !tunnelSetupMatch` would mask the just-opened overlay until the user clicked back twice.
|
|
558
|
+
|
|
559
|
+
The priority chain (alive on click): `archiveBrowserCwd → specsBrowserCwd → flowYamlPreview → diffViewSessionId → piResourceFilePreview → readmePreview → piResourcesState → previewState → navigate("/")`. Mobile is unchanged — it keeps its own inline `onBack` switch covering the same chain. See change `fix-desktop-back-navigation`.
|
|
560
|
+
|
|
283
561
|
### Model & Thinking Level Flow
|
|
284
562
|
1. Bridge sends current model and thinking level in `session_register` on connect
|
|
285
563
|
2. When user changes model (via `/model`), pi emits `model_select` event
|
|
@@ -317,7 +595,7 @@ When a user sends a prompt to an ended session, the server automatically resumes
|
|
|
317
595
|
1. Server's DirectoryService polls `openspec` CLI for each known directory (union of pinned dirs + session cwds) at a **configurable interval** (`DashboardConfig.openspec.pollIntervalSeconds`, default 30 s, range 5–3600 s).
|
|
318
596
|
2. OpenSpec data is keyed by directory (cwd), not by session — one poll per directory regardless of session count.
|
|
319
597
|
3. Changes are broadcast to all connected browsers via `openspec_update { cwd, data }`.
|
|
320
|
-
4. Browsers can request immediate refresh via `openspec_refresh { cwd }`.
|
|
598
|
+
4. Browsers can request immediate refresh via `openspec_refresh { cwd }`. User-initiated refresh **bypasses the mtime gate** (force-mode) but still respects the concurrency cap — see *Refresh paths* below.
|
|
321
599
|
5. New directories (pinned or from new sessions) trigger immediate discovery + polling (eager; bypasses jitter + mtime gate).
|
|
322
600
|
6. Each `OpenSpecChange` carries an optional `isComplete?: boolean` field forwarded straight through from `openspec status --change <name> --json`. It indicates artifact-authoring completeness only — orthogonal to the task tally — and never feeds `deriveChangeState`. The dashboard uses it solely to gate the **Archive anyway** escape hatch (see “OpenSpec session card”).
|
|
323
601
|
|
|
@@ -327,14 +605,19 @@ A naive `for each cwd: list + for each change: status` fan-out explodes quickly:
|
|
|
327
605
|
|
|
328
606
|
The scheduler in `packages/server/src/directory-service.ts` applies four layers of throttling (all configurable under `DashboardConfig.openspec`):
|
|
329
607
|
|
|
330
|
-
1. **mtime gate** (`changeDetection: "mtime" | "always"`, default `mtime`) — skips `openspec list`
|
|
608
|
+
1. **mtime gate** (`changeDetection: "mtime" | "always"`, default `mtime`) — skips `openspec list` and `openspec status --change X` when no tracked artifact has changed since the last successful poll. The gate uses **file-aware effective mtime** (the max over a fixed file set) rather than directory mtime alone, because POSIX directory mtime advances only on entry create/delete/rename and misses in-place file edits. The list-step signal unions `<changes>/` with each known `<change>/tasks.md`; the per-change signal unions `<change>/` with `tasks.md`, `proposal.md`, `design.md`, **plus the entire `specs/**` subtree** (the `specs/` directory itself, every immediate `specs/<cap>/` directory, and every `specs/<cap>/spec.md`). Missing files or directories (e.g. a change with no `design.md` or no `specs/` yet) are skipped, not zero — `readdirSync` on `specs/` is try/catch-wrapped so absence yields an empty fan-out. A `stat` is ~10 µs vs. ~500 ms per CLI spawn; in steady state this drops 67 spawns/tick to 0–2. The gate is **TOCTOU-safe**: each per-change iteration captures `preCallMtime` before awaiting `runOpenSpecStatus` and stamps THAT value into the cache; if the post-call effective mtime differs, the entry is racy and the cache is left untouched (the next gated tick re-polls because the post-write mtime no longer matches the preserved cached value). Without this guard, a write landing during the CLI call would stamp `{ mtimeMs: post-write, status: pre-write }` and latch the stale status indefinitely — trivially triggered by `/opsx:ff` mid-poll. **Defense in depth**: `buildOpenSpecData` also accepts a `SpecsProbeFactory` (parallel to the existing `DesignProbeFactory`) that promotes `specs: ready → done` whenever any `specs/**/*.md` file is found locally — promote-only, never demote, never `blocked → done`. So even if a future blind spot creeps into the gate, the dashboard cannot under-report `specs` as ready when at least one spec file exists. See changes: `fix-openspec-specs-mtime-gate-blind-spot`, `fix-openspec-mtime-gate-toctou`, `fix-openspec-mtime-gate-blind-spots`.
|
|
331
609
|
2. **Concurrency cap** (`maxConcurrentSpawns`, default 3, range 1–16) — an in-repo semaphore (`packages/shared/src/semaphore.ts`) serializes CLI spawns across all directories. Burst-work spreads uniformly over the interval instead of pinning every core.
|
|
332
610
|
3. **Per-cwd jitter** (`jitterSeconds`, default 5) — each known directory is assigned a deterministic phase offset `fnv1a32(cwd) % (jitterSeconds * 1000)` within the interval so polls don't all align on the same scheduling boundary.
|
|
333
611
|
4. **Split pi-resources timer** — `scanPiResources(cwd)` no longer rides the openspec tick; it has its own interval at 5× the openspec cadence (pi extensions/skills change far less often than OpenSpec artifacts).
|
|
334
612
|
|
|
335
613
|
Cache shape (per cwd): `{ listMtimeMs, listResult, changes: Map<name, { mtimeMs, change }>, data }`. Cache is updated atomically per directory — a partial failure leaves the previous snapshot intact and the next tick retries.
|
|
336
614
|
|
|
337
|
-
|
|
615
|
+
Refresh paths split into two camps:
|
|
616
|
+
|
|
617
|
+
- **User-initiated** (`openspec_refresh` WebSocket message → `refreshOpenSpec(cwd)`) **bypasses** the gate via `pollOne(cwd, true)`. The gate is heuristic; the CLI is authoritative. When the user clicks the refresh icon they expect fresh data, never silently-cached data — force-mode is the manual escape hatch for any future gate blind spot. Cost: `1 + N` spawns per click. Per-click is rare and the user is already waiting.
|
|
618
|
+
- **Internal / periodic** (`pollDirectoryGated(cwd)`, `onDirectoryAdded(cwd)`, `handleOpenSpecBulkArchive` post-archive refresh) **honor** the gate via `pollOne(cwd, false)`. Post-archive refresh on a folder with N active changes used to cost `1 + N` spawns (when these paths went through the user-facing `refreshOpenSpec`); it now costs `1` (list) plus only the few status spawns whose artifact files actually moved.
|
|
619
|
+
|
|
620
|
+
All paths still go through the semaphore, so a refresh-button storm cannot overload the host. See changes: `fix-openspec-mtime-gate-toctou` (current contract), `fix-openspec-mtime-gate-blind-spots` (file-aware gate).
|
|
338
621
|
|
|
339
622
|
Live reconfiguration: `PUT /api/config` with an `openspec` block calls `directoryService.reconfigurePolling(cfg)` — the timer cadence and semaphore max are updated without a server restart; in-flight polls finish on their old config.
|
|
340
623
|
|
|
@@ -359,11 +642,12 @@ The server exposes `GET /api/file?cwd=...&path=...` for reading files or listing
|
|
|
359
642
|
|
|
360
643
|
### Filesystem Browser (PathPicker)
|
|
361
644
|
|
|
362
|
-
The dashboard's reusable directory chooser (`PathPicker`) is backed by
|
|
645
|
+
The dashboard's reusable directory chooser (`PathPicker`) is backed by three localhost-only endpoints:
|
|
363
646
|
|
|
364
|
-
- `GET /api/browse?path=<dir>&q=<query>` — lists subdirectories of `<dir>` (or `$HOME` when omitted)
|
|
647
|
+
- `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:
|
|
365
648
|
- **Tier 0** exact match → **Tier 1** prefix → **Tier 2** word-boundary substring (after `-`, `_`, `.`, space, `/`) → **Tier 3** plain substring.
|
|
366
|
-
- Alphabetical within each tier. The 200-entry cap is applied **after** filter+rank so best matches always survive truncation.
|
|
649
|
+
- Alphabetical within each tier. The 200-entry cap is applied **after** filter+rank so best matches always survive truncation. See change: split-browse-flags.
|
|
650
|
+
- `GET /api/browse/flags?paths=<json-array>` — bulk classifier for paths produced by `/api/browse`. The `paths` query is a URL-encoded JSON array of absolute path strings (length ≤ 100). Returns `{ flags: { [path]: { isGit, isPi } } }`. Per-path probe failures (ENOENT, EACCES, ELOOP, race-on-deletion, anything) map to `{ isGit: false, isPi: false }` for that key — only malformed input or over-cap arrays produce a top-level error (`invalid paths` / `too many paths`, both HTTP 400). Internal `fs.access` fan-out is bounded at 32 in-flight calls. The `PathPicker` calls this lazily after each `/api/browse` enumeration and merges the flag map into the rendered rows so badges fade in without blocking the initial paint. See change: split-browse-flags.
|
|
367
651
|
- `POST /api/browse/mkdir` body `{ parent, name }` — creates a new directory non-recursively (`fs.mkdir` without `recursive: true`). Name validation rejects `/`, `\`, `\0`, `.`, `..`, empty, and leading/trailing whitespace. Errors map to 400 (`invalid name`, `parent is not a directory`), 404 (`parent not found`), 409 (`already exists`).
|
|
368
652
|
|
|
369
653
|
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:
|
|
@@ -551,6 +835,18 @@ When a session sends `flows_list`, the server notifies other sessions in the sam
|
|
|
551
835
|
### Event Broadcast During Replay
|
|
552
836
|
During bridge session replay (while `replayingSessions` set contains the session), `event_forward` messages are stored but NOT broadcast individually to browser subscribers. Instead, when `replay_complete` arrives (or the 5s safety timeout fires), the server sends all accumulated events as a single `event_replay` batch to subscribers. This prevents per-event serialization overhead during replay while still delivering the full history to browsers.
|
|
553
837
|
|
|
838
|
+
### Per-message entry id stamping (live vs replay)
|
|
839
|
+
|
|
840
|
+
The per-message ⤘ Fork button needs each chat bubble to carry the entry id of the entry it represents in the persisted JSONL. Two paths populate this:
|
|
841
|
+
|
|
842
|
+
- **Replay path** (`packages/shared/src/state-replay.ts`): reads from the persisted JSONL directly, so each `message_start` / `message_end` event carries the stable `entryId` from the source entry. No back-fill needed.
|
|
843
|
+
- **Live path** (`packages/extension/src/bridge.ts`): pi 0.69+ awaits extension handlers BEFORE calling `sessionManager.appendMessage`, which means an entry id does NOT exist at the bridge's emit time. The bridge instead:
|
|
844
|
+
1. Stamps a per-event `nonce` on `message_start` / `message_end` events so the client can correlate later.
|
|
845
|
+
2. Defers the `message_end` SEND via `setTimeout(0)` (a macrotask) so pi's awaited dispatcher unwinds and `appendMessage` runs in between — by the time the timeout fires, pi has mutated `event.message.id` in place.
|
|
846
|
+
3. Wraps `ctx.sessionManager.appendMessage` once per session at `session_start`. After a successful append, the wrapper emits an `entry_persisted { entryId, nonce }` event so the client reducer back-fills the matching ChatMessage's `entryId` (covers user messages, whose `message_start` fires before persistence).
|
|
847
|
+
|
|
848
|
+
`queueMicrotask` was used previously but no longer works: on pi 0.69+ the microtask resolves *inside* the awaited `_emitExtensionEvent`, before persistence. See change `fix-per-message-fork`.
|
|
849
|
+
|
|
554
850
|
## Persistence
|
|
555
851
|
|
|
556
852
|
| Data | Storage | Details |
|
|
@@ -630,6 +926,22 @@ The dashboard is installable as a Progressive Web App on mobile devices:
|
|
|
630
926
|
- **Service Worker** (`public/sw.js`) — minimal fetch pass-through for installability
|
|
631
927
|
- **Tunnel/QR Button** — unified sidebar button: shows tunnel icon when zrok is not installed (click → setup guide), QR code icon when set up but disconnected (click → setup guide), green QR code icon when connected (click → QR dialog with disconnect and setup buttons)
|
|
632
928
|
|
|
929
|
+
### External Link Routing (#13)
|
|
930
|
+
|
|
931
|
+
The dashboard runs in three shells (regular browser tab, installed PWA with `"display": "standalone"`, Electron), and all three previously stranded the user when a link in chat content was clicked — the PWA and Electron shells have no URL bar / back button to recover with. Two layers of hardening route external URLs safely:
|
|
932
|
+
|
|
933
|
+
1. **Client markdown renderer** (`packages/client/src/components/MarkdownContent.tsx`) overrides ReactMarkdown's `a` component. `isExternalHref(href)` classifies URLs using the `URL` constructor against `window.location.origin`; external URLs render as `<a target="_blank" rel="noopener noreferrer">`, while fragment-only and same-origin hrefs stay bare so in-document scrolling and internal navigation (e.g. the `/auth/login?return=...` redirect) keep working. Applies uniformly to chat bodies, thinking blocks, flow agent detail, package READMEs, and markdown previews — every consumer of `MarkdownContent`.
|
|
934
|
+
|
|
935
|
+
2. **Electron shell** (`packages/electron/src/main.ts` `createMainWindow`) registers two `webContents` handlers BEFORE `loadURL(serverUrl)`:
|
|
936
|
+
- `setWindowOpenHandler((details) => { shell.openExternal(details.url); return { action: "deny" }; })` — every `target="_blank"` / `window.open` call is routed to the user's real system browser; no secondary Electron `BrowserWindow` is spawned.
|
|
937
|
+
- `on("will-navigate", (event, url) => { if (!isSameOriginUrl(url, serverUrl)) { event.preventDefault(); shell.openExternal(url); } })` — defense-in-depth for any bare `<a href>` that slipped past layer 1. Same-origin navigation (including the client-side auth-login redirect) passes through untouched.
|
|
938
|
+
|
|
939
|
+
The same-origin decision lives in a pure, electron-free helper (`packages/electron/src/lib/link-handling.ts::isSameOriginUrl(href, serverOrigin)`) with 15 unit tests covering relative paths, fragments, different-origin URLs, `javascript:`/`mailto:` schemes, and malformed inputs (which safely fall through to "external").
|
|
940
|
+
|
|
941
|
+
A repo-level lint (`packages/client/src/__tests__/no-bare-external-anchor.test.ts`) scans every client `.tsx` for literal `<a href="http(s)://...">` opening tags without `target="_blank"` and fails the build if any slip in. Per-line opt-out via `// ban:bare-anchor-ok`.
|
|
942
|
+
|
|
943
|
+
See change: `harden-external-link-handling`.
|
|
944
|
+
|
|
633
945
|
| `devBuildOnReload` | false | Rebuild Vite client + restart server on `/reload` |
|
|
634
946
|
|
|
635
947
|
## Shared Config
|
|
@@ -658,13 +970,16 @@ The restart endpoint accepts `{ dev: boolean }` to switch between dev/production
|
|
|
658
970
|
|
|
659
971
|
### Cross-Platform Server Launch
|
|
660
972
|
|
|
661
|
-
The dashboard server is spawned via `node --import <loader> <cli.ts>` from
|
|
973
|
+
The dashboard server is spawned via `node --import <loader> <cli.ts>` from four call sites (`packages/server/src/cli.ts` `cmdStart`, `packages/extension/src/server-launcher.ts` `launchServer`, `packages/electron/src/lib/server-lifecycle.ts` `launchServer`, `packages/server/src/restart-helper.ts` `buildOrchestratorScript`). On Node ≥ 20, Windows's ESM loader parses **both** the `--import` loader position AND the entry-script position as URLs. A raw Windows path like `B:\Dev\cli.ts` parses with scheme `b:` (not in the ESM loader's `file`/`data`/`node` allowlist) and crashes with `ERR_UNSUPPORTED_ESM_URL_SCHEME`. Node has a drive-letter heuristic that auto-wraps common Windows paths with `file://` before the URL parse in the entry-script position, but the heuristic has known gaps for less-common drives (`A:`, `B:`, ...), so reliance on it is unsafe.
|
|
974
|
+
|
|
975
|
+
Both positions are wrapped as `file://` URLs universally:
|
|
662
976
|
|
|
663
|
-
- `packages/shared/src/
|
|
664
|
-
- `packages/
|
|
665
|
-
- `packages/server/src/cli.ts` —
|
|
977
|
+
- `packages/shared/src/platform/node-spawn.ts` — `toFileUrl(pathOrUrl)` (idempotent path → file:// URL, handles Windows drive letters on POSIX hosts) and `spawnNodeScript(opts)` (wraps both loader and entry before delegating to `platform/exec.ts::spawn`). This is the canonical chokepoint.
|
|
978
|
+
- `packages/shared/src/resolve-jiti.ts` — `resolveJitiImport()` and `resolveJitiFromAnchor(anchorPath)` return `pathToFileURL(registerPath).href` for the loader position.
|
|
979
|
+
- `packages/server/src/cli.ts` — routes through `spawnNodeScript`.
|
|
980
|
+
- `packages/extension/src/server-launcher.ts`, `packages/electron/src/lib/server-lifecycle.ts`, `packages/server/src/restart-helper.ts` — wrap the entry `cliPath` with `toFileUrl(cliPath)` before argv construction.
|
|
666
981
|
|
|
667
|
-
The URL form is cross-platform safe (Linux/macOS accept
|
|
982
|
+
The URL form is cross-platform safe (Linux/macOS accept `file://` URLs identically to raw paths), so no platform gating is needed. A repo-level lint test (`packages/shared/src/__tests__/no-raw-node-import.test.ts`) refuses any new call site that passes a raw identifier as argv after `--import` / `--loader`, preventing regression. Mirrors the `platform/exec.ts` + `no-direct-child-process.test.ts` pattern. See changes: `fix-windows-server-parity` (loader position), `fix-windows-entry-script-url` (entry-script position).
|
|
668
983
|
|
|
669
984
|
#### stdout + stderr capture parity
|
|
670
985
|
|
|
@@ -688,6 +1003,27 @@ This is a **race-independent fix**: it doesn't try to close the timing window, i
|
|
|
688
1003
|
|
|
689
1004
|
`packages/server/package.json` declares `"engines": { "node": ">=22.18.0 <23 || >=24.3.0" }` as an npm-level advisory.
|
|
690
1005
|
|
|
1006
|
+
#### AppImage CLI self-recursion guard (Linux power-user mode)
|
|
1007
|
+
|
|
1008
|
+
The Electron app's power-user launch path (`ensureServer()` → `detectPiDashboardCli()` → `launchViaCli()`) prefers an already-installed `pi-dashboard` CLI on PATH. On Linux **AppImage** builds, the AppImage runtime prepends its squashfs mount directory (e.g. `/tmp/.mount_PI-Das.../`) to `PATH` of the Electron child. That mount contains a binary literally named `pi-dashboard` because `forge.config.ts` declares `packagerConfig.executableName: "pi-dashboard"` for user-facing branding consistency. Without a guard, `which pi-dashboard` returns the AppImage's own launcher first; `launchViaCli()` then spawns the Electron app recursively as if it were the dashboard CLI, the recursive child silently ignores `start --port 8000`, never opens the dashboard port, and `waitForReady` polls until its 15-second deadline expires — user sees an indefinite loading screen.
|
|
1009
|
+
|
|
1010
|
+
The fix lives at two layers:
|
|
1011
|
+
|
|
1012
|
+
- **Layer 2 — shared registry strategy** (`packages/shared/src/tool-registry/strategies.ts`). After `whichSync(name)` returns a path, `whereStrategy` runs it through `isAppImageSelfHit(path)`; on hit, the strategy returns `{ ok: false, reason: "appimage-self-hit: <path>" }` so the registry's `Resolution.tried` records the rejection. **Every tool registered via `whereStrategy`** (currently `node`, `pi`, `openspec`, `npm`, `git`, `zrok`, `wt`, build-time `electron`/`node-pty`) inherits this guard transparently. Future tool registrations benefit by default.
|
|
1013
|
+
- **Layer 1 — Electron-only detector** (`packages/electron/src/lib/dependency-detector.ts`). `detectPiDashboardCli()` is intentionally NOT a registered tool (it's the dashboard package this code is part of), so it applies the same `isAppImageSelfHit` filter inline alongside the existing `_npx` cache-shim filter. Both rejections silently return `{ found: false }` so `ensureServer()` falls through to the standalone `launchServer()` path (tsx + `cli.ts`). `detectPi()` and `detectSystemNode()` apply the same guard symmetrically on the registry-resolved path — belt-and-braces beyond the Layer-2 filter.
|
|
1014
|
+
|
|
1015
|
+
`isAppImageSelfHit(candidatePath, opts?)` lives in `packages/shared/src/platform/binary-lookup.ts` and treats a path as a self-hit when ANY of:
|
|
1016
|
+
|
|
1017
|
+
- `realpath(candidatePath) === realpath(process.execPath)`, OR
|
|
1018
|
+
- `candidatePath` lives under the directory named by `process.env.APPDIR` (the AppImage squashfs mount), OR
|
|
1019
|
+
- `realpath(candidatePath) === realpath(process.env.APPIMAGE)`.
|
|
1020
|
+
|
|
1021
|
+
All `realpath` calls are wrapped in try/catch so broken symlinks / ENOENT fall back to literal string compares; the helper never throws. Tests inject explicit `{ execPath?, appDir?, appImage? }` overrides via `opts`; production callers omit `opts` and the helper reads `process.execPath` / `process.env.APPDIR` / `process.env.APPIMAGE`.
|
|
1022
|
+
|
|
1023
|
+
The `executableName: "pi-dashboard"` collision is **left in place** — renaming would break user-facing branding and existing `.desktop` files. The fix sits at the resolution layer where it belongs. If the guard ever fails to fire (future regression / edge case), the `launchViaCli` timeout error decoration includes a `readlink -f $(which pi-dashboard)` hint so the failure is recognizable from the error dialog alone.
|
|
1024
|
+
|
|
1025
|
+
See change: `fix-electron-appimage-cli-self-detection`.
|
|
1026
|
+
|
|
691
1027
|
### Cross-OS Platform Primitives
|
|
692
1028
|
|
|
693
1029
|
Cross-OS behavior (`process.platform === "win32"` branches) is centralized in `packages/shared/src/platform/` (pure Node, consumed by server + extension + Electron). The module has an `index.ts` barrel plus per-concern files:
|
|
@@ -798,9 +1134,21 @@ The dashboard uses mDNS (via `bonjour-service`) for zero-config server discovery
|
|
|
798
1134
|
### Server Selector UI
|
|
799
1135
|
- The header dropdown shows persisted known servers (from config) plus localhost, not raw mDNS results
|
|
800
1136
|
- Each entry shows label (or hostname), host:port, Local/Remote badge, and availability status
|
|
801
|
-
-
|
|
802
|
-
-
|
|
803
|
-
- Last-used server persisted in `localStorage` (`pi-dashboard-last-server`)
|
|
1137
|
+
- **Probe lifecycle**: availability is probed via `/api/health` **only when the dropdown opens** — once per open. No mount probe, no timer, no probing while the dropdown is closed. Current-server status is derived from the live WebSocket state, not a separate probe.
|
|
1138
|
+
- **Unreachable entries** are rendered with `opacity-50`, `cursor-not-allowed`, and the `disabled` attribute set; clicks are no-ops. To re-probe, close and reopen the dropdown. The transactional switch (below) still protects against races between the last probe and a click on a reachable entry.
|
|
1139
|
+
- Last-used server persisted in `localStorage` (`pi-dashboard-last-server`) — **only after** a successful switch (see transactional switching below).
|
|
1140
|
+
|
|
1141
|
+
### Transactional Server Switching
|
|
1142
|
+
Switching servers is a two-phase transaction that never destructs state before verifying the target is reachable. Implemented by `performServerSwitch` (`packages/client/src/lib/server-switch.ts`) + `openStagingSocket` (`packages/client/src/lib/staging-socket.ts`):
|
|
1143
|
+
|
|
1144
|
+
1. **Stage**: open a second ("staging") WebSocket to the target URL with a 5-second timeout. The live WebSocket stays connected.
|
|
1145
|
+
2. **Commit (on staging `OPEN`)**: close the staging socket, clear in-memory session/command/flow/openspec/terminal state, call `setWsUrl(newUrl)` so `useWebSocket` reconnects, and **only then** write `localStorage["pi-dashboard-last-server"]`.
|
|
1146
|
+
3. **Abort (on staging error/timeout)**: close the staging socket, show a toast "Couldn't reach <host>", leave the live connection and state untouched. localStorage is not written — so a subsequent refresh still recovers the last-known-good server.
|
|
1147
|
+
|
|
1148
|
+
An `inFlightSwitchKey` ref guards against duplicate clicks; the clicked dropdown entry renders a spinner while staging is in progress. The `POST /api/config { lastServer }` fire-and-forget call was removed as dead weight (no consumer read the field).
|
|
1149
|
+
|
|
1150
|
+
### Connection Status Banner
|
|
1151
|
+
`ConnectionStatusBanner` (`packages/client/src/components/ConnectionStatusBanner.tsx`) mounts above `<MobileShell>`. It shows "Disconnected from <host>. Retrying…" when the active WebSocket has been non-`OPEN` for more than 3 seconds continuously. The threshold is implemented via `setTimeout` cleared on any return-to-`OPEN` or unmount, so brief reconnects (laptop sleep, wifi hiccup) never flash the banner. During an in-flight staging switch the banner is suppressed — the live socket is still open, so no disconnection has actually occurred.
|
|
804
1152
|
|
|
805
1153
|
### Server Management (Settings Panel)
|
|
806
1154
|
- **Known Servers section**: lists persisted servers with remove buttons and an inline add form (host, port, label)
|
|
@@ -882,6 +1230,45 @@ This is separate from the main JSON dashboard WebSocket (`/ws`).
|
|
|
882
1230
|
1. **Postinstall** — `packages/server/scripts/fix-pty-permissions.cjs` (wired at workspace-root `postinstall`) uses `require.resolve("node-pty/package.json")` to locate the dependency wherever npm placed it and sets mode `0o755` on every `prebuilds/*/spawn-helper` and `prebuilds/*/pty.node`.
|
|
883
1231
|
2. **Electron bundle** — `packages/electron/scripts/bundle-server.sh` runs `find … -name spawn-helper -exec chmod +x` after `npm install` and removes macOS quarantine flags (`xattr -d com.apple.quarantine`) from native binaries.
|
|
884
1232
|
|
|
1233
|
+
### Package management (install / remove / update / move)
|
|
1234
|
+
|
|
1235
|
+
Package operations all flow through `package-manager-wrapper.ts`'s single-flight `busy` lock. The route layer (`/api/packages/install` / `/remove` / `/update` / `/move`) returns `202 { operationId | moveId }` synchronously and progress streams over the existing `package_progress` + `package_operation_complete` WebSocket channels.
|
|
1236
|
+
|
|
1237
|
+
**Move semantics** (added in change `unify-package-management-ui`):
|
|
1238
|
+
|
|
1239
|
+
Moving a package between scopes (global ↔ local) is a hybrid operation, keyed on the source kind:
|
|
1240
|
+
|
|
1241
|
+
```
|
|
1242
|
+
npm: / git: / https:// → install at destination + remove from origin
|
|
1243
|
+
(real fetch — npm cache or git clone, both cached after first run)
|
|
1244
|
+
busy lock held across both phases; reload coalesced to one at the end
|
|
1245
|
+
filter objects in packages[] entries are post-patched onto the dest
|
|
1246
|
+
entry after pi's installer writes a bare-string entry
|
|
1247
|
+
|
|
1248
|
+
abs-path / rel-path → settings-only edit; no file copy
|
|
1249
|
+
(matches pi's "paths are not copied" contract from docs/packages.md)
|
|
1250
|
+
reads both packages[] arrays via SettingsManager.getGlobalSettings/
|
|
1251
|
+
getProjectSettings; rewrites source string for destination scope:
|
|
1252
|
+
to global → path.resolve against fromSettingsDir (absolute)
|
|
1253
|
+
to local → path.relative against toSettingsDir; falls back to
|
|
1254
|
+
absolute when the relative form would escape the cwd
|
|
1255
|
+
tree by more than 2 `..` segments
|
|
1256
|
+
splices destination + removes from origin via setPackages /
|
|
1257
|
+
setProjectPackages (atomic write per pi's settings APIs)
|
|
1258
|
+
```
|
|
1259
|
+
|
|
1260
|
+
**Identity preflight** (per pi's dedup rules from `docs/packages.md`): before any side-effect, the wrapper computes the package identity and rejects with `AlreadyAtDestinationError` (→ 409) if the destination scope already contains a matching entry. Identity rules:
|
|
1261
|
+
|
|
1262
|
+
```
|
|
1263
|
+
npm:<spec> → bare package name (without @version)
|
|
1264
|
+
git:<url> / https://<url> → url with trailing @<ref> stripped
|
|
1265
|
+
path source → resolved absolute path
|
|
1266
|
+
```
|
|
1267
|
+
|
|
1268
|
+
**Composite progress events**: the wrapper threads an internal `moveId?: string` parameter through `executeOperation` so progress and completion events from both sub-phases share the same `moveId`. The server gateway forwards the field on every WS broadcast; the client's `move-tracker` (singleton store) groups events by `moveId` and exposes per-source state through `usePackageOperations.moveStateFor()`. Consumers that ignore `moveId` continue to render install + remove as two unrelated operations — graceful back-compat.
|
|
1269
|
+
|
|
1270
|
+
**Partial-success recovery**: if install at destination succeeds but remove from origin fails, the move's `package_operation_complete` event includes `partialSuccess: { installed: true, removed: false, removeError: <message> }`. The client's `<InstalledPackagesList>` renders an inline banner with a Cleanup button that POSTs `/api/packages/remove` against `fromScope` (idempotent on retry). No HTTP-level 207 — the move endpoint is async (202 + moveId pattern), so partial-success surfaces post-202 via the WS channel.
|
|
1271
|
+
|
|
885
1272
|
### Bundled first-party extensions (Electron installer)
|
|
886
1273
|
|
|
887
1274
|
The Electron installer can optionally ship a curated subset of recommended pi extensions inside `resources/bundled-extensions/<id>/` so first-run works with zero network access. The set is declared by `BUNDLED_EXTENSION_IDS` in `packages/shared/src/recommended-extensions.ts` (currently `pi-anthropic-messages`, `pi-flows`) — a strict subset of `RECOMMENDED_EXTENSIONS`, enforced by a unit test.
|
|
@@ -1022,6 +1409,22 @@ Every external binary, module, and directory the dashboard depends on is resolve
|
|
|
1022
1409
|
| `pi-coding-agent` | module | override → bare-import → managed (`MANAGED_DIR/node_modules/.../dist/index.js`) → npm-global; probes both `@mariozechner/*` and `@oh-my-pi/*` aliases |
|
|
1023
1410
|
| `openspec`, `npm`, `node`, `tsx`, `git`, `zrok` | binary | override → managed → where |
|
|
1024
1411
|
| `pi-dashboard` | module | override → managed → npm-global (presence of `package.json` is enough) |
|
|
1412
|
+
| `electron` | module | override → bare-import (`paths: ["packages/electron"]`) → managed; resolves the package directory containing `install.js`, hoist-aware. See change: register-build-time-tools |
|
|
1413
|
+
| `node-pty` | module | override → bare-import; resolves the package directory containing `prebuilds/`. See change: register-build-time-tools |
|
|
1414
|
+
|
|
1415
|
+
### Build-time consumers (shell-callable wrapper)
|
|
1416
|
+
|
|
1417
|
+
CI workflows, Dockerfiles, and root-level postinstall scripts cannot import the shared package's TypeScript directly — those run before any TS build has fired (or, for postinstall, before the shared package is even unpacked). For these consumers, `packages/shared/bin/pi-dashboard-resolve-tool.cjs` provides a CommonJS, dependency-free shell wrapper that mirrors the registry's `override` + `bare-import` strategies for the build-time tool subset (`electron`, `node-pty`):
|
|
1418
|
+
|
|
1419
|
+
```bash
|
|
1420
|
+
# Resolve a build-time tool from any shell context
|
|
1421
|
+
ELECTRON_DIR=$(node packages/shared/bin/pi-dashboard-resolve-tool.cjs electron)
|
|
1422
|
+
cd "$ELECTRON_DIR" && node install.js
|
|
1423
|
+
```
|
|
1424
|
+
|
|
1425
|
+
The wrapper is used by `.github/workflows/publish.yml` (linux/arm64 native rebuild) and `packages/electron/scripts/Dockerfile.build` (Docker cross-platform native rebuild). The root postinstall `scripts/fix-pty-permissions.cjs` reimplements the same `bare-import` semantics inline (it cannot shell out because it runs DURING `npm install`).
|
|
1426
|
+
|
|
1427
|
+
Reintroduction of hardcoded `node_modules/<dep>` paths in any of these sites is blocked by the lint test at `packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts`.
|
|
1025
1428
|
|
|
1026
1429
|
### Resolution record
|
|
1027
1430
|
|