@blackbelt-technology/pi-agent-dashboard 0.4.1 → 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 (108) hide show
  1. package/AGENTS.md +79 -32
  2. package/README.md +7 -3
  3. package/docs/architecture.md +361 -12
  4. package/package.json +7 -7
  5. package/packages/extension/package.json +7 -2
  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 +51 -7
  8. package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
  9. package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
  10. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
  11. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
  12. package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
  13. package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
  14. package/packages/extension/src/ask-user-tool.ts +165 -57
  15. package/packages/extension/src/bridge.ts +97 -4
  16. package/packages/extension/src/multiselect-decode.ts +40 -0
  17. package/packages/extension/src/multiselect-polyfill.ts +38 -8
  18. package/packages/extension/src/ui-modules.ts +272 -0
  19. package/packages/server/package.json +9 -3
  20. package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
  21. package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
  22. package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
  23. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
  24. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
  25. package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
  26. package/packages/server/src/__tests__/directory-service.test.ts +174 -0
  27. package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
  28. package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
  29. package/packages/server/src/__tests__/package-routes.test.ts +136 -3
  30. package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
  31. package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
  32. package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
  33. package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
  34. package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
  35. package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
  36. package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
  37. package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
  38. package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
  39. package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
  40. package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
  41. package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
  42. package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
  43. package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
  44. package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
  45. package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
  46. package/packages/server/src/browse.ts +118 -13
  47. package/packages/server/src/browser-gateway.ts +19 -0
  48. package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
  49. package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
  50. package/packages/server/src/browser-handlers/handler-context.ts +15 -0
  51. package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
  52. package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
  53. package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
  54. package/packages/server/src/cli.ts +5 -6
  55. package/packages/server/src/directory-service.ts +156 -15
  56. package/packages/server/src/event-wiring.ts +111 -10
  57. package/packages/server/src/installed-package-enricher.ts +143 -0
  58. package/packages/server/src/package-manager-wrapper.ts +305 -8
  59. package/packages/server/src/package-source-helpers.ts +104 -0
  60. package/packages/server/src/pending-attach-registry.ts +112 -0
  61. package/packages/server/src/pending-resume-intent-registry.ts +107 -0
  62. package/packages/server/src/pi-core-checker.ts +9 -14
  63. package/packages/server/src/pi-gateway.ts +14 -0
  64. package/packages/server/src/proposal-attach-naming.ts +47 -0
  65. package/packages/server/src/routes/file-routes.ts +29 -3
  66. package/packages/server/src/routes/package-routes.ts +72 -3
  67. package/packages/server/src/routes/plugin-config-routes.ts +129 -0
  68. package/packages/server/src/routes/system-routes.ts +2 -0
  69. package/packages/server/src/server.ts +339 -10
  70. package/packages/server/src/session-api.ts +30 -5
  71. package/packages/server/src/session-order-manager.ts +22 -0
  72. package/packages/server/src/session-scanner.ts +10 -1
  73. package/packages/shared/package.json +9 -2
  74. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
  75. package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
  76. package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
  77. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
  78. package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
  79. package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
  80. package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
  81. package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
  82. package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
  83. package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
  84. package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
  85. package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
  86. package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
  87. package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
  88. package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
  89. package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
  90. package/packages/shared/src/browser-protocol.ts +110 -4
  91. package/packages/shared/src/config.ts +45 -0
  92. package/packages/shared/src/dashboard-plugin/index.ts +11 -0
  93. package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
  94. package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
  95. package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
  96. package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
  97. package/packages/shared/src/openspec-activity-detector.ts +18 -22
  98. package/packages/shared/src/openspec-design-evidence.ts +109 -0
  99. package/packages/shared/src/openspec-poller.ts +117 -3
  100. package/packages/shared/src/openspec-specs-evidence.ts +79 -0
  101. package/packages/shared/src/platform/binary-lookup.ts +96 -1
  102. package/packages/shared/src/plugin-bridge-register.ts +139 -0
  103. package/packages/shared/src/protocol.ts +56 -2
  104. package/packages/shared/src/recommended-extensions.ts +7 -1
  105. package/packages/shared/src/rest-api.ts +68 -3
  106. package/packages/shared/src/state-replay.ts +11 -1
  107. package/packages/shared/src/tool-registry/strategies.ts +17 -3
  108. package/packages/shared/src/types.ts +160 -0
