@vanillagreen/pi-claude-bridge 1.9.0 → 3.2.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.
@@ -6,11 +6,47 @@
6
6
  //
7
7
  // Extracted from index.ts so tests can import without activating the extension.
8
8
 
9
+ import type { ContentBlockParam } from "@anthropic-ai/sdk/resources";
9
10
  import type { AssistantMessage, AssistantMessageEventStream, Model } from "@earendil-works/pi-ai";
11
+ import { isConnectorTool } from "./connectors.js";
10
12
  import type { McpResult } from "./extract-tool-results.js";
13
+ import { currentRequestLaneId } from "./request-lane.js";
14
+
15
+ /** A mid-query user run captured for replay after the active query ends.
16
+ * `text` is the joined text form (previews, and the replay prompt when no
17
+ * image blocks were captured). `blocks` is present when the run carried
18
+ * images — the replay must send the blocks or the images are silently lost
19
+ * (vstack#993). */
20
+ export interface DeferredUserMessage {
21
+ text: string;
22
+ blocks?: ContentBlockParam[];
23
+ }
24
+
25
+ /** Diag payload for a deferred-message drop: counts, sites, and lengths only.
26
+ * The messages are user-authored prompt text and the diag log sits outside
27
+ * any host app's retention boundary, so no content — not even a preview —
28
+ * may appear in the entry (VST-15). */
29
+ export function summarizeDroppedUserMessages(site: string, dropped: DeferredUserMessage[]): Record<string, unknown> {
30
+ return {
31
+ site,
32
+ count: dropped.length,
33
+ textLengths: dropped.map((message) => message.text.length),
34
+ imageOnlyCount: dropped.filter((message) => !message.text && message.blocks?.length).length,
35
+ };
36
+ }
11
37
 
12
38
  export interface PendingToolCall {
13
39
  toolName: string;
40
+ /** The MCP invocation's schema-validated arguments. The SDK hands the handler
41
+ * the COMPLETE input, so this is the authoritative copy — the grace-timer
42
+ * finalize settles a still-partial streamed block from here instead of from
43
+ * its truncated partial JSON (vstack#1469: a `{}` settle made Pi execute
44
+ * empty-argument calls). */
45
+ args: Record<string, unknown>;
46
+ /** `QueryContext.callbackGeneration` at registration. A handler from an older
47
+ * generation whose id was never forwarded to Pi can never be answered — see
48
+ * drainStrandedToolCalls. */
49
+ generation: number;
14
50
  resolve: (result: McpResult) => void;
15
51
  }
16
52
 
@@ -56,6 +92,86 @@ export function drainPendingToolCalls(queryCtx: QueryContext, cause: ToolCallDra
56
92
  return drained;
57
93
  }
58
94
 
