@blackbelt-technology/pi-agent-dashboard 0.2.2 → 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.
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) |
@@ -157,7 +157,11 @@ make clean # Destroy all cloned VMs
157
157
  | `src/client/hooks/useAuthStatus.ts` | Client auth status hook and login redirect helper |
158
158
  | `src/server/localhost-guard.ts` | Network access guard: `createNetworkGuard` (loopback/trusted/authenticated), `isBypassedHost` (CIDR/wildcard/exact), netmask-to-CIDR helpers |
159
159
  | `src/server/server-pid.ts` | PID file management for daemon mode |
160
- | `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 |
161
165
  | `src/server/terminal-manager.ts` | PTY lifecycle, ring buffer, spawn/attach/kill terminals |
162
166
  | `src/server/terminal-gateway.ts` | Binary WebSocket upgrade handler for `/ws/terminal/:id` |
163
167
  | `scripts/fix-pty-permissions.cjs` | Postinstall: fix node-pty spawn-helper execute permissions |
@@ -202,8 +206,8 @@ make clean # Destroy all cloned VMs
202
206
  | `src/client/hooks/useZoomPan.ts` | Reusable zoom/pan hook (wheel, drag, pinch, buttons) |
203
207
  | `src/client/hooks/useMessageHandler.ts` | WebSocket message dispatch hook (extracted from App.tsx) |
204
208
  | `src/client/hooks/useSessionActions.ts` | Session action callbacks hook (send, abort, resume, spawn, etc.) |
205
- | `src/client/hooks/useOpenSpecActions.ts` | OpenSpec action callbacks hook (refresh, archive, attach, detach) |
206
- | `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 |
207
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` |
208
212
  | `src/client/hooks/usePendingPromptTimeout.ts` | 30-second safety timeout for stuck `pendingPrompt` spinners |
209
213
  | `src/client/lib/flow-reducer.ts` | Flow state machine: all flow_* event handling |
@@ -262,7 +266,7 @@ make clean # Destroy all cloned VMs
262
266
  | `packages/electron/scripts/test-electron-install.sh` | Full e2e Docker test: install, wizard, server launch, health check |
263
267
  | `packages/electron/scripts/test-electron-install-inner.sh` | Inner test script run inside Docker container |
264
268
  | `packages/electron/resources/icon.png` | Master 1024×1024 app icon (π on dark navy) |
265
- | `.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 |
266
270
 
267
271
  ## Build & Restart Workflow
268
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
 
@@ -34,7 +34,8 @@ A global pi extension that runs in every pi session. It:
34
34
  - Routes `ctx.ui` dialog methods (confirm, select, input, editor, notify) through `PromptBus` (`prompt-bus.ts`)
35
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
36
  - First-response-wins: multiple adapters (TUI, dashboard, custom) can claim a prompt; the first to respond resolves it, others are dismissed
37
- - Bridge emits `prompt:ctx-originals` so TUI adapters can capture original ctx.ui methods before patching
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
38
39
  - Client-side `prompt-component-registry.ts` maps component type strings to render placement (inline, widget-bar, overlay)
39
40
  - Protocol messages: `prompt_request`, `prompt_dismiss`, `prompt_cancel`, `prompt_response`
40
41
 
@@ -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
@@ -113,9 +114,15 @@ TypeScript type definitions shared across all components:
113
114
  - Adapters return custom `PromptClaim` with arbitrary component types (e.g. `"architect-prompt"`)
114
115
  - Client-side registry maps type strings to render placement; unknown types fall back to `"generic-dialog"`
115
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.
122
+
116
123
  **Resilience:**
117
- - **Page refresh**: Server replays pending `prompt_request` messages when a browser subscribes.
118
- - **Server restart**: TODO PromptBus reconnect resend not yet implemented.
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.
119
126
 
120
127
  ### Command Flow (browser → pi)
121
128
  1. User types prompt or command in browser
@@ -286,6 +293,18 @@ The web client includes a generic `MarkdownPreviewView` component that replaces
286
293
  ### Archive Browser
