@blackbelt-technology/pi-agent-dashboard 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/AGENTS.md +12 -6
  2. package/LICENSE +21 -0
  3. package/README.md +2 -2
  4. package/docs/architecture.md +79 -26
  5. package/package.json +4 -2
  6. package/packages/extension/package.json +1 -1
  7. package/packages/extension/src/__tests__/ask-user-tool.test.ts +50 -0
  8. package/packages/extension/src/__tests__/dashboard-default-adapter.test.ts +77 -0
  9. package/packages/extension/src/__tests__/dev-build.test.ts +2 -2
  10. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +18 -18
  11. package/packages/extension/src/__tests__/prompt-bus-wiring.test.ts +791 -0
  12. package/packages/extension/src/__tests__/prompt-bus.test.ts +469 -0
  13. package/packages/extension/src/__tests__/server-launcher.test.ts +35 -34
  14. package/packages/extension/src/__tests__/tui-prompt-adapter.test.ts +207 -0
  15. package/packages/extension/src/ask-user-tool.ts +26 -6
  16. package/packages/extension/src/bridge-context.ts +1 -1
  17. package/packages/extension/src/bridge.ts +214 -59
  18. package/packages/extension/src/command-handler.ts +2 -2
  19. package/packages/extension/src/dashboard-default-adapter.ts +37 -0
  20. package/packages/extension/src/flow-event-wiring.ts +6 -23
  21. package/packages/extension/src/pi-env.d.ts +13 -0
  22. package/packages/extension/src/prompt-bus.ts +240 -0
  23. package/packages/extension/src/server-launcher.ts +2 -2
  24. package/packages/extension/src/session-sync.ts +2 -1
  25. package/packages/server/package.json +1 -1
  26. package/packages/server/src/__tests__/bridge-register-nondestructive.test.ts +108 -0
  27. package/packages/server/src/__tests__/extension-register-appimage.test.ts +39 -0
  28. package/packages/server/src/__tests__/extension-register.test.ts +26 -22
  29. package/packages/server/src/__tests__/known-servers-routes.test.ts +129 -0
  30. package/packages/server/src/__tests__/process-manager.test.ts +4 -1
  31. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +5 -5
  32. package/packages/server/src/__tests__/tunnel.test.ts +2 -2
  33. package/packages/server/src/browser-gateway.ts +55 -16
  34. package/packages/server/src/cli.ts +1 -1
  35. package/packages/server/src/editor-manager.ts +1 -1
  36. package/packages/server/src/event-status-extraction.ts +7 -0
  37. package/packages/server/src/event-wiring.ts +20 -22
  38. package/packages/server/src/package-manager-wrapper.ts +1 -1
  39. package/packages/server/src/process-manager.ts +8 -69
  40. package/packages/server/src/routes/known-servers-routes.ts +110 -0
  41. package/packages/server/src/routes/system-routes.ts +3 -1
  42. package/packages/server/src/server.ts +8 -4
  43. package/packages/shared/package.json +1 -1
  44. package/packages/shared/src/__tests__/bridge-register.test.ts +136 -0
  45. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +62 -0
  46. package/packages/shared/src/__tests__/tool-resolver.test.ts +164 -0
  47. package/packages/shared/src/bridge-register.ts +95 -0
  48. package/packages/shared/src/browser-protocol.ts +47 -1
  49. package/packages/shared/src/config.ts +23 -0
  50. package/packages/shared/src/managed-paths.ts +15 -0
  51. package/packages/shared/src/mdns-discovery.ts +1 -1
  52. package/packages/shared/src/openspec-activity-detector.ts +8 -6
  53. package/packages/shared/src/protocol.ts +46 -0
  54. package/packages/shared/src/rest-api.ts +28 -0
  55. package/packages/shared/src/tool-resolver.ts +201 -0
  56. package/packages/shared/src/types.ts +24 -0
  57. package/packages/extension/src/__tests__/ui-proxy.test.ts +0 -583
  58. package/packages/extension/src/ui-proxy.ts +0 -269
  59. package/packages/server/src/extension-register.ts +0 -92
package/AGENTS.md CHANGED
@@ -56,7 +56,7 @@ make clean # Destroy all cloned VMs
56
56
  | File | Purpose |
57
57
  |------|---------|
58
58
  | `src/shared/protocol.ts` | Extension↔Server WebSocket messages |
59
- | `src/shared/browser-protocol.ts` | Server↔Browser WebSocket messages |
59
+ | `src/shared/browser-protocol.ts` | Server↔Browser WebSocket messages (all message types including PromptBus `prompt_request`/`prompt_dismiss`/`prompt_cancel` must be in the `ServerToBrowserMessage` union — `as any` switch cases are stripped by esbuild in production) |
60
60
  | `src/shared/types.ts` | Data models (Session, Workspace, Event) |
61
61
  | `src/shared/config.ts` | Shared config loader (`~/.pi/dashboard/config.json`) |
