@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/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";
@@ -79,7 +80,7 @@ export { cancelScheduledSessionPersistence, conversationFingerprint, conversatio
79
80
  export { NATIVE_PROVIDER_UNSUPPORTED_MESSAGE, buildNativeProvider, claudeAuthSourceLabel, supportsNativeProvider } from "./native-provider.js";
80
81
  export { DEFAULT_STREAM_IDLE_TIMEOUT_MS, STREAM_IDLE_BACKOFF_HINT_MS, STREAM_IDLE_TIMEOUT_ENV, buildStreamIdleTimeoutErrorMessage, createStreamIdleWatchdog, streamIdleTimeoutMsFromEnv, type StreamIdleTimeoutInfo, type StreamIdleWatchdog, type StreamIdleWatchdogState } from "./stream-idle-watchdog.js";
81
82
  export { ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD, formatAllowedRateLimitWarning, formatResetTimestamp, isUsageLimitMessage, normalizeRateLimitUtilization, resetTimestampMs, uniqueNonEmptyLines } from "./rate-limit.js";
82
- export { mapToolName } from "./tool-mapping.js";
83
+ export { isPiDispatchable, mapToolName } from "./tool-mapping.js";
83
84
  export { cancelScheduledToolUseEnd, endToolUseTurn, finalizeToolUseTurnFromMcpInvocation, noteChildExecutedToolResults, processAssistantMessage, processStreamEvent, reapStaleQueuedResults, scheduleToolUseTurnEnd } from "./assistant-stream.js";
84
85
  export {
85
86
  accountSessionScope,
@@ -239,18 +240,17 @@ export interface DeferredUserReplayPlan {
239
240
  // with no image blocks).
240
241
  prompt: string | null;
241
242
  // Present when the run carries image blocks — the replay must send these
242
- // (via wrapPromptStream) or the images are silently lost (kendex#993).
243
+ // (via wrapPromptStream) or the images are silently lost.
243
244
  blocks: ContentBlockParam[] | null;
244
245
  }
245
246
 
246
247
  /** Plan replay of user messages pi injected mid-query (steer drain, followUp).
247
248
  * Captures the ENTIRE trailing consecutive user run, not just the last
248
- * message — dropping the earlier ones was silent input loss (kendex#967) —
249
- * but never walks below `capturedThrough`, the position an earlier callback
249
+ * message, but never walks below `capturedThrough`, the position a prior callback
250
250
  * of the SAME query already captured (or deliberately held at, for an
251
251
  * all-empty run). Without that lower bound a second mid-query steer re-planned
252
252
  * the whole run from scratch and the first steer was queued — and delivered to
253
- * Claude — twice (kendex#1009). */
253
+ * Claude — twice. */
254
254
  export function planDeferredUserReplay(messages: Context["messages"], capturedThrough = 0): DeferredUserReplayPlan {
255
255
  let runStart = messages.length;
256
256
  while (runStart > capturedThrough && messages[runStart - 1]?.role === "user") runStart--;
@@ -299,7 +299,7 @@ export function resolveMcpTools(context: Context, excludeToolName?: string): {
299
299
  // namespace belongs to the child's own MCP servers, so a Pi tool sitting
300
300
  // on it would be advertised a SECOND time under our prefix — two names
301
301
  // for one capability, and the model picking the wrong one gets a real
302
- // `Tool ... not found` from the dispatcher (memsira#320). It would also
302
+ // `Tool... not found` from the dispatcher. It would also
303
303
  // be uncallable in any case: a `tool_use` under that namespace is treated
304
304
  // as child-executed and never handed to Pi (isChildExecutedTool), so
305
305
  // filtering here is what makes the two halves agree end to end.
@@ -418,8 +418,8 @@ function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string,
418
418
  // 2. consumeQuery() iterates the SDK generator, pushing events to currentPiStream
419
419
  // 3. On tool_use: ends the current pi stream, nulls it out. The MCP handler
420
420
  // blocks the generator naturally — no events arrive until resolved.
421
- // 4. Pi executes the tool, calls streamSimple again. We swap in the new stream,
422
- // resolve the MCP handler, and the generator unblocks — events flow to new stream.
421
+ // 4. Pi executes the tool and calls streamSimple again. We swap in the replacement
422
+ // stream, resolve the MCP handler, and the generator unblocks into that stream.
423
423
  //
424
424
  // Note: resetTurnState clears turnSawStreamEvent while the generator may still
425
425
  // have queued messages from the previous turn. This is safe because step 3 nulls
@@ -448,6 +448,10 @@ function releaseProviderTokens(event: string): void {
448
448
  if (g[CLAUDE_BRIDGE_ACCOUNT_HOST_SYMBOL] === BRIDGE_ACCOUNT_HOST) {
449
449
  g[CLAUDE_BRIDGE_ACCOUNT_HOST_SYMBOL] = undefined;
450
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.
451
455
  if (g[ACTIVE_STREAM_SIMPLE_KEY] === streamClaudeAgentSdk) {
452
456
  debug(`${event}: clearing ACTIVE_STREAM_SIMPLE_KEY`);
453
457
  g[ACTIVE_STREAM_SIMPLE_KEY] = undefined;
@@ -502,7 +506,7 @@ function applyProviderRegistration(trigger: string): void {
502
506
  debug(`${trigger}: native registration upsert, credentialed=${credentialed} (module=${moduleInstanceId})`);
503
507
  // Start the connector inventory now, not on the first turn: the query path
504
508
  // can only read a synchronous snapshot, so priming here is what gets the
505
- // declarations in place before turn 1 (kendex#832). Fire and forget —
509
+ // declarations in place before turn 1. Fire and forget —
506
510
  // registration must not wait on the network. Primes the DEFAULT credential
507
511
  // scope only; managed profiles are primed per request in their own scope.
508
512
  if (hasClaudeCredentials() && connectorsEnabledFor(loadConfig(process.cwd()))) primeConnectorServers();
@@ -529,7 +533,60 @@ function applyProviderRegistration(trigger: string): void {
529
533
  }
530
534
  }
531
535
 
532
- /** Provider entry point. Pi calls this for each new prompt and each tool result.
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
+
589
+ /** Provider entry point. Pi calls this for each prompt and each tool result.
533
590
  * Two cases: tool result delivery (active query) or fresh query. Exported for
534
591
  * the rotation-stream unit tests, which drive it with a fake SDK factory. */
535
592
  export function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
@@ -550,6 +607,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
550
607
  if (!ephemeralLane) return;
551
608
  deleteSharedSessionLane(laneId);
552
609
  deleteQueryLane(laneId);
610
+ deleteBillingIdentityLane(laneId);
553
611
  };
554
612
 
555
613
  // DEBUG: trace followUp message triggering
@@ -563,6 +621,44 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
563
621
  // handlers. Results that arrive before their handler get queued in pendingResults.
564
622
  if (ctx().activeQuery) {
565
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
+ }
566
662
  queryCtx.currentPiStream = stream;
567
663
  queryCtx.resetTurnState(model);
568
664
  // A fresh callback separates handlers registered for settled turns from
@@ -619,7 +715,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
619
715
  if (queryCtx.pendingToolCalls.size > 0) {
620
716
  // A waiting handler whose call never reached Pi can never be answered —
621
717
  // fail it now with a retryable error instead of letting the SDK await it
622
- // forever (the 2026-08-17 five-and-a-half-hour deadlock, kendex#1469).
718
+ // indefinitely.
623
719
  // Forwarded-but-unanswered handlers stay: steer-split batches legitimately
624
720
  // deliver their results in a later callback.
625
721
  const stranded = drainStrandedToolCalls(queryCtx);
@@ -646,7 +742,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
646
742
  // so we save them for replay as continuation queries after consumeQuery ends.
647
743
  // The cursor may only advance over messages actually captured for replay:
648
744
  // claiming Claude owns a user message that was never deferred is permanent
649
- // silent input loss (kendex#967 — only the LAST of several trailing user
745
+ // silent input loss ( — only the LAST of several trailing user
650
746
  // messages was captured while the cursor skipped them all).
651
747
  let capturedThrough = context.messages.length;
652
748
  if (lastMsgRole === "user") {
@@ -654,13 +750,13 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
654
750
  // Math.max-advances with every callback's capturedThrough below), so a
655
751
  // second steer callback only queues messages BEYOND what the first one
656
752
  // already owns — re-planning the whole trailing run queued the earlier
657
- // steer twice (kendex#1009). latestCursor, not the shared record's
753
+ // steer twice. latestCursor, not the shared record's
658
754
  // cursor, deliberately: it lives on this QueryContext, so it is correct
659
755
  // for reentrant and detached foreign queries too, whose contexts the
660
- // shared cursor does not index (kendex#1001).
756
+ // shared cursor does not index.
661
757
  const replay = planDeferredUserReplay(context.messages, queryCtx.latestCursor);
662
758
  // Image-only runs have no usable text but must still replay — capture
663
- // whenever EITHER form has content (kendex#993).
759
+ // whenever EITHER form has content.
664
760
  if (replay.prompt || replay.blocks) {
665
761
  ctx().deferredUserMessages.push({ text: replay.prompt ?? "", blocks: replay.blocks ?? undefined });
666
762
  debug(`provider: deferred ${replay.userMessageCount} user message(s) for replay after query${replay.blocks ? ` (${replay.blocks.length} blocks incl. images)` : ""}: ${(replay.prompt ?? "[image-only]").slice(0, 60)}`);
@@ -678,12 +774,12 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
678
774
  // Cursor may only ADVANCE, and only for a query that holds the record's
679
775
  // claim. A reentrant subagent call routed through this instance arrives
680
776
  // here with a SHORT foreign context (its own [user…] conversation, not
681
- // the one the cursor indexes) — writing its length used to shrink the
777
+ // the one the cursor indexes). Writing its length would shrink the
682
778
  // parent cursor and make the next REUSE replay already-owned history.
683
779
  // The stackDepth guard covers a pushed subagent context; the detached
684
780
  // flag covers a foreign one-shot on the top-level ctx, whose GROWN
685
781
  // mid-query context could otherwise out-length the parent's cursor and
686
- // advance it past history Claude never saw (kendex#1001); Math.max
782
+ // advance it past history Claude never saw; Math.max
687
783
  // remains the backstop for a legacy-record foreign context the
688
784
  // fingerprint guard could not classify.
689
785
  const activeSession = getSharedSession();
@@ -702,7 +798,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
702
798
  debug(`provider: orphaned tool result after abort, emitting end_turn`);
703
799
  // The detached flag deliberately survives query end: an orphaned result
704
800
  // from a foreign one-shot indexes ITS conversation, and writing that
705
- // length here would move (even shrink) the parent's cursor (kendex#1001).
801
+ // length here would move (even shrink) the parent's cursor.
706
802
  const activeSession = getSharedSession();
707
803
  if (activeSession && stackDepth() === 0 && !ctx().detachedFromSharedSession) setSharedSession({ ...activeSession, cursor: context.messages.length });
708
804
  const c = ctx(); // capture current context for the microtask
@@ -716,6 +812,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
716
812
  }
717
813
 
718
814
  // --- Fresh query ---
815
+ const recordBillingIdentity = beginBillingIdentityAttempt();
719
816
 
720
817
  // Fail-fast credential re-check (only for a fresh query — NEVER for
721
818
  // tool-result delivery of an in-flight query, handled above, where creds were
@@ -757,6 +854,9 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
757
854
  ctx().pendingToolCalls.clear();
758
855
  ctx().pendingResults.clear();
759
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();
760
860
  ctx().forwardedToolCallIds.clear();
761
861
  ctx().deadToolCallIds.clear();
762
862
  ctx().callbackGeneration = 0;
@@ -765,6 +865,10 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
765
865
  ctx().resetToolTracking();
766
866
  ctx().latestCursor = 0;
767
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;
768
872
  // A reentrant query never claims the shared record; a foreign-conversation
769
873
  // one-shot joins it below once syncSharedSession has ruled.
770
874
  ctx().detachedFromSharedSession = isReentrant;
@@ -883,7 +987,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
883
987
  const { sessionId: resumeSessionId, promptStart } = syncResult;
884
988
  // A FOREIGN-conversation query (conversation-fingerprint mismatch against
885
989
  // the shared record — a subagent-shaped request arriving while the parent
886
- // is IDLE, kendex#1001) also runs as a clean one-shot and gets the same
990
+ // is IDLE,) also runs as a clean one-shot and gets the same
887
991
  // hands-off treatment as a reentrant one below: never persist over the
888
992
  // module-level record, never mark it for rebuild. The flag also rides the
889
993
  // QueryContext so the mismatch/abort/teardown paths that mutate the record
@@ -953,6 +1057,9 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
953
1057
 
954
1058
  // 3. Start SDK query and claim it for this context
955
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;
956
1063
  let streamIdleTimedOut = false;
957
1064
  let retryRequested = false;
958
1065
  let retryFailure: ClaudeAttemptFailure | undefined;
@@ -961,22 +1068,36 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
961
1068
 
962
1069
  // 4. Capture context for abort handling (must be AFTER pushContext)
963
1070
  const abortCtx = ctx();
964
- // Failure metadata consumeQuery observed, surviving an iterator THROW (the
965
- // .catch below reuses it instead of re-classifying — see C5 note there).
1071
+ // Failure metadata from consumeQuery survives an iterator throw. The catch
1072
+ // below reuses it instead of re-classifying it; see the C5 note.
966
1073
  const attemptFailure: { failure?: ClaudeAttemptFailure } = {};
967
1074
  // A reentrant (subagent) query must never write the module-level shared
968
1075
  // session: its completion/failure handlers would overwrite the PARENT's
969
1076
  // record with the child's session id and cursor. A foreign-conversation
970
- // one-shot (kendex#1001) has exactly the same non-claim on the record.
1077
+ // one-shot has exactly the same non-claim on the record.
971
1078
  const persistSession = (next: SessionState | null): void => {
972
1079
  if (isReentrant || foreignContext) return;
973
- 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);
974
1095
  };
975
1096
  const markRebuildForThisQuery = (opts: { forceRotate?: boolean } = {}): void => {
976
1097
  if (isReentrant || foreignContext) return;
977
1098
  markSessionForRebuild(opts);
978
1099
  };
979
- // #967 invariant: a deferred (mid-query) user message may be dropped only
1100
+ // invariant: a deferred (mid-query) user message may be dropped only
980
1101
  // LOUDLY — the cursor already advanced over it on the promise of replay.
981
1102
  // Callers that keep a session record after a non-empty drop must persist it
982
1103
  // with needsRebuild so the next turn re-imports the steers from Pi history.
@@ -1000,13 +1121,6 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1000
1121
  accountFailureRecorded = true;
1001
1122
  };
1002
1123
 
1003
- const requestAbort = () => {
1004
- // interrupt() asks the CLI to stop gracefully; close() kills it immediately.
1005
- // Both are needed — interrupt alone lets the current API call finish.
1006
- void sdkQuery.interrupt().catch(() => {});
1007
- try { sdkQuery.close(); } catch {}
1008
- };
1009
-
1010
1124
  // Decide whether a classified failure may be replayed on the next profile.
1011
1125
  // Records the failure with the router either way (a post-output failure is
1012
1126
  // not replayable but the next prompt's routing should still avoid the
@@ -1062,7 +1176,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1062
1176
  // specifics (needsRebuild/forceRotate, killing the child) stay here;
1063
1177
  // eligibility and retry bookkeeping are requestRotation's.
1064
1178
  if (requestRotation(idleFailure)) {
1065
- requestAbort();
1179
+ abortSdkQuery(sdkQuery);
1066
1180
  return;
1067
1181
  }
1068
1182
  abortCtx.handledTerminalError = true;
@@ -1090,7 +1204,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1090
1204
  abortCtx.currentPiStream?.push({ type: "error", reason: "error", error: abortCtx.turnOutput! });
1091
1205
  abortCtx.currentPiStream?.end();
1092
1206
  abortCtx.currentPiStream = null;
1093
- requestAbort();
1207
+ abortSdkQuery(sdkQuery);
1094
1208
  },
1095
1209
  timeoutMs: streamIdleTimeoutMs,
1096
1210
  })
@@ -1113,7 +1227,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1113
1227
  const drained = drainPendingToolCalls(abortCtx, "abort");
1114
1228
  if (drained > 0) debug(`provider: abort drained ${drained} waiting MCP handler(s) as errors`);
1115
1229
  abortCtx.pendingResults.clear();
1116
- requestAbort();
1230
+ abortSdkQuery(sdkQuery);
1117
1231
  });
1118
1232
  if (options?.signal) {
1119
1233
  if (options.signal.aborted) onAbort();
@@ -1154,9 +1268,16 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1154
1268
  // query CAN end in that window (abort, child process death throwing out of
1155
1269
  // the generator). Live-ctx handlers there mutated the subagent's turn state
1156
1270
  // and stream and skipped the parent's own teardown entirely.
1157
- consumeQuery(sdkQuery, abortCtx, customToolNameToPi, queryModel, bridgeConfig, () => wasAborted, account, router, attemptFailure)
1271
+ consumeQuery(sdkQuery, abortCtx, customToolNameToPi, queryModel, bridgeConfig, () => wasAborted, recordBillingIdentity, account, router, attemptFailure)
1158
1272
  .then(async ({ capturedSessionId, failure }) => {
1159
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
+ }
1160
1281
  if (streamIdleTimedOut) {
1161
1282
  dropDeferredUserMessages("stream-idle-timeout-completion");
1162
1283
  debug(`provider: stream idle timeout ${retryRequested ? "queued account rotation" : "already surfaced"}; skipping normal completion`);
@@ -1185,7 +1306,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1185
1306
  // execute. Deferred user input is dropped LOUDLY, and when steers
1186
1307
  // were dropped the record is marked needsRebuild: the cursor already
1187
1308
  // advanced over them on the promise of replay, so a plain REUSE next
1188
- // turn would silently lose them forever (#967).
1309
+ // turn would silently lose them forever.
1189
1310
  if (!abortCtx.handledTerminalError) surfaceFailure(failure);
1190
1311
  const droppedSteers = dropDeferredUserMessages("terminal-failure");
1191
1312
  const activeSession = getSharedSession();
@@ -1206,6 +1327,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1206
1327
  debug(`provider: query done, session=${sessionId.slice(0, 8)}, cursor=${cursor}, account=${account?.label ?? "legacy"}`);
1207
1328
  // Fresh record on purpose: a transient mid-turn needsRebuild/forceRotate
1208
1329
  // must not survive a completed query and force a rebuild next turn.
1330
+ // persistSession re-adds what a pi history replacement requires.
1209
1331
  persistSession({ sessionId, cursor, cwd, ...accountScope });
1210
1332
  }
1211
1333
  // The failure branch above returned, so reaching here means success.
@@ -1235,14 +1357,18 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1235
1357
 
1236
1358
  const contOptions = { ...queryOptions, resume: resumeId, ...makeCliDebugOptions("continuation") };
1237
1359
  // Runs carrying image blocks replay as blocks (wrapPromptStream) so
1238
- // the images survive; text-only runs stay plain strings (kendex#993).
1360
+ // the images survive; text-only runs stay plain strings.
1239
1361
  const contQuery = sdkQueryFactory({ prompt: steer.blocks ? wrapPromptStream(steer.blocks) : steer.text, options: contOptions });
1240
1362
  abortCtx.activeQuery = contQuery;
1241
1363
 
1242
1364
  debug(`provider: continuation query, model=${queryModel.id}, resume=${resumeId.slice(0, 8)}, account=${account?.label ?? "legacy"}, prompt=${steerPreview}`);
1243
1365
 
1244
1366
  try {
1245
- 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;
1246
1372
  if (continuation.failure) {
1247
1373
  // Continuations never rotate: the original prompt already
1248
1374
  // committed on this account.
@@ -1250,7 +1376,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1250
1376
  if (!abortCtx.handledTerminalError) surfaceFailure(continuation.failure);
1251
1377
  // The shifted steer may never have reached the child, and any
1252
1378
  // remaining ones certainly did not — the record must rebuild so
1253
- // they re-import from Pi history (#967).
1379
+ // they re-import from Pi history.
1254
1380
  if (dropDeferredUserMessages("continuation-failure", steer).length > 0) {
1255
1381
  markRebuildForThisQuery();
1256
1382
  }
@@ -1262,6 +1388,10 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1262
1388
  persistSession({ sessionId: sid, cursor: activeSession?.cursor ?? 0, cwd, ...accountScope });
1263
1389
  }
1264
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;
1265
1395
  debug(`provider: continuation query error:`, contError);
1266
1396
  const continuationFailure: ClaudeAttemptFailure = {
1267
1397
  kind: classifyClaudeFailure(contError),
@@ -1269,7 +1399,7 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1269
1399
  };
1270
1400
  recordAttemptFailure(continuationFailure);
1271
1401
  if (!abortCtx.handledTerminalError) surfaceFailure(continuationFailure);
1272
- // Same #967 posture as the failure branch above.
1402
+ // Apply the same rebuild rule as the failure branch above.
1273
1403
  if (dropDeferredUserMessages("continuation-error", steer).length > 0) {
1274
1404
  markRebuildForThisQuery();
1275
1405
  }
@@ -1287,11 +1417,16 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1287
1417
  })
1288
1418
  .catch((error) => {
1289
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
+ }
1290
1425
  const suppressDuplicateError = abortCtx.handledTerminalError || (streamIdleTimedOut && !retryRequested);
1291
1426
  if (wasAborted || options?.signal?.aborted) {
1292
1427
  markRebuildForThisQuery({ forceRotate: true });
1293
1428
  }
1294
- // #967: a record kept past this error with steers behind its cursor
1429
+ // a record kept past this error with steers behind its cursor
1295
1430
  // must rebuild so they re-import from Pi history. (The non-abort
1296
1431
  // surface path below replaces the record with null, which rebuilds too.)
1297
1432
  if (dropDeferredUserMessages("query-error").length > 0) {
@@ -1322,9 +1457,38 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1322
1457
  if (options?.signal) options.signal.removeEventListener("abort", onAbort);
1323
1458
  const cause = toolCallDrainCause({ wasAborted, signalAborted: options?.signal?.aborted, streamIdleTimedOut });
1324
1459
  teardownQuery(abortCtx, sdkQuery, cause, cwd, isReentrant);
1325
- sdkQuery.close();
1460
+ closeSdkQuery(sdkQuery);
1326
1461
  })
1327
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
+ }
1328
1492
  // --- Account retry re-entry ---
1329
1493
  // Runs AFTER teardown so the failed attempt's query state is fully
1330
1494
  // released. The recursive call re-enters the fresh-query path with the
@@ -1342,8 +1506,8 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1342
1506
  abortCtx.turnOutput.stopReason = "aborted";
1343
1507
  abortCtx.turnOutput.errorMessage = "Operation aborted";
1344
1508
  }
