@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/README.md +5 -1
- package/bundle/connector-inventory.js +8 -7
- package/bundle/index.js +254 -74
- package/package.json +3 -2
- package/src/assistant-stream.ts +4 -3
- package/src/billing-identity.ts +116 -0
- package/src/bridge-state.ts +9 -6
- package/src/connector-audit.ts +9 -5
- package/src/connector-inventory.ts +8 -8
- package/src/connectors.ts +1 -0
- package/src/consume-query.ts +17 -2
- package/src/index.ts +210 -42
- package/src/native-provider.ts +3 -1
- package/src/query-state.ts +65 -11
- package/src/query-teardown.ts +16 -0
- package/src/session-persistence.ts +5 -5
- package/src/session-verify.ts +5 -5
- package/src/tool-pairing-audit.ts +2 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1345
|
-
|
|
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)
|
|
1359
|
-
|
|
1523
|
+
for await (const event of retryStream) reentryStream.push(event);
|
|
1524
|
+
reentryStream.end();
|
|
1360
1525
|
})
|
|
1361
1526
|
.catch((error) => {
|
|
1362
|
-
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
|
+
}
|
|
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
|
-
|
|
1368
|
-
|
|
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
|
|
1444
|
-
//
|
|
1445
|
-
|
|
1446
|
-
|
|
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
|
//
|
package/src/native-provider.ts
CHANGED
|
@@ -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))
|
|
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
|
package/src/query-state.ts
CHANGED
|
@@ -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: `
|
|
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: "
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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.
|
|
470
|
-
this.
|
|
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("
|
|
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("
|
|
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()!;
|
package/src/query-teardown.ts
CHANGED
|
@@ -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. */
|