62
62
  | `src/extension/bridge.ts` | Main extension entry point (composes sync/tracker/flow modules, tracks `isAgentStreaming` in persistent BridgeState) |
@@ -83,7 +83,9 @@ make clean # Destroy all cloned VMs
83
83
  | `src/client/components/BranchPicker.tsx` | Typeahead branch picker with keyboard navigation |
84
84
  | `src/client/components/BranchSwitchDialog.tsx` | Checkout orchestration: dirty-state stash, pop prompt |
85
85
  | `src/client/lib/git-api.ts` | Client-side fetch helpers for git API endpoints |
86
- | `src/extension/ui-proxy.ts` | Proxies ctx.ui dialogs to dashboard (confirm/select/input/editor/notify) |
86
+ | `src/extension/prompt-bus.ts` | PromptBus unified prompt routing to registered adapters (TUI, dashboard, custom). First-response-wins, cross-adapter dismissal. |
87
+ | `src/extension/dashboard-default-adapter.ts` | Built-in PromptBus adapter that renders prompts as generic interactive dialogs in dashboard chat |
88
+ | `src/client/lib/prompt-component-registry.ts` | Client-side component registry mapping prompt type strings to render metadata (placement, component) |
87
89
  | `src/extension/ask-user-tool.ts` | `ask_user` tool registration (bundled in bridge, registered at session_start to avoid static tool-name conflicts with other extensions) |
88
90
  | `src/shared/openspec-activity-detector.ts` | Detects OpenSpec activity from tool events; auto-attach requires only changeName (phase optional) |
89
91
  | `src/shared/openspec-poller.ts` | OpenSpec CLI polling (shared, used by server DirectoryService) |
@@ -155,7 +157,11 @@ make clean # Destroy all cloned VMs
155
157
  | `src/client/hooks/useAuthStatus.ts` | Client auth status hook and login redirect helper |
156
158
  | `src/server/localhost-guard.ts` | Network access guard: `createNetworkGuard` (loopback/trusted/authenticated), `isBypassedHost` (CIDR/wildcard/exact), netmask-to-CIDR helpers |
157
159
  | `src/server/server-pid.ts` | PID file management for daemon mode |
158
- | `src/client/components/ServerSelector.tsx` | Server selector dropdown for switching between discovered dashboard servers |
160
+ | `src/client/components/ServerSelector.tsx` | Server selector dropdown showing persisted known servers with availability probing |
161
+ | `src/client/components/KnownServersSection.tsx` | Settings section: list/add/remove persisted known remote servers |
162
+ | `src/client/components/NetworkDiscoverySection.tsx` | Settings section: mDNS network scan with "Add" action and label prompt |
163
+ | `src/client/lib/known-servers-api.ts` | Client-side fetch helpers for known servers CRUD and discovery endpoints |
164
+ | `src/server/routes/known-servers-routes.ts` | REST routes: known servers CRUD, on-demand mDNS discovery scan |
159
165
  | `src/server/terminal-manager.ts` | PTY lifecycle, ring buffer, spawn/attach/kill terminals |
160
166
  | `src/server/terminal-gateway.ts` | Binary WebSocket upgrade handler for `/ws/terminal/:id` |
161
167
  | `scripts/fix-pty-permissions.cjs` | Postinstall: fix node-pty spawn-helper execute permissions |
@@ -200,8 +206,8 @@ make clean # Destroy all cloned VMs
200
206
  | `src/client/hooks/useZoomPan.ts` | Reusable zoom/pan hook (wheel, drag, pinch, buttons) |
201
207
  | `src/client/hooks/useMessageHandler.ts` | WebSocket message dispatch hook (extracted from App.tsx) |
202
208
  | `src/client/hooks/useSessionActions.ts` | Session action callbacks hook (send, abort, resume, spawn, etc.) |
203
- | `src/client/hooks/useOpenSpecActions.ts` | OpenSpec action callbacks hook (refresh, archive, attach, detach) |
204
- | `src/client/hooks/useContentViews.ts` | Content view state + fetch (pi resources, readme, file preview) |
209
+ | `src/client/hooks/useOpenSpecActions.ts` | OpenSpec action callbacks hook (refresh, archive, attach, detach); calls `clearAllContentViews` before opening preview |
210
+ | `src/client/hooks/useContentViews.ts` | Content view state + fetch (pi resources, readme, file preview); `clearAll()` resets all hook-owned states; `onBeforeOpen` callback for cross-component clearing |
205
211
  | `src/client/lib/event-reducer.ts` | Event-sourced state reducer (delegates flow events to flow-reducer); extracts LLM errors from `agent_end` into `lastError` |
206
212
  | `src/client/hooks/usePendingPromptTimeout.ts` | 30-second safety timeout for stuck `pendingPrompt` spinners |
207
213
  | `src/client/lib/flow-reducer.ts` | Flow state machine: all flow_* event handling |
