@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.
@@ -1,16 +1,29 @@
1
1
  import { calculateCost, type AssistantMessage, type Model } from "@earendil-works/pi-ai";
2
2
  import { type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
3
- import { debug } from "./debug.js";
4
- import { ctx } from "./query-state.js";
3
+ import { appendIntegrityEntry, safeNotify } from "./bridge-state.js";
4
+ import { connectorResultByteSize, recordConnectorCallResult } from "./connector-audit.js";
5
+ import { isChildExecutedTool } from "./connectors.js";
6
+ import { debug, diagDump } from "./debug.js";
7
+ import { ctx, failStrandedToolCall, type QueryContext } from "./query-state.js";
5
8
  import { mapToolArgs, mapToolName } from "./tool-mapping.js";
6
9
 
7
10
  // --- Usage helpers ---
8
11
 
9
- function updateUsage(output: AssistantMessage, usage: Record<string, number | undefined>, model: Model<any>): void {
10
- if (usage.input_tokens != null) output.usage.input = usage.input_tokens;
11
- if (usage.output_tokens != null) output.usage.output = usage.output_tokens;
12
- if (usage.cache_read_input_tokens != null) output.usage.cacheRead = usage.cache_read_input_tokens;
13
- if (usage.cache_creation_input_tokens != null) output.usage.cacheWrite = usage.cache_creation_input_tokens;
12
+ function updateUsage(output: AssistantMessage, usage: Record<string, number | undefined>, model: Model<any>, c: QueryContext): void {
13
+ // Anthropic reports per-message counters and RE-reports them as the message
14
+ // grows, so the in-flight message's figures are replaced, not added. What is
15
+ // added is every child message already finished in this Pi turn — see
16
+ // `turnUsageCarry` in query-state.ts for why a turn can span several.
17
+ const current = c.currentMessageUsage;
18
+ const carry = c.turnUsageCarry;
19
+ if (usage.input_tokens != null) current.input = usage.input_tokens;
20
+ if (usage.output_tokens != null) current.output = usage.output_tokens;
21
+ if (usage.cache_read_input_tokens != null) current.cacheRead = usage.cache_read_input_tokens;
22
+ if (usage.cache_creation_input_tokens != null) current.cacheWrite = usage.cache_creation_input_tokens;
23
+ output.usage.input = carry.input + current.input;
24
+ output.usage.output = carry.output + current.output;
25
+ output.usage.cacheRead = carry.cacheRead + current.cacheRead;
26
+ output.usage.cacheWrite = carry.cacheWrite + current.cacheWrite;
14
27
  output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
15
28
  calculateCost(model, output.usage);
16
29
  const promptTokens = output.usage.input + output.usage.cacheRead + output.usage.cacheWrite;
@@ -33,39 +46,265 @@ export function parsePartialJson(input: string, fallback: Record<string, unknown
33
46
  try { return JSON.parse(input); } catch { return fallback; }
34
47
  }
35
48
 
36
- export function ensureTurnStarted(): void {
37
- if (!ctx().turnStarted && ctx().currentPiStream && ctx().turnOutput) {
38
- ctx().currentPiStream!.push({ type: "start", partial: ctx().turnOutput });
39
- ctx().turnStarted = true;
49
+ // Both take the query context explicitly (defaulting to the live one) so the
50
+ // completion/teardown closures in index.ts can finalize the stream of the query
51
+ // they were created for — under reentrancy the live ctx() is the subagent's.
52
+ export function ensureTurnStarted(c: QueryContext = ctx()): void {
53
+ if (!c.turnStarted && c.currentPiStream && c.turnOutput) {
54
+ c.currentPiStream.push({ type: "start", partial: c.turnOutput });
55
+ c.turnStarted = true;
40
56
  }
41
57
  }
42
58
 
43
- export function finalizeCurrentStream(stopReason?: string): void {
44
- if (!ctx().currentPiStream || !ctx().turnOutput) return;
45
- debug(`provider: finalizeCurrentStream called, stopReason=${stopReason}, turnOutput=${JSON.stringify({stopReason: ctx().turnOutput!.stopReason, error: ctx().turnOutput!.errorMessage})}`);
46
- if (!ctx().turnStarted) ensureTurnStarted();
59
+ export function finalizeCurrentStream(stopReason?: string, c: QueryContext = ctx()): void {
60
+ if (!c.currentPiStream || !c.turnOutput) return;
61
+ debug(`provider: finalizeCurrentStream called, stopReason=${stopReason}, turnOutput=${JSON.stringify({stopReason: c.turnOutput.stopReason, error: c.turnOutput.errorMessage})}`);
62
+ if (!c.turnStarted) ensureTurnStarted(c);
47
63
  const reason = stopReason === "length" ? "length" : "stop";
48
- ctx().currentPiStream!.push({ type: "done", reason, message: ctx().turnOutput });
49
- ctx().currentPiStream!.end();
50
- ctx().currentPiStream = null;
64
+ c.currentPiStream.push({ type: "done", reason, message: c.turnOutput });
65
+ c.currentPiStream.end();
66
+ c.currentPiStream = null;
67
+ }
68
+
69
+ // --- Tool-use turn end: deferred to the stream's terminal events ---
70
+ //
71
+ // The Claude Code CLI dispatches MCP tool calls (and the SDK yields the
72
+ // completed assistant message) BEFORE the stream's message_delta arrives — and
73
+ // message_delta is what carries the message's REAL output-token count (measured:
74
+ // handler invoked ~45ms before message_delta on every tool-use turn). Ending
75
+ // the pi stream at either of those early signals therefore froze usage at the
76
+ // message_start placeholder values, which is why pi sessions recorded 1–7
77
+ // output tokens per tool-use turn while the final text turn recorded hundreds
78
+ // (2026-07-28 token test, both bridge panes).
79
+ //
80
+ // So the turn now ends at message_stop, exactly like the streamed-text case,
81
+ // and the early signals only ARM a grace timer. The timer is the deadlock
82
+ // backstop for the one observed case where the terminal events never arrive
83
+ // (pi 0.80 steer draining): pi cannot execute tools before the stream ends, and
84
+ // the MCP handler cannot resolve before pi executes, so a stream that has gone
85
+ // silent must be ended by force — just 1.5s later instead of immediately.
86
+
87
+ const TOOL_USE_END_GRACE_MS = 1500;
88
+
89
+ /** End the current pi stream as a tool_use turn boundary. Safe to call when the
90
+ * turn already ended (no-op). Every end path funnels here, so this is where
91
+ * two invariants are enforced by construction (vstack#1469): a block still
92
+ * carrying partialJson never ships — Pi executes the done message's content,
93
+ * and truncated arguments must never execute — and every call that DOES ship
94
+ * is stamped forwarded so no lagging replay can dispatch it again. */
95
+ export function endToolUseTurn(c: QueryContext): void {
96
+ if (!c.currentPiStream || !c.turnOutput) return;
97
+ cancelScheduledToolUseEnd(c);
98
+ const partial = (c.turnOutput.content as Array<any>).filter((b) => b?.type === "toolCall" && "partialJson" in b);
99
+ if (partial.length > 0) {
100
+ const calls = partial.map((b) => ({ id: b.id, name: b.name }));
101
+ debug(`endToolUseTurn: pruning ${partial.length} still-partial tool call(s) — truncated arguments never execute:`, calls.map((entry) => `${entry.name} [${entry.id}]`).join(", "));
102
+ diagDump("partial_tool_calls_pruned", { count: partial.length, calls });
103
+ appendIntegrityEntry("partial_tool_calls_pruned", { count: partial.length, calls });
104
+ c.turnOutput.content = (c.turnOutput.content as Array<any>).filter((b) => !(b?.type === "toolCall" && "partialJson" in b));
105
+ }
106
+ // Every tool call Pi is about to execute from this turn is owed a result and
107
+ // must never be dispatched again: a lagging stream replays the same tool_use
108
+ // into the NEXT turn, whose per-message dedup cannot see it (vstack#1469).
109
+ for (const block of c.turnOutput.content as Array<{ type?: string; id?: unknown }>) {
110
+ if (block?.type === "toolCall" && typeof block.id === "string") c.forwardedToolCallIds.add(block.id);
111
+ }
112
+ c.turnOutput.stopReason = "toolUse";
113
+ c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
114
+ c.currentPiStream.end();
115
+ c.currentPiStream = null;
116
+ }
117
+
118
+ export function cancelScheduledToolUseEnd(c: QueryContext): void {
119
+ if (!c.scheduledToolUseEnd) return;
120
+ clearTimeout(c.scheduledToolUseEnd.timer);
121
+ c.scheduledToolUseEnd = null;
51
122
  }
52
123
 
53
- export function updateTurnOutputModel(modelId: unknown): void {
54
- const c = ctx();
124
+ /**
125
+ * Arm the grace timer that force-ends the current tool_use turn if the stream's
126
+ * terminal events never arrive. First arming per stream wins; message_stop (or
127
+ * resetTurnState) disarms it. `action` runs only if the SAME stream is still
128
+ * current when the grace elapses — a turn that ended normally makes it a no-op.
129
+ */
130
+ export function scheduleToolUseTurnEnd(c: QueryContext, action: () => void, source: string): void {
131
+ if (!c.currentPiStream || !c.turnOutput) return;
132
+ if (c.scheduledToolUseEnd?.stream === c.currentPiStream) return;
133
+ cancelScheduledToolUseEnd(c);
134
+ const stream = c.currentPiStream;
135
+ const timer = setTimeout(() => {
136
+ if (c.currentPiStream !== stream) return;
137
+ debug(`scheduleToolUseTurnEnd: no terminal stream event within ${TOOL_USE_END_GRACE_MS}ms (${source}) — force-ending tool_use turn`);
138
+ c.scheduledToolUseEnd = null;
139
+ action();
140
+ }, TOOL_USE_END_GRACE_MS);
141
+ timer.unref?.();
142
+ c.scheduledToolUseEnd = { stream, timer };
143
+ }
144
+
145
+ /**
146
+ * Park queued tool results whose handler has not fired by a child message
147
+ * boundary, and say so everywhere it matters. The boundary is where stale
148
+ * entries would start poisoning mismatch reports — but it does NOT prove the
149
+ * handler gave up: the SDK staggers handler invocations, and in the 2026-08-17
150
+ * deadlock session three of five parallel handlers fired after this point
151
+ * (vstack#1469). Parked results stay consumable through
152
+ * takeQueuedOrParkedResult; one that is never consumed belongs to a call the
153
+ * SDK abandoned client-side (permission denial), which is exactly what the
154
+ * notice describes.
155
+ */
156
+ export function reapStaleQueuedResults(c: QueryContext): void {
157
+ const stale = c.takeStaleQueuedResults();
158
+ if (stale.length === 0) return;
159
+ const names = stale.map((entry) => entry.toolName);
160
+ debug(`reapStaleQueuedResults: parked ${stale.length} early tool result(s) awaiting a late handler:`, names.join(", "));
161
+ diagDump("stale_queued_tool_results_parked", { count: stale.length, stale });
162
+ appendIntegrityEntry("stale_queued_tool_results_parked", { count: stale.length, stale });
163
+ safeNotify(
164
+ `Claude bridge: parked ${stale.length} early tool result(s) whose handler has not arrived (${names.slice(0, 6).join(", ")}${names.length > 6 ? ", …" : ""}). ` +
165
+ `A late handler can still consume them.`,
166
+ "warning",
167
+ );
168
+ }
169
+
170
+ export function updateTurnOutputModel(modelId: unknown, c: QueryContext = ctx()): void {
55
171
  if (typeof modelId !== "string" || !modelId || !c.turnOutput) return;
56
172
  if (c.turnOutput.model === modelId) return;
57
173
  debug(`provider: active Claude model changed ${c.turnOutput.model} -> ${modelId}`);
58
174
  c.turnOutput.model = modelId;
59
175
  }
60
176
 
177
+ export const FINALIZE_MAX_REARMS = 3;
178
+
179
+ /** Force-finalizes the current pi turn as a tool_use boundary when its terminal
180
+ * stream events never arrived (the grace-timer action armed by an MCP handler
181
+ * invocation — see scheduleToolUseTurnEnd).
182
+ *
183
+ * Observed with Claude Code under pi 0.80's steer draining (tool result and
184
+ * drained steer arrive in one provider call): the NEXT tool turn's tool_use
185
+ * streams in, the SDK invokes the MCP handler — and neither terminal event
186
+ * ever arrives. The invocation itself proves the assistant turn is committed,
187
+ * so end the pi stream like the `message_stop` path — with this handler's
188
+ * schema-validated arguments, never a partial parse — after settling every
189
+ * sibling whose handler has fired and giving a merely-lagging stream up to
190
+ * FINALIZE_MAX_REARMS extra grace periods for the rest (vstack#1469).
191
+ *
192
+ * The dead-stream guard is a backstop: the grace timer's own stream-identity
193
+ * check means this normally never runs after the turn ended. The primary
194
+ * recovery for a handler whose call missed its turn is the generation-guarded
195
+ * drainStrandedToolCalls at the next provider callback. */
196
+ export function finalizeToolUseTurnFromMcpInvocation(
197
+ queryCtx: QueryContext,
198
+ toolCallId: string,
199
+ toolName: string,
200
+ mappedArgs: Record<string, unknown>,
201
+ rearmCount = 0,
202
+ ): void {
203
+ if (!queryCtx.currentPiStream || !queryCtx.turnOutput) {
204
+ // The turn ended without this call. An unforwarded handler here can never
205
+ // be answered — the yield that could have replayed its call was consumed
206
+ // against a null stream, and the dead-mark below suppresses any replay
207
+ // that has not happened yet. Failing it now is what turns the observed
208
+ // multi-hour session deadlock into one retryable error (vstack#1469).
209
+ if (failStrandedToolCall(queryCtx, toolCallId)) {
210
+ debug(`mcp handler: ${toolName} [${toolCallId}] stranded — turn ended before its call reached Pi; resolved with error`);
211
+ diagDump("tool_handler_stranded", { toolCallId, toolName, site: "finalize-no-stream" });
212
+ appendIntegrityEntry("tool_handler_stranded", { toolCallId, toolName, site: "finalize-no-stream" });
213
+ }
214
+ return;
215
+ }
216
+ let idx = queryCtx.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === toolCallId);
217
+ if (idx >= 0) {
218
+ const block = queryCtx.turnBlocks[idx] as any;
219
+ if ("partialJson" in block) {
220
+ // Stream ended before content_block_stop. The SDK invoked this handler
221
+ // with the COMPLETE schema-validated input, so the handler's copy is
222
+ // authoritative — the streamed partial JSON is by definition behind it.
223
+ // Settling from the partial is what forwarded `{}`-argument calls that
224
+ // Pi then executed and errored (vstack#1469), exactly the divergence
225
+ // the synthesize branch below never had.
226
+ block.arguments = mappedArgs;
227
+ queryCtx.updateToolCallArgs(block.id, block.arguments);
228
+ delete block.partialJson;
229
+ delete block.index;
230
+ queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: queryCtx.turnOutput });
231
+ }
232
+ } else if (queryCtx.forwardedToolCallIds.has(toolCallId) || queryCtx.deadToolCallIds.has(toolCallId)) {
233
+ // Pi already executed this call in an earlier turn (its result arrives or
234
+ // sits parked), or the handler was already failed — either way a second
235
+ // dispatch is the one outcome worse than waiting. Do NOT return: this
236
+ // firing consumed the stream's only grace timer, so the current turn's own
237
+ // blocks must still be settled and the turn still ended below, or a turn
238
+ // that loses its terminal events afterwards has no backstop left and Pi
239
+ // sits busy until manual abort.
240
+ debug(`mcp handler: ${toolName} [${toolCallId}] already ${queryCtx.forwardedToolCallIds.has(toolCallId) ? "forwarded" : "dead"} — not re-emitting`);
241
+ } else {
242
+ // The invocation can arrive before the tool_use is streamed at all
243
+ // (observed after a tool-result+steer provider call reset the turn):
244
+ // synthesize the toolCall from the claim — the MCP call carries the
245
+ // authoritative id, name, and arguments.
246
+ queryCtx.turnBlocks.push({ type: "toolCall", id: toolCallId, name: toolName, arguments: mappedArgs });
247
+ idx = queryCtx.turnBlocks.length - 1;
248
+ const block = queryCtx.turnBlocks[idx] as any;
249
+ queryCtx.currentPiStream.push({ type: "toolcall_start", contentIndex: idx, partial: queryCtx.turnOutput });
250
+ queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: queryCtx.turnOutput });
251
+ }
252
+ // Settle every OTHER still-partial block whose handler has fired: each
253
+ // waiting handler carries the authoritative args for its own call.
254
+ for (let i = 0; i < queryCtx.turnBlocks.length; i++) {
255
+ const sibling = queryCtx.turnBlocks[i] as any;
256
+ if (sibling.type !== "toolCall" || !("partialJson" in sibling)) continue;
257
+ const waiting = queryCtx.pendingToolCalls.get(sibling.id);
258
+ if (!waiting) continue;
259
+ sibling.arguments = waiting.args;
260
+ queryCtx.updateToolCallArgs(sibling.id, sibling.arguments);
261
+ delete sibling.partialJson;
262
+ delete sibling.index;
263
+ queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: i, toolCall: sibling, partial: queryCtx.turnOutput });
264
+ }
265
+ // A block still partial here has NO fired handler: its complete arguments
266
+ // exist nowhere on this side of the boundary yet. Ending the turn now would
267
+ // hand Pi truncated arguments to execute — a truncated bash command is not a
268
+ // hypothetical hazard — so give the lagging stream more grace first.
269
+ const unsettled = queryCtx.turnBlocks.filter((b: any) => b.type === "toolCall" && "partialJson" in b);
270
+ if (unsettled.length > 0 && rearmCount < FINALIZE_MAX_REARMS) {
271
+ debug(`mcp handler: ${unsettled.length} sibling tool call(s) still streaming — re-arming grace (${rearmCount + 1}/${FINALIZE_MAX_REARMS})`);
272
+ scheduleToolUseTurnEnd(
273
+ queryCtx,
274
+ () => finalizeToolUseTurnFromMcpInvocation(queryCtx, toolCallId, toolName, mappedArgs, rearmCount + 1),
275
+ `finalize-rearm:${toolName}`,
276
+ );
277
+ return;
278
+ }
279
+ // Grace exhausted with blocks still partial: endToolUseTurn prunes them —
280
+ // truncated arguments never execute; each pruned call replays complete on
281
+ // the SDK's assistant yield in a later turn, or its handler eventually
282
+ // fires and the stranded drain fails it with a retryable error. When the
283
+ // turn holds NOTHING executable (this call suppressed as forwarded/dead and
284
+ // no settled siblings), leave the stream to its own terminal events — an
285
+ // empty tool_use turn would make Pi execute nothing and record an empty
286
+ // assistant message for it.
287
+ const executable = queryCtx.turnBlocks.some((b: any) => b.type === "toolCall" && !("partialJson" in b));
288
+ if (!executable) {
289
+ debug(`mcp handler: nothing executable in this turn after suppression — leaving the stream to its own terminal events (${toolName} [${toolCallId}])`);
290
+ return;
291
+ }
292
+ queryCtx.turnSawToolCall = true;
293
+ debug(`mcp handler: finalizing tool_use turn from MCP invocation [${toolCallId}] (${toolName}) — terminal stream events never arrived`);
294
+ endToolUseTurn(queryCtx);
295
+ }
296
+
61
297
  /** Maps Anthropic stream events to pi stream events (text, thinking, toolcall).
62
298
  * On message_stop with tool_use: ends currentPiStream so pi can execute the tool. */