@@ -10,58 +10,89 @@ import { Type } from "typebox";
10
10
  import { polyfillMultiselect } from "./multiselect-polyfill.js";
11
11
 
12
12
  // ──────────────────────────────────────────────────────────────────────────
13
- // Single-question schema arms (reused inside the batch arm's questions array)
13
+ // Schema definition
14
+ //
15
+ // IMPORTANT: We use a single flat `Type.Object` at the root (rather than a
16
+ // `Type.Union` of per-method object arms) so the generated JSON Schema has
17
+ // `"type": "object"` at the root.
18
+ //
19
+ // Rationale: OpenAI's function-calling validator (and especially the strict
20
+ // mode used by GPT-4.1+/GPT-5.x/Codex/Responses API) REQUIRES the parameters
21
+ // schema to be an object at the root. A `Type.Union` produces `anyOf` at the
22
+ // root with no `type` field, which Anthropic accepts but OpenAI rejects with:
23
+ // "Invalid schema for function 'ask_user': schema must be a JSON Schema
24
+ // of 'type: \"object\"', got 'type: \"None\"'."
25
+ //
26
+ // Per-method validation (which fields are required for which `method`) is
27
+ // enforced at runtime by `prepareArguments` (rescue/normalization) and the
28
+ // `execute` switch below — the JSON Schema only needs to describe the union
29
+ // of allowed fields.
14
30
  // ──────────────────────────────────────────────────────────────────────────
15
31
 
