@vanillagreen/pi-claude-bridge 4.0.1 → 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/src/index.ts CHANGED
@@ -7,8 +7,8 @@ import { PROVIDER_ID, messageContentToText } from "./convert.js";
7
7
  import { buildModels, modelDisplayName } from "./models.js";
8
8
  import { MCP_SERVER_NAME, MCP_TOOL_PREFIX } from "./skills.js";
9
9
  import { extractAllToolResults as _extractAllToolResults, type McpResult } from "./extract-tool-results.js";
10
- import { QueryContext, ctx, deleteQueryLane, drainPendingToolCalls, drainStrandedToolCalls, popContext, stackDepth, pushContext, summarizeDroppedUserMessages, takeQueuedOrParkedResult, toolCallDrainCause, type DeferredUserMessage } from "./query-state.js";
11
- import { teardownQuery } from "./query-teardown.js";
10
+ import { QueryContext, ctx, deleteQueryLane, drainPendingToolCalls, drainStrandedToolCalls, popContext, stackDepth, pushContext, summarizeDroppedUserMessages, takeQueuedOrParkedResult, toolCallDrainCause, type DeferredUserMessage, type QueryRestartRequest } from "./query-state.js";
11
+ import { abortSdkQuery, closeSdkQuery, teardownQuery } from "./query-teardown.js";
12
12
  import { loadConfig, recordProjectTrust, registerExternalConfigResolver } from "./config.js";
13
13
  import { hasClaudeCredentials } from "./auth-presence.js";
14
14
  import { NATIVE_PROVIDER_UNSUPPORTED_MESSAGE, buildNativeProvider, supportsNativeProvider } from "./native-provider.js";
@@ -59,6 +59,7 @@ import {
59
59
  type ClaudeAccountRoute,
60
60
  } from "./account-router.js";
61
61
  import { BRIDGE_ACCOUNT_HOST } from "./account-host.js";
62
+ import { BRIDGE_BILLING_IDENTITY, CLAUDE_BILLING_IDENTITY_SYMBOL, beginBillingIdentityAttempt, deleteBillingIdentityLane } from "./billing-identity.js";
62
63
  import { registerBridgeCommands } from "./bridge-commands.js";
63
64
  import { consumeQuery, emitRateLimitEvent, type ClaudeAttemptFailure } from "./consume-query.js";
64
65
  import { buildClaudeQueryOptions } from "./query-options.js";
@@ -447,6 +448,10 @@ function releaseProviderTokens(event: string): void {
447
448
  if (g[CLAUDE_BRIDGE_ACCOUNT_HOST_SYMBOL] === BRIDGE_ACCOUNT_HOST) {
448
449
  g[CLAUDE_BRIDGE_ACCOUNT_HOST_SYMBOL] = undefined;
449
450
  }
451
+ // Billing identity is request-lane state. The shutdown handler removes its
452
+ // one lane after this call. The process publisher remains available to
453
+ // sibling sessions and reads the shared lane registry across reloads until a
454
+ // new primary replaces it.
450
455
  if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk) {
451
456
  debug(`${event}: clearing ACTIVE_STREAM_SIMPLE_KEY`);
452
457
  g[ACTIVE_STREAM_SIMPLE_KEY] = undefined;
@@ -528,6 +533,59 @@ function applyProviderRegistration(trigger: string): void {
528
533
  }
529
534
  }
530
535
 
536
+ /** Prompt for the query that replaces one whose Pi history was replaced. Pi's
537
+ * whole context becomes imported history — the summary, the retained messages
538
+ * and the results of the tool calls that already ran — so the replacement query
539
+ * is only told to carry on from it. */
540
+ export const HISTORY_REPLACED_PROMPT = "The conversation above was rewritten by Pi (compaction or history navigation). It is the complete current history, including the results of every tool call that has already run. Continue the turn from there, and do not repeat a tool call whose result is already above.";
541
+
542
+ /** Pi's new context plus the continuation prompt, so the rebuild imports every
543
+ * message Pi holds — trailing tool results included, which keeps each one
544
+ * paired with its tool call and present exactly once. */
545
+ function restartContext(request: QueryRestartRequest): Context {
546
+ return {
547
+ ...request.context,
548
+ messages: [...request.context.messages, { role: "user", content: HISTORY_REPLACED_PROMPT, timestamp: Date.now() }],
549
+ };
550
+ }
551
+
552
+ /** Pi replaced the conversation's history: pi /compact and session-tree
553
+ * navigation (rewind / fork-at-point / branch switch) both mutate pi's messages
554
+ * array out from under the bridge. syncSharedSession's REUSE check would
555
+ * otherwise see slice(cursor) === [] (or skip entries) and keep --resume'ing a
556
+ * CC session that does not match pi's history; /compact in particular triggers
557
+ * CC's autocompact-thrashing guard. Force the next call down the REBUILD path,
558
+ * and mark an ACTIVE query stale so the next provider callback restarts it
559
+ * rather than continuing it on history Pi has thrown away.
560
+ *
561
+ * Exported for the compaction-restart unit test; pi.on wires the two events. */
562
+ export function onPiHistoryReplaced(event: string): void {
563
+ const activeSession = getSharedSession();
564
+ const queryCtx = ctx();
565
+ // Whether THIS call requests a restart. A query with no claim on the shared
566
+ // record — a reentrant subagent, a foreign one-shot — is running its own
567
+ // conversation, so pi's replacement is not its history and it is never
568
+ // restarted on it. Read here rather than off piHistoryReplaced below, which
569
+ // outlives a query that ended without its restart.
570
+ const running = queryCtx.activeQuery !== null;
571
+ const restarts = running && !queryCtx.detachedFromSharedSession;
572
+ if (running) {
573
+ if (restarts) queryCtx.piHistoryReplaced = true;
574
+ // A restart re-imports the interrupted calls' results from pi's own
575
+ // history, so their interruption is expected teardown rather than the
576
+ // integrity fault an unhandled one reports.
577
+ reportToolResultMismatch(queryCtx, event, activeSession?.cwd ?? process.cwd(), { expectedInterruption: restarts });
578
+ }
579
+ if (activeSession) {
580
+ debug(`${event}: marking needsRebuild on session ${activeSession.sessionId.slice(0, 8)}`);
581
+ // A restart kills the child, which goes on flushing its jsonl: rebuilding
582
+ // in place would race that writer, so the replacement takes a new session
583
+ // id and leaves the old transcript alone. A compaction that kills nothing
584
+ // has no writer to race and rebuilds in place.
585
+ markSessionForRebuild({ forceRotate: restarts });
586
+ }
587
+ }
588
+
531
589
  /** Provider entry point. Pi calls this for each prompt and each tool result.
532
590
  * Two cases: tool result delivery (active query) or fresh query. Exported for
533
591
  * the rotation-stream unit tests, which drive it with a fake SDK factory. */
