@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.
Files changed (49) hide show
  1. package/AGENTS.md +3 -1
  2. package/docs/architecture.md +30 -23
  3. package/package.json +1 -1
  4. package/packages/extension/package.json +1 -1
  5. package/packages/extension/src/__tests__/dashboard-default-adapter.test.ts +77 -0
  6. package/packages/extension/src/__tests__/dev-build.test.ts +2 -2
  7. package/packages/extension/src/__tests__/prompt-bus-wiring.test.ts +791 -0
  8. package/packages/extension/src/__tests__/prompt-bus.test.ts +469 -0
  9. package/packages/extension/src/__tests__/server-launcher.test.ts +35 -34
  10. package/packages/extension/src/__tests__/tui-prompt-adapter.test.ts +207 -0
  11. package/packages/extension/src/ask-user-tool.ts +1 -1
  12. package/packages/extension/src/bridge-context.ts +1 -1
  13. package/packages/extension/src/bridge.ts +214 -59
  14. package/packages/extension/src/command-handler.ts +2 -2
  15. package/packages/extension/src/dashboard-default-adapter.ts +37 -0
  16. package/packages/extension/src/flow-event-wiring.ts +6 -23
  17. package/packages/extension/src/pi-env.d.ts +13 -0
  18. package/packages/extension/src/prompt-bus.ts +240 -0
  19. package/packages/extension/src/server-launcher.ts +2 -2
  20. package/packages/extension/src/session-sync.ts +2 -1
  21. package/packages/server/package.json +1 -1
  22. package/packages/server/src/__tests__/bridge-register-nondestructive.test.ts +108 -0
  23. package/packages/server/src/__tests__/extension-register-appimage.test.ts +39 -0
  24. package/packages/server/src/__tests__/extension-register.test.ts +26 -22
  25. package/packages/server/src/__tests__/process-manager.test.ts +4 -1
  26. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +5 -5
  27. package/packages/server/src/__tests__/tunnel.test.ts +2 -2
  28. package/packages/server/src/browser-gateway.ts +55 -16
  29. package/packages/server/src/cli.ts +1 -1
  30. package/packages/server/src/editor-manager.ts +1 -1
  31. package/packages/server/src/event-status-extraction.ts +7 -0
  32. package/packages/server/src/event-wiring.ts +16 -19
  33. package/packages/server/src/package-manager-wrapper.ts +1 -1
  34. package/packages/server/src/process-manager.ts +8 -69
  35. package/packages/server/src/routes/system-routes.ts +3 -1
  36. package/packages/server/src/server.ts +6 -4
  37. package/packages/shared/package.json +1 -1
  38. package/packages/shared/src/__tests__/bridge-register.test.ts +136 -0
  39. package/packages/shared/src/__tests__/tool-resolver.test.ts +164 -0
  40. package/packages/shared/src/bridge-register.ts +95 -0
  41. package/packages/shared/src/browser-protocol.ts +10 -0
  42. package/packages/shared/src/managed-paths.ts +15 -0
  43. package/packages/shared/src/mdns-discovery.ts +1 -1
  44. package/packages/shared/src/protocol.ts +46 -0
  45. package/packages/shared/src/tool-resolver.ts +201 -0
  46. package/packages/shared/src/types.ts +24 -0
  47. package/packages/extension/src/__tests__/ui-proxy.test.ts +0 -583
  48. package/packages/extension/src/ui-proxy.ts +0 -269
  49. 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 session_switch/fork) */
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 { createUiProxy } from "./ui-proxy.js";
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 uiProxy: ReturnType<typeof createUiProxy> | undefined;
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
- // Route UI responses to the proxy
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 architect prompt responses back to flow-workspace via pi.events
319
- if (msg.type === "architect_prompt_response" && pi.events) {
320
- pi.events.emit("flow:prompt-response", {
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
- // - `session_switch`: dedicated handler session_register protocol message
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
- // Set up UI proxy to forward dialogs to dashboard.
641
- // For dashboard-spawned sessions (tmux or headless), skip the TUI race —
642
- // the dashboard is the primary UI, and the TUI dialog in an unattended
643
- // tmux window would auto-resolve/flood.
644
- const dashboardSpawned = detectSessionSource(cachedHasUI, sessionFile) === "dashboard";
645
- uiProxy = createUiProxy({
646
- ui: ctx.ui as any,
647
- hasUI: ctx.hasUI && !dashboardSpawned,
648
- getSessionId: () => sessionId,
649
- send: (msg: any) => connection.send(msg),
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
- // Replace ctx.ui methods with proxied versions.
652
- // The ui-proxy has a recursion guard (inProxy flag) so even if ctx.ui
653
- // is already patched from a previous /reload, the TUI race path won't
654
- // recurse — it falls back to dashboard-only on re-entry.
655
- // Replace ctx.ui methods with proxied versions.
656
- // The ui-proxy has a recursion guard (inProxy flag) so even if ctx.ui
657
- // is already patched from a previous /reload, the TUI race path won't
658
- // recurse it falls back to dashboard-only on re-entry.
659
- (ctx.ui as any).confirm = uiProxy.wrappedUi.confirm;
660
- (ctx.ui as any).select = uiProxy.wrappedUi.select;
661
- (ctx.ui as any).input = uiProxy.wrappedUi.input;
662
- (ctx.ui as any).editor = uiProxy.wrappedUi.editor;
663
- (ctx.ui as any).notify = uiProxy.wrappedUi.notify;
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 session_switch and session_fork
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
- pi.on("session_switch" as any, safe(async (_event: any, ctx: any) => {
827
- if (!isActive()) return;
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
- // Forward architect prompt requests directly to the dashboard widget bar.
80
- // Non-architect prompts still go through ui-proxy (flow-tui -> ctx.ui.select -> extension_ui_request).
81
- // Both paths race -- first response wins via emitPromptAndAwait's resolved guard.
82
- pi.events.on("flow:prompt-request", (data: unknown) => {
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
  }