@blackbelt-technology/pi-agent-dashboard 0.2.2 → 0.2.4
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 +9 -5
- package/LICENSE +21 -0
- package/README.md +2 -2
- package/docs/architecture.md +52 -6
- package/package.json +4 -2
- package/packages/extension/package.json +1 -1
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +50 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +18 -18
- package/packages/extension/src/ask-user-tool.ts +25 -5
- package/packages/extension/src/bridge.ts +8 -8
- package/packages/server/package.json +1 -1
- package/packages/server/src/__tests__/known-servers-routes.test.ts +129 -0
- package/packages/server/src/event-wiring.ts +7 -6
- package/packages/server/src/routes/known-servers-routes.ts +110 -0
- package/packages/server/src/server.ts +2 -0
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +62 -0
- package/packages/shared/src/browser-protocol.ts +37 -1
- package/packages/shared/src/config.ts +23 -0
- package/packages/shared/src/openspec-activity-detector.ts +8 -6
- package/packages/shared/src/rest-api.ts +28 -0
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
|
|
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/
|
|
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
|
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
|
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
|
-
- **
|
|
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
|
-
-
|
|
511
|
-
- Each entry shows hostname, port, Local/Remote badge, and
|
|
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.
|
|
3
|
+
"version": "0.2.4",
|
|
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",
|
|
@@ -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: "
|
|
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(
|
|
64
|
+
result = await ctx.ui.confirm(title, params.message ?? "");
|
|
45
65
|
break;
|
|
46
66
|
case "select":
|
|
47
|
-
result = await ctx.ui.select(
|
|
67
|
+
result = await ctx.ui.select(title, options, msgOpts);
|
|
48
68
|
break;
|
|
49
69
|
case "multiselect":
|
|
50
|
-
result = await (ctx.ui as any).multiselect(
|
|
70
|
+
result = await (ctx.ui as any).multiselect(title, options, msgOpts);
|
|
51
71
|
break;
|
|
52
72
|
case "input":
|
|
53
|
-
result = await ctx.ui.input(
|
|
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[],
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
@@ -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
|
|
139
|
-
//
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
@@ -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;
|