@vanillagreen/pi-claude-bridge 4.0.0 → 4.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-claude-bridge",
3
- "version": "4.0.0",
3
+ "version": "4.0.2",
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": [
@@ -133,7 +133,7 @@
133
133
  }
134
134
  },
135
135
  "dependencies": {
136
- "@anthropic-ai/claude-agent-sdk": "^0.3.220",
136
+ "@anthropic-ai/claude-agent-sdk": "^0.3.261",
137
137
  "@anthropic-ai/sdk": "^0.112.4",
138
138
  "cc-session-io": "^0.3.2",
139
139
  "change-case": "^5.4.4"
@@ -145,15 +145,17 @@
145
145
  "devDependencies": {
146
146
  "@earendil-works/pi-ai": "^0.84.0",
147
147
  "@earendil-works/pi-coding-agent": "^0.84.0",
148
+ "@modelcontextprotocol/sdk": "^1.30.0",
148
149
  "@types/node": "^24.3.0",
149
150
  "esbuild": "^0.28.0",
150
151
  "tsx": "^4.21.0",
151
152
  "typescript": "^6.0.3"
152
153
  },
153
154
  "scripts": {
154
- "build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node22 --outfile=bundle/index.js --external:@earendil-works/pi-ai --external:@earendil-works/pi-coding-agent && esbuild src/connector-inventory.ts --bundle --platform=node --format=esm --target=node22 --outfile=bundle/connector-inventory.js",
155
+ "build": "esbuild src/index.ts --bundle --preserve-symlinks --platform=node --format=esm --target=node22 --outfile=bundle/index.js --external:@earendil-works/pi-ai --external:@earendil-works/pi-coding-agent && esbuild src/connector-inventory.ts --bundle --preserve-symlinks --platform=node --format=esm --target=node22 --outfile=bundle/connector-inventory.js",
155
156
  "prepack": "npm run build",
156
157
  "test:unit": "node --import tsx --test tests/unit-*.mjs",
158
+ "test:ci": "npm run build && npm run test:unit",
157
159
  "test": "set -a && [ -f .env.test ] && . ./.env.test; set +a && npm run test:unit && tests/int-smoke.sh && tests/int-multi-turn.sh && tests/int-cache.sh && node --import tsx --test tests/int-*.mjs",
158
160
  "test:usage": "tests/usage-test.sh",
159
161
  "typecheck": "tsc --noEmit"
@@ -4,8 +4,8 @@
4
4
  // versioned symbol below to supply a subscription profile for each fresh
5
5
  // Claude request. The bridge passes only an opaque profile id, a display
6
6
  // label, and an optional CLAUDE_CONFIG_DIR; credentials remain owned by the
7
- // official Claude CLI. Incompatible future shapes must use a new
8
- // symbol/version instead of mutating this contract in place.
7
+ // official Claude CLI. An incompatible shape must use a separate
8
+ // symbol or version instead of mutating this contract in place.
9
9
 
10
10
  import type { AssistantMessageEvent, AssistantMessageEventStream } from "@earendil-works/pi-ai";
11
11
  import { homedir } from "node:os";
@@ -48,7 +48,7 @@ export interface ClaudeAccountRouterV1 {
48
48
  recordFailure(profileId: string, kind: ClaudeAccountFailureKind, modelId: string): void;
49
49
  recordSuccess(profileId: string, sessionId?: string): void;
50
50
  current(modelId: string, sessionId?: string): ClaudeAccountRoute | undefined;
51
- /** Resolve a previously issued profile id back to its route. The bridge
51
+ /** Resolve an issued profile id back to its route. The bridge
52
52
  * persists ONLY the opaque profile id into Pi session entries (config-dir
53
53
  * paths are account-identifying and travel with shared session archives),
54
54
  * so restoring a session re-derives the config dir here. A missing router
@@ -5,14 +5,14 @@ import { connectorResultByteSize, recordConnectorCallResult } from "./connector-
5
5
  import { isChildExecutedTool } from "./connectors.js";
6
6
  import { debug, diagDump } from "./debug.js";
7
7
  import { ctx, failStrandedToolCall, type QueryContext } from "./query-state.js";
8
- import { mapToolArgs, mapToolName } from "./tool-mapping.js";
8
+ import { isForeignMcpTool, isPiDispatchable, mapToolArgs, mapToolName } from "./tool-mapping.js";
9
9
 
10
10
  // --- Usage helpers ---
11
11
 
12
12
  function updateUsage(output: AssistantMessage, usage: Record<string, number | undefined>, model: Model<any>, c: QueryContext): void {
13
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
14
+ // grows, so the in-flight message's figures replace, never accumulate. What
15
+ // accumulates is every child message already finished in this Pi turn — see
16
16
  // `turnUsageCarry` in query-state.ts for why a turn can span several.
17
17
  const current = c.currentMessageUsage;
18
18
  const carry = c.turnUsageCarry;
@@ -70,25 +70,24 @@ export function finalizeCurrentStream(stopReason?: string, c: QueryContext = ctx
70
70
  //
71
71
  // The Claude Code CLI dispatches MCP tool calls (and the SDK yields the
72
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).
73
+ // message_delta is what carries the message's REAL output-token count (the
74
+ // handler fires tens of milliseconds ahead of it on every tool-use turn).
75
+ // Ending the pi stream at either of those early signals freezes usage at the
76
+ // message_start placeholder values: a handful of output tokens per tool-use
77
+ // turn while the final text turn records hundreds.
79
78
  //
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.
79
+ // So the turn ends at message_stop, exactly like the streamed-text case, and
80
+ // the early signals only ARM a grace timer. The timer is the deadlock backstop
81
+ // for a stream whose terminal events never arrive (pi's steer draining
82
+ // produces one): pi cannot execute tools before the stream ends, and the MCP
83
+ // handler cannot resolve before pi executes, so a stream that has gone silent
84
+ // must be ended by force — TOOL_USE_END_GRACE_MS later instead of immediately.
86
85
 
87
86
  const TOOL_USE_END_GRACE_MS = 1500;
88
87
 
89
88
  /** End the current pi stream as a tool_use turn boundary. Safe to call when the
90
89
  * turn already ended (no-op). Every end path funnels here, so this is where
91
- * two invariants are enforced by construction (kendex#1469): a block still
90
+ * two invariants are enforced by construction: a block still
92
91
  * carrying partialJson never ships — Pi executes the done message's content,
93
92
  * and truncated arguments must never execute — and every call that DOES ship
94
93
  * is stamped forwarded so no lagging replay can dispatch it again. */
@@ -105,7 +104,7 @@ export function endToolUseTurn(c: QueryContext): void {
105
104
  }
106
105
  // Every tool call Pi is about to execute from this turn is owed a result and
107
106
  // 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 (kendex#1469).
107
+ // into the NEXT turn, whose per-message dedup cannot see it.
109
108
  for (const block of c.turnOutput.content as Array<{ type?: string; id?: unknown }>) {
110
109
  if (block?.type === "toolCall" && typeof block.id === "string") c.forwardedToolCallIds.add(block.id);
111
110
  }
@@ -146,12 +145,11 @@ export function scheduleToolUseTurnEnd(c: QueryContext, action: () => void, sour
146
145
  * Park queued tool results whose handler has not fired by a child message
147
146
  * boundary, and say so everywhere it matters. The boundary is where stale
148
147
  * 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
- * (kendex#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.
148
+ * handler gave up: the SDK staggers handler invocations, and handlers in a
149
+ * parallel batch routinely fire after this point. Parked results stay
150
+ * consumable through takeQueuedOrParkedResult; one that is never consumed
151
+ * belongs to a call the SDK abandoned client-side (permission denial), which
152
+ * is exactly what the notice describes.
155
153
  */
156
154
  export function reapStaleQueuedResults(c: QueryContext): void {
157
155
  const stale = c.takeStaleQueuedResults();
@@ -161,6 +159,7 @@ export function reapStaleQueuedResults(c: QueryContext): void {
161
159
  diagDump("stale_queued_tool_results_parked", { count: stale.length, stale });
162
160
  appendIntegrityEntry("stale_queued_tool_results_parked", { count: stale.length, stale });
163
161
  safeNotify(
162
+ `queued-results-parked=${JSON.stringify({ count: stale.length, tools: names })}\n` +
164
163
  `Claude bridge: parked ${stale.length} early tool result(s) whose handler has not arrived (${names.slice(0, 6).join(", ")}${names.length > 6 ? ", …" : ""}). ` +
165
164
  `A late handler can still consume them.`,
166
165
  "warning",
@@ -180,14 +179,14 @@ export const FINALIZE_MAX_REARMS = 3;
180
179
  * stream events never arrived (the grace-timer action armed by an MCP handler
181
180
  * invocation — see scheduleToolUseTurnEnd).
182
181
  *
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 (kendex#1469).
182
+ * The producer is pi's steer draining (tool result and drained steer arrive
183
+ * in one provider call): the NEXT tool turn's tool_use streams in, the SDK
184
+ * invokes the MCP handler — and neither terminal event ever arrives. The
185
+ * invocation itself proves the assistant turn is committed, so end the pi
186
+ * stream like the `message_stop` path — with this handler's schema-validated
187
+ * arguments, never a partial parse — after settling every sibling whose
188
+ * handler has fired and giving a merely-lagging stream up to
189
+ * FINALIZE_MAX_REARMS extra grace periods for the rest.
191
190
  *
192
191
  * The dead-stream guard is a backstop: the grace timer's own stream-identity
193
192
  * check means this normally never runs after the turn ended. The primary
@@ -204,8 +203,8 @@ export function finalizeToolUseTurnFromMcpInvocation(
204
203
  // The turn ended without this call. An unforwarded handler here can never
205
204
  // be answered — the yield that could have replayed its call was consumed
206
205
  // 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 (kendex#1469).
206
+ // that has not happened yet. Failing it here turns what would be a
207
+ // session-long deadlock into one retryable error.
209
208
  if (failStrandedToolCall(queryCtx, toolCallId)) {
210
209
  debug(`mcp handler: ${toolName} [${toolCallId}] stranded — turn ended before its call reached Pi; resolved with error`);
211
210
  diagDump("tool_handler_stranded", { toolCallId, toolName, site: "finalize-no-stream" });
@@ -220,9 +219,9 @@ export function finalizeToolUseTurnFromMcpInvocation(
220
219
  // Stream ended before content_block_stop. The SDK invoked this handler
221
220
  // with the COMPLETE schema-validated input, so the handler's copy is
222
221
  // 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 (kendex#1469), exactly the divergence
225
- // the synthesize branch below never had.
222
+ // Settling from the partial forwards `{}`-argument calls that Pi then
223
+ // executes and errors on, a divergence the synthesize branch below
224
+ // cannot have.
226
225
  block.arguments = mappedArgs;
227
226
  queryCtx.updateToolCallArgs(block.id, block.arguments);
228
227
  delete block.partialJson;
@@ -230,13 +229,13 @@ export function finalizeToolUseTurnFromMcpInvocation(
230
229
  queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: queryCtx.turnOutput });
231
230
  }
232
231
  } 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.
232
+ // Pi already executed this call in a turn that has ended (its result
233
+ // arrives or sits parked), or the handler was already failed — either
234
+ // way a second dispatch is the one outcome worse than waiting. Do NOT
235
+ // return: this firing consumed the stream's only grace timer, so the
236
+ // current turn's own blocks must still be settled and the turn still
237
+ // ended below, or a turn that loses its terminal events afterwards has
238
+ // no backstop left and Pi sits busy until manual abort.
240
239
  debug(`mcp handler: ${toolName} [${toolCallId}] already ${queryCtx.forwardedToolCallIds.has(toolCallId) ? "forwarded" : "dead"} — not re-emitting`);
241
240
  } else {
242
241
  // The invocation can arrive before the tool_use is streamed at all
@@ -314,13 +313,14 @@ export function processStreamEvent(
314
313
  }
315
314
 
316
315
  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.
316
+ // The child moving on to another message is where a still-queued result
317
+ // would start poisoning mismatch reports: park it, consumable by a late
318
+ // handler (see reapStaleQueuedResults).
319
319
  reapStaleQueuedResults(c);
320
320
  c.resetToolTracking();
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).
321
+ // Another child message begins: bank what the previous one billed before
322
+ // its counters are replaced. No-op on the turn's first, and no-op if this
323
+ // same message was already declared (see beginChildMessage).
324
324
  c.beginChildMessage(event.message?.id);
325
325
  updateTurnOutputModel(event.message?.model, c);
326
326
  if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model, c);
@@ -330,7 +330,7 @@ export function processStreamEvent(
330
330
  if (event?.type === "content_block_start") {
331
331
  c.turnSawStreamEvent = true;
332
332
  ensureTurnStarted(c);
333
- // A new block owns this index from here on, so release any child-executed
333
+ // This block owns its index from here on, so release any child-executed
334
334
  // or suppressed claim on it. Belt-and-braces against a missed
335
335
  // message_start: without this a stale index could silently swallow a later
336
336
  // text block's deltas.
@@ -344,6 +344,12 @@ export function processStreamEvent(
344
344
  debug(`processStreamEvent: child-executed tool ${event.content_block.name} [${event.content_block.id}] — not mirrored as a Pi tool call`);
345
345
  return;
346
346
  }
347
+ if (event.content_block?.type === "tool_use" && !isPiDispatchable(event.content_block.name, customToolNameToPi)) {
348
+ c.suppressedStreamIndexes.add(event.index);
349
+ if (isForeignMcpTool(event.content_block.name)) c.noteForeignMcpToolCall(event.content_block.id, event.content_block.name);
350
+ debug(`processStreamEvent: non-dispatchable tool ${event.content_block.name} [${event.content_block.id}] — not mirrored as a Pi tool call`);
351
+ return;
352
+ }
347
353
  if (event.content_block?.type === "text") {
348
354
  c.turnBlocks.push({ type: "text", text: "", index: event.index });
349
355
  c.currentPiStream!.push({ type: "text_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
@@ -355,7 +361,7 @@ export function processStreamEvent(
355
361
  if (typeof streamedId === "string" && (c.forwardedToolCallIds.has(streamedId) || c.deadToolCallIds.has(streamedId))) {
356
362
  // A lagging stream replaying a call Pi already executed — or one whose
357
363
  // handler was already failed as stranded — into a later turn. Mirroring
358
- // it would make Pi dispatch it a second time (kendex#1469).
364
+ // it would make Pi dispatch it a second time.
359
365
  c.suppressedStreamIndexes.add(event.index);
360
366
  debug(`processStreamEvent: tool_use ${streamedId} already ${c.forwardedToolCallIds.has(streamedId) ? "forwarded" : "dead"} — suppressing duplicate stream block`);
361
367
  return;
@@ -364,7 +370,7 @@ export function processStreamEvent(
364
370
  // Same turn, same id: the completed-message yield beat the stream (its
365
371
  // block is already recorded with complete arguments). A second
366
372
  // partialJson copy would ship the id twice in one done message — and
367
- // if its stop never arrives, ship it truncated (kendex#1469).
373
+ // if its stop never arrives, ship it truncated.
368
374
  c.suppressedStreamIndexes.add(event.index);
369
375
  debug(`processStreamEvent: tool_use ${streamedId} already recorded in this turn — suppressing duplicate stream block`);
370
376
  return;
@@ -494,7 +500,7 @@ function appendMissingToolUsesFromAssistant(
494
500
  // array of a turnOutput that endToolUseTurn already handed Pi BY REFERENCE
495
501
  // in its done event, so a push here appends calls into a delivered message
496
502
  // behind Pi's back — whether Pi's dispatch enumerates before or after the
497
- // push is a microtask race (kendex#1469).
503
+ // push is a microtask race.
498
504
  const streamLive = Boolean(c.currentPiStream && c.turnOutput);
499
505
  let sawToolUse = false;
500
506
  for (const block of assistantMsg.content) {
@@ -507,11 +513,16 @@ function appendMissingToolUsesFromAssistant(
507
513
  debug(`assistant message: child-executed tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
508
514
  continue;
509
515
  }
516
+ if (!isPiDispatchable(block.name, customToolNameToPi)) {
517
+ if (isForeignMcpTool(block.name)) c.noteForeignMcpToolCall(block.id, block.name);
518
+ debug(`assistant message: non-dispatchable tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
519
+ continue;
520
+ }
510
521
  const existingIdx = c.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === block.id);
511
522
  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 (kendex#1469).
523
+ // Completed-message replay of a call Pi already executed in a turn that
524
+ // has ended (or one already failed as stranded). Not a live Pi turn
525
+ // boundary: sawToolUse stays false for it, and no block is emitted.
515
526
  debug(`assistant message: tool_use ${block.id} already ${c.forwardedToolCallIds.has(block.id) ? "forwarded" : "dead"} — skipping duplicate`);
516
527
  continue;
517
528
  }
@@ -545,9 +556,9 @@ function appendMissingToolUsesFromAssistant(
545
556
  c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
546
557
  }
547
558
  // 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.
559
+ // message_start placeholder usage, and once the done event has delivered
560
+ // turnOutput to pi, overwriting its usage with those placeholders would
561
+ // corrupt the very figure message_delta got right.
551
562
  if (assistantMsg.usage && c.turnOutput && c.currentPiStream) updateUsage(c.turnOutput, assistantMsg.usage, model, c);
552
563
  return sawToolUse;
553
564
  }
@@ -592,14 +603,14 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
592
603
  updateTurnOutputModel(assistantMsg.model, c);
593
604
  if (c.turnSawStreamEvent) {
594
605
  // 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.
606
+ // message_delta/message_stop on every tool-use turn (the norm, not a
607
+ // fallback). Record any tool_use blocks the stream hasn't delivered yet,
608
+ // but do NOT end the pi stream here: message_delta, which arrives tens of
609
+ // ms later, carries the message's real output-token count, and
610
+ // message_stop is the normal turn end. Ending here freezes usage at the
611
+ // message_start placeholders. The grace timer force-ends the turn if the
612
+ // terminal events never arrive, so pi still gets to execute the tools and
613
+ // unblock the MCP handlers.
603
614
  if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi, c)) {
604
615
  c.turnSawToolCall = true;
605
616
  scheduleToolUseTurnEnd(c, () => endToolUseTurn(c), "assistant-boundary");
@@ -609,11 +620,11 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
609
620
  // The SDK yields the SAME assistant message more than once (per-block
610
621
  // partial copies and the completed message share one id). With stream
611
622
  // 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.
623
+ // naturally ignored; on this no-stream-events path, re-rendering each
624
+ // yield wholesale prints a rate-limited turn's "You've hit your weekly
625
+ // limit" twice. Same-message yields keep the turn's tracking (a reset
626
+ // mid-message would wipe live tool-claim state) and render only blocks not
627
+ // already rendered.
617
628
  const sameMessage = typeof assistantMsg.id === "string" && assistantMsg.id.length > 0 && assistantMsg.id === c.currentMessageId;
618
629
  if (!sameMessage) {
619
630
  reapStaleQueuedResults(c);
@@ -627,9 +638,9 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
627
638
  debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}${sameMessage ? " (same message re-yield)" : ""}`);
628
639
  // Deduped against the WHOLE current turn, not just same-id re-yields: a
629
640
  // 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
641
+ // arrives as multiple assistant yields whose ids DIFFER or are absent (one
642
+ // pi message, two byte-identical text blocks), so an id-keyed guard alone
643
+ // still renders it twice. A model legitimately
633
644
  // producing two byte-identical full blocks in one turn is vanishingly rare;
634
645
  // rendering such a duplicate once is the better failure mode.
635
646
  const alreadyRendered = (type: string, content: string): boolean =>
@@ -659,14 +670,19 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
659
670
  debug(`processAssistantMessage fallback: child-executed tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
660
671
  continue;
661
672
  }
673
+ if (!isPiDispatchable(block.name, customToolNameToPi)) {
674
+ if (isForeignMcpTool(block.name)) c.noteForeignMcpToolCall(block.id, block.name);
675
+ debug(`processAssistantMessage fallback: non-dispatchable tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
676
+ continue;
677
+ }
662
678
  if (!c.turnBlocks.some((b: any) => b.type === "toolCall" && b.id === block.id)
663
679
  && (c.forwardedToolCallIds.has(block.id) || c.deadToolCallIds.has(block.id))) {
664
680
  // 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 (kendex#1469).
669
- // Not recorded either — a forwarded call must not be claimable again.
681
+ // handler was already failed as stranded). This is the path a
682
+ // duplicate dispatch takes: the completed-message yield lands in the
683
+ // callback AFTER a grace finalize already ended the call's turn, and
684
+ // per-message dedup cannot see across turns. Not recorded either — a
685
+ // forwarded call must not be claimable again.
670
686
  debug(`processAssistantMessage fallback: tool_use ${block.id} already ${c.forwardedToolCallIds.has(block.id) ? "forwarded" : "dead"} — skipping duplicate`);
671
687
  continue;
672
688
  }
@@ -85,7 +85,7 @@ function hasApiKeyHelper(configDir: string): boolean {
85
85
  * Keychain, NOT in `.credentials.json`, so file-absence is NOT evidence of
86
86
  * logged-out and we cannot cheaply/safely probe the Keychain here. On darwin,
87
87
  * when no other signal is present, we default to credentialed=true — preserving
88
- * the pre-fix "always available" behavior for Mac subscription users. Honesty
88
+ * an "always available" result for Mac subscription users. Honesty
89
89
  * enforcement therefore applies on Linux/Windows, where `.credentials.json`
90
90
  * existence is an observable, truthful proxy (empirically, `claude auth logout`
91
91
  * unlinks it).
@@ -111,4 +111,3 @@ export function hasClaudeCredentials(
111
111
 
112
112
  return false;
113
113
  }
114
-
@@ -0,0 +1,116 @@
1
+ // The Anthropic login the bridge's child query actually authenticated as,
2
+ // published for other extensions to read.
3
+ //
4
+ // WHY a published surface rather than letting a reader resolve
5
+ // CLAUDE_CONFIG_DIR for itself: that directory names the login only when the
6
+ // child used one. The bridge also accepts an API key and the Bedrock, Vertex,
7
+ // Foundry, Anthropic-AWS and Mantle backends (auth-presence.ts), and it passes
8
+ // those environment values straight to the child (query-options.ts). A
9
+ // companion account router may additionally hand each request its own profile
10
+ // and rotate it while the process environment never changes
11
+ // (account-router.ts). Only the SDK's own accountInfo() names the identity a
12
+ // request ran under, so the rule for reading it lives here once instead of in
13
+ // every consumer.
14
+ //
15
+ // SECURITY: this module holds one email per live request lane in memory and
16
+ // never logs it.
17
+
18
+ import type { AccountInfo } from "@anthropic-ai/claude-agent-sdk";
19
+ import { currentRequestLaneId } from "./request-lane.js";
20
+
21
+ export const CLAUDE_BILLING_IDENTITY_SYMBOL = Symbol.for("kendex.pi.claude-bridge.billing-identity.v1");
22
+
23
+ /** Anthropic's own login backend. Every other `apiProvider` value is an
24
+ * external credential whose payer this bridge cannot name. */
25
+ const FIRST_PARTY = "firstParty";
26
+
27
+ export interface ClaudeBillingIdentityV1 {
28
+ version: 1;
29
+ /** The Anthropic login email of the latest child attempt in `sessionId`, or
30
+ * undefined when that attempt authenticated with an API key or a
31
+ * third-party backend, has not reported yet, or its probe failed. A
32
+ * consumer passes the visible Pi session id, displays the result, and
33
+ * derives nothing further. */
34
+ currentLoginEmail(sessionId: string | undefined): string | undefined;
35
+ }
36
+
37
+ interface BillingIdentityStore extends ClaudeBillingIdentityV1 {
38
+ beginAttempt(sessionId: string | undefined): (info: AccountInfo) => void;
39
+ deleteLane(sessionId: string | undefined): void;
40
+ clear(): void;
41
+ }
42
+
43
+ interface BillingIdentityLane {
44
+ attempt: symbol;
45
+ loginEmail?: string;
46
+ }
47
+
48
+ const BILLING_IDENTITY_LANES_SYMBOL = Symbol.for("kendex.pi.claude-bridge.billing-identity-lanes.v1");
49
+
50
+ function sharedBillingIdentityLanes(): Map<string | undefined, BillingIdentityLane> {
51
+ const host = globalThis as Record<symbol, unknown>;
52
+ let lanes = host[BILLING_IDENTITY_LANES_SYMBOL] as Map<string | undefined, BillingIdentityLane> | undefined;
53
+ if (!lanes) {
54
+ lanes = new Map();
55
+ host[BILLING_IDENTITY_LANES_SYMBOL] = lanes;
56
+ }
57
+ return lanes;
58
+ }
59
+
60
+ function nonEmpty(value: string | undefined): string | undefined {
61
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
62
+ }
63
+
64
+ /** The login email an `accountInfo()` result confirms, or undefined when it
65
+ * confirms none. An API key is rejected even under the first-party backend:
66
+ * the key's owner is not the signed-in login, and `apiKeySource` is how the
67
+ * SDK reports that a key was used. */
68
+ export function loginEmailFrom(info: AccountInfo): string | undefined {
69
+ if (info.apiProvider !== FIRST_PARTY) return undefined;
70
+ if (nonEmpty(info.apiKeySource)) return undefined;
71
+ return nonEmpty(info.email);
72
+ }
73
+
74
+ export function makeBillingIdentityStore(
75
+ lanes: Map<string | undefined, BillingIdentityLane> = new Map(),
76
+ ): BillingIdentityStore {
77
+ return {
78
+ version: 1,
79
+ currentLoginEmail: (sessionId) => lanes.get(sessionId)?.loginEmail,
80
+ beginAttempt: (sessionId) => {
81
+ const attempt = Symbol("billing-identity-attempt");
82
+ lanes.set(sessionId, { attempt });
83
+ return (info) => {
84
+ const current = lanes.get(sessionId);
85
+ if (current?.attempt !== attempt) return;
86
+ lanes.set(sessionId, { attempt, loginEmail: loginEmailFrom(info) });
87
+ };
88
+ },
89
+ deleteLane: (sessionId) => lanes.delete(sessionId),
90
+ clear: () => lanes.clear(),
91
+ };
92
+ }
93
+
94
+ export const BRIDGE_BILLING_IDENTITY = makeBillingIdentityStore(sharedBillingIdentityLanes());
95
+
96
+ /** Start the billing probe for the current request lane. Starting clears that
97
+ * lane, so a rejected probe cannot leave the previous attempt's identity.
98
+ * The returned recorder ignores an older probe that settles after a newer
99
+ * attempt in the same lane. */
100
+ export function beginBillingIdentityAttempt(): (info: AccountInfo) => void {
101
+ return BRIDGE_BILLING_IDENTITY.beginAttempt(currentRequestLaneId());
102
+ }
103
+
104
+ /** Remove one completed Pi session without changing concurrent sessions. */
105
+ export function deleteBillingIdentityLane(sessionId: string | undefined): void {
106
+ BRIDGE_BILLING_IDENTITY.deleteLane(sessionId);
107
+ }
108
+
109
+ /** Read the published store, or undefined when no bridge is loaded. Never
110
+ * installs one: a consumer that created its own would answer for a bridge
111
+ * that is not running. */
112
+ export function resolveClaudeBillingIdentity(): ClaudeBillingIdentityV1 | undefined {
113
+ const host = globalThis as unknown as Record<PropertyKey, unknown>;
114
+ const candidate = host[CLAUDE_BILLING_IDENTITY_SYMBOL] as ClaudeBillingIdentityV1 | undefined;
115
+ return candidate?.version === 1 && typeof candidate.currentLoginEmail === "function" ? candidate : undefined;
116
+ }
@@ -35,7 +35,7 @@ function showBridgeStatus(ctx: { ui: ExtensionUIContext; cwd?: string }): void {
35
35
  ].join("\n"), "info");
36
36
  }
37
37
 
38
- // Deterministic connector enumeration for the host app (kendex#838). Reports the
38
+ // Deterministic connector enumeration for the host app. Reports the
39
39
  // failure reason rather than an empty list, so "no connectors" and "could not
40
40
  // check" stay distinguishable.
41
41
  async function reportConnectorInventory(ctx: {
@@ -1,5 +1,5 @@
1
1
  import { type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
- import { debug, diagDump, diagGuidance } from "./debug.js";
2
+ import { DEBUG, debug, diagDump, diagGuidance, diagLogPath } from "./debug.js";
3
3
  import { type QueryContext } from "./query-state.js";
4
4
  import { currentRequestLaneId } from "./request-lane.js";
5
5
  import { summarizeMissingToolNames, type MissingToolResult } from "./tool-pairing-audit.js";
@@ -23,7 +23,7 @@ export interface SessionState {
23
23
  // never rewrite those opening messages — compact/tree-nav mutations set
24
24
  // needsRebuild instead — so a component mismatch marks a FOREIGN
25
25
  // conversation (a subagent-shaped query arriving while the parent is idle,
26
- // kendex#1001) that must run as a clean one-shot without touching this
26
+ //) that must run as a clean one-shot without touching this
27
27
  // record. The user component must always match; the assistant component is
28
28
  // compared only when BOTH sides carry one, so a record stamped on turn 1
29
29
  // (no assistant yet) still matches its own grown conversation and upgrades
@@ -35,14 +35,16 @@ export interface SessionState {
35
35
  // navigation) or after an abort left the JSONL in an indeterminate state.
36
36
  // REBUILD wipes and rewrites the file to match pi's current history.
37
37
  needsRebuild?: boolean;
38
- // Set ONLY after an abort. The killed CC subprocess may still be flushing
39
- // a late "[Request interrupted by user]" record to the session JSONL.
38
+ // Set whenever a CC subprocess was killed and may still be flushing to its
39
+ // session JSONL: after an abort, whose late "[Request interrupted by user]"
40
+ // record is the classic case, and at a history replacement (compact, tree
41
+ // navigation) that stops an ACTIVE query to restart it on pi's new context.
40
42
  // Reusing the same sessionId/path would race that orphan write into our
41
43
  // fresh file and break CC's parent-uuid chain on the next resume. When
42
44
  // this flag is set, REBUILD takes a fresh UUID and skips deleteSession
43
- // so the orphan writes land on a dead inode. Compact/tree do NOT set
44
- // this — there's no concurrent CC writer during those events, so
45
- // in-place rebuild (preserve UUID, deleteSession + createSession) is safe.
45
+ // so the orphan writes land on a dead inode. A compact or tree event with
46
+ // no active query kills nothing, so it leaves this unset and rebuilds in
47
+ // place (preserve UUID, deleteSession + createSession).
46
48
  forceRotate?: boolean;
47
49
  }
48
50
 
@@ -169,10 +171,9 @@ export const INTEGRITY_CUSTOM_TYPE = "claude-bridge-integrity";
169
171
  /**
170
172
  * Persist a bridge integrity event into the pi session transcript.
171
173
  *
172
- * The diag log and a piUI toast both die with the machine or the render cycle:
173
- * the 2026-07-28 post-mortem found `Error: Claude bridge: …` messages that were
174
- * SHOWN but existed nowhere in the pi session file, making analysis from the
175
- * session alone impossible. A `CustomEntry` closes that gap the same way the
174
+ * The diag log and a piUI toast both die with the machine or render cycle, so
175
+ * they cannot support analysis from the session alone. A `CustomEntry` persists
176
+ * the record in the same way the
176
177
  * connector-call audit does — persisted, never part of built context, never
177
178
  * dispatchable by pi's agent loop. Payloads must stay compact metadata (ids,
178
179
  * counts, tool names), never tool output.
@@ -242,7 +243,7 @@ export function reportToolResultMismatch(
242
243
  queryCtx.reportedToolResultMismatch = true;
243
244
  // The single choke point every mismatch path funnels through (abort,
244
245
  // unmatched result, stream-idle, teardown). A context with no claim on
245
- // the shared record (reentrant subagent or foreign one-shot, kendex#1001)
246
+ // the shared record (reentrant subagent or foreign one-shot,)
246
247
  // still gets the full diagnostics below, but its unresolved tool state is
247
248
  // its own — marking the PARENT's record needsRebuild/forceRotate here
248
249
  // would flush the parent's prompt cache for a query that never touched
@@ -285,6 +286,7 @@ export function reportToolResultMismatch(
285
286
  unmatchedResultIds: progress.unmatchedResultIds,
286
287
  });
287
288
  safeNotify(
289
+ `tool-result-mismatch=${JSON.stringify({ delivered: progress.deliveredCount, expected: progress.expectedCount, resolved: progress.resolvedCount, diagnostic: DEBUG ? diagLogPath() : "CLAUDE_BRIDGE_DEBUG=1" })}\n` +
288
290
  `Claude bridge: tool result delivery interrupted during ${reason}; ` +
289
291
  `delivered ${progress.deliveredCount}/${progress.expectedCount}, resolved ${progress.resolvedCount}/${progress.expectedCount}, ` +
290
292
  `waiting=${progress.waitingCount}, queued=${progress.queuedCount}, unmatched=${progress.unmatchedResultCount}` +