@vanillagreen/pi-claude-bridge 1.9.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-claude-bridge",
3
- "version": "1.9.0",
3
+ "version": "2.0.0",
4
4
  "description": "Pi provider bridge that runs Claude Code through the Claude Agent SDK, with opt-in forwarding for Pi prompt context.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -142,18 +142,18 @@
142
142
  }
143
143
  },
144
144
  "dependencies": {
145
- "@anthropic-ai/claude-agent-sdk": "^0.3.215",
145
+ "@anthropic-ai/claude-agent-sdk": "^0.3.220",
146
146
  "@anthropic-ai/sdk": "^0.112.4",
147
147
  "cc-session-io": "^0.3.1",
148
148
  "change-case": "^5.4.4"
149
149
  },
150
150
  "peerDependencies": {
151
- "@earendil-works/pi-ai": "*",
152
- "@earendil-works/pi-coding-agent": "*"
151
+ "@earendil-works/pi-ai": ">=0.81.0",
152
+ "@earendil-works/pi-coding-agent": ">=0.81.0"
153
153
  },
154
154
  "devDependencies": {
155
- "@earendil-works/pi-ai": "^0.80.10",
156
- "@earendil-works/pi-coding-agent": "^0.80.10",
155
+ "@earendil-works/pi-ai": "^0.82.1",
156
+ "@earendil-works/pi-coding-agent": "^0.82.1",
157
157
  "@types/node": "^24.3.0",
158
158
  "esbuild": "^0.28.0",
159
159
  "tsx": "^4.21.0",
@@ -1,16 +1,30 @@
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, type QueryContext } from "./query-state.js";
5
8
  import { mapToolArgs, mapToolName } from "./tool-mapping.js";
6
9
 
7
10
  // --- Usage helpers ---
8
11
 
9
12
  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;
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 c = ctx();
18
+ const current = c.currentMessageUsage;
19
+ const carry = c.turnUsageCarry;
20
+ if (usage.input_tokens != null) current.input = usage.input_tokens;
21
+ if (usage.output_tokens != null) current.output = usage.output_tokens;
22
+ if (usage.cache_read_input_tokens != null) current.cacheRead = usage.cache_read_input_tokens;
23
+ if (usage.cache_creation_input_tokens != null) current.cacheWrite = usage.cache_creation_input_tokens;
24
+ output.usage.input = carry.input + current.input;
25
+ output.usage.output = carry.output + current.output;
26
+ output.usage.cacheRead = carry.cacheRead + current.cacheRead;
27
+ output.usage.cacheWrite = carry.cacheWrite + current.cacheWrite;
14
28
  output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
15
29
  calculateCost(model, output.usage);
16
30
  const promptTokens = output.usage.input + output.usage.cacheRead + output.usage.cacheWrite;
@@ -33,21 +47,105 @@ export function parsePartialJson(input: string, fallback: Record<string, unknown
33
47
  try { return JSON.parse(input); } catch { return fallback; }
34
48
  }
35
49
 
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;
50
+ // Both take the query context explicitly (defaulting to the live one) so the
51
+ // completion/teardown closures in index.ts can finalize the stream of the query
52
+ // they were created for — under reentrancy the live ctx() is the subagent's.
53
+ export function ensureTurnStarted(c: QueryContext = ctx()): void {
54
+ if (!c.turnStarted && c.currentPiStream && c.turnOutput) {
55
+ c.currentPiStream.push({ type: "start", partial: c.turnOutput });
56
+ c.turnStarted = true;
40
57
  }
41
58
  }
