@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
@@ -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
- expect(env.PATH).toBe(`${managedBin}:/usr/bin`);
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 (!sessionPending) return;
99
- for (const req of sessionPending.values()) {
100
- sendTo(ws, {
101
- type: "extension_ui_request",
102
- sessionId,
103
- requestId: req.requestId,
104
- method: req.method,
105
- params: req.params,
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
- ctx.piGateway.sendToSession(msg.sessionId, {
337
- type: "architect_prompt_response",
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 (phase is optional —
139
- // skills loaded via prompt templates don't emit a SKILL.md read event)
138
+ // Auto-attach proposal when changeName is detected via active operations
139
+ // (write/CLI). Reads are passive (browsing/analysis) and don't trigger attach.
140
+ // Phase is optional — skills loaded via prompt templates don't emit a SKILL.md read event.
140
141
  const attachUpdates: Partial<DashboardSession> = {};
141
- if (updatedSession?.openspecChange && !updatedSession.attachedProposal) {
142
+ if (updatedSession?.openspecChange && !updatedSession.attachedProposal && detected.isActive) {
142
143
  attachUpdates.attachedProposal = updatedSession.openspecChange;
143
144
  if (!updatedSession.name?.trim()) {
144
145
  attachUpdates.name = updatedSession.openspecChange;
@@ -504,25 +505,22 @@ export function wireEvents(deps: EventWiringDeps): void {
504
505
  browserGateway.broadcastSessionUpdated(sessionId, modelUpdates);
505
506
  }
506
507
 
507
- if (msg.type === "extension_ui_request") {
508
- const tracked = browserGateway.trackUiRequest(sessionId, msg.requestId, msg.method, msg.params);
509
- if (tracked !== false) {
510
- browserGateway.sendToSubscribers(sessionId, {
511
- type: "extension_ui_request",
512
- sessionId,
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 === "extension_ui_dismiss") {
521
- browserGateway.sendToSubscribers(sessionId, {
522
- type: "ui_dismiss",
523
- sessionId,
524
- requestId: msg.requestId,
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
- /** Path to managed install bin directory */
11
- const MANAGED_BIN = path.join(os.homedir(), ".pi-dashboard", "node_modules", ".bin");
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
- * Ensures `pi`, `node`, and user-installed tools (code-server) are findable.
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
- const currentPath = baseEnv.PATH || "";
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
- * On Unix: returns ["path/to/pi"]
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
- if (process.platform === "win32") {
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: {