1345
- stream.push({ type: "error", reason: "aborted", error: abortCtx.turnOutput! });
1346
- stream.end();
1509
+ reentryStream.push({ type: "error", reason: "aborted", error: abortCtx.turnOutput! });
1510
+ reentryStream.end();
1347
1511
  return;
1348
1512
  }
1349
1513
  debug(`provider: starting account retry after ${retryFailure?.kind ?? "failure"}; excluded=${[...rotationState.excludedProfileIds].join(",")}`);
@@ -1351,22 +1515,35 @@ function streamClaudeAgentSdkInLane(model: Model<any>, context: Context, options
1351
1515
  ...(options ?? {}),
1352
1516
  [ROTATION_STATE_KEY]: rotationState,
1353
1517
  } as BridgeStreamOptions);
1354
- // End exactly once per outcome (VST-53). Ending in a `finally` ran on
1518
+ // End exactly once per outcome. Ending in a `finally` ran on
1355
1519
  // the throw path too, BEFORE the .catch below could push its error
1356
1520
  // event — and EventStream.push is a silent no-op after end, so a failed
1357
1521
  // rotation ended the turn with no error event at all. Success ends
1358
1522
  // here; every throw ends in the .catch, after the error is pushed.
1359
- for await (const event of retryStream) stream.push(event);
1360
- stream.end();
1523
+ for await (const event of retryStream) reentryStream.push(event);
1524
+ reentryStream.end();
1361
1525
  })