16
- const ConfirmSchema = Type.Object({
17
- method: Type.Literal("confirm", { description: "Yes/no question" }),
18
- title: Type.String({ description: "The question to confirm" }),
19
- message: Type.Optional(Type.String({ description: "Additional context or detailed question body" })),
20
- });
21
-
22
- const SelectSchema = Type.Object({
23
- method: Type.Literal("select", { description: "Pick one option from a list" }),
24
- title: Type.String({ description: "Short title for the question" }),
25
- options: Type.Array(Type.String(), {
26
- minItems: 2,
27
- description: "Options the user chooses between (at least 2; use 'confirm' for yes/no)",
28
- }),
29
- message: Type.Optional(Type.String({ description: "Additional context" })),
30
- });
31
-
32
- const MultiselectSchema = Type.Object({
33
- method: Type.Literal("multiselect", { description: "Pick multiple options from a list" }),
34
- title: Type.String({ description: "Short title for the question" }),
35
- options: Type.Array(Type.String(), {
36
- minItems: 1,
37
- description: "Options the user can multi-select",
38
- }),
39
- message: Type.Optional(Type.String({ description: "Additional context" })),
40
- });
41
-
42
- const InputSchema = Type.Object({
43
- method: Type.Literal("input", { description: "Free-text input" }),
44
- title: Type.String({ description: "Short title for the question" }),
45
- placeholder: Type.Optional(Type.String({ description: "Placeholder text for the input field" })),
46
- message: Type.Optional(Type.String({ description: "Additional context" })),
47
- });
48
-
49
- // Sub-question union deliberately omits the batch arm (no nesting).
50
- const SubQuestionSchema = Type.Union([ConfirmSchema, SelectSchema, MultiselectSchema, InputSchema], {
51
- description: "A single question inside a batch. Must not itself be a batch.",
52
- });
53
-
54
- const BatchSchema = Type.Object({
55
- method: Type.Literal("batch", {
56
- description: "Ask multiple related questions in one call; answers are returned as an ordered array.",
57
- }),
58
- title: Type.String({ description: "Header shown above the sequence of dialogs" }),
59
- questions: Type.Array(SubQuestionSchema, {
60
- minItems: 1,
61
- description: "One or more sub-questions (confirm/select/multiselect/input). Cannot nest batch.",
62
- }),
63
- message: Type.Optional(Type.String({ description: "Additional context for the whole batch" })),
64
- });
32
+ const MethodEnum = Type.Union(
33
+ [
34
+ Type.Literal("confirm"),
35
+ Type.Literal("select"),
36
+ Type.Literal("multiselect"),
37
+ Type.Literal("input"),
38
+ Type.Literal("batch"),
39
+ ],
40
+ {
41
+ description:
42
+ "Question kind. 'confirm' = yes/no, 'select' = pick one of options[], 'multiselect' = pick many of options[], 'input' = free text, 'batch' = ask several questions in one call (provide questions[]).",
43
+ },
44
+ );
45
+
46
+ // Sub-question schema for batch.method — flat object (root: type=object) so
47
+ // the emitted JSON Schema stays OpenAI-compatible at every level.
48
+ //
49
+ // IMPORTANT: this object MUST NOT carry a root-level `oneOf` / `anyOf` /
50
+ // `allOf` / `enum` / `not`. OpenAI strict mode (GPT-4.1+, GPT-5.x, Codex,
51
+ // Responses API) explicitly rejects those at *any* schema's top level
52
+ // with: "schema must have type 'object' and not have 'oneOf' / 'anyOf' /
53
+ // 'allOf' / 'enum' / 'not' at the top level." An earlier draft of
54
+ // fix-multiselect-auto-cancel-on-dashboard tried to add a body-level
55
+ // `oneOf` discriminator to restore Anthropic's per-arm strictness, but
56
+ // real-world OpenAI gpt-5 rejected it; the fallback path documented in
57
+ // tasks.md §9.7 was taken — Layer 2 dropped, Layer 1 ships alone.
58
+ //
59
+ // Per-method requirements (select/multiselect need `options`, batch
60
+ // needs `questions[]`, etc.) are enforced exclusively by
61
+ // `prepareArguments` rescue + the `execute` switch's runtime guards.
62
+ // Sub-questions cannot themselves be a batch (no nesting); enforced at
63
+ // runtime in `execute`.
64
+ //
65
+ // See change: fix-multiselect-auto-cancel-on-dashboard.
66
+ const SubQuestionSchema = Type.Object(
67
+ {
68
+ method: Type.Union(
69
+ [
70
+ Type.Literal("confirm"),
71
+ Type.Literal("select"),
72
+ Type.Literal("multiselect"),
73
+ Type.Literal("input"),
74
+ ],
75
+ { description: "Sub-question kind. Cannot be 'batch' (no nesting)." },
76
+ ),
77
+ title: Type.String({ description: "Short title / question text for this sub-question" }),
78
+ options: Type.Optional(
79
+ Type.Array(Type.String(), {
80
+ description:
81
+ "Required for 'select' (>=2) and 'multiselect' (>=1). Plain string[] — not [{label,value}].",
82
+ }),
83
+ ),
84
+ placeholder: Type.Optional(
85
+ Type.String({ description: "Placeholder for 'input' method" }),
86
+ ),
87
+ message: Type.Optional(
88
+ Type.String({ description: "Additional context for this sub-question" }),
89
+ ),
90
+ },
91
+ {
92
+ description:
93
+ "A single question inside a batch. Must not itself be a batch.",
94
+ },
95
+ );
65
96
 
66
97
  // ──────────────────────────────────────────────────────────────────────────
67
98
  // Argument rescue helpers
@@ -132,9 +163,67 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
132
163
  "Do not nest batches. Send `options` as a plain string[] — not [{label, value}].",
133
164
  "This applies to all workflows including OpenSpec, planning, and any situation where you need user input before proceeding.",
134
165
  ],