@@ -549,6 +607,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
549
607
  if (!ephemeralLane) return;
550
608
  deleteSharedSessionLane(laneId);
551
609
  deleteQueryLane(laneId);
610
+ deleteBillingIdentityLane(laneId);
552
611
  };
553
612
 
554
613
  // DEBUG: trace followUp message triggering
@@ -562,6 +621,44 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
562
621
  // handlers. Results that arrive before their handler get queued in pendingResults.
563
622
  if (ctx().activeQuery) {
564
623
  const queryCtx = ctx();
624
+ if (queryCtx.piHistoryReplaced) {
625
+ // Pi compacted or navigated this conversation while the query ran, so the
626
+ // query's Claude session holds history Pi has replaced. Restart from THIS
627
+ // context instead of delivering into it: Pi's messages already carry every
628
+ // tool call Pi executed, so the rebuilt session imports each result once
629
+ // and no such tool runs again. The dying query's own chain performs the
630
+ // restart, once teardown has released the query state.
631
+ //
632
+ // Except when the CHILD ran a call pi never sees — a claude.ai connector,
633
+ // or a foreign MCP tool it loaded from filesystem settings. Those calls are
634
+ // never mirrored into pi's messages and their results are observed at most,
635
+ // never recorded, so no rebuild from pi's context can carry them. Handing
636
+ // the replacement a history missing an account-visible call, under a
637
+ // prompt saying every result is present, invites the model to run it
638
+ // again. Keep this query on its stale history instead, and keep the
639
+ // marker set: pi replaced the history whether or not the bridge acts on
640
+ // it, so the record this query writes as it ends must still say so or the
641
+ // NEXT turn resumes the history the compaction threw away. Report the
642
+ // refusal once, not once per remaining tool result.
643
+ if (queryCtx.childSideCalls.size > 0) {
644
+ if (!queryCtx.reportedHistoryRestartDecline) {
645
+ queryCtx.reportedHistoryRestartDecline = true;
646
+ const names = [...new Set([...queryCtx.childSideCalls.values()].map((call) => call.name))];
647
+ debug(`provider: pi replaced this query's history, but ${queryCtx.childSideCalls.size} child-executed call(s) are absent from pi's context; not restarting (${names.join(", ")})`);
648
+ appendIntegrityEntry("history_restart_declined", { reason: "child-executed calls pi's history cannot carry", count: queryCtx.childSideCalls.size, names });
649
+ }
650
+ } else {
651
+ // Consumed: the pending request carries the replacement from here, and
652
+ // persistSession reads it there.
653
+ queryCtx.piHistoryReplaced = false;
654
+ queryCtx.restartRequest = { model, context, options, stream };
655
+ // Nothing the dying query still emits belongs to the new history.
656
+ queryCtx.currentPiStream = null;
657
+ debug(`provider: pi replaced this query's history; restarting from ${context.messages.length} message(s)`);
658
+ abortSdkQuery(queryCtx.activeQuery);
659
+ return stream;
660
+ }
661
+ }
565
662
  queryCtx.currentPiStream = stream;
566
663
  queryCtx.resetTurnState(model);
567
664
  // A fresh callback separates handlers registered for settled turns from
@@ -715,6 +812,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
715
812
  }
716
813
 
717
814
  // --- Fresh query ---
815
+ const recordBillingIdentity = beginBillingIdentityAttempt();
718
816
 
719
817
  // Fail-fast credential re-check (only for a fresh query — NEVER for