1362
1526
  .catch((error) => {
1363
- 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
+ }
1364
1541
  if (abortCtx.turnOutput) {
1365
1542
  abortCtx.turnOutput.stopReason = "error";
1366
1543
  abortCtx.turnOutput.errorMessage = error instanceof Error ? error.message : String(error);
1367
1544
  }
1368
- stream.push({ type: "error", reason: "error", error: abortCtx.turnOutput! });
1369
- stream.end();
1545
+ reentryStream.push({ type: "error", reason: "error", error: abortCtx.turnOutput! });
1546
+ reentryStream.end();
1370
1547
  })
1371
1548
  // After teardown and any retry pipeline: nothing else reads this lane.
1372
1549
  .finally(releaseEphemeralLane);
@@ -1398,6 +1575,10 @@ export default function (pi: ExtensionAPI) {
1398
1575
  if (claimPrimaryInstance()) {
1399
1576
  const host = globalThis as Record<symbol, any>;
1400
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;
1401
1582
  }
1402
1583
 
1403
1584
  // Reset shared (Claude) conversation state on pi session lifecycle events.
@@ -1435,31 +1616,17 @@ export default function (pi: ExtensionAPI) {
1435
1616
  });
1436
1617
  deleteSharedSessionLane(sessionId);
1437
1618
  deleteQueryLane(sessionId);