135
- parameters: Type.Union(
136
- [ConfirmSchema, SelectSchema, MultiselectSchema, InputSchema, BatchSchema],
137
- { description: "Parameters for ask_user, discriminated by method." },
166
+ // Flat object schema (root: type=object) for OpenAI strict-mode
167
+ // compatibility.
168
+ //
169
+ // IMPORTANT: this object MUST NOT carry a root-level `oneOf` / `anyOf`
170
+ // / `allOf` / `enum` / `not`. OpenAI strict mode (GPT-4.1+, GPT-5.x,
171
+ // Codex, Responses API) explicitly rejects those at the top level with:
172
+ // "schema must have type 'object' and not have 'oneOf' / 'anyOf' /
173
+ // 'allOf' / 'enum' / 'not' at the top level."
174
+ //
175
+ // An earlier iteration of fix-multiselect-auto-cancel-on-dashboard
176
+ // ("Layer 2: defense in depth") tried adding a body-level `oneOf`
177
+ // discriminator over `method` so Anthropic would regain per-arm
178
+ // `required` + `minItems` enforcement. That worked for Anthropic
179
+ // models but real-world OpenAI gpt-5 rejected the schema (verified by
180
+ // the user 2026-04-30). The fallback documented in tasks.md §9.7 was
181
+ // taken: Layer 2 was dropped; Layer 1 (multiselect dashboard routing)
182
+ // ships alone, which is what actually fixes the user-reported bug.
183
+ //
184
+ // Per-method shape requirements (select/multiselect need `options`,
185
+ // batch needs `questions[]`, etc.) are enforced exclusively at runtime
186
+ // by `prepareArguments` (rescue/normalization) and the `execute` switch.
187
+ //
188
+ // The `no-root-oneof-in-ask-user-schema` guard test at
189
+ // packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts
190
+ // pins this constraint so a future refactor cannot reintroduce it.
191
+ //
192
+ // See change: fix-multiselect-auto-cancel-on-dashboard.
193
+ parameters: Type.Object(
194
+ {
195
+ method: MethodEnum,
196
+ title: Type.Optional(
197
+ Type.String({
198
+ description:
199
+ "Short title / question text. Required for all methods except when 'questions' carry it (batch may omit and inherit from first sub-question).",
200
+ }),
201
+ ),
202
+ message: Type.Optional(
203
+ Type.String({ description: "Additional context shown alongside the question(s)." }),
204
+ ),
205
+ options: Type.Optional(
206
+ Type.Array(Type.String(), {
207
+ description:
208
+ "Required for method 'select' (>=2 items) and 'multiselect' (>=1 item). Plain string[] — not [{label,value}]. Ignored for other methods.",
209
+ }),
210
+ ),
211
+ placeholder: Type.Optional(
212
+ Type.String({
213
+ description: "Placeholder for method 'input'. Ignored for other methods.",
214
+ }),
215
+ ),
216
+ questions: Type.Optional(
217
+ Type.Array(SubQuestionSchema, {
218
+ description:
219
+ "Required for method 'batch' (>=1 sub-question). Each sub-question is its own confirm/select/multiselect/input — cannot nest 'batch'.",
220
+ }),
221
+ ),
222
+ },
223
+ {
224
+ description:
225
+ "Parameters for ask_user. The required fields depend on `method`: confirm→title; select→title+options(>=2); multiselect→title+options(>=1); input→title (placeholder optional); batch→questions[] (title auto-derived from first question if omitted). Validation is enforced at runtime by prepareArguments + execute (no schema-level discriminator — OpenAI strict mode forbids root-level oneOf).",
226
+ },
138
227
  ),
139
228
  prepareArguments(args: unknown) {
140
229
  let obj = (args && typeof args === "object" ? { ...(args as Record<string, unknown>) } : {}) as Record<string, unknown>;
@@ -223,6 +312,21 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
223
312
  return obj as any;
224
313
  },
225
314
  async execute(_toolCallId: any, params: any, _signal: any, _onUpdate: any, ctx: any) {
315
+ // Capture the originating toolCallId so the resulting prompt_request
316
+ // metadata carries it; the client reducer pairs the interactiveUi
317
+ // row with its parent toolResult row using this id.
318
+ // See change: fix-interactive-ui-reorder.
319
+ const toolCallId: string | undefined =
320
+ typeof _toolCallId === "string" && _toolCallId.length > 0
321
+ ? _toolCallId
322
+ : undefined;
323
+ const withTcid = (
324
+ opts: Record<string, unknown> | undefined,
325
+ ): Record<string, unknown> | undefined => {
326
+ if (!toolCallId) return opts;
327
+ return { ...(opts ?? {}), toolCallId };
328
+ };
329
+
226
330
  // ── Batch branch ─────────────────────────────────────────────────
227
331
  if (params.method === "batch" && Array.isArray(params.questions)) {
228
332
  const results: Array<unknown> = [];
@@ -230,13 +334,17 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
230
334
 
231
335
  for (const sq of params.questions) {
232
336
  const subTitle = `${params.title} — ${sq.title ?? "Question"}`;
233
- const subMsg = params.message ? { message: params.message } : undefined;
337
+ const subMsg = withTcid(params.message ? { message: params.message } : undefined);
234
338
 
235
339
  let answer: unknown;
236
340
  try {
237
341
  switch (sq.method) {
238
342
  case "confirm":
239
- answer = await ctx.ui.confirm(subTitle, sq.message ?? params.message ?? "");
343
+ answer = await ctx.ui.confirm(
344
+ subTitle,
345
+ sq.message ?? params.message ?? "",
346
+ withTcid(undefined),
347
+ );
240
348
  break;
241
349
  case "select": {
242
350
  const opts = Array.isArray(sq.options) ? sq.options : [];
@@ -312,7 +420,7 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
312
420
 
313
421
  // ── Single-question branches (unchanged behavior) ────────────────
314
422
  let result: unknown;
315
- const msgOpts = params.message ? { message: params.message } : undefined;
423
+ const msgOpts = withTcid(params.message ? { message: params.message } : undefined);
316
424
  const title = params.title || params.message || "Question";
317
425
 
318
426
  const options: string[] = Array.isArray(params.options)
@@ -331,7 +439,7 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
331
439
 
332
440
  switch (params.method) {
333
441
  case "confirm":
334
- result = await ctx.ui.confirm(title, params.message ?? "");
442
+ result = await ctx.ui.confirm(title, params.message ?? "", withTcid(undefined));
335
443
  break;
336
444
  case "select":
337
445
  result = await ctx.ui.select(title, options, msgOpts);
@@ -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;
@@ -263,11 +265,29 @@ function initBridge(pi: ExtensionAPI) {
263
265
  const config = loadConfig();
264
266
  const dashboardUrl = process.env.PI_DASHBOARD_URL ?? `ws://localhost:${config.piPort}`;
265
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
+
266
278
  const connection = new ConnectionManager({
267
279
  url: dashboardUrl,
268
280
  onMessage: safe(async (data: unknown) => {
269
281
  if (!isActive()) return; // Stale listener guard
270
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
+ }
271
291
  // Legacy extension_ui_response removed — now handled by prompt_response → promptBus.respond()
272
292
  // Reload auth credentials when dashboard notifies of changes
273
293
  if (msg.type === "credentials_updated") {
@@ -463,6 +483,11 @@ function initBridge(pi: ExtensionAPI) {
463
483
  if (getBridgeState().isAgentStreaming) {
464
484
  connection.send(mapEventToProtocol(sessionId, { type: "agent_start" }));
465
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);
466
491
  }),
467
492
  });
468
493
 
@@ -830,6 +855,16 @@ function initBridge(pi: ExtensionAPI) {
830
855
  input: ctx.ui.input?.bind(ctx.ui) as ((q: string, placeholder?: string, extra?: any) => Promise<string | undefined>) | undefined,
831
856
  confirm: ctx.ui.confirm?.bind(ctx.ui) as ((q: string, msg: string, extra?: any) => Promise<boolean>) | undefined,
832
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).
833
868
  };
834
869
 
835
870
  // Register TUI adapter — presents prompts in the terminal using original
@@ -858,6 +893,13 @@ function initBridge(pi: ExtensionAPI) {
858
893
  } else if (prompt.type === "editor" && originals.editor) {
859
894
  answer = await originals.editor(prompt.question, prompt.defaultValue || "", { signal: ac.signal });
860
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.
861
903
  return;
862
904
  }
863
905
 
@@ -908,22 +950,66 @@ function initBridge(pi: ExtensionAPI) {
908
950
  // now route through the bus, which distributes to all registered adapters.
909
951
  {
910
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
+
911
972
  (ctx.ui as any).select = (title: string, options: string[], opts?: any) =>
912
- 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) })
913
974
  .then(r => r.cancelled ? undefined : r.answer);
914
975
 
915
976
  (ctx.ui as any).input = (title: string, placeholder?: string, opts?: any) =>
916
- 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) })
917
978
  .then(r => r.cancelled ? undefined : r.answer);
918
979
 
919
980
  (ctx.ui as any).confirm = (title: string, message?: string, opts?: any) =>
920
- 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) })
921
982
  .then(r => !r.cancelled && r.answer === "true");
922
983
 
923
984
  (ctx.ui as any).editor = (title: string, prefill?: string, opts?: any) =>
924
- 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) })
925
986
  .then(r => r.cancelled ? undefined : r.answer);