720
818
  // tool-result delivery of an in-flight query, handled above, where creds were
@@ -756,6 +854,9 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
756
854
  ctx().pendingToolCalls.clear();
757
855
  ctx().pendingResults.clear();
758
856
  ctx().reapedResults.clear();
857
+ // Teardown flushed the previous query's calls; carrying them into this one
858
+ // would make it look like it ran child-side calls it never ran.
859
+ ctx().childSideCalls.clear();
759
860
  ctx().forwardedToolCallIds.clear();
760
861
  ctx().deadToolCallIds.clear();
761
862
  ctx().callbackGeneration = 0;
@@ -764,6 +865,10 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
764
865
  ctx().resetToolTracking();
765
866
  ctx().latestCursor = 0;
766
867
  ctx().committedOutput = false;
868
+ // This query is built from the context Pi holds now; only a replacement that
869
+ // arrives while it runs makes it stale.
870
+ ctx().piHistoryReplaced = false;
871
+ ctx().reportedHistoryRestartDecline = false;
767
872
  // A reentrant query never claims the shared record; a foreign-conversation
768
873
  // one-shot joins it below once syncSharedSession has ruled.
769
874
  ctx().detachedFromSharedSession = isReentrant;
@@ -952,6 +1057,9 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
952
1057
 
953
1058
  // 3. Start SDK query and claim it for this context
954
1059
  let wasAborted = false;
1060
+ // The stream the post-teardown re-entry feeds: an account retry continues this
1061
+ // call's own stream, a history restart the callback stream that requested it.
1062
+ let reentryStream = stream;
955
1063
  let streamIdleTimedOut = false;
956
1064
  let retryRequested = false;
957
1065
  let retryFailure: ClaudeAttemptFailure | undefined;
@@ -969,7 +1077,21 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
969
1077
  // one-shot has exactly the same non-claim on the record.
970
1078
  const persistSession = (next: SessionState | null): void => {
971
1079
  if (isReentrant || foreignContext) return;
972
- setSharedSession(next && conversationFp ? { conversationFingerprint: conversationFp, ...next } : next);
1080
+ // A history replacement pi made while this query ran describes PI's
1081
+ // messages, not this query, so every record the query writes — this
1082
+ // turn's, a terminal failure's, a continuation's — has to keep it.
1083
+ // Dropping it lets the next sync resume history pi has thrown away, or
1084
+ // read the post-compaction context as a foreign conversation and run it
1085
+ // as a historyless one-shot. forceRotate rides along only while a
1086
+ // restart is pending, which is the case where a child was killed and may
1087
+ // still be flushing to the session jsonl.
1088
+ // Observed and not yet acted on, or already consumed into a pending
1089
+ // restart: both mean pi replaced the history after this query started.
1090
+ const restartPending = abortCtx.restartRequest !== null;
1091
+ const replaced = next && (abortCtx.piHistoryReplaced || restartPending)
1092
+ ? { ...next, needsRebuild: true, ...(restartPending ? { forceRotate: true } : {}) }
1093
+ : next;
1094
+ setSharedSession(replaced && conversationFp ? { conversationFingerprint: conversationFp, ...replaced } : replaced);
973
1095
  };
974
1096
  const markRebuildForThisQuery = (opts: { forceRotate?: boolean } = {}): void => {
975
1097
  if (isReentrant || foreignContext) return;
@@ -999,13 +1121,6 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
999
1121
  accountFailureRecorded = true;
1000
1122
  };
1001
1123
 
1002
- const requestAbort = () => {
1003
- // interrupt() asks the CLI to stop gracefully; close() kills it immediately.
1004
- // Both are needed — interrupt alone lets the current API call finish.
1005
- void sdkQuery.interrupt().catch(() => {});
1006
- try { sdkQuery.close(); } catch {}
1007
- };
1008
-
1009
1124
  // Decide whether a classified failure may be replayed on the next profile.
1010
1125
  // Records the failure with the router either way (a post-output failure is
1011
1126
  // not replayable but the next prompt's routing should still avoid the
@@ -1061,7 +1176,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1061
1176
  // specifics (needsRebuild/forceRotate, killing the child) stay here;
1062
1177
  // eligibility and retry bookkeeping are requestRotation's.
1063
1178
  if (requestRotation(idleFailure)) {
1064
- requestAbort();
1179
+ abortSdkQuery(sdkQuery);
1065
1180
  return;
1066
1181
  }
1067
1182
  abortCtx.handledTerminalError = true;
@@ -1089,7 +1204,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1089
1204
  abortCtx.currentPiStream?.push({ type: "error", reason: "error", error: abortCtx.turnOutput! });
1090
1205
  abortCtx.currentPiStream?.end();
1091
1206
  abortCtx.currentPiStream = null;
1092
- requestAbort();
1207
+ abortSdkQuery(sdkQuery);
1093
1208
  },
1094
1209
  timeoutMs: streamIdleTimeoutMs,
1095
1210
  })
@@ -1112,7 +1227,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1112
1227
  const drained = drainPendingToolCalls(abortCtx, "abort");
1113
1228
  if (drained > 0) debug(`provider: abort drained ${drained} waiting MCP handler(s) as errors`);
