@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
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Regression guard for the `ask_user` tool's parameters JSON Schema shape.
3
+ *
4
+ * History:
5
+ * 1. Pre-a53933f — schema was `Type.Union` of per-method `Type.Object`
6
+ * arms (root-level `anyOf`). Anthropic loved it; OpenAI rejected it
7
+ * ("schema must be a JSON Schema of 'type: \"object\"', got
8
+ * 'type: \"None\"'").
9
+ *
10
+ * 2. a53933f — collapsed to a single flat `Type.Object` (root
11
+ * `type: "object"`, all fields optional). OpenAI happy. Anthropic
12
+ * lost its per-method strictness; Claude started emitting
13
+ * multiselect calls without `options` and the dashboard auto-
14
+ * cancelled them — the user-visible bug behind
15
+ * fix-multiselect-auto-cancel-on-dashboard.
16
+ *
17
+ * 3. Layer-2 attempt of fix-multiselect-auto-cancel-on-dashboard —
18
+ * tried to keep root `type: "object"` AND attach a body-level
19
+ * `oneOf` discriminator over `method`. Anthropic worked. Real
20
+ * OpenAI gpt-5 rejected it (verified 2026-04-30) with: "schema
21
+ * must have type 'object' and not have 'oneOf' / 'anyOf' / 'allOf'
22
+ * / 'enum' / 'not' at the top level." The fallback documented in
23
+ * tasks.md §9.7 was taken: Layer 2 dropped, Layer 1 (multiselect
24
+ * dashboard routing) ships alone — that's what actually fixes the
25
+ * user bug.
26
+ *
27
+ * This test pins the post-fallback shape so it cannot regress in
28
+ * either direction:
29
+ * • Root MUST be `type: "object"` (OpenAI rule).
30
+ * • Root MUST NOT have `oneOf`, `anyOf`, `allOf`, `enum`, `not`.
31
+ * • The same five forbidden keys MUST NOT appear on `SubQuestionSchema`
32
+ * either — OpenAI applies the rule recursively per the error message.
33
+ *
34
+ * Per-method strictness is enforced by `prepareArguments` rescue + the
35
+ * `execute` runtime switch (covered by `ask-user-tool.test.ts`).
36
+ *
37
+ * See change: fix-multiselect-auto-cancel-on-dashboard.
38
+ */
39
+ import { describe, it, expect, vi } from "vitest";
40
+ import { registerAskUserTool } from "../ask-user-tool.js";
41
+
42
+ const FORBIDDEN_TOP_LEVEL_KEYS = ["oneOf", "anyOf", "allOf", "enum", "not"] as const;
43
+
44
+ interface PiSchema {
45
+ type?: string;
46
+ properties?: Record<string, any>;
47
+ required?: string[];
48
+ description?: string;
49
+ oneOf?: unknown;
50
+ anyOf?: unknown;
51
+ allOf?: unknown;
52
+ enum?: unknown;
53
+ not?: unknown;
54
+ }
55
+
56
+ function captureRegisteredTool() {
57
+ const calls: any[] = [];
58
+ const pi = {
59
+ registerTool: vi.fn((def: any) => calls.push(def)),
60
+ };
61
+ registerAskUserTool(pi as any);
62
+ expect(calls.length).toBe(1);
63
+ return calls[0];
64
+ }
65
+
66
+ function getSubQuestionSchema(parameters: PiSchema): PiSchema {
67
+ const items = parameters.properties?.questions?.items;
68
+ expect(items).toBeDefined();
69
+ return items as PiSchema;
70
+ }
71
+
72
+ // Walk a Union<Literal> schema (typebox emits it as `anyOf` of `{const: ...}`)
73
+ // into a flat list of literal values.
74
+ function flattenUnionLiterals(s: any): string[] {
75
+ if (!s) return [];
76
+ if (Array.isArray(s.anyOf)) return s.anyOf.flatMap(flattenUnionLiterals);
77
+ if (s.const !== undefined) return [String(s.const)];
78
+ return [];
79
+ }
80
+
81
+ describe("ask_user parameters schema — OpenAI strict-mode shape", () => {
82
+ it("root is type:object", () => {
83
+ const tool = captureRegisteredTool();
84
+ const params = tool.parameters as PiSchema;
85
+ expect(params.type).toBe("object");
86
+ });
87
+
88
+ it.each(FORBIDDEN_TOP_LEVEL_KEYS)(
89
+ "root has NO `%s` (OpenAI strict mode forbids it at the top level)",
90
+ (key) => {
91
+ const tool = captureRegisteredTool();
92
+ const params = tool.parameters as Record<string, unknown>;
93
+ expect(
94
+ params[key],
95
+ `parameters.${key} would break OpenAI gpt-5: "schema must have type 'object' and not have 'oneOf' / 'anyOf' / 'allOf' / 'enum' / 'not' at the top level."`,
96
+ ).toBeUndefined();
97
+ },
98
+ );
99
+
100
+ it("declares the five method literals (regression guard for MethodEnum)", () => {
101
+ const tool = captureRegisteredTool();
102
+ const methodSchema = (tool.parameters as PiSchema).properties?.method;
103
+ expect(methodSchema).toBeDefined();
104
+ // typebox emits a Union<Literal> as `anyOf` UNDER `properties.method`
105
+ // (NOT at the schema root). That is OpenAI-legal because it isn't at
106
+ // the top level. We only assert the literal set is preserved.
107
+ const methods = flattenUnionLiterals(methodSchema);
108
+ expect(methods).toEqual(
109
+ expect.arrayContaining(["confirm", "select", "multiselect", "input", "batch"]),
110
+ );
111
+ });
112
+ });
113
+
114
+ describe("ask_user SubQuestionSchema — OpenAI strict-mode shape", () => {
115
+ it("sub-question schema is type:object", () => {
116
+ const tool = captureRegisteredTool();
117
+ const sq = getSubQuestionSchema(tool.parameters);
118
+ expect(sq.type).toBe("object");
119
+ });
120
+
121
+ it.each(FORBIDDEN_TOP_LEVEL_KEYS)(
122
+ "sub-question schema has NO `%s` at top level (OpenAI rule applies recursively)",
123
+ (key) => {
124
+ const tool = captureRegisteredTool();
125
+ const sq = getSubQuestionSchema(tool.parameters) as Record<string, unknown>;
126
+ expect(sq[key]).toBeUndefined();
127
+ },
128
+ );
129
+
130
+ it("sub-question excludes 'batch' from method literals (no nesting)", () => {
131
+ const tool = captureRegisteredTool();
132
+ const sq = getSubQuestionSchema(tool.parameters);
133
+ const methodSchema = sq.properties?.method;
134
+ expect(methodSchema).toBeDefined();
135
+ const methods = flattenUnionLiterals(methodSchema);
136
+ expect(methods).toEqual(
137
+ expect.arrayContaining(["confirm", "select", "multiselect", "input"]),
138
+ );
139
+ expect(methods).not.toContain("batch");
140
+ });
141
+ });
@@ -80,13 +80,24 @@ describe("registerAskUserTool", () => {
80
80
  it("passes message through opts for input", async () => {
81
81
  const { tool, ctx } = getToolAndMockCtx();
82
82
  await tool.execute("id", { method: "input", title: "Q", message: "Details here" }, undefined, undefined, ctx);
83
- expect(ctx.ui.input).toHaveBeenCalledWith("Q", undefined, { message: "Details here" });
83
+ // toolCallId is also threaded through opts since change
84
+ // `fix-interactive-ui-reorder`. Asserts both fields without
85
+ // pinning property order.
86
+ expect(ctx.ui.input).toHaveBeenCalledWith(
87
+ "Q",
88
+ undefined,
89
+ expect.objectContaining({ message: "Details here", toolCallId: "id" }),
90
+ );
84
91
  });
85
92
 
86
93
  it("passes message through opts for select", async () => {
87
94
  const { tool, ctx } = getToolAndMockCtx();
88
95
  await tool.execute("id", { method: "select", title: "Pick", message: "Context", options: ["A", "B"] }, undefined, undefined, ctx);
89
- expect(ctx.ui.select).toHaveBeenCalledWith("Pick", ["A", "B"], { message: "Context" });
96
+ expect(ctx.ui.select).toHaveBeenCalledWith(
97
+ "Pick",
98
+ ["A", "B"],
99
+ expect.objectContaining({ message: "Context", toolCallId: "id" }),
100
+ );
90
101
  });
91
102
 
92
103
  it("dispatches multiselect through the polyfill via ctx.ui.custom", async () => {
@@ -104,28 +115,48 @@ describe("registerAskUserTool", () => {
104
115
  expect(result.details.result).toEqual(["A"]);
105
116
  });
106
117
 
107
- it("does not pass opts when message is undefined", async () => {
118
+ it("passes only toolCallId through opts when message is undefined", async () => {
108
119
  const { tool, ctx } = getToolAndMockCtx();
109
120
  await tool.execute("id", { method: "input", title: "Q" }, undefined, undefined, ctx);
110
- expect(ctx.ui.input).toHaveBeenCalledWith("Q", undefined, undefined);
121
+ // Even with no `message`, the wrapper still attaches toolCallId so
122
+ // the resulting prompt_request can be paired by the client reducer.
123
+ expect(ctx.ui.input).toHaveBeenCalledWith(
124
+ "Q",
125
+ undefined,
126
+ expect.objectContaining({ toolCallId: "id" }),
127
+ );
111
128
  });
112
129
 
113
130
  it("falls back to message when title is missing", async () => {
114
131
  const { tool, ctx } = getToolAndMockCtx();
115
132
  await tool.execute("id", { method: "input", message: "Detailed question" }, undefined, undefined, ctx);
116
- expect(ctx.ui.input).toHaveBeenCalledWith("Detailed question", undefined, { message: "Detailed question" });
133
+ expect(ctx.ui.input).toHaveBeenCalledWith(
134
+ "Detailed question",
135
+ undefined,
136
+ expect.objectContaining({ message: "Detailed question", toolCallId: "id" }),
137
+ );
117
138
  });
118
139
 
119
140
  it("falls back to 'Question' when both title and message are missing", async () => {
120
141
  const { tool, ctx } = getToolAndMockCtx();
121
142
  await tool.execute("id", { method: "confirm" }, undefined, undefined, ctx);
122
- expect(ctx.ui.confirm).toHaveBeenCalledWith("Question", "");
143
+ // confirm now also threads toolCallId via 3rd arg.
144
+ expect(ctx.ui.confirm).toHaveBeenCalledWith(
145
+ "Question",
146
+ "",
147
+ expect.objectContaining({ toolCallId: "id" }),
148
+ );
123
149
  });
124
150
 
125
151
  it("parses options from JSON string", async () => {
126
152
  const { tool, ctx } = getToolAndMockCtx();
127
153
  await tool.execute("id", { method: "select", title: "Pick", options: '["A", "B"]' }, undefined, undefined, ctx);
128
- expect(ctx.ui.select).toHaveBeenCalledWith("Pick", ["A", "B"], undefined);
154
+ // No message, no other opts — only toolCallId.
155
+ expect(ctx.ui.select).toHaveBeenCalledWith(
156
+ "Pick",
157
+ ["A", "B"],
158
+ expect.objectContaining({ toolCallId: "id" }),
159
+ );
129
160
  });
130
161
 
131
162
  it("throws when select reaches execute with unparseable options string", async () => {
@@ -160,6 +191,19 @@ describe("registerAskUserTool", () => {
160
191
  return pi.registerTool.mock.calls[0][0];
161
192
  }
162
193
 
194
+ it("leaves empty {} args untouched (no synthetic method) so schema rejection still fires", () => {
195
+ // Regression test for the Opus-emits-empty-args bug seen in session 019dd05c.
196
+ // Our rescue layer must NOT silently fabricate a method/title when there is
197
+ // nothing to rescue — the framework's schema validator must still reject {}
198
+ // so the model is forced to retry with valid args.
199
+ const tool = getTool();
200
+ const result = tool.prepareArguments({});
201
+ expect(result.method).toBeUndefined();
202
+ expect(result.title).toBeUndefined();
203
+ expect(result.questions).toBeUndefined();
204
+ expect(Object.keys(result).filter((k) => k !== "__normalizations")).toHaveLength(0);
205
+ });
206
+
163
207
  it("parses stringified options array", () => {
164
208
  const tool = getTool();
165
209
  const result = tool.prepareArguments({ method: "select", title: "Pick", options: '["A", "B"]' });
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Tests for the bridge's `ctx.ui.multiselect` PromptBus patch and the TUI
3
+ * adapter's multiselect handling. The patch lives in `bridge.ts` but is
4
+ * exercised here through a small reproduction harness so we don't pull in
5
+ * the full session lifecycle.
6
+ *
7
+ * See change: fix-multiselect-auto-cancel-on-dashboard.
8
+ */
9
+ import { describe, it, expect, vi, beforeEach } from "vitest";
10
+ import { decodeMultiselectAnswer } from "../multiselect-decode.js";
11
+
12
+ // ──────────────────────────────────────────────────────────────────────
13
+ // `decodeMultiselectAnswer` — pure helper used by both the runtime patch
14
+ // and the TUI adapter's response encoding round-trip.
15
+ // ──────────────────────────────────────────────────────────────────────
16
+
17
+ describe("decodeMultiselectAnswer", () => {
18
+ it("resolves cancellation as undefined", () => {
19
+ expect(decodeMultiselectAnswer({ cancelled: true })).toBeUndefined();
20
+ expect(decodeMultiselectAnswer({ cancelled: true, answer: '["x"]' })).toBeUndefined();
21
+ });
22
+
23
+ it("resolves successful selection from JSON-encoded array", () => {
24
+ expect(decodeMultiselectAnswer({ cancelled: false, answer: '["a","c"]' })).toEqual(["a", "c"]);
25
+ });
26
+
27
+ it("resolves empty selection as []", () => {
28
+ expect(decodeMultiselectAnswer({ cancelled: false, answer: "[]" })).toEqual([]);
29
+ });
30
+
31
+ it("resolves null / undefined / empty answer as [] (not undefined)", () => {
32
+ expect(decodeMultiselectAnswer({ cancelled: false, answer: undefined })).toEqual([]);
33
+ expect(decodeMultiselectAnswer({ cancelled: false, answer: "" })).toEqual([]);
34
+ });
35
+
36
+ it("resolves unparseable JSON as [] without throwing", () => {
37
+ expect(decodeMultiselectAnswer({ cancelled: false, answer: "not-json" })).toEqual([]);
38
+ expect(decodeMultiselectAnswer({ cancelled: false, answer: "{not:array}" })).toEqual([]);
39
+ });
40
+
41
+ it("resolves valid JSON that is not an array as []", () => {
42
+ expect(decodeMultiselectAnswer({ cancelled: false, answer: '"just-a-string"' })).toEqual([]);
43
+ expect(decodeMultiselectAnswer({ cancelled: false, answer: '{"k":"v"}' })).toEqual([]);
44
+ });
45
+ });
46
+
47
+ // ──────────────────────────────────────────────────────────────────────
48
+ // Bridge patch reproduction — emulate the assignment block in
49
+ // `bridge.ts:935-948` plus the new multiselect arm and assert behavior.
50
+ // We cannot import `bridge.ts` directly (it has heavy session-lifecycle
51
+ // imports), so we reproduce the assignment closure here. The closure
52
+ // shape MUST match `bridge.ts` exactly; if `bridge.ts` drifts, the
53
+ // regression guard test (see "ctx.ui.multiselect is assigned by the
54
+ // bridge patch block" below) re-loads the bridge source and asserts
55
+ // the assignment exists.
56
+ // ──────────────────────────────────────────────────────────────────────
57
+
58
+ interface BusRequestArgs {
59
+ pipeline: string;
60
+ type: string;
61
+ question: string;
62
+ options?: string[];
63
+ metadata?: Record<string, unknown>;
64
+ }
65
+
66
+ interface FakeBus {
67
+ request: (args: BusRequestArgs) => Promise<{ cancelled?: boolean; answer?: string }>;
68
+ }
69
+
70
+ function applyMultiselectPatch(
71
+ ctx: { ui: Record<string, any> },
72
+ bus: FakeBus,
73
+ ): void {
74
+ const existing = (ctx.ui as any).multiselect;
75
+ if (typeof existing === "function") {
76
+ // eslint-disable-next-line no-console
77
+ console.warn("[bridge] ctx.ui.multiselect already exists — overriding for PromptBus routing");
78
+ }
79
+ (ctx.ui as any).multiselect = (title: string, options: string[], opts?: { message?: string }) =>
80
+ bus.request({
81
+ pipeline: "command",
82
+ type: "multiselect",
83
+ question: title,
84
+ options,
85
+ metadata: opts?.message ? { message: opts.message } : undefined,
86
+ }).then((r) => decodeMultiselectAnswer(r));
87
+ }
88
+
89
+ describe("bridge ctx.ui.multiselect patch", () => {
90
+ let ctx: { ui: Record<string, any> };
91
+ let bus: FakeBus;
92
+ let requestSpy: ReturnType<typeof vi.fn>;
93
+
94
+ beforeEach(() => {
95
+ requestSpy = vi.fn();
96
+ bus = { request: requestSpy as any };
97
+ ctx = { ui: {} };
98
+ });
99
+
100
+ it("assigns ctx.ui.multiselect as a function after the patch runs", () => {
101
+ expect(typeof ctx.ui.multiselect).toBe("undefined");
102
+ applyMultiselectPatch(ctx, bus);
103
+ expect(typeof ctx.ui.multiselect).toBe("function");
104
+ });
105
+
106
+ it("dispatches bus.request with the right shape on call", async () => {
107
+ applyMultiselectPatch(ctx, bus);
108
+ requestSpy.mockResolvedValue({ cancelled: false, answer: '["a","c"]' });
109
+
110
+ const result = await ctx.ui.multiselect("Pick", ["a", "b", "c"], { message: "ctx" });
111
+
112
+ expect(requestSpy).toHaveBeenCalledTimes(1);
113
+ expect(requestSpy).toHaveBeenCalledWith({
114
+ pipeline: "command",
115
+ type: "multiselect",
116
+ question: "Pick",
117
+ options: ["a", "b", "c"],
118
+ metadata: { message: "ctx" },
119
+ });
120
+ expect(result).toEqual(["a", "c"]);
121
+ });
122
+
123
+ it("omits metadata when no message is provided", async () => {
124
+ applyMultiselectPatch(ctx, bus);
125
+ requestSpy.mockResolvedValue({ cancelled: false, answer: "[]" });
126
+
127
+ await ctx.ui.multiselect("Pick", ["a", "b"]);
128
+
129
+ expect(requestSpy).toHaveBeenCalledWith(
130
+ expect.objectContaining({ metadata: undefined }),
131
+ );
132
+ });
133
+
134
+ it("resolves successful selection through the decoder", async () => {
135
+ applyMultiselectPatch(ctx, bus);
136
+ requestSpy.mockResolvedValue({ cancelled: false, answer: '["a","c"]' });
137
+ await expect(ctx.ui.multiselect("t", ["a", "b", "c"])).resolves.toEqual(["a", "c"]);
138
+ });
139
+
140
+ it("resolves empty selection as []", async () => {
141
+ applyMultiselectPatch(ctx, bus);
142
+ requestSpy.mockResolvedValue({ cancelled: false, answer: "[]" });
143
+ await expect(ctx.ui.multiselect("t", ["a"])).resolves.toEqual([]);
144
+ });
145
+
146
+ it("resolves cancellation as undefined", async () => {
147
+ applyMultiselectPatch(ctx, bus);
148
+ requestSpy.mockResolvedValue({ cancelled: true });
149
+ await expect(ctx.ui.multiselect("t", ["a"])).resolves.toBeUndefined();
150
+ });
151
+
152
+ it("resolves unparseable answer as [] without throwing", async () => {
153
+ applyMultiselectPatch(ctx, bus);
154
+ requestSpy.mockResolvedValue({ cancelled: false, answer: "not-json" });
155
+ await expect(ctx.ui.multiselect("t", ["a"])).resolves.toEqual([]);
156
+ });
157
+
158
+ it("warns when ctx.ui.multiselect was already a function before the patch", () => {
159
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
160
+ ctx.ui.multiselect = () => Promise.resolve(["pre-existing"]);
161
+
162
+ applyMultiselectPatch(ctx, bus);
163
+
164
+ expect(warnSpy).toHaveBeenCalledWith(
165
+ expect.stringContaining("already exists"),
166
+ );
167
+ // The patch still wins — subsequent calls go through bus.request.
168
+ requestSpy.mockResolvedValue({ cancelled: false, answer: '["winner"]' });
169
+ return ctx.ui.multiselect("t", ["a"]).then((r: any) => {
170
+ expect(r).toEqual(["winner"]);
171
+ expect(requestSpy).toHaveBeenCalled();
172
+ warnSpy.mockRestore();
173
+ });
174
+ });
175
+ });
176
+
177
+ // ──────────────────────────────────────────────────────────────────────
178
+ // Source regression guard — re-load bridge.ts as text and assert the
179
+ // multiselect patch line exists. Mirrors `no-direct-process-kill.test.ts`
180
+ // pattern: textual scan over a single file, fail with file:line if the
181
+ // expected snippet is missing or moved.
182
+ // ──────────────────────────────────────────────────────────────────────
183
+
184
+ describe("bridge.ts source regression guard", () => {
185
+ it("contains the ctx.ui.multiselect PromptBus assignment", async () => {
186
+ const fs = await import("node:fs");
187
+ const path = await import("node:path");
188
+ const url = await import("node:url");
189
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
190
+ const bridgePath = path.resolve(here, "../bridge.ts");
191
+ const src = fs.readFileSync(bridgePath, "utf8");
192
+
193
+ // The assignment must mention `multiselect` as a key on ctx.ui AND
194
+ // dispatch through bus.request with type:"multiselect".
195
+ expect(src).toMatch(/\(ctx\.ui as any\)\.multiselect\s*=/);
196
+ expect(src).toMatch(/type:\s*"multiselect"/);
197
+ });
198
+
199
+ // Note: the previous-change assertions about `custom: ctx.ui.custom?.bind(...)`
200
+ // capture and the TUI `prompt.type === "multiselect"` arm were removed by
201
+ // change `fix-multiselect-tui-arm-self-cancel`. The inverse-guard for that
202
+ // removal lives in `no-tui-multiselect-arm-regression.test.ts`.
203
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Tests for `polyfillMultiselect`'s fallback chain. After the
3
+ * fix-multiselect-auto-cancel-on-dashboard change, the polyfill prefers a
4
+ * bridge-patched `ctx.ui.multiselect` (the PromptBus path that surfaces in
5
+ * the dashboard browser) and falls back to the legacy `ctx.ui.custom` +
6
+ * `MultiSelectList` overlay only when the patch is absent (older pi
7
+ * versions or non-bridge embeddings).
8
+ *
9
+ * See change: fix-multiselect-auto-cancel-on-dashboard.
10
+ */
11
+ import { describe, it, expect, vi } from "vitest";
12
+ import { polyfillMultiselect } from "../multiselect-polyfill.js";
13
+
14
+ describe("polyfillMultiselect — fallback chain", () => {
15
+ it("delegates to ctx.ui.multiselect when present (primary, bus-routed path)", async () => {
16
+ const multiselectFn = vi.fn().mockResolvedValue(["a"]);
17
+ const customFn = vi.fn();
18
+ const ctx = {
19
+ ui: {
20
+ multiselect: multiselectFn,
21
+ custom: customFn,
22
+ },
23
+ };
24
+
25
+ const result = await polyfillMultiselect(ctx as any, "Pick", ["a", "b"]);
26
+
27
+ expect(result).toEqual(["a"]);
28
+ expect(multiselectFn).toHaveBeenCalledTimes(1);
29
+ expect(multiselectFn).toHaveBeenCalledWith("Pick", ["a", "b"], undefined);
30
+ expect(customFn).not.toHaveBeenCalled();
31
+ });
32
+
33
+ it("forwards opts.message to ctx.ui.multiselect when present", async () => {
34
+ const multiselectFn = vi.fn().mockResolvedValue([]);
35
+ const ctx = { ui: { multiselect: multiselectFn, custom: vi.fn() } };
36
+
37
+ await polyfillMultiselect(ctx as any, "Pick", ["a"], { message: "ctx" });
38
+
39
+ expect(multiselectFn).toHaveBeenCalledWith("Pick", ["a"], { message: "ctx" });
40
+ });
41
+
42
+ it("propagates undefined (cancellation) through the primary path", async () => {
43
+ const multiselectFn = vi.fn().mockResolvedValue(undefined);
44
+ const ctx = { ui: { multiselect: multiselectFn, custom: vi.fn() } };
45
+
46
+ await expect(polyfillMultiselect(ctx as any, "t", ["a"])).resolves.toBeUndefined();
47
+ });
48
+
49
+ it("falls back to ctx.ui.custom when ctx.ui.multiselect is absent (TUI path)", async () => {
50
+ let capturedDone: ((r: string[] | undefined) => void) | undefined;
51
+ const customFn = vi.fn().mockImplementation((factory: any) => {
52
+ return new Promise<string[] | undefined>((resolve) => {
53
+ const done = (r: string[] | undefined) => resolve(r);
54
+ capturedDone = done;
55
+ const component = factory({}, {}, {}, done);
56
+ // Simulate user confirming after the factory wires the component.
57
+ component?.onConfirm?.(["b"]);
58
+ });
59
+ });
60
+ const ctx = { ui: { custom: customFn } };
61
+
62
+ const result = await polyfillMultiselect(ctx as any, "Pick", ["a", "b"]);
63
+
64
+ expect(result).toEqual(["b"]);
65
+ expect(customFn).toHaveBeenCalledTimes(1);
66
+ expect(capturedDone).toBeTypeOf("function");
67
+ });
68
+
69
+ it("falls back to ctx.ui.custom and resolves undefined on cancel", async () => {
70
+ const customFn = vi.fn().mockImplementation((factory: any) => {
71
+ return new Promise<string[] | undefined>((resolve) => {
72
+ const component = factory({}, {}, {}, (r: any) => resolve(r));
73
+ component?.onCancel?.();
74
+ });
75
+ });
76
+ const ctx = { ui: { custom: customFn } };
77
+
78
+ await expect(polyfillMultiselect(ctx as any, "Pick", ["a"])).resolves.toBeUndefined();
79
+ });
80
+
81
+ it("does NOT fall back to ctx.ui.custom when ctx.ui.multiselect resolves to []", async () => {
82
+ // Empty selection is a valid answer — must NOT trigger the legacy fallback.
83
+ const multiselectFn = vi.fn().mockResolvedValue([]);
84
+ const customFn = vi.fn();
85
+ const ctx = { ui: { multiselect: multiselectFn, custom: customFn } };
86
+
87
+ const result = await polyfillMultiselect(ctx as any, "t", ["a"]);
88
+
89
+ expect(result).toEqual([]);
90
+ expect(customFn).not.toHaveBeenCalled();
91
+ });
92
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Repo-level invariant: `packages/extension/src/bridge.ts` MUST NOT
3
+ * contain the co-occurrence of two substrings:
4
+ *
5
+ * 1. `originals.custom`
6
+ * 2. `prompt.type === "multiselect"`
7
+ *
8
+ * If both appear in the same file, a contributor has (re)introduced the
9
+ * TUI PromptBus-adapter multiselect arm that was removed by change
10
+ * `fix-multiselect-tui-arm-self-cancel`. The arm is forbidden because
11
+ * pi 0.70's RPC mode (the only mode dashboard headless sessions run
12
+ * under) defines `ExtensionUIContext.custom` as an unconditional no-op
13
+ * (`async custom() { return undefined; }`, see
14
+ * `~/.nvm/.../@mariozechner/pi-coding-agent/dist/modes/rpc/rpc-mode.js`
15
+ * lines 150-153). Awaiting that primitive resolves to `undefined`
16
+ * synchronously, and the TUI arm's `bus.respond({ cancelled: true,
17
+ * source: "tui" })` triggers the PromptBus's first-response-wins
18
+ * dismissal — which auto-cancels the dashboard's already-rendered
19
+ * `MultiselectRenderer` within ~1 event-loop tick.
20
+ *
21
+ * The bus-routed `(ctx.ui as any).multiselect = (...) => bus.request(...)`
22
+ * patch site uses the substring `type: "multiselect"` (object-literal
23
+ * shape), not `prompt.type === "multiselect"` (equality-check shape),
24
+ * so it is unaffected by this lint.
25
+ *
26
+ * To remove a legitimate `originals.custom` reference (e.g. for a
27
+ * future use that does not include multiselect prompt routing), keep
28
+ * one substring and ensure the other does not co-occur.
29
+ *
30
+ * See change: fix-multiselect-tui-arm-self-cancel.
31
+ */
32
+ import { describe, it, expect } from "vitest";
33
+ import fs from "node:fs/promises";
34
+ import path from "node:path";
35
+ import url from "node:url";
36
+
37
+ const FORBIDDEN_A = "originals.custom";
38
+ const FORBIDDEN_B = 'prompt.type === "multiselect"';
39
+
40
+ function findLineNumbers(src: string, needle: string): number[] {
41
+ const lines = src.split(/\r?\n/);
42
+ const hits: number[] = [];
43
+ lines.forEach((line, idx) => {
44
+ if (line.includes(needle)) hits.push(idx + 1);
45
+ });
46
+ return hits;
47
+ }
48
+
49
+ describe("no TUI multiselect arm regression in bridge.ts", () => {
50
+ it("bridge.ts MUST NOT contain both `originals.custom` and `prompt.type === \"multiselect\"`", async () => {
51
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
52
+ const bridgePath = path.resolve(here, "..", "bridge.ts");
53
+ const src = await fs.readFile(bridgePath, "utf-8");
54
+
55
+ const hasA = src.includes(FORBIDDEN_A);
56
+ const hasB = src.includes(FORBIDDEN_B);
57
+
58
+ if (hasA && hasB) {
59
+ const linesA = findLineNumbers(src, FORBIDDEN_A);
60
+ const linesB = findLineNumbers(src, FORBIDDEN_B);
61
+ const msg =
62
+ `Forbidden co-occurrence in ${path.relative(process.cwd(), bridgePath)}:\n` +
63
+ ` - "${FORBIDDEN_A}" found on line(s): ${linesA.join(", ")}\n` +
64
+ ` - "${FORBIDDEN_B}" found on line(s): ${linesB.join(", ")}\n` +
65
+ `\n` +
66
+ `This pattern was removed by change "fix-multiselect-tui-arm-self-cancel".\n` +
67
+ `pi 0.70 RPC mode's ctx.ui.custom is a no-op, so a TUI multiselect\n` +
68
+ `arm that awaits originals.custom auto-cancels the dashboard-rendered\n` +
69
+ `dialog within ~1 event-loop tick. The bus-routed ctx.ui.multiselect\n` +
70
+ `patch + DashboardDefaultAdapter handle multiselect end-to-end without\n` +
71
+ `any TUI arm participation. See:\n` +
72
+ ` openspec/changes/archive/<date>-fix-multiselect-tui-arm-self-cancel/\n` +
73
+ `(or openspec/changes/fix-multiselect-tui-arm-self-cancel/ if not yet archived).`;
74
+ expect.fail(msg);
75
+ }
76
+
77
+ // Guardrail: at least one of the two substrings absent (we already
78
+ // assert above that both-present is illegal). Either-alone is fine.
79
+ expect(hasA && hasB).toBe(false);
80
+ });
81
+ });
@@ -229,4 +229,41 @@ describe("detectOpenSpecActivity", () => {
229
229
  expect(result).toBeNull();
230
230
  });
231
231
  });
232
+
233
+ describe("flag-shaped change names", () => {
234
+ it("returns null for `openspec archive --help`", () => {
235
+ const result = detectOpenSpecActivity("bash", {
236
+ command: "openspec archive --help",
237
+ });
238
+ expect(result).toBeNull();
239
+ });
240
+
241
+ it("returns null for `openspec new change --help`", () => {
242
+ const result = detectOpenSpecActivity("bash", {
243
+ command: "openspec new change --help",
244
+ });
245
+ expect(result).toBeNull();
246
+ });
247
+
248
+ it("returns null when --change is followed by another flag", () => {
249
+ const result = detectOpenSpecActivity("bash", {
250
+ command: "openspec foo --change --help",
251
+ });
252
+ expect(result).toBeNull();
253
+ });
254
+
255
+ it("still extracts a real change name from `openspec archive add-auth`", () => {
256
+ const result = detectOpenSpecActivity("bash", {
257
+ command: "openspec archive add-auth",
258
+ });
259
+ expect(result).toEqual({ changeName: "add-auth", isActive: true });
260
+ });
261
+
262
+ it("still extracts a quoted change name from `openspec archive \"add-auth\"`", () => {
263
+ const result = detectOpenSpecActivity("bash", {
264
+ command: 'openspec archive "add-auth"',
265
+ });
266
+ expect(result).toEqual({ changeName: "add-auth", isActive: true });
267
+ });
268
+ });
232
269
  });