@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
@@ -1,269 +0,0 @@
1
- /**
2
- * UI Proxy for the dashboard bridge extension.
3
- *
4
- * Wraps ctx.ui dialog methods (confirm, select, input, editor) to forward
5
- * them to the dashboard server. For TUI sessions, races the original method
6
- * against the dashboard response. For headless sessions, only the dashboard
7
- * can respond.
8
- *
9
- * Fire-and-forget methods (notify) are forwarded alongside the original call.
10
- */
11
-
12
- import type { ExtensionUiResponseMessage } from "@blackbelt-technology/pi-dashboard-shared/protocol.js";
13
-
14
- export interface UiProxyOptions {
15
- /** The original ctx.ui object to wrap */
16
- ui: {
17
- confirm(title: string, message: string, opts?: any): Promise<boolean>;
18
- select(title: string, options: string[], opts?: any): Promise<string | undefined>;
19
- input(title: string, placeholder?: string, opts?: any): Promise<string | undefined>;
20
- editor?(title: string, prefill?: string, opts?: any): Promise<string | undefined>;
21
- notify(message: string, type?: string): void;
22
- };
23
- /** Whether TUI is available (race mode vs dashboard-only) */
24
- hasUI: boolean;
25
- /** Get current session ID */
26
- getSessionId: () => string;
27
- /** Send a message to the dashboard server */
28
- send: (msg: any) => void;
29
- }
30
-
31
- interface PendingRequest {
32
- method: string;
33
- params: Record<string, unknown>;
34
- resolve: (value: any) => void;
35
- }
36
-
37
- export function createUiProxy(options: UiProxyOptions) {
38
- const { ui, hasUI, getSessionId, send } = options;
39
- const pending = new Map<string, PendingRequest>();
40
-
41
- // Capture original method references BEFORE ctx.ui is patched in-place.
42
- // Without this, the proxy's call to ui.notify() would recurse into itself
43
- // because bridge.ts overwrites ctx.ui.notify with the proxy's own method.
44
- const originalConfirm = ui.confirm.bind(ui);
45
- const originalSelect = ui.select.bind(ui);
46
- const originalInput = ui.input.bind(ui);
47
- const originalEditor = ui.editor?.bind(ui);
48
- const originalNotify = ui.notify.bind(ui);
49
-
50
- function generateRequestId(): string {
51
- return crypto.randomUUID();
52
- }
53
-
54
- function sendRequest(method: string, params: Record<string, unknown>): string {
55
- const requestId = generateRequestId();
56
- send({
57
- type: "extension_ui_request",
58
- sessionId: getSessionId(),
59
- requestId,
60
- method,
61
- params,
62
- });
63
- return requestId;
64
- }
65
-
66
- function createDashboardPromise<T>(requestId: string, method: string, params: Record<string, unknown>): Promise<T> {
67
- return new Promise<T>((resolve) => {
68
- pending.set(requestId, { method, params, resolve });
69
- });
70
- }
71
-
72
- /** Re-send all pending UI requests (e.g. after server reconnect) */
73
- function resendPending(): void {
74
- for (const [requestId, entry] of pending) {
75
- send({
76
- type: "extension_ui_request",
77
- sessionId: getSessionId(),
78
- requestId,
79
- method: entry.method,
80
- params: entry.params,
81
- });
82
- }
83
- }
84
-
85
- /** Extract the result for a specific dialog method from the response */
86
- function extractResult(method: string, response: ExtensionUiResponseMessage): any {
87
- if (response.cancelled) {
88
- switch (method) {
89
- case "confirm":
90
- return false;
91
- case "multiselect":
92
- return [];
93
- default:
94
- return undefined;
95
- }
96
- }
97
-
98
- const result = response.result as Record<string, unknown> | undefined;
99
- switch (method) {
100
- case "confirm":
101
- return result?.confirmed ?? false;
102
- case "select":
103
- case "input":
104
- case "editor":
105
- return result?.value;
106
- case "multiselect":
107
- return (result?.values as string[]) ?? [];
108
- default:
109
- return result;
110
- }
111
- }
112
-
113
- // Recursion guard: if ui.confirm/select/etc is actually our own proxy
114
- // (e.g. ctx.ui was already patched from a previous /reload), skip the
115
- // TUI race to avoid infinite recursion.
116
- let inProxy = false;
117
-
118
- /** Send a dismiss message to the server so dashboard can close the stale dialog */
119
- function sendDismiss(requestId: string): void {
120
- send({
121
- type: "extension_ui_dismiss",
122
- sessionId: getSessionId(),
123
- requestId,
124
- });
125
- }
126
-
127
- /**
128
- * Race TUI promise against dashboard promise with proper cancellation.
129
- * When TUI wins: clean up pending Map entry + send dismiss to server.
130
- * When dashboard wins: abort TUI dialog via AbortController.
131
- */
132
- function raceWithCancellation<T>(requestId: string, tuiPromise: Promise<T>, dashPromise: Promise<T>, ac: AbortController): Promise<T> {
133
- // Wire up cross-cancellation before racing
134
- tuiPromise.then(() => {
135
- // TUI won — clean up dashboard side
136
- pending.delete(requestId);
137
- sendDismiss(requestId);
138
- }).catch(() => {});
139
- dashPromise.then(() => {
140
- // Dashboard won — abort TUI dialog
141
- ac.abort();
142
- }).catch(() => {});
143
- return Promise.race([tuiPromise, dashPromise]);
144
- }
145
-
146
- const wrappedUi = {
147
- confirm: (title: string, message: string, opts?: any): Promise<boolean> => {
148
- const params = { title, message };
149
- const requestId = sendRequest("confirm", params);
150
- const dashPromise = createDashboardPromise<boolean>(requestId, "confirm", params);
151
-
152
- if (hasUI && !inProxy) {
153
- const ac = new AbortController();
154
- inProxy = true;
155
- const originalPromise = originalConfirm(title, message, { ...opts, signal: ac.signal }).finally(() => { inProxy = false; });
156
- return raceWithCancellation(requestId, originalPromise, dashPromise, ac);
157
- }
158
- return dashPromise;
159
- },
160
-
161
- select: (title: string, selectOptions: string[], opts?: any): Promise<string | undefined> => {
162
- const message = opts?.message as string | undefined;
163
- const params = { title, options: selectOptions, ...(message ? { message } : {}) };
164
- const requestId = sendRequest("select", params);
165
- const dashPromise = createDashboardPromise<string | undefined>(requestId, "select", params);
166
-
167
- if (hasUI && !inProxy) {
168
- const ac = new AbortController();
169
- inProxy = true;
170
- const tuiTitle = message ? `${title}\n\n${message}` : title;
171
- const originalPromise = originalSelect(tuiTitle, selectOptions, { ...opts, signal: ac.signal }).finally(() => { inProxy = false; });
172
- return raceWithCancellation(requestId, originalPromise, dashPromise, ac);
173
- }
174
- return dashPromise;
175
- },
176
-
177
- input: (title: string, placeholder?: string, opts?: any): Promise<string | undefined> => {
178
- const message = opts?.message as string | undefined;
179
- const params = { title, placeholder, ...(message ? { message } : {}) };
180
- const requestId = sendRequest("input", params);
181
- const dashPromise = createDashboardPromise<string | undefined>(requestId, "input", params);
182
-
183
- if (hasUI && !inProxy) {
184
- const ac = new AbortController();
185
- inProxy = true;
186
- const tuiTitle = message ? `${title}\n\n${message}` : title;
187
- const originalPromise = originalInput(tuiTitle, placeholder, { ...opts, signal: ac.signal }).finally(() => { inProxy = false; });
188
- return raceWithCancellation(requestId, originalPromise, dashPromise, ac);
189
- }
190
- return dashPromise;
191
- },
192
-
193
- editor: (title: string, prefill?: string, opts?: any): Promise<string | undefined> => {
194
- const params = { title, prefill };
195
- const requestId = sendRequest("editor", params);
196
- const dashPromise = createDashboardPromise<string | undefined>(requestId, "editor", params);
197
-
198
- if (hasUI && !inProxy && originalEditor) {
199
- const ac = new AbortController();
200
- inProxy = true;
201
- const originalPromise = originalEditor(title, prefill, { ...opts, signal: ac.signal }).finally(() => { inProxy = false; });
202
- return raceWithCancellation(requestId, originalPromise, dashPromise, ac);
203
- }
204
- return dashPromise;
205
- },
206
-
207
- multiselect: (title: string, selectOptions: string[], opts?: any): Promise<string[]> => {
208
- const message = opts?.message as string | undefined;
209
- const params = { title, options: selectOptions, ...(message ? { message } : {}) };
210
- const requestId = sendRequest("multiselect", params);
211
- const dashPromise = createDashboardPromise<string[]>(requestId, "multiselect", params);
212
-
213
- if (hasUI && !inProxy) {
214
- const ac = new AbortController();
215
- inProxy = true;
216
- const numbered = selectOptions.map((o, i) => `${i + 1}. ${o}`).join("\n");
217
- const tuiBase = message ? `${title}\n\n${message}` : title;
218
- const tuiPromise = originalInput(`${tuiBase}\n${numbered}`, "e.g. 1,3", { signal: ac.signal }).then((raw) => {
219
- if (!raw) return [] as string[];
220
- return raw
221
- .split(",")
222
- .map((s) => parseInt(s.trim(), 10))
223
- .filter((n) => !isNaN(n) && n >= 1 && n <= selectOptions.length)
224
- .map((n) => selectOptions[n - 1]);
225
- }).finally(() => { inProxy = false; });
226
- return raceWithCancellation(requestId, tuiPromise, dashPromise, ac);
227
- }
228
- return dashPromise;
229
- },
230
-
231
- notify: (message: string, type?: string): void => {
232
- originalNotify(message, type);
233
- sendRequest("notify", { message, level: type });
234
- },
235
- };
236
-
237
- function handleResponse(response: ExtensionUiResponseMessage): void {
238
- const entry = pending.get(response.requestId);
239
- if (!entry) return;
240
-
241
- pending.delete(response.requestId);
242
- entry.resolve(extractResult(entry.method, response));
243
- }
244
-
245
- /**
246
- * Cancel all pending UI requests. Resolves each pending promise with a
247
- * "cancelled" result so the TUI dialogs are dismissed.
248
- *
249
- * Used when an external channel (e.g. architect_prompt_response) answers a
250
- * question that was also forwarded through the ui-proxy. Without this, the
251
- * TUI dialog would stay open forever because the proxy’s dashPromise never
252
- * resolves.
253
- */
254
- function cancelAllPending(): void {
255
- for (const [requestId, entry] of pending) {
256
- const cancelled: ExtensionUiResponseMessage = {
257
- type: "extension_ui_response",
258
- sessionId: getSessionId(),
259
- requestId,
260
- cancelled: true,
261
- };
262
- entry.resolve(extractResult(entry.method, cancelled));
263
- sendDismiss(requestId);
264
- }
265
- pending.clear();
266
- }
267
-
268
- return { wrappedUi, handleResponse, resendPending, cancelAllPending };
269
- }
@@ -1,92 +0,0 @@
1
- /**
2
- * Ensures the pi-dashboard bridge extension is registered in pi's global settings
3
- * so all pi sessions (headless or interactive) can discover and load it.
4
- *
5
- * On bundled installs (Electron DEB/DMG), the extension lives inside the server
6
- * bundle at packages/extension/. This module detects the bundled extension path
7
- * and adds it to ~/.pi/agent/settings.json if not already present.
8
- */
9
- import fs from "node:fs";
10
- import path from "node:path";
11
- import { fileURLToPath } from "node:url";
12
-
13
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
-
15
- /** Locate the bundled extension package directory, if it exists. */
16
- function findBundledExtension(): string | null {
17
- // From packages/server/src/ → ../extension/
18
- const candidate = path.resolve(__dirname, "..", "..", "extension");
19
- if (
20
- fs.existsSync(candidate) &&
21
- fs.existsSync(path.join(candidate, "package.json"))
22
- ) {
23
- return candidate;
24
- }
25
- return null;
26
- }
27
-
28
- /** Read ~/.pi/agent/settings.json (returns {} if missing/invalid). */
29
- function readSettings(settingsPath: string): Record<string, unknown> {
30
- try {
31
- if (!fs.existsSync(settingsPath)) return {};
32
- const raw = fs.readFileSync(settingsPath, "utf-8").trim();
33
- if (!raw) return {};
34
- return JSON.parse(raw);
35
- } catch {
36
- return {};
37
- }
38
- }
39
-
40
- /** Write settings back to disk atomically. */
41
- function writeSettings(settingsPath: string, data: Record<string, unknown>): void {
42
- const dir = path.dirname(settingsPath);
43
- fs.mkdirSync(dir, { recursive: true });
44
- const tmp = settingsPath + ".tmp";
45
- fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
46
- fs.renameSync(tmp, settingsPath);
47
- }
48
-
49
- /**
50
- * Ensure the bridge extension is registered in pi's global settings.
51
- * No-op if:
52
- * - No bundled extension found (development mode uses package.json pi field)
53
- * - Extension path already present in settings
54
- */
55
- export function ensureBridgeExtensionRegistered(): void {
56
- const extPath = findBundledExtension();
57
- if (!extPath) return; // Not bundled — development mode
58
-
59
- const settingsPath = path.join(
60
- process.env.HOME || process.env.USERPROFILE || "",
61
- ".pi",
62
- "agent",
63
- "settings.json",
64
- );
65
-
66
- const settings = readSettings(settingsPath);
67
- const packages = Array.isArray(settings.packages) ? settings.packages as string[] : [];
68
-
69
- // Check if already registered (exact path match)
70
- if (packages.includes(extPath)) return;
71
-
72
- // Remove any stale dashboard extension paths (different install location)
73
- const cleaned = packages.filter((p) => {
74
- if (typeof p !== "string") return true;
75
- // Keep non-local-path entries (npm:, git:, etc.)
76
- // Local paths start with / (Unix) or X:\ (Windows)
77
- const isLocalPath = p.startsWith("/") || /^[a-zA-Z]:[/\\]/.test(p);
78
- if (!isLocalPath) return true;
79
- // Remove stale dashboard extension paths
80
- return !p.includes("pi-dashboard");
81
- });
82
-
83
- cleaned.push(extPath);
84
- settings.packages = cleaned;
85
-
86
- try {
87
- writeSettings(settingsPath, settings);
88
- console.log(`[dashboard] Registered bridge extension in pi settings: ${extPath}`);
89
- } catch (err) {
90
- console.error("[dashboard] Failed to register bridge extension:", err);
91
- }
92
- }