1114
1229
  abortCtx.pendingResults.clear();
1115
- requestAbort();
1230
+ abortSdkQuery(sdkQuery);
1116
1231
  });
1117
1232
  if (options?.signal) {
1118
1233
  if (options.signal.aborted) onAbort();
@@ -1153,9 +1268,16 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1153
1268
  // query CAN end in that window (abort, child process death throwing out of
1154
1269
  // the generator). Live-ctx handlers there mutated the subagent's turn state
1155
1270
  // and stream and skipped the parent's own teardown entirely.
1156
- consumeQuery(sdkQuery, abortCtx, customToolNameToPi, queryModel, bridgeConfig, () => wasAborted, account, router, attemptFailure)
1271
+ consumeQuery(sdkQuery, abortCtx, customToolNameToPi, queryModel, bridgeConfig, () => wasAborted, recordBillingIdentity, account, router, attemptFailure)
1157
1272
  .then(async ({ capturedSessionId, failure }) => {
1158
1273
  debug(`provider: consumeQuery completed, stopReason=${abortCtx.turnOutput?.stopReason}, failure=${failure?.kind ?? "none"}, aborted=${wasAborted}`);
1274
+ if (abortCtx.restartRequest) {
1275
+ // The replacement query owns this turn's outcome. This one must not
1276
+ // surface its interruption, end a stream, or write the record: its
1277
+ // session id and cursor describe history Pi has replaced.
1278
+ debug("provider: query ended for a history restart; leaving the outcome to the replacement query");
1279
+ return;
1280
+ }
1159
1281
  if (streamIdleTimedOut) {
1160
1282
  dropDeferredUserMessages("stream-idle-timeout-completion");
1161
1283
  debug(`provider: stream idle timeout ${retryRequested ? "queued account rotation" : "already surfaced"}; skipping normal completion`);
@@ -1205,6 +1327,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1205
1327
  debug(`provider: query done, session=${sessionId.slice(0, 8)}, cursor=${cursor}, account=${account?.label ?? "legacy"}`);
1206
1328
  // Fresh record on purpose: a transient mid-turn needsRebuild/forceRotate
1207
1329
  // must not survive a completed query and force a rebuild next turn.
1330
+ // persistSession re-adds what a pi history replacement requires.
1208
1331
  persistSession({ sessionId, cursor, cwd, ...accountScope });
1209
1332
  }
1210
1333
  // The failure branch above returned, so reaching here means success.
@@ -1241,7 +1364,11 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1241
1364
  debug(`provider: continuation query, model=${queryModel.id}, resume=${resumeId.slice(0, 8)}, account=${account?.label ?? "legacy"}, prompt=${steerPreview}`);
1242
1365
 
1243
1366
  try {
1244
- const continuation = await consumeQuery(contQuery, abortCtx, customToolNameToPi, queryModel, bridgeConfig, () => wasAborted, account, router);
1367
+ const continuation = await consumeQuery(contQuery, abortCtx, customToolNameToPi, queryModel, bridgeConfig, () => wasAborted, recordBillingIdentity, account, router);
1368
+ // Superseded: the restart killed this attempt, so its end is not
1369
+ // an outcome to record. The replacement carries the remaining
1370
+ // steers, which pi's history holds and the rebuild imports.
1371
+ if (abortCtx.restartRequest) break;
1245
1372
  if (continuation.failure) {
1246
1373
  // Continuations never rotate: the original prompt already
1247
1374
  // committed on this account.
@@ -1261,6 +1388,10 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1261
1388
  persistSession({ sessionId: sid, cursor: activeSession?.cursor ?? 0, cwd, ...accountScope });
1262
1389
  }
1263
1390
  } catch (contError) {
1391
+ // Killing the child can throw out of its iterator; that is this
1392
+ // restart's own doing, not an attempt failure to charge to the
1393
+ // account or a reason to drop input the rebuild carries.
1394
+ if (abortCtx.restartRequest) break;
1264
1395
  debug(`provider: continuation query error:`, contError);
1265
1396
  const continuationFailure: ClaudeAttemptFailure = {
1266
1397
  kind: classifyClaudeFailure(contError),
@@ -1286,6 +1417,11 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1286
1417
  })
1287
1418
  .catch((error) => {
1288
1419
  debug(`provider: query error, model=${queryModel.id}, aborted=${Boolean(options?.signal?.aborted)}, error=`, error);
1420
+ if (abortCtx.restartRequest) {
1421
+ // Killed for the restart: the replacement query surfaces the outcome.
1422
+ debug("provider: query error while a history restart was pending; suppressed");
1423
+ return;
1424
+ }
1289
1425
  const suppressDuplicateError = abortCtx.handledTerminalError || (streamIdleTimedOut && !retryRequested);
1290
1426
  if (wasAborted || options?.signal?.aborted) {
1291
1427
  markRebuildForThisQuery({ forceRotate: true });
@@ -1321,9 +1457,38 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1321
1457
  if (options?.signal) options.signal.removeEventListener("abort", onAbort);
1322
1458
  const cause = toolCallDrainCause({ wasAborted, signalAborted: options?.signal?.aborted, streamIdleTimedOut });
1323
1459
  teardownQuery(abortCtx, sdkQuery, cause, cwd, isReentrant);
1324
- sdkQuery.close();
1460
+ closeSdkQuery(sdkQuery);
1325
1461
  })
1326
1462
  .then(async () => {
1463
+ // --- History restart re-entry ---
1464
+ // Runs AFTER teardown, like the account retry below: the replacement is
1465
+ // a fresh outermost query over Pi's new context, and its events belong to
1466
+ // the callback that observed the replacement, not to this one.
1467
+ const restart = abortCtx.restartRequest;
1468
+ if (restart) {
1469
+ abortCtx.restartRequest = null;
1470
+ reentryStream = restart.stream;
1471
+ if (wasAborted || options?.signal?.aborted || restart.options?.signal?.aborted) {
1472
+ // The request died between the restart and this teardown: rebuilding
1473
+ // the session and spawning a child the fresh-query path kills on the
1474
+ // same signal buys nothing. Terminate the callback's stream instead.
1475
+ debug("provider: abort before the history restart — terminating the stream without restarting");
1476
+ // A fresh output for THIS message: the restart branch returned before
1477
+ // resetTurnState, so turnOutput is still the message pi already holds
1478
+ // for the pre-compaction turn. Stamping "aborted" onto it would
1479
+ // rewrite a delivered turn whose tools really ran.
1480
+ abortCtx.resetTurnState(restart.model);
1481
+ abortCtx.turnOutput!.stopReason = "aborted";
1482
+ abortCtx.turnOutput!.errorMessage = "Operation aborted";
1483
+ reentryStream.push({ type: "error", reason: "aborted", error: abortCtx.turnOutput! });
1484
+ reentryStream.end();
1485
+ return;
1486
+ }
1487
+ debug(`provider: restarting query on replaced history, ${restart.context.messages.length} pi message(s)`);
1488
+ for await (const event of streamClaudeAgentSdk(restart.model, restartContext(restart), restart.options)) reentryStream.push(event);
1489
+ reentryStream.end();
1490
+ return;
1491
+ }
1327
1492
  // --- Account retry re-entry ---
1328
1493
  // Runs AFTER teardown so the failed attempt's query state is fully
1329
1494
  // released. The recursive call re-enters the fresh-query path with the
@@ -1341,8 +1506,8 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1341
1506
  abortCtx.turnOutput.stopReason = "aborted";
1342
1507
  abortCtx.turnOutput.errorMessage = "Operation aborted";
1343
1508
  }
1344
- stream.push({ type: "error", reason: "aborted", error: abortCtx.turnOutput! });
1345
- stream.end();
1509
+ reentryStream.push({ type: "error", reason: "aborted", error: abortCtx.turnOutput! });
1510
+ reentryStream.end();
1346
1511
  return;
1347
1512
  }
1348
1513
  debug(`provider: starting account retry after ${retryFailure?.kind ?? "failure"}; excluded=${[...rotationState.excludedProfileIds].join(",")}`);
@@ -1355,17 +1520,30 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1355
1520
  // event — and EventStream.push is a silent no-op after end, so a failed
1356
1521
  // rotation ended the turn with no error event at all. Success ends
1357
1522
  // here; every throw ends in the .catch, after the error is pushed.
1358
- for await (const event of retryStream) stream.push(event);
1359
- stream.end();
1523
+ for await (const event of retryStream) reentryStream.push(event);
1524
+ reentryStream.end();
1360
1525
  })
1361
1526
  .catch((error) => {
1362
- debug("provider: account retry pipeline failed:", error);
1527
+ debug("provider: re-entry pipeline failed:", error);
1528
+ // A throw BEFORE the re-entry ran — teardown, or closing the stale query
1529
+ // — skips it, leaving reentryStream on this call's own stream, which has
1530
+ // already ended, while pi still waits on the callback stream the restart
1531
+ // was meant to feed. Take that request over so the failure reaches the
1532
+ // stream pi is holding, and clear it so nothing stale outlives this chain.
1533
+ const restart = abortCtx.restartRequest;
1534
+ if (restart) {
1535
+ abortCtx.restartRequest = null;
1536
+ reentryStream = restart.stream;
1537
+ // Same reason as the abort branch above: without a restart this
1538
+ // message would be the delivered pre-compaction turn.
1539
+ abortCtx.resetTurnState(restart.model);
1540
+ }
1363
1541
  if (abortCtx.turnOutput) {
1364
1542
  abortCtx.turnOutput.stopReason = "error";
1365
1543
  abortCtx.turnOutput.errorMessage = error instanceof Error ? error.message : String(error);
1366
1544
  }
1367
- stream.push({ type: "error", reason: "error", error: abortCtx.turnOutput! });
1368
- stream.end();
1545
+ reentryStream.push({ type: "error", reason: "error", error: abortCtx.turnOutput! });
1546
+ reentryStream.end();
1369
1547
  })
1370
1548
  // After teardown and any retry pipeline: nothing else reads this lane.
1371
1549
  .finally(releaseEphemeralLane);
@@ -1397,6 +1575,10 @@ export default function (pi: ExtensionAPI) {
1397
1575
  if (claimPrimaryInstance()) {
1398
1576
  const host = globalThis as Record<symbol, any>;
1399
1577
  host[CLAUDE_BRIDGE_ACCOUNT_HOST_SYMBOL] = BRIDGE_ACCOUNT_HOST;
1578
+ // Published beside it so a reader finds a store that answers "no login
1579
+ // confirmed yet" rather than nothing at all, and so both are owned by
1580
+ // the same instance.
1581
+ host[CLAUDE_BILLING_IDENTITY_SYMBOL] = BRIDGE_BILLING_IDENTITY;
1400
1582
  }
1401
1583
 
1402
1584
  // Reset shared (Claude) conversation state on pi session lifecycle events.
@@ -1434,31 +1616,17 @@ export default function (pi: ExtensionAPI) {
1434
1616
  });
1435
1617
  deleteSharedSessionLane(sessionId);
1436
1618
  deleteQueryLane(sessionId);
1619
+ deleteBillingIdentityLane(sessionId);
1437
1620
  });
