@blackbelt-technology/pi-agent-dashboard 0.2.1 → 0.2.2
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 +3 -1
- package/docs/architecture.md +30 -23
- package/package.json +1 -1
- package/packages/extension/package.json +1 -1
- 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__/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 +1 -1
- 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__/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 +16 -19
- package/packages/server/src/package-manager-wrapper.ts +1 -1
- package/packages/server/src/process-manager.ts +8 -69
- package/packages/server/src/routes/system-routes.ts +3 -1
- package/packages/server/src/server.ts +6 -4
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/bridge-register.test.ts +136 -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 +10 -0
- package/packages/shared/src/managed-paths.ts +15 -0
- package/packages/shared/src/mdns-discovery.ts +1 -1
- package/packages/shared/src/protocol.ts +46 -0
- package/packages/shared/src/tool-resolver.ts +201 -0
- package/packages/shared/src/types.ts +24 -0
- package/packages/extension/src/__tests__/ui-proxy.test.ts +0 -583
- package/packages/extension/src/ui-proxy.ts +0 -269
- package/packages/server/src/extension-register.ts +0 -92
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the bridge's built-in TUI adapter behavior.
|
|
3
|
+
* Tests the adapter contract using the same mock pattern as the wiring tests.
|
|
4
|
+
* The TUI adapter is defined inline in bridge.ts session_start.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
8
|
+
import { PromptBus, type PromptAdapter, type PromptRequest, type PromptResponse, type PromptClaim } from "../prompt-bus.js";
|
|
9
|
+
|
|
10
|
+
// Minimal TUI adapter reimplementation for testing the contract
|
|
11
|
+
// (mirrors the inline adapter in bridge.ts session_start)
|
|
12
|
+
function createTestTuiAdapter(mockUi: any, bus: PromptBus): PromptAdapter {
|
|
13
|
+
const controllers = new Map<string, AbortController>();
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
name: "tui",
|
|
17
|
+
onRequest(prompt: PromptRequest): PromptClaim | null {
|
|
18
|
+
const ac = new AbortController();
|
|
19
|
+
controllers.set(prompt.id, ac);
|
|
20
|
+
|
|
21
|
+
const present = async () => {
|
|
22
|
+
try {
|
|
23
|
+
let answer: any;
|
|
24
|
+
if (prompt.type === "select") {
|
|
25
|
+
answer = await mockUi.select(prompt.question, prompt.options, { signal: ac.signal });
|
|
26
|
+
} else if (prompt.type === "input") {
|
|
27
|
+
answer = await mockUi.input(prompt.question, prompt.defaultValue || "", { signal: ac.signal });
|
|
28
|
+
} else if (prompt.type === "confirm") {
|
|
29
|
+
answer = await mockUi.confirm(prompt.question, "", { signal: ac.signal });
|
|
30
|
+
}
|
|
31
|
+
if (!ac.signal.aborted) {
|
|
32
|
+
const str = typeof answer === "boolean" ? (answer ? "true" : "false") : answer;
|
|
33
|
+
bus.respond({ id: prompt.id, answer: str ?? undefined, cancelled: str == null, source: "tui" });
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
if (!ac.signal.aborted) {
|
|
37
|
+
bus.respond({ id: prompt.id, cancelled: true, source: "tui" });
|
|
38
|
+
}
|
|
39
|
+
} finally {
|
|
40
|
+
controllers.delete(prompt.id);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
present();
|
|
44
|
+
return {};
|
|
45
|
+
},
|
|
46
|
+
onResponse(response: PromptResponse) {
|
|
47
|
+
if (response.source !== "tui") {
|
|
48
|
+
const ac = controllers.get(response.id);
|
|
49
|
+
if (ac) { ac.abort(); controllers.delete(response.id); }
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
onCancel(id: string) {
|
|
53
|
+
const ac = controllers.get(id);
|
|
54
|
+
if (ac) { ac.abort(); controllers.delete(id); }
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createMockUi() {
|
|
60
|
+
const signals: Record<string, AbortSignal | undefined> = {};
|
|
61
|
+
const resolvers: Record<string, { resolve: (v: any) => void }> = {};
|
|
62
|
+
|
|
63
|
+
function makeMock(name: string) {
|
|
64
|
+
return vi.fn().mockImplementation((_q: string, _a?: any, opts?: any) => {
|
|
65
|
+
signals[name] = opts?.signal;
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
resolvers[name] = { resolve };
|
|
68
|
+
opts?.signal?.addEventListener("abort", () => resolve(undefined), { once: true });
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
select: makeMock("select"),
|
|
75
|
+
input: makeMock("input"),
|
|
76
|
+
confirm: makeMock("confirm"),
|
|
77
|
+
_resolve: (method: string, value: any) => resolvers[method]?.resolve(value),
|
|
78
|
+
_signal: (method: string) => signals[method],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("Bridge TUI adapter", () => {
|
|
83
|
+
let bus: PromptBus;
|
|
84
|
+
let mockUi: ReturnType<typeof createMockUi>;
|
|
85
|
+
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
vi.useFakeTimers();
|
|
88
|
+
bus = new PromptBus({ timeoutMs: 5000 });
|
|
89
|
+
mockUi = createMockUi();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
vi.useRealTimers();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("calls original select with question and options", () => {
|
|
97
|
+
bus.registerAdapter(createTestTuiAdapter(mockUi, bus));
|
|
98
|
+
bus.request({ pipeline: "command", type: "select", question: "Pick:", options: ["A", "B"] });
|
|
99
|
+
|
|
100
|
+
expect(mockUi.select).toHaveBeenCalledWith("Pick:", ["A", "B"], expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("calls original input with question and defaultValue", () => {
|
|
104
|
+
bus.registerAdapter(createTestTuiAdapter(mockUi, bus));
|
|
105
|
+
bus.request({ pipeline: "command", type: "input", question: "Name:", defaultValue: "default" });
|
|
106
|
+
|
|
107
|
+
expect(mockUi.input).toHaveBeenCalledWith("Name:", "default", expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("calls original confirm with question", () => {
|
|
111
|
+
bus.registerAdapter(createTestTuiAdapter(mockUi, bus));
|
|
112
|
+
bus.request({ pipeline: "command", type: "confirm", question: "Sure?" });
|
|
113
|
+
|
|
114
|
+
expect(mockUi.confirm).toHaveBeenCalledWith("Sure?", "", expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("responds via bus when user answers select", async () => {
|
|
118
|
+
bus.registerAdapter(createTestTuiAdapter(mockUi, bus));
|
|
119
|
+
const promise = bus.request({ pipeline: "command", type: "select", question: "Q", options: ["A"] });
|
|
120
|
+
|
|
121
|
+
mockUi._resolve("select", "A");
|
|
122
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
123
|
+
|
|
124
|
+
const result = await promise;
|
|
125
|
+
expect(result.answer).toBe("A");
|
|
126
|
+
expect(result.source).toBe("tui");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("responds cancelled when user dismisses dialog", async () => {
|
|
130
|
+
bus.registerAdapter(createTestTuiAdapter(mockUi, bus));
|
|
131
|
+
const promise = bus.request({ pipeline: "command", type: "select", question: "Q", options: ["A"] });
|
|
132
|
+
|
|
133
|
+
mockUi._resolve("select", undefined);
|
|
134
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
135
|
+
|
|
136
|
+
const result = await promise;
|
|
137
|
+
expect(result.cancelled).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("converts boolean confirm to string", async () => {
|
|
141
|
+
bus.registerAdapter(createTestTuiAdapter(mockUi, bus));
|
|
142
|
+
const promise = bus.request({ pipeline: "command", type: "confirm", question: "Sure?" });
|
|
143
|
+
|
|
144
|
+
mockUi._resolve("confirm", true);
|
|
145
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
146
|
+
|
|
147
|
+
const result = await promise;
|
|
148
|
+
expect(result.answer).toBe("true");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("aborts TUI dialog when another adapter answers", async () => {
|
|
152
|
+
bus.registerAdapter(createTestTuiAdapter(mockUi, bus));
|
|
153
|
+
const promise = bus.request({ pipeline: "command", type: "select", question: "Q", options: ["A"] });
|
|
154
|
+
|
|
155
|
+
// External adapter answers
|
|
156
|
+
const id = (mockUi.select.mock.calls[0] as any)?.[2]?.signal ? undefined : undefined;
|
|
157
|
+
// Get the prompt id from the bus's pending
|
|
158
|
+
bus.respond({ id: (bus as any).pending.keys().next().value, answer: "B", source: "dashboard" });
|
|
159
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
160
|
+
|
|
161
|
+
const result = await promise;
|
|
162
|
+
expect(result.answer).toBe("B");
|
|
163
|
+
expect(result.source).toBe("dashboard");
|
|
164
|
+
expect(mockUi._signal("select")?.aborted).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("aborts TUI dialog on cancel", async () => {
|
|
168
|
+
bus.registerAdapter(createTestTuiAdapter(mockUi, bus));
|
|
169
|
+
const promise = bus.request({ pipeline: "command", type: "select", question: "Q", options: ["A"] });
|
|
170
|
+
|
|
171
|
+
const id = (bus as any).pending.keys().next().value;
|
|
172
|
+
bus.cancel(id);
|
|
173
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
174
|
+
|
|
175
|
+
const result = await promise;
|
|
176
|
+
expect(result.cancelled).toBe(true);
|
|
177
|
+
expect(mockUi._signal("select")?.aborted).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("does not respond after being aborted", async () => {
|
|
181
|
+
const respondSpy = vi.spyOn(bus, "respond");
|
|
182
|
+
bus.registerAdapter(createTestTuiAdapter(mockUi, bus));
|
|
183
|
+
bus.request({ pipeline: "command", type: "select", question: "Q", options: ["A"] });
|
|
184
|
+
|
|
185
|
+
const id = (bus as any).pending.keys().next().value;
|
|
186
|
+
// Dashboard answers first
|
|
187
|
+
bus.respond({ id, answer: "B", source: "dashboard" });
|
|
188
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
189
|
+
|
|
190
|
+
// TUI resolves after abort — should NOT call respond again
|
|
191
|
+
mockUi._resolve("select", "A");
|
|
192
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
193
|
+
|
|
194
|
+
// respond was called exactly once (by dashboard)
|
|
195
|
+
expect(respondSpy.mock.calls.filter(c => c[0].id === id)).toHaveLength(1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("returns empty claim (no component)", () => {
|
|
199
|
+
const adapter = createTestTuiAdapter(mockUi, bus);
|
|
200
|
+
bus.registerAdapter(adapter);
|
|
201
|
+
const claim = adapter.onRequest({
|
|
202
|
+
id: "test", pipeline: "command", type: "select", question: "Q", options: ["A"],
|
|
203
|
+
});
|
|
204
|
+
expect(claim).toEqual({});
|
|
205
|
+
expect(claim?.component).toBeUndefined();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -34,7 +34,7 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
|
|
|
34
34
|
),
|
|
35
35
|
placeholder: Type.Optional(Type.String({ description: "Placeholder text (for input)" })),
|
|
36
36
|
}),
|
|
37
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
37
|
+
async execute(_toolCallId: any, params: any, _signal: any, _onUpdate: any, ctx: any) {
|
|
38
38
|
let result: unknown;
|
|
39
39
|
|
|
40
40
|
const msgOpts = params.message ? { message: params.message } : undefined;
|
|
@@ -8,7 +8,7 @@ import type { ConnectionManager } from "./connection.js";
|
|
|
8
8
|
export interface BridgeContext {
|
|
9
9
|
pi: ExtensionAPI;
|
|
10
10
|
connection: ConnectionManager;
|
|
11
|
-
/** Current session ID (mutated on
|
|
11
|
+
/** Current session ID (mutated on session change: new/fork/resume) */
|
|
12
12
|
sessionId: string;
|
|
13
13
|
cachedCtx: any;
|
|
14
14
|
cachedModelRegistry: any;
|
|
@@ -22,7 +22,8 @@ import { autoStartServer } from "./server-auto-start.js";
|
|
|
22
22
|
import type { ServerToExtensionMessage } from "@blackbelt-technology/pi-dashboard-shared/protocol.js";
|
|
23
23
|
import { expandPromptTemplateFromDisk } from "./prompt-expander.js";
|
|
24
24
|
|
|
25
|
-
import {
|
|
25
|
+
import { PromptBus } from "./prompt-bus.js";
|
|
26
|
+
import { DashboardDefaultAdapter } from "./dashboard-default-adapter.js";
|
|
26
27
|
import { registerAskUserTool } from "./ask-user-tool.js";
|
|
27
28
|
import { activate as activateProviderRegister, onProviderChanged } from "./provider-register.js";
|
|
28
29
|
import type { FlowInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
@@ -178,7 +179,7 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
178
179
|
let cachedCtx: any | undefined = prev.ctx;
|
|
179
180
|
let lastModel: string | undefined;
|
|
180
181
|
let lastThinkingLevel: string | undefined;
|
|
181
|
-
let
|
|
182
|
+
let promptBus: PromptBus | undefined;
|
|
182
183
|
|
|
183
184
|
/** Wrap a callback so errors log instead of crashing the host pi agent. */
|
|
184
185
|
function safe<T extends (...args: any[]) => any>(fn: T): T {
|
|
@@ -207,11 +208,7 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
207
208
|
onMessage: safe(async (data: unknown) => {
|
|
208
209
|
if (!isActive()) return; // Stale listener guard
|
|
209
210
|
const msg = data as ServerToExtensionMessage;
|
|
210
|
-
//
|
|
211
|
-
if (msg.type === "extension_ui_response" && uiProxy) {
|
|
212
|
-
uiProxy.handleResponse(msg);
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
211
|
+
// Legacy extension_ui_response removed — now handled by prompt_response → promptBus.respond()
|
|
215
212
|
// Reload auth credentials when dashboard notifies of changes
|
|
216
213
|
if (msg.type === "credentials_updated") {
|
|
217
214
|
try { cachedModelRegistry?.authStorage?.reload?.(); } catch { /* ignore */ }
|
|
@@ -315,22 +312,20 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
315
312
|
});
|
|
316
313
|
return;
|
|
317
314
|
}
|
|
318
|
-
// Route
|
|
319
|
-
if (msg.type === "
|
|
320
|
-
|
|
321
|
-
id: msg.promptId,
|
|
322
|
-
answer: msg.answer,
|
|
323
|
-
cancelled: msg.cancelled,
|
|
315
|
+
// Route PromptBus responses from dashboard client
|
|
316
|
+
if (msg.type === "prompt_response" && promptBus) {
|
|
317
|
+
promptBus.respond({
|
|
318
|
+
id: (msg as any).promptId,
|
|
319
|
+
answer: (msg as any).answer,
|
|
320
|
+
cancelled: (msg as any).cancelled,
|
|
321
|
+
source: (msg as any).source ?? "dashboard-default",
|
|
324
322
|
});
|
|
325
|
-
// Cancel any pending ui-proxy dialogs so the TUI selector is dismissed.
|
|
326
|
-
// The architect prompt was also forwarded via the ui-proxy (through
|
|
327
|
-
// flow-tui’s prompt-request handler calling uiCtx.select()), but the
|
|
328
|
-
// dashboard client suppresses the duplicate extension_ui_request when
|
|
329
|
-
// an architect_prompt_request is pending. So the proxy’s dashPromise
|
|
330
|
-
// would never resolve, leaving the TUI dialog open forever.
|
|
331
|
-
uiProxy?.cancelAllPending();
|
|
332
323
|
return;
|
|
333
324
|
}
|
|
325
|
+
// Legacy architect_prompt_response routing REMOVED.
|
|
326
|
+
// Previously routed to flow:prompt-response + cancelAllPending().
|
|
327
|
+
// Now handled by PromptBus: dashboard sends prompt_response,
|
|
328
|
+
// bus calls respond(), adapters get onResponse() for cross-cancellation.
|
|
334
329
|
// Route flow control messages to pi-flows via pi.events
|
|
335
330
|
if (msg.type === "flow_control" && pi.events) {
|
|
336
331
|
if (msg.action === "abort") {
|
|
@@ -357,13 +352,33 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
357
352
|
if (!isActive()) return; // Stale listener guard
|
|
358
353
|
sendStateSync();
|
|
359
354
|
replaySessionEntries();
|
|
355
|
+
// Re-send pending PromptBus requests so dashboard dialogs survive browser refresh.
|
|
356
|
+
// Synchronous within this tick to prevent TUI respond() from interleaving.
|
|
357
|
+
// Client-side dedup by requestId prevents double-rendering.
|
|
358
|
+
if (promptBus) {
|
|
359
|
+
for (const { request, component, placement } of promptBus.getPendingRequests()) {
|
|
360
|
+
connection.send({
|
|
361
|
+
type: "prompt_request" as any,
|
|
362
|
+
sessionId,
|
|
363
|
+
promptId: request.id,
|
|
364
|
+
prompt: {
|
|
365
|
+
type: request.type,
|
|
366
|
+
question: request.question,
|
|
367
|
+
options: request.options,
|
|
368
|
+
defaultValue: request.defaultValue,
|
|
369
|
+
pipeline: request.pipeline,
|
|
370
|
+
metadata: request.metadata,
|
|
371
|
+
},
|
|
372
|
+
component,
|
|
373
|
+
placement,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
360
377
|
connection.send({ type: "replay_complete", sessionId });
|
|
361
378
|
// If agent is mid-turn, send synthetic agent_start so server sets status to "streaming"
|
|
362
379
|
if (getBridgeState().isAgentStreaming) {
|
|
363
380
|
connection.send(mapEventToProtocol(sessionId, { type: "agent_start" }));
|
|
364
381
|
}
|
|
365
|
-
// Re-send pending interactive UI requests so the new server can track them
|
|
366
|
-
uiProxy?.resendPending();
|
|
367
382
|
}),
|
|
368
383
|
});
|
|
369
384
|
|
|
@@ -530,8 +545,7 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
530
545
|
// - `context`: carries full message arrays (very large)
|
|
531
546
|
// - `before_provider_request`: carries raw API payloads (very large)
|
|
532
547
|
// - `session_start`: dedicated handler → session_register protocol message
|
|
533
|
-
// -
|
|
534
|
-
// - `session_fork`: dedicated handler → session_register protocol message
|
|
548
|
+
// - session change (new/fork/resume): handled inside session_start via event.reason
|
|
535
549
|
// - `session_shutdown`: dedicated handler → disconnect/cleanup
|
|
536
550
|
|
|
537
551
|
// Unified EventBus rename map for the emit intercept (flow + subagent events)
|
|
@@ -623,6 +637,13 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
623
637
|
if (!isActive()) return;
|
|
624
638
|
const newSessionId = ctx.sessionManager.getSessionId();
|
|
625
639
|
|
|
640
|
+
// On session switch/fork (0.65.0+: event.reason replaces session_switch/session_fork events),
|
|
641
|
+
// unregister the old session before re-registering the new one.
|
|
642
|
+
const reason = _event?.reason;
|
|
643
|
+
if ((reason === "new" || reason === "fork" || reason === "resume") && sessionId && sessionId !== newSessionId) {
|
|
644
|
+
handleSessionChange(ctx);
|
|
645
|
+
}
|
|
646
|
+
|
|
626
647
|
cachedHasUI = ctx.hasUI;
|
|
627
648
|
cachedCtx = ctx;
|
|
628
649
|
sessionId = newSessionId;
|
|
@@ -637,30 +658,173 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
637
658
|
lastSessionFile = sessionFile;
|
|
638
659
|
lastSessionDir = sessionDir;
|
|
639
660
|
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
661
|
+
// ── PromptBus setup ──
|
|
662
|
+
// Create bus with dashboard connection wiring.
|
|
663
|
+
// Replaces the old ui-proxy race pattern.
|
|
664
|
+
promptBus = new PromptBus({
|
|
665
|
+
onDashboardRequest: (prompt, component, placement) => {
|
|
666
|
+
connection.send({
|
|
667
|
+
type: "prompt_request" as any,
|
|
668
|
+
sessionId,
|
|
669
|
+
promptId: prompt.id,
|
|
670
|
+
prompt: {
|
|
671
|
+
question: prompt.question,
|
|
672
|
+
type: prompt.type,
|
|
673
|
+
options: prompt.options,
|
|
674
|
+
defaultValue: prompt.defaultValue,
|
|
675
|
+
pipeline: prompt.pipeline,
|
|
676
|
+
metadata: prompt.metadata,
|
|
677
|
+
},
|
|
678
|
+
component,
|
|
679
|
+
placement,
|
|
680
|
+
});
|
|
681
|
+
},
|
|
682
|
+
onDashboardDismiss: (id) => {
|
|
683
|
+
connection.send({ type: "prompt_dismiss" as any, sessionId, promptId: id });
|
|
684
|
+
},
|
|
685
|
+
onDashboardCancel: (id) => {
|
|
686
|
+
connection.send({ type: "prompt_cancel" as any, sessionId, promptId: id });
|
|
687
|
+
},
|
|
650
688
|
});
|
|
651
|
-
|
|
652
|
-
//
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
//
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
689
|
+
|
|
690
|
+
// Register built-in default adapter (always present, works without pi-flows)
|
|
691
|
+
promptBus.registerAdapter(new DashboardDefaultAdapter());
|
|
692
|
+
|
|
693
|
+
// Capture original ctx.ui method references BEFORE patching
|
|
694
|
+
const originalNotify = ctx.ui.notify?.bind(ctx.ui);
|
|
695
|
+
const originals = {
|
|
696
|
+
select: ctx.ui.select?.bind(ctx.ui) as ((q: string, opts: string[], extra?: any) => Promise<string | undefined>) | undefined,
|
|
697
|
+
input: ctx.ui.input?.bind(ctx.ui) as ((q: string, placeholder?: string, extra?: any) => Promise<string | undefined>) | undefined,
|
|
698
|
+
confirm: ctx.ui.confirm?.bind(ctx.ui) as ((q: string, msg: string, extra?: any) => Promise<boolean>) | undefined,
|
|
699
|
+
editor: ctx.ui.editor?.bind(ctx.ui) as ((q: string, prefill?: string, extra?: any) => Promise<string | undefined>) | undefined,
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
// Register TUI adapter — presents prompts in the terminal using original
|
|
703
|
+
// (unpatched) ctx.ui methods. Must be registered BEFORE patching ctx.ui.
|
|
704
|
+
if (ctx.hasUI) {
|
|
705
|
+
const activeControllers = new Map<string, AbortController>();
|
|
706
|
+
const bus = promptBus;
|
|
707
|
+
|
|
708
|
+
bus.registerAdapter({
|
|
709
|
+
name: "tui",
|
|
710
|
+
|
|
711
|
+
onRequest(prompt: any) {
|
|
712
|
+
const ac = new AbortController();
|
|
713
|
+
activeControllers.set(prompt.id, ac);
|
|
714
|
+
|
|
715
|
+
const present = async () => {
|
|
716
|
+
try {
|
|
717
|
+
let answer: string | boolean | undefined;
|
|
718
|
+
|
|
719
|
+
if (prompt.type === "select" && prompt.options && originals.select) {
|
|
720
|
+
answer = await originals.select(prompt.question, prompt.options, { signal: ac.signal });
|
|
721
|
+
} else if (prompt.type === "input" && originals.input) {
|
|
722
|
+
answer = await originals.input(prompt.question, prompt.defaultValue || "", { signal: ac.signal });
|
|
723
|
+
} else if (prompt.type === "confirm" && originals.confirm) {
|
|
724
|
+
answer = await originals.confirm(prompt.question, "", { signal: ac.signal });
|
|
725
|
+
} else if (prompt.type === "editor" && originals.editor) {
|
|
726
|
+
answer = await originals.editor(prompt.question, prompt.defaultValue || "", { signal: ac.signal });
|
|
727
|
+
} else {
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (!ac.signal.aborted) {
|
|
732
|
+
const answerStr = typeof answer === "boolean" ? (answer ? "true" : "false") : answer;
|
|
733
|
+
bus.respond({
|
|
734
|
+
id: prompt.id,
|
|
735
|
+
answer: answerStr ?? undefined,
|
|
736
|
+
cancelled: answerStr == null,
|
|
737
|
+
source: "tui",
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
} catch {
|
|
741
|
+
if (!ac.signal.aborted) {
|
|
742
|
+
bus.respond({ id: prompt.id, cancelled: true, source: "tui" });
|
|
743
|
+
}
|
|
744
|
+
} finally {
|
|
745
|
+
activeControllers.delete(prompt.id);
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
present();
|
|
750
|
+
return {}; // Claim without component (TUI-only)
|
|
751
|
+
},
|
|
752
|
+
|
|
753
|
+
onResponse(response: any) {
|
|
754
|
+
if (response.source !== "tui") {
|
|
755
|
+
const ac = activeControllers.get(response.id);
|
|
756
|
+
if (ac) {
|
|
757
|
+
ac.abort();
|
|
758
|
+
activeControllers.delete(response.id);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
},
|
|
762
|
+
|
|
763
|
+
onCancel(id: string) {
|
|
764
|
+
const ac = activeControllers.get(id);
|
|
765
|
+
if (ac) {
|
|
766
|
+
ac.abort();
|
|
767
|
+
activeControllers.delete(id);
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Replace ctx.ui dialog methods with PromptBus wrappers.
|
|
774
|
+
// All extension commands that call ctx.ui.select/input/confirm/editor
|
|
775
|
+
// now route through the bus, which distributes to all registered adapters.
|
|
776
|
+
{
|
|
777
|
+
const bus = promptBus;
|
|
778
|
+
(ctx.ui as any).select = (title: string, options: string[], _opts?: any) =>
|
|
779
|
+
bus.request({ pipeline: "command", type: "select", question: title, options })
|
|
780
|
+
.then(r => r.cancelled ? undefined : r.answer);
|
|
781
|
+
|
|
782
|
+
(ctx.ui as any).input = (title: string, placeholder?: string, _opts?: any) =>
|
|
783
|
+
bus.request({ pipeline: "command", type: "input", question: title, defaultValue: placeholder })
|
|
784
|
+
.then(r => r.cancelled ? undefined : r.answer);
|
|
785
|
+
|
|
786
|
+
(ctx.ui as any).confirm = (title: string, _message: string, _opts?: any) =>
|
|
787
|
+
bus.request({ pipeline: "command", type: "confirm", question: title })
|
|
788
|
+
.then(r => !r.cancelled && r.answer === "true");
|
|
789
|
+
|
|
790
|
+
(ctx.ui as any).editor = (title: string, prefill?: string, _opts?: any) =>
|
|
791
|
+
bus.request({ pipeline: "command", type: "editor", question: title, defaultValue: prefill })
|
|
792
|
+
.then(r => r.cancelled ? undefined : r.answer);
|
|
793
|
+
|
|
794
|
+
// Notify is fire-and-forget: call original + forward to dashboard
|
|
795
|
+
(ctx.ui as any).notify = (message: string, level?: string) => {
|
|
796
|
+
originalNotify?.(message, level);
|
|
797
|
+
connection.send({
|
|
798
|
+
type: "prompt_request" as any,
|
|
799
|
+
sessionId,
|
|
800
|
+
promptId: crypto.randomUUID(),
|
|
801
|
+
prompt: { question: message, type: "notify" },
|
|
802
|
+
component: { type: "notify", props: { message, level } },
|
|
803
|
+
placement: "inline",
|
|
804
|
+
});
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Listen for adapter registrations from other extensions (e.g. pi-flows)
|
|
809
|
+
if (pi.events) {
|
|
810
|
+
pi.events.on("prompt:register-adapter", (adapter: any) => {
|
|
811
|
+
if (promptBus && adapter && typeof adapter.name === "string") {
|
|
812
|
+
promptBus.registerAdapter(adapter);
|
|
813
|
+
// Inject respond/cancel functions so cross-package adapters can talk back
|
|
814
|
+
if (typeof adapter.setRespond === "function") {
|
|
815
|
+
adapter.setRespond((response: any) => promptBus!.respond(response));
|
|
816
|
+
}
|
|
817
|
+
if (typeof adapter.setCancel === "function") {
|
|
818
|
+
adapter.setCancel((id: string) => promptBus!.cancel(id));
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
// Expose bus request function for pi-flows to use via emitPromptAndAwait
|
|
824
|
+
pi.events.emit("prompt:set-bus-request", {
|
|
825
|
+
request: (options: any) => promptBus!.request(options),
|
|
826
|
+
});
|
|
827
|
+
}
|
|
664
828
|
|
|
665
829
|
// Connect first, then auto-start if needed.
|
|
666
830
|
// session_register must be buffered before any event_forward messages.
|
|
@@ -810,7 +974,7 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
810
974
|
registerFlowEventListeners(syncBc(), () => sessionReady, getFlowsList);
|
|
811
975
|
}));
|
|
812
976
|
|
|
813
|
-
// Shared handler for
|
|
977
|
+
// Shared handler for session changes (new/fork/resume)
|
|
814
978
|
function handleSessionChange(ctx: any) {
|
|
815
979
|
const bc = syncBc();
|
|
816
980
|
_handleSessionChange(bc, ctx, getFlowsList);
|
|
@@ -823,17 +987,8 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
823
987
|
}, GIT_POLL_INTERVAL);
|
|
824
988
|
}
|
|
825
989
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
cachedCtx = ctx;
|
|
829
|
-
handleSessionChange(ctx);
|
|
830
|
-
}));
|
|
831
|
-
|
|
832
|
-
pi.on("session_fork" as any, safe(async (_event: any, ctx: any) => {
|
|
833
|
-
if (!isActive()) return;
|
|
834
|
-
cachedCtx = ctx;
|
|
835
|
-
handleSessionChange(ctx);
|
|
836
|
-
}));
|
|
990
|
+
// session_switch and session_fork events removed in pi 0.65.0.
|
|
991
|
+
// Now handled via session_start with event.reason ("new"|"fork"|"resume").
|
|
837
992
|
|
|
838
993
|
pi.on("turn_end", safe(async (event: any, ctx: any) => {
|
|
839
994
|
if (!isActive()) return;
|
|
@@ -178,8 +178,8 @@ export function createCommandHandler(
|
|
|
178
178
|
const sessionId = getSessionId();
|
|
179
179
|
|
|
180
180
|
// Ignore messages for other sessions (skip session-less messages like heartbeat_ack)
|
|
181
|
-
if (msg.sessionId !== undefined && msg.sessionId !== sessionId) {
|
|
182
|
-
console.error(`[dashboard] Ignoring message type=${msg.type} for session ${msg.sessionId}, current session is ${sessionId}`);
|
|
181
|
+
if ((msg as any).sessionId !== undefined && (msg as any).sessionId !== sessionId) {
|
|
182
|
+
console.error(`[dashboard] Ignoring message type=${msg.type} for session ${(msg as any).sessionId}, current session is ${sessionId}`);
|
|
183
183
|
return undefined;
|
|
184
184
|
}
|
|
185
185
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DashboardDefaultAdapter — built-in adapter that renders all prompts
|
|
3
|
+
* as generic interactive dialogs in the dashboard chat area.
|
|
4
|
+
*
|
|
5
|
+
* This is the fallback when no other adapter claims with a custom component.
|
|
6
|
+
* Always registered — works without pi-flows.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PromptAdapter, PromptRequest, PromptResponse, PromptClaim } from "./prompt-bus.js";
|
|
10
|
+
|
|
11
|
+
export class DashboardDefaultAdapter implements PromptAdapter {
|
|
12
|
+
readonly name = "dashboard-default";
|
|
13
|
+
|
|
14
|
+
onRequest(_prompt: PromptRequest): PromptClaim {
|
|
15
|
+
return {
|
|
16
|
+
component: {
|
|
17
|
+
type: "generic-dialog",
|
|
18
|
+
props: {
|
|
19
|
+
question: _prompt.question,
|
|
20
|
+
type: _prompt.type,
|
|
21
|
+
options: _prompt.options,
|
|
22
|
+
defaultValue: _prompt.defaultValue,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
placement: "inline",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
onResponse(_response: PromptResponse): void {
|
|
30
|
+
// Dismiss is handled by the bus via onDashboardDismiss callback.
|
|
31
|
+
// No extra work needed here — the bus sends prompt_dismiss for us.
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
onCancel(_id: string): void {
|
|
35
|
+
// Cleanup is handled by the bus via onDashboardCancel callback.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -36,6 +36,8 @@ export const FLOW_EVENT_MAP: Record<string, string> = {
|
|
|
36
36
|
"flow:architect-context-generating": "architect_context_generating",
|
|
37
37
|
"flow:architect-context-ready": "architect_context_ready",
|
|
38
38
|
"flow:architect-run-handoff": "architect_run_handoff",
|
|
39
|
+
// Autonomous mode feedback
|
|
40
|
+
"flow:autonomous-mode-changed": "flow_autonomous_changed",
|
|
39
41
|
};
|
|
40
42
|
|
|
41
43
|
/** Map of pi-subagents event names to dashboard protocol event types */
|
|
@@ -76,27 +78,8 @@ export function registerFlowEventListeners(
|
|
|
76
78
|
// Note: event_forward sending for flow and subagent events is handled by
|
|
77
79
|
// the EventBus emit intercept in bridge.ts (catch-all forwarding).
|
|
78
80
|
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
if (!isSessionReady()) return;
|
|
84
|
-
const req = data as { id?: string; pipeline?: string; type?: string; question?: string; options?: string[]; defaultValue?: string };
|
|
85
|
-
if (!req.id || !req.pipeline?.startsWith("architect-")) return;
|
|
86
|
-
connection.send({
|
|
87
|
-
type: "event_forward",
|
|
88
|
-
sessionId: bc.sessionId,
|
|
89
|
-
event: {
|
|
90
|
-
eventType: "architect_prompt_request",
|
|
91
|
-
timestamp: Date.now(),
|
|
92
|
-
data: {
|
|
93
|
-
id: req.id,
|
|
94
|
-
promptType: req.type,
|
|
95
|
-
question: req.question,
|
|
96
|
-
options: req.options,
|
|
97
|
-
defaultValue: req.defaultValue,
|
|
98
|
-
},
|
|
99
|
-
},
|
|
100
|
-
});
|
|
101
|
-
});
|
|
81
|
+
// Legacy architect prompt forwarding REMOVED.
|
|
82
|
+
// Previously forwarded flow:prompt-request events with architect-* pipelines
|
|
83
|
+
// as architect_prompt_request to the dashboard. Now handled by
|
|
84
|
+
// ArchitectUIAdapter registered with the PromptBus (see architect-ui-adapter.ts).
|
|
102
85
|
}
|