287
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`.
288
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
+
289
308
  ### Network Access Control
290
309
 
291
310
  The server has a two-layer access model:
@@ -506,12 +525,26 @@ The dashboard uses mDNS (via `bonjour-service`) for zero-config server discovery
506
525
  - `isDashboardRunning(port)` replaces `isPortOpen(port)` for identity-verified detection
507
526
  - After auto-starting, the bridge waits up to 10s for the server's mDNS advertisement
508
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
+
509
535
  ### Server Selector UI
510
- - A dropdown in the sidebar header shows all discovered servers (local + LAN)
511
- - 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
512
539
  - Switching closes the current WebSocket and connects to the selected server
513
540
  - Last-used server persisted in `localStorage` (`pi-dashboard-last-server`)
514
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
+
515
548
  ## Provider Authentication
516
549
 
517
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.
@@ -619,6 +652,19 @@ All code-server traffic is proxied through `/editor/:id/*` on the dashboard serv
619
652
 
620
653
  Binary auto-detection order: config override → `code-server` on PATH → `openvscode-server` on PATH.
621
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
+
622
668
  ## Bundled Skill: pi-dashboard
623
669
 
624
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.2",
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.2",
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
  });
@@ -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", () => {
@@ -27,30 +27,50 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
27
27
  description:
28
28
  "Type of question: confirm (yes/no), select (pick from options), multiselect (pick multiple), input (free text)",
29
29
  }),
30
- title: Type.String({ description: "The question to ask" }),
30
+ title: Type.Optional(Type.String({ description: "Short title for the question (optional, falls back to message)" })),
31
31
  message: Type.Optional(Type.String({ description: "Additional context or detailed question body (all methods)" })),
32
32
  options: Type.Optional(
33
33
  Type.Array(Type.String(), { description: "Options to choose from (for select)" }),
34
34
  ),
35
35
  placeholder: Type.Optional(Type.String({ description: "Placeholder text (for input)" })),
36
36
  }),
37
+ prepareArguments(args: unknown) {
38
+ const obj = (args && typeof args === "object" ? args : {}) as Record<string, unknown>;
39
+ // LLMs sometimes send options as a JSON string instead of an array
40
+ if (typeof obj.options === "string") {
41
+ try {
42
+ const parsed = JSON.parse(obj.options);
43
+ if (Array.isArray(parsed)) obj.options = parsed;
44
+ } catch { /* leave as-is, validation will report */ }
45
+ }
46
+ return obj as any;
47
+ },
37
48
  async execute(_toolCallId: any, params: any, _signal: any, _onUpdate: any, ctx: any) {
38
49
  let result: unknown;
39
50
 
40
51
  const msgOpts = params.message ? { message: params.message } : undefined;
41
52
 
53
+ const title = params.title || params.message || "Question";
54
+
55
+ // LLMs sometimes send options as a JSON string instead of an array
56
+ const options: string[] = Array.isArray(params.options)
57
+ ? params.options
58
+ : typeof params.options === "string"
59
+ ? (() => { try { const p = JSON.parse(params.options); return Array.isArray(p) ? p : []; } catch { return []; } })()
60
+ : [];
61
+
42
62
  switch (params.method) {
43
63
  case "confirm":
44
- result = await ctx.ui.confirm(params.title, params.message ?? "");
64
+ result = await ctx.ui.confirm(title, params.message ?? "");
45
65
  break;
46
66
  case "select":
47
- result = await ctx.ui.select(params.title, params.options ?? [], msgOpts);
67
+ result = await ctx.ui.select(title, options, msgOpts);
48
68
  break;
49
69
  case "multiselect":
50
- result = await (ctx.ui as any).multiselect(params.title, params.options ?? [], msgOpts);
70
+ result = await (ctx.ui as any).multiselect(title, options, msgOpts);
51
71
  break;
52
72
  case "input":
53
- result = await ctx.ui.input(params.title, params.placeholder, msgOpts);
73
+ result = await ctx.ui.input(title, params.placeholder, msgOpts);
54
74
  break;
55
75
  }
56
76
 
@@ -775,20 +775,20 @@ function initBridge(pi: ExtensionAPI) {
775
775
  // now route through the bus, which distributes to all registered adapters.
776
776
  {
777
777
  const bus = promptBus;
778
- (ctx.ui as any).select = (title: string, options: string[], _opts?: any) =>
779
- bus.request({ pipeline: "command", type: "select", question: title, options })
778
+ (ctx.ui as any).select = (title: string, options: string[], opts?: any) =>
779
+ bus.request({ pipeline: "command", type: "select", question: title, options, metadata: opts?.message ? { message: opts.message } : undefined })
780
780
  .then(r => r.cancelled ? undefined : r.answer);
781
781
 
782
- (ctx.ui as any).input = (title: string, placeholder?: string, _opts?: any) =>
783
- bus.request({ pipeline: "command", type: "input", question: title, defaultValue: placeholder })
782
+ (ctx.ui as any).input = (title: string, placeholder?: string, opts?: any) =>
783
+ bus.request({ pipeline: "command", type: "input", question: title, defaultValue: placeholder, metadata: opts?.message ? { message: opts.message } : undefined })
784
784
  .then(r => r.cancelled ? undefined : r.answer);
785
785
 
786
- (ctx.ui as any).confirm = (title: string, _message: string, _opts?: any) =>
787
- bus.request({ pipeline: "command", type: "confirm", question: title })
786
+ (ctx.ui as any).confirm = (title: string, message?: string, opts?: any) =>
787
+ bus.request({ pipeline: "command", type: "confirm", question: title, metadata: (message || opts?.message) ? { message: message || opts?.message } : undefined })
788
788
  .then(r => !r.cancelled && r.answer === "true");
789
789
 
790
- (ctx.ui as any).editor = (title: string, prefill?: string, _opts?: any) =>
791
- bus.request({ pipeline: "command", type: "editor", question: title, defaultValue: prefill })
790
+ (ctx.ui as any).editor = (title: string, prefill?: string, opts?: any) =>
791
+ bus.request({ pipeline: "command", type: "editor", question: title, defaultValue: prefill, metadata: opts?.message ? { message: opts.message } : undefined })
792
792
  .then(r => r.cancelled ? undefined : r.answer);
793
793
 
794
794
  // Notify is fire-and-forget: call original + forward to dashboard
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-server",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Dashboard server for monitoring and interacting with pi agent sessions",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
3
+ import { writeConfigPartial } from "../config-api.js";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import os from "node:os";
7
+ import type { DiscoveredServer } from "@blackbelt-technology/pi-dashboard-shared/mdns-discovery.js";
8
+
9
+ describe("known-servers CRUD", () => {
10
+ let testDir: string;
11
+ let configFile: string;
12
+ let origHome: string;
13
+
14
+ beforeEach(() => {
15
+ testDir = path.join(os.tmpdir(), `test-known-servers-${Date.now()}`);
16
+ fs.mkdirSync(path.join(testDir, ".pi", "dashboard"), { recursive: true });
17
+ configFile = path.join(testDir, ".pi", "dashboard", "config.json");
18
+ fs.writeFileSync(configFile, JSON.stringify({}));
19
+ origHome = process.env.HOME!;
20
+ process.env.HOME = testDir;
21
+ });
22
+
23
+ afterEach(() => {
24
+ process.env.HOME = origHome;
25
+ if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
26
+ });
27
+
28
+ it("should default to empty knownServers", () => {
29
+ const config = loadConfig();
30
+ expect(config.knownServers).toEqual([]);
31
+ });
32
+
33
+ it("should add a known server", () => {
34
+ const server = { host: "office-mac", port: 8000, label: "Office", addedAt: new Date().toISOString() };
35
+ writeConfigPartial({ knownServers: [server] });
36
+ const config = loadConfig();
37
+ expect(config.knownServers).toHaveLength(1);
38
+ expect(config.knownServers[0].host).toBe("office-mac");
39
+ expect(config.knownServers[0].port).toBe(8000);
40
+ expect(config.knownServers[0].label).toBe("Office");
41
+ });
42
+
43
+ it("should update label on duplicate host:port", () => {
44
+ const servers = [
45
+ { host: "office-mac", port: 8000, label: "Old Label", addedAt: new Date().toISOString() },
46
+ ];
47
+ writeConfigPartial({ knownServers: servers });
48
+
49
+ // Simulate "add duplicate" by reading, updating, writing
50
+ const config = loadConfig();
51
+ const existing = config.knownServers;
52
+ const idx = existing.findIndex((s) => s.host === "office-mac" && s.port === 8000);
53
+ expect(idx).toBe(0);
54
+ existing[idx] = { ...existing[idx], label: "New Label" };
55
+ writeConfigPartial({ knownServers: existing });
56
+
57
+ const updated = loadConfig();
58
+ expect(updated.knownServers).toHaveLength(1);
59
+ expect(updated.knownServers[0].label).toBe("New Label");
60
+ });
61
+
62
+ it("should remove a known server", () => {
63
+ const servers = [
64
+ { host: "office-mac", port: 8000, label: "Office", addedAt: new Date().toISOString() },
65
+ { host: "build-server", port: 8000, label: "Build", addedAt: new Date().toISOString() },
66
+ ];
67
+ writeConfigPartial({ knownServers: servers });
68
+
69
+ const config = loadConfig();
70
+ const filtered = config.knownServers.filter((s) => !(s.host === "office-mac" && s.port === 8000));
71
+ writeConfigPartial({ knownServers: filtered });
72
+
73
+ const updated = loadConfig();
74
+ expect(updated.knownServers).toHaveLength(1);
75
+ expect(updated.knownServers[0].host).toBe("build-server");
76
+ });
77
+
78
+ it("should be idempotent when removing non-existent server", () => {
79
+ const servers = [
80
+ { host: "office-mac", port: 8000, label: "Office", addedAt: new Date().toISOString() },
81
+ ];
82
+ writeConfigPartial({ knownServers: servers });
83
+
84
+ const config = loadConfig();
85
+ const filtered = config.knownServers.filter((s) => !(s.host === "nonexistent" && s.port === 9999));
86
+ writeConfigPartial({ knownServers: filtered });
87
+
88
+ const updated = loadConfig();
89
+ expect(updated.knownServers).toHaveLength(1);
90
+ });
91
+
92
+ it("should list known servers from config", () => {
93
+ const servers = [
94
+ { host: "a", port: 8000, addedAt: "2024-01-01T00:00:00Z" },
95
+ { host: "b", port: 9000, label: "B Server", addedAt: "2024-01-02T00:00:00Z" },
96
+ ];
97
+ writeConfigPartial({ knownServers: servers });
98
+
99
+ const config = loadConfig();
100
+ expect(config.knownServers).toHaveLength(2);
101
+ expect(config.knownServers[0].host).toBe("a");
102
+ expect(config.knownServers[1].label).toBe("B Server");
103
+ });
104
+
105
+ it("should handle entries without label", () => {
106
+ const servers = [{ host: "no-label", port: 8000, addedAt: "2024-01-01T00:00:00Z" }];
107
+ writeConfigPartial({ knownServers: servers });
108
+
109
+ const config = loadConfig();
110
+ expect(config.knownServers[0].label).toBeUndefined();
111
+ });
112
+
113
+ it("should ignore invalid entries in knownServers", () => {
114
+ // Write raw JSON with invalid entries
115
+ fs.writeFileSync(configFile, JSON.stringify({
116
+ knownServers: [
117
+ { host: "valid", port: 8000, addedAt: "2024-01-01T00:00:00Z" },
118
+ { host: "no-port" }, // missing port
119
+ "invalid-string",
120
+ null,
121
+ { port: 8000 }, // missing host
122
+ ],
123
+ }));
124
+
125
+ const config = loadConfig();
126
+ expect(config.knownServers).toHaveLength(1);
127
+ expect(config.knownServers[0].host).toBe("valid");
128
+ });
129
+ });
@@ -135,10 +135,11 @@ export function wireEvents(deps: EventWiringDeps): void {
135
135
  if (changed) {
136
136
  sessionManager.update(sessionId, activityUpdates);
137
137
  const updatedSession = sessionManager.get(sessionId);
138
- // Auto-attach proposal when changeName is detected (phase is optional —
139
- // skills loaded via prompt templates don't emit a SKILL.md read event)
138
+ // Auto-attach proposal when changeName is detected via active operations
139
+ // (write/CLI). Reads are passive (browsing/analysis) and don't trigger attach.
140
+ // Phase is optional — skills loaded via prompt templates don't emit a SKILL.md read event.
140
141
  const attachUpdates: Partial<DashboardSession> = {};
141
- if (updatedSession?.openspecChange && !updatedSession.attachedProposal) {
142
+ if (updatedSession?.openspecChange && !updatedSession.attachedProposal && detected.isActive) {
142
143
  attachUpdates.attachedProposal = updatedSession.openspecChange;
143
144
  if (!updatedSession.name?.trim()) {
144
145
  attachUpdates.name = updatedSession.openspecChange;
@@ -507,17 +508,17 @@ export function wireEvents(deps: EventWiringDeps): void {
507
508
  // Legacy extension_ui_request/dismiss removed — replaced by PromptBus protocol.
508
509
 
509
510
  // ── PromptBus protocol messages (extension → browser) ──
510
- if ((msg as any).type === "prompt_request") {
511
+ if (msg.type === "prompt_request") {
511
512
  browserGateway.trackPromptRequest(sessionId, msg as any);
512
513
  browserGateway.sendToSubscribers(sessionId, msg as any);
513
514
  }
514
515
 
515
- if ((msg as any).type === "prompt_dismiss") {
516
+ if (msg.type === "prompt_dismiss") {
516
517
  browserGateway.clearPromptRequest(sessionId, (msg as any).promptId);
517
518
  browserGateway.sendToSubscribers(sessionId, msg as any);
518
519
  }
519
520
 
520
- if ((msg as any).type === "prompt_cancel") {
521
+ if (msg.type === "prompt_cancel") {
521
522
  browserGateway.clearPromptRequest(sessionId, (msg as any).promptId);
522
523
  browserGateway.sendToSubscribers(sessionId, msg as any);
523
524
  }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * REST routes for known servers management and network discovery.
3
+ */
4
+ import type { FastifyInstance } from "fastify";
5
+ import type { NetworkGuard } from "./route-deps.js";
6
+ import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/types.js";
7
+ import type { KnownServer } from "@blackbelt-technology/pi-dashboard-shared/config.js";
8
+ import type { DiscoveredServer } from "@blackbelt-technology/pi-dashboard-shared/mdns-discovery.js";
9
+ import type {
10
+ AddKnownServerRequest,
11
+ RemoveKnownServerRequest,
12
+ DiscoveredServerInfo,
13
+ } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
14
+ import { loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
15
+ import { writeConfigPartial } from "../config-api.js";
16
+
17
+ export function registerKnownServersRoutes(
18
+ fastify: FastifyInstance,
19
+ deps: {
20
+ networkGuard: NetworkGuard;
21
+ getPeerServers: () => Map<string, DiscoveredServer>;
22
+ },
23
+ ) {
24
+ const { networkGuard, getPeerServers } = deps;
25
+
26
+ // List known servers from config
27
+ fastify.get(
28
+ "/api/known-servers",
29
+ { preHandler: networkGuard },
30
+ async (): Promise<ApiResponse<KnownServer[]>> => {
31
+ const config = loadConfig();
32
+ return { success: true, data: config.knownServers };
33
+ },
34
+ );
35
+
36
+ // Add or update a known server
37
+ fastify.post<{ Body: AddKnownServerRequest }>(
38
+ "/api/known-servers",
39
+ { preHandler: networkGuard },
40
+ async (request): Promise<ApiResponse> => {
41
+ const { host, port, label } = request.body;
42
+ if (!host || typeof port !== "number") {
43
+ return { success: false, error: "host and port are required" };
44
+ }
45
+
46
+ const config = loadConfig();
47
+ const existing = config.knownServers;
48
+ const idx = existing.findIndex((s) => s.host === host && s.port === port);
49
+
50
+ if (idx >= 0) {
51
+ // Update label on duplicate
52
+ existing[idx] = { ...existing[idx], ...(label !== undefined ? { label } : {}) };
53
+ } else {
54
+ existing.push({
55
+ host,
56
+ port,
57
+ ...(label ? { label } : {}),
58
+ addedAt: new Date().toISOString(),
59
+ });
60
+ }
61
+
62
+ const result = writeConfigPartial({ knownServers: existing });
63
+ if (!result.success) {
64
+ return { success: false, error: result.error };
65
+ }
66
+ return { success: true };
67
+ },
68
+ );
69
+
70
+ // Remove a known server
71
+ fastify.delete<{ Body: RemoveKnownServerRequest }>(
72
+ "/api/known-servers",
73
+ { preHandler: networkGuard },
74
+ async (request): Promise<ApiResponse> => {
75
+ const { host, port } = request.body;
76
+ if (!host || typeof port !== "number") {
77
+ return { success: false, error: "host and port are required" };
78
+ }
79
+
80
+ const config = loadConfig();
81
+ const filtered = config.knownServers.filter(
82
+ (s) => !(s.host === host && s.port === port),
83
+ );
84
+
85
+ const result = writeConfigPartial({ knownServers: filtered });
86
+ if (!result.success) {
87
+ return { success: false, error: result.error };
88
+ }
89
+ return { success: true };
90
+ },
91
+ );
92
+
93
+ // On-demand network discovery — returns current mDNS peers
94
+ fastify.post(
95
+ "/api/discover-servers",
96
+ { preHandler: networkGuard },
97
+ async (): Promise<ApiResponse<DiscoveredServerInfo[]>> => {
98
+ const peers = getPeerServers();
99
+ const data: DiscoveredServerInfo[] = Array.from(peers.values()).map((s) => ({
100
+ host: s.host,
101
+ port: s.port,
102
+ piPort: s.piPort,
103
+ version: s.version,
104
+ pid: s.pid,
105
+ isLocal: s.isLocal,
106
+ }));
107
+ return { success: true, data };
108
+ },
109
+ );
110
+ }
@@ -46,6 +46,7 @@ import { registerProviderRoutes } from "./routes/provider-routes.js";
46
46
  import { PackageManagerWrapper } from "./package-manager-wrapper.js";
47
47
  import { createEditorManager, type EditorManager } from "./editor-manager.js";
48
48
  import { registerEditorRoutes } from "./routes/editor-routes.js";
49
+ import { registerKnownServersRoutes } from "./routes/known-servers-routes.js";
49
50
  import { registerEditorProxy, handleEditorUpgrade } from "./editor-proxy.js";
50
51
  import { detectCodeServerBinary } from "./editor-detection.js";
51
52
 
@@ -342,6 +343,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
342
343
  registerEditorProxy(fastify, editorManager);
343
344
 
344
345
  registerProviderAuthRoutes(fastify, { piGateway });
346
+ registerKnownServersRoutes(fastify, { networkGuard, getPeerServers: () => peerServers });
345
347
  registerProviderRoutes(fastify, { networkGuard });
346
348
 
347
349
  // Serve static files / SPA fallback
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-shared",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Shared types and utilities for pi-dashboard",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Type-level tests ensuring PromptBus messages are included in ServerToBrowserMessage.
3
+ *
4
+ * These tests prevent the regression where `case "prompt_request" as any:` etc.
5
+ * in switch statements were dead-code eliminated by esbuild because the message
6
+ * types were not in the ServerToBrowserMessage union.
7
+ */
8
+ import { describe, it, expect } from "vitest";
9
+ import type {
10
+ ServerToBrowserMessage,
11
+ BrowserPromptRequestMessage,
12
+ BrowserPromptDismissMessage,
13
+ BrowserPromptCancelMessage,
14
+ } from "../browser-protocol.js";
15
+
16
+ // Type-level assertion: if these types are NOT in the union, this will fail to compile.
17
+ type AssertExtends<T, U> = T extends U ? true : never;
18
+ type _PromptRequestInUnion = AssertExtends<BrowserPromptRequestMessage, ServerToBrowserMessage>;
19
+ type _PromptDismissInUnion = AssertExtends<BrowserPromptDismissMessage, ServerToBrowserMessage>;
20
+ type _PromptCancelInUnion = AssertExtends<BrowserPromptCancelMessage, ServerToBrowserMessage>;
21
+
22
+ // Runtime verification that the type discriminants are reachable in a switch
23
+ function extractPromptType(msg: ServerToBrowserMessage): string | null {
24
+ switch (msg.type) {
25
+ case "prompt_request": return msg.promptId;
26
+ case "prompt_dismiss": return msg.promptId;
27
+ case "prompt_cancel": return msg.promptId;
28
+ default: return null;
29
+ }
30
+ }
31
+
32
+ describe("ServerToBrowserMessage includes PromptBus messages", () => {
33
+ it("prompt_request is a valid discriminant", () => {
34
+ const msg: BrowserPromptRequestMessage = {
35
+ type: "prompt_request",
36
+ sessionId: "s1",
37
+ promptId: "p1",
38
+ prompt: { question: "Q?", type: "input" },
39
+ component: { type: "generic-dialog", props: {} },
40
+ placement: "inline",
41
+ };
42
+ expect(extractPromptType(msg)).toBe("p1");
43
+ });
44
+
45
+ it("prompt_dismiss is a valid discriminant", () => {
46
+ const msg: BrowserPromptDismissMessage = {
47
+ type: "prompt_dismiss",
48
+ sessionId: "s1",
49
+ promptId: "p1",
50
+ };
51
+ expect(extractPromptType(msg)).toBe("p1");
52
+ });
53
+
54
+ it("prompt_cancel is a valid discriminant", () => {
55
+ const msg: BrowserPromptCancelMessage = {
56
+ type: "prompt_cancel",
57
+ sessionId: "s1",
58
+ promptId: "p1",
59
+ };
60
+ expect(extractPromptType(msg)).toBe("p1");
61
+ });
62
+ });
@@ -162,6 +162,39 @@ export interface EditorStatusMessage {
162
162
  status: EditorInstanceStatus;
163
163
  }
164
164
 
165
+ // ── PromptBus protocol (Server → Browser) ───────────────────────────
166
+
167
+ export interface BrowserPromptRequestMessage {
168
+ type: "prompt_request";
169
+ sessionId: string;
170
+ promptId: string;
171
+ prompt: {
172
+ question: string;
173
+ type: string;
174
+ options?: string[];
175
+ defaultValue?: string;
176
+ pipeline?: string;
177
+ metadata?: Record<string, unknown>;
178
+ };
179
+ component: {
180
+ type: string;
181
+ props: Record<string, unknown>;
182
+ };
183
+ placement: string;
184
+ }
185
+
186
+ export interface BrowserPromptDismissMessage {
187
+ type: "prompt_dismiss";
188
+ sessionId: string;
189
+ promptId: string;
190
+ }
191
+
192
+ export interface BrowserPromptCancelMessage {
193
+ type: "prompt_cancel";
194
+ sessionId: string;
195
+ promptId: string;
196
+ }
197
+
165
198
  /** Progress event streamed during a package install/remove/update operation. */
166
199
  export interface PackageProgressMessage {
167
200
  type: "package_progress";
@@ -216,7 +249,10 @@ export type ServerToBrowserMessage =
216
249
  | BrowserRolesListMessage
217
250
  | ProcessListUpdateMessage
218
251
  | ServersDiscoveredMessage
219
- | ServersUpdatedMessage;
252
+ | ServersUpdatedMessage
253
+ | BrowserPromptRequestMessage
254
+ | BrowserPromptDismissMessage
255
+ | BrowserPromptCancelMessage;
220
256
 
221
257
  // ── Browser → Server ────────────────────────────────────────────────
222
258
 
@@ -55,6 +55,13 @@ export const DEFAULT_EDITOR_CONFIG: EditorConfig = {
55
55
  maxInstances: 3,
56
56
  };
57
57
 
58
+ export interface KnownServer {
59
+ host: string;
60
+ port: number;
61
+ label?: string;
62
+ addedAt: string; // ISO timestamp
63
+ }
64
+
58
65
  export interface DashboardConfig {
59
66
  port: number;
60
67
  piPort: number;
@@ -78,6 +85,8 @@ export interface DashboardConfig {
78
85
  lastServer?: string;
79
86
  /** Whether the server was launched by the Electron app */
80
87
  electronMode: boolean;
88
+ /** Persisted list of known remote servers */
89
+ knownServers: KnownServer[];
81
90
  }
82
91
 
83
92
  export interface CorsConfig {
@@ -103,6 +112,7 @@ const DEFAULTS: DashboardConfig = {
103
112
  resolvedTrustedNetworks: [],
104
113
  cors: { allowedOrigins: [] },
105
114
  electronMode: false,
115
+ knownServers: [],
106
116
  };
107
117
 
108
118
  /**
@@ -156,6 +166,18 @@ function parseMemoryLimits(raw: any): MemoryLimitsConfig {
156
166
  };
157
167
  }
158
168
 
169
+ function parseKnownServers(raw: any): KnownServer[] {
170
+ if (!Array.isArray(raw)) return [];
171
+ return raw
172
+ .filter((entry: any) => entry && typeof entry === "object" && typeof entry.host === "string" && typeof entry.port === "number")
173
+ .map((entry: any) => ({
174
+ host: entry.host,
175
+ port: entry.port,
176
+ ...(typeof entry.label === "string" ? { label: entry.label } : {}),
177
+ addedAt: typeof entry.addedAt === "string" ? entry.addedAt : new Date().toISOString(),
178
+ }));
179
+ }
180
+
159
181
  function parseTrustedNetworks(raw: any): string[] {
160
182
  if (!Array.isArray(raw)) return [];
161
183
  return raw.filter((entry: unknown) => typeof entry === "string" && entry.length > 0);
@@ -204,6 +226,7 @@ export function loadConfig(): DashboardConfig {
204
226
  },
205
227
  ...(typeof parsed.lastServer === "string" ? { lastServer: parsed.lastServer } : {}),
206
228
  electronMode: parsed.electronMode === true,
229
+ knownServers: parseKnownServers(parsed.knownServers),
207
230
  };
208
231
 
209
232
  // Compute resolvedTrustedNetworks: merge trustedNetworks + auth.bypassHosts
@@ -7,6 +7,8 @@ import type { OpenSpecPhase } from "./types.js";
7
7
  export interface DetectedActivity {
8
8
  phase?: OpenSpecPhase;
9
9
  changeName?: string;
10
+ /** True for write/CLI operations (active work), false for reads (passive browsing) */
11
+ isActive?: boolean;
10
12
  }
11
13
 
12
14
  /** Map from skill directory name suffix to phase */
@@ -59,10 +61,10 @@ export function detectOpenSpecActivity(
59
61
  return null;
60
62
  }
61
63
 
62
- // Check for openspec change file read → change name detection
64
+ // Check for openspec change file read → change name detection (passive)
63
65
  const changeMatch = path.match(CHANGE_PATH_RE);
64
66
  if (changeMatch) {
65
- return { changeName: changeMatch[1] };
67
+ return { changeName: changeMatch[1], isActive: false };
66
68
  }
67
69
 
68
70
  return null;
@@ -74,7 +76,7 @@ export function detectOpenSpecActivity(
74
76
 
75
77
  const changeMatch = path.match(CHANGE_PATH_RE);
76
78
  if (changeMatch) {
77
- return { changeName: changeMatch[1] };
79
+ return { changeName: changeMatch[1], isActive: true };
78
80
  }
79
81
 
80
82
  return null;
@@ -87,19 +89,19 @@ export function detectOpenSpecActivity(
87
89
  // Check for --change flag
88
90
  const flagMatch = command.match(CLI_CHANGE_FLAG_RE);
89
91
  if (flagMatch) {
90
- return { changeName: flagMatch[1] };
92
+ return { changeName: flagMatch[1], isActive: true };
91
93
  }
92
94
 
93
95
  // Check for openspec archive <name>
94
96
  const archiveMatch = command.match(CLI_ARCHIVE_RE);
95
97
  if (archiveMatch) {
96
- return { changeName: archiveMatch[1] };
98
+ return { changeName: archiveMatch[1], isActive: true };
97
99
  }
98
100
 
99
101
  // Check for openspec new change "name"
100
102
  const newChangeMatch = command.match(CLI_NEW_CHANGE_RE);
101
103
  if (newChangeMatch) {
102
- return { changeName: newChangeMatch[1] };
104
+ return { changeName: newChangeMatch[1], isActive: true };
103
105
  }
104
106
 
105
107
  return null;
@@ -246,6 +246,34 @@ export interface PackageUpdateInfo {
246
246
 
247
247
  export type CheckUpdatesResponse = ApiResponse<PackageUpdateInfo[]>;
248
248
 
249
+ // ── Known Servers ─────────────────────────────────────────────
250
+
251
+ import type { KnownServer } from "./config.js";
252
+
253
+ export type KnownServersListResponse = ApiResponse<KnownServer[]>;
254
+
255
+ export interface AddKnownServerRequest {
256
+ host: string;
257
+ port: number;
258
+ label?: string;
259
+ }
260
+
261
+ export interface RemoveKnownServerRequest {
262
+ host: string;
263
+ port: number;
264
+ }
265
+
266
+ export interface DiscoveredServerInfo {
267
+ host: string;
268
+ port: number;
269
+ piPort: number;
270
+ version: string;
271
+ pid: number;
272
+ isLocal: boolean;
273
+ }
274
+
275
+ export type DiscoverServersResponse = ApiResponse<DiscoveredServerInfo[]>;
276
+
249
277
  /** Detected network interface for trusted networks UI. */
250
278
  export interface NetworkInterface {
251
279
  name: string;