@@ -260,7 +266,7 @@ make clean # Destroy all cloned VMs
260
266
  | `packages/electron/scripts/test-electron-install.sh` | Full e2e Docker test: install, wizard, server launch, health check |
261
267
  | `packages/electron/scripts/test-electron-install-inner.sh` | Inner test script run inside Docker container |
262
268
  | `packages/electron/resources/icon.png` | Master 1024×1024 app icon (π on dark navy) |
263
- | `.github/workflows/electron-build.yml` | CI: builds DMG (macOS), DEB+AppImage (Linux), NSIS (Windows) on native runners |
269
+ | `.github/workflows/publish.yml` | CI: builds DMG (macOS), DEB+AppImage (Linux), NSIS+ZIP+portable (Windows) on native runners; publishes npm + GitHub Release |
264
270
 
265
271
  ## Build & Restart Workflow
266
272
 
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Robert Csakany
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -428,7 +428,7 @@ curl -X POST http://localhost:8000/api/restart -H 'Content-Type: application/jso
428
428
  src/
429
429
  ├── shared/ # Shared TypeScript types
430
430
  │ ├── protocol.ts # Extension ↔ Server messages
431
- │ ├── browser-protocol.ts # Server ↔ Browser messages
431
+ │ ├── browser-protocol.ts # Server ↔ Browser messages (incl. PromptBus types)
432
432
  │ ├── types.ts # Data models
433
433
  │ ├── config.ts # Shared config loader
434
434
  │ └── rest-api.ts # REST API types
@@ -534,7 +534,7 @@ Output by platform:
534
534
  |----------|--------|----------|
535
535
  | macOS | `.dmg` | `packages/electron/out/make/` |
536
536
  | Linux | `.deb` + `.AppImage` | `packages/electron/out/make/` |
537
- | Windows | `.exe` (NSIS installer) | `packages/electron/out/make/` |
537
+ | Windows | `.exe` (NSIS installer) + `.zip` + portable `.exe` | `packages/electron/out/make/` |
538
538
 
539
539
  ### Cross-Platform Builds (via Docker)
540
540
 
@@ -31,12 +31,13 @@ A global pi extension that runs in every pi session. It:
31
31
  - 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).
32
32
  - **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.
33
33
  - **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).
34
- - Proxies `ctx.ui` dialog methods (confirm, select, input, editor) to the dashboard via `ui-proxy.ts`
35
- - TUI sessions: races terminal dialog against dashboard response (first wins)
36
- - Race cancellation: when dashboard wins, TUI dialog is aborted via `AbortSignal`; when TUI wins, dashboard dialog is dismissed via `extension_ui_dismiss` message
37
- - Headless sessions: only dashboard can respond
38
- - Fire-and-forget methods (notify) are forwarded alongside the original call
39
- - Re-sends pending UI requests on WebSocket reconnect (server restart resilience)
34
+ - Routes `ctx.ui` dialog methods (confirm, select, input, editor, notify) through `PromptBus` (`prompt-bus.ts`)
35
+ - Adapters register to handle prompts: `DashboardDefaultAdapter` renders generic dialogs inline; extensions (e.g. pi-flows) can register custom adapters via `prompt:register-adapter` event
36
+ - First-response-wins: multiple adapters (TUI, dashboard, custom) can claim a prompt; the first to respond resolves it, others are dismissed
37
+ - Bridge's TUI adapter is registered inline (captures original `ctx.ui` methods before patching) and presents prompts in the terminal with AbortController-based cancellation
38
+ - Patched `ctx.ui` methods forward the `message` field (from opts) via `metadata` in the PromptBus request
39
+ - Client-side `prompt-component-registry.ts` maps component type strings to render placement (inline, widget-bar, overlay)
40
+ - Protocol messages: `prompt_request`, `prompt_dismiss`, `prompt_cancel`, `prompt_response`
40
41
 
41
42
  ### 2. Dashboard Server (`src/server/`)
42
43
  A Node.js HTTP + WebSocket server that:
@@ -78,7 +79,7 @@ A React-based responsive web UI that:
78
79
  ### 4. Shared Types (`src/shared/`)
79
80
  TypeScript type definitions shared across all components:
80
81
  - `protocol.ts` - Extension↔Server WebSocket messages
81
- - `browser-protocol.ts` - Server↔Browser WebSocket messages
82
+ - `browser-protocol.ts` - Server↔Browser WebSocket messages (includes PromptBus messages: `prompt_request`, `prompt_dismiss`, `prompt_cancel`)
82
83
  - `types.ts` - Data models (Session, Workspace, Event, etc.)
83
84
 
84
85
  ## Data Flow
@@ -90,25 +91,38 @@ TypeScript type definitions shared across all components:
90
91
  4. Server broadcasts to all subscribed browsers via `event` message
91
92
  5. Browser's event reducer processes event, React renders update
92
93
 