95
+ /** The error a stranded handler resolves with: its call never reached Pi and
96
+ * the forward paths have marked it dead, so no result can ever arrive and the
97
+ * call is guaranteed not to have executed on the Pi side. */
98
+ export function strandedToolCallResult(): McpResult {
99
+ return {
100
+ content: [{ type: "text", text: "Claude bridge: this tool call was never forwarded to Pi before its turn ended, so it did not execute and no result can arrive. Re-run the tool." }],
101
+ isError: true,
102
+ };
103
+ }
104
+
105
+ /** Fail ONE waiting handler whose call never reached Pi. No-op when the id was
106
+ * forwarded (Pi owes it a result — steer-split deliveries arrive turns later)
107
+ * or nothing is waiting. Marks the id dead so a lagging stream replay can
108
+ * never forward it AFTER the model was told it failed — that late forward
109
+ * would execute the call a second time behind the model's back (vstack#1469).
110
+ * Returns true when a handler was failed. */
111
+ export function failStrandedToolCall(queryCtx: QueryContext, id: string): boolean {
112
+ if (queryCtx.forwardedToolCallIds.has(id)) return false;
113
+ const pending = queryCtx.pendingToolCalls.get(id);
114
+ if (!pending) return false;
115
+ queryCtx.pendingToolCalls.delete(id);
116
+ queryCtx.deadToolCallIds.add(id);
117
+ pending.resolve(strandedToolCallResult());
118
+ return true;
119
+ }
120
+
121
+ /** Fail every waiting handler that provably can never be answered: registered
122
+ * before the CURRENT provider callback (older `generation`) with an id Pi was
123
+ * never told about. Runs at the delivery site, where a fresh callback proves
124
+ * the previous turn is settled. Handlers whose id WAS forwarded stay waiting —
125
+ * Pi may deliver their result in a later callback (steer-split batches).
126
+ * Handlers from the current generation stay untouched: their turn is still
127
+ * streaming and the forward may simply not have happened yet. Failed ids are
128
+ * marked dead exactly like failStrandedToolCall. */
129
+ export function drainStrandedToolCalls(queryCtx: QueryContext): Array<{ id: string; toolName: string }> {
130
+ const stranded: Array<{ id: string; toolName: string }> = [];
131
+ for (const [id, pending] of queryCtx.pendingToolCalls) {
132
+ if (pending.generation >= queryCtx.callbackGeneration) continue;
133
+ if (queryCtx.forwardedToolCallIds.has(id)) continue;
134
+ stranded.push({ id, toolName: pending.toolName });
135
+ }
136
+ for (const { id } of stranded) {
137
+ const pending = queryCtx.pendingToolCalls.get(id)!;
138
+ queryCtx.pendingToolCalls.delete(id);
139
+ queryCtx.deadToolCallIds.add(id);
140
+ pending.resolve(strandedToolCallResult());
141
+ }
142
+ return stranded;
143
+ }
144
+
145
+ /** Consume a result waiting for `id`, checking the live queue first and the
146
+ * reap-parked store second. Late handlers land here: Pi delivers every result
147
+ * of a turn in one callback, while the SDK staggers handler invocations, so a
148
+ * handler can fire after a message boundary already parked its result. */
149
+ export function takeQueuedOrParkedResult(queryCtx: QueryContext, id: string): McpResult | undefined {
150
+ const queued = queryCtx.pendingResults.get(id);
151
+ if (queued !== undefined) {
152
+ queryCtx.pendingResults.delete(id);
153
+ return queued;
154
+ }
155
+ const parked = queryCtx.reapedResults.get(id);
156
+ if (parked !== undefined) {
157
+ queryCtx.reapedResults.delete(id);
158
+ return parked;
159
+ }
160
+ return undefined;
161
+ }
162
+
163
+ /** One connector call's audit state for the life of a query. `recorded` means an
164
+ * entry for it has already been appended (or attempted), so neither a re-yielded
165
+ * result nor the teardown flush can record it twice. */
166
+ export interface ConnectorCallAuditState {
167
+ name: string;
168
+ /** The child session that issued it, captured when the call was seen — a
169
+ * continuation query gets a new one, and a call is audited against the session
170
+ * that actually made it. */
171
+ childSessionId?: string;
172
+ recorded: boolean;
173
+ }
174
+
59
175
  export interface TurnToolCallRecord {
60
176
  id: string;
61
177
  toolName: string;
@@ -67,6 +183,12 @@ export interface ClaimedToolCall {
67
183
  match: "tool-args" | "tool-name" | "none";
68
184
  ambiguous: boolean;
69
185
  available: number;
186
+ /** True when the claim went through the sole-same-name fallback even though
187
+ * the recorded call had (different) arguments. Recorded args come from the
188
+ * raw streamed input while the handler receives the MCP server's
189
+ * schema-validated copy, so a benign divergence (stripped unknown key,
190
+ * applied default) must not strand the call — but it is worth a diagnostic. */
191
+ argsMismatch?: boolean;
70
192
  }
71
193
 
72
194
  export interface ToolResultProgress {
@@ -130,15 +252,150 @@ export class QueryContext {
130
252
  latestCursor = 0;
131
253
  pendingToolCalls = new Map<string, PendingToolCall>();
132
254
  pendingResults = new Map<string, McpResult>();
255
+ /** Results a message-boundary reap moved OUT of pendingResults so they stop
256
+ * poisoning mismatch reports, kept CONSUMABLE for a handler that fires later.
257
+ * The 2026-08-17 deadlock session showed the reap's "no consumer will ever
258
+ * come" assumption failing routinely: Pi delivers a turn's results in one
259
+ * callback while the SDK staggers handler invocations past the next message
260
+ * boundary. Query-scoped, bounded by the query's tool-call count. */
261
+ reapedResults = new Map<string, McpResult>();
262
+ /** Every tool-call id this query has handed to Pi inside an ENDED turn — the
263
+ * set endToolUseTurn stamps from the turn's content. A forwarded id is one Pi
264
+ * will execute and answer; it must never be emitted again (a lagging stream
265
+ * replays the same tool_use into the NEXT turn, and per-message turnBlocks
266
+ * dedup cannot see across turns — vstack#1469's duplicate executions), and a
267
+ * handler waiting on it must be left waiting at the stranded-handler drains.
268
+ * Query-scoped, never reset per message. */
269
+ forwardedToolCallIds = new Set<string>();
270
+ /** Ids whose waiting handler was resolved with strandedToolCallResult. The
271
+ * model has been told these calls failed; forwarding one later would execute
272
+ * it behind the model's back, so every forward path skips them. */
273
+ deadToolCallIds = new Set<string>();
274
+ /** Streamed block indexes suppressed as duplicate or dead tool_use blocks —
275
+ * their deltas and stops must be ignored the same way child-executed indexes
276
+ * are. Per message; reset by resetToolTracking. */
277
+ suppressedStreamIndexes = new Set<number>();
278
+ /** Bumped at every provider callback for this query. Stamped onto handlers at
279
+ * registration so the stranded-handler drain can tell "registered before this
280
+ * callback, provably settled" from "racing this callback's own stream". */
281
+ callbackGeneration = 0;
133
282
  turnToolCallIds: string[] = [];
134
283
  turnToolCalls: TurnToolCallRecord[] = [];
284
+ /**
285
+ * id → Pi tool name for every tool call this QUERY recorded, across all child
286
+ * messages. Deliberately NOT cleared by resetToolTracking: per-message tracking
287
+ * resets at every message boundary, but `pendingResults` is query-scoped, so a
288
+ * result stranded there outlives the message that named it. Without this map a
289
+ * teardown report can only say "1 queued" with empty toolNames and 0/0
290
+ * counters — which is exactly the unactionable record the 2026-07-28 diag log
291
+ * showed. Bounded by the number of tool calls in one query.
292
+ */
293
+ queryToolNames = new Map<string, string>();
294
+ /** id → last-known arguments, query-scoped like queryToolNames and for the
295
+ * same reason: a late handler firing after resetToolTracking wiped the
296
+ * per-message records must still be able to exact-match the parked/queued
297
+ * result of ITS OWN call — without stored args the only fallback is
298
+ * sole-same-name, which can hand it a LIVE sibling's id (vstack#1469). */
299
+ queryToolArgs = new Map<string, Record<string, unknown>>();
135
300
  claimedToolCallIds = new Set<string>();
136
301
  deliveredToolResultIds = new Set<string>();
137
302
  resolvedToolResultIds = new Set<string>();
138
303
  unmatchedToolResultIds = new Set<string>();
139
304
  reportedToolResultMismatch = false;
140
- deferredUserMessages: string[] = [];
305
+ deferredUserMessages: DeferredUserMessage[] = [];
141
306
  handledTerminalError = false;
307
+ // Once visible text/thinking, a complete tool call, or a child-executed
308
+ // CONNECTOR dispatch reaches Pi, the request must never be replayed on
309
+ // another account (duplicate side effects). Query-scoped, not per-turn:
310
+ // resetTurnState must not clear it.
311
+ committedOutput = false;
312
+ /** True when this query holds NO claim on the module-level shared session
313
+ * record: a reentrant (subagent) query, or a foreign-conversation one-shot
314
+ * (vstack#1001). Every shared-record mutation reachable from this context —
315
+ * reportToolResultMismatch's needsRebuild/forceRotate mark, the cursor
316
+ * advances on the tool-result-delivery and orphaned-result paths — must
317
+ * no-op so the PARENT's record stays untouched. Assigned at fresh-query
318
+ * setup; deliberately NOT cleared at query end, so a late orphaned tool
319
+ * result arriving after this query settled is still attributed to it. */
320
+ detachedFromSharedSession = false;
321
+ /** Armed grace timer for ending a tool_use turn whose terminal stream events
322
+ * (message_delta/message_stop) never arrive. The normal path ends the turn at
323
+ * message_stop, AFTER message_delta delivered the real output-token count;
324
+ * this is the deadlock backstop for streams that go silent instead. Managed
325
+ * by schedule/cancelToolUseTurnEnd in assistant-stream.ts. */
326
+ scheduledToolUseEnd: { stream: unknown; timer: ReturnType<typeof setTimeout> } | null = null;
327
+
328
+ // Tool calls the CHILD executes itself (see isChildExecutedTool).
329
+ // Deliberately NOT in turnToolCalls/turnToolCallIds: those track calls Pi
330
+ // owes a result for, and Pi owes nothing here. CONNECTORS ONLY — kept so the
331
+ // child's real result can be recognized when it comes back on the SDK's
332
+ // `user` message and audited. A child-internal built-in (ToolSearch et al.)
333
+ // never enters this map: its result needs no recognition and no audit, only
334
+ // its streamed deltas need skipping (childExecutedStreamIndexes below).
335
+ /** tool_use id → raw SDK tool name. */
336
+ childExecutedToolCalls = new Map<string, string>();
337
+ /**
338
+ * The same calls, for the connector-call audit trail (see connector-audit.ts).
339
+ *
340
+ * Query-scoped and deliberately NOT cleared by resetToolTracking: that runs at
341
+ * every child message boundary, and a call issued in one child message is only
342
+ * reconciled after that message ends. Clearing it there would make an abandoned
343
+ * call unrecordable at teardown — which is the one case the trail exists for.
344
+ */
345
+ connectorCallAudit = new Map<string, ConnectorCallAuditState>();
346
+ /** Claude Code session id for this query, from the SDK's `system` init message.
347
+ * Undefined until it arrives; the audit trail omits the field rather than
348
+ * guessing. */
349
+ childSessionId: string | undefined;
350
+ /** Anthropic content-block indexes of the current assistant message that carry
351
+ * a child-executed tool_use. Scoped to one message: cleared at message_start,
352
+ * and an index is released as soon as a new block starts there. */
353
+ childExecutedStreamIndexes = new Set<number>();
354
+
355
+ // Usage accounting for a Pi turn that spans SEVERAL child assistant messages.
356
+ //
357
+ // Every child message is a separate billed API call, and each reports its own
358
+ // counters — `message_start`/`message_delta` REPLACE rather than accumulate. A
359
+ // Pi turn used to end at the first tool call, so one Pi message meant one child
360
+ // message and replacing was right. A turn containing a child-executed connector
361
+ // call now keeps running across the child's follow-up messages, so replacing
362
+ // would silently drop everything the earlier ones billed (measured: 55,685
363
+ // cache-write tokens lost on a single connector turn).
364
+ //
365
+ // So: `turnUsageCarry` holds the totals of the child messages already COMPLETE
366
+ // in this Pi turn, `currentMessageUsage` holds the one in flight, and the Pi
367
+ // message reports their sum. Summing is the correct model for input and cache
368
+ // too — each call bills its own.
369
+ turnUsageCarry = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
370
+ currentMessageUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
371
+ /** Anthropic id of the child message `currentMessageUsage` describes. */
372
+ currentMessageId: string | undefined;
373
+
374
+ /**
375
+ * Declare which child message the following usage belongs to, banking the
376
+ * previous one's counters into the turn total.
377
+ *
378
+ * Keyed on the MESSAGE ID rather than on the call site, because both paths
379
+ * that see a message boundary can fire for the SAME message: `message_start`
380
+ * arrives on the stream, and the SDK then yields that message again in
381
+ * completed form. Banking per call site double-counted whenever the completed
382
+ * copy took the no-stream-events branch — which it does whenever a message
383
+ * produced no content blocks, since `turnSawStreamEvent` only tracks those.
384
+ *
385
+ * With no id on either side (older/streamless shapes) this degrades to
386
+ * banking on every call, which is what each caller means when it cannot
387
+ * prove otherwise.
388
+ */
389
+ beginChildMessage(messageId?: unknown): void {
390
+ const id = typeof messageId === "string" && messageId.length > 0 ? messageId : undefined;
391
+ if (id !== undefined && id === this.currentMessageId) return; // same message
392
+ this.turnUsageCarry.input += this.currentMessageUsage.input;
393
+ this.turnUsageCarry.output += this.currentMessageUsage.output;
394
+ this.turnUsageCarry.cacheRead += this.currentMessageUsage.cacheRead;
395
+ this.turnUsageCarry.cacheWrite += this.currentMessageUsage.cacheWrite;
396
+ this.currentMessageUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
397
+ this.currentMessageId = id;
398
+ }
142
399
 
143
400
  // Per-turn (reset together)
144
401
  turnOutput: AssistantMessage | null = null;
@@ -163,6 +420,17 @@ export class QueryContext {
163
420
  this.turnSawStreamEvent = false;
164
421
  this.turnSawToolCall = false;
165
422
  this.handledTerminalError = false;
423
+ // A new pi message means the previous turn's stream is done with; an armed
424
+ // end-timer for it must not fire into the new turn's state.
425
+ if (this.scheduledToolUseEnd) {
426
+ clearTimeout(this.scheduledToolUseEnd.timer);
427
+ this.scheduledToolUseEnd = null;
428
+ }
429
+ // Usage accounting IS per-Pi-message, so it resets with the message it
430
+ // describes — unlike tool-call tracking below.
431
+ this.turnUsageCarry = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
432
+ this.currentMessageUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
433
+ this.currentMessageId = undefined;
166
434
  // Tool-call tracking is NOT reset here — it persists across the
167
435
  // tool-result delivery callback for the same assistant message. New
168
436
  // assistant messages call resetToolTracking() explicitly.
@@ -176,10 +444,47 @@ export class QueryContext {
176
444
  this.resolvedToolResultIds.clear();
177
445
  this.unmatchedToolResultIds.clear();
178
446
  this.reportedToolResultMismatch = false;
447
+ this.childExecutedToolCalls.clear();
448
+ this.childExecutedStreamIndexes.clear();
449
+ this.suppressedStreamIndexes.clear();
450
+ }
451
+
452
+ /** Note a tool_use the child runs itself. `streamIndex` is present only on the
453
+ * streamed path, where later deltas/stops for that block must be skipped —
454
+ * that skip applies to every child-executed call. Result recognition and the
455
+ * connector-call audit apply to CONNECTORS only: a child-internal built-in
456
+ * (ToolSearch et al.) is tool plumbing, not account-data access, so nothing
457
+ * about it belongs in the audit trail and no result needs matching. */
458
+ noteChildExecutedToolCall(id: string | undefined, rawName: string, streamIndex?: number): void {
459
+ if (isConnectorTool(rawName)) {
460
+ // A connector call is an account-visible side effect the child may run
461
+ // before any Pi-visible event; crossing it permanently forbids account
462
+ // replay even when the result or a later text delta never arrives.
463
+ // Child-internal built-ins (ToolSearch et al.) are pure plumbing and
464
+ // deliberately do NOT commit — an early ToolSearch must not make the
465
+ // whole turn non-rotatable.
466
+ this.markOutputCommitted();
467
+ }
468
+ if (id && isConnectorTool(rawName)) {
469
+ this.childExecutedToolCalls.set(id, rawName);
470
+ // Both emission paths can see the same call (streamed block, then the
471
+ // SDK's completed copy), so never overwrite an existing audit state —
472
+ // that would resurrect one already recorded.
473
+ if (!this.connectorCallAudit.has(id)) {
474
+ this.connectorCallAudit.set(id, {
475
+ name: rawName,
476
+ ...(this.childSessionId ? { childSessionId: this.childSessionId } : {}),
477
+ recorded: false,
478
+ });
479
+ }
480
+ }
481
+ if (typeof streamIndex === "number") this.childExecutedStreamIndexes.add(streamIndex);
179
482
  }
180
483
 
181
484
  recordToolCall(id: string | undefined, toolName: string, args: Record<string, unknown> = {}): void {
182
485
  if (!id) return;
486
+ this.queryToolNames.set(id, toolName);
487
+ this.queryToolArgs.set(id, args);
183
488
  if (!this.turnToolCallIds.includes(id)) this.turnToolCallIds.push(id);
184
489
  const existing = this.turnToolCalls.find((call) => call.id === id);
185
490
  if (existing) {
@@ -192,6 +497,7 @@ export class QueryContext {
192
497
 
193
498
  updateToolCallArgs(id: string | undefined, args: Record<string, unknown>): void {
194
499
  if (!id) return;
500
+ this.queryToolArgs.set(id, args);
195
501
  const existing = this.turnToolCalls.find((call) => call.id === id);
196
502
  if (existing) existing.arguments = args;
197
503
  }
@@ -200,30 +506,99 @@ export class QueryContext {
200
506
  return Boolean(id && (this.turnToolCallIds.includes(id) || this.turnToolCalls.some((call) => call.id === id)));
201
507
  }
202
508
 
509
+ markOutputCommitted(): void {
510
+ this.committedOutput = true;
511
+ }
512
+
203
513
  claimToolCall(toolName: string, args: Record<string, unknown> = {}): ClaimedToolCall {
204
514
  const unclaimed = this.turnToolCalls.filter((call) => !this.claimedToolCallIds.has(call.id));
205
515
  const byName = unclaimed.filter((call) => call.toolName === toolName);
206
516
  const exact = byName.filter((call) => sameArgs(call.arguments, args));
517
+ // Ids whose RESULT already sits queued or parked. A handler can fire after
518
+ // the message boundary wiped the per-message records — by then Pi has
519
+ // executed its call and only these query-scoped stores still know it
520
+ // (vstack#1469: the boundary reap used to make such a handler error out
521
+ // and the model re-run an already-executed side-effectful call). An
522
+ // exact-args match here outranks the live sole-same-name fallback below,
523
+ // so a late handler can never steal a live sibling's id while its own
524
+ // result waits; without an exact match it is only a last resort.
525
+ const resultBacked = [...new Set([...this.pendingResults.keys(), ...this.reapedResults.keys()])]
526
+ .filter((id) => !this.claimedToolCallIds.has(id) && this.queryToolNames.get(id) === toolName);
527
+ const backedExact = resultBacked.filter((id) => sameArgs(this.queryToolArgs.get(id), args));
528
+ const claimBacked = (id: string, viaExact: boolean): ClaimedToolCall => {
529
+ this.claimedToolCallIds.add(id);
530
+ return {
531
+ toolCallId: id,
532
+ match: viaExact ? "tool-args" : "tool-name",
533
+ ambiguous: viaExact && backedExact.length > 1,
534
+ available: unclaimed.length,
535
+ ...(!viaExact && hasRecordedArgs(this.queryToolArgs.get(id)) ? { argsMismatch: true } : {}),
536
+ };
537
+ };
207
538
  let chosen: TurnToolCallRecord | undefined;
208
539
  let match: ClaimedToolCall["match"] = "none";
209
540
  let ambiguous = false;
210
541
 
542
+ let argsMismatch = false;
211
543
  if (exact.length > 0) {
212
544
  chosen = exact[0];
213
545
  match = "tool-args";
214
546
  ambiguous = exact.length > 1;
215
- } else if (byName.length === 1 && !hasRecordedArgs(byName[0].arguments)) {
216
- // The SDK can invoke the MCP handler after content_block_start but
217
- // before input_json_delta/content_block_stop finalizes arguments.
218
- // Falling back to the sole same-name, argument-less call preserves that
219
- // race without ever claiming a different tool type.
547
+ } else if (backedExact.length > 0) {
548
+ return claimBacked(backedExact[0], true);
549
+ } else if (byName.length === 1) {
550
+ // A single unclaimed call of this tool type is the only call this
551
+ // handler can possibly belong to, so claim it even when the recorded
552
+ // arguments differ. Two known benign sources of divergence:
553
+ // - the SDK can invoke the handler after content_block_start but
554
+ // before input_json_delta/content_block_stop finalizes arguments,
555
+ // so the record still holds a partial parse;
556
+ // - the handler receives the MCP server's schema-VALIDATED copy of
557
+ // the input (zod may strip unknown keys or apply defaults) while
558
+ // the record holds the raw streamed input.
559
+ // Refusing here stranded the call outright: the handler errored into
560
+ // the child while pi's real result sat queued forever (diag log
561
+ // 2026-07-28, `edit` with argKeys [edits, path] on both sides). A
562
+ // same-type sole-candidate claim is strictly safer than that. With
563
+ // SEVERAL same-name candidates and no exact match we still refuse —
564
+ // cross-pairing two live calls is the one outcome worse than failing.
220
565
  chosen = byName[0];
221
566
  match = "tool-name";
567
+ argsMismatch = hasRecordedArgs(byName[0].arguments);
222
568
  }
223
569
 
570
+ // Last resort: nothing live matched and no exact result-backed pairing —
571
+ // a sole result-backed same-name id is still this handler's only possible
572
+ // owner, same reasoning as the live sole-candidate fallback above.
573
+ if (!chosen && resultBacked.length === 1) return claimBacked(resultBacked[0], false);
574
+
224
575
  if (!chosen) return { match: "none", ambiguous: false, available: unclaimed.length };
225
576
  this.claimedToolCallIds.add(chosen.id);
226
- return { toolCallId: chosen.id, match, ambiguous, available: unclaimed.length };
577
+ return { toolCallId: chosen.id, match, ambiguous, available: unclaimed.length, ...(argsMismatch ? { argsMismatch } : {}) };
578
+ }
579
+
580
+ /**
581
+ * Move results still queued in `pendingResults` into the parked store and
582
+ * report what moved.
583
+ *
584
+ * Called at a child MESSAGE boundary (message_start / the no-stream-events
585
+ * assistant fallback). Left in pendingResults, each entry poisons every later
586
+ * mismatch report for the whole query (queued>0 with 0/0 counters and no tool
587
+ * names) and forces a session rebuild per turn. But the boundary does NOT
588
+ * prove the handler gave up — the SDK staggers handler invocations, and the
589
+ * 2026-08-17 deadlock session (vstack#1469) had three of five parallel
590
+ * handlers fire after this reap destroyed their results. So the reap parks
591
+ * instead of dropping: reports stay clean, and a late handler still gets its
592
+ * real result through takeQueuedOrParkedResult.
593
+ */
594
+ takeStaleQueuedResults(): Array<{ id: string; toolName: string }> {
595
+ if (this.pendingResults.size === 0) return [];
596
+ const stale = [...this.pendingResults.entries()].map(([id, result]) => {
597
+ this.reapedResults.set(id, result);
598
+ return { id, toolName: this.queryToolNames.get(id) ?? "unknown" };
599
+ });
600
+ this.pendingResults.clear();
601
+ return stale;
227
602
  }
228
603
 
229
604
  markToolResultDelivered(id: string | undefined): void {
@@ -252,9 +627,21 @@ export class QueryContext {
252
627
  const unresolvedIds = expectedIds.filter((id) => !this.resolvedToolResultIds.has(id));
253
628
  const affectedIds = new Set([...missingDeliveredIds, ...unresolvedIds, ...waitingIds, ...queuedIds, ...unmatchedResultIds]);
254
629
  const counts = new Map<string, number>();
255
- for (const call of this.turnToolCalls) {
256
- if (affectedIds.size > 0 && !affectedIds.has(call.id)) continue;
257
- counts.set(call.toolName, (counts.get(call.toolName) ?? 0) + 1);
630
+ if (affectedIds.size > 0) {
631
+ // Name the affected ids from the query-scoped map, not just this
632
+ // message's records: a queued straggler from an earlier child message is
633
+ // exactly the case a mismatch report exists for, and this message's
634
+ // turnToolCalls no longer knows it.
635
+ for (const id of affectedIds) {
636
+ const name = this.queryToolNames.get(id)
637
+ ?? this.turnToolCalls.find((call) => call.id === id)?.toolName
638
+ ?? "unknown";
639
+ counts.set(name, (counts.get(name) ?? 0) + 1);
640
+ }
641
+ } else {
642
+ for (const call of this.turnToolCalls) {
643
+ counts.set(call.toolName, (counts.get(call.toolName) ?? 0) + 1);
644
+ }
258
645
  }
259
646
  return {
260
647
  expectedIds,
@@ -278,29 +665,107 @@ export class QueryContext {
278
665
  }
279
666
  }
280
667
 
281
- let _ctx = new QueryContext();
282
- const contextStack: QueryContext[] = [];
668
+ interface QueryLaneState {
669
+ current: QueryContext;
670
+ stack: QueryContext[];
671
+ }
283
672
 
284
- export function ctx(): QueryContext { return _ctx; }
673
+ interface QueryLaneStoreV1 {
674
+ defaultLane: QueryLaneState;
675
+ sessionLanes: Map<string, QueryLaneState>;
676
+ }
285
677
 
286
- export function stackDepth(): number { return contextStack.length; }
678
+ const QUERY_LANES_SYMBOL = Symbol.for("vstack.pi.claude-bridge.query-lanes.v1");
679
+
680
+ function queryLaneStore(): QueryLaneStoreV1 {
681
+ const host = globalThis as Record<symbol, unknown>;
682
+ let store = host[QUERY_LANES_SYMBOL] as QueryLaneStoreV1 | undefined;
683
+ if (!store) {
684
+ store = {
685
+ defaultLane: { current: new QueryContext(), stack: [] },
686
+ sessionLanes: new Map(),
687
+ };
688
+ host[QUERY_LANES_SYMBOL] = store;
689
+ }
690
+ return store;
691
+ }
692
+
693
+ function lane(): QueryLaneState {
694
+ const store = queryLaneStore();
695
+ const sessionId = currentRequestLaneId();
696
+ if (sessionId === undefined) return store.defaultLane;
697
+ let state = store.sessionLanes.get(sessionId);
698
+ if (!state) {
699
+ state = { current: new QueryContext(), stack: [] };
700
+ store.sessionLanes.set(sessionId, state);
701
+ }
702
+ return state;
703
+ }
704
+
705
+ export function ctx(): QueryContext { return lane().current; }
706
+
707
+ export function stackDepth(): number { return lane().stack.length; }
287
708
 
288
709
  export function pushContext(): void {
289
- if (!_ctx.activeQuery) throw new Error("pushContext() called with no active query");
290
- contextStack.push(_ctx);
291
- _ctx = new QueryContext();
710
+ const state = lane();
711
+ if (!state.current.activeQuery) throw new Error("pushContext() called with no active query");
712
+ state.stack.push(state.current);
713
+ state.current = new QueryContext();
292
714
  }
293
715
 
294
716
  export function popContext(): void {
295
- if (contextStack.length === 0) throw new Error("popContext() called with empty stack");
296
- const parent = contextStack[contextStack.length - 1];
297
- parent.deferredUserMessages.push(..._ctx.deferredUserMessages);
298
- _ctx = contextStack.pop()!;
717
+ const state = lane();
718
+ if (state.stack.length === 0) throw new Error("popContext() called with empty stack");
719
+ const parent = state.stack[state.stack.length - 1];
720
+ parent.deferredUserMessages.push(...state.current.deferredUserMessages);
721
+ state.current = state.stack.pop()!;
722
+ }
723
+
724
+ /** Pop the context that belongs to ONE specific query, wherever it sits.
725
+ *
726
+ * The common case is `target === ctx()` and this is exactly popContext(). The
727
+ * reason this exists: a reentrant parent query can end ABNORMALLY (abort, child
728
+ * process death) while its own subagent's context is still pushed above it. A
729
+ * bare popContext() there would discard the live grandchild's context and
730
+ * merge the wrong deferred messages. Instead, splice `target` out of the stack
731
+ * and hand its deferred messages to its own parent (the element below it), so
732
+ * the still-live contexts above keep their positions and later pops restore
733
+ * the correct lineage. Returns false when `target` is nowhere in the state —
734
+ * already popped — so callers can treat that as "someone else tore this down". */
735
+ export function popContextFor(target: QueryContext): boolean {
736
+ const state = lane();
737
+ if (state.current === target) {
738
+ popContext();
739
+ return true;
740
+ }
741
+ const idx = state.stack.indexOf(target);
742
+ if (idx < 0) return false;
743
+ const parent = idx > 0 ? state.stack[idx - 1] : undefined;
744
+ parent?.deferredUserMessages.push(...target.deferredUserMessages);
745
+ state.stack.splice(idx, 1);
746
+ return true;
299
747
  }
300
748
 
301
- // Test-only: drop all state so test files can start from a clean module.
302
- // Not called from production.
749
+ // Test-only: drop every lane so test files can start clean.
303
750
  export function resetStack(): void {
304
- _ctx = new QueryContext();
305
- contextStack.length = 0;
751
+ clearQueryLanes();
752
+ }
753
+
754
+ export function deleteQueryLane(sessionId: string | undefined): void {
755
+ const store = queryLaneStore();
756
+ if (sessionId === undefined) {
757
+ store.defaultLane.current = new QueryContext();
758
+ store.defaultLane.stack.length = 0;
759
+ } else store.sessionLanes.delete(sessionId);
760
+ }
761
+
762
+ export function clearQueryLanes(): void {
763
+ const store = queryLaneStore();
764
+ store.sessionLanes.clear();
765
+ store.defaultLane.current = new QueryContext();
766
+ store.defaultLane.stack.length = 0;
767
+ }
768
+
769
+ export function __testQueryLaneCount(): number {
770
+ return queryLaneStore().sessionLanes.size;
306
771
  }
@@ -0,0 +1,45 @@
1
+ // End-of-query teardown, extracted from streamClaudeAgentSdk's .finally so it
2
+ // operates on the ONE context captured at query start — never the live ctx().
3
+ // The two only differ while a reentrant (subagent) context is pushed, which is
4
+ // exactly when a parent query ending abnormally (abort, child process death)
5
+ // used to run this against the subagent's state: the parent's drain, audit
6
+ // flush, and activeQuery clear were skipped, leaking its pending MCP handlers.
7
+
8
+ import { reportToolResultMismatch } from "./bridge-state.js";
9
+ import { flushConnectorCallAudit } from "./connector-audit.js";
10
+ import { debug } from "./debug.js";
11
+ import { drainPendingToolCalls, popContextFor, type QueryContext, type ToolCallDrainCause } from "./query-state.js";
12
+
13
+ /** Tear down `queryCtx` after its SDK query settled. No-ops when the query is
14
+ * no longer the context's active one (a continuation replaced it, or teardown
15
+ * already ran). Returns true when teardown actually ran. */
16
+ export function teardownQuery(
17
+ queryCtx: QueryContext,
18
+ sdkQuery: unknown,
19
+ cause: ToolCallDrainCause,
20
+ cwd: string,
21
+ isReentrant: boolean,
22
+ ): boolean {
23
+ if (queryCtx.activeQuery !== sdkQuery) return false;
24
+ reportToolResultMismatch(queryCtx, "query teardown", cwd, { forceRotate: cause !== "query-end" });
25
+ // Drain pending handlers for this query as errors naming the cause —
26
+ // their results are never coming.
27
+ const drained = drainPendingToolCalls(queryCtx, cause);
28
+ if (drained > 0) debug(`provider: query teardown drained ${drained} waiting MCP handler(s) as errors (cause=${cause})`);
29
+ queryCtx.pendingResults.clear();
30
+
31
+ // Same idea for calls the CHILD owned: one whose result never came back
32
+ // is recorded as unobserved rather than left silent, so an answer in the
33
+ // transcript is never the only evidence a connector call was made.
34
+ const unobserved = flushConnectorCallAudit(queryCtx, cause);
35
+ if (unobserved > 0) debug(`provider: query teardown recorded ${unobserved} connector call(s) with no observed result (cause=${cause})`);
36
+
37
+ if (isReentrant) {
38
+ // Merges deferred messages and restores/repairs the stack. popContextFor
39
+ // (not popContext): a live subagent context may sit above this one.
40
+ if (!popContextFor(queryCtx)) debug("provider: query teardown found context already popped; skipping pop");
41
+ } else {
42
+ queryCtx.activeQuery = null;
43
+ }
44
+ return true;
45
+ }