926
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
+
927
1013
  // Notify is fire-and-forget: call original + forward to dashboard
928
1014
  (ctx.ui as any).notify = (message: string, level?: string) => {
929
1015
  originalNotify?.(message, level);
@@ -1160,6 +1246,13 @@ function initBridge(pi: ExtensionAPI) {
1160
1246
 
1161
1247
  // Register flow event listeners (pi-flows emits these via pi.events)
1162
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);
1163
1256
  }));
1164
1257
 
1165
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
+ }
@@ -4,23 +4,38 @@
4
4
  * `ExtensionUIContext` does not expose. Without this, any TUI dispatch of
5
5
  * `method: "multiselect"` crashes with `"ctx.ui.multiselect is not a function"`.
6
6
  *
7
- * Implementation strategy: always delegate to the already-exposed
8
- * `ctx.ui.custom<T>()` primitive, which takes a factory that returns a
9
- * focused pi-tui `Component`. We instantiate a `MultiSelectList`, wire
10
- * `onConfirm` → `done(selected)` and `onCancel` → `done(undefined)`, and
11
- * return the component.
7
+ * Fallback chain (introduced by change fix-multiselect-auto-cancel-on-dashboard):
12
8
  *
13
- * The result contract matches what the current (broken) call expects:
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:
14
22
  * - resolves to `string[]` when the user confirms a selection
