@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vanillagreen/pi-claude-bridge",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.2",
|
|
4
4
|
"description": "Pi provider bridge that runs Claude Code through the Claude Agent SDK, with opt-in forwarding for Pi prompt context.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -145,13 +145,14 @@
|
|
|
145
145
|
"devDependencies": {
|
|
146
146
|
"@earendil-works/pi-ai": "^0.84.0",
|
|
147
147
|
"@earendil-works/pi-coding-agent": "^0.84.0",
|
|
148
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
148
149
|
"@types/node": "^24.3.0",
|
|
149
150
|
"esbuild": "^0.28.0",
|
|
150
151
|
"tsx": "^4.21.0",
|
|
151
152
|
"typescript": "^6.0.3"
|
|
152
153
|
},
|
|
153
154
|
"scripts": {
|
|
154
|
-
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node22 --outfile=bundle/index.js --external:@earendil-works/pi-ai --external:@earendil-works/pi-coding-agent && esbuild src/connector-inventory.ts --bundle --platform=node --format=esm --target=node22 --outfile=bundle/connector-inventory.js",
|
|
155
|
+
"build": "esbuild src/index.ts --bundle --preserve-symlinks --platform=node --format=esm --target=node22 --outfile=bundle/index.js --external:@earendil-works/pi-ai --external:@earendil-works/pi-coding-agent && esbuild src/connector-inventory.ts --bundle --preserve-symlinks --platform=node --format=esm --target=node22 --outfile=bundle/connector-inventory.js",
|
|
155
156
|
"prepack": "npm run build",
|
|
156
157
|
"test:unit": "node --import tsx --test tests/unit-*.mjs",
|
|
157
158
|
"test:ci": "npm run build && npm run test:unit",
|
package/src/assistant-stream.ts
CHANGED
|
@@ -159,6 +159,7 @@ export function reapStaleQueuedResults(c: QueryContext): void {
|
|
|
159
159
|
diagDump("stale_queued_tool_results_parked", { count: stale.length, stale });
|
|
160
160
|
appendIntegrityEntry("stale_queued_tool_results_parked", { count: stale.length, stale });
|
|
161
161
|
safeNotify(
|
|
162
|
+
`queued-results-parked=${JSON.stringify({ count: stale.length, tools: names })}\n` +
|
|
162
163
|
`Claude bridge: parked ${stale.length} early tool result(s) whose handler has not arrived (${names.slice(0, 6).join(", ")}${names.length > 6 ? ", …" : ""}). ` +
|
|
163
164
|
`A late handler can still consume them.`,
|
|
164
165
|
"warning",
|
|
@@ -345,7 +346,7 @@ export function processStreamEvent(
|
|
|
345
346
|
}
|
|
346
347
|
if (event.content_block?.type === "tool_use" && !isPiDispatchable(event.content_block.name, customToolNameToPi)) {
|
|
347
348
|
c.suppressedStreamIndexes.add(event.index);
|
|
348
|
-
if (isForeignMcpTool(event.content_block.name)) c.
|
|
349
|
+
if (isForeignMcpTool(event.content_block.name)) c.noteForeignMcpToolCall(event.content_block.id, event.content_block.name);
|
|
349
350
|
debug(`processStreamEvent: non-dispatchable tool ${event.content_block.name} [${event.content_block.id}] — not mirrored as a Pi tool call`);
|
|
350
351
|
return;
|
|
351
352
|
}
|
|
@@ -513,7 +514,7 @@ function appendMissingToolUsesFromAssistant(
|
|
|
513
514
|
continue;
|
|
514
515
|
}
|
|
515
516
|
if (!isPiDispatchable(block.name, customToolNameToPi)) {
|
|
516
|
-
if (isForeignMcpTool(block.name)) c.
|
|
517
|
+
if (isForeignMcpTool(block.name)) c.noteForeignMcpToolCall(block.id, block.name);
|
|
517
518
|
debug(`assistant message: non-dispatchable tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
|
|
518
519
|
continue;
|
|
519
520
|
}
|
|
@@ -670,7 +671,7 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
|
|
|
670
671
|
continue;
|
|
671
672
|
}
|
|
672
673
|
if (!isPiDispatchable(block.name, customToolNameToPi)) {
|
|
673
|
-
if (isForeignMcpTool(block.name)) c.
|
|
674
|
+
if (isForeignMcpTool(block.name)) c.noteForeignMcpToolCall(block.id, block.name);
|
|
674
675
|
debug(`processAssistantMessage fallback: non-dispatchable tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
|
|
675
676
|
continue;
|
|
676
677
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// The Anthropic login the bridge's child query actually authenticated as,
|
|
2
|
+
// published for other extensions to read.
|
|
3
|
+
//
|
|
4
|
+
// WHY a published surface rather than letting a reader resolve
|
|
5
|
+
// CLAUDE_CONFIG_DIR for itself: that directory names the login only when the
|
|
6
|
+
// child used one. The bridge also accepts an API key and the Bedrock, Vertex,
|
|
7
|
+
// Foundry, Anthropic-AWS and Mantle backends (auth-presence.ts), and it passes
|
|
8
|
+
// those environment values straight to the child (query-options.ts). A
|
|
9
|
+
// companion account router may additionally hand each request its own profile
|
|
10
|
+
// and rotate it while the process environment never changes
|
|
11
|
+
// (account-router.ts). Only the SDK's own accountInfo() names the identity a
|
|
12
|
+
// request ran under, so the rule for reading it lives here once instead of in
|
|
13
|
+
// every consumer.
|
|
14
|
+
//
|
|
15
|
+
// SECURITY: this module holds one email per live request lane in memory and
|
|
16
|
+
// never logs it.
|
|
17
|
+
|
|
18
|
+
import type { AccountInfo } from "@anthropic-ai/claude-agent-sdk";
|
|
19
|
+
import { currentRequestLaneId } from "./request-lane.js";
|
|
20
|
+
|
|
21
|
+
export const CLAUDE_BILLING_IDENTITY_SYMBOL = Symbol.for("kendex.pi.claude-bridge.billing-identity.v1");
|
|
22
|
+
|
|
23
|
+
/** Anthropic's own login backend. Every other `apiProvider` value is an
|
|
24
|
+
* external credential whose payer this bridge cannot name. */
|
|
25
|
+
const FIRST_PARTY = "firstParty";
|
|
26
|
+
|
|
27
|
+
export interface ClaudeBillingIdentityV1 {
|
|
28
|
+
version: 1;
|
|
29
|
+
/** The Anthropic login email of the latest child attempt in `sessionId`, or
|
|
30
|
+
* undefined when that attempt authenticated with an API key or a
|
|
31
|
+
* third-party backend, has not reported yet, or its probe failed. A
|
|
32
|
+
* consumer passes the visible Pi session id, displays the result, and
|
|
33
|
+
* derives nothing further. */
|
|
34
|
+
currentLoginEmail(sessionId: string | undefined): string | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface BillingIdentityStore extends ClaudeBillingIdentityV1 {
|
|
38
|
+
beginAttempt(sessionId: string | undefined): (info: AccountInfo) => void;
|
|
39
|
+
deleteLane(sessionId: string | undefined): void;
|
|
40
|
+
clear(): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface BillingIdentityLane {
|
|
44
|
+
attempt: symbol;
|
|
45
|
+
loginEmail?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const BILLING_IDENTITY_LANES_SYMBOL = Symbol.for("kendex.pi.claude-bridge.billing-identity-lanes.v1");
|
|
49
|
+
|
|
50
|
+
function sharedBillingIdentityLanes(): Map<string | undefined, BillingIdentityLane> {
|
|
51
|
+
const host = globalThis as Record<symbol, unknown>;
|
|
52
|
+
let lanes = host[BILLING_IDENTITY_LANES_SYMBOL] as Map<string | undefined, BillingIdentityLane> | undefined;
|
|
53
|
+
if (!lanes) {
|
|
54
|
+
lanes = new Map();
|
|
55
|
+
host[BILLING_IDENTITY_LANES_SYMBOL] = lanes;
|
|
56
|
+
}
|
|
57
|
+
return lanes;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function nonEmpty(value: string | undefined): string | undefined {
|
|
61
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The login email an `accountInfo()` result confirms, or undefined when it
|
|
65
|
+
* confirms none. An API key is rejected even under the first-party backend:
|
|
66
|
+
* the key's owner is not the signed-in login, and `apiKeySource` is how the
|
|
67
|
+
* SDK reports that a key was used. */
|
|
68
|
+
export function loginEmailFrom(info: AccountInfo): string | undefined {
|
|
69
|
+
if (info.apiProvider !== FIRST_PARTY) return undefined;
|
|
70
|
+
if (nonEmpty(info.apiKeySource)) return undefined;
|
|
71
|
+
return nonEmpty(info.email);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function makeBillingIdentityStore(
|
|
75
|
+
lanes: Map<string | undefined, BillingIdentityLane> = new Map(),
|
|
76
|
+
): BillingIdentityStore {
|
|
77
|
+
return {
|
|
78
|
+
version: 1,
|
|
79
|
+
currentLoginEmail: (sessionId) => lanes.get(sessionId)?.loginEmail,
|
|
80
|
+
beginAttempt: (sessionId) => {
|
|
81
|
+
const attempt = Symbol("billing-identity-attempt");
|
|
82
|
+
lanes.set(sessionId, { attempt });
|
|
83
|
+
return (info) => {
|
|
84
|
+
const current = lanes.get(sessionId);
|
|
85
|
+
if (current?.attempt !== attempt) return;
|
|
86
|
+
lanes.set(sessionId, { attempt, loginEmail: loginEmailFrom(info) });
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
deleteLane: (sessionId) => lanes.delete(sessionId),
|
|
90
|
+
clear: () => lanes.clear(),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const BRIDGE_BILLING_IDENTITY = makeBillingIdentityStore(sharedBillingIdentityLanes());
|
|
95
|
+
|
|
96
|
+
/** Start the billing probe for the current request lane. Starting clears that
|
|
97
|
+
* lane, so a rejected probe cannot leave the previous attempt's identity.
|
|
98
|
+
* The returned recorder ignores an older probe that settles after a newer
|
|
99
|
+
* attempt in the same lane. */
|
|
100
|
+
export function beginBillingIdentityAttempt(): (info: AccountInfo) => void {
|
|
101
|
+
return BRIDGE_BILLING_IDENTITY.beginAttempt(currentRequestLaneId());
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Remove one completed Pi session without changing concurrent sessions. */
|
|
105
|
+
export function deleteBillingIdentityLane(sessionId: string | undefined): void {
|
|
106
|
+
BRIDGE_BILLING_IDENTITY.deleteLane(sessionId);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Read the published store, or undefined when no bridge is loaded. Never
|
|
110
|
+
* installs one: a consumer that created its own would answer for a bridge
|
|
111
|
+
* that is not running. */
|
|
112
|
+
export function resolveClaudeBillingIdentity(): ClaudeBillingIdentityV1 | undefined {
|
|
113
|
+
const host = globalThis as unknown as Record<PropertyKey, unknown>;
|
|
114
|
+
const candidate = host[CLAUDE_BILLING_IDENTITY_SYMBOL] as ClaudeBillingIdentityV1 | undefined;
|
|
115
|
+
return candidate?.version === 1 && typeof candidate.currentLoginEmail === "function" ? candidate : undefined;
|
|
116
|
+
}
|
package/src/bridge-state.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ExtensionAPI, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { debug, diagDump, diagGuidance } from "./debug.js";
|
|
2
|
+
import { DEBUG, debug, diagDump, diagGuidance, diagLogPath } from "./debug.js";
|
|
3
3
|
import { type QueryContext } from "./query-state.js";
|
|
4
4
|
import { currentRequestLaneId } from "./request-lane.js";
|
|
5
5
|
import { summarizeMissingToolNames, type MissingToolResult } from "./tool-pairing-audit.js";
|
|
@@ -35,14 +35,16 @@ export interface SessionState {
|
|
|
35
35
|
// navigation) or after an abort left the JSONL in an indeterminate state.
|
|
36
36
|
// REBUILD wipes and rewrites the file to match pi's current history.
|
|
37
37
|
needsRebuild?: boolean;
|
|
38
|
-
// Set
|
|
39
|
-
//
|
|
38
|
+
// Set whenever a CC subprocess was killed and may still be flushing to its
|
|
39
|
+
// session JSONL: after an abort, whose late "[Request interrupted by user]"
|
|
40
|
+
// record is the classic case, and at a history replacement (compact, tree
|
|
41
|
+
// navigation) that stops an ACTIVE query to restart it on pi's new context.
|
|
40
42
|
// Reusing the same sessionId/path would race that orphan write into our
|
|
41
43
|
// fresh file and break CC's parent-uuid chain on the next resume. When
|
|
42
44
|
// this flag is set, REBUILD takes a fresh UUID and skips deleteSession
|
|
43
|
-
// so the orphan writes land on a dead inode.
|
|
44
|
-
//
|
|
45
|
-
//
|
|
45
|
+
// so the orphan writes land on a dead inode. A compact or tree event with
|
|
46
|
+
// no active query kills nothing, so it leaves this unset and rebuilds in
|
|
47
|
+
// place (preserve UUID, deleteSession + createSession).
|
|
46
48
|
forceRotate?: boolean;
|
|
47
49
|
}
|
|
48
50
|
|
|
@@ -284,6 +286,7 @@ export function reportToolResultMismatch(
|
|
|
284
286
|
unmatchedResultIds: progress.unmatchedResultIds,
|
|
285
287
|
});
|
|
286
288
|
safeNotify(
|
|
289
|
+
`tool-result-mismatch=${JSON.stringify({ delivered: progress.deliveredCount, expected: progress.expectedCount, resolved: progress.resolvedCount, diagnostic: DEBUG ? diagLogPath() : "CLAUDE_BRIDGE_DEBUG=1" })}\n` +
|
|
287
290
|
`Claude bridge: tool result delivery interrupted during ${reason}; ` +
|
|
288
291
|
`delivered ${progress.deliveredCount}/${progress.expectedCount}, resolved ${progress.resolvedCount}/${progress.expectedCount}, ` +
|
|
289
292
|
`waiting=${progress.waitingCount}, queued=${progress.queuedCount}, unmatched=${progress.unmatchedResultCount}` +
|
package/src/connector-audit.ts
CHANGED
|
@@ -160,10 +160,12 @@ export function recordConnectorCallResult(
|
|
|
160
160
|
isError: boolean,
|
|
161
161
|
byteSize: number | undefined,
|
|
162
162
|
): boolean {
|
|
163
|
-
const pending = queryCtx.
|
|
163
|
+
const pending = queryCtx.childSideCalls.get(toolUseId);
|
|
164
164
|
if (pending?.recorded) return false;
|
|
165
165
|
const childSessionId = pending?.childSessionId ?? queryCtx.childSessionId;
|
|
166
|
-
|
|
166
|
+
// Reached only for a child-executed connector result, so an entry this call
|
|
167
|
+
// never saw noted is a connector's.
|
|
168
|
+
queryCtx.childSideCalls.set(toolUseId, { ...pending, kind: pending?.kind ?? "connector", name, childSessionId, recorded: true });
|
|
167
169
|
return appendConnectorCallAudit({
|
|
168
170
|
name,
|
|
169
171
|
toolUseId,
|
|
@@ -183,9 +185,11 @@ export function recordConnectorCallResult(
|
|
|
183
185
|
*/
|
|
184
186
|
export function flushConnectorCallAudit(queryCtx: QueryContext, reason: ToolCallDrainCause): number {
|
|
185
187
|
let appended = 0;
|
|
186
|
-
for (const [toolUseId, state] of queryCtx.
|
|
187
|
-
|
|
188
|
-
|
|
188
|
+
for (const [toolUseId, state] of queryCtx.childSideCalls) {
|
|
189
|
+
// Foreign MCP calls share the map but not the trail: its entries are
|
|
190
|
+
// `claude-bridge-connector-call` records naming a claude.ai connector.
|
|
191
|
+
if (state.recorded || state.kind !== "connector") continue;
|
|
192
|
+
queryCtx.childSideCalls.set(toolUseId, { ...state, recorded: true });
|
|
189
193
|
const childSessionId = state.childSessionId ?? queryCtx.childSessionId;
|
|
190
194
|
if (appendConnectorCallAudit({
|
|
191
195
|
name: state.name,
|
|
@@ -244,8 +244,8 @@ export async function listAccountConnectors(deps: ListConnectorsDeps): Promise<C
|
|
|
244
244
|
// fetch/proxy layer is free to put the request headers — and therefore the
|
|
245
245
|
// bearer token — into the message it throws, and that message would otherwise
|
|
246
246
|
// land in a reason string that callers log.
|
|
247
|
-
const fail = (reason: string): ConnectorInventory =>
|
|
248
|
-
({ ok: false, complete: false, reason: redactSecret(reason
|
|
247
|
+
const fail = (key: string, value: string | number, reason: string): ConnectorInventory =>
|
|
248
|
+
({ ok: false, complete: false, reason: redactSecret(`${key}=${JSON.stringify(value)}\n${reason}`, credentials.accessToken) });
|
|
249
249
|
|
|
250
250
|
let response: Response;
|
|
251
251
|
try {
|
|
@@ -260,32 +260,32 @@ export async function listAccountConnectors(deps: ListConnectorsDeps): Promise<C
|
|
|
260
260
|
signal,
|
|
261
261
|
});
|
|
262
262
|
} catch (error) {
|
|
263
|
-
return fail(`connector list request failed: ${errorText(error)}`);
|
|
263
|
+
return fail("connector-request", "transport", `connector list request failed: ${errorText(error)}`);
|
|
264
264
|
}
|
|
265
265
|
|
|
266
266
|
let bodyText: string;
|
|
267
267
|
try {
|
|
268
268
|
bodyText = await response.text();
|
|
269
269
|
} catch (error) {
|
|
270
|
-
return fail(`connector list response unreadable: ${errorText(error)}`);
|
|
270
|
+
return fail("connector-response", "unreadable", `connector list response unreadable: ${errorText(error)}`);
|
|
271
271
|
}
|
|
272
272
|
|
|
273
273
|
if (!response.ok) {
|
|
274
|
-
return fail(`connector list returned HTTP ${response.status}${apiErrorSuffix(bodyText)}`);
|
|
274
|
+
return fail("connector-http", response.status, `connector list returned HTTP ${response.status}${apiErrorSuffix(bodyText)}`);
|
|
275
275
|
}
|
|
276
276
|
|
|
277
277
|
let parsed: Json;
|
|
278
278
|
try {
|
|
279
279
|
parsed = JSON.parse(bodyText) as Json;
|
|
280
280
|
} catch {
|
|
281
|
-
return fail("connector list returned a non-JSON body");
|
|
281
|
+
return fail("connector-json", "invalid", "connector list returned a non-JSON body");
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
// A missing/!Array `results` is a protocol change, not an empty account. Treat
|
|
285
285
|
// it as failure — reporting "no connectors" here would recreate exactly the
|
|
286
286
|
// silent-wrong-answer failure this module exists to remove.
|
|
287
287
|
if (!Array.isArray(parsed?.results)) {
|
|
288
|
-
return fail("connector list response had no results array");
|
|
288
|
+
return fail("connector-results", "not-array", "connector list response had no results array");
|
|
289
289
|
}
|
|
290
290
|
|
|
291
291
|
const connectors: ConnectorEntry[] = [];
|
|
@@ -296,7 +296,7 @@ export async function listAccountConnectors(deps: ListConnectorsDeps): Promise<C
|
|
|
296
296
|
// so silently keeping it would understate the inventory in a way the
|
|
297
297
|
// caller could not detect. Fail instead.
|
|
298
298
|
if (!name) {
|
|
299
|
-
return fail("connector list contained an entry with no name");
|
|
299
|
+
return fail("connector-name", connectors.length, "connector list contained an entry with no name");
|
|
300
300
|
}
|
|
301
301
|
connectors.push({
|
|
302
302
|
name,
|
package/src/connectors.ts
CHANGED
|
@@ -517,6 +517,7 @@ function connectorWriteDenyOutput(toolName: string) {
|
|
|
517
517
|
hookEventName: "PreToolUse" as const,
|
|
518
518
|
permissionDecision: "deny" as const,
|
|
519
519
|
permissionDecisionReason:
|
|
520
|
+
`connector-write-denied=${JSON.stringify(toolName)}\n` +
|
|
520
521
|
`Connector write tool "${toolName}" is blocked in read-only connector mode. ` +
|
|
521
522
|
`Connector writes must go through the host application's gated approval flow.`,
|
|
522
523
|
},
|
package/src/consume-query.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// generator and pushes events into the query's captured Pi stream.
|
|
4
4
|
|
|
5
5
|
import { type Model } from "@earendil-works/pi-ai";
|
|
6
|
-
import { type query } from "@anthropic-ai/claude-agent-sdk";
|
|
6
|
+
import { type AccountInfo, type query } from "@anthropic-ai/claude-agent-sdk";
|
|
7
7
|
import {
|
|
8
8
|
classifyClaudeFailure,
|
|
9
9
|
rateLimitResetFromInfo,
|
|
@@ -88,6 +88,7 @@ export async function consumeQuery(
|
|
|
88
88
|
model: Model<any>,
|
|
89
89
|
bridgeConfig: Config,
|
|
90
90
|
wasAborted: () => boolean,
|
|
91
|
+
recordBillingIdentity: (info: AccountInfo) => void,
|
|
91
92
|
account?: ClaudeAccountRoute,
|
|
92
93
|
router?: ClaudeAccountRouterV1,
|
|
93
94
|
// Mirror of the held failure for the caller's .catch: the SDK iterator can
|
|
@@ -100,6 +101,7 @@ export async function consumeQuery(
|
|
|
100
101
|
let capturedSessionId: string | undefined;
|
|
101
102
|
let failure: ClaudeAttemptFailure | undefined;
|
|
102
103
|
let accountProbe: Promise<void> | undefined;
|
|
104
|
+
let accountInfoProbe: Promise<AccountInfo> | undefined;
|
|
103
105
|
const holdFailure = (next: ClaudeAttemptFailure | undefined): void => {
|
|
104
106
|
failure = next;
|
|
105
107
|
if (attemptFailureBox) attemptFailureBox.failure = next;
|
|
@@ -216,9 +218,22 @@ export async function consumeQuery(
|
|
|
216
218
|
// from the teardown flush, which runs outside this function's scope.
|
|
217
219
|
queryCtx.childSessionId = capturedSessionId;
|
|
218
220
|
noteFastModeDisabledReason(message, bridgeConfig);
|
|
221
|
+
// Which login this child authenticated as is published for other
|
|
222
|
+
// extensions, for every child rather than only a routed one:
|
|
223
|
+
// an unrouted child is the common case and its identity is
|
|
224
|
+
// just as unknowable from outside the SDK. Nothing waits for
|
|
225
|
+
// it, so it stays off the turn's critical path. The call sits
|
|
226
|
+
// in an async IIFE so a synchronous throw arrives as a
|
|
227
|
+
// rejection the debug line below names.
|
|
228
|
+
if (!accountInfoProbe) {
|
|
229
|
+
accountInfoProbe = (async () => sdkQuery.accountInfo())();
|
|
230
|
+
void accountInfoProbe
|
|
231
|
+
.then((info) => recordBillingIdentity(info))
|
|
232
|
+
.catch((error) => debug("consumeQuery: billing identity probe rejected:", error));
|
|
233
|
+
}
|
|
219
234
|
if (account && router && !accountProbe) {
|
|
220
235
|
accountProbe = Promise.allSettled([
|
|
221
|
-
|
|
236
|
+
accountInfoProbe.then((info) => router.recordIdentity(account.profileId, {
|
|
222
237
|
email: info.email,
|
|
223
238
|
organization: info.organization,
|
|
224
239
|
subscriptionType: info.subscriptionType,
|