@blackbelt-technology/pi-agent-dashboard 0.4.0 → 0.4.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 (129) hide show
  1. package/AGENTS.md +104 -35
  2. package/README.md +390 -494
  3. package/docs/architecture.md +423 -20
  4. package/package.json +11 -8
  5. package/packages/extension/package.json +11 -4
  6. package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
  7. package/packages/extension/src/__tests__/ask-user-tool.test.ts +91 -15
  8. package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
  9. package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
  10. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
  11. package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
  12. package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
  13. package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
  14. package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
  15. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
  16. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
  17. package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
  18. package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
  19. package/packages/extension/src/ask-user-tool.ts +170 -61
  20. package/packages/extension/src/bridge.ts +199 -19
  21. package/packages/extension/src/multiselect-decode.ts +40 -0
  22. package/packages/extension/src/multiselect-list.ts +146 -0
  23. package/packages/extension/src/multiselect-polyfill.ts +73 -0
  24. package/packages/extension/src/server-launcher.ts +15 -3
  25. package/packages/extension/src/ui-modules.ts +272 -0
  26. package/packages/server/package.json +11 -5
  27. package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
  28. package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
  29. package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
  30. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
  31. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
  32. package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
  33. package/packages/server/src/__tests__/directory-service.test.ts +174 -0
  34. package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
  35. package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
  36. package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
  37. package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
  38. package/packages/server/src/__tests__/package-routes.test.ts +136 -3
  39. package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
  40. package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
  41. package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
  42. package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
  43. package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
  44. package/packages/server/src/__tests__/pi-version-skew.test.ts +72 -0
  45. package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
  46. package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
  47. package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
  48. package/packages/server/src/__tests__/restart-helper.test.ts +34 -6
  49. package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
  50. package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
  51. package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
  52. package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
  53. package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
  54. package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
  55. package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
  56. package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
  57. package/packages/server/src/browse.ts +118 -13
  58. package/packages/server/src/browser-gateway.ts +19 -0
  59. package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
  60. package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
  61. package/packages/server/src/browser-handlers/handler-context.ts +15 -0
  62. package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
  63. package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
  64. package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
  65. package/packages/server/src/cli.ts +61 -15
  66. package/packages/server/src/directory-service.ts +156 -15
  67. package/packages/server/src/event-wiring.ts +111 -10
  68. package/packages/server/src/installed-package-enricher.ts +143 -0
  69. package/packages/server/src/package-manager-wrapper.ts +305 -8
  70. package/packages/server/src/package-source-helpers.ts +104 -0
  71. package/packages/server/src/pending-attach-registry.ts +112 -0
  72. package/packages/server/src/pending-resume-intent-registry.ts +107 -0
  73. package/packages/server/src/pi-core-checker.ts +9 -14
  74. package/packages/server/src/pi-gateway.ts +14 -0
  75. package/packages/server/src/pi-version-skew.ts +12 -1
  76. package/packages/server/src/proposal-attach-naming.ts +47 -0
  77. package/packages/server/src/restart-helper.ts +13 -2
  78. package/packages/server/src/routes/file-routes.ts +29 -3
  79. package/packages/server/src/routes/package-routes.ts +72 -3
  80. package/packages/server/src/routes/plugin-config-routes.ts +129 -0
  81. package/packages/server/src/routes/system-routes.ts +2 -0
  82. package/packages/server/src/server.ts +339 -10
  83. package/packages/server/src/session-api.ts +30 -5
  84. package/packages/server/src/session-order-manager.ts +22 -0
  85. package/packages/server/src/session-scanner.ts +10 -1
  86. package/packages/shared/package.json +9 -2
  87. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
  88. package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
  89. package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
  90. package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
  91. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
  92. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
  93. package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
  94. package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
  95. package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
  96. package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
  97. package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
  98. package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
  99. package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
  100. package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
  101. package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
  102. package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
  103. package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
  104. package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
  105. package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
  106. package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
  107. package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
  108. package/packages/shared/src/browser-protocol.ts +110 -4
  109. package/packages/shared/src/config.ts +45 -0
  110. package/packages/shared/src/dashboard-plugin/index.ts +11 -0
  111. package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
  112. package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
  113. package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
  114. package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
  115. package/packages/shared/src/openspec-activity-detector.ts +18 -22
  116. package/packages/shared/src/openspec-design-evidence.ts +109 -0
  117. package/packages/shared/src/openspec-poller.ts +117 -3
  118. package/packages/shared/src/openspec-specs-evidence.ts +79 -0
  119. package/packages/shared/src/platform/binary-lookup.ts +96 -1
  120. package/packages/shared/src/platform/index.ts +1 -0
  121. package/packages/shared/src/platform/node-spawn.ts +154 -0
  122. package/packages/shared/src/plugin-bridge-register.ts +139 -0
  123. package/packages/shared/src/protocol.ts +79 -2
  124. package/packages/shared/src/recommended-extensions.ts +7 -1
  125. package/packages/shared/src/rest-api.ts +68 -3
  126. package/packages/shared/src/state-replay.ts +20 -1
  127. package/packages/shared/src/tool-registry/definitions.ts +92 -0
  128. package/packages/shared/src/tool-registry/strategies.ts +17 -3
  129. package/packages/shared/src/types.ts +160 -0