1619
+ deleteBillingIdentityLane(sessionId);
1438
1620
  });
1439
1621
  pi.on("message_end", (event, ctx) => runInRequestLane(ctx.sessionManager.getSessionId(), () => {
1440
1622
  const message = (event as { message?: AssistantMessage }).message;
1441
1623
  if (message?.role === "assistant" && message.provider === PROVIDER_ID) schedulePersistSharedSession(ctx);
1442
1624
  }));
1443
1625
 
1444
- // pi /compact and session-tree navigation (rewind / fork-at-point /
1445
- // branch switch) both mutate pi's messages array out from under the
1446
- // bridge. syncSharedSession's REUSE check would otherwise see
1447
- // slice(cursor) === [] (or skip entries) and keep --resume'ing a CC
1448
- // session that no longer matches pi's history. /compact in particular
1449
- // triggers CC's autocompact-thrashing guard (issue #8). Force the next
1450
- // call down the REBUILD path so CC sees the current history.
1451
- const markRebuild = (event: string) => {
1452
- const activeSession = getSharedSession();
1453
- if (ctx().activeQuery) {
1454
- reportToolResultMismatch(ctx(), event, activeSession?.cwd ?? process.cwd());
1455
- }
1456
- if (activeSession) {
1457
- debug(`${event}: marking needsRebuild on session ${activeSession.sessionId.slice(0, 8)}`);
1458
- markSessionForRebuild();
1459
- }
1460
- };
1461
- pi.on("session_compact", (_event, ctx) => runInRequestLane(ctx.sessionManager.getSessionId(), () => markRebuild("session_compact")));
1462
- 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")));
1463
1630
 
1464
1631
  // --- Provider ---
1465
1632
  //