@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/README.md +41 -90
- package/bundle/connector-inventory.js +8 -7
- package/bundle/index.js +34086 -25259
- package/package.json +5 -3
- package/src/account-router.ts +3 -3
- package/src/assistant-stream.ts +95 -79
- package/src/auth-presence.ts +1 -2
- package/src/billing-identity.ts +116 -0
- package/src/bridge-commands.ts +1 -1
- package/src/bridge-state.ts +14 -12
- package/src/config.ts +14 -6
- package/src/connector-audit.ts +16 -16
- package/src/connector-cache.ts +6 -7
- package/src/connector-inventory.ts +13 -15
- package/src/connector-runtime.ts +8 -8
- package/src/connectors.ts +35 -35
- package/src/consume-query.ts +20 -5
- package/src/convert.ts +1 -1
- package/src/debug.ts +2 -2
- package/src/index.ts +238 -71
- package/src/models.ts +5 -5
- package/src/native-provider.ts +3 -1
- package/src/query-options.ts +4 -4
- package/src/query-state.ts +102 -54
- package/src/query-teardown.ts +19 -3
- package/src/rate-limit.ts +1 -1
- package/src/session-persistence.ts +13 -13
- package/src/session-verify.ts +5 -5
- package/src/tool-mapping.ts +41 -8
- package/src/tool-pairing-audit.ts +7 -7
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
422
|
-
// resolve the MCP handler, and the generator unblocks
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
965
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
1346
|
-
|
|
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
|
|
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)
|
|
1360
|
-
|
|
1523
|
+
for await (const event of retryStream) reentryStream.push(event);
|
|
1524
|
+
reentryStream.end();
|
|
1361
1525
|
})
|
|
1362
1526
|
.catch((error) => {
|
|
1363
|
-
debug("provider:
|
|
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
|
-
|
|
1369
|
-
|
|
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
|
|
1445
|
-
//
|
|
1446
|
-
|
|
1447
|
-
|
|
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
|
//
|