@blackbelt-technology/pi-agent-dashboard 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +12 -6
- package/LICENSE +21 -0
- package/README.md +2 -2
- package/docs/architecture.md +79 -26
- 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__/dashboard-default-adapter.test.ts +77 -0
- package/packages/extension/src/__tests__/dev-build.test.ts +2 -2
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +18 -18
- package/packages/extension/src/__tests__/prompt-bus-wiring.test.ts +791 -0
- package/packages/extension/src/__tests__/prompt-bus.test.ts +469 -0
- package/packages/extension/src/__tests__/server-launcher.test.ts +35 -34
- package/packages/extension/src/__tests__/tui-prompt-adapter.test.ts +207 -0
- package/packages/extension/src/ask-user-tool.ts +26 -6
- package/packages/extension/src/bridge-context.ts +1 -1
- package/packages/extension/src/bridge.ts +214 -59
- package/packages/extension/src/command-handler.ts +2 -2
- package/packages/extension/src/dashboard-default-adapter.ts +37 -0
- package/packages/extension/src/flow-event-wiring.ts +6 -23
- package/packages/extension/src/pi-env.d.ts +13 -0
- package/packages/extension/src/prompt-bus.ts +240 -0
- package/packages/extension/src/server-launcher.ts +2 -2
- package/packages/extension/src/session-sync.ts +2 -1
- package/packages/server/package.json +1 -1
- package/packages/server/src/__tests__/bridge-register-nondestructive.test.ts +108 -0
- package/packages/server/src/__tests__/extension-register-appimage.test.ts +39 -0
- package/packages/server/src/__tests__/extension-register.test.ts +26 -22
- package/packages/server/src/__tests__/known-servers-routes.test.ts +129 -0
- package/packages/server/src/__tests__/process-manager.test.ts +4 -1
- package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +5 -5
- package/packages/server/src/__tests__/tunnel.test.ts +2 -2
- package/packages/server/src/browser-gateway.ts +55 -16
- package/packages/server/src/cli.ts +1 -1
- package/packages/server/src/editor-manager.ts +1 -1
- package/packages/server/src/event-status-extraction.ts +7 -0
- package/packages/server/src/event-wiring.ts +20 -22
- package/packages/server/src/package-manager-wrapper.ts +1 -1
- package/packages/server/src/process-manager.ts +8 -69
- package/packages/server/src/routes/known-servers-routes.ts +110 -0
- package/packages/server/src/routes/system-routes.ts +3 -1
- package/packages/server/src/server.ts +8 -4
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/bridge-register.test.ts +136 -0
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +62 -0
- package/packages/shared/src/__tests__/tool-resolver.test.ts +164 -0
- package/packages/shared/src/bridge-register.ts +95 -0
- package/packages/shared/src/browser-protocol.ts +47 -1
- package/packages/shared/src/config.ts +23 -0
- package/packages/shared/src/managed-paths.ts +15 -0
- package/packages/shared/src/mdns-discovery.ts +1 -1
- package/packages/shared/src/openspec-activity-detector.ts +8 -6
- package/packages/shared/src/protocol.ts +46 -0
- package/packages/shared/src/rest-api.ts +28 -0
- package/packages/shared/src/tool-resolver.ts +201 -0
- package/packages/shared/src/types.ts +24 -0
- package/packages/extension/src/__tests__/ui-proxy.test.ts +0 -583
- package/packages/extension/src/ui-proxy.ts +0 -269
- package/packages/server/src/extension-register.ts +0 -92
|
@@ -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
|
+
});
|
|
@@ -168,7 +168,10 @@ describe("Process Manager", () => {
|
|
|
168
168
|
it("should not duplicate managed bin if already present", () => {
|
|
169
169
|
const managedBin = require("path").join(require("os").homedir(), ".pi-dashboard", "node_modules", ".bin");
|
|
170
170
|
const env = buildSpawnEnv({ PATH: `${managedBin}:/usr/bin` });
|
|
171
|
-
|
|
171
|
+
// Managed bin should appear exactly once
|
|
172
|
+
const parts = env.PATH!.split(":");
|
|
173
|
+
const managedCount = parts.filter(p => p === managedBin).length;
|
|
174
|
+
expect(managedCount).toBe(1);
|
|
172
175
|
});
|
|
173
176
|
});
|
|
174
177
|
|
|
@@ -43,7 +43,7 @@ describe("Session lifecycle logging", () => {
|
|
|
43
43
|
}));
|
|
44
44
|
await delay(100);
|
|
45
45
|
|
|
46
|
-
const logs = errorSpy.mock.calls.map((c) => c[0]);
|
|
46
|
+
const logs = errorSpy.mock.calls.map((c: any) => c[0]);
|
|
47
47
|
expect(logs).toContainEqual(expect.stringContaining("[gateway] session registered: log-reg cwd=/tmp/test"));
|
|
48
48
|
ws.close();
|
|
49
49
|
}, 10000);
|
|
@@ -64,7 +64,7 @@ describe("Session lifecycle logging", () => {
|
|
|
64
64
|
ws.send(JSON.stringify({ type: "session_unregister", sessionId: "log-unreg" }));
|
|
65
65
|
await delay(100);
|
|
66
66
|
|
|
67
|
-
const logs = errorSpy.mock.calls.map((c) => c[0]);
|
|
67
|
+
const logs = errorSpy.mock.calls.map((c: any) => c[0]);
|
|
68
68
|
expect(logs).toContainEqual(expect.stringContaining("[gateway] session unregistered: log-unreg (explicit)"));
|
|
69
69
|
ws.close();
|
|
70
70
|
}, 10000);
|
|
@@ -87,7 +87,7 @@ describe("Session lifecycle logging", () => {
|
|
|
87
87
|
ws.close();
|
|
88
88
|
await delay(SHORT_HB + 300);
|
|
89
89
|
|
|
90
|
-
const logs = errorSpy.mock.calls.map((c) => c[0]);
|
|
90
|
+
const logs = errorSpy.mock.calls.map((c: any) => c[0]);
|
|
91
91
|
expect(logs).toContainEqual(expect.stringContaining("[gateway] session timed out: log-timeout (no heartbeat for"));
|
|
92
92
|
}, 10000);
|
|
93
93
|
|
|
@@ -107,7 +107,7 @@ describe("Session lifecycle logging", () => {
|
|
|
107
107
|
ws.close();
|
|
108
108
|
await delay(200);
|
|
109
109
|
|
|
110
|
-
const logs = errorSpy.mock.calls.map((c) => c[0]);
|
|
110
|
+
const logs = errorSpy.mock.calls.map((c: any) => c[0]);
|
|
111
111
|
expect(logs).toContainEqual(expect.stringContaining("[gateway] connection closed: log-close"));
|
|
112
112
|
}, 10000);
|
|
113
113
|
|
|
@@ -132,7 +132,7 @@ describe("Session lifecycle logging", () => {
|
|
|
132
132
|
(ws as any)._socket?.pause();
|
|
133
133
|
await delay(200 * 4);
|
|
134
134
|
|
|
135
|
-
const logs = errorSpy.mock.calls.map((c) => c[0]);
|
|
135
|
+
const logs = errorSpy.mock.calls.map((c: any) => c[0]);
|
|
136
136
|
expect(logs).toContainEqual(expect.stringContaining("[gateway] connection dead (ping timeout, 2 misses): log-ping"));
|
|
137
137
|
}, 10000);
|
|
138
138
|
});
|
|
@@ -7,7 +7,7 @@ vi.mock("node:fs", async (importOriginal) => {
|
|
|
7
7
|
return {
|
|
8
8
|
...actual,
|
|
9
9
|
default: {
|
|
10
|
-
...actual.default,
|
|
10
|
+
...(actual as any).default,
|
|
11
11
|
existsSync: vi.fn(),
|
|
12
12
|
readFileSync: vi.fn(),
|
|
13
13
|
writeFileSync: vi.fn(),
|
|
@@ -25,7 +25,7 @@ vi.mock("node:os", async (importOriginal) => {
|
|
|
25
25
|
const actual = await importOriginal<typeof import("node:os")>();
|
|
26
26
|
return {
|
|
27
27
|
...actual,
|
|
28
|
-
default: { ...actual.default, homedir: vi.fn(() => "/home/testuser") },
|
|
28
|
+
default: { ...(actual as any).default, homedir: vi.fn(() => "/home/testuser") },
|
|
29
29
|
homedir: vi.fn(() => "/home/testuser"),
|
|
30
30
|
};
|
|
31
31
|
});
|
|
@@ -41,6 +41,10 @@ export interface BrowserGateway {
|
|
|
41
41
|
trackUiRequest(sessionId: string, requestId: string, method: string, params: Record<string, unknown>): boolean | void;
|
|
42
42
|
/** Clear a pending interactive UI request (resolved or cancelled) */
|
|
43
43
|
clearUiRequest(sessionId: string, requestId: string): void;
|
|
44
|
+
/** Track a pending PromptBus request for replay on browser refresh */
|
|
45
|
+
trackPromptRequest(sessionId: string, msg: Record<string, unknown>): void;
|
|
46
|
+
/** Clear a pending PromptBus request (dismissed or cancelled) */
|
|
47
|
+
clearPromptRequest(sessionId: string, promptId: string): void;
|
|
44
48
|
/** Tell browser subscribers to reset accumulated state for a session (bridge reconnected) */
|
|
45
49
|
broadcastSessionStateReset(sessionId: string): void;
|
|
46
50
|
/** Shut down all tracked headless child processes */
|
|
@@ -83,6 +87,9 @@ export function createBrowserGateway(
|
|
|
83
87
|
// Track pending interactive UI requests per session for replay on reconnect
|
|
84
88
|
const pendingUiRequests = new Map<string, Map<string, { requestId: string; method: string; params: Record<string, unknown> }>>();
|
|
85
89
|
|
|
90
|
+
// Track pending PromptBus requests per session for replay on browser refresh
|
|
91
|
+
const pendingPromptRequests = new Map<string, Map<string, Record<string, unknown>>>();
|
|
92
|
+
|
|
86
93
|
// Track pending auto-resume prompts for ended sessions
|
|
87
94
|
const pendingResumeRegistry = createPendingResumeRegistry({
|
|
88
95
|
onTimeout(oldSessionId) {
|
|
@@ -95,15 +102,23 @@ export function createBrowserGateway(
|
|
|
95
102
|
/** Send any pending interactive UI requests to a specific browser socket */
|
|
96
103
|
function replayPendingUiRequests(ws: WebSocket, sessionId: string) {
|
|
97
104
|
const sessionPending = pendingUiRequests.get(sessionId);
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
if (sessionPending) {
|
|
106
|
+
for (const req of sessionPending.values()) {
|
|
107
|
+
sendTo(ws, {
|
|
108
|
+
type: "extension_ui_request",
|
|
109
|
+
sessionId,
|
|
110
|
+
requestId: req.requestId,
|
|
111
|
+
method: req.method,
|
|
112
|
+
params: req.params,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Also replay pending PromptBus requests
|
|
117
|
+
const sessionPrompts = pendingPromptRequests.get(sessionId);
|
|
118
|
+
if (sessionPrompts) {
|
|
119
|
+
for (const msg of sessionPrompts.values()) {
|
|
120
|
+
sendTo(ws, msg as any);
|
|
121
|
+
}
|
|
107
122
|
}
|
|
108
123
|
}
|
|
109
124
|
|
|
@@ -125,6 +140,26 @@ export function createBrowserGateway(
|
|
|
125
140
|
return true;
|
|
126
141
|
}
|
|
127
142
|
|
|
143
|
+
function trackPromptRequest(sessionId: string, msg: Record<string, unknown>): void {
|
|
144
|
+
let sessionMap = pendingPromptRequests.get(sessionId);
|
|
145
|
+
if (!sessionMap) {
|
|
146
|
+
sessionMap = new Map();
|
|
147
|
+
pendingPromptRequests.set(sessionId, sessionMap);
|
|
148
|
+
}
|
|
149
|
+
const promptId = msg.promptId as string;
|
|
150
|
+
if (promptId) {
|
|
151
|
+
sessionMap.set(promptId, msg);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function clearPromptRequest(sessionId: string, promptId: string): void {
|
|
156
|
+
const sessionMap = pendingPromptRequests.get(sessionId);
|
|
157
|
+
if (sessionMap) {
|
|
158
|
+
sessionMap.delete(promptId);
|
|
159
|
+
if (sessionMap.size === 0) pendingPromptRequests.delete(sessionId);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
128
163
|
function getSubscribers(sessionId: string): WebSocket[] {
|
|
129
164
|
const result: WebSocket[] = [];
|
|
130
165
|
for (const [ws, subs] of subscriptions) {
|
|
@@ -321,6 +356,12 @@ export function createBrowserGateway(
|
|
|
321
356
|
break;
|
|
322
357
|
}
|
|
323
358
|
|
|
359
|
+
case "prompt_response": {
|
|
360
|
+
// Route PromptBus response from browser to extension
|
|
361
|
+
ctx.piGateway.sendToSession((msg as any).sessionId, msg as any);
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
|
|
324
365
|
case "flow_management": {
|
|
325
366
|
ctx.piGateway.sendToSession(msg.sessionId, {
|
|
326
367
|
type: "flow_management",
|
|
@@ -333,13 +374,8 @@ export function createBrowserGateway(
|
|
|
333
374
|
break;
|
|
334
375
|
}
|
|
335
376
|
case "architect_prompt_response": {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
sessionId: msg.sessionId,
|
|
339
|
-
promptId: msg.promptId,
|
|
340
|
-
answer: msg.answer,
|
|
341
|
-
cancelled: msg.cancelled,
|
|
342
|
-
});
|
|
377
|
+
// Legacy: now handled by prompt_response via PromptBus.
|
|
378
|
+
// Keep case to avoid "unhandled message" warnings from old clients.
|
|
343
379
|
break;
|
|
344
380
|
}
|
|
345
381
|
case "role_set": {
|
|
@@ -482,6 +518,9 @@ export function createBrowserGateway(
|
|
|
482
518
|
}
|
|
483
519
|
},
|
|
484
520
|
|
|
521
|
+
trackPromptRequest,
|
|
522
|
+
clearPromptRequest,
|
|
523
|
+
|
|
485
524
|
shutdownHeadlessProcesses() {
|
|
486
525
|
headlessPidRegistry.killAll();
|
|
487
526
|
},
|
|
@@ -218,7 +218,7 @@ function findPortHolders(port: number): number[] {
|
|
|
218
218
|
try {
|
|
219
219
|
const { execSync } = require("node:child_process");
|
|
220
220
|
const output = execSync(`lsof -t -i :${port} -sTCP:LISTEN 2>/dev/null`, { encoding: "utf-8" });
|
|
221
|
-
return output.trim().split("\n").map(Number).filter(n => n > 0 && n !== process.pid);
|
|
221
|
+
return output.trim().split("\n").map(Number).filter((n: number) => n > 0 && n !== process.pid);
|
|
222
222
|
} catch { return []; }
|
|
223
223
|
}
|
|
224
224
|
|
|
@@ -207,7 +207,7 @@ export function createEditorManager(options: EditorManagerOptions): EditorManage
|
|
|
207
207
|
if (inst.status === "starting") {
|
|
208
208
|
// Wait for it to become ready
|
|
209
209
|
const ready = await waitForPort(inst.port);
|
|
210
|
-
if (ready && inst.status !== "stopped") {
|
|
210
|
+
if (ready && (inst.status as string) !== "stopped") {
|
|
211
211
|
setStatus(inst, "ready");
|
|
212
212
|
startIdleTimer(inst);
|
|
213
213
|
}
|
|
@@ -117,6 +117,13 @@ export function extractSessionUpdates(event: DashboardEvent): SessionUpdates | n
|
|
|
117
117
|
};
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
case "flow_summary_dismissed": {
|
|
121
|
+
return {
|
|
122
|
+
activeFlowName: null,
|
|
123
|
+
flowStatus: null,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
120
127
|
case "architect_complete":
|
|
121
128
|
case "architect_cancelled": {
|
|
122
129
|
return {
|
|
@@ -85,7 +85,7 @@ export function wireEvents(deps: EventWiringDeps): void {
|
|
|
85
85
|
const session = sessionManager.get(sessionId);
|
|
86
86
|
updates.flowAgentsDone = (session?.flowAgentsDone ?? 0) + 1;
|
|
87
87
|
}
|
|
88
|
-
sessionManager.update(sessionId, updates);
|
|
88
|
+
sessionManager.update(sessionId, updates as Partial<DashboardSession>);
|
|
89
89
|
}
|
|
90
90
|
// Skip insert + broadcast — events are already in store
|
|
91
91
|
// Still need to continue to the rest of the handler for openspec/stats
|
|
@@ -105,7 +105,7 @@ export function wireEvents(deps: EventWiringDeps): void {
|
|
|
105
105
|
const session = sessionManager.get(sessionId);
|
|
106
106
|
updates.flowAgentsDone = (session?.flowAgentsDone ?? 0) + 1;
|
|
107
107
|
}
|
|
108
|
-
sessionManager.update(sessionId, updates);
|
|
108
|
+
sessionManager.update(sessionId, updates as Partial<DashboardSession>);
|
|
109
109
|
// During replay, accumulate in sessionManager but don't broadcast
|
|
110
110
|
// to avoid rapid status flickers on the session card
|
|
111
111
|
if (!replayingSessions.has(sessionId)) {
|
|
@@ -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;
|
|
@@ -504,25 +505,22 @@ export function wireEvents(deps: EventWiringDeps): void {
|
|
|
504
505
|
browserGateway.broadcastSessionUpdated(sessionId, modelUpdates);
|
|
505
506
|
}
|
|
506
507
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
requestId: msg.requestId,
|
|
514
|
-
method: msg.method,
|
|
515
|
-
params: msg.params,
|
|
516
|
-
});
|
|
517
|
-
}
|
|
508
|
+
// Legacy extension_ui_request/dismiss removed — replaced by PromptBus protocol.
|
|
509
|
+
|
|
510
|
+
// ── PromptBus protocol messages (extension → browser) ──
|
|
511
|
+
if (msg.type === "prompt_request") {
|
|
512
|
+
browserGateway.trackPromptRequest(sessionId, msg as any);
|
|
513
|
+
browserGateway.sendToSubscribers(sessionId, msg as any);
|
|
518
514
|
}
|
|
519
515
|
|
|
520
|
-
if (msg.type === "
|
|
521
|
-
browserGateway.
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
516
|
+
if (msg.type === "prompt_dismiss") {
|
|
517
|
+
browserGateway.clearPromptRequest(sessionId, (msg as any).promptId);
|
|
518
|
+
browserGateway.sendToSubscribers(sessionId, msg as any);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (msg.type === "prompt_cancel") {
|
|
522
|
+
browserGateway.clearPromptRequest(sessionId, (msg as any).promptId);
|
|
523
|
+
browserGateway.sendToSubscribers(sessionId, msg as any);
|
|
526
524
|
}
|
|
527
525
|
|
|
528
526
|
if (msg.type === "session_name_update") {
|
|
@@ -23,7 +23,7 @@ async function loadPiPackageManager() {
|
|
|
23
23
|
|
|
24
24
|
// Try direct import first (works if installed as a dependency)
|
|
25
25
|
try {
|
|
26
|
-
const mod = await import("@mariozechner/pi-coding-agent");
|
|
26
|
+
const mod = await import("@mariozechner/pi-coding-agent") as any;
|
|
27
27
|
if (mod.DefaultPackageManager) {
|
|
28
28
|
piModuleCache = { DefaultPackageManager: mod.DefaultPackageManager, SettingsManager: mod.SettingsManager };
|
|
29
29
|
return piModuleCache;
|
|
@@ -6,53 +6,17 @@ import { existsSync } from "node:fs";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import os from "node:os";
|
|
8
8
|
import type { SpawnStrategy } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
9
|
+
import { MANAGED_BIN } from "@blackbelt-technology/pi-dashboard-shared/managed-paths.js";
|
|
10
|
+
import { ToolResolver } from "@blackbelt-technology/pi-dashboard-shared/tool-resolver.js";
|
|
9
11
|
|
|
10
|
-
/**
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
/** Common user bin directories that may not be on PATH when launched from
|
|
14
|
-
* a desktop .desktop file or Electron app (shell profiles not sourced). */
|
|
15
|
-
function getUserBinDirs(): string[] {
|
|
16
|
-
const home = os.homedir();
|
|
17
|
-
const dirs = [
|
|
18
|
-
path.join(home, ".local", "bin"), // pip, install.sh scripts
|
|
19
|
-
path.join(home, ".npm-global", "bin"), // npm config prefix
|
|
20
|
-
"/usr/local/bin", // brew, manual installs
|
|
21
|
-
];
|
|
22
|
-
return dirs.filter(d => existsSync(d));
|
|
23
|
-
}
|
|
12
|
+
/** Server-side resolver — knows the current process node binary. */
|
|
13
|
+
const resolver = new ToolResolver({ processExecPath: process.execPath });
|
|
24
14
|
|
|
25
15
|
/** Build env with managed install bin + current node binary dir prepended to PATH.
|
|
26
|
-
*
|
|
27
|
-
* This is critical when launched from Electron (no shell profile sourced).
|
|
16
|
+
* Delegates to ToolResolver.buildSpawnEnv().
|
|
28
17
|
*/
|
|
29
18
|
export function buildSpawnEnv(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
30
|
-
|
|
31
|
-
const parts: string[] = [];
|
|
32
|
-
|
|
33
|
-
// Always ensure managed bin is on PATH (for pi, openspec, tsx)
|
|
34
|
-
if (!currentPath.includes(MANAGED_BIN)) {
|
|
35
|
-
parts.push(MANAGED_BIN);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Always ensure the current node binary's directory is on PATH.
|
|
39
|
-
// Handles bundled Node.js (e.g., /usr/lib/pi-dashboard/resources/node/bin/node)
|
|
40
|
-
// where spawned scripts use #!/usr/bin/env node.
|
|
41
|
-
const nodeBinDir = path.dirname(process.execPath);
|
|
42
|
-
if (!currentPath.includes(nodeBinDir)) {
|
|
43
|
-
parts.push(nodeBinDir);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Add common user bin directories that Electron apps miss
|
|
47
|
-
// (desktop launchers don't source ~/.bashrc / ~/.profile)
|
|
48
|
-
for (const dir of getUserBinDirs()) {
|
|
49
|
-
if (!currentPath.includes(dir)) {
|
|
50
|
-
parts.push(dir);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (parts.length === 0) return baseEnv;
|
|
55
|
-
return { ...baseEnv, PATH: `${parts.join(path.delimiter)}${path.delimiter}${currentPath}` };
|
|
19
|
+
return resolver.buildSpawnEnv(baseEnv);
|
|
56
20
|
}
|
|
57
21
|
|
|
58
22
|
export interface PlatformInfo {
|
|
@@ -134,35 +98,10 @@ export function buildHeadlessArgs(options?: SessionOptions): string[] {
|
|
|
134
98
|
}
|
|
135
99
|
|
|
136
100
|
/** Resolve the pi command as [command, ...prefixArgs].
|
|
137
|
-
*
|
|
138
|
-
* On Windows: returns ["path/to/node.exe", "path/to/pi/dist/cli.mjs"] to avoid .cmd
|
|
101
|
+
* Delegates to ToolResolver.resolvePi().
|
|
139
102
|
*/
|
|
140
103
|
function resolvePiCommand(): string[] | null {
|
|
141
|
-
|
|
142
|
-
// Try to resolve pi's CLI entry point directly (avoids .cmd → no cmd window)
|
|
143
|
-
const piCli = path.join(MANAGED_BIN, "..", "@mariozechner", "pi-coding-agent", "dist", "cli.js");
|
|
144
|
-
if (existsSync(piCli)) {
|
|
145
|
-
// Find node.exe: process.execPath is the current node binary
|
|
146
|
-
return [process.execPath, piCli];
|
|
147
|
-
}
|
|
148
|
-
// Fallback to .cmd
|
|
149
|
-
const managed = path.join(MANAGED_BIN, "pi.cmd");
|
|
150
|
-
if (existsSync(managed)) return [managed];
|
|
151
|
-
} else {
|
|
152
|
-
const managed = path.join(MANAGED_BIN, "pi");
|
|
153
|
-
if (existsSync(managed)) return [managed];
|
|
154
|
-
}
|
|
155
|
-
// System PATH
|
|
156
|
-
try {
|
|
157
|
-
const whichCmd = process.platform === "win32" ? "where" : "which";
|
|
158
|
-
const result = execSync(`${whichCmd} pi`, {
|
|
159
|
-
encoding: "utf-8",
|
|
160
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
161
|
-
env: buildSpawnEnv(),
|
|
162
|
-
}).trim();
|
|
163
|
-
if (result) return [result.split("\n")[0]];
|
|
164
|
-
} catch { /* not found */ }
|
|
165
|
-
return null;
|
|
104
|
+
return resolver.resolvePi();
|
|
166
105
|
}
|
|
167
106
|
|
|
168
107
|
function spawnHeadless(cwd: string, options?: SessionOptions): SpawnResult {
|
|
@@ -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
|
+
}
|
|
@@ -26,9 +26,10 @@ export function registerSystemRoutes(
|
|
|
26
26
|
metaPersistence: MetaPersistence;
|
|
27
27
|
config: ServerConfig;
|
|
28
28
|
networkGuard: NetworkGuard;
|
|
29
|
+
version?: string;
|
|
29
30
|
},
|
|
30
31
|
) {
|
|
31
|
-
const { sessionManager, preferencesStore, metaPersistence, config, networkGuard } = deps;
|
|
32
|
+
const { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version } = deps;
|
|
32
33
|
const serverStartTime = Date.now();
|
|
33
34
|
|
|
34
35
|
// Editor detection endpoint
|
|
@@ -164,6 +165,7 @@ export function registerSystemRoutes(
|
|
|
164
165
|
return {
|
|
165
166
|
ok: true,
|
|
166
167
|
pid: process.pid,
|
|
168
|
+
version: version ?? "unknown",
|
|
167
169
|
uptime: Math.floor((Date.now() - serverStartTime) / 1000),
|
|
168
170
|
mode: config.dev ? "dev" : "production",
|
|
169
171
|
server: {
|