@blackbelt-technology/pi-agent-dashboard 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +80 -32
- package/README.md +7 -3
- package/docs/architecture.md +361 -12
- package/package.json +7 -7
- package/packages/extension/package.json +7 -2
- package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +51 -7
- package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
- package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
- package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
- package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
- package/packages/extension/src/ask-user-tool.ts +165 -57
- package/packages/extension/src/bridge.ts +97 -4
- package/packages/extension/src/multiselect-decode.ts +40 -0
- package/packages/extension/src/multiselect-polyfill.ts +38 -8
- package/packages/extension/src/ui-modules.ts +272 -0
- package/packages/server/package.json +9 -3
- package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
- package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
- package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
- package/packages/server/src/__tests__/directory-service.test.ts +174 -0
- package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
- package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
- package/packages/server/src/__tests__/package-routes.test.ts +136 -3
- package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
- package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
- package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
- package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
- package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
- package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
- package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
- package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
- package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
- package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
- package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
- package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
- package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
- package/packages/server/src/browse.ts +118 -13
- package/packages/server/src/browser-gateway.ts +19 -0
- package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
- package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
- package/packages/server/src/browser-handlers/handler-context.ts +15 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
- package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
- package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
- package/packages/server/src/cli.ts +5 -6
- package/packages/server/src/directory-service.ts +156 -15
- package/packages/server/src/event-wiring.ts +111 -10
- package/packages/server/src/installed-package-enricher.ts +143 -0
- package/packages/server/src/package-manager-wrapper.ts +305 -8
- package/packages/server/src/package-source-helpers.ts +104 -0
- package/packages/server/src/pending-attach-registry.ts +112 -0
- package/packages/server/src/pending-resume-intent-registry.ts +107 -0
- package/packages/server/src/pi-core-checker.ts +9 -14
- package/packages/server/src/pi-gateway.ts +14 -0
- package/packages/server/src/proposal-attach-naming.ts +47 -0
- package/packages/server/src/routes/file-routes.ts +29 -3
- package/packages/server/src/routes/package-routes.ts +72 -3
- package/packages/server/src/routes/plugin-config-routes.ts +129 -0
- package/packages/server/src/routes/system-routes.ts +2 -0
- package/packages/server/src/server.ts +339 -10
- package/packages/server/src/session-api.ts +30 -5
- package/packages/server/src/session-order-manager.ts +22 -0
- package/packages/server/src/session-scanner.ts +10 -1
- package/packages/shared/package.json +9 -2
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
- package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
- package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
- package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
- package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
- package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
- package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
- package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
- package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
- package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
- package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
- package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +123 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
- package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
- package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
- package/packages/shared/src/browser-protocol.ts +110 -4
- package/packages/shared/src/config.ts +45 -0
- package/packages/shared/src/dashboard-plugin/index.ts +11 -0
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
- package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
- package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
- package/packages/shared/src/openspec-activity-detector.ts +18 -22
- package/packages/shared/src/openspec-design-evidence.ts +109 -0
- package/packages/shared/src/openspec-poller.ts +117 -3
- package/packages/shared/src/openspec-specs-evidence.ts +79 -0
- package/packages/shared/src/platform/binary-lookup.ts +96 -1
- package/packages/shared/src/plugin-bridge-register.ts +139 -0
- package/packages/shared/src/protocol.ts +56 -2
- package/packages/shared/src/recommended-extensions.ts +7 -1
- package/packages/shared/src/rest-api.ts +68 -3
- package/packages/shared/src/state-replay.ts +11 -1
- package/packages/shared/src/tool-registry/strategies.ts +17 -3
- 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
|
-
//
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
const SubQuestionSchema = Type.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
description: "
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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(
|
|
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
|
|
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
|
|
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: (
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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);
|