93
- ### Interactive UI Flow (extension dialog → browser → response)
94
+ ### Interactive UI Flow (PromptBus — extension dialog → browser → response)
94
95
  1. Extension calls `ctx.ui.confirm()` / `select()` / `input()` / `editor()`
95
- 2. Bridge UI proxy intercepts, sends `extension_ui_request` to server
96
- 3. Server tracks the request in `pendingUiRequests` map and forwards to subscribed browsers
97
- 4. Browser renders interactive card inline in chat (renderers in `interactive-renderers/`)
98
- 5. User clicks Allow/Deny/option/submits text
99
- 6. Browser sends `extension_ui_response` to server, optimistically clears "Waiting for input" on session card
100
- 7. Server clears the request from `pendingUiRequests` and routes response to bridge extension
101
- 8. Bridge UI proxy resolves the original dialog promise
102
-
103
- **Race cancellation (TUI sessions):**
104
- - TUI and dashboard both show the dialog simultaneously via `Promise.race`
105
- - When dashboard answers first: TUI dialog is dismissed via `AbortSignal` (passed in `ExtensionUIDialogOptions.signal`)
106
- - When TUI answers first: bridge sends `extension_ui_dismiss` to server → forwarded as `ui_dismiss` to browsers → dashboard transitions dialog to "dismissed" ("Answered in terminal")
107
- - Pending Map entry is cleaned up immediately when TUI wins, preventing memory leaks
96
+ 2. Bridge PromptBus intercepts via patched `ctx.ui` methods, creates a `PromptRequest` with a unique `promptId` and `pipeline` tag (e.g. `"command"`, `"architect"`)
97
+ 3. Registered adapters claim the prompt:
98
+ - `DashboardDefaultAdapter` (always registered) returns a `PromptClaim` with `component: { type: "generic-dialog", props }` and `placement: "inline"`
99
+ - Custom adapters (e.g. `ArchitectUIAdapter` from pi-flows) can claim with custom component types and widget-bar placement
100
+ - TUI adapters (registered via `prompt:register-adapter` event) can claim to show a terminal dialog
101
+ 4. Bus sends `prompt_request` to server with the winning adapter's component info
102
+ 5. Server forwards to subscribed browsers
103
+ 6. Browser's `prompt-component-registry.ts` resolves the component type to a React renderer and placement
104
+ 7. User responds in browser → `prompt_response` sent to server → routed to bridge
105
+ 8. Bus resolves the original dialog promise and calls `onResponse()` on all adapters for cleanup
106
+
107
+ **First-response-wins (multi-adapter):**
108
+ - Multiple adapters can claim the same prompt (e.g. TUI + dashboard)
109
+ - The first adapter to respond wins; the bus sends `prompt_dismiss` to the server for the losing adapter's dashboard component
110
+ - Adapters implement `onCancel()` for cleanup when another adapter wins
111
+
112
+ **Custom UI components:**
113
+ - Extensions register adapters via `pi.events.emit("prompt:register-adapter", adapter)`
114
+ - Adapters return custom `PromptClaim` with arbitrary component types (e.g. `"architect-prompt"`)
115
+ - Client-side registry maps type strings to render placement; unknown types fall back to `"generic-dialog"`
116
+
117
+ **Message passthrough:**
118
+ - The `message` field from `ask_user` tool (and other `ctx.ui` callers) is forwarded via `metadata.message` in the PromptBus request, through the `prompt_request` protocol message, and extracted by the client into the interactive renderer's `params.message`.
119
+
120
+ **Type safety:**
121
+ - `prompt_request`, `prompt_dismiss`, and `prompt_cancel` **must** be in the `ServerToBrowserMessage` union in `browser-protocol.ts`. If they are only handled via `case "..." as any:` in switch statements, esbuild's dead-code elimination will strip the handlers in production builds, silently breaking the interactive UI.
108
122
 
109
123
  **Resilience:**
110
- - **Page refresh**: Server replays pending `extension_ui_request` messages when a browser subscribes, so interactive dialogs survive page refreshes.
111
- - **Server restart**: Bridge UI proxy re-sends all pending requests on WebSocket reconnect (`resendPending()`), so dialogs survive server restarts.
124
+ - **Page refresh**: Server replays pending `prompt_request` messages when a browser subscribes. Client deduplicates by `requestId` or pending title match.
125
+ - **Bridge reconnect**: Bridge replays pending PromptBus requests on WebSocket reconnect so dashboard dialogs survive server restarts.
112
126
 
113
127
  ### Command Flow (browser → pi)
114
128
  1. User types prompt or command in browser
@@ -149,7 +163,7 @@ Inline stop buttons also appear on running tool cards in `ToolCallStep`, providi
149
163
  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.
150
164
 
151
165
  **Fork decisions and subagent ask_user:**
152
- - Already work through existing UI proxy — `TuiFlowIOAdapter` calls `ctx.ui.select/confirm/input` which the bridge wraps and races between TUI and dashboard
166
+ - Work through PromptBus — `TuiFlowIOAdapter` calls `ctx.ui.select/confirm/input` which the bridge routes through the bus to registered adapters (dashboard, TUI, or custom)
153
167
 