42
59
 
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();
60
+ export function finalizeCurrentStream(stopReason?: string, c: QueryContext = ctx()): void {
61
+ if (!c.currentPiStream || !c.turnOutput) return;
62
+ debug(`provider: finalizeCurrentStream called, stopReason=${stopReason}, turnOutput=${JSON.stringify({stopReason: c.turnOutput.stopReason, error: c.turnOutput.errorMessage})}`);
63
+ if (!c.turnStarted) ensureTurnStarted(c);
47
64
  const reason = stopReason === "length" ? "length" : "stop";
48
- ctx().currentPiStream!.push({ type: "done", reason, message: ctx().turnOutput });
49
- ctx().currentPiStream!.end();
50
- ctx().currentPiStream = null;
65
+ c.currentPiStream.push({ type: "done", reason, message: c.turnOutput });
66
+ c.currentPiStream.end();
67
+ c.currentPiStream = null;
68
+ }
69
+
70
+ // --- Tool-use turn end: deferred to the stream's terminal events ---
71
+ //
72
+ // The Claude Code CLI dispatches MCP tool calls (and the SDK yields the
73
+ // completed assistant message) BEFORE the stream's message_delta arrives — and
74
+ // message_delta is what carries the message's REAL output-token count (measured:
75
+ // handler invoked ~45ms before message_delta on every tool-use turn). Ending
76
+ // the pi stream at either of those early signals therefore froze usage at the
77
+ // message_start placeholder values, which is why pi sessions recorded 1–7
78
+ // output tokens per tool-use turn while the final text turn recorded hundreds
79
+ // (2026-07-28 token test, both bridge panes).
80
+ //
81
+ // So the turn now ends at message_stop, exactly like the streamed-text case,
82
+ // and the early signals only ARM a grace timer. The timer is the deadlock
83
+ // backstop for the one observed case where the terminal events never arrive
84
+ // (pi 0.80 steer draining): pi cannot execute tools before the stream ends, and
85
+ // the MCP handler cannot resolve before pi executes, so a stream that has gone
86
+ // silent must be ended by force — just 1.5s later instead of immediately.
87
+
88
+ const TOOL_USE_END_GRACE_MS = 1500;
89
+
90
+ /** End the current pi stream as a tool_use turn boundary. Safe to call when the
91
+ * turn already ended (no-op). */
92
+ export function endToolUseTurn(c: QueryContext): void {
93
+ if (!c.currentPiStream || !c.turnOutput) return;
94
+ cancelScheduledToolUseEnd(c);
95
+ c.turnOutput.stopReason = "toolUse";
96
+ c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
97
+ c.currentPiStream.end();
98
+ c.currentPiStream = null;
99
+ }
100
+
101
+ export function cancelScheduledToolUseEnd(c: QueryContext): void {
102
+ if (!c.scheduledToolUseEnd) return;
103
+ clearTimeout(c.scheduledToolUseEnd.timer);
104
+ c.scheduledToolUseEnd = null;
105
+ }
106
+
107
+ /**
108
+ * Arm the grace timer that force-ends the current tool_use turn if the stream's
109
+ * terminal events never arrive. First arming per stream wins; message_stop (or
110
+ * resetTurnState) disarms it. `action` runs only if the SAME stream is still
111
+ * current when the grace elapses — a turn that ended normally makes it a no-op.
112
+ */
113
+ export function scheduleToolUseTurnEnd(c: QueryContext, action: () => void, source: string): void {
114
+ if (!c.currentPiStream || !c.turnOutput) return;
115
+ if (c.scheduledToolUseEnd?.stream === c.currentPiStream) return;
116
+ cancelScheduledToolUseEnd(c);
117
+ const stream = c.currentPiStream;
118
+ const timer = setTimeout(() => {
119
+ if (c.currentPiStream !== stream) return;
120
+ debug(`scheduleToolUseTurnEnd: no terminal stream event within ${TOOL_USE_END_GRACE_MS}ms (${source}) — force-ending tool_use turn`);
121
+ c.scheduledToolUseEnd = null;
122
+ action();
123
+ }, TOOL_USE_END_GRACE_MS);
124
+ timer.unref?.();
125
+ c.scheduledToolUseEnd = { stream, timer };
126
+ }
127
+
128
+ /**
129
+ * Drop queued tool results that no handler can ever consume again, and say so
130
+ * everywhere it matters. Runs at a child message boundary — by then every
131
+ * handler for the previous message has either resolved (directly or from this
132
+ * queue) or already returned an error, so anything still queued is the real
133
+ * output of a call whose handler gave up. See takeStaleQueuedResults for why
134
+ * leaving them queued poisoned every later mismatch report and forced a
135
+ * session rebuild per turn.
136
+ */
137
+ export function reapStaleQueuedResults(c: QueryContext): void {
138
+ const stale = c.takeStaleQueuedResults();
139
+ if (stale.length === 0) return;
140
+ const names = stale.map((entry) => entry.toolName);
141
+ debug(`reapStaleQueuedResults: dropping ${stale.length} queued tool result(s) with no possible consumer:`, names.join(", "));
142
+ diagDump("stale_queued_tool_results_dropped", { count: stale.length, stale });
143
+ appendIntegrityEntry("stale_queued_tool_results_dropped", { count: stale.length, stale });
144
+ safeNotify(
145
+ `Claude bridge: dropped ${stale.length} tool result(s) whose handler never matched (${names.slice(0, 6).join(", ")}${names.length > 6 ? ", …" : ""}). ` +
146
+ `The model saw an error for these calls and may retry them.`,
147
+ "warning",
148
+ );
51
149
  }