1438
1621
  pi.on("message_end", (event, ctx) => runInRequestLane(ctx.sessionManager.getSessionId(), () => {
1439
1622
  const message = (event as { message?: AssistantMessage }).message;
1440
1623
  if (message?.role === "assistant" && message.provider === PROVIDER_ID) schedulePersistSharedSession(ctx);
1441
1624
  }));
1442
1625
 
1443
- // pi /compact and session-tree navigation (rewind / fork-at-point /
1444
- // branch switch) both mutate pi's messages array out from under the
1445
- // bridge. syncSharedSession's REUSE check would otherwise see
1446
- // slice(cursor) === [] (or skip entries) and keep --resume'ing a CC
1447
- // session that does not match pi's history. /compact in particular
1448
- // triggers CC's autocompact-thrashing guard. Force the next
1449
- // call down the REBUILD path so CC sees the current history.
1450
- const markRebuild = (event: string) => {
1451
- const activeSession = getSharedSession();
1452
- if (ctx().activeQuery) {
1453
- reportToolResultMismatch(ctx(), event, activeSession?.cwd ?? process.cwd());
1454
- }
1455
- if (activeSession) {
1456
- debug(`${event}: marking needsRebuild on session ${activeSession.sessionId.slice(0, 8)}`);
1457
- markSessionForRebuild();
1458
- }
1459
- };
1460
- pi.on("session_compact", (_event, ctx) => runInRequestLane(ctx.sessionManager.getSessionId(), () => markRebuild("session_compact")));
1461
- pi.on("session_tree", (_event, ctx) => runInRequestLane(ctx.sessionManager.getSessionId(), () => markRebuild("session_tree")));
1626
+ // Both events replace pi's messages array out from under the bridge; see
1627
+ // onPiHistoryReplaced for what that forces.
1628
+ pi.on("session_compact", (_event, ctx) => runInRequestLane(ctx.sessionManager.getSessionId(), () => onPiHistoryReplaced("session_compact")));
1629
+ pi.on("session_tree", (_event, ctx) => runInRequestLane(ctx.sessionManager.getSessionId(), () => onPiHistoryReplaced("session_tree")));
1462
1630
 