154
168
  **Flow launcher:**
155
169
  - Available flows detected from session commands list (heuristic: `source: "extension"`, excluding management commands)
@@ -279,6 +293,18 @@ The web client includes a generic `MarkdownPreviewView` component that replaces
279
293
  ### Archive Browser
280
294
  The `ArchiveBrowserView` provides a searchable, date-grouped listing of archived OpenSpec changes. It uses a dedicated `GET /api/openspec-archive?cwd=<path>` endpoint that scans `openspec/changes/archive/` and returns entry metadata (name, date, artifacts). The view uses two-level navigation: the list is the first level, and clicking an artifact letter (P/D/S/T) opens the reader as the second level. Back from the reader returns to the list (preserving search and scroll), and back from the list returns to the session view. Entry point is the `[Archive]` button in `FolderOpenSpecSection`.
281
295
 
296
+ ### Content View Management
297
+
298
+ The content area (right panel) shows one view at a time: ChatView, ArchiveBrowserView, SpecsBrowserView, PiResourcesView, MarkdownPreviewView (readme, pi resource file, flow YAML, OpenSpec artifact), FileDiffView, FlowArchitectDetail, or FlowAgentDetail. Each view is controlled by independent state in `App.tsx` and `useContentViews`. A priority chain in the JSX determines which view renders (first truthy state wins).
299
+
300
+ **Mutual exclusivity**: A `clearAllContentViews()` helper resets all content view states. It is called before opening any new content view, ensuring the previous view is always dismissed. This combines `clearAppContentViews()` (App-level states: preview, specs browser, archive browser, diff view, flow YAML, architect detail, flow agent detail) with `clearContentViews()` from `useContentViews` (pi resources, pi resource file preview, readme preview).
301
+
302
+ **Session switch**: When the selected session changes, `clearAllContentViews()` is called to dismiss any open content view.
303
+
304
+ **Sub-navigation**: `handleViewPiResourceFile` (viewing a file within PiResourcesView) does not clear other views — it's sub-navigation within an already-active content view.
305
+
306
+ **`onBeforeOpen` callback**: `useContentViews` accepts an optional `onBeforeOpen` callback. When `handleOpenPiResources` or `handleViewReadme` opens a new top-level view, it calls `onBeforeOpen` first so App.tsx can clear its own states, then clears the hook's sibling states internally.
307
+
282
308
  ### Network Access Control
283
309
 
284
310
  The server has a two-layer access model:
@@ -499,12 +525,26 @@ The dashboard uses mDNS (via `bonjour-service`) for zero-config server discovery
499
525
  - `isDashboardRunning(port)` replaces `isPortOpen(port)` for identity-verified detection
500
526
  - After auto-starting, the bridge waits up to 10s for the server's mDNS advertisement
501
527
 
528
+ ### Known Servers
529
+ - Users can persist remote servers in `config.json` via `knownServers: KnownServer[]`
530
+ - Each entry has `host`, `port`, optional `label`, and `addedAt` timestamp
531
+ - REST API: `GET/POST/DELETE /api/known-servers` for CRUD, `POST /api/discover-servers` for on-demand mDNS scan
532
+ - Localhost is always implicitly available (not stored)
533
+ - The data model is extensible for future key exchange / auth tokens
534
+
502
535
  ### Server Selector UI
503
- - A dropdown in the sidebar header shows all discovered servers (local + LAN)
504
- - Each entry shows hostname, port, Local/Remote badge, and connection status
536
+ - The header dropdown shows persisted known servers (from config) plus localhost, not raw mDNS results
537
+ - Each entry shows label (or hostname), host:port, Local/Remote badge, and availability status
538
+ - Non-current servers are probed via health check when the dropdown opens
505
539
  - Switching closes the current WebSocket and connects to the selected server
506
540
  - Last-used server persisted in `localStorage` (`pi-dashboard-last-server`)
507
541
 
542
+ ### Server Management (Settings Panel)
543
+ - **Known Servers section**: lists persisted servers with remove buttons and an inline add form (host, port, label)
544
+ - **Network Discovery section**: "Scan network" triggers `POST /api/discover-servers`, shows results with "Add" button that prompts for a label
545
+ - Already-known servers show "Already added" badge in discovery results
546
+ - Electron loading page shows known servers as fallback when primary server is unreachable
547
+
508
548
  ## Provider Authentication
509
549
 
510
550
  The dashboard supports browser-based authentication with pi's LLM providers, enabling login from phones, tablets, or remote tunnel access without needing terminal access.
@@ -612,6 +652,19 @@ All code-server traffic is proxied through `/editor/:id/*` on the dashboard serv
612
652
 
613
653
  Binary auto-detection order: config override → `code-server` on PATH → `openvscode-server` on PATH.