15
23
  * (possibly empty if nothing is checked)
16
- * - resolves to `undefined` when the user cancels (Escape)
24
+ * - resolves to `undefined` when the user cancels (Escape / Cancel)
25
+ *
26
+ * See change: fix-multiselect-auto-cancel-on-dashboard.
17
27
  */
18
28
  import { MultiSelectList } from "./multiselect-list.js";
19
29
 
20
30
  // Intentionally loose: `ctx` shape varies slightly across pi versions; the
21
- // polyfill only needs `ctx.ui.custom`.
31
+ // polyfill only needs `ctx.ui.multiselect` (primary) or `ctx.ui.custom` (fallback).
22
32
  export interface PolyfillCtx {
23
33
  ui: {
34
+ multiselect?: (
35
+ title: string,
36
+ options: string[],
37
+ opts?: { message?: string },
38
+ ) => Promise<string[] | undefined>;
24
39
  custom<T>(
25
40
  factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (result: T) => void) => unknown,
26
41
  options?: unknown,
@@ -34,6 +49,21 @@ export function polyfillMultiselect(
34
49
  options: string[],
35
50
  opts?: { message?: string },
36
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.
37
67
  return ctx.ui.custom<string[] | undefined>((_tui, _theme, _keybindings, done) => {
38
68
  const list = new MultiSelectList(title, options, opts?.message);
39
69
  list.onConfirm = (selected) => done(selected);