@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
|
@@ -31,7 +31,7 @@ import { scanAllSessions } from "./session-scanner.js";
|
|
|
31
31
|
import { needsMigration, runMigration } from "./migrate-persistence.js";
|
|
32
32
|
import { detectZrokBinary, cleanupStaleZrok, createTunnel, deleteTunnel } from "./tunnel.js";
|
|
33
33
|
import { registerAuthPlugin, validateWsUpgrade } from "./auth-plugin.js";
|
|
34
|
-
import {
|
|
34
|
+
import { findBundledExtension, registerBridgeExtension } from "@blackbelt-technology/pi-dashboard-shared/bridge-register.js";
|
|
35
35
|
import { createNetworkGuard, isLoopback, isBypassedHost } from "./localhost-guard.js";
|
|
36
36
|
import type { AuthConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
37
37
|
import { registerSessionApi } from "./session-api.js";
|
|
@@ -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
|
|
|
@@ -83,7 +84,9 @@ export interface DashboardServer {
|
|
|
83
84
|
export async function createServer(config: ServerConfig): Promise<DashboardServer> {
|
|
84
85
|
// Ensure bridge extension is registered in pi's global settings
|
|
85
86
|
// (needed for bundled installs where pi can't discover it from package.json)
|
|
86
|
-
|
|
87
|
+
const __serverDir = path.dirname(fileURLToPath(import.meta.url));
|
|
88
|
+
const extPath = findBundledExtension(path.resolve(__serverDir, "..", ".."));
|
|
89
|
+
if (extPath) registerBridgeExtension(extPath);
|
|
87
90
|
|
|
88
91
|
// Run migration from sessions.json + state.json if needed
|
|
89
92
|
if (needsMigration()) {
|
|
@@ -131,7 +134,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
131
134
|
cacheRead: session.cacheRead,
|
|
132
135
|
cacheWrite: session.cacheWrite,
|
|
133
136
|
cost: session.cost,
|
|
134
|
-
contextTokens: session.contextTokens,
|
|
137
|
+
contextTokens: session.contextTokens ?? undefined,
|
|
135
138
|
contextWindow: session.contextWindow,
|
|
136
139
|
firstMessage: session.firstMessage,
|
|
137
140
|
cachedAt: Date.now(),
|
|
@@ -292,7 +295,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
292
295
|
registerGitRoutes(fastify, { networkGuard });
|
|
293
296
|
registerFileRoutes(fastify, { sessionManager, preferencesStore, networkGuard });
|
|
294
297
|
registerOpenSpecRoutes(fastify, { sessionManager, preferencesStore, directoryService, networkGuard });
|
|
295
|
-
registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard });
|
|
298
|
+
registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version: pkgVersion });
|
|
296
299
|
// Package management
|
|
297
300
|
const packageManagerWrapper = new PackageManagerWrapper();
|
|
298
301
|
|
|
@@ -340,6 +343,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
340
343
|
registerEditorProxy(fastify, editorManager);
|
|
341
344
|
|
|
342
345
|
registerProviderAuthRoutes(fastify, { piGateway });
|
|
346
|
+
registerKnownServersRoutes(fastify, { networkGuard, getPeerServers: () => peerServers });
|
|
343
347
|
registerProviderRoutes(fastify, { networkGuard });
|
|
344
348
|
|
|
345
349
|
// Serve static files / SPA fallback
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the shared bridge-register module.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
|
|
9
|
+
// We test against a real temp filesystem
|
|
10
|
+
import { findBundledExtension, registerBridgeExtension } from "../bridge-register.js";
|
|
11
|
+
|
|
12
|
+
describe("shared bridge-register", () => {
|
|
13
|
+
let tmpDir: string;
|
|
14
|
+
let settingsPath: string;
|
|
15
|
+
let origHome: string | undefined;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "shared-bridge-test-"));
|
|
19
|
+
settingsPath = path.join(tmpDir, ".pi", "agent", "settings.json");
|
|
20
|
+
origHome = process.env.HOME;
|
|
21
|
+
process.env.HOME = tmpDir;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
process.env.HOME = origHome;
|
|
26
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function writeSettings(data: Record<string, unknown>) {
|
|
30
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
31
|
+
fs.writeFileSync(settingsPath, JSON.stringify(data, null, 2));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readSettings(): Record<string, unknown> {
|
|
35
|
+
return JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("findBundledExtension", () => {
|
|
39
|
+
it("finds extension in packages/extension/ under base dir", () => {
|
|
40
|
+
const baseDir = path.join(tmpDir, "server");
|
|
41
|
+
const extDir = path.join(baseDir, "packages", "extension");
|
|
42
|
+
fs.mkdirSync(extDir, { recursive: true });
|
|
43
|
+
fs.writeFileSync(path.join(extDir, "package.json"), '{"name":"test"}');
|
|
44
|
+
|
|
45
|
+
expect(findBundledExtension(baseDir)).toBe(extDir);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns null when extension dir does not exist", () => {
|
|
49
|
+
expect(findBundledExtension(tmpDir)).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns null when no package.json in extension dir", () => {
|
|
53
|
+
const extDir = path.join(tmpDir, "packages", "extension");
|
|
54
|
+
fs.mkdirSync(extDir, { recursive: true });
|
|
55
|
+
// No package.json
|
|
56
|
+
expect(findBundledExtension(tmpDir)).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns null for AppImage temp mount paths", () => {
|
|
60
|
+
// We can't easily create a /tmp/.mount_ path, but we can verify
|
|
61
|
+
// the function handles it via the string check
|
|
62
|
+
const mockBase = "/tmp/.mount_PI1234/resources/server";
|
|
63
|
+
// findBundledExtension will check existsSync which returns false for this
|
|
64
|
+
expect(findBundledExtension(mockBase)).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("registerBridgeExtension", () => {
|
|
69
|
+
it("registers extension path in empty settings", () => {
|
|
70
|
+
const extPath = "/app/packages/extension";
|
|
71
|
+
registerBridgeExtension(extPath);
|
|
72
|
+
|
|
73
|
+
const settings = readSettings();
|
|
74
|
+
expect(settings.packages).toContain(extPath);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("is idempotent — does not add duplicates", () => {
|
|
78
|
+
const extPath = "/app/packages/extension";
|
|
79
|
+
registerBridgeExtension(extPath);
|
|
80
|
+
registerBridgeExtension(extPath);
|
|
81
|
+
|
|
82
|
+
const settings = readSettings();
|
|
83
|
+
const count = (settings.packages as string[]).filter(p => p === extPath).length;
|
|
84
|
+
expect(count).toBe(1);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("preserves existing valid dashboard paths", () => {
|
|
88
|
+
// Create a valid extension dir
|
|
89
|
+
const existingExt = path.join(tmpDir, "dev", "pi-agent-dashboard", "ext");
|
|
90
|
+
fs.mkdirSync(existingExt, { recursive: true });
|
|
91
|
+
fs.writeFileSync(path.join(existingExt, "package.json"), "{}");
|
|
92
|
+
|
|
93
|
+
writeSettings({ packages: [existingExt] });
|
|
94
|
+
|
|
95
|
+
const newPath = "/app/new/extension";
|
|
96
|
+
registerBridgeExtension(newPath);
|
|
97
|
+
|
|
98
|
+
const settings = readSettings();
|
|
99
|
+
const packages = settings.packages as string[];
|
|
100
|
+
expect(packages).toContain(existingExt); // preserved
|
|
101
|
+
expect(packages).toContain(newPath); // added
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("removes stale (non-existent) dashboard paths", () => {
|
|
105
|
+
const stalePath = "/old/nonexistent/pi-dashboard/ext";
|
|
106
|
+
writeSettings({ packages: [stalePath] });
|
|
107
|
+
|
|
108
|
+
const newPath = "/app/new/extension";
|
|
109
|
+
registerBridgeExtension(newPath);
|
|
110
|
+
|
|
111
|
+
const settings = readSettings();
|
|
112
|
+
const packages = settings.packages as string[];
|
|
113
|
+
expect(packages).not.toContain(stalePath);
|
|
114
|
+
expect(packages).toContain(newPath);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("preserves non-dashboard paths", () => {
|
|
118
|
+
writeSettings({ packages: ["/some/other/extension"] });
|
|
119
|
+
|
|
120
|
+
registerBridgeExtension("/app/extension");
|
|
121
|
+
|
|
122
|
+
const settings = readSettings();
|
|
123
|
+
const packages = settings.packages as string[];
|
|
124
|
+
expect(packages).toContain("/some/other/extension");
|
|
125
|
+
expect(packages).toContain("/app/extension");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("handles missing settings.json gracefully", () => {
|
|
129
|
+
// No settings dir exists
|
|
130
|
+
registerBridgeExtension("/app/extension");
|
|
131
|
+
|
|
132
|
+
const settings = readSettings();
|
|
133
|
+
expect(settings.packages).toContain("/app/extension");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for ToolResolver.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
|
|
8
|
+
const { mockExecSync, mockExistsSync } = vi.hoisted(() => ({
|
|
9
|
+
mockExecSync: vi.fn(),
|
|
10
|
+
mockExistsSync: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("node:child_process", () => ({ execSync: mockExecSync }));
|
|
14
|
+
vi.mock("node:fs", () => ({ existsSync: mockExistsSync }));
|
|
15
|
+
|
|
16
|
+
import { ToolResolver } from "../tool-resolver.js";
|
|
17
|
+
|
|
18
|
+
const MANAGED_BIN = path.join(os.homedir(), ".pi-dashboard", "node_modules", ".bin");
|
|
19
|
+
|
|
20
|
+
describe("ToolResolver", () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.clearAllMocks();
|
|
23
|
+
mockExistsSync.mockReturnValue(false);
|
|
24
|
+
mockExecSync.mockImplementation(() => { throw new Error("not found"); });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("which()", () => {
|
|
28
|
+
it("finds binary in managed bin first", () => {
|
|
29
|
+
const managedPi = path.join(MANAGED_BIN, "pi");
|
|
30
|
+
mockExistsSync.mockImplementation((p: string) => p === managedPi);
|
|
31
|
+
|
|
32
|
+
const resolver = new ToolResolver();
|
|
33
|
+
expect(resolver.which("pi")).toBe(managedPi);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("finds binary in extra bin dirs before system PATH", () => {
|
|
37
|
+
const extraDir = "/custom/bin";
|
|
38
|
+
const extraPi = path.join(extraDir, "pi");
|
|
39
|
+
mockExistsSync.mockImplementation((p: string) => p === extraPi);
|
|
40
|
+
|
|
41
|
+
const resolver = new ToolResolver({ extraBinDirs: [extraDir] });
|
|
42
|
+
expect(resolver.which("pi")).toBe(extraPi);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("falls back to system PATH via which", () => {
|
|
46
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
47
|
+
if (typeof cmd === "string" && cmd.includes("which pi")) return "/usr/bin/pi\n";
|
|
48
|
+
throw new Error("not found");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const resolver = new ToolResolver();
|
|
52
|
+
expect(resolver.which("pi")).toBe("/usr/bin/pi");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("tries login shell when enabled and PATH fails", () => {
|
|
56
|
+
// Regular which fails
|
|
57
|
+
mockExecSync.mockImplementation((cmd: string) => {
|
|
58
|
+
if (typeof cmd === "string" && cmd.includes("-ilc")) return "/nvm/bin/pi\n";
|
|
59
|
+
throw new Error("not found");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const resolver = new ToolResolver({ useLoginShell: true });
|
|
63
|
+
// On win32 login shell is skipped — test on non-win32 only
|
|
64
|
+
if (process.platform !== "win32") {
|
|
65
|
+
expect(resolver.which("pi")).toBe("/nvm/bin/pi");
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns null when binary not found anywhere", () => {
|
|
70
|
+
const resolver = new ToolResolver();
|
|
71
|
+
expect(resolver.which("nonexistent")).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("resolvePi()", () => {
|
|
76
|
+
it("returns [path] for Unix managed pi", () => {
|
|
77
|
+
const managedPi = path.join(MANAGED_BIN, "pi");
|
|
78
|
+
mockExistsSync.mockImplementation((p: string) => p === managedPi);
|
|
79
|
+
|
|
80
|
+
const resolver = new ToolResolver();
|
|
81
|
+
if (process.platform !== "win32") {
|
|
82
|
+
expect(resolver.resolvePi()).toEqual([managedPi]);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns null when pi not found", () => {
|
|
87
|
+
const resolver = new ToolResolver();
|
|
88
|
+
expect(resolver.resolvePi()).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("resolveTsx()", () => {
|
|
93
|
+
it("returns [path] for managed tsx", () => {
|
|
94
|
+
const managedTsx = path.join(MANAGED_BIN, "tsx");
|
|
95
|
+
mockExistsSync.mockImplementation((p: string) => p === managedTsx);
|
|
96
|
+
|
|
97
|
+
const resolver = new ToolResolver();
|
|
98
|
+
if (process.platform !== "win32") {
|
|
99
|
+
expect(resolver.resolveTsx()).toEqual([managedTsx]);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns null when tsx not found", () => {
|
|
104
|
+
const resolver = new ToolResolver();
|
|
105
|
+
expect(resolver.resolveTsx()).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("resolveNode()", () => {
|
|
110
|
+
it("returns processExecPath when provided", () => {
|
|
111
|
+
const resolver = new ToolResolver({ processExecPath: "/usr/bin/node" });
|
|
112
|
+
expect(resolver.resolveNode()).toBe("/usr/bin/node");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("finds node in extra bin dirs", () => {
|
|
116
|
+
const extraDir = "/bundled/bin";
|
|
117
|
+
const nodeName = process.platform === "win32" ? "node.exe" : "node";
|
|
118
|
+
const nodePath = path.join(extraDir, nodeName);
|
|
119
|
+
mockExistsSync.mockImplementation((p: string) => p === nodePath);
|
|
120
|
+
|
|
121
|
+
const resolver = new ToolResolver({ extraBinDirs: [extraDir] });
|
|
122
|
+
expect(resolver.resolveNode()).toBe(nodePath);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("falls back to which(node) when no context paths", () => {
|
|
126
|
+
const managedNode = path.join(MANAGED_BIN, "node");
|
|
127
|
+
mockExistsSync.mockImplementation((p: string) => p === managedNode);
|
|
128
|
+
|
|
129
|
+
const resolver = new ToolResolver();
|
|
130
|
+
expect(resolver.resolveNode()).toBe(managedNode);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("buildSpawnEnv()", () => {
|
|
135
|
+
it("prepends managed bin to PATH", () => {
|
|
136
|
+
const resolver = new ToolResolver();
|
|
137
|
+
const env = resolver.buildSpawnEnv({ PATH: "/usr/bin" });
|
|
138
|
+
expect(env.PATH).toContain(MANAGED_BIN);
|
|
139
|
+
expect(env.PATH).toContain("/usr/bin");
|
|
140
|
+
// Managed bin should come before /usr/bin
|
|
141
|
+
expect(env.PATH!.indexOf(MANAGED_BIN)).toBeLessThan(env.PATH!.indexOf("/usr/bin"));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("does not duplicate managed bin if already present", () => {
|
|
145
|
+
const resolver = new ToolResolver();
|
|
146
|
+
const env = resolver.buildSpawnEnv({ PATH: `${MANAGED_BIN}:/usr/bin` });
|
|
147
|
+
const count = env.PATH!.split(path.delimiter).filter(p => p === MANAGED_BIN).length;
|
|
148
|
+
expect(count).toBe(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("includes processExecPath dir", () => {
|
|
152
|
+
const resolver = new ToolResolver({ processExecPath: "/custom/node/bin/node" });
|
|
153
|
+
const env = resolver.buildSpawnEnv({ PATH: "/usr/bin" });
|
|
154
|
+
expect(env.PATH).toContain("/custom/node/bin");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("includes extra bin dirs", () => {
|
|
158
|
+
const resolver = new ToolResolver({ extraBinDirs: ["/extra/one", "/extra/two"] });
|
|
159
|
+
const env = resolver.buildSpawnEnv({ PATH: "/usr/bin" });
|
|
160
|
+
expect(env.PATH).toContain("/extra/one");
|
|
161
|
+
expect(env.PATH).toContain("/extra/two");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared bridge extension registration for pi's settings.json.
|
|
3
|
+
* Used by both the server and Electron app to register the dashboard
|
|
4
|
+
* bridge extension so pi sessions can discover and load it.
|
|
5
|
+
*
|
|
6
|
+
* Single source of truth — replaces the near-identical implementations
|
|
7
|
+
* in packages/server/src/extension-register.ts and
|
|
8
|
+
* packages/electron/src/lib/bridge-register.ts.
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Find the bundled extension directory relative to a base directory.
|
|
16
|
+
* Looks for `packages/extension/` (monorepo layout) under baseDir.
|
|
17
|
+
*
|
|
18
|
+
* Returns null if:
|
|
19
|
+
* - Directory not found
|
|
20
|
+
* - No package.json in the directory
|
|
21
|
+
* - Path is under /tmp/.mount_* (unstable AppImage mount)
|
|
22
|
+
*/
|
|
23
|
+
export function findBundledExtension(baseDir: string): string | null {
|
|
24
|
+
const candidate = path.resolve(baseDir, "packages", "extension");
|
|
25
|
+
if (!fs.existsSync(candidate) || !fs.existsSync(path.join(candidate, "package.json"))) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Reject unstable AppImage temp mount paths
|
|
30
|
+
if (candidate.includes("/tmp/.mount_")) {
|
|
31
|
+
console.warn("[dashboard] AppImage detected — extension path is temporary, skipping registration:", candidate);
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return candidate;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Register an extension path in pi's settings.json packages array.
|
|
40
|
+
*
|
|
41
|
+
* Non-destructive cleanup: only removes dashboard-related paths
|
|
42
|
+
* that point to non-existent directories or directories without package.json.
|
|
43
|
+
* Existing valid registrations (dev, global, other bundled) are preserved.
|
|
44
|
+
*
|
|
45
|
+
* No-op if the path is already registered.
|
|
46
|
+
*/
|
|
47
|
+
export function registerBridgeExtension(extensionPath: string): void {
|
|
48
|
+
// Compute at call time so tests can override HOME
|
|
49
|
+
const settingsPath = path.join(
|
|
50
|
+
process.env.HOME || process.env.USERPROFILE || os.homedir(),
|
|
51
|
+
".pi", "agent", "settings.json",
|
|
52
|
+
);
|
|
53
|
+
const settingsDir = path.dirname(settingsPath);
|
|
54
|
+
fs.mkdirSync(settingsDir, { recursive: true });
|
|
55
|
+
|
|
56
|
+
let settings: Record<string, unknown> = {};
|
|
57
|
+
try {
|
|
58
|
+
if (fs.existsSync(settingsPath)) {
|
|
59
|
+
const raw = fs.readFileSync(settingsPath, "utf-8").trim();
|
|
60
|
+
if (raw) settings = JSON.parse(raw);
|
|
61
|
+
}
|
|
62
|
+
} catch { /* start fresh */ }
|
|
63
|
+
|
|
64
|
+
const packages = Array.isArray(settings.packages) ? settings.packages as string[] : [];
|
|
65
|
+
|
|
66
|
+
// Already registered?
|
|
67
|
+
if (packages.includes(extensionPath)) return;
|
|
68
|
+
|
|
69
|
+
// Non-destructive cleanup: only remove broken dashboard paths
|
|
70
|
+
const cleaned = packages.filter((p) => {
|
|
71
|
+
if (typeof p !== "string") return true;
|
|
72
|
+
const isLocalPath = p.startsWith("/") || /^[a-zA-Z]:[/\\]/.test(p);
|
|
73
|
+
if (!isLocalPath) return true;
|
|
74
|
+
// Only consider dashboard-related paths for cleanup
|
|
75
|
+
if (!p.includes("pi-dashboard") && !p.includes("pi-agent-dashboard")) return true;
|
|
76
|
+
// Keep paths that point to existing directories with a package.json
|
|
77
|
+
try {
|
|
78
|
+
return fs.existsSync(p) && fs.existsSync(path.join(p, "package.json"));
|
|
79
|
+
} catch {
|
|
80
|
+
return false; // Can't check — treat as stale
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
cleaned.push(extensionPath);
|
|
85
|
+
settings.packages = cleaned;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const tmp = settingsPath + ".tmp";
|
|
89
|
+
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
|
|
90
|
+
fs.renameSync(tmp, settingsPath);
|
|
91
|
+
console.log(`[dashboard] Registered bridge extension in pi settings: ${extensionPath}`);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.error("[dashboard] Failed to register bridge extension:", err);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -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
|
|
|
@@ -462,6 +498,15 @@ export interface ArchitectPromptResponseBrowserMessage {
|
|
|
462
498
|
cancelled?: boolean;
|
|
463
499
|
}
|
|
464
500
|
|
|
501
|
+
export interface PromptResponseBrowserMessage {
|
|
502
|
+
type: "prompt_response";
|
|
503
|
+
sessionId: string;
|
|
504
|
+
promptId: string;
|
|
505
|
+
answer?: string;
|
|
506
|
+
cancelled?: boolean;
|
|
507
|
+
source: string;
|
|
508
|
+
}
|
|
509
|
+
|
|
465
510
|
export interface RoleSetBrowserMessage {
|
|
466
511
|
type: "role_set";
|
|
467
512
|
sessionId: string;
|
|
@@ -526,6 +571,7 @@ export type BrowserToServerMessage =
|
|
|
526
571
|
| ForceKillBrowserMessage
|
|
527
572
|
| FlowManagementBrowserMessage
|
|
528
573
|
| ArchitectPromptResponseBrowserMessage
|
|
574
|
+
| PromptResponseBrowserMessage
|
|
529
575
|
| RoleSetBrowserMessage
|
|
530
576
|
| RolePresetLoadBrowserMessage
|
|
531
577
|
| RolePresetSaveBrowserMessage
|
|
@@ -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
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared constants for the managed install directory (~/.pi-dashboard/).
|
|
3
|
+
* Single source of truth — all packages import from here.
|
|
4
|
+
*/
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
|
|
8
|
+
/** Root directory for managed installs (pi, openspec, tsx). */
|
|
9
|
+
export const MANAGED_DIR = path.join(os.homedir(), ".pi-dashboard");
|
|
10
|
+
|
|
11
|
+
/** Bin directory for managed install executables. */
|
|
12
|
+
export const MANAGED_BIN = path.join(MANAGED_DIR, "node_modules", ".bin");
|
|
13
|
+
|
|
14
|
+
/** Path to pi's global settings file. */
|
|
15
|
+
export const PI_SETTINGS_PATH = path.join(os.homedir(), ".pi", "agent", "settings.json");
|
|
@@ -64,7 +64,7 @@ export function advertiseDashboard(port: number, piPort: number): void {
|
|
|
64
64
|
*/
|
|
65
65
|
export function stopAdvertising(): void {
|
|
66
66
|
if (publishedService) {
|
|
67
|
-
publishedService.stop(() => {});
|
|
67
|
+
publishedService.stop?.(() => {});
|
|
68
68
|
publishedService = null;
|
|
69
69
|
}
|
|
70
70
|
if (bonjourInstance) {
|