614
654
 
655
+ ### Known Servers Configuration
656
+
657
+ ```json
658
+ {
659
+ "knownServers": [
660
+ { "host": "office-mac.local", "port": 8000, "label": "Office Mac", "addedAt": "2024-01-15T10:30:00Z" },
661
+ { "host": "build-server", "port": 8000, "addedAt": "2024-01-20T14:00:00Z" }
662
+ ]
663
+ }
664
+ ```
665
+
666
+ Managed via REST API (`/api/known-servers`) or Settings panel. Localhost is always implicit.
667
+
615
668
  ## Bundled Skill: pi-dashboard
616
669
 
617
670
  The `.pi/skills/pi-dashboard/` directory is both a local project skill (discovered by pi from `.pi/skills/`) and shipped with the npm package (discovered via `pi.skills` in `package.json`). This means any pi session in the dashboard project or any project that installs the dashboard package gets access to the skill.
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-agent-dashboard",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Web dashboard for monitoring and interacting with pi agent sessions",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/BlackBeltTechnology/pi-agent-dashboard"
8
8
  },
9
+ "license": "MIT",
9
10
  "publishConfig": {
10
11
  "access": "public"
11
12
  },
@@ -41,7 +42,8 @@
41
42
  "packages/dist/",
42
43
  "docs/architecture.md",
43
44
  "AGENTS.md",
44
- "README.md"
45
+ "README.md",
46
+ "LICENSE"
45
47
  ],
46
48
  "scripts": {
47
49
  "dev": "npm run dev --workspace=@blackbelt-technology/pi-dashboard-web",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-extension",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Pi bridge extension for pi-dashboard",
5
5
  "type": "module",
6
6
  "pi": {
@@ -81,5 +81,55 @@ describe("registerAskUserTool", () => {
81
81
  await tool.execute("id", { method: "input", title: "Q" }, undefined, undefined, ctx);
82
82
  expect(ctx.ui.input).toHaveBeenCalledWith("Q", undefined, undefined);
83
83
  });
84
+
85
+ it("falls back to message when title is missing", async () => {
86
+ const { tool, ctx } = getToolAndMockCtx();
87
+ await tool.execute("id", { method: "input", message: "Detailed question" }, undefined, undefined, ctx);
88
+ expect(ctx.ui.input).toHaveBeenCalledWith("Detailed question", undefined, { message: "Detailed question" });
89
+ });
90
+
91
+ it("falls back to 'Question' when both title and message are missing", async () => {
92
+ const { tool, ctx } = getToolAndMockCtx();
93
+ await tool.execute("id", { method: "confirm" }, undefined, undefined, ctx);
94
+ expect(ctx.ui.confirm).toHaveBeenCalledWith("Question", "");
95
+ });
96
+
97
+ it("parses options from JSON string", async () => {
98
+ const { tool, ctx } = getToolAndMockCtx();
99
+ await tool.execute("id", { method: "select", title: "Pick", options: '["A", "B"]' }, undefined, undefined, ctx);
100
+ expect(ctx.ui.select).toHaveBeenCalledWith("Pick", ["A", "B"], undefined);
101
+ });
102
+
103
+ it("handles malformed options string gracefully", async () => {
104
+ const { tool, ctx } = getToolAndMockCtx();
105
+ await tool.execute("id", { method: "select", title: "Pick", options: "not json" }, undefined, undefined, ctx);
106
+ expect(ctx.ui.select).toHaveBeenCalledWith("Pick", [], undefined);
107
+ });
108
+ });
109
+
110
+ describe("prepareArguments", () => {
111
+ function getTool() {
112
+ const pi = createMockPi();
113
+ registerAskUserTool(pi as any);
114
+ return pi.registerTool.mock.calls[0][0];
115
+ }
116
+
117
+ it("parses stringified options array", () => {
118
+ const tool = getTool();
119
+ const result = tool.prepareArguments({ method: "select", title: "Pick", options: '["A", "B"]' });
120
+ expect(result.options).toEqual(["A", "B"]);
121
+ });
122
+
123
+ it("leaves real array options unchanged", () => {
124
+ const tool = getTool();
125
+ const result = tool.prepareArguments({ method: "select", title: "Pick", options: ["A", "B"] });
126
+ expect(result.options).toEqual(["A", "B"]);
127
+ });
128
+
129
+ it("leaves malformed string as-is", () => {
130
+ const tool = getTool();
131
+ const result = tool.prepareArguments({ method: "select", title: "Pick", options: "not json" });
132
+ expect(result.options).toBe("not json");
133
+ });
84
134
  });