52
150
 
53
151
  export function updateTurnOutputModel(modelId: unknown): void {
@@ -58,6 +156,53 @@ export function updateTurnOutputModel(modelId: unknown): void {
58
156
  c.turnOutput.model = modelId;
59
157
  }
60
158
 
159
+ /** Force-finalizes the current pi turn as a tool_use boundary when its terminal
160
+ * stream events never arrived (the grace-timer action armed by an MCP handler
161
+ * invocation — see scheduleToolUseTurnEnd).
162
+ *
163
+ * Observed with Claude Code under pi 0.80's steer draining (tool result and
164
+ * drained steer arrive in one provider call): the NEXT tool turn's tool_use
165
+ * streams in, the SDK invokes the MCP handler — and neither terminal event
166
+ * ever arrives. The invocation itself proves the assistant turn is committed,
167
+ * so end the pi stream here exactly like the `message_stop` path; otherwise
168
+ * the handler blocks on a result pi will never deliver (deadlock). No-op when
169
+ * the turn already ended (stream null) or the tool call isn't part of the
170
+ * currently streamed turn. */
171
+ export function finalizeToolUseTurnFromMcpInvocation(
172
+ queryCtx: QueryContext,
173
+ toolCallId: string,
174
+ toolName: string,
175
+ mappedArgs: Record<string, unknown>,
176
+ ): void {
177
+ if (!queryCtx.currentPiStream || !queryCtx.turnOutput) return;
178
+ let idx = queryCtx.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === toolCallId);
179
+ if (idx >= 0) {
180
+ const block = queryCtx.turnBlocks[idx] as any;
181
+ if ("partialJson" in block) {
182
+ // Stream ended before content_block_stop — settle the args from the
183
+ // partial JSON the same way content_block_stop would have.
184
+ block.arguments = mapToolArgs(block.name, parsePartialJson(block.partialJson, block.arguments));
185
+ queryCtx.updateToolCallArgs(block.id, block.arguments);
186
+ delete block.partialJson;
187
+ delete block.index;
188
+ queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: queryCtx.turnOutput });
189
+ }
190
+ } else {
191
+ // The invocation can arrive before the tool_use is streamed at all
192
+ // (observed after a tool-result+steer provider call reset the turn):
193
+ // synthesize the toolCall from the claim — the MCP call carries the
194
+ // authoritative id, name, and arguments.
195
+ queryCtx.turnBlocks.push({ type: "toolCall", id: toolCallId, name: toolName, arguments: mappedArgs });
196
+ idx = queryCtx.turnBlocks.length - 1;
197
+ const block = queryCtx.turnBlocks[idx] as any;
198
+ queryCtx.currentPiStream.push({ type: "toolcall_start", contentIndex: idx, partial: queryCtx.turnOutput });
199
+ queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: queryCtx.turnOutput });
200
+ }
201
+ queryCtx.turnSawToolCall = true;
202
+ debug(`mcp handler: finalizing tool_use turn from MCP invocation [${toolCallId}] (${toolName}) — terminal stream events never arrived`);
203
+ endToolUseTurn(queryCtx);
204
+ }
205
+
61
206
  /** Maps Anthropic stream events to pi stream events (text, thinking, toolcall).
62
207
  * On message_stop with tool_use: ends currentPiStream so pi can execute the tool. */
