@ask-llm/plugin 0.15.0 → 0.16.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.
Files changed (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.cursor-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +979 -0
  4. package/README.md +2 -0
  5. package/agents/brainstorm-coordinator.md +1 -1
  6. package/agents/gemini-reviewer.md +1 -1
  7. package/dist/antigravity-run.js +0 -0
  8. package/dist/brainstorm-run.js +0 -0
  9. package/dist/codex-run.js +0 -0
  10. package/dist/grok-run.js +0 -0
  11. package/dist/ollama-run.js +0 -0
  12. package/dist/run.js +0 -0
  13. package/package.json +14 -14
  14. package/pi/extensions/provider-tools.ts +1 -1
  15. package/scripts/benchmark/README.md +114 -0
  16. package/scripts/benchmark/fixtures/README.md +29 -0
  17. package/scripts/codex-pair-debounce-worker.mjs +0 -0
  18. package/scripts/codex-pair-log.mjs +4 -13
  19. package/scripts/codex-pair-prompt-drain.mjs +1 -1
  20. package/scripts/codex-pair-session.mjs +2 -2
  21. package/scripts/codex-pair-stop-gate.mjs +8 -8
  22. package/scripts/codex-pair-watch.mjs +20 -39
  23. package/skills/gemini-review/SKILL.md +1 -1
  24. package/scripts/lib/broker-lifecycle.mjs +0 -575
  25. package/scripts/lib/broker-rpc.mjs +0 -203
  26. package/scripts/lib/broker-transport.mjs +0 -407
  27. package/scripts/lib/broker.mjs +0 -537
  28. package/scripts/lib/debounce-state.mjs +0 -208
  29. package/scripts/lib/parser.d.mts +0 -12
  30. package/scripts/lib/parser.mjs +0 -229
  31. package/scripts/lib/process.mjs +0 -56
  32. package/scripts/lib/prompt.d.mts +0 -8
  33. package/scripts/lib/prompt.mjs +0 -41
  34. package/scripts/lib/session-registry.mjs +0 -162
  35. package/scripts/lib/state.d.mts +0 -58
  36. package/scripts/lib/state.mjs +0 -733
  37. package/scripts/lib/stop-gate.mjs +0 -134
@@ -1,537 +0,0 @@
1
- // App-server broker interface (ADR-090, refined per ADR-093).
2
- //
3
- // Future home of the long-lived codex sidecar that replaces per-edit cold
4
- // spawns with persistent JSON-RPC requests. Today this module defines the
5
- // API surface and stable state-file layout — the implementation lands
6
- // across Tier 3 follow-on milestones 2–4 tracked in docs/ROADMAP.md.
7
- //
8
- // **Status:** interface defined; implementation deferred. The `isBrokerEnabled`
9
- // check returns false until ASK_CODEX_BROKER=1 ships alongside a real
10
- // implementation. The hook MUST treat broker absence as a no-op and fall
11
- // back to the existing per-edit codex spawn (ADR-077). This keeps the
12
- // happy path byte-identical to v0.6.6 until the broker stabilizes.
13
- //
14
- // See ADR-090 for original design rationale (transport, lifecycle,
15
- // failure modes, stale-daemon recovery). See ADR-093 for the protocol
16
- // discovery findings that refined this interface against the real
17
- // `codex app-server` JSON-RPC surface (codex-cli 0.130.0+):
18
- // - Transport: unix:// (POSIX) / ws:// (Windows), via `--listen` flag
19
- // - Handshake: JSON-RPC `initialize` with clientInfo
20
- // - Per-review: `thread/start` (ephemeral) + `turn/start` (with
21
- // outputSchema constraint) + listen for `turn/completed` notification
22
- // - Cancellation: `turn/interrupt` on the in-flight turn id
23
- // - Health probe: `model/list` (cheap) or `initialize` with deadline
24
-
25
- import { join } from "node:path";
26
- import { connectWebSocket } from "./broker-transport.mjs";
27
- import { createRpcClient } from "./broker-rpc.mjs";
28
-
29
- // State file under <markerDir>/.codex-pair/state/ (ADR-092).
30
- export const BROKER_STATE_FILE = "broker.json";
31
- export const BROKER_HEALTH_TIMEOUT_MS = 2000;
32
- export const BROKER_SOCKET_PREFIX = "codex-pair-broker";
33
-
34
- // Protocol version we target. Pinned so a codex CLI upgrade with breaking
35
- // protocol changes is detected at handshake time rather than silently
36
- // producing malformed requests. Verified empirically against codex-cli
37
- // 0.130.0 via `codex app-server generate-json-schema`; ADR-093 documents
38
- // the methods + notification stream we depend on.
39
- export const BROKER_PROTOCOL_VERSION = "v2";
40
-
41
- // JSON-RPC client → server methods codex-pair USES (subset of the 75
42
- // available; see ADR-093). Pinning these here documents the contract and
43
- // lets structural tests catch silent drift.
44
- export const JSONRPC_METHODS = Object.freeze({
45
- INITIALIZE: "initialize",
46
- THREAD_START: "thread/start",
47
- TURN_START: "turn/start",
48
- TURN_INTERRUPT: "turn/interrupt",
49
- MODEL_LIST: "model/list", // used for health probe (cheap, no side effects)
50
- });
51
-
52
- // JSON-RPC server → client notifications codex-pair LISTENS for. Subset
53
- // of the full event stream; we ignore the rest. `TURN_COMPLETED` is the
54
- // terminal event that carries the final agent message (which carries the
55
- // structured verdict when outputSchema is set).
56
- export const JSONRPC_NOTIFICATIONS = Object.freeze({
57
- TURN_COMPLETED: "turn/completed",
58
- TURN_STARTED: "turn/started",
59
- ITEM_AGENT_MESSAGE_DELTA: "item/agentMessage/delta", // streaming text
60
- THREAD_TOKEN_USAGE_UPDATED: "thread/tokenUsage/updated", // cost tracking
61
- });
62
-
63
- // Build the JSON Schema we send as `outputSchema` on `turn/start`. Codex
64
- // constrains the agent's final message to this shape per ADR-093, which
65
- // means we get a structured verdict back without prose-parsing (per
66
- // ADR-083's verdict contract). Centralized here so test fixtures and
67
- // production code build the same schema.
68
- // JSON Schema for `turn/start.outputSchema`. Harmonized in M3 to match
69
- // the `parseConcernsJson` contract in `lib/parser.mjs` so the broker's
70
- // structured output drops in to the existing per-edit hook flow without
71
- // translation. Brainstorm-coordinator and codex-pair both flagged the
72
- // prior mismatch (the original schema used `concerns: { high, med, low }`
73
- // while the parser expects `findings: [{ severity, ... }]`).
74
- //
75
- // Shape matches `parser.mjs::parseConcernsJson`:
76
- // { verdict: "clean" } → no concerns
77
- // { verdict: "concerns", findings: [{ severity, body, file?, line?, recommendation? }] }
78
- //
79
- // Severity enum mirrors `lib/parser.mjs::SEVERITY_TO_BUCKET`:
80
- // "high" | "medium" | "low" (parser also accepts "med" but the codex
81
- // model emits the canonical "medium" — `med` is a legacy alias).
82
- export function buildVerdictSchema() {
83
- return {
84
- type: "object",
85
- required: ["verdict"],
86
- additionalProperties: false,
87
- properties: {
88
- verdict: {
89
- type: "string",
90
- enum: ["clean", "concerns"],
91
- description: "Closed-set verdict (parser.mjs:parseConcernsJson contract).",
92
- },
93
- findings: {
94
- type: "array",
95
- description: "Required when verdict == 'concerns'. Empty array also accepted.",
96
- items: {
97
- type: "object",
98
- required: ["severity", "body"],
99
- additionalProperties: false,
100
- properties: {
101
- severity: {
102
- type: "string",
103
- enum: ["high", "medium", "low"],
104
- description: "ADR-077 severity ladder. 'medium' (canonical) — 'med' is a legacy alias.",
105
- },
106
- body: {
107
- type: "string",
108
- description: "The concern itself — what's wrong + why it matters + how to fix.",
109
- },
110
- title: {
111
- type: "string",
112
- description: "Optional short title rendered ahead of the file:line line.",
113
- },
114
- file: {
115
- type: "string",
116
- description: "File path (optional). Prepended to the rendered concern.",
117
- },
118
- line_start: {
119
- type: "integer",
120
- description: "Line number (optional). Multi-review M3 hotfix: parser.mjs reads `line_start`, not `line`. Rendered as ':<n>' suffix on file.",
121
- },
122
- recommendation: {
123
- type: "string",
124
- description: "Optional fix suggestion. Appended on a new line after body.",
125
- },
126
- },
127
- },
128
- },
129
- },
130
- };
131
- }
132
-
133
- // Single source of truth for "is the broker active for this project right
134
- // now". Reads .codex-pair/state/broker.json and returns the broker descriptor
135
- // (transport URL, pid, started_at, codex version, protocol version) or null
136
- // if no broker is running. The hook's main flow checks this BEFORE the
137
- // cache + inflight lock; a live broker bypasses both because the broker
138
- // itself coordinates concurrent requests.
139
- //
140
- // The implementation is intentionally stubbed for v0.7.x Milestone 1.
141
- // Returning null here causes every hook invocation to fall through to the
142
- // existing per-edit spawn path — byte-identical behavior to pre-broker.
143
- // M4: delegate to the lifecycle module's descriptor reader. The function
144
- // signature is preserved for the stable contract; the body now returns
145
- // a real descriptor when one exists (vs the M2 stub returning null
146
- // unconditionally). isBrokerEnabled uses lifecycleReadBrokerDescriptor
147
- // directly for the same purpose; this exported form is the public API
148
- // per ADR-090.
149
- export function readBrokerState(markerDir) {
150
- return lifecycleReadBrokerDescriptor(markerDir);
151
- }
152
-
153
- // Stable predicate the hook can call without knowing the broker mechanics.
154
- // Returns true iff (a) ASK_CODEX_BROKER=1 in env, (b) readBrokerState
155
- // returns a non-null descriptor, (c) the broker process is alive AND
156
- // answered a health probe within BROKER_HEALTH_TIMEOUT_MS. Today (b) is
157
- // stubbed to null, so this is always false.
158
- // Tier 3 Milestone 4: implementation flipped from "always false" to a
159
- // real check. Returns true iff:
160
- // 1. ASK_CODEX_BROKER=1 in env (master switch — default off)
161
- // 2. A valid descriptor exists at <markerDir>/.codex-pair/state/broker.json
162
- // 3. The descriptor's protocolVersion matches BROKER_PROTOCOL_VERSION
163
- // 4. The recorded pid is still alive (cheap process.kill(pid, 0))
164
- //
165
- // Per ADR-077 silent-on-error: any check that fails returns false; caller
166
- // falls through to the existing per-edit spawn path. clearStaleBrokerState
167
- // (called by SessionStart per ADR-090) keeps the descriptor honest between
168
- // sessions; this check is the per-edit-hook's defense for the case where
169
- // the broker died MID-SESSION.
170
- export function isBrokerEnabled(markerDir) {
171
- if (process.env.ASK_CODEX_BROKER !== "1") return false;
172
- const state = lifecycleReadBrokerDescriptor(markerDir);
173
- if (!state) return false;
174
- if (state.protocolVersion !== BROKER_PROTOCOL_VERSION) return false;
175
- if (!lifecycleIsPidAlive(state.pid)) return false;
176
- return true;
177
- }
178
-
179
- // Path resolver for the per-marker-dir broker state file. Used by the
180
- // SessionStart hook (writer) and the per-edit hook (reader).
181
- export function brokerStatePath(markerDir, stateDir) {
182
- return join(markerDir, stateDir, BROKER_STATE_FILE);
183
- }
184
-
185
- // Stale-state cleanup helper. SessionStart calls this BEFORE launching a
186
- // fresh broker; the per-edit hook MAY call it on startup as a belt-and-
187
- // suspenders defense (but the SessionStart path is the contract per
188
- // ADR-090). Returns "absent" | "live" | "stale".
189
- //
190
- // Implementation lives in `broker-lifecycle.mjs` to keep the descriptor-
191
- // read + cleanup primitives co-located with the rest of the lifecycle
192
- // orchestration. Re-exported here so consumers (the per-edit hook in M4,
193
- // codex-pair-session.mjs in this PR) can import a single contract surface.
194
- //
195
- // The implementation needs `BROKER_PROTOCOL_VERSION` (this module's
196
- // constant) — so the lifecycle module imports it from here, and we
197
- // re-export the function below. This avoids a circular dep because
198
- // broker-lifecycle.mjs already imports initializeBroker from this file;
199
- // adding BROKER_PROTOCOL_VERSION to that import doesn't introduce a new
200
- // cycle.
201
- export { clearStaleBrokerState } from "./broker-lifecycle.mjs";
202
-
203
- // M4: import the descriptor reader + pid-liveness helper into this module
204
- // for isBrokerEnabled's per-edit-hook gating check. The one-way dep from
205
- // broker.mjs → broker-lifecycle.mjs is fine: broker-lifecycle imports
206
- // BROKER_PROTOCOL_VERSION + initializeBroker from this file, but only
207
- // uses them inside function bodies (called after module init finishes),
208
- // so the static-evaluation order is acyclic at the value-of-import level.
209
- import { isPidAlive as lifecycleIsPidAlive, readBrokerDescriptorSync as lifecycleReadBrokerDescriptor } from "./broker-lifecycle.mjs";
210
-
211
- // Open a transport connection to a running broker, perform the JSON-RPC
212
- // `initialize` handshake, and return `{ connection, rpc, initializeResult }`.
213
- // Caller owns connection lifetime — call `connection.close()` and stop
214
- // using `rpc` when done. On any failure (transport error, handshake
215
- // timeout, initialize rejection) this rejects; caller falls back to the
216
- // per-edit spawn path per ADR-077.
217
- //
218
- // `clientInfo` is the InitializeParams.clientInfo object — codex-cli
219
- // 0.130.0 requires `{ name, title, version }` (brainstorm-verified;
220
- // ADR-093 protocol note). Callers should pass real plugin identity.
221
- export async function initializeBroker(transportUrl, clientInfo, options = {}) {
222
- const { handshakeTimeoutMs = 5000, initializeTimeoutMs = 5000 } = options;
223
- const connection = await connectWebSocket(transportUrl, { handshakeTimeoutMs });
224
- const rpc = createRpcClient(connection, { defaultTimeoutMs: initializeTimeoutMs });
225
- try {
226
- const initializeResult = await rpc.request(
227
- JSONRPC_METHODS.INITIALIZE,
228
- { clientInfo },
229
- { timeoutMs: initializeTimeoutMs },
230
- );
231
- return { connection, rpc, initializeResult };
232
- } catch (err) {
233
- try {
234
- connection.close(1011, "initialize failed");
235
- } catch {
236
- // already torn down
237
- }
238
- throw err;
239
- }
240
- }
241
-
242
- // Health probe: open the transport, send `model/list` (idempotent, cheap),
243
- // wait up to BROKER_HEALTH_TIMEOUT_MS for a response. Returns boolean.
244
- // Never throws — callers in the hook path treat any failure as "broker
245
- // unreachable, fall back to per-edit spawn" per ADR-077.
246
- //
247
- // `state` is the broker descriptor read from .codex-pair/state/broker.json
248
- // (shape: `{ transportUrl, pid, codexVersion, protocolVersion, startedAt }`).
249
- // Health probe uses transportUrl + initializes ad-hoc because the long-
250
- // lived connection lives in the per-edit hook process, not here.
251
- //
252
- // Implementation note: model/list is preferred over `initialize` for the
253
- // probe because the brainstorm verified that codex's `initialize` is
254
- // metadata-rich + always-succeeds. `model/list` exercises the actual
255
- // JSON-RPC plumbing AND validates that the broker can complete a real
256
- // request — a stricter health signal.
257
- export async function probeBrokerHealth(state) {
258
- if (!state || typeof state.transportUrl !== "string") return false;
259
- let connection;
260
- let rpc;
261
- try {
262
- connection = await connectWebSocket(state.transportUrl, {
263
- handshakeTimeoutMs: BROKER_HEALTH_TIMEOUT_MS,
264
- });
265
- rpc = createRpcClient(connection, { defaultTimeoutMs: BROKER_HEALTH_TIMEOUT_MS });
266
- // `model/list` requires the connection to have completed `initialize`
267
- // first per the codex protocol. The PROBE path opens a fresh
268
- // connection (no broker-side state shared with the long-lived hook
269
- // connection), so we must initialize here before model/list.
270
- await rpc.request(
271
- JSONRPC_METHODS.INITIALIZE,
272
- { clientInfo: { name: "codex-pair-health-probe", title: "codex-pair health probe", version: "0.0.0" } },
273
- { timeoutMs: BROKER_HEALTH_TIMEOUT_MS },
274
- );
275
- await rpc.request(JSONRPC_METHODS.MODEL_LIST, undefined, {
276
- timeoutMs: BROKER_HEALTH_TIMEOUT_MS,
277
- });
278
- return true;
279
- } catch {
280
- return false;
281
- } finally {
282
- if (connection && !connection.destroyed) {
283
- try {
284
- connection.close(1000, "probe done");
285
- } catch {
286
- // best-effort
287
- }
288
- }
289
- }
290
- }
291
-
292
- // Submit-review API. The hook calls this when isBrokerEnabled returns
293
- // true. Today never reached. Real implementation (Milestone 3) performs
294
- // a 3-step JSON-RPC dance per ADR-093:
295
- // 1. `thread/start { ephemeral: true, cwd, baseInstructions, model,
296
- // approvalPolicy: "never", sandbox: <readonly> }`
297
- // → receives { thread: { id } }
298
- // 2. `turn/start { threadId, input: [{type:"text", text: prompt}],
299
- // outputSchema: buildVerdictSchema(), effort: "high" }`
300
- // → receives { turn: { id } }
301
- // 3. Listen on the JSON-RPC connection for `turn/completed`
302
- // notification; extract the final agentMessage; parse its JSON
303
- // content (constrained by outputSchema) into the verdict shape;
304
- // return same shape spawnCodex returns today.
305
- //
306
- // On abort signal: send `turn/interrupt { turnId }` and reject the
307
- // promise. On timeout: same. On schema-violating output (rare per ADR-093
308
- // risk acceptance): reject; caller falls through to per-edit spawn per
309
- // ADR-077's silent-on-error contract.
310
- //
311
- // Args object shape (refined from ADR-090's `(state, prompt, options)`):
312
- // { state, baseInstructions, prompt, model, threadOptions, abortSignal }
313
- // Returns: { agentMessage: string, tokenUsage: { ... }, durationMs: number }
314
- // — mirrors the shape spawnCodex currently produces so the hook's main()
315
- // integration is a one-line substitution.
316
- // Tier 3 Milestone 3 implementation. Performs the JSON-RPC dance per
317
- // ADR-093 + brainstorm-verified protocol facts:
318
- // 1. `thread/start { ephemeral: true, cwd, baseInstructions, model,
319
- // approvalPolicy: "never", sandbox: "read-only" }`
320
- // → receives `{ thread: Thread }`. Pin `thread.id`.
321
- // 2. Register `turn/completed` waiter BEFORE turn/start (race-safe).
322
- // 3. `turn/start { threadId, input: [{type:"text", text: prompt}],
323
- // outputSchema: buildVerdictSchema(), effort: "high",
324
- // sandboxPolicy: { type: "readOnly", networkAccess: false } }`
325
- // → receives `{ turn: Turn }`. Pin `turn.id`.
326
- // 4. Await `turn/completed` notification matching our threadId. Extract
327
- // the final agentMessage text via `turn.items.findLast(i =>
328
- // i.type === "agentMessage")?.text`.
329
- // 5. On abort: send `turn/interrupt { threadId, turnId }` best-effort.
330
- // 6. Return STRING (matches spawnCodex's return so the hook flow doesn't
331
- // need a translation layer).
332
- //
333
- // `args` shape (refined from ADR-090's `(state, prompt, options)`):
334
- // { connection, rpc, cwd, baseInstructions, prompt, model, timeoutMs,
335
- // abortSignal }
336
- //
337
- // Caller owns connection + rpc lifetime; submitReview does NOT close them.
338
- // Failures map to thrown errors with `.code` matching the existing
339
- // taggedError verdict set in codex-pair-watch.mjs (timeout, error,
340
- // parse_failed) so the surrounding hook flow handles them uniformly.
341
- // Wrap an rpc.request for the thread/start + turn/start dance so a
342
- // transport-layer failure (request timeout, connection closed/errored) on a
343
- // broker that handshook OK but then hung is tagged brokerFailure=true. Per
344
- // ADR-077 a post-handshake broker hang must be a silent fallback to the
345
- // per-edit spawn, but broker-rpc's timeout/close/error rejections are PLAIN
346
- // Errors lacking that marker, so runCodexWithFallback would otherwise
347
- // rethrow them as a hard verdict. broker-rpc rejects a transport failure with
348
- // either a PLAIN Error (timeout / connection-closed → no `.code`) or the RAW
349
- // socket error (transport "error" event → a STRING `.code` like ECONNRESET).
350
- // Only a genuine JSON-RPC error RESPONSE carries a NUMERIC `.code` (broker-rpc
351
- // sets it from env.error.code) — that's a real server-side verdict, not a
352
- // broker outage. So we tag everything EXCEPT numeric-coded errors.
353
- async function brokerRequest(rpc, method, params, timeoutMs, brokerPhase) {
354
- try {
355
- return await rpc.request(method, params, { timeoutMs });
356
- } catch (err) {
357
- if (err && typeof err === "object" && typeof err.code !== "number" && !err.brokerFailure) {
358
- err.verdict = "error";
359
- err.brokerFailure = true;
360
- err.brokerPhase = brokerPhase;
361
- }
362
- throw err;
363
- }
364
- }
365
-
366
- export async function submitReview(args) {
367
- const { rpc, connection, cwd, baseInstructions, prompt, model, timeoutMs = 60_000, abortSignal } = args;
368
- if (!rpc) throw new Error("submitReview: rpc client required");
369
- if (!connection) throw new Error("submitReview: connection required");
370
- if (typeof prompt !== "string" || prompt.length === 0) {
371
- throw new Error("submitReview: prompt required");
372
- }
373
-
374
- const deadline = Date.now() + timeoutMs;
375
- const remaining = () => Math.max(0, deadline - Date.now());
376
-
377
- // 1. thread/start (ephemeral — codex auto-discards after the turn).
378
- // approvalPolicy: "never" is mandatory for the hook context (no user
379
- // available to approve interactive prompts). sandbox: "read-only" denies
380
- // file writes from the reviewer.
381
- const threadResp = await brokerRequest(
382
- rpc,
383
- JSONRPC_METHODS.THREAD_START,
384
- {
385
- ephemeral: true,
386
- cwd,
387
- baseInstructions,
388
- model,
389
- approvalPolicy: "never",
390
- sandbox: "read-only",
391
- },
392
- remaining(),
393
- "thread_start",
394
- );
395
- const threadId = threadResp?.thread?.id;
396
- if (typeof threadId !== "string") {
397
- // M4 brokerFailure discriminator: thread_start failures indicate the
398
- // broker is broken at the protocol layer. Hook falls back to spawnCodex.
399
- const err = new Error("submitReview: thread/start returned no thread.id");
400
- err.verdict = "error";
401
- err.brokerFailure = true;
402
- err.brokerPhase = "thread_start";
403
- throw err;
404
- }
405
-
406
- // 2. Register the turn/completed waiter BEFORE turn/start. Codex can
407
- // emit turn/completed between the turn/start dispatch and the listener
408
- // registration if we order them the other way around (brainstorm
409
- // Risk #1).
410
- const completionPromise = rpc.waitFor(
411
- JSONRPC_NOTIFICATIONS.TURN_COMPLETED,
412
- (n) => n.params?.threadId === threadId,
413
- remaining(),
414
- );
415
-
416
- // 3. turn/start. outputSchema constrains the agent's final message to
417
- // the parser-compatible shape (parser.mjs::parseConcernsJson).
418
- const turnResp = await brokerRequest(
419
- rpc,
420
- JSONRPC_METHODS.TURN_START,
421
- {
422
- threadId,
423
- input: [{ type: "text", text: prompt }],
424
- outputSchema: buildVerdictSchema(),
425
- effort: "high",
426
- // Belt-and-suspenders: also pin turn-level sandbox + deny network.
427
- sandboxPolicy: { type: "readOnly", networkAccess: false },
428
- },
429
- remaining(),
430
- "turn_start",
431
- );
432
- const turnId = turnResp?.turn?.id;
433
- if (typeof turnId !== "string") {
434
- // M4 brokerFailure discriminator: turn_start failures = broker protocol
435
- // is broken. Hook falls back to spawnCodex.
436
- const err = new Error("submitReview: turn/start returned no turn.id");
437
- err.verdict = "error";
438
- err.brokerFailure = true;
439
- err.brokerPhase = "turn_start";
440
- throw err;
441
- }
442
-
443
- // 4. Wire cancellation. abortSignal abort → turn/interrupt + reject.
444
- let abortHandler = null;
445
- let interruptSent = false;
446
- // Multi-review M3 hotfix: track completion so a late abort (firing
447
- // AFTER completion resolves but BEFORE finally cleans up) doesn't
448
- // send a spurious turn/interrupt for an already-done turn.
449
- let completed = false;
450
- const abortPromise =
451
- abortSignal
452
- ? new Promise((_, reject) => {
453
- abortHandler = () => {
454
- // Early-return if we already got the completion. Closes the
455
- // abort-after-completion race window flagged in multi-review.
456
- if (completed) return;
457
- interruptSent = true;
458
- // Best-effort interrupt; don't await it on the abort path —
459
- // we want to reject the user-facing promise immediately.
460
- rpc
461
- .request(JSONRPC_METHODS.TURN_INTERRUPT, { threadId, turnId }, { timeoutMs: 2000 })
462
- .catch(() => {});
463
- // Multi-review M3 hotfix: use `verdict` not `code` — the
464
- // hook's verdictFromError reads err.verdict. "aborted" is
465
- // not in VERDICT_PREFIXES so map to "error".
466
- const err = new Error("submitReview: aborted");
467
- err.verdict = "error";
468
- err.aborted = true; // structured marker for callers who care
469
- reject(err);
470
- };
471
- if (abortSignal.aborted) abortHandler();
472
- else abortSignal.addEventListener("abort", abortHandler);
473
- })
474
- : null;
475
-
476
- // 5. Race the completion against the abort.
477
- let completion;
478
- try {
479
- completion = abortPromise
480
- ? await Promise.race([completionPromise, abortPromise])
481
- : await completionPromise;
482
- completed = true;
483
- } catch (err) {
484
- // On timeout (waitFor rejects), send best-effort interrupt so we
485
- // don't leak a server-side turn. Multi-review M3 hotfix: use the
486
- // structured err.timeout marker from broker-rpc, not regex on message.
487
- if (!interruptSent && err && err.timeout === true) {
488
- rpc.request(JSONRPC_METHODS.TURN_INTERRUPT, { threadId, turnId }, { timeoutMs: 2000 }).catch(() => {});
489
- const wrapped = new Error("submitReview: turn timed out");
490
- wrapped.verdict = "timeout";
491
- wrapped.timeout = true;
492
- throw wrapped;
493
- }
494
- throw err;
495
- } finally {
496
- if (abortHandler && abortSignal) {
497
- abortSignal.removeEventListener("abort", abortHandler);
498
- }
499
- // If the abort handler already fired but we'd completed, it sent a
500
- // spurious interrupt and rejected an unawaited promise. The flag
501
- // above prevents that — abortHandler now early-returns if completed.
502
- }
503
-
504
- // 6. Extract the final agentMessage from `turn.items`. Brainstorm
505
- // confirmed multiple `agentMessage` items can appear (reasoning summaries
506
- // vs final answer); `findLast` picks the last/final one.
507
- // `completed` is read by the abortHandler closure to detect the
508
- // abort-after-completion race; biome won't strip it.
509
- const turn = completion?.params?.turn;
510
- if (!turn || !Array.isArray(turn.items)) {
511
- // M4: protocol-layer failure → brokerFailure → hook falls back.
512
- const err = new Error("submitReview: turn/completed missing turn.items");
513
- err.verdict = "parse_failed";
514
- err.brokerFailure = true;
515
- err.brokerPhase = "protocol";
516
- throw err;
517
- }
518
- if (turn.status === "failed" || turn.status === "interrupted") {
519
- const err = new Error(
520
- `submitReview: turn ${turn.status}${turn.error?.message ? ` — ${turn.error.message}` : ""}`,
521
- );
522
- err.verdict = "error";
523
- throw err;
524
- }
525
- const finalMessage = turn.items.findLast?.((i) => i?.type === "agentMessage");
526
- if (!finalMessage || typeof finalMessage.text !== "string") {
527
- // M4: protocol-layer failure (broker spoke turn/completed but with no
528
- // agentMessage). Mark brokerFailure so hook falls back to spawnCodex —
529
- // this is a broker-broken state, not a real codex result.
530
- const err = new Error("submitReview: turn/completed has no agentMessage item");
531
- err.verdict = "parse_failed";
532
- err.brokerFailure = true;
533
- err.brokerPhase = "protocol";
534
- throw err;
535
- }
536
- return finalMessage.text;
537
- }