85
135
  });
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { DashboardDefaultAdapter } from "../dashboard-default-adapter.js";
3
+ import type { PromptRequest } from "../prompt-bus.js";
4
+
5
+ function makePrompt(overrides: Partial<PromptRequest> = {}): PromptRequest {
6
+ return {
7
+ id: "test-1",
8
+ pipeline: "command",
9
+ type: "select",
10
+ question: "Pick one:",
11
+ options: ["A", "B"],
12
+ ...overrides,
13
+ };
14
+ }
15
+
16
+ describe("DashboardDefaultAdapter", () => {
17
+ it("has name 'dashboard-default'", () => {
18
+ const adapter = new DashboardDefaultAdapter();
19
+ expect(adapter.name).toBe("dashboard-default");
20
+ });
21
+
22
+ it("claims all prompts with generic-dialog component", () => {
23
+ const adapter = new DashboardDefaultAdapter();
24
+ const claim = adapter.onRequest(makePrompt());
25
+
26
+ expect(claim).toEqual({
27
+ component: {
28
+ type: "generic-dialog",
29
+ props: {
30
+ question: "Pick one:",
31
+ type: "select",
32
+ options: ["A", "B"],
33
+ defaultValue: undefined,
34
+ },
35
+ },
36
+ placement: "inline",
37
+ });
38
+ });
39
+
40
+ it("claims input prompts with correct props", () => {
41
+ const adapter = new DashboardDefaultAdapter();
42
+ const claim = adapter.onRequest(makePrompt({
43
+ type: "input",
44
+ question: "Name:",
45
+ options: undefined,
46
+ defaultValue: "default",
47
+ }));
48
+
49
+ expect(claim.component!.type).toBe("generic-dialog");
50
+ expect(claim.component!.props.type).toBe("input");
51
+ expect(claim.component!.props.defaultValue).toBe("default");
52
+ });
53
+
54
+ it("claims confirm prompts", () => {
55
+ const adapter = new DashboardDefaultAdapter();
56
+ const claim = adapter.onRequest(makePrompt({ type: "confirm", question: "Sure?" }));
57
+
58
+ expect(claim.component!.type).toBe("generic-dialog");
59
+ expect(claim.component!.props.type).toBe("confirm");
60
+ });
61
+
62
+ it("placement is always inline", () => {
63
+ const adapter = new DashboardDefaultAdapter();
64
+ const claim = adapter.onRequest(makePrompt({ pipeline: "architect-new" }));
65
+ expect(claim.placement).toBe("inline");
66
+ });
67
+
68
+ it("onResponse does not throw", () => {
69
+ const adapter = new DashboardDefaultAdapter();
70
+ expect(() => adapter.onResponse({ id: "x", answer: "A", source: "tui" })).not.toThrow();
71
+ });
72
+
73
+ it("onCancel does not throw", () => {
74
+ const adapter = new DashboardDefaultAdapter();
75
+ expect(() => adapter.onCancel("x")).not.toThrow();
76
+ });
77
+ });
@@ -42,7 +42,7 @@ describe("runDevBuild", () => {
42
42
  it("should log progress messages", () => {
43
43
  run();
44
44
 
45
- const logs = logSpy.mock.calls.map((c) => c[0]);
45
+ const logs = logSpy.mock.calls.map((c: any) => c[0]);
46
46
  expect(logs).toContain("🔨 Dashboard: building client...");
47
47
  expect(logs).toContain("✅ Dashboard: client built");
48
48
  expect(logs).toContain("🛑 Dashboard: stopping server...");
@@ -54,7 +54,7 @@ describe("runDevBuild", () => {
54
54
 
55
55
  run();
56
56
 
57
- const logs = logSpy.mock.calls.map((c) => c[0]);
57
+ const logs = logSpy.mock.calls.map((c: any) => c[0]);
58
58
  expect(logs).toContain("❌ Dashboard: build failed — build error");
59
59
  expect(mockFetch).toHaveBeenCalledWith(
60
60
  "http://localhost:8000/api/shutdown",
@@ -93,28 +93,28 @@ describe("detectOpenSpecActivity", () => {
93
93
  const result = detectOpenSpecActivity("bash", {
94
94
  command: 'openspec status --change "session-sync" --json',
95
95
  });
96
- expect(result).toEqual({ changeName: "session-sync" });
96
+ expect(result).toEqual({ changeName: "session-sync", isActive: true });
97
97
  });
98
98
 
99
99
  it("detects change name from openspec instructions command", () => {
100
100
  const result = detectOpenSpecActivity("bash", {
101
101
  command: 'openspec instructions apply --change "my-feature" --json',
102
102
  });
103
- expect(result).toEqual({ changeName: "my-feature" });
103
+ expect(result).toEqual({ changeName: "my-feature", isActive: true });
104
104
  });
105
105
 
106
106
  it("detects change name without quotes", () => {
107
107
  const result = detectOpenSpecActivity("bash", {
108
108
  command: "openspec status --change session-sync --json",
109
109
  });
110
- expect(result).toEqual({ changeName: "session-sync" });
110
+ expect(result).toEqual({ changeName: "session-sync", isActive: true });
111
111
  });
112
112
 
113
113
  it("detects change name from openspec archive command", () => {
114
114
  const result = detectOpenSpecActivity("bash", {
115
115
  command: "openspec archive session-sync",
116
116
  });
117
- expect(result).toEqual({ changeName: "session-sync" });
117
+ expect(result).toEqual({ changeName: "session-sync", isActive: true });
118
118
  });
119
119
 
120
120
  it("returns null for non-openspec bash commands", () => {
@@ -128,21 +128,21 @@ describe("detectOpenSpecActivity", () => {
128
128
  const result = detectOpenSpecActivity("bash", {
129
129
  command: 'openspec new change "add-auth"',
130
130
  });
131
- expect(result).toEqual({ changeName: "add-auth" });
131
+ expect(result).toEqual({ changeName: "add-auth", isActive: true });
132
132
  });
133
133
 
134
134
  it("detects change name from openspec new change with unquoted name", () => {
135
135
  const result = detectOpenSpecActivity("bash", {
136
136
  command: "openspec new change add-auth",
137
137
  });
138
- expect(result).toEqual({ changeName: "add-auth" });
138
+ expect(result).toEqual({ changeName: "add-auth", isActive: true });
139
139
  });
140
140
 
141
141
  it("detects change name from openspec new change with cd prefix", () => {
142
142
  const result = detectOpenSpecActivity("bash", {
143
143
  command: 'cd /Users/dev/project && openspec new change "my-feature"',
144
144
  });
145
- expect(result).toEqual({ changeName: "my-feature" });
145
+ expect(result).toEqual({ changeName: "my-feature", isActive: true });
146
146
  });
147
147
 
148
148
  it("returns null for openspec list (no change name)", () => {
@@ -154,18 +154,18 @@ describe("detectOpenSpecActivity", () => {
154
154
  });
155
155
 
156
156
  describe("change name detection from file reads", () => {
157
- it("detects change name from openspec change file read", () => {
157
+ it("detects change name from openspec change file read as passive", () => {
158
158
  const result = detectOpenSpecActivity("read", {
159
159
  path: "openspec/changes/session-sync/tasks.md",
160
160
  });
161
- expect(result).toEqual({ changeName: "session-sync" });
161
+ expect(result).toEqual({ changeName: "session-sync", isActive: false });
162
162
  });
163
163
 
164
- it("detects change name from absolute path", () => {
164
+ it("detects change name from absolute path as passive", () => {
165
165
  const result = detectOpenSpecActivity("read", {
166
166
  path: "/Users/dev/project/openspec/changes/my-feature/proposal.md",
167
167
  });
168
- expect(result).toEqual({ changeName: "my-feature" });
168
+ expect(result).toEqual({ changeName: "my-feature", isActive: false });
169
169
  });
170
170
 
171
171
  it("returns null for non-openspec file reads", () => {
@@ -177,18 +177,18 @@ describe("detectOpenSpecActivity", () => {
177
177
  });
178
178
 
179
179
  describe("change name detection from file writes", () => {
180
- it("detects change name from openspec change file write", () => {
180
+ it("detects change name from openspec change file write as active", () => {
181
181
  const result = detectOpenSpecActivity("write", {
182
182
  path: "openspec/changes/session-sync/proposal.md",
183
183
  });
184
- expect(result).toEqual({ changeName: "session-sync" });
184
+ expect(result).toEqual({ changeName: "session-sync", isActive: true });
185
185
  });
186
186
 
187
- it("detects change name from absolute path write", () => {
187
+ it("detects change name from absolute path write as active", () => {
188
188
  const result = detectOpenSpecActivity("write", {
189
189
  path: "/Users/dev/project/openspec/changes/my-feature/spec.md",
190
190
  });
191
- expect(result).toEqual({ changeName: "my-feature" });
191
+ expect(result).toEqual({ changeName: "my-feature", isActive: true });
192
192
  });
193
193
 
194
194
  it("returns null for non-openspec file writes", () => {
@@ -203,15 +203,15 @@ describe("detectOpenSpecActivity", () => {
203
203
  it("handles capitalized tool names (backward compatibility)", () => {
204
204
  expect(detectOpenSpecActivity("Read", {
205
205
  path: "openspec/changes/my-feature/proposal.md",
206
- })).toEqual({ changeName: "my-feature" });
206
+ })).toEqual({ changeName: "my-feature", isActive: false });
207
207
 
208
208
  expect(detectOpenSpecActivity("Bash", {
209
209
  command: 'openspec status --change "add-auth" --json',
210
- })).toEqual({ changeName: "add-auth" });
210
+ })).toEqual({ changeName: "add-auth", isActive: true });
211
211
 
212
212
  expect(detectOpenSpecActivity("Write", {
213
213
  path: "openspec/changes/my-feature/design.md",
214
- })).toEqual({ changeName: "my-feature" });
214
+ })).toEqual({ changeName: "my-feature", isActive: true });
215
215
  });
216
216
 
217
217
  it("returns null for unknown tool names", () => {