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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/AGENTS.md +12 -6
  2. package/LICENSE +21 -0
  3. package/README.md +2 -2
  4. package/docs/architecture.md +79 -26
  5. package/package.json +4 -2
  6. package/packages/extension/package.json +1 -1
  7. package/packages/extension/src/__tests__/ask-user-tool.test.ts +50 -0
  8. package/packages/extension/src/__tests__/dashboard-default-adapter.test.ts +77 -0
  9. package/packages/extension/src/__tests__/dev-build.test.ts +2 -2
  10. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +18 -18
  11. package/packages/extension/src/__tests__/prompt-bus-wiring.test.ts +791 -0
  12. package/packages/extension/src/__tests__/prompt-bus.test.ts +469 -0
  13. package/packages/extension/src/__tests__/server-launcher.test.ts +35 -34
  14. package/packages/extension/src/__tests__/tui-prompt-adapter.test.ts +207 -0
  15. package/packages/extension/src/ask-user-tool.ts +26 -6
  16. package/packages/extension/src/bridge-context.ts +1 -1
  17. package/packages/extension/src/bridge.ts +214 -59
  18. package/packages/extension/src/command-handler.ts +2 -2
  19. package/packages/extension/src/dashboard-default-adapter.ts +37 -0
  20. package/packages/extension/src/flow-event-wiring.ts +6 -23
  21. package/packages/extension/src/pi-env.d.ts +13 -0
  22. package/packages/extension/src/prompt-bus.ts +240 -0
  23. package/packages/extension/src/server-launcher.ts +2 -2
  24. package/packages/extension/src/session-sync.ts +2 -1
  25. package/packages/server/package.json +1 -1
  26. package/packages/server/src/__tests__/bridge-register-nondestructive.test.ts +108 -0
  27. package/packages/server/src/__tests__/extension-register-appimage.test.ts +39 -0
  28. package/packages/server/src/__tests__/extension-register.test.ts +26 -22
  29. package/packages/server/src/__tests__/known-servers-routes.test.ts +129 -0
  30. package/packages/server/src/__tests__/process-manager.test.ts +4 -1
  31. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +5 -5
  32. package/packages/server/src/__tests__/tunnel.test.ts +2 -2
  33. package/packages/server/src/browser-gateway.ts +55 -16
  34. package/packages/server/src/cli.ts +1 -1
  35. package/packages/server/src/editor-manager.ts +1 -1
  36. package/packages/server/src/event-status-extraction.ts +7 -0
  37. package/packages/server/src/event-wiring.ts +20 -22
  38. package/packages/server/src/package-manager-wrapper.ts +1 -1
  39. package/packages/server/src/process-manager.ts +8 -69
  40. package/packages/server/src/routes/known-servers-routes.ts +110 -0
  41. package/packages/server/src/routes/system-routes.ts +3 -1
  42. package/packages/server/src/server.ts +8 -4
  43. package/packages/shared/package.json +1 -1
  44. package/packages/shared/src/__tests__/bridge-register.test.ts +136 -0
  45. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +62 -0
  46. package/packages/shared/src/__tests__/tool-resolver.test.ts +164 -0
  47. package/packages/shared/src/bridge-register.ts +95 -0
  48. package/packages/shared/src/browser-protocol.ts +47 -1
  49. package/packages/shared/src/config.ts +23 -0
  50. package/packages/shared/src/managed-paths.ts +15 -0
  51. package/packages/shared/src/mdns-discovery.ts +1 -1
  52. package/packages/shared/src/openspec-activity-detector.ts +8 -6
  53. package/packages/shared/src/protocol.ts +46 -0
  54. package/packages/shared/src/rest-api.ts +28 -0
  55. package/packages/shared/src/tool-resolver.ts +201 -0
  56. package/packages/shared/src/types.ts +24 -0
  57. package/packages/extension/src/__tests__/ui-proxy.test.ts +0 -583
  58. package/packages/extension/src/ui-proxy.ts +0 -269
  59. package/packages/server/src/extension-register.ts +0 -92
@@ -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 { ensureBridgeExtensionRegistered } from "./extension-register.js";
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
- ensureBridgeExtensionRegistered();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-shared",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Shared types and utilities for pi-dashboard",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,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) {