63
299
  export function processStreamEvent(
64
300
  message: SDKMessage,
65
301
  customToolNameToPi: Map<string, string>,
66
302
  model: Model<any>,
303
+ // The consuming query's CAPTURED context, never the live ctx() (see the C4
304
+ // note in consumeQuery): a reentrant subagent can be pushed while the parent
305
+ // iterator is suspended, and live-state reads would hit the wrong query.
306
+ c: QueryContext = ctx(),
67
307
  ): void {
68
- const c = ctx();
69
308
  if (!c.currentPiStream || !c.turnOutput) return;
70
309
  const event = (message as SDKMessage & { event: any }).event;
71
310
  if (event?.type === "ping") return;
@@ -75,15 +314,36 @@ export function processStreamEvent(
75
314
  }
76
315
 
77
316
  if (event?.type === "message_start") {
317
+ // The child moving to a new message proves every result for the previous
318
+ // one reached it; anything still queued can never be consumed.
319
+ reapStaleQueuedResults(c);
78
320
  c.resetToolTracking();
79
- updateTurnOutputModel(event.message?.model);
80
- if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model);
321
+ // A new child message begins: bank what the previous one billed before its
322
+ // counters are replaced. No-op on the turn's first, and no-op if this same
323
+ // message was already declared (see beginChildMessage).
324
+ c.beginChildMessage(event.message?.id);
325
+ updateTurnOutputModel(event.message?.model, c);
326
+ if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model, c);
81
327
  return;