63
208
  export function processStreamEvent(
@@ -75,7 +220,14 @@ export function processStreamEvent(
75
220
  }
76
221
 
77
222
  if (event?.type === "message_start") {
223
+ // The child moving to a new message proves every result for the previous
224
+ // one reached it; anything still queued can never be consumed.
225
+ reapStaleQueuedResults(c);
78
226
  c.resetToolTracking();
227
+ // A new child message begins: bank what the previous one billed before its
228
+ // counters are replaced. No-op on the turn's first, and no-op if this same
229
+ // message was already declared (see beginChildMessage).
230
+ c.beginChildMessage(event.message?.id);
79
231
  updateTurnOutputModel(event.message?.model);
80
232
  if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model);
81
233
  return;
@@ -84,6 +236,18 @@ export function processStreamEvent(
84
236
  if (event?.type === "content_block_start") {
85
237
  c.turnSawStreamEvent = true;
86
238
  ensureTurnStarted();
239
+ // A new block owns this index from here on, so release any child-executed
240
+ // claim on it. Belt-and-braces against a missed message_start: without this
241
+ // a stale index could silently swallow a later text block's deltas.
242
+ c.childExecutedStreamIndexes.delete(event.index);
243
+ if (event.content_block?.type === "tool_use" && isChildExecutedTool(event.content_block.name)) {
244
+ // The child runs this one itself — mirroring it into the Pi stream would
245
+ // make Pi's agent loop dispatch a tool it does not have. See
246
+ // isChildExecutedTool.
247
+ c.noteChildExecutedToolCall(event.content_block.id, event.content_block.name, event.index);
248
+ debug(`processStreamEvent: child-executed tool ${event.content_block.name} [${event.content_block.id}] — not mirrored as a Pi tool call`);
249
+ return;
250
+ }
87
251
  if (event.content_block?.type === "text") {
88
252
  c.turnBlocks.push({ type: "text", text: "", index: event.index });
89
253
  c.currentPiStream!.push({ type: "text_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
@@ -108,6 +272,14 @@ export function processStreamEvent(
108
272
  }
109
273
 
110
274
  if (event?.type === "content_block_delta") {
275
+ // A child-executed tool's argument deltas have no Pi block to land in. Skip
276
+ // them here rather than letting the lookup below miss, so the "unmatched"
277
+ // warning keeps meaning "something is wrong". Unlike that stale-event case
278
+ // this IS a live event for the current message, so it still counts as one.
279
+ if (c.childExecutedStreamIndexes.has(event.index)) {
280
+ c.turnSawStreamEvent = true;
281
+ return;
282
+ }
111
283
  const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
112
284
  const block = c.turnBlocks[index];
113
285
  if (!block) {
@@ -134,6 +306,12 @@ export function processStreamEvent(
134
306
  }
135
307
 
136
308
  if (event?.type === "content_block_stop") {
309
+ // Same as the delta case: the block was never mirrored, so there is nothing
310
+ // to seal and nothing unmatched about it.
311
+ if (c.childExecutedStreamIndexes.has(event.index)) {
312
+ c.turnSawStreamEvent = true;
313
+ return;
314
+ }
137
315
  const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
138
316
  const block = c.turnBlocks[index];
139
317
  if (!block) {
@@ -165,14 +343,13 @@ export function processStreamEvent(
165
343
  }
166
344
 
167
345
  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;
346
+ // Tool call complete — end this pi stream, disarming any grace timer the
347
+ // MCP-invocation or assistant-boundary path armed. This is the NORMAL end
348
+ // for a tool-use turn: message_delta already delivered the message's real
349
+ // usage just above, so the done event carries correct output tokens. The
350
+ // MCP handler blocks the generator until pi delivers the tool result via
351
+ // the next streamSimple call.
352
+ endToolUseTurn(c);
176
353
 
177
354
  // Cursor is updated by the next streamSimple call (tool result delivery path)
178
355
  // which sets cursor = context.messages.length with the post-tool-result context.
@@ -200,6 +377,14 @@ function appendMissingToolUsesFromAssistant(
200
377
  let sawToolUse = false;
201
378
  for (const block of assistantMsg.content) {
202
379
  if (block.type !== "tool_use") continue;
380
+ if (isChildExecutedTool(block.name)) {
381
+ // Not a Pi tool call, so it is NOT a turn boundary either: `sawToolUse`
382
+ // stays false for it and the caller keeps streaming this Pi message. The
383
+ // child neither blocks on Pi nor needs a result from it.
384
+ c.noteChildExecutedToolCall(block.id, block.name);
385
+ debug(`assistant message: child-executed tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
386
+ continue;
387
+ }
203
388
  sawToolUse = true;
204
389
  const existingIdx = c.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === block.id);
205
390
  const name = mapToolName(block.name, customToolNameToPi);
@@ -229,38 +414,101 @@ function appendMissingToolUsesFromAssistant(
229
414
  c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
230
415
  c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
231
416
  }
232
- if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
417
+ // Only while the stream is still live: the SDK's assistant yields carry the
418
+ // message_start placeholder usage (output ≈ 1–7), and once the done event has
419
+ // delivered turnOutput to pi, overwriting its usage with those placeholders
420
+ // would corrupt the very figure message_delta got right.
421
+ if (assistantMsg.usage && c.turnOutput && c.currentPiStream) updateUsage(c.turnOutput, assistantMsg.usage, model);
233
422
  return sawToolUse;
234
423
  }
235
424
 
425
+ /**
426
+ * Record that a child-executed tool call came back, from the SDK's `user` message
427
+ * carrying the child's own `tool_result` blocks.
428
+ *
429
+ * This is the only place the bridge ever OBSERVES one of these results, and it is
430
+ * deliberately observation-only: the result already reached the model inside the
431
+ * child, which is the conversation of record for a bridge turn, so re-delivering
432
+ * it would double it. What the bridge could not do before this existed was say
433
+ * anything true about these calls at all — the Pi transcript claimed they failed
434
+ * and nothing anywhere claimed otherwise.
435
+ *
436
+ * The payload is NEVER logged or recorded, only its shape: a connector result is
437
+ * live account data (mail, messages, documents) and the bridge's debug log sits
438
+ * outside a host app's redaction boundary.
439
+ *
440
+ * Observing it is also what makes the call auditable: each one appends a session
441
+ * `CustomEntry` (connector-audit.ts) so the Pi session records that the call
442
+ * happened, without a content block Pi's agent loop could try to dispatch.
443
+ */
444
+ export function noteChildExecutedToolResults(message: SDKMessage): void {
445
+ const c = ctx();
446
+ if (c.childExecutedToolCalls.size === 0) return;
447
+ const content = (message as SDKMessage & { message?: { content?: unknown } }).message?.content;
448
+ if (!Array.isArray(content)) return;
449
+ for (const block of content) {
450
+ if (block?.type !== "tool_result") continue;
451
+ const name = c.childExecutedToolCalls.get(block.tool_use_id);
452
+ if (!name) continue;
453
+ const isError = block.is_error === true;
454
+ const byteSize = connectorResultByteSize(block.content);
455
+ const audited = recordConnectorCallResult(c, block.tool_use_id, name, isError, byteSize);
456
+ debug(`child-executed tool result: ${name} [${block.tool_use_id}] isError=${isError} byteSize=${byteSize ?? "unknown"} audited=${audited}`);
457
+ }
458
+ }
459
+
236
460
  export function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>): void {
237
461
  const c = ctx();
238
462
  const assistantMsg = (message as any).message;
239
463
  if (!assistantMsg?.content) return;
240
464
  updateTurnOutputModel(assistantMsg.model);
241
465
  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.
466
+ // The SDK yields the completed assistant message BEFORE the stream's
467
+ // message_delta/message_stop on every tool-use turn (measured — this is
468
+ // the norm, not a fallback). Record any tool_use blocks the stream hasn't
469
+ // delivered yet, but do NOT end the pi stream here: message_delta, which
470
+ // arrives tens of ms later, carries the message's real output-token count,
471
+ // and message_stop is the normal turn end. Ending here froze usage at the
472
+ // message_start placeholders (1–7 output tokens per tool turn). The grace
473
+ // timer force-ends the turn if the terminal events never arrive, so pi
474
+ // still gets to execute the tools and unblock the MCP handlers.
248
475
  if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi)) {
249
476
  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
- }
477
+ scheduleToolUseTurnEnd(c, () => endToolUseTurn(c), "assistant-boundary");
257
478
  }
258
479
  return;
259
480
  }
260
- c.resetToolTracking();
261
- debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}`);
481
+ // The SDK yields the SAME assistant message more than once (per-block
482
+ // partial copies and the completed message share one id). With stream
483
+ // events, the streamed path already renders content and the duplicates are
484
+ // naturally ignored; on this no-stream-events path each yield used to be
485
+ // re-rendered wholesale — a rate-limited turn printed "You've hit your
486
+ // weekly limit" twice. Same-message yields keep the turn's tracking (a
487
+ // reset mid-message would wipe live tool-claim state) and render only
488
+ // blocks not already rendered.
489
+ const sameMessage = typeof assistantMsg.id === "string" && assistantMsg.id.length > 0 && assistantMsg.id === c.currentMessageId;
490
+ if (!sameMessage) {
491
+ reapStaleQueuedResults(c);
492
+ c.resetToolTracking();
493
+ }
494
+ // The no-stream-events path also sees a message boundary. It is keyed on the
495
+ // message ID rather than trusted blindly, because this branch is ALSO reached
496
+ // for a message whose `message_start` already streamed — any message that
497
+ // produced no content blocks, since `turnSawStreamEvent` only tracks those.
498
+ c.beginChildMessage(assistantMsg.id);
499
+ debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}${sameMessage ? " (same message re-yield)" : ""}`);
500
+ // Deduped against the WHOLE current turn, not just same-id re-yields: a
501
+ // rejected turn's synthesized error message ("You've hit your weekly limit")
502
+ // arrives as multiple assistant yields whose ids DIFFER or are absent
503
+ // (measured 2026-07-28: one pi message, two byte-identical text blocks), so
504
+ // an id-keyed guard alone still rendered it twice. A model legitimately
505
+ // producing two byte-identical full blocks in one turn is vanishingly rare;
506
+ // rendering such a duplicate once is the better failure mode.
507
+ const alreadyRendered = (type: string, content: string): boolean =>
508
+ c.turnBlocks.some((b: any) => b.type === type && (type === "text" ? b.text : b.thinking) === content);
262
509
  for (const block of assistantMsg.content) {
263
510
  if (block.type === "text" && block.text) {
511
+ if (alreadyRendered("text", block.text)) continue;
264
512
  ensureTurnStarted();
265
513
  c.turnBlocks.push({ type: "text", text: block.text });
266
514
  const idx = c.turnBlocks.length - 1;
@@ -268,6 +516,7 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
268
516
  c.currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: block.text, partial: c.turnOutput });
269
517
  c.currentPiStream?.push({ type: "text_end", contentIndex: idx, content: block.text, partial: c.turnOutput });
270
518
  } else if (block.type === "thinking") {
519
+ if (alreadyRendered("thinking", block.thinking ?? "")) continue;
271
520
  ensureTurnStarted();
272
521
  c.turnBlocks.push({ type: "thinking", thinking: block.thinking ?? "", thinkingSignature: block.signature ?? "" });
273
522
  const idx = c.turnBlocks.length - 1;
@@ -275,11 +524,29 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
275
524
  if (block.thinking) c.currentPiStream?.push({ type: "thinking_delta", contentIndex: idx, delta: block.thinking, partial: c.turnOutput });
276
525
  c.currentPiStream?.push({ type: "thinking_end", contentIndex: idx, content: block.thinking ?? "", partial: c.turnOutput });
277
526
  } else if (block.type === "tool_use") {
527
+ if (isChildExecutedTool(block.name)) {
528
+ // Same as the streamed path: the child owns this call, so it never
529
+ // becomes a Pi tool call and never ends the turn.
530
+ c.noteChildExecutedToolCall(block.id, block.name);
531
+ debug(`processAssistantMessage fallback: child-executed tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
532
+ continue;
533
+ }
278
534
  ensureTurnStarted();
279
535
  c.turnSawToolCall = true;
280
536
  const mappedName = mapToolName(block.name, customToolNameToPi);
281
537
  const mappedArgs = mapToolArgs(mappedName, block.input);
282
538
  c.recordToolCall(block.id, mappedName, mappedArgs);
539
+ // A same-message re-yield of an already-mirrored call refreshes its
540
+ // arguments in place — a second toolCall block would make pi dispatch
541
+ // the tool twice.
542
+ const existingIdx = c.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === block.id);
543
+ if (existingIdx >= 0) {
544
+ const existing = c.turnBlocks[existingIdx] as any;
545
+ existing.name = mappedName;
546
+ existing.arguments = mappedArgs;
547
+ c.updateToolCallArgs(block.id, mappedArgs);
548
+ continue;
549
+ }
283
550
  c.turnBlocks.push({
284
551
  type: "toolCall", id: block.id,
285
552
  name: mappedName,
@@ -297,11 +564,11 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
297
564
  }
298
565
  if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
299
566
 
300
- // End the stream on tool_use, same as processStreamEvent's message_stop handler.
567
+ // End the stream on tool_use. Immediate (no grace deferral) ON PURPOSE: this
568
+ // branch only runs when NO content blocks streamed for the message, so there
569
+ // is no reason to expect terminal stream events either, and the completed
570
+ // message's own usage — applied just above — is the best figure available.
301
571
  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;
572
+ endToolUseTurn(c);
306
573
  }
307
574
  }
@@ -6,10 +6,12 @@
6
6
  // "not-used"` as "configured" and the provider would look connected while every
7
7
  // request fails at spawn time.
8
8
  //
9
- // This module answers two pure questions used to gate registration:
10
- // 1. hasClaudeCredentials() — are real credentials present RIGHT NOW?
11
- // 2. decideRegistration() given credential presence + the primary-instance
12
- // / stream-guard tokens, should we register / unregister / do nothing?
9
+ // This module answers one pure question: hasClaudeCredentials() are real
10
+ // credentials present RIGHT NOW? Since 2.0 it feeds the native provider's
11
+ // auth check/resolve (native-provider.ts) and the pre-spawn fail-fast, rather
12
+ // than gating register/unregister transitions (the 1.x decideRegistration
13
+ // state machine is gone — registration is unconditional and pi hides
14
+ // unconfigured providers' models itself).
13
15
  //
14
16
  // SECURITY: this module only ever checks for the EXISTENCE of credentials — a
15
17
  // file's presence, an env var being non-empty, a settings key being a non-empty
@@ -110,49 +112,3 @@ export function hasClaudeCredentials(
110
112
  return false;
111
113
  }
112
114
 
113
- /**
114
- * Snapshot of the inputs to a registration decision.
115
- *
116
- * The bridge keeps two process-global tokens (Symbol.for): a PRIMARY-instance
117
- * token, claimed unconditionally by the first-loaded module instance, and the
118
- * stream-guard token holding the registered instance's streamSimple. ONLY the
119
- * primary instance may ever register/unregister or claim the stream guard — this
120
- * prevents a subagent module reload (a fresh, non-primary instance) from
121
- * stealing ownership and registering ITS streamSimple, which would split-brain
122
- * the shared session/ctx and break tool-result delivery.
123
- */
124
- export interface RegistrationState {
125
- /** Does the machine have Claude credentials right now? */
126
- credentialed: boolean;
127
- /** Is THIS module instance the primary (first-loaded) instance? */
128
- isPrimary: boolean;
129
- /** Has this instance already registered (owns the stream guard)? */
130
- registered: boolean;
131
- }
132
-
133
- export type RegistrationDecision = "register" | "unregister" | "noop";
134
-
135
- /**
136
- * Pure decision for extension load, every session_start re-check, and the
137
- * pre-spawn fail-fast path.
138
- *
139
- * Rules:
140
- * - Not the primary instance → NOOP (never touch registration).
141
- * - Primary + credentialed + not registered → REGISTER (claim guard + register).
142
- * - Primary + credentialed + already registered → NOOP.
143
- * - Primary + uncredentialed → UNREGISTER (defensive).
144
- *
145
- * The uncredentialed primary always returns UNREGISTER rather than NOOP:
146
- * pi.unregisterProvider is idempotent ("Has no effect if the provider was never
147
- * registered"), and a defensive call is the ONLY way to retract a registration
148
- * that survived a /reload — the ModelRegistry's registeredProviders is a
149
- * process-lifetime Map and module reload does NOT clear it. (At extension-load
150
- * time this defensive unregister only filters the pending-registration queue and
151
- * cannot mutate the persistent registry; the authoritative retraction happens on
152
- * the post-load session_start re-check — see applyProviderRegistration.)
153
- */
154
- export function decideRegistration(state: RegistrationState): RegistrationDecision {
155
- if (!state.isPrimary) return "noop";
156
- if (state.credentialed) return state.registered ? "noop" : "register";
157
- return "unregister";
158
- }
@@ -56,6 +56,33 @@ export function safeToolCallSummary(calls: Array<{ id: string; toolName: string;
56
56
  return calls.map((call) => ({ id: call.id, toolName: call.toolName, argKeys: argKeys(call.arguments) }));
57
57
  }
58
58
 
59
+ export const INTEGRITY_CUSTOM_TYPE = "claude-bridge-integrity";
60
+
61
+ /**
62
+ * Persist a bridge integrity event into the pi session transcript.
63
+ *
64
+ * The diag log and a piUI toast both die with the machine or the render cycle:
65
+ * the 2026-07-28 post-mortem found `Error: Claude bridge: …` messages that were
66
+ * SHOWN but existed nowhere in the pi session file, making analysis from the
67
+ * session alone impossible. A `CustomEntry` closes that gap the same way the
68
+ * connector-call audit does — persisted, never part of built context, never
69
+ * dispatchable by pi's agent loop. Payloads must stay compact metadata (ids,
70
+ * counts, tool names), never tool output.
71
+ *
72
+ * Never throws; returns whether the entry was appended (false outside a pi
73
+ * session — tests, embedded hosts without extensionApi).
74
+ */
75
+ export function appendIntegrityEntry(label: string, data: Record<string, unknown>): boolean {
76
+ try {
77
+ if (!extensionApi) return false;
78
+ extensionApi.appendEntry(INTEGRITY_CUSTOM_TYPE, { label, at: new Date().toISOString(), ...data });
79
+ return true;
80
+ } catch (error) {
81
+ debug("appendIntegrityEntry failed:", error);
82
+ return false;
83
+ }
84
+ }
85
+
59
86
  function compactToolNameSummary(names: Array<{ name: string; count: number }>, limit = 12): string[] {
60
87
  const shown = names.slice(0, limit).map(({ name, count }) => count > 1 ? `${name}×${count}` : name);
61
88
  if (names.length > limit) shown.push(`+${names.length - limit} more`);
@@ -75,8 +102,13 @@ export function reportSyntheticToolResultRepair(missing: MissingToolResult[], co
75
102
  missing: missing.slice(0, 50),
76
103
  ...context,
77
104
  });
105
+ appendIntegrityEntry("repair_tool_pairing_synthetic_results", {
106
+ count: missing.length,
107
+ toolNames,
108
+ sampledToolCallIds: sampledToolCallIds.slice(0, 12),
109
+ });
78
110
  safeNotify(
79
- `Claude bridge: ${missing.length} missing tool result(s) repaired with "[no tool result recorded]"` +
111
+ `Claude bridge: ${missing.length} missing tool result(s) repaired with an explicit error placeholder` +
80
112
  `${toolNameSummary.length ? ` for ${toolNameSummary.join(", ")}` : ""}. ` +
81
113
  `Real tool output was lost before Claude session import; see ${diagLogPath()}.`,
82
114
  "error",
@@ -111,6 +143,16 @@ export function reportToolResultMismatch(queryCtx: QueryContext, reason: string,
111
143
  forceRotate: sharedSession.forceRotate === true,
112
144
  } : null,
113
145
  });
146
+ appendIntegrityEntry("tool_result_delivery_mismatch", {
147
+ reason,
148
+ toolNames: progress.toolNames,
149
+ expectedCount: progress.expectedCount,
150
+ deliveredCount: progress.deliveredCount,
151
+ resolvedCount: progress.resolvedCount,
152
+ waitingIds: progress.waitingIds,
153
+ queuedIds: progress.queuedIds,
154
+ unmatchedResultIds: progress.unmatchedResultIds,
155
+ });
114
156
  safeNotify(
115
157
  `Claude bridge: tool result delivery interrupted during ${reason}; ` +
116
158
  `delivered ${progress.deliveredCount}/${progress.expectedCount}, resolved ${progress.resolvedCount}/${progress.expectedCount}, ` +