@@ -26,6 +26,7 @@ import { expandPromptTemplateFromDisk } from "./prompt-expander.js";
26
26
  import { PromptBus } from "./prompt-bus.js";
27
27
  import { DashboardDefaultAdapter } from "./dashboard-default-adapter.js";
28
28
  import { registerAskUserTool } from "./ask-user-tool.js";
29
+ import { decodeMultiselectAnswer } from "./multiselect-decode.js";
29
30
  import { activate as activateProviderRegister, onProviderChanged, reloadProviders } from "./provider-register.js";
30
31
  import type { FlowInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
31
32
  import { startMetricsMonitor, stopMetricsMonitor, collectMetrics } from "./process-metrics.js";
@@ -35,6 +36,7 @@ import { filterHiddenCommands, extractFirstMessage, getCurrentModelString } from
35
36
  import { sendStateSync as _sendStateSync, replaySessionEntries as _replaySessionEntries, handleSessionChange as _handleSessionChange } from "./session-sync.js";
36
37
  import { sendModelUpdateIfChanged as _sendModelUpdateIfChanged, sendSessionNameIfChanged as _sendSessionNameIfChanged, sendGitInfoIfChanged as _sendGitInfoIfChanged } from "./model-tracker.js";
37
38
  import { registerFlowEventListeners, FLOW_EVENT_MAP, SUBAGENT_EVENT_MAP } from "./flow-event-wiring.js";
39
+ import { refreshUiModules, subscribeUiInvalidate, handleUiManagement, type UiModulesBridgeCtx } from "./ui-modules.js";
38
40
 
39
41
  const HEARTBEAT_INTERVAL = 15_000;
40
42
  const GIT_POLL_INTERVAL = 30_000;
@@ -186,6 +188,61 @@ function initBridge(pi: ExtensionAPI) {
186
188
  let lastThinkingLevel: string | undefined;
187
189
  let promptBus: PromptBus | undefined;
188
190
 
191
+ // ── Per-message entry id tracking (for fix-per-message-fork) ──
192
+ // Pi 0.69+ awaits extension handlers BEFORE sessionManager.appendMessage runs,
193
+ // which means getLeafId() at emit time returns the previous leaf, not the
194
+ // entry id of the message currently being emitted. We solve this by:
195
+ // 1. Wrapping ctx.sessionManager.appendMessage at session_start to stamp
196
+ // the just-generated entry id onto the message object reference.
197
+ // 2. Deferring the message_end enrichment-and-send via setTimeout(0) so
198
+ // the awaited dispatcher unwinds and appendMessage runs in between.
199
+ // 3. Stamping a nonce on message_start/message_end events; emitting an
200
+ // entry_persisted event after appendMessage so the client reducer can
201
+ // back-fill user-message ChatMessage.entryId.
202
+ // See change: fix-per-message-fork.
203
+ const idByMessage = new WeakMap<object, string>();
204
+ const pendingNonces = new WeakMap<object, string>();
205
+ let nonceCounter = 0;
206
+ const nextNonce = (): string => `n-${++nonceCounter}-${Date.now()}`;
207
+ let appendMessageWrapped = false;
208
+ let lastWrappedSm: any = null;
209
+
210
+ /**
211
+ * Wrap ctx.sessionManager.appendMessage once per session so that when pi
212
+ * generates an entry id we capture it in the WeakMap and emit
213
+ * entry_persisted to the server.
214
+ */
215
+ function wrapAppendMessageForCtx(ctx: any): void {
216
+ const sm = ctx?.sessionManager;
217
+ if (!sm || typeof sm.appendMessage !== "function") return;
218
+ // Re-wrap when sessionManager identity changes (session replacement).
219
+ if (sm === lastWrappedSm && appendMessageWrapped) return;
220
+ const original = sm.appendMessage.bind(sm);
221
+ sm.appendMessage = (msg: any, ...rest: any[]) => {
222
+ const result = original(msg, ...rest);
223
+ try {
224
+ if (msg && typeof msg === "object" && typeof msg.id === "string") {
225
+ idByMessage.set(msg as object, msg.id);
226
+ const nonce = pendingNonces.get(msg as object);
227
+ if (nonce && sessionReady && isActive()) {
228
+ const ev = {
229
+ type: "entry_persisted",
230
+ entryId: msg.id,
231
+ nonce,
232
+ };
233
+ connection.send(mapEventToProtocol(sessionId, ev));
234
+ pendingNonces.delete(msg as object);
235
+ }
236
+ }
237
+ } catch (err) {
238
+ console.error("[dashboard] entry_persisted emit failed:", err);
239
+ }
240
+ return result;
241
+ };
242
+ lastWrappedSm = sm;
243
+ appendMessageWrapped = true;
244
+ }
245
+
189
246
  /** Wrap a callback so errors log instead of crashing the host pi agent. */
190
247
  function safe<T extends (...args: any[]) => any>(fn: T): T {
191
248
  return ((...args: any[]) => {
@@ -208,11 +265,29 @@ function initBridge(pi: ExtensionAPI) {
208
265
  const config = loadConfig();
209
266
  const dashboardUrl = process.env.PI_DASHBOARD_URL ?? `ws://localhost:${config.piPort}`;
210
267
 
268
+ // Long-lived ctx wrapper for the Extension UI System (Phase 1) — see
269
+ // change: add-extension-ui-modal. `getSessionId` reads the closed-over
270
+ // `sessionId` so the helper always uses the current value (which is
271
+ // mutated when `event.reason ∈ {"new","fork","resume"}` fires).
272
+ const uiModulesBridgeCtx: UiModulesBridgeCtx = {
273
+ pi: pi as any,
274
+ connection: { send: (msg: unknown) => connection.send(msg) },
275
+ getSessionId: () => sessionId,
276
+ };
277
+
211
278
  const connection = new ConnectionManager({
212
279
  url: dashboardUrl,
213
280
  onMessage: safe(async (data: unknown) => {
214
281
  if (!isActive()) return; // Stale listener guard
215
282
  const msg = data as ServerToExtensionMessage;
283
+ // Extension UI System (Phase 1): browser-originated action / data
284
+ // request. Re-emit on pi.events; the listener either populates
285
+ // data.items synchronously or calls _reply asynchronously.
286
+ // See change: add-extension-ui-modal.
287
+ if ((msg as any).type === "ui_management") {
288
+ handleUiManagement(uiModulesBridgeCtx, msg as any);
289
+ return;
290
+ }
216
291
  // Legacy extension_ui_response removed — now handled by prompt_response → promptBus.respond()
217
292
  // Reload auth credentials when dashboard notifies of changes
218
293
  if (msg.type === "credentials_updated") {
@@ -408,6 +483,11 @@ function initBridge(pi: ExtensionAPI) {
408
483
  if (getBridgeState().isAgentStreaming) {
409
484
  connection.send(mapEventToProtocol(sessionId, { type: "agent_start" }));
410
485
  }
486
+ // Extension UI System (Phase 1): re-probe modules after every
487
+ // reconnect so the server-side cache stays accurate. The probe is
488
+ // synchronous and re-runs the listener stack each call.
489
+ // See change: add-extension-ui-modal.
490
+ refreshUiModules(uiModulesBridgeCtx);
411
491
  }),
412
492
  });
413
493
 
@@ -612,30 +692,53 @@ function initBridge(pi: ExtensionAPI) {
612
692
  }
613
693
  }
614
694
 
615
- // For message_start, enrich with entryId immediately (current leaf)
695
+ // For message_start: stamp a nonce on the event so the client reducer
696
+ // can correlate a later entry_persisted back-fill with this bubble.
697
+ // We do NOT attach entryId here — the message has no id yet on pi
698
+ // 0.69+ (persistence is deferred to message_end). See change:
699
+ // fix-per-message-fork.
616
700
  if (eventType === "message_start") {
617
- const entryId = ctx.sessionManager?.getLeafId?.();
618
- if (entryId) {
619
- const enriched = { ...event, entryId };
701
+ wrapAppendMessageForCtx(ctx);
702
+ const messageRef = (event as any).message;
703
+ if (messageRef && typeof messageRef === "object") {
704
+ const nonce = nextNonce();
705
+ pendingNonces.set(messageRef as object, nonce);
706
+ const enriched = { ...event, nonce };
620
707
  const msg = mapEventToProtocol(sessionId, enriched);
621
708
  connection.send(msg);
622
709
  return;
623
710
  }
624
711
  }
625
712
 
626
- // For message_end, defer getLeafId() so it runs after pi core persists the entry.
627
- // Pi core calls _emit (which invokes this handler) BEFORE appendMessage (which updates leafId).
628
- // Since _emit doesn't await async handlers, yielding via queueMicrotask lets appendMessage
629
- // run first, so getLeafId() returns the correct entry ID for the just-persisted message.
713
+ // For message_end: defer the SEND via setTimeout(0). Pi 0.69+ runs
714
+ // sessionManager.appendMessage AFTER the awaited extension dispatcher
715
+ // returns, so a queueMicrotask deferral is no longer enough. By the
716
+ // time the macrotask fires, appendMessage has run, pi has mutated
717
+ // event.message.id in place, and the wrapped appendMessage above has
718
+ // populated idByMessage. We also stamp a nonce so a downstream
719
+ // entry_persisted can correlate (covers user message_end where the
720
+ // earlier message_start nonce is what the reducer is waiting on).
721
+ // See change: fix-per-message-fork.
630
722
  if (eventType === "message_end") {
631
- await new Promise<void>(resolve => queueMicrotask(resolve));
632
- const entryId = ctx.sessionManager?.getLeafId?.();
633
- if (entryId) {
634
- const enriched = { ...event, entryId };
635
- const msg = mapEventToProtocol(sessionId, enriched);
636
- connection.send(msg);
637
- return;
723
+ wrapAppendMessageForCtx(ctx);
724
+ const messageRef = (event as any).message;
725
+ const nonce = messageRef && typeof messageRef === "object"
726
+ ? (pendingNonces.get(messageRef as object) ?? nextNonce())
727
+ : nextNonce();
728
+ if (messageRef && typeof messageRef === "object" && !pendingNonces.has(messageRef as object)) {
729
+ pendingNonces.set(messageRef as object, nonce);
638
730
  }
731
+ setTimeout(() => {
732
+ if (!isActive() || !sessionReady) return;
733
+ const entryId =
734
+ (messageRef && typeof messageRef === "object" && typeof messageRef.id === "string" ? messageRef.id : undefined)
735
+ ?? (messageRef ? idByMessage.get(messageRef as object) : undefined)
736
+ ?? ctx.sessionManager?.getLeafId?.();
737
+ const enriched = { ...event, entryId, nonce };
738
+ const protoMsg = mapEventToProtocol(sessionId, enriched);
739
+ connection.send(protoMsg);
740
+ }, 0);
741
+ return;
639
742
  }
640
743
 
641
744
  const msg = mapEventToProtocol(sessionId, event);
@@ -694,6 +797,15 @@ function initBridge(pi: ExtensionAPI) {
694
797
  cachedCtx = ctx;
695
798
  sessionId = newSessionId;
696
799
 
800
+ // Wrap sessionManager.appendMessage so that future message_end events can
801
+ // recover the just-generated entry id, even when their setTimeout(0)
802
+ // fires before pi has finished mutating event.message in place. The
803
+ // helper is idempotent and re-wraps on session replacement.
804
+ // See change: fix-per-message-fork.
805
+ appendMessageWrapped = false;
806
+ lastWrappedSm = null;
807
+ wrapAppendMessageForCtx(ctx);
808
+
697
809
  // Register ask_user at runtime (not at load time) to avoid static
698
810
  // tool-name conflicts with other extensions like pi-flows.
699
811
  registerAskUserTool(pi);
@@ -743,6 +855,16 @@ function initBridge(pi: ExtensionAPI) {
743
855
  input: ctx.ui.input?.bind(ctx.ui) as ((q: string, placeholder?: string, extra?: any) => Promise<string | undefined>) | undefined,
744
856
  confirm: ctx.ui.confirm?.bind(ctx.ui) as ((q: string, msg: string, extra?: any) => Promise<boolean>) | undefined,
745
857
  editor: ctx.ui.editor?.bind(ctx.ui) as ((q: string, prefill?: string, extra?: any) => Promise<string | undefined>) | undefined,
858
+ // NOTE: the `custom` field is intentionally NOT captured here. A
859
+ // previous change (fix-multiselect-auto-cancel-on-dashboard) added a
860
+ // TUI multiselect arm that awaited the original ctx.ui.custom binding,
861
+ // but pi 0.70's RPC mode defines that primitive as a no-op (returns
862
+ // undefined synchronously), causing the TUI adapter to auto-cancel the
863
+ // dashboard-rendered dialog within one event-loop tick. The arm has
864
+ // been removed; see change fix-multiselect-tui-arm-self-cancel for full
865
+ // rationale. A repo lint (no-tui-multiselect-arm-regression.test.ts)
866
+ // prevents reintroduction by banning the co-occurrence of two
867
+ // substrings (the captured original binding and the TUI arm match).
746
868
  };
747
869
 
748
870
  // Register TUI adapter — presents prompts in the terminal using original
@@ -771,6 +893,13 @@ function initBridge(pi: ExtensionAPI) {
771
893
  } else if (prompt.type === "editor" && originals.editor) {
772
894
  answer = await originals.editor(prompt.question, prompt.defaultValue || "", { signal: ac.signal });
773
895
  } else {
896
+ // NOTE: there is intentionally no `else if` arm for the
897
+ // multiselect prompt type here. See change
898
+ // fix-multiselect-tui-arm-self-cancel — pi 0.70 RPC mode's
899
+ // ctx.ui.custom primitive is a no-op, so any TUI arm that
900
+ // awaits it auto-cancels the dashboard-rendered dialog. The
901
+ // bus-routed ctx.ui.multiselect patch below + the
902
+ // DashboardDefaultAdapter handle multiselect end-to-end.
774
903
  return;
775
904
  }
776
905
 
@@ -821,22 +950,66 @@ function initBridge(pi: ExtensionAPI) {
821
950
  // now route through the bus, which distributes to all registered adapters.
822
951
  {
823
952
  const bus = promptBus;
953
+ // Build a `metadata` envelope for bus.request that includes both
954
+ // `message` (existing) and `toolCallId` (new — added by change
955
+ // `fix-interactive-ui-reorder` so the client reducer can pair the
956
+ // resulting interactiveUi row with its parent toolResult row).
957
+ // Free-floating callers (slash commands, architect prompts) omit
958
+ // `opts.toolCallId` and the metadata field stays undefined.
959
+ const buildMeta = (
960
+ opts: any,
961
+ explicitMessage?: string,
962
+ ): Record<string, unknown> | undefined => {
963
+ const message = explicitMessage ?? opts?.message;
964
+ const toolCallId = opts?.toolCallId;
965
+ if (!message && !toolCallId) return undefined;
966
+ const meta: Record<string, unknown> = {};
967
+ if (message) meta.message = message;
968
+ if (toolCallId) meta.toolCallId = toolCallId;
969
+ return meta;
970
+ };
971
+
824
972
  (ctx.ui as any).select = (title: string, options: string[], opts?: any) =>
825
- bus.request({ pipeline: "command", type: "select", question: title, options, metadata: opts?.message ? { message: opts.message } : undefined })
973
+ bus.request({ pipeline: "command", type: "select", question: title, options, metadata: buildMeta(opts) })
826
974
  .then(r => r.cancelled ? undefined : r.answer);
827
975
 
828
976
  (ctx.ui as any).input = (title: string, placeholder?: string, opts?: any) =>
829
- bus.request({ pipeline: "command", type: "input", question: title, defaultValue: placeholder, metadata: opts?.message ? { message: opts.message } : undefined })
977
+ bus.request({ pipeline: "command", type: "input", question: title, defaultValue: placeholder, metadata: buildMeta(opts) })
830
978
  .then(r => r.cancelled ? undefined : r.answer);
831
979
 
832
980
  (ctx.ui as any).confirm = (title: string, message?: string, opts?: any) =>
833
- bus.request({ pipeline: "command", type: "confirm", question: title, metadata: (message || opts?.message) ? { message: message || opts?.message } : undefined })
981
+ bus.request({ pipeline: "command", type: "confirm", question: title, metadata: buildMeta(opts, message) })
834
982
  .then(r => !r.cancelled && r.answer === "true");
835
983
 
836
984
  (ctx.ui as any).editor = (title: string, prefill?: string, opts?: any) =>
837
- bus.request({ pipeline: "command", type: "editor", question: title, defaultValue: prefill, metadata: opts?.message ? { message: opts.message } : undefined })
985
+ bus.request({ pipeline: "command", type: "editor", question: title, defaultValue: prefill, metadata: buildMeta(opts) })
838
986
  .then(r => r.cancelled ? undefined : r.answer);
839
987
 
988
+ // ── Multiselect ──────────────────────────────────────────────
989
+ // ctx.ui.multiselect is NOT a built-in pi method — we attach it here
990
+ // so that polyfillMultiselect (and any other consumer) routes through
991
+ // PromptBus. The dashboard adapter renders a real browser dialog via
992
+ // MultiselectRenderer; there is intentionally no TUI adapter arm for
993
+ // multiselect (pi 0.70 RPC mode's ctx.ui.custom is a no-op, so any TUI
994
+ // arm would auto-cancel the dashboard render in <1s). See changes
995
+ // fix-multiselect-auto-cancel-on-dashboard (initial bus routing) and
996
+ // fix-multiselect-tui-arm-self-cancel (TUI arm removal).
997
+ if (typeof (ctx.ui as any).multiselect === "function") {
998
+ // Defensive: future upstream pi may add a built-in multiselect.
999
+ // Override is intentional — the bus-routed version is what
1000
+ // participates in PromptBus first-response-wins semantics.
1001
+ // eslint-disable-next-line no-console
1002
+ console.warn("[bridge] ctx.ui.multiselect already exists — overriding for PromptBus routing");
1003
+ }
1004
+ (ctx.ui as any).multiselect = (title: string, options: string[], opts?: any) =>
1005
+ bus.request({
1006
+ pipeline: "command",
1007
+ type: "multiselect",
1008
+ question: title,
1009
+ options,
1010
+ metadata: opts?.message ? { message: opts.message } : undefined,
1011
+ }).then(decodeMultiselectAnswer);
1012
+
840
1013
  // Notify is fire-and-forget: call original + forward to dashboard
841
1014
  (ctx.ui as any).notify = (message: string, level?: string) => {
842
1015
  originalNotify?.(message, level);
@@ -1073,6 +1246,13 @@ function initBridge(pi: ExtensionAPI) {
1073
1246
 
1074
1247
  // Register flow event listeners (pi-flows emits these via pi.events)
1075
1248
  registerFlowEventListeners(syncBc(), () => sessionReady, getFlowsList);
1249
+
1250
+ // Extension UI System (Phase 1): subscribe to invalidate once per
1251
+ // session, then run the discovery probe. The probe is synchronous
1252
+ // and re-runs on every reconnect (see `onReconnect` callback above).
1253
+ // See change: add-extension-ui-modal.
1254
+ subscribeUiInvalidate(uiModulesBridgeCtx);
1255
+ refreshUiModules(uiModulesBridgeCtx);
1076
1256
  }));
1077
1257
 
1078
1258
  // Shared handler for session changes (new/fork/resume)
@@ -0,0 +1,40 @@
1
+ /**
2
+ * decodeMultiselectAnswer — pure helper that turns a `PromptResponse`
3
+ * (from PromptBus) into the `string[] | undefined` shape expected by
4
+ * `polyfillMultiselect` and other multiselect callers.
5
+ *
6
+ * Contract:
7
+ * • cancelled: true → undefined
8
+ * • cancelled: false, answer: undefined / null / "" → [] (empty selection
9
+ * is a real answer,
10
+ * distinct from
11
+ * cancellation)
12
+ * • cancelled: false, answer: '["a","b"]' → ["a","b"]
13
+ * • cancelled: false, answer: <unparseable> → [] (graceful
14
+ * degradation,
15
+ * never throw)
16
+ *
17
+ * Kept separate from `bridge.ts` so unit tests can exercise it without
18
+ * instantiating a live PromptBus or session context.
19
+ *
20
+ * See change: fix-multiselect-auto-cancel-on-dashboard.
21
+ */
22
+
23
+ export interface DecodableResponse {
24
+ cancelled?: boolean;
25
+ answer?: string | undefined;
26
+ }
27
+
28
+ export function decodeMultiselectAnswer(
29
+ response: DecodableResponse,
30
+ ): string[] | undefined {
31
+ if (response.cancelled) return undefined;
32
+ const answer = response.answer;
33
+ if (answer == null || answer === "") return [];
34
+ try {
35
+ const parsed = JSON.parse(answer);
36
+ return Array.isArray(parsed) ? (parsed as string[]) : [];
37
+ } catch {
38
+ return [];
39
+ }
40
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * MultiSelectList — a TUI multi-select component implementing pi-tui's
3
+ * `Component` interface. Used by `polyfillMultiselect` to emulate the
4
+ * `ctx.ui.multiselect(...)` call that `pi-coding-agent`'s `ExtensionUIContext`
5
+ * does not expose natively.
6
+ *
7
+ * Keyboard contract (intentional — no "select all" binding in TUI):
8
+ * ↑ / k move cursor up
9
+ * ↓ / j move cursor down
10
+ * space toggle the checked state of the current item
11
+ * enter confirm → onConfirm(selected[])
12
+ * esc cancel → onCancel()
13
+ *
14
+ * The selected array preserves the original option order, not toggle order.
15
+ */
16
+
17
+ interface Item {
18
+ value: string;
19
+ label: string;
20
+ description?: string;
21
+ checked: boolean;
22
+ }
23
+
24
+ /**
25
+ * Minimal shape of pi-tui's `Component` interface — we avoid importing from
26
+ * `@mariozechner/pi-tui` directly so this module stays compile-friendly when
27
+ * that peer dep isn't present (e.g. in unit tests running via vitest without
28
+ * the full pi runtime).
29
+ */
30
+ export interface ComponentLike {
31
+ render(width: number): string[];
32
+ handleInput?(data: string): void;
33
+ }
34
+
35
+ const CURSOR = "▸ ";
36
+ const NO_CURSOR = " ";
37
+ const CHECKED = "[x]";
38
+ const UNCHECKED = "[ ]";
39
+ const FOOTER_HINT = "space toggle · enter confirm · esc cancel";
40
+
41
+ const MAX_VISIBLE = 10;
42
+
43
+ function truncate(text: string, maxWidth: number): string {
44
+ if (maxWidth <= 1) return "";
45
+ if (text.length <= maxWidth) return text;
46
+ if (maxWidth <= 1) return "…";
47
+ return text.slice(0, Math.max(0, maxWidth - 1)) + "…";
48
+ }
49
+
50
+ export class MultiSelectList implements ComponentLike {
51
+ private items: Item[];
52
+ private cursor = 0;
53
+ private scrollOffset = 0;
54
+
55
+ onConfirm?: (selectedValues: string[]) => void;
56
+ onCancel?: () => void;
57
+
58
+ constructor(
59
+ private title: string,
60
+ options: string[],
61
+ private message?: string,
62
+ ) {
63
+ this.items = options.map((opt) => ({
64
+ value: opt,
65
+ label: opt,
66
+ checked: false,
67
+ }));
68
+ }
69
+
70
+ /** Expose current state for testing / adapters. */
71
+ getItems(): readonly Item[] {
72
+ return this.items;
73
+ }
74
+ getCursor(): number {
75
+ return this.cursor;
76
+ }
77
+
78
+ /** Return values of currently checked items in original option order. */
79
+ private selectedValues(): string[] {
80
+ return this.items.filter((it) => it.checked).map((it) => it.value);
81
+ }
82
+
83
+ render(width: number): string[] {
84
+ const lines: string[] = [];
85
+ if (this.title) lines.push(truncate(this.title, width));
86
+ if (this.message) lines.push(truncate(this.message, width));
87
+ if (lines.length > 0) lines.push("");
88
+
89
+ // Scroll window around cursor.
90
+ const visible = Math.min(MAX_VISIBLE, this.items.length);
91
+ if (this.cursor < this.scrollOffset) {
92
+ this.scrollOffset = this.cursor;
93
+ } else if (this.cursor >= this.scrollOffset + visible) {
94
+ this.scrollOffset = this.cursor - visible + 1;
95
+ }
96
+
97
+ for (let i = 0; i < visible; i++) {
98
+ const idx = this.scrollOffset + i;
99
+ const item = this.items[idx];
100
+ if (!item) break;
101
+ const marker = idx === this.cursor ? CURSOR : NO_CURSOR;
102
+ const box = item.checked ? CHECKED : UNCHECKED;
103
+ let line = `${marker}${box} ${item.label}`;
104
+ if (item.description) line += ` — ${item.description}`;
105
+ lines.push(truncate(line, width));
106
+ }
107
+
108
+ if (this.items.length > visible) {
109
+ lines.push(` (${this.cursor + 1}/${this.items.length})`);
110
+ }
111
+
112
+ lines.push("");
113
+ lines.push(truncate(FOOTER_HINT, width));
114
+ return lines;
115
+ }
116
+
117
+ handleInput(data: string): void {
118
+ // Escape
119
+ if (data === "\u001b" || data === "\x1b") {
120
+ this.onCancel?.();
121
+ return;
122
+ }
123
+ // Enter (CR or LF)
124
+ if (data === "\r" || data === "\n") {
125
+ this.onConfirm?.(this.selectedValues());
126
+ return;
127
+ }
128
+ // Space — toggle current
129
+ if (data === " ") {
130
+ const item = this.items[this.cursor];
131
+ if (item) item.checked = !item.checked;
132
+ return;
133
+ }
134
+ // Arrow up / k
135
+ if (data === "\u001b[A" || data === "k") {
136
+ if (this.cursor > 0) this.cursor--;
137
+ return;
138
+ }
139
+ // Arrow down / j
140
+ if (data === "\u001b[B" || data === "j") {
141
+ if (this.cursor < this.items.length - 1) this.cursor++;
142
+ return;
143
+ }
144
+ // Everything else (including "a", "A", bulk-toggle attempts) is a no-op.
145
+ }
146
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Polyfill for `ctx.ui.multiselect(...)` — a method the dashboard bridge's
3
+ * `ask_user` tool advertises but which `pi-coding-agent`'s
4
+ * `ExtensionUIContext` does not expose. Without this, any TUI dispatch of
5
+ * `method: "multiselect"` crashes with `"ctx.ui.multiselect is not a function"`.
6
+ *
7
+ * Fallback chain (introduced by change fix-multiselect-auto-cancel-on-dashboard):
8
+ *
9
+ * 1. PRIMARY — bridge-patched `ctx.ui.multiselect` (PromptBus path).
10
+ * The bridge attaches this method on session_start so the dashboard
11
+ * browser renders a real `MultiselectRenderer` dialog and the TUI
12
+ * adapter renders a `MultiSelectList` overlay in the terminal.
13
+ *
14
+ * 2. FALLBACK — legacy `ctx.ui.custom` + `MultiSelectList` overlay.
15
+ * Reached when (a) running against an older pi without the bridge
16
+ * patch, (b) running outside the bridge entirely, or (c) the bridge
17
+ * patch was removed for some reason. TUI-only — does NOT render a
18
+ * browser dialog in dashboard / RPC mode (which is exactly the bug
19
+ * that motivated the primary path).
20
+ *
21
+ * The result contract is unchanged in either branch:
22
+ * - resolves to `string[]` when the user confirms a selection
23
+ * (possibly empty if nothing is checked)
24
+ * - resolves to `undefined` when the user cancels (Escape / Cancel)
25
+ *
26
+ * See change: fix-multiselect-auto-cancel-on-dashboard.
27
+ */
28
+ import { MultiSelectList } from "./multiselect-list.js";
29
+
30
+ // Intentionally loose: `ctx` shape varies slightly across pi versions; the
31
+ // polyfill only needs `ctx.ui.multiselect` (primary) or `ctx.ui.custom` (fallback).
32
+ export interface PolyfillCtx {
33
+ ui: {
34
+ multiselect?: (
35
+ title: string,
36
+ options: string[],
37
+ opts?: { message?: string },
38
+ ) => Promise<string[] | undefined>;
39
+ custom<T>(
40
+ factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (result: T) => void) => unknown,
41
+ options?: unknown,
42
+ ): Promise<T>;
43
+ };
44
+ }
45
+
46
+ export function polyfillMultiselect(
47
+ ctx: PolyfillCtx,
48
+ title: string,
49
+ options: string[],
50
+ opts?: { message?: string },
51
+ ): Promise<string[] | undefined> {
52
+ // Primary path: delegate to the bridge-patched ctx.ui.multiselect (which
53
+ // routes through PromptBus → DashboardDefaultAdapter → client
54
+ // MultiselectRenderer). This is the only working path on pi 0.70 RPC mode
55
+ // (dashboard headless sessions).
56
+ const ui = ctx.ui as PolyfillCtx["ui"] & { multiselect?: Function };
57
+ if (typeof ui.multiselect === "function") {
58
+ return Promise.resolve(ui.multiselect(title, options, opts));
59
+ }
60
+
61
+ // Legacy fallback: TUI overlay via ctx.ui.custom. Used when the bridge
62
+ // patch is absent (older pi / non-bridge embedding) OR a future pi version
63
+ // restores ctx.ui.custom in RPC mode. NOTE: in pi 0.70 RPC mode
64
+ // ctx.ui.custom is a no-op that resolves to undefined synchronously, so
65
+ // this branch returns undefined immediately on dashboard headless
66
+ // sessions — the primary path above is the only effective route there.
67
+ return ctx.ui.custom<string[] | undefined>((_tui, _theme, _keybindings, done) => {
68
+ const list = new MultiSelectList(title, options, opts?.message);
69
+ list.onConfirm = (selected) => done(selected);
70
+ list.onCancel = () => done(undefined);
71
+ return list as unknown;
72
+ });
73
+ }
@@ -11,6 +11,7 @@ import { createRequire } from "node:module";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import type { DashboardConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
13
13
  import { resolveJitiImport } from "@blackbelt-technology/pi-dashboard-shared/resolve-jiti.js";
14
+ import { toFileUrl, shouldUrlWrapEntry } from "@blackbelt-technology/pi-dashboard-shared/platform/node-spawn.js";
14
15
  import { isDashboardRunning } from "@blackbelt-technology/pi-dashboard-shared/server-identity.js";
15
16
 
16
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -77,11 +78,22 @@ export async function launchServer(config: DashboardConfig): Promise<LaunchResul
77
78
  );
78
79
  } catch { /* if we can't open the log, spawn still works */ }
79
80
 
80
- // Spawn server via the detached-spawn primitive. resolveJitiImport()
81
- // returns a file:// URL (required on Windows for node --import).
81
+ // Spawn server via the detached-spawn primitive. The loader is always
82
+ // URL-wrapped (Node needs file:// for --import on Windows drive letters).
83
+ // The entry is URL-wrapped only on Windows + non-tsx loader (Node parses
84
+ // drive letters as URL schemes in argv); on POSIX the entry MUST be raw
85
+ // because jiti's resolver misbehaves on file:// URL entries. See
86
+ // openspec/changes/archive/2026-04-24-fix-windows-entry-script-url.
87
+ const loader = resolveJitiImport();
88
+ const wrapEntry = shouldUrlWrapEntry(loader);
89
+ // entry is gated by shouldUrlWrapEntry(loader): returns true only on
90
+ // Windows + non-tsx (where URL wrap is required); false on POSIX
91
+ // where jiti needs the raw path (file:// URL entries trigger jiti's
92
+ // `<cwd>/file:/...` misresolution bug).
93
+ const entry = wrapEntry ? toFileUrl(cliPath) : cliPath;
82
94
  const r = await spawnDetached({
83
95
  cmd: process.execPath,
84
- args: ["--import", resolveJitiImport(), cliPath, ...args],
96
+ args: ["--import", loader, entry, ...args], // ban:raw-node-import-ok: entry gated by shouldUrlWrapEntry
85
97
  env: { ...process.env },
86
98
  logFd,
87
99
  });