82
328
  }
83
329
 
84
330
  if (event?.type === "content_block_start") {
85
331
  c.turnSawStreamEvent = true;
86
- ensureTurnStarted();
332
+ ensureTurnStarted(c);
333
+ // A new block owns this index from here on, so release any child-executed
334
+ // or suppressed claim on it. Belt-and-braces against a missed
335
+ // message_start: without this a stale index could silently swallow a later
336
+ // text block's deltas.
337
+ c.childExecutedStreamIndexes.delete(event.index);
338
+ c.suppressedStreamIndexes.delete(event.index);
339
+ if (event.content_block?.type === "tool_use" && isChildExecutedTool(event.content_block.name)) {
340
+ // The child runs this one itself — mirroring it into the Pi stream would
341
+ // make Pi's agent loop dispatch a tool it does not have. See
342
+ // isChildExecutedTool.
343
+ c.noteChildExecutedToolCall(event.content_block.id, event.content_block.name, event.index);
344
+ debug(`processStreamEvent: child-executed tool ${event.content_block.name} [${event.content_block.id}] — not mirrored as a Pi tool call`);
345
+ return;
346
+ }
87
347
  if (event.content_block?.type === "text") {
88
348
  c.turnBlocks.push({ type: "text", text: "", index: event.index });
89
349
  c.currentPiStream!.push({ type: "text_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
@@ -91,6 +351,24 @@ export function processStreamEvent(
91
351
  c.turnBlocks.push({ type: "thinking", thinking: "", thinkingSignature: "", index: event.index });
92
352
  c.currentPiStream!.push({ type: "thinking_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
93
353
  } else if (event.content_block?.type === "tool_use") {
354
+ const streamedId: unknown = event.content_block.id;
355
+ if (typeof streamedId === "string" && (c.forwardedToolCallIds.has(streamedId) || c.deadToolCallIds.has(streamedId))) {
356
+ // A lagging stream replaying a call Pi already executed — or one whose
357
+ // handler was already failed as stranded — into a later turn. Mirroring
358
+ // it would make Pi dispatch it a second time (vstack#1469).
359
+ c.suppressedStreamIndexes.add(event.index);
360
+ debug(`processStreamEvent: tool_use ${streamedId} already ${c.forwardedToolCallIds.has(streamedId) ? "forwarded" : "dead"} — suppressing duplicate stream block`);
361
+ return;
362
+ }
363
+ if (typeof streamedId === "string" && c.turnBlocks.some((b: any) => b.type === "toolCall" && b.id === streamedId)) {
364
+ // Same turn, same id: the completed-message yield beat the stream (its
365
+ // block is already recorded with complete arguments). A second
366
+ // partialJson copy would ship the id twice in one done message — and
367
+ // if its stop never arrives, ship it truncated (vstack#1469).
368
+ c.suppressedStreamIndexes.add(event.index);
369
+ debug(`processStreamEvent: tool_use ${streamedId} already recorded in this turn — suppressing duplicate stream block`);
370
+ return;
371
+ }
94
372
  c.turnSawToolCall = true;
95
373
  const mappedName = mapToolName(event.content_block.name, customToolNameToPi);
96
374
  c.recordToolCall(event.content_block.id, mappedName, {});
@@ -108,6 +386,15 @@ export function processStreamEvent(
108
386
  }
109
387
 
110
388
  if (event?.type === "content_block_delta") {
389
+ // A child-executed tool's argument deltas have no Pi block to land in. Skip
390
+ // them here rather than letting the lookup below miss, so the "unmatched"
391
+ // warning keeps meaning "something is wrong". Unlike that stale-event case
392
+ // this IS a live event for the current message, so it still counts as one.
393
+ // Suppressed duplicate/dead blocks skip identically.
394
+ if (c.childExecutedStreamIndexes.has(event.index) || c.suppressedStreamIndexes.has(event.index)) {
395
+ c.turnSawStreamEvent = true;
396
+ return;
397
+ }
111
398
  const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
112
399
  const block = c.turnBlocks[index];
113
400
  if (!block) {
@@ -134,6 +421,12 @@ export function processStreamEvent(
134
421
  }
135
422
 
136
423
  if (event?.type === "content_block_stop") {
424
+ // Same as the delta case: the block was never mirrored, so there is nothing
425
+ // to seal and nothing unmatched about it.
426
+ if (c.childExecutedStreamIndexes.has(event.index) || c.suppressedStreamIndexes.has(event.index)) {
427
+ c.turnSawStreamEvent = true;
428
+ return;
429
+ }
137
430
  const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
138
431
  const block = c.turnBlocks[index];
139
432
  if (!block) {
@@ -160,19 +453,18 @@ export function processStreamEvent(
160
453
 
161
454
  if (event?.type === "message_delta") {
162
455
  c.turnOutput.stopReason = mapStopReason(event.delta?.stop_reason);
163
- if (event.usage) updateUsage(c.turnOutput, event.usage, model);
456
+ if (event.usage) updateUsage(c.turnOutput, event.usage, model, c);
164
457
  return;
165
458
  }
166
459
 
167
460
  if (event?.type === "message_stop" && c.turnSawToolCall) {
168
- // Tool call complete — end this pi stream. The SDK will still yield an
169
- // assistant message for this turn, but currentPiStream=null causes
170
- // consumeQuery to skip it. The MCP handler blocks the generator until
171
- // pi delivers the tool result via the next streamSimple call.
172
- c.turnOutput.stopReason = "toolUse";
173
- c.currentPiStream!.push({ type: "done", reason: "toolUse", message: c.turnOutput });
174
- c.currentPiStream!.end();
175
- c.currentPiStream = null;
461
+ // Tool call complete — end this pi stream, disarming any grace timer the
462
+ // MCP-invocation or assistant-boundary path armed. This is the NORMAL end
463
+ // for a tool-use turn: message_delta already delivered the message's real
464
+ // usage just above, so the done event carries correct output tokens. The
465
+ // MCP handler blocks the generator until pi delivers the tool result via
466
+ // the next streamSimple call.
467
+ endToolUseTurn(c);
176
468
 
177
469
  // Cursor is updated by the next streamSimple call (tool result delivery path)
178
470
  // which sets cursor = context.messages.length with the post-tool-result context.
@@ -194,17 +486,40 @@ function appendMissingToolUsesFromAssistant(
194
486
  assistantMsg: { content?: Array<any>; usage?: Record<string, number | undefined> },
195
487
  model: Model<any>,
196
488
  customToolNameToPi: Map<string, string>,
489
+ c: QueryContext,
197
490
  ): boolean {
198
- const c = ctx();
199
491
  if (!assistantMsg?.content) return false;
492
+ // With a dead stream, ids are still RECORDED (claims and result matching
493
+ // need them) but the content is never touched: turnBlocks IS the content
494
+ // array of a turnOutput that endToolUseTurn already handed Pi BY REFERENCE
495
+ // in its done event, so a push here appends calls into a delivered message
496
+ // behind Pi's back — whether Pi's dispatch enumerates before or after the
497
+ // push is a microtask race (vstack#1469).
498
+ const streamLive = Boolean(c.currentPiStream && c.turnOutput);
200
499
  let sawToolUse = false;
201
500
  for (const block of assistantMsg.content) {
202
501
  if (block.type !== "tool_use") continue;
203
- sawToolUse = true;
502
+ if (isChildExecutedTool(block.name)) {
503
+ // Not a Pi tool call, so it is NOT a turn boundary either: `sawToolUse`
504
+ // stays false for it and the caller keeps streaming this Pi message. The
505
+ // child neither blocks on Pi nor needs a result from it.
506
+ c.noteChildExecutedToolCall(block.id, block.name);
507
+ debug(`assistant message: child-executed tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
508
+ continue;
509
+ }
204
510
  const existingIdx = c.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === block.id);
511
+ if (existingIdx < 0 && (c.forwardedToolCallIds.has(block.id) || c.deadToolCallIds.has(block.id))) {
512
+ // Completed-message replay of a call Pi already executed in an earlier
513
+ // turn (or one already failed as stranded). Not a live Pi turn boundary:
514
+ // sawToolUse stays false for it, and no block is emitted (vstack#1469).
515
+ debug(`assistant message: tool_use ${block.id} already ${c.forwardedToolCallIds.has(block.id) ? "forwarded" : "dead"} — skipping duplicate`);
516
+ continue;
517
+ }
518
+ sawToolUse = true;
205
519
  const name = mapToolName(block.name, customToolNameToPi);
206
520
  const mappedArgs = mapToolArgs(name, block.input);
207
521
  c.recordToolCall(block.id, name, mappedArgs);
522
+ if (!streamLive) continue;
208
523
  if (existingIdx >= 0) {
209
524
  const existing = c.turnBlocks[existingIdx] as any;
210
525
  existing.name = name;
@@ -218,7 +533,7 @@ function appendMissingToolUsesFromAssistant(
218
533
  continue;
219
534
  }
220
535
 
221
- ensureTurnStarted();
536
+ ensureTurnStarted(c);
222
537
  c.turnBlocks.push({
223
538
  type: "toolCall", id: block.id,
224
539
  name,
@@ -229,57 +544,148 @@ function appendMissingToolUsesFromAssistant(
229
544
  c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
230
545
  c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
231
546
  }
232
- if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
547
+ // Only while the stream is still live: the SDK's assistant yields carry the
548
+ // message_start placeholder usage (output ≈ 1–7), and once the done event has
549
+ // delivered turnOutput to pi, overwriting its usage with those placeholders
550
+ // would corrupt the very figure message_delta got right.
551
+ if (assistantMsg.usage && c.turnOutput && c.currentPiStream) updateUsage(c.turnOutput, assistantMsg.usage, model, c);
233
552
  return sawToolUse;
234
553
  }
235
554
 
236
- export function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>): void {
237
- const c = ctx();
555
+ /**
556
+ * Record that a child-executed tool call came back, from the SDK's `user` message
557
+ * carrying the child's own `tool_result` blocks.
558
+ *
559
+ * This is the only place the bridge ever OBSERVES one of these results, and it is
560
+ * deliberately observation-only: the result already reached the model inside the
561
+ * child, which is the conversation of record for a bridge turn, so re-delivering
562
+ * it would double it. What the bridge could not do before this existed was say
563
+ * anything true about these calls at all — the Pi transcript claimed they failed
564
+ * and nothing anywhere claimed otherwise.
565
+ *
566
+ * The payload is NEVER logged or recorded, only its shape: a connector result is
567
+ * live account data (mail, messages, documents) and the bridge's debug log sits
568
+ * outside a host app's redaction boundary.
569
+ *
570
+ * Observing it is also what makes the call auditable: each one appends a session
571
+ * `CustomEntry` (connector-audit.ts) so the Pi session records that the call
572
+ * happened, without a content block Pi's agent loop could try to dispatch.
573
+ */
574
+ export function noteChildExecutedToolResults(message: SDKMessage, c: QueryContext = ctx()): void {
575
+ if (c.childExecutedToolCalls.size === 0) return;
576
+ const content = (message as SDKMessage & { message?: { content?: unknown } }).message?.content;
577
+ if (!Array.isArray(content)) return;
578
+ for (const block of content) {
579
+ if (block?.type !== "tool_result") continue;
580
+ const name = c.childExecutedToolCalls.get(block.tool_use_id);
581
+ if (!name) continue;
582
+ const isError = block.is_error === true;
583
+ const byteSize = connectorResultByteSize(block.content);
584
+ const audited = recordConnectorCallResult(c, block.tool_use_id, name, isError, byteSize);
585
+ debug(`child-executed tool result: ${name} [${block.tool_use_id}] isError=${isError} byteSize=${byteSize ?? "unknown"} audited=${audited}`);
586
+ }
587
+ }
588
+
589
+ export function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>, c: QueryContext = ctx()): void {
238
590
  const assistantMsg = (message as any).message;
239
591
  if (!assistantMsg?.content) return;
240
- updateTurnOutputModel(assistantMsg.model);
592
+ updateTurnOutputModel(assistantMsg.model, c);
241
593
  if (c.turnSawStreamEvent) {
242
- // Claude Agent SDK can yield the completed assistant message before (or
243
- // instead of) a stream_event message_stop for a tool-use turn. Treat that
244
- // assistant message as a hard turn boundary so Pi executes the tool calls
245
- // and the MCP handlers stay blocked until real tool results are delivered.
246
- // Without this fallback, Claude Code can continue internally with empty MCP
247
- // results and Pi only sees the real outputs one render cycle later.
248
- if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi)) {
594
+ // The SDK yields the completed assistant message BEFORE the stream's
595
+ // message_delta/message_stop on every tool-use turn (measured — this is
596
+ // the norm, not a fallback). Record any tool_use blocks the stream hasn't
597
+ // delivered yet, but do NOT end the pi stream here: message_delta, which
598
+ // arrives tens of ms later, carries the message's real output-token count,
599
+ // and message_stop is the normal turn end. Ending here froze usage at the
600
+ // message_start placeholders (1–7 output tokens per tool turn). The grace
601
+ // timer force-ends the turn if the terminal events never arrive, so pi
602
+ // still gets to execute the tools and unblock the MCP handlers.
603
+ if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi, c)) {
249
604
  c.turnSawToolCall = true;
250
- if (c.currentPiStream && c.turnOutput) {
251
- c.turnOutput.stopReason = "toolUse";
252
- c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
253
- c.currentPiStream.end();
254
- c.currentPiStream = null;
255
- debug("processAssistantMessage boundary: ended streamed tool_use turn from assistant message");
256
- }
605
+ scheduleToolUseTurnEnd(c, () => endToolUseTurn(c), "assistant-boundary");
257
606
  }
258
607
  return;
259
608
  }
260
- c.resetToolTracking();
261
- debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}`);
609
+ // The SDK yields the SAME assistant message more than once (per-block
610
+ // partial copies and the completed message share one id). With stream
611
+ // events, the streamed path already renders content and the duplicates are
612
+ // naturally ignored; on this no-stream-events path each yield used to be
613
+ // re-rendered wholesale — a rate-limited turn printed "You've hit your
614
+ // weekly limit" twice. Same-message yields keep the turn's tracking (a
615
+ // reset mid-message would wipe live tool-claim state) and render only
616
+ // blocks not already rendered.
617
+ const sameMessage = typeof assistantMsg.id === "string" && assistantMsg.id.length > 0 && assistantMsg.id === c.currentMessageId;
618
+ if (!sameMessage) {
619
+ reapStaleQueuedResults(c);
620
+ c.resetToolTracking();
621
+ }
622
+ // The no-stream-events path also sees a message boundary. It is keyed on the
623
+ // message ID rather than trusted blindly, because this branch is ALSO reached
624
+ // for a message whose `message_start` already streamed — any message that
625
+ // produced no content blocks, since `turnSawStreamEvent` only tracks those.
626
+ c.beginChildMessage(assistantMsg.id);
627
+ debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}${sameMessage ? " (same message re-yield)" : ""}`);
628
+ // Deduped against the WHOLE current turn, not just same-id re-yields: a
629
+ // rejected turn's synthesized error message ("You've hit your weekly limit")
630
+ // arrives as multiple assistant yields whose ids DIFFER or are absent
631
+ // (measured 2026-07-28: one pi message, two byte-identical text blocks), so
632
+ // an id-keyed guard alone still rendered it twice. A model legitimately
633
+ // producing two byte-identical full blocks in one turn is vanishingly rare;
634
+ // rendering such a duplicate once is the better failure mode.
635
+ const alreadyRendered = (type: string, content: string): boolean =>
636
+ c.turnBlocks.some((b: any) => b.type === type && (type === "text" ? b.text : b.thinking) === content);
262
637
  for (const block of assistantMsg.content) {
263
638
  if (block.type === "text" && block.text) {
264
- ensureTurnStarted();
639
+ if (alreadyRendered("text", block.text)) continue;
640
+ ensureTurnStarted(c);
265
641
  c.turnBlocks.push({ type: "text", text: block.text });
266
642
  const idx = c.turnBlocks.length - 1;
267
643
  c.currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: c.turnOutput });
268
644
  c.currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: block.text, partial: c.turnOutput });
269
645
  c.currentPiStream?.push({ type: "text_end", contentIndex: idx, content: block.text, partial: c.turnOutput });
270
646
  } else if (block.type === "thinking") {
271
- ensureTurnStarted();
647
+ if (alreadyRendered("thinking", block.thinking ?? "")) continue;
648
+ ensureTurnStarted(c);
272
649
  c.turnBlocks.push({ type: "thinking", thinking: block.thinking ?? "", thinkingSignature: block.signature ?? "" });
273
650
  const idx = c.turnBlocks.length - 1;
274
651
  c.currentPiStream?.push({ type: "thinking_start", contentIndex: idx, partial: c.turnOutput });
275
652
  if (block.thinking) c.currentPiStream?.push({ type: "thinking_delta", contentIndex: idx, delta: block.thinking, partial: c.turnOutput });
276
653
  c.currentPiStream?.push({ type: "thinking_end", contentIndex: idx, content: block.thinking ?? "", partial: c.turnOutput });
277
654
  } else if (block.type === "tool_use") {
278
- ensureTurnStarted();
655
+ if (isChildExecutedTool(block.name)) {
656
+ // Same as the streamed path: the child owns this call, so it never
657
+ // becomes a Pi tool call and never ends the turn.
658
+ c.noteChildExecutedToolCall(block.id, block.name);
659
+ debug(`processAssistantMessage fallback: child-executed tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
660
+ continue;
661
+ }
662
+ if (!c.turnBlocks.some((b: any) => b.type === "toolCall" && b.id === block.id)
663
+ && (c.forwardedToolCallIds.has(block.id) || c.deadToolCallIds.has(block.id))) {
664
+ // A cross-turn replay of a call Pi already executed (or one whose
665
+ // handler was already failed as stranded). This exact path produced
666
+ // the observed duplicate dispatches: the completed-message yield lands
667
+ // in the callback AFTER a grace finalize already ended the call's
668
+ // turn, and per-message dedup cannot see across turns (vstack#1469).
669
+ // Not recorded either — a forwarded call must not be claimable again.
670
+ debug(`processAssistantMessage fallback: tool_use ${block.id} already ${c.forwardedToolCallIds.has(block.id) ? "forwarded" : "dead"} — skipping duplicate`);
671
+ continue;
672
+ }
673
+ ensureTurnStarted(c);
279
674
  c.turnSawToolCall = true;
280
675
  const mappedName = mapToolName(block.name, customToolNameToPi);
281
676
  const mappedArgs = mapToolArgs(mappedName, block.input);
282
677
  c.recordToolCall(block.id, mappedName, mappedArgs);
678
+ // A same-message re-yield of an already-mirrored call refreshes its
679
+ // arguments in place — a second toolCall block would make pi dispatch
680
+ // the tool twice.
681
+ const existingIdx = c.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === block.id);
682
+ if (existingIdx >= 0) {
683
+ const existing = c.turnBlocks[existingIdx] as any;
684
+ existing.name = mappedName;
685
+ existing.arguments = mappedArgs;
686
+ c.updateToolCallArgs(block.id, mappedArgs);
687
+ continue;
688
+ }
283
689
  c.turnBlocks.push({
284
690
  type: "toolCall", id: block.id,
285
691
  name: mappedName,
@@ -290,18 +696,18 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
290
696
  c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
291
697
  c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
292
698
  } else if (block.type === "fallback") {
293
- updateTurnOutputModel(block.to?.model);
699
+ updateTurnOutputModel(block.to?.model, c);
294
700
  } else {
295
701
  debug("processAssistantMessage: unhandled block type", block.type);
296
702
  }
297
703
  }
298
- if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
704
+ if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model, c);
299
705
 
300
- // End the stream on tool_use, same as processStreamEvent's message_stop handler.
706
+ // End the stream on tool_use. Immediate (no grace deferral) ON PURPOSE: this
707
+ // branch only runs when NO content blocks streamed for the message, so there
708
+ // is no reason to expect terminal stream events either, and the completed
709
+ // message's own usage — applied just above — is the best figure available.
301
710
  if (c.turnSawToolCall && c.currentPiStream && c.turnOutput) {
302
- c.turnOutput.stopReason = "toolUse";
303
- c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
304
- c.currentPiStream.end();
305
- c.currentPiStream = null;
711
+ endToolUseTurn(c);
306
712
  }
307
713
  }