1463
1631
  // --- Provider ---
1464
1632
  //
@@ -60,7 +60,9 @@ export function buildNativeProvider(
60
60
  // passes a probe that also accepts a companion account-router pool.
61
61
  hasCredentials: () => boolean = () => hasClaudeCredentials(env),
62
62
  ): unknown {
63
- if (!supportsNativeProvider(piAi)) throw new Error(NATIVE_PROVIDER_UNSUPPORTED_MESSAGE);
63
+ if (!supportsNativeProvider(piAi)) {
64
+ throw Object.assign(new Error(NATIVE_PROVIDER_UNSUPPORTED_MESSAGE), { code: "CLAUDE_BRIDGE_NATIVE_PROVIDER_UNSUPPORTED" });
65
+ }
64
66
  // The legacy config path stamped provider/api/baseUrl onto each model during
65
67
  // composition; createProvider passes models through verbatim, so stamp here.
66
68
  // Stamps win over any provider field the source model carries — the models
@@ -6,7 +6,7 @@
6
6
  // Separate from index.ts so tests can import it without activating the extension.
7
7
 
8
8
  import type { ContentBlockParam } from "@anthropic-ai/sdk/resources";
9
- import type { AssistantMessage, AssistantMessageEventStream, Model } from "@earendil-works/pi-ai";
9
+ import type { AssistantMessage, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
10
10
  import { isConnectorTool } from "./connectors.js";
11
11
  import type { McpResult } from "./extract-tool-results.js";
12
12
  import { currentRequestLaneId } from "./request-lane.js";
@@ -33,6 +33,16 @@ export function summarizeDroppedUserMessages(site: string, dropped: DeferredUser
33
33
  };
34
34
  }
35
35
 
36
+ /** A provider call held to replace a query whose Pi history was replaced while
37
+ * it ran: the callback's own model, context, options and stream, so the
38
+ * replacement runs under the current request instead of the dead query's. */
39
+ export interface QueryRestartRequest {
40
+ model: Model<any>;
41
+ context: Context;
42
+ options: SimpleStreamOptions | undefined;
43
+ stream: AssistantMessageEventStream;
44
+ }
45
+
36
46
  export interface PendingToolCall {
37
47
  toolName: string;
38
48
  /** The MCP invocation's schema-validated arguments. The SDK hands the handler
@@ -64,7 +74,7 @@ const DRAIN_CAUSE_TEXT: Record<ToolCallDrainCause, string> = {
64
74
 
65
75
  export function interruptedToolCallResult(cause: ToolCallDrainCause): McpResult {
66
76
  return {
67
- content: [{ type: "text", text: `Claude bridge: ${DRAIN_CAUSE_TEXT[cause]} before this tool call's result was delivered. The call did not complete and produced no output.` }],
77
+ content: [{ type: "text", text: `tool-call-drain=${cause}\nClaude bridge: ${DRAIN_CAUSE_TEXT[cause]} before this tool call's result was delivered. The call did not complete and produced no output.` }],
68
78
  isError: true,
69
79
  };
70
80
  }
@@ -95,7 +105,7 @@ export function drainPendingToolCalls(queryCtx: QueryContext, cause: ToolCallDra
95
105
  * call is guaranteed not to have executed on the Pi side. */
96
106
  export function strandedToolCallResult(): McpResult {
97
107
  return {
98
- content: [{ type: "text", text: "Claude bridge: this tool call was never forwarded to Pi before its turn ended, so it did not execute and no result can arrive. Re-run the tool." }],
108
+ content: [{ type: "text", text: "tool-call-stranded=unforwarded\nClaude bridge: this tool call was never forwarded to Pi before its turn ended, so it did not execute and no result can arrive. Re-run the tool." }],
99
109
  isError: true,
100
110
  };
101
111
  }
@@ -161,8 +171,14 @@ export function takeQueuedOrParkedResult(queryCtx: QueryContext, id: string): Mc
161
171
  /** One connector call's audit state for the life of a query. `recorded` means an
162
172
  * entry for it has already been appended (or attempted), so neither a re-yielded
163
173
  * result nor the teardown flush can record it twice. */
164
- export interface ConnectorCallAuditState {
174
+ /** Why pi's messages can never carry this call: a claude.ai connector the child
175
+ * ran itself, or a foreign MCP tool it loaded from filesystem settings. Only a
176
+ * connector backs the connector audit trail. */
177
+ export type ChildSideCallKind = "connector" | "foreign-mcp";
178
+
179
+ export interface ChildSideCallState {
165
180
  name: string;
181
+ kind: ChildSideCallKind;
166
182
  /** The child session that issued it, captured when the call was seen — a
167
183
  * continuation query gets its own, and a call is audited against the session
168
184
  * that actually made it. */
@@ -247,6 +263,22 @@ export class QueryContext {
247
263
  // Query-scoped (fully isolated per query)
248
264
  activeQuery: unknown | null = null;
249
265
  currentPiStream: AssistantMessageEventStream | null = null;
266
+ /** Pi replaced the history this query's Claude session was built from
267
+ * (compaction, history navigation) while the query was still running.
268
+ * Delivering further tool results into it would keep Claude Code on history
269
+ * Pi no longer holds, so the next provider callback restarts the query from
270
+ * Pi's new context. A query that ENDS while this is set persists its record
271
+ * with needsRebuild, so the next turn rebuilds either way. */
272
+ piHistoryReplaced = false;
273
+ /** The handover this replacement asked for was refused, and the refusal is
274
+ * already reported. Every later callback of the query re-reads
275
+ * `piHistoryReplaced`, which stays set, so without this the same refusal
276
+ * would be recorded once per remaining tool result. */
277
+ reportedHistoryRestartDecline = false;
278
+ /** The provider callback that observed `piHistoryReplaced`. The dying query's
279
+ * own promise chain runs it, after teardown released the query state, and
280
+ * feeds the replacement query's events into that callback's stream. */
281
+ restartRequest: QueryRestartRequest | null = null;
250
282
  latestCursor = 0;
251
283
  pendingToolCalls = new Map<string, PendingToolCall>();
252
284
  pendingResults = new Map<string, McpResult>();
@@ -332,14 +364,20 @@ export class QueryContext {
332
364
  /** tool_use id → raw SDK tool name. */
333
365
  childExecutedToolCalls = new Map<string, string>();
334
366
  /**
335
- * The same calls, for the connector-call audit trail (see connector-audit.ts).
367
+ * Every call the CHILD executed that pi's messages cannot carry — a claude.ai
368
+ * connector, or a foreign MCP tool the child loaded itself — keyed by tool_use
369
+ * id. Nothing can rebuild these from pi's context, so a history handover is
370
+ * refused while the map is non-empty. Connector entries also back the
371
+ * connector-call audit trail (see connector-audit.ts).
336
372
  *
337
373
  * Query-scoped and deliberately NOT cleared by resetToolTracking: that runs at
338
374
  * every child message boundary, and a call issued in one child message is only
339
375
  * reconciled after that message ends. Clearing it there would make an abandoned
340
376
  * call unrecordable at teardown — which is the one case the trail exists for.
377
+ * Fresh-query setup clears it instead, once teardown has flushed it: a reused
378
+ * top-level context would otherwise answer for calls an earlier query made.
341
379
  */
342
- connectorCallAudit = new Map<string, ConnectorCallAuditState>();
380
+ childSideCalls = new Map<string, ChildSideCallState>();
343
381
  /** Claude Code session id for this query, from the SDK's `system` init message.
344
382
  * Undefined until it arrives; the audit trail omits the field rather than
345
383
  * guessing. */
@@ -400,7 +438,7 @@ export class QueryContext {
400
438
  turnSawToolCall = false;
401
439
 
402
440
  get turnBlocks(): Array<any> {
403
- if (!this.turnOutput) throw new Error("turnBlocks accessed before resetTurnState");
441
+ if (!this.turnOutput) throw new Error("turn-state-uninitialized=turnBlocks\nturnBlocks accessed before resetTurnState");
404
442
  return this.turnOutput.content;
405
443
  }
406
444
 
@@ -466,9 +504,10 @@ export class QueryContext {
466
504
  // Both emission paths can see the same call (streamed block, then the
467
505
  // SDK's completed copy), so never overwrite an existing audit state —
468
506
  // that would resurrect one already recorded.
469
- if (!this.connectorCallAudit.has(id)) {
470
- this.connectorCallAudit.set(id, {
507
+ if (!this.childSideCalls.has(id)) {
508
+ this.childSideCalls.set(id, {
471
509
  name: rawName,
510
+ kind: "connector",
472
511
  ...(this.childSessionId ? { childSessionId: this.childSessionId } : {}),
473
512
  recorded: false,
474
513
  });
@@ -477,6 +516,21 @@ export class QueryContext {
477
516
  if (typeof streamIndex === "number") this.childExecutedStreamIndexes.add(streamIndex);
478
517
  }
479
518
 
519
+ /** A foreign MCP tool the child loaded from filesystem settings and ran
520
+ * itself. Pi never sees the call or its result, so it commits the turn the
521
+ * same way a connector does and joins the same map: a rebuild from pi's
522
+ * context would erase an account-visible operation the model could repeat. */
523
+ noteForeignMcpToolCall(id: string | undefined, rawName: string): void {
524
+ this.markOutputCommitted();
525
+ if (!id || this.childSideCalls.has(id)) return;
526
+ this.childSideCalls.set(id, {
527
+ name: rawName,
528
+ kind: "foreign-mcp",
529
+ ...(this.childSessionId ? { childSessionId: this.childSessionId } : {}),
530
+ recorded: false,
531
+ });
532
+ }
533
+
480
534
  recordToolCall(id: string | undefined, toolName: string, args: Record<string, unknown> = {}): void {
481
535
  if (!id) return;
482
536
  this.queryToolNames.set(id, toolName);
@@ -702,14 +756,14 @@ export function stackDepth(): number { return lane().stack.length; }
702
756
 
703
757
  export function pushContext(): void {
704
758
  const state = lane();
705
- if (!state.current.activeQuery) throw new Error("pushContext() called with no active query");
759
+ if (!state.current.activeQuery) throw new Error("query-stack-push=inactive\npushContext() called with no active query");
706
760
  state.stack.push(state.current);
707
761
  state.current = new QueryContext();
708
762
  }
709
763
 
710
764
  export function popContext(): void {
711
765
  const state = lane();
712
- if (state.stack.length === 0) throw new Error("popContext() called with empty stack");
766
+ if (state.stack.length === 0) throw new Error("query-stack-pop=empty\npopContext() called with empty stack");
713
767
  const parent = state.stack[state.stack.length - 1];
714
768
  parent.deferredUserMessages.push(...state.current.deferredUserMessages);
715
769
  state.current = state.stack.pop()!;
@@ -10,6 +10,22 @@ import { flushConnectorCallAudit } from "./connector-audit.js";
10
10
  import { debug } from "./debug.js";
11
11
  import { drainPendingToolCalls, popContextFor, type QueryContext, type ToolCallDrainCause } from "./query-state.js";
12
12
 
13
+ /** Close a settled or dying SDK query. Its transport can throw on the way down
14
+ * — a child already gone, a socket already closed — and that throw belongs to
15
+ * the query being closed, never to whatever its caller does next. */
16
+ export function closeSdkQuery(sdkQuery: unknown): void {
17
+ try { (sdkQuery as { close(): void }).close(); }
18
+ catch (error) { debug("provider: closing the sdk query threw; continuing teardown:", error); }
19
+ }
20
+
21
+ /** Stop an in-flight SDK query. `interrupt()` asks the CLI to stop gracefully,
22
+ * `close()` kills it; both are needed, because interrupt alone lets the current
23
+ * API call finish. */
24
+ export function abortSdkQuery(sdkQuery: unknown): void {
25
+ void (sdkQuery as { interrupt(): Promise<void> }).interrupt().catch(() => {});
26
+ closeSdkQuery(sdkQuery);
27
+ }
28
+
13
29
  /** Tear down `queryCtx` after its SDK query settled. No-ops when the query is
14
30
  * is not the context's active one (a continuation replaced it, or teardown
15
31
  * already ran). Returns true when teardown actually ran. */