@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/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": [
|
|
@@ -133,7 +133,7 @@
|
|
|
133
133
|
}
|
|
134
134
|
},
|
|
135
135
|
"dependencies": {
|
|
136
|
-
"@anthropic-ai/claude-agent-sdk": "^0.3.
|
|
136
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.261",
|
|
137
137
|
"@anthropic-ai/sdk": "^0.112.4",
|
|
138
138
|
"cc-session-io": "^0.3.2",
|
|
139
139
|
"change-case": "^5.4.4"
|
|
@@ -145,15 +145,17 @@
|
|
|
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",
|
|
158
|
+
"test:ci": "npm run build && npm run test:unit",
|
|
157
159
|
"test": "set -a && [ -f .env.test ] && . ./.env.test; set +a && npm run test:unit && tests/int-smoke.sh && tests/int-multi-turn.sh && tests/int-cache.sh && node --import tsx --test tests/int-*.mjs",
|
|
158
160
|
"test:usage": "tests/usage-test.sh",
|
|
159
161
|
"typecheck": "tsc --noEmit"
|
package/src/account-router.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// versioned symbol below to supply a subscription profile for each fresh
|
|
5
5
|
// Claude request. The bridge passes only an opaque profile id, a display
|
|
6
6
|
// label, and an optional CLAUDE_CONFIG_DIR; credentials remain owned by the
|
|
7
|
-
// official Claude CLI.
|
|
8
|
-
// symbol
|
|
7
|
+
// official Claude CLI. An incompatible shape must use a separate
|
|
8
|
+
// symbol or version instead of mutating this contract in place.
|
|
9
9
|
|
|
10
10
|
import type { AssistantMessageEvent, AssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
11
11
|
import { homedir } from "node:os";
|
|
@@ -48,7 +48,7 @@ export interface ClaudeAccountRouterV1 {
|
|
|
48
48
|
recordFailure(profileId: string, kind: ClaudeAccountFailureKind, modelId: string): void;
|
|
49
49
|
recordSuccess(profileId: string, sessionId?: string): void;
|
|
50
50
|
current(modelId: string, sessionId?: string): ClaudeAccountRoute | undefined;
|
|
51
|
-
/** Resolve
|
|
51
|
+
/** Resolve an issued profile id back to its route. The bridge
|
|
52
52
|
* persists ONLY the opaque profile id into Pi session entries (config-dir
|
|
53
53
|
* paths are account-identifying and travel with shared session archives),
|
|
54
54
|
* so restoring a session re-derives the config dir here. A missing router
|
package/src/assistant-stream.ts
CHANGED
|
@@ -5,14 +5,14 @@ import { connectorResultByteSize, recordConnectorCallResult } from "./connector-
|
|
|
5
5
|
import { isChildExecutedTool } from "./connectors.js";
|
|
6
6
|
import { debug, diagDump } from "./debug.js";
|
|
7
7
|
import { ctx, failStrandedToolCall, type QueryContext } from "./query-state.js";
|
|
8
|
-
import { mapToolArgs, mapToolName } from "./tool-mapping.js";
|
|
8
|
+
import { isForeignMcpTool, isPiDispatchable, mapToolArgs, mapToolName } from "./tool-mapping.js";
|
|
9
9
|
|
|
10
10
|
// --- Usage helpers ---
|
|
11
11
|
|
|
12
12
|
function updateUsage(output: AssistantMessage, usage: Record<string, number | undefined>, model: Model<any>, c: QueryContext): void {
|
|
13
13
|
// Anthropic reports per-message counters and RE-reports them as the message
|
|
14
|
-
// grows, so the in-flight message's figures
|
|
15
|
-
//
|
|
14
|
+
// grows, so the in-flight message's figures replace, never accumulate. What
|
|
15
|
+
// accumulates is every child message already finished in this Pi turn — see
|
|
16
16
|
// `turnUsageCarry` in query-state.ts for why a turn can span several.
|
|
17
17
|
const current = c.currentMessageUsage;
|
|
18
18
|
const carry = c.turnUsageCarry;
|
|
@@ -70,25 +70,24 @@ export function finalizeCurrentStream(stopReason?: string, c: QueryContext = ctx
|
|
|
70
70
|
//
|
|
71
71
|
// The Claude Code CLI dispatches MCP tool calls (and the SDK yields the
|
|
72
72
|
// completed assistant message) BEFORE the stream's message_delta arrives — and
|
|
73
|
-
// message_delta is what carries the message's REAL output-token count (
|
|
74
|
-
// handler
|
|
75
|
-
// the pi stream at either of those early signals
|
|
76
|
-
// message_start placeholder values
|
|
77
|
-
//
|
|
78
|
-
// (2026-07-28 token test, both bridge panes).
|
|
73
|
+
// message_delta is what carries the message's REAL output-token count (the
|
|
74
|
+
// handler fires tens of milliseconds ahead of it on every tool-use turn).
|
|
75
|
+
// Ending the pi stream at either of those early signals freezes usage at the
|
|
76
|
+
// message_start placeholder values: a handful of output tokens per tool-use
|
|
77
|
+
// turn while the final text turn records hundreds.
|
|
79
78
|
//
|
|
80
|
-
// So the turn
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
79
|
+
// So the turn ends at message_stop, exactly like the streamed-text case, and
|
|
80
|
+
// the early signals only ARM a grace timer. The timer is the deadlock backstop
|
|
81
|
+
// for a stream whose terminal events never arrive (pi's steer draining
|
|
82
|
+
// produces one): pi cannot execute tools before the stream ends, and the MCP
|
|
83
|
+
// handler cannot resolve before pi executes, so a stream that has gone silent
|
|
84
|
+
// must be ended by force — TOOL_USE_END_GRACE_MS later instead of immediately.
|
|
86
85
|
|
|
87
86
|
const TOOL_USE_END_GRACE_MS = 1500;
|
|
88
87
|
|
|
89
88
|
/** End the current pi stream as a tool_use turn boundary. Safe to call when the
|
|
90
89
|
* turn already ended (no-op). Every end path funnels here, so this is where
|
|
91
|
-
* two invariants are enforced by construction
|
|
90
|
+
* two invariants are enforced by construction: a block still
|
|
92
91
|
* carrying partialJson never ships — Pi executes the done message's content,
|
|
93
92
|
* and truncated arguments must never execute — and every call that DOES ship
|
|
94
93
|
* is stamped forwarded so no lagging replay can dispatch it again. */
|
|
@@ -105,7 +104,7 @@ export function endToolUseTurn(c: QueryContext): void {
|
|
|
105
104
|
}
|
|
106
105
|
// Every tool call Pi is about to execute from this turn is owed a result and
|
|
107
106
|
// must never be dispatched again: a lagging stream replays the same tool_use
|
|
108
|
-
// into the NEXT turn, whose per-message dedup cannot see it
|
|
107
|
+
// into the NEXT turn, whose per-message dedup cannot see it.
|
|
109
108
|
for (const block of c.turnOutput.content as Array<{ type?: string; id?: unknown }>) {
|
|
110
109
|
if (block?.type === "toolCall" && typeof block.id === "string") c.forwardedToolCallIds.add(block.id);
|
|
111
110
|
}
|
|
@@ -146,12 +145,11 @@ export function scheduleToolUseTurnEnd(c: QueryContext, action: () => void, sour
|
|
|
146
145
|
* Park queued tool results whose handler has not fired by a child message
|
|
147
146
|
* boundary, and say so everywhere it matters. The boundary is where stale
|
|
148
147
|
* entries would start poisoning mismatch reports — but it does NOT prove the
|
|
149
|
-
* handler gave up: the SDK staggers handler invocations, and in
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* notice describes.
|
|
148
|
+
* handler gave up: the SDK staggers handler invocations, and handlers in a
|
|
149
|
+
* parallel batch routinely fire after this point. Parked results stay
|
|
150
|
+
* consumable through takeQueuedOrParkedResult; one that is never consumed
|
|
151
|
+
* belongs to a call the SDK abandoned client-side (permission denial), which
|
|
152
|
+
* is exactly what the notice describes.
|
|
155
153
|
*/
|
|
156
154
|
export function reapStaleQueuedResults(c: QueryContext): void {
|
|
157
155
|
const stale = c.takeStaleQueuedResults();
|
|
@@ -161,6 +159,7 @@ export function reapStaleQueuedResults(c: QueryContext): void {
|
|
|
161
159
|
diagDump("stale_queued_tool_results_parked", { count: stale.length, stale });
|
|
162
160
|
appendIntegrityEntry("stale_queued_tool_results_parked", { count: stale.length, stale });
|
|
163
161
|
safeNotify(
|
|
162
|
+
`queued-results-parked=${JSON.stringify({ count: stale.length, tools: names })}\n` +
|
|
164
163
|
`Claude bridge: parked ${stale.length} early tool result(s) whose handler has not arrived (${names.slice(0, 6).join(", ")}${names.length > 6 ? ", …" : ""}). ` +
|
|
165
164
|
`A late handler can still consume them.`,
|
|
166
165
|
"warning",
|
|
@@ -180,14 +179,14 @@ export const FINALIZE_MAX_REARMS = 3;
|
|
|
180
179
|
* stream events never arrived (the grace-timer action armed by an MCP handler
|
|
181
180
|
* invocation — see scheduleToolUseTurnEnd).
|
|
182
181
|
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* FINALIZE_MAX_REARMS extra grace periods for the rest
|
|
182
|
+
* The producer is pi's steer draining (tool result and drained steer arrive
|
|
183
|
+
* in one provider call): the NEXT tool turn's tool_use streams in, the SDK
|
|
184
|
+
* invokes the MCP handler — and neither terminal event ever arrives. The
|
|
185
|
+
* invocation itself proves the assistant turn is committed, so end the pi
|
|
186
|
+
* stream like the `message_stop` path — with this handler's schema-validated
|
|
187
|
+
* arguments, never a partial parse — after settling every sibling whose
|
|
188
|
+
* handler has fired and giving a merely-lagging stream up to
|
|
189
|
+
* FINALIZE_MAX_REARMS extra grace periods for the rest.
|
|
191
190
|
*
|
|
192
191
|
* The dead-stream guard is a backstop: the grace timer's own stream-identity
|
|
193
192
|
* check means this normally never runs after the turn ended. The primary
|
|
@@ -204,8 +203,8 @@ export function finalizeToolUseTurnFromMcpInvocation(
|
|
|
204
203
|
// The turn ended without this call. An unforwarded handler here can never
|
|
205
204
|
// be answered — the yield that could have replayed its call was consumed
|
|
206
205
|
// against a null stream, and the dead-mark below suppresses any replay
|
|
207
|
-
// that has not happened yet. Failing it
|
|
208
|
-
//
|
|
206
|
+
// that has not happened yet. Failing it here turns what would be a
|
|
207
|
+
// session-long deadlock into one retryable error.
|
|
209
208
|
if (failStrandedToolCall(queryCtx, toolCallId)) {
|
|
210
209
|
debug(`mcp handler: ${toolName} [${toolCallId}] stranded — turn ended before its call reached Pi; resolved with error`);
|
|
211
210
|
diagDump("tool_handler_stranded", { toolCallId, toolName, site: "finalize-no-stream" });
|
|
@@ -220,9 +219,9 @@ export function finalizeToolUseTurnFromMcpInvocation(
|
|
|
220
219
|
// Stream ended before content_block_stop. The SDK invoked this handler
|
|
221
220
|
// with the COMPLETE schema-validated input, so the handler's copy is
|
|
222
221
|
// authoritative — the streamed partial JSON is by definition behind it.
|
|
223
|
-
// Settling from the partial
|
|
224
|
-
//
|
|
225
|
-
//
|
|
222
|
+
// Settling from the partial forwards `{}`-argument calls that Pi then
|
|
223
|
+
// executes and errors on, a divergence the synthesize branch below
|
|
224
|
+
// cannot have.
|
|
226
225
|
block.arguments = mappedArgs;
|
|
227
226
|
queryCtx.updateToolCallArgs(block.id, block.arguments);
|
|
228
227
|
delete block.partialJson;
|
|
@@ -230,13 +229,13 @@ export function finalizeToolUseTurnFromMcpInvocation(
|
|
|
230
229
|
queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: queryCtx.turnOutput });
|
|
231
230
|
}
|
|
232
231
|
} else if (queryCtx.forwardedToolCallIds.has(toolCallId) || queryCtx.deadToolCallIds.has(toolCallId)) {
|
|
233
|
-
// Pi already executed this call in
|
|
234
|
-
// sits parked), or the handler was already failed — either
|
|
235
|
-
// dispatch is the one outcome worse than waiting. Do NOT
|
|
236
|
-
// firing consumed the stream's only grace timer, so the
|
|
237
|
-
// blocks must still be settled and the turn still
|
|
238
|
-
// that loses its terminal events afterwards has
|
|
239
|
-
// sits busy until manual abort.
|
|
232
|
+
// Pi already executed this call in a turn that has ended (its result
|
|
233
|
+
// arrives or sits parked), or the handler was already failed — either
|
|
234
|
+
// way a second dispatch is the one outcome worse than waiting. Do NOT
|
|
235
|
+
// return: this firing consumed the stream's only grace timer, so the
|
|
236
|
+
// current turn's own blocks must still be settled and the turn still
|
|
237
|
+
// ended below, or a turn that loses its terminal events afterwards has
|
|
238
|
+
// no backstop left and Pi sits busy until manual abort.
|
|
240
239
|
debug(`mcp handler: ${toolName} [${toolCallId}] already ${queryCtx.forwardedToolCallIds.has(toolCallId) ? "forwarded" : "dead"} — not re-emitting`);
|
|
241
240
|
} else {
|
|
242
241
|
// The invocation can arrive before the tool_use is streamed at all
|
|
@@ -314,13 +313,14 @@ export function processStreamEvent(
|
|
|
314
313
|
}
|
|
315
314
|
|
|
316
315
|
if (event?.type === "message_start") {
|
|
317
|
-
// The child moving to
|
|
318
|
-
//
|
|
316
|
+
// The child moving on to another message is where a still-queued result
|
|
317
|
+
// would start poisoning mismatch reports: park it, consumable by a late
|
|
318
|
+
// handler (see reapStaleQueuedResults).
|
|
319
319
|
reapStaleQueuedResults(c);
|
|
320
320
|
c.resetToolTracking();
|
|
321
|
-
//
|
|
322
|
-
// counters are replaced. No-op on the turn's first, and no-op if this
|
|
323
|
-
// message was already declared (see beginChildMessage).
|
|
321
|
+
// Another child message begins: bank what the previous one billed before
|
|
322
|
+
// its counters are replaced. No-op on the turn's first, and no-op if this
|
|
323
|
+
// same message was already declared (see beginChildMessage).
|
|
324
324
|
c.beginChildMessage(event.message?.id);
|
|
325
325
|
updateTurnOutputModel(event.message?.model, c);
|
|
326
326
|
if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model, c);
|
|
@@ -330,7 +330,7 @@ export function processStreamEvent(
|
|
|
330
330
|
if (event?.type === "content_block_start") {
|
|
331
331
|
c.turnSawStreamEvent = true;
|
|
332
332
|
ensureTurnStarted(c);
|
|
333
|
-
//
|
|
333
|
+
// This block owns its index from here on, so release any child-executed
|
|
334
334
|
// or suppressed claim on it. Belt-and-braces against a missed
|
|
335
335
|
// message_start: without this a stale index could silently swallow a later
|
|
336
336
|
// text block's deltas.
|
|
@@ -344,6 +344,12 @@ export function processStreamEvent(
|
|
|
344
344
|
debug(`processStreamEvent: child-executed tool ${event.content_block.name} [${event.content_block.id}] — not mirrored as a Pi tool call`);
|
|
345
345
|
return;
|
|
346
346
|
}
|
|
347
|
+
if (event.content_block?.type === "tool_use" && !isPiDispatchable(event.content_block.name, customToolNameToPi)) {
|
|
348
|
+
c.suppressedStreamIndexes.add(event.index);
|
|
349
|
+
if (isForeignMcpTool(event.content_block.name)) c.noteForeignMcpToolCall(event.content_block.id, event.content_block.name);
|
|
350
|
+
debug(`processStreamEvent: non-dispatchable tool ${event.content_block.name} [${event.content_block.id}] — not mirrored as a Pi tool call`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
347
353
|
if (event.content_block?.type === "text") {
|
|
348
354
|
c.turnBlocks.push({ type: "text", text: "", index: event.index });
|
|
349
355
|
c.currentPiStream!.push({ type: "text_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
|
|
@@ -355,7 +361,7 @@ export function processStreamEvent(
|
|
|
355
361
|
if (typeof streamedId === "string" && (c.forwardedToolCallIds.has(streamedId) || c.deadToolCallIds.has(streamedId))) {
|
|
356
362
|
// A lagging stream replaying a call Pi already executed — or one whose
|
|
357
363
|
// handler was already failed as stranded — into a later turn. Mirroring
|
|
358
|
-
// it would make Pi dispatch it a second time
|
|
364
|
+
// it would make Pi dispatch it a second time.
|
|
359
365
|
c.suppressedStreamIndexes.add(event.index);
|
|
360
366
|
debug(`processStreamEvent: tool_use ${streamedId} already ${c.forwardedToolCallIds.has(streamedId) ? "forwarded" : "dead"} — suppressing duplicate stream block`);
|
|
361
367
|
return;
|
|
@@ -364,7 +370,7 @@ export function processStreamEvent(
|
|
|
364
370
|
// Same turn, same id: the completed-message yield beat the stream (its
|
|
365
371
|
// block is already recorded with complete arguments). A second
|
|
366
372
|
// partialJson copy would ship the id twice in one done message — and
|
|
367
|
-
// if its stop never arrives, ship it truncated
|
|
373
|
+
// if its stop never arrives, ship it truncated.
|
|
368
374
|
c.suppressedStreamIndexes.add(event.index);
|
|
369
375
|
debug(`processStreamEvent: tool_use ${streamedId} already recorded in this turn — suppressing duplicate stream block`);
|
|
370
376
|
return;
|
|
@@ -494,7 +500,7 @@ function appendMissingToolUsesFromAssistant(
|
|
|
494
500
|
// array of a turnOutput that endToolUseTurn already handed Pi BY REFERENCE
|
|
495
501
|
// in its done event, so a push here appends calls into a delivered message
|
|
496
502
|
// behind Pi's back — whether Pi's dispatch enumerates before or after the
|
|
497
|
-
// push is a microtask race
|
|
503
|
+
// push is a microtask race.
|
|
498
504
|
const streamLive = Boolean(c.currentPiStream && c.turnOutput);
|
|
499
505
|
let sawToolUse = false;
|
|
500
506
|
for (const block of assistantMsg.content) {
|
|
@@ -507,11 +513,16 @@ function appendMissingToolUsesFromAssistant(
|
|
|
507
513
|
debug(`assistant message: child-executed tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
|
|
508
514
|
continue;
|
|
509
515
|
}
|
|
516
|
+
if (!isPiDispatchable(block.name, customToolNameToPi)) {
|
|
517
|
+
if (isForeignMcpTool(block.name)) c.noteForeignMcpToolCall(block.id, block.name);
|
|
518
|
+
debug(`assistant message: non-dispatchable tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
510
521
|
const existingIdx = c.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === block.id);
|
|
511
522
|
if (existingIdx < 0 && (c.forwardedToolCallIds.has(block.id) || c.deadToolCallIds.has(block.id))) {
|
|
512
|
-
// Completed-message replay of a call Pi already executed in
|
|
513
|
-
//
|
|
514
|
-
// sawToolUse stays false for it, and no block is emitted
|
|
523
|
+
// Completed-message replay of a call Pi already executed in a turn that
|
|
524
|
+
// has ended (or one already failed as stranded). Not a live Pi turn
|
|
525
|
+
// boundary: sawToolUse stays false for it, and no block is emitted.
|
|
515
526
|
debug(`assistant message: tool_use ${block.id} already ${c.forwardedToolCallIds.has(block.id) ? "forwarded" : "dead"} — skipping duplicate`);
|
|
516
527
|
continue;
|
|
517
528
|
}
|
|
@@ -545,9 +556,9 @@ function appendMissingToolUsesFromAssistant(
|
|
|
545
556
|
c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
|
|
546
557
|
}
|
|
547
558
|
// Only while the stream is still live: the SDK's assistant yields carry the
|
|
548
|
-
// message_start placeholder usage
|
|
549
|
-
//
|
|
550
|
-
//
|
|
559
|
+
// message_start placeholder usage, and once the done event has delivered
|
|
560
|
+
// turnOutput to pi, overwriting its usage with those placeholders would
|
|
561
|
+
// corrupt the very figure message_delta got right.
|
|
551
562
|
if (assistantMsg.usage && c.turnOutput && c.currentPiStream) updateUsage(c.turnOutput, assistantMsg.usage, model, c);
|
|
552
563
|
return sawToolUse;
|
|
553
564
|
}
|
|
@@ -592,14 +603,14 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
|
|
|
592
603
|
updateTurnOutputModel(assistantMsg.model, c);
|
|
593
604
|
if (c.turnSawStreamEvent) {
|
|
594
605
|
// The SDK yields the completed assistant message BEFORE the stream's
|
|
595
|
-
// message_delta/message_stop on every tool-use turn (
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
// message_start placeholders
|
|
601
|
-
//
|
|
602
|
-
//
|
|
606
|
+
// message_delta/message_stop on every tool-use turn (the norm, not a
|
|
607
|
+
// fallback). Record any tool_use blocks the stream hasn't delivered yet,
|
|
608
|
+
// but do NOT end the pi stream here: message_delta, which arrives tens of
|
|
609
|
+
// ms later, carries the message's real output-token count, and
|
|
610
|
+
// message_stop is the normal turn end. Ending here freezes usage at the
|
|
611
|
+
// message_start placeholders. The grace timer force-ends the turn if the
|
|
612
|
+
// terminal events never arrive, so pi still gets to execute the tools and
|
|
613
|
+
// unblock the MCP handlers.
|
|
603
614
|
if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi, c)) {
|
|
604
615
|
c.turnSawToolCall = true;
|
|
605
616
|
scheduleToolUseTurnEnd(c, () => endToolUseTurn(c), "assistant-boundary");
|
|
@@ -609,11 +620,11 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
|
|
|
609
620
|
// The SDK yields the SAME assistant message more than once (per-block
|
|
610
621
|
// partial copies and the completed message share one id). With stream
|
|
611
622
|
// events, the streamed path already renders content and the duplicates are
|
|
612
|
-
// naturally ignored; on this no-stream-events path each
|
|
613
|
-
//
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
//
|
|
623
|
+
// naturally ignored; on this no-stream-events path, re-rendering each
|
|
624
|
+
// yield wholesale prints a rate-limited turn's "You've hit your weekly
|
|
625
|
+
// limit" twice. Same-message yields keep the turn's tracking (a reset
|
|
626
|
+
// mid-message would wipe live tool-claim state) and render only blocks not
|
|
627
|
+
// already rendered.
|
|
617
628
|
const sameMessage = typeof assistantMsg.id === "string" && assistantMsg.id.length > 0 && assistantMsg.id === c.currentMessageId;
|
|
618
629
|
if (!sameMessage) {
|
|
619
630
|
reapStaleQueuedResults(c);
|
|
@@ -627,9 +638,9 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
|
|
|
627
638
|
debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}${sameMessage ? " (same message re-yield)" : ""}`);
|
|
628
639
|
// Deduped against the WHOLE current turn, not just same-id re-yields: a
|
|
629
640
|
// rejected turn's synthesized error message ("You've hit your weekly limit")
|
|
630
|
-
// arrives as multiple assistant yields whose ids DIFFER or are absent
|
|
631
|
-
//
|
|
632
|
-
//
|
|
641
|
+
// arrives as multiple assistant yields whose ids DIFFER or are absent (one
|
|
642
|
+
// pi message, two byte-identical text blocks), so an id-keyed guard alone
|
|
643
|
+
// still renders it twice. A model legitimately
|
|
633
644
|
// producing two byte-identical full blocks in one turn is vanishingly rare;
|
|
634
645
|
// rendering such a duplicate once is the better failure mode.
|
|
635
646
|
const alreadyRendered = (type: string, content: string): boolean =>
|
|
@@ -659,14 +670,19 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
|
|
|
659
670
|
debug(`processAssistantMessage fallback: child-executed tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
|
|
660
671
|
continue;
|
|
661
672
|
}
|
|
673
|
+
if (!isPiDispatchable(block.name, customToolNameToPi)) {
|
|
674
|
+
if (isForeignMcpTool(block.name)) c.noteForeignMcpToolCall(block.id, block.name);
|
|
675
|
+
debug(`processAssistantMessage fallback: non-dispatchable tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
662
678
|
if (!c.turnBlocks.some((b: any) => b.type === "toolCall" && b.id === block.id)
|
|
663
679
|
&& (c.forwardedToolCallIds.has(block.id) || c.deadToolCallIds.has(block.id))) {
|
|
664
680
|
// A cross-turn replay of a call Pi already executed (or one whose
|
|
665
|
-
// handler was already failed as stranded). This
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
681
|
+
// handler was already failed as stranded). This is the path a
|
|
682
|
+
// duplicate dispatch takes: the completed-message yield lands in the
|
|
683
|
+
// callback AFTER a grace finalize already ended the call's turn, and
|
|
684
|
+
// per-message dedup cannot see across turns. Not recorded either — a
|
|
685
|
+
// forwarded call must not be claimable again.
|
|
670
686
|
debug(`processAssistantMessage fallback: tool_use ${block.id} already ${c.forwardedToolCallIds.has(block.id) ? "forwarded" : "dead"} — skipping duplicate`);
|
|
671
687
|
continue;
|
|
672
688
|
}
|
package/src/auth-presence.ts
CHANGED
|
@@ -85,7 +85,7 @@ function hasApiKeyHelper(configDir: string): boolean {
|
|
|
85
85
|
* Keychain, NOT in `.credentials.json`, so file-absence is NOT evidence of
|
|
86
86
|
* logged-out and we cannot cheaply/safely probe the Keychain here. On darwin,
|
|
87
87
|
* when no other signal is present, we default to credentialed=true — preserving
|
|
88
|
-
*
|
|
88
|
+
* an "always available" result for Mac subscription users. Honesty
|
|
89
89
|
* enforcement therefore applies on Linux/Windows, where `.credentials.json`
|
|
90
90
|
* existence is an observable, truthful proxy (empirically, `claude auth logout`
|
|
91
91
|
* unlinks it).
|
|
@@ -111,4 +111,3 @@ export function hasClaudeCredentials(
|
|
|
111
111
|
|
|
112
112
|
return false;
|
|
113
113
|
}
|
|
114
|
-
|
|
@@ -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-commands.ts
CHANGED
|
@@ -35,7 +35,7 @@ function showBridgeStatus(ctx: { ui: ExtensionUIContext; cwd?: string }): void {
|
|
|
35
35
|
].join("\n"), "info");
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
// Deterministic connector enumeration for the host app
|
|
38
|
+
// Deterministic connector enumeration for the host app. Reports the
|
|
39
39
|
// failure reason rather than an empty list, so "no connectors" and "could not
|
|
40
40
|
// check" stay distinguishable.
|
|
41
41
|
async function reportConnectorInventory(ctx: {
|
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";
|
|
@@ -23,7 +23,7 @@ export interface SessionState {
|
|
|
23
23
|
// never rewrite those opening messages — compact/tree-nav mutations set
|
|
24
24
|
// needsRebuild instead — so a component mismatch marks a FOREIGN
|
|
25
25
|
// conversation (a subagent-shaped query arriving while the parent is idle,
|
|
26
|
-
//
|
|
26
|
+
//) that must run as a clean one-shot without touching this
|
|
27
27
|
// record. The user component must always match; the assistant component is
|
|
28
28
|
// compared only when BOTH sides carry one, so a record stamped on turn 1
|
|
29
29
|
// (no assistant yet) still matches its own grown conversation and upgrades
|
|
@@ -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
|
|
|
@@ -169,10 +171,9 @@ export const INTEGRITY_CUSTOM_TYPE = "claude-bridge-integrity";
|
|
|
169
171
|
/**
|
|
170
172
|
* Persist a bridge integrity event into the pi session transcript.
|
|
171
173
|
*
|
|
172
|
-
* The diag log and a piUI toast both die with the machine or
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
* session alone impossible. A `CustomEntry` closes that gap the same way the
|
|
174
|
+
* The diag log and a piUI toast both die with the machine or render cycle, so
|
|
175
|
+
* they cannot support analysis from the session alone. A `CustomEntry` persists
|
|
176
|
+
* the record in the same way the
|
|
176
177
|
* connector-call audit does — persisted, never part of built context, never
|
|
177
178
|
* dispatchable by pi's agent loop. Payloads must stay compact metadata (ids,
|
|
178
179
|
* counts, tool names), never tool output.
|
|
@@ -242,7 +243,7 @@ export function reportToolResultMismatch(
|
|
|
242
243
|
queryCtx.reportedToolResultMismatch = true;
|
|
243
244
|
// The single choke point every mismatch path funnels through (abort,
|
|
244
245
|
// unmatched result, stream-idle, teardown). A context with no claim on
|
|
245
|
-
// the shared record (reentrant subagent or foreign one-shot,
|
|
246
|
+
// the shared record (reentrant subagent or foreign one-shot,)
|
|
246
247
|
// still gets the full diagnostics below, but its unresolved tool state is
|
|
247
248
|
// its own — marking the PARENT's record needsRebuild/forceRotate here
|
|
248
249
|
// would flush the parent's prompt cache for a query that never touched
|
|
@@ -285,6 +286,7 @@ export function reportToolResultMismatch(
|
|
|
285
286
|
unmatchedResultIds: progress.unmatchedResultIds,
|
|
286
287
|
});
|
|
287
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` +
|
|
288
290
|
`Claude bridge: tool result delivery interrupted during ${reason}; ` +
|
|
289
291
|
`delivered ${progress.deliveredCount}/${progress.expectedCount}, resolved ${progress.resolvedCount}/${progress.expectedCount}, ` +
|
|
290
292
|
`waiting=${progress.waitingCount}, queued=${progress.queuedCount}, unmatched=${progress.unmatchedResultCount}` +
|