@vanillagreen/pi-claude-bridge 3.2.2 → 4.0.1
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 +37 -90
- package/bundle/index.js +33818 -25171
- package/package.json +8 -7
- package/src/account-router.ts +5 -5
- package/src/assistant-stream.ts +94 -79
- package/src/auth-presence.ts +1 -2
- package/src/bridge-commands.ts +2 -2
- package/src/bridge-state.ts +7 -8
- package/src/config.ts +20 -12
- package/src/connector-audit.ts +7 -11
- package/src/connector-cache.ts +6 -7
- package/src/connector-inventory.ts +5 -7
- package/src/connector-runtime.ts +8 -8
- package/src/connectors.ts +34 -35
- package/src/consume-query.ts +3 -3
- package/src/convert.ts +1 -1
- package/src/debug.ts +2 -2
- package/src/index.ts +30 -31
- package/src/models.ts +5 -5
- package/src/query-options.ts +4 -4
- package/src/query-state.ts +38 -44
- package/src/query-teardown.ts +3 -3
- package/src/rate-limit.ts +2 -2
- package/src/request-lane.ts +1 -1
- package/src/session-persistence.ts +10 -10
- package/src/tool-mapping.ts +41 -8
- package/src/tool-pairing-audit.ts +5 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vanillagreen/pi-claude-bridge",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.1",
|
|
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": [
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
"extensions": [
|
|
16
16
|
"./bundle/index.js"
|
|
17
17
|
],
|
|
18
|
-
"image": "https://raw.githubusercontent.com/vanillagreencom/
|
|
18
|
+
"image": "https://raw.githubusercontent.com/vanillagreencom/kendex/main/pi-extensions/pi-claude-bridge/assets/bridge-demo.png"
|
|
19
19
|
},
|
|
20
|
-
"
|
|
20
|
+
"kendex": {
|
|
21
21
|
"extensionManager": {
|
|
22
22
|
"displayName": "Pi Claude",
|
|
23
23
|
"settings": [
|
|
@@ -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"
|
|
@@ -154,6 +154,7 @@
|
|
|
154
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
155
|
"prepack": "npm run build",
|
|
156
156
|
"test:unit": "node --import tsx --test tests/unit-*.mjs",
|
|
157
|
+
"test:ci": "npm run build && npm run test:unit",
|
|
157
158
|
"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
159
|
"test:usage": "tests/usage-test.sh",
|
|
159
160
|
"typecheck": "tsc --noEmit"
|
|
@@ -168,12 +169,12 @@
|
|
|
168
169
|
],
|
|
169
170
|
"repository": {
|
|
170
171
|
"type": "git",
|
|
171
|
-
"url": "git+https://github.com/vanillagreencom/
|
|
172
|
+
"url": "git+https://github.com/vanillagreencom/kendex.git",
|
|
172
173
|
"directory": "pi-extensions/pi-claude-bridge"
|
|
173
174
|
},
|
|
174
|
-
"homepage": "https://github.com/vanillagreencom/
|
|
175
|
+
"homepage": "https://github.com/vanillagreencom/kendex/tree/main/pi-extensions/pi-claude-bridge",
|
|
175
176
|
"bugs": {
|
|
176
|
-
"url": "https://github.com/vanillagreencom/
|
|
177
|
+
"url": "https://github.com/vanillagreencom/kendex/issues"
|
|
177
178
|
},
|
|
178
179
|
"author": "vanillagreen",
|
|
179
180
|
"publishConfig": {
|
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";
|
|
@@ -13,8 +13,8 @@ import { join } from "node:path";
|
|
|
13
13
|
import { debug } from "./debug.js";
|
|
14
14
|
import { resetTimestampMs } from "./rate-limit.js";
|
|
15
15
|
|
|
16
|
-
export const CLAUDE_ACCOUNT_ROUTER_SYMBOL = Symbol.for("
|
|
17
|
-
export const CLAUDE_BRIDGE_ACCOUNT_HOST_SYMBOL = Symbol.for("
|
|
16
|
+
export const CLAUDE_ACCOUNT_ROUTER_SYMBOL = Symbol.for("kendex.pi.claude-account-router.v1");
|
|
17
|
+
export const CLAUDE_BRIDGE_ACCOUNT_HOST_SYMBOL = Symbol.for("kendex.pi.claude-bridge.account-host.v1");
|
|
18
18
|
|
|
19
19
|
export interface ClaudeAccountRoute {
|
|
20
20
|
profileId: string;
|
|
@@ -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();
|
|
@@ -180,14 +178,14 @@ export const FINALIZE_MAX_REARMS = 3;
|
|
|
180
178
|
* stream events never arrived (the grace-timer action armed by an MCP handler
|
|
181
179
|
* invocation — see scheduleToolUseTurnEnd).
|
|
182
180
|
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* FINALIZE_MAX_REARMS extra grace periods for the rest
|
|
181
|
+
* The producer is pi's steer draining (tool result and drained steer arrive
|
|
182
|
+
* in one provider call): the NEXT tool turn's tool_use streams in, the SDK
|
|
183
|
+
* invokes the MCP handler — and neither terminal event ever arrives. The
|
|
184
|
+
* invocation itself proves the assistant turn is committed, so end the pi
|
|
185
|
+
* stream like the `message_stop` path — with this handler's schema-validated
|
|
186
|
+
* arguments, never a partial parse — after settling every sibling whose
|
|
187
|
+
* handler has fired and giving a merely-lagging stream up to
|
|
188
|
+
* FINALIZE_MAX_REARMS extra grace periods for the rest.
|
|
191
189
|
*
|
|
192
190
|
* The dead-stream guard is a backstop: the grace timer's own stream-identity
|
|
193
191
|
* check means this normally never runs after the turn ended. The primary
|
|
@@ -204,8 +202,8 @@ export function finalizeToolUseTurnFromMcpInvocation(
|
|
|
204
202
|
// The turn ended without this call. An unforwarded handler here can never
|
|
205
203
|
// be answered — the yield that could have replayed its call was consumed
|
|
206
204
|
// against a null stream, and the dead-mark below suppresses any replay
|
|
207
|
-
// that has not happened yet. Failing it
|
|
208
|
-
//
|
|
205
|
+
// that has not happened yet. Failing it here turns what would be a
|
|
206
|
+
// session-long deadlock into one retryable error.
|
|
209
207
|
if (failStrandedToolCall(queryCtx, toolCallId)) {
|
|
210
208
|
debug(`mcp handler: ${toolName} [${toolCallId}] stranded — turn ended before its call reached Pi; resolved with error`);
|
|
211
209
|
diagDump("tool_handler_stranded", { toolCallId, toolName, site: "finalize-no-stream" });
|
|
@@ -220,9 +218,9 @@ export function finalizeToolUseTurnFromMcpInvocation(
|
|
|
220
218
|
// Stream ended before content_block_stop. The SDK invoked this handler
|
|
221
219
|
// with the COMPLETE schema-validated input, so the handler's copy is
|
|
222
220
|
// authoritative — the streamed partial JSON is by definition behind it.
|
|
223
|
-
// Settling from the partial
|
|
224
|
-
//
|
|
225
|
-
//
|
|
221
|
+
// Settling from the partial forwards `{}`-argument calls that Pi then
|
|
222
|
+
// executes and errors on, a divergence the synthesize branch below
|
|
223
|
+
// cannot have.
|
|
226
224
|
block.arguments = mappedArgs;
|
|
227
225
|
queryCtx.updateToolCallArgs(block.id, block.arguments);
|
|
228
226
|
delete block.partialJson;
|
|
@@ -230,13 +228,13 @@ export function finalizeToolUseTurnFromMcpInvocation(
|
|
|
230
228
|
queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: queryCtx.turnOutput });
|
|
231
229
|
}
|
|
232
230
|
} 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.
|
|
231
|
+
// Pi already executed this call in a turn that has ended (its result
|
|
232
|
+
// arrives or sits parked), or the handler was already failed — either
|
|
233
|
+
// way a second dispatch is the one outcome worse than waiting. Do NOT
|
|
234
|
+
// return: this firing consumed the stream's only grace timer, so the
|
|
235
|
+
// current turn's own blocks must still be settled and the turn still
|
|
236
|
+
// ended below, or a turn that loses its terminal events afterwards has
|
|
237
|
+
// no backstop left and Pi sits busy until manual abort.
|
|
240
238
|
debug(`mcp handler: ${toolName} [${toolCallId}] already ${queryCtx.forwardedToolCallIds.has(toolCallId) ? "forwarded" : "dead"} — not re-emitting`);
|
|
241
239
|
} else {
|
|
242
240
|
// The invocation can arrive before the tool_use is streamed at all
|
|
@@ -314,13 +312,14 @@ export function processStreamEvent(
|
|
|
314
312
|
}
|
|
315
313
|
|
|
316
314
|
if (event?.type === "message_start") {
|
|
317
|
-
// The child moving to
|
|
318
|
-
//
|
|
315
|
+
// The child moving on to another message is where a still-queued result
|
|
316
|
+
// would start poisoning mismatch reports: park it, consumable by a late
|
|
317
|
+
// handler (see reapStaleQueuedResults).
|
|
319
318
|
reapStaleQueuedResults(c);
|
|
320
319
|
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).
|
|
320
|
+
// Another child message begins: bank what the previous one billed before
|
|
321
|
+
// its counters are replaced. No-op on the turn's first, and no-op if this
|
|
322
|
+
// same message was already declared (see beginChildMessage).
|
|
324
323
|
c.beginChildMessage(event.message?.id);
|
|
325
324
|
updateTurnOutputModel(event.message?.model, c);
|
|
326
325
|
if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model, c);
|
|
@@ -330,7 +329,7 @@ export function processStreamEvent(
|
|
|
330
329
|
if (event?.type === "content_block_start") {
|
|
331
330
|
c.turnSawStreamEvent = true;
|
|
332
331
|
ensureTurnStarted(c);
|
|
333
|
-
//
|
|
332
|
+
// This block owns its index from here on, so release any child-executed
|
|
334
333
|
// or suppressed claim on it. Belt-and-braces against a missed
|
|
335
334
|
// message_start: without this a stale index could silently swallow a later
|
|
336
335
|
// text block's deltas.
|
|
@@ -344,6 +343,12 @@ export function processStreamEvent(
|
|
|
344
343
|
debug(`processStreamEvent: child-executed tool ${event.content_block.name} [${event.content_block.id}] — not mirrored as a Pi tool call`);
|
|
345
344
|
return;
|
|
346
345
|
}
|
|
346
|
+
if (event.content_block?.type === "tool_use" && !isPiDispatchable(event.content_block.name, customToolNameToPi)) {
|
|
347
|
+
c.suppressedStreamIndexes.add(event.index);
|
|
348
|
+
if (isForeignMcpTool(event.content_block.name)) c.markOutputCommitted();
|
|
349
|
+
debug(`processStreamEvent: non-dispatchable tool ${event.content_block.name} [${event.content_block.id}] — not mirrored as a Pi tool call`);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
347
352
|
if (event.content_block?.type === "text") {
|
|
348
353
|
c.turnBlocks.push({ type: "text", text: "", index: event.index });
|
|
349
354
|
c.currentPiStream!.push({ type: "text_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
|
|
@@ -355,7 +360,7 @@ export function processStreamEvent(
|
|
|
355
360
|
if (typeof streamedId === "string" && (c.forwardedToolCallIds.has(streamedId) || c.deadToolCallIds.has(streamedId))) {
|
|
356
361
|
// A lagging stream replaying a call Pi already executed — or one whose
|
|
357
362
|
// handler was already failed as stranded — into a later turn. Mirroring
|
|
358
|
-
// it would make Pi dispatch it a second time
|
|
363
|
+
// it would make Pi dispatch it a second time.
|
|
359
364
|
c.suppressedStreamIndexes.add(event.index);
|
|
360
365
|
debug(`processStreamEvent: tool_use ${streamedId} already ${c.forwardedToolCallIds.has(streamedId) ? "forwarded" : "dead"} — suppressing duplicate stream block`);
|
|
361
366
|
return;
|
|
@@ -364,7 +369,7 @@ export function processStreamEvent(
|
|
|
364
369
|
// Same turn, same id: the completed-message yield beat the stream (its
|
|
365
370
|
// block is already recorded with complete arguments). A second
|
|
366
371
|
// partialJson copy would ship the id twice in one done message — and
|
|
367
|
-
// if its stop never arrives, ship it truncated
|
|
372
|
+
// if its stop never arrives, ship it truncated.
|
|
368
373
|
c.suppressedStreamIndexes.add(event.index);
|
|
369
374
|
debug(`processStreamEvent: tool_use ${streamedId} already recorded in this turn — suppressing duplicate stream block`);
|
|
370
375
|
return;
|
|
@@ -494,7 +499,7 @@ function appendMissingToolUsesFromAssistant(
|
|
|
494
499
|
// array of a turnOutput that endToolUseTurn already handed Pi BY REFERENCE
|
|
495
500
|
// in its done event, so a push here appends calls into a delivered message
|
|
496
501
|
// behind Pi's back — whether Pi's dispatch enumerates before or after the
|
|
497
|
-
// push is a microtask race
|
|
502
|
+
// push is a microtask race.
|
|
498
503
|
const streamLive = Boolean(c.currentPiStream && c.turnOutput);
|
|
499
504
|
let sawToolUse = false;
|
|
500
505
|
for (const block of assistantMsg.content) {
|
|
@@ -507,11 +512,16 @@ function appendMissingToolUsesFromAssistant(
|
|
|
507
512
|
debug(`assistant message: child-executed tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
|
|
508
513
|
continue;
|
|
509
514
|
}
|
|
515
|
+
if (!isPiDispatchable(block.name, customToolNameToPi)) {
|
|
516
|
+
if (isForeignMcpTool(block.name)) c.markOutputCommitted();
|
|
517
|
+
debug(`assistant message: non-dispatchable tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
510
520
|
const existingIdx = c.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === block.id);
|
|
511
521
|
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
|
|
522
|
+
// Completed-message replay of a call Pi already executed in a turn that
|
|
523
|
+
// has ended (or one already failed as stranded). Not a live Pi turn
|
|
524
|
+
// boundary: sawToolUse stays false for it, and no block is emitted.
|
|
515
525
|
debug(`assistant message: tool_use ${block.id} already ${c.forwardedToolCallIds.has(block.id) ? "forwarded" : "dead"} — skipping duplicate`);
|
|
516
526
|
continue;
|
|
517
527
|
}
|
|
@@ -545,9 +555,9 @@ function appendMissingToolUsesFromAssistant(
|
|
|
545
555
|
c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
|
|
546
556
|
}
|
|
547
557
|
// Only while the stream is still live: the SDK's assistant yields carry the
|
|
548
|
-
// message_start placeholder usage
|
|
549
|
-
//
|
|
550
|
-
//
|
|
558
|
+
// message_start placeholder usage, and once the done event has delivered
|
|
559
|
+
// turnOutput to pi, overwriting its usage with those placeholders would
|
|
560
|
+
// corrupt the very figure message_delta got right.
|
|
551
561
|
if (assistantMsg.usage && c.turnOutput && c.currentPiStream) updateUsage(c.turnOutput, assistantMsg.usage, model, c);
|
|
552
562
|
return sawToolUse;
|
|
553
563
|
}
|
|
@@ -592,14 +602,14 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
|
|
|
592
602
|
updateTurnOutputModel(assistantMsg.model, c);
|
|
593
603
|
if (c.turnSawStreamEvent) {
|
|
594
604
|
// 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
|
-
//
|
|
605
|
+
// message_delta/message_stop on every tool-use turn (the norm, not a
|
|
606
|
+
// fallback). Record any tool_use blocks the stream hasn't delivered yet,
|
|
607
|
+
// but do NOT end the pi stream here: message_delta, which arrives tens of
|
|
608
|
+
// ms later, carries the message's real output-token count, and
|
|
609
|
+
// message_stop is the normal turn end. Ending here freezes usage at the
|
|
610
|
+
// message_start placeholders. The grace timer force-ends the turn if the
|
|
611
|
+
// terminal events never arrive, so pi still gets to execute the tools and
|
|
612
|
+
// unblock the MCP handlers.
|
|
603
613
|
if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi, c)) {
|
|
604
614
|
c.turnSawToolCall = true;
|
|
605
615
|
scheduleToolUseTurnEnd(c, () => endToolUseTurn(c), "assistant-boundary");
|
|
@@ -609,11 +619,11 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
|
|
|
609
619
|
// The SDK yields the SAME assistant message more than once (per-block
|
|
610
620
|
// partial copies and the completed message share one id). With stream
|
|
611
621
|
// 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
|
-
//
|
|
622
|
+
// naturally ignored; on this no-stream-events path, re-rendering each
|
|
623
|
+
// yield wholesale prints a rate-limited turn's "You've hit your weekly
|
|
624
|
+
// limit" twice. Same-message yields keep the turn's tracking (a reset
|
|
625
|
+
// mid-message would wipe live tool-claim state) and render only blocks not
|
|
626
|
+
// already rendered.
|
|
617
627
|
const sameMessage = typeof assistantMsg.id === "string" && assistantMsg.id.length > 0 && assistantMsg.id === c.currentMessageId;
|
|
618
628
|
if (!sameMessage) {
|
|
619
629
|
reapStaleQueuedResults(c);
|
|
@@ -627,9 +637,9 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
|
|
|
627
637
|
debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}${sameMessage ? " (same message re-yield)" : ""}`);
|
|
628
638
|
// Deduped against the WHOLE current turn, not just same-id re-yields: a
|
|
629
639
|
// 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
|
-
//
|
|
640
|
+
// arrives as multiple assistant yields whose ids DIFFER or are absent (one
|
|
641
|
+
// pi message, two byte-identical text blocks), so an id-keyed guard alone
|
|
642
|
+
// still renders it twice. A model legitimately
|
|
633
643
|
// producing two byte-identical full blocks in one turn is vanishingly rare;
|
|
634
644
|
// rendering such a duplicate once is the better failure mode.
|
|
635
645
|
const alreadyRendered = (type: string, content: string): boolean =>
|
|
@@ -659,14 +669,19 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
|
|
|
659
669
|
debug(`processAssistantMessage fallback: child-executed tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
|
|
660
670
|
continue;
|
|
661
671
|
}
|
|
672
|
+
if (!isPiDispatchable(block.name, customToolNameToPi)) {
|
|
673
|
+
if (isForeignMcpTool(block.name)) c.markOutputCommitted();
|
|
674
|
+
debug(`processAssistantMessage fallback: non-dispatchable tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
662
677
|
if (!c.turnBlocks.some((b: any) => b.type === "toolCall" && b.id === block.id)
|
|
663
678
|
&& (c.forwardedToolCallIds.has(block.id) || c.deadToolCallIds.has(block.id))) {
|
|
664
679
|
// 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
|
-
//
|
|
680
|
+
// handler was already failed as stranded). This is the path a
|
|
681
|
+
// duplicate dispatch takes: the completed-message yield lands in the
|
|
682
|
+
// callback AFTER a grace finalize already ended the call's turn, and
|
|
683
|
+
// per-message dedup cannot see across turns. Not recorded either — a
|
|
684
|
+
// forwarded call must not be claimable again.
|
|
670
685
|
debug(`processAssistantMessage fallback: tool_use ${block.id} already ${c.forwardedToolCallIds.has(block.id) ? "forwarded" : "dead"} — skipping duplicate`);
|
|
671
686
|
continue;
|
|
672
687
|
}
|
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
|
-
|
package/src/bridge-commands.ts
CHANGED
|
@@ -17,7 +17,7 @@ function commandCwd(ctx: unknown): string {
|
|
|
17
17
|
|
|
18
18
|
async function tryOpenExtensionManagerSettings(ctx: { ui: ExtensionUIContext }): Promise<boolean> {
|
|
19
19
|
const host = globalThis as unknown as Record<PropertyKey, unknown>;
|
|
20
|
-
const openQuickSettings = host[Symbol.for("
|
|
20
|
+
const openQuickSettings = host[Symbol.for("kendex.pi.extension-manager.open-quick-settings")];
|
|
21
21
|
if (typeof openQuickSettings !== "function") return false;
|
|
22
22
|
try {
|
|
23
23
|
await (openQuickSettings as (ctx: unknown, hint?: string) => Promise<void>)(ctx, "@vanillagreen/pi-claude-bridge");
|
|
@@ -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
|
@@ -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
|
|
@@ -56,7 +56,7 @@ interface SharedSessionLaneStoreV1 {
|
|
|
56
56
|
sessions: Map<string, SessionState | null>;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
const SHARED_SESSION_LANES_SYMBOL = Symbol.for("
|
|
59
|
+
const SHARED_SESSION_LANES_SYMBOL = Symbol.for("kendex.pi.claude-bridge.shared-session-lanes.v1");
|
|
60
60
|
|
|
61
61
|
function sharedSessionLaneStore(): SharedSessionLaneStoreV1 {
|
|
62
62
|
const host = globalThis as Record<symbol, unknown>;
|
|
@@ -107,7 +107,7 @@ export function clearSharedSessionLanes(): void {
|
|
|
107
107
|
// registry above because session_start and session_shutdown can reach different
|
|
108
108
|
// module instances (`/reload` mid-session, a child agent's own copy) and both
|
|
109
109
|
// must see the same entry.
|
|
110
|
-
const STARTED_LANES_SYMBOL = Symbol.for("
|
|
110
|
+
const STARTED_LANES_SYMBOL = Symbol.for("kendex.pi.claude-bridge.started-lanes.v1");
|
|
111
111
|
|
|
112
112
|
function startedLaneStore(): WeakMap<object, string> {
|
|
113
113
|
const host = globalThis as Record<symbol, unknown>;
|
|
@@ -169,10 +169,9 @@ export const INTEGRITY_CUSTOM_TYPE = "claude-bridge-integrity";
|
|
|
169
169
|
/**
|
|
170
170
|
* Persist a bridge integrity event into the pi session transcript.
|
|
171
171
|
*
|
|
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
|
|
172
|
+
* The diag log and a piUI toast both die with the machine or render cycle, so
|
|
173
|
+
* they cannot support analysis from the session alone. A `CustomEntry` persists
|
|
174
|
+
* the record in the same way the
|
|
176
175
|
* connector-call audit does — persisted, never part of built context, never
|
|
177
176
|
* dispatchable by pi's agent loop. Payloads must stay compact metadata (ids,
|
|
178
177
|
* counts, tool names), never tool output.
|
|
@@ -242,7 +241,7 @@ export function reportToolResultMismatch(
|
|
|
242
241
|
queryCtx.reportedToolResultMismatch = true;
|
|
243
242
|
// The single choke point every mismatch path funnels through (abort,
|
|
244
243
|
// unmatched result, stream-idle, teardown). A context with no claim on
|
|
245
|
-
// the shared record (reentrant subagent or foreign one-shot,
|
|
244
|
+
// the shared record (reentrant subagent or foreign one-shot,)
|
|
246
245
|
// still gets the full diagnostics below, but its unresolved tool state is
|
|
247
246
|
// its own — marking the PARENT's record needsRebuild/forceRotate here
|
|
248
247
|
// would flush the parent's prompt cache for a query that never touched
|
package/src/config.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// User-facing extension config. Legacy config is loaded from
|
|
2
|
-
// ~/.pi/agent/claude-bridge.json and .pi/claude-bridge.json.
|
|
2
|
+
// ~/.pi/agent/claude-bridge.json and .pi/claude-bridge.json. kendex extension
|
|
3
3
|
// manager config is loaded from settings.json and overrides legacy files in
|
|
4
4
|
// normal Pi sessions. Isolated embedding hosts consume only the authoritative
|
|
5
5
|
// user claude-bridge.json and never read extension-manager settings.
|
|
@@ -13,11 +13,11 @@ import { debug } from "./debug.js";
|
|
|
13
13
|
export const PACKAGE_ID = "@vanillagreen/pi-claude-bridge";
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* Registry the
|
|
16
|
+
* Registry the kendex extension manager reads to display the value an
|
|
17
17
|
* extension actually resolves for a manifest key, for extensions whose config
|
|
18
18
|
* has channels the manager does not own. Keyed by package id.
|
|
19
19
|
*/
|
|
20
|
-
const EXTERNAL_CONFIG_RESOLVER_SYMBOL = Symbol.for("
|
|
20
|
+
const EXTERNAL_CONFIG_RESOLVER_SYMBOL = Symbol.for("kendex.pi.extension-config-resolver");
|
|
21
21
|
|
|
22
22
|
export type BridgeEffortLevel = "low" | "medium" | "high" | "xhigh" | "max";
|
|
23
23
|
|
|
@@ -44,7 +44,7 @@ export interface Config {
|
|
|
44
44
|
modelEffortOverrides?: Record<string, BridgeEffortLevel>;
|
|
45
45
|
/**
|
|
46
46
|
* Verbatim override for the child's filesystem setting sources.
|
|
47
|
-
* Defaults (see settingSourcesForQuery in connectors.ts): connectors
|
|
47
|
+
* Defaults (see settingSourcesForQuery in connectors.ts) : connectors
|
|
48
48
|
* mode uses ["user"] only, so repo-controlled `.claude/settings.json`
|
|
49
49
|
* (project/local scope) cannot inject `env`/`apiKeyHelper` into the
|
|
50
50
|
* child. Listing "project"/"local" here reopens that surface — only do
|
|
@@ -95,17 +95,25 @@ function expandHome(input: string): string {
|
|
|
95
95
|
return input;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/** Root-anchored as `crates/core/src/harness/pi.rs::pi_root_is_absolute_for`
|
|
99
|
+
* means it, which `isAbsolute` is not: it calls a driveless `\root` absolute
|
|
100
|
+
* where the renderer does not, putting the two on different roots. Hoisted, so
|
|
101
|
+
* a circular import cannot reach it inside a temporal dead zone. */
|
|
102
|
+
function rootAnchored(path: string, windows: boolean): boolean { return windows ? /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+)/.test(path) : path.startsWith("/"); }
|
|
103
|
+
|
|
98
104
|
/**
|
|
99
|
-
* The Pi agent config dir: `PI_CODING_AGENT_DIR` when
|
|
100
|
-
*
|
|
101
|
-
* so a host app that owns the agent dir owns
|
|
105
|
+
* The Pi agent config dir: `PI_CODING_AGENT_DIR` when it names a root-anchored
|
|
106
|
+
* path, else `~/.pi/agent`. Every bridge default routes through this function
|
|
107
|
+
* so a host app that owns the agent dir owns
|
|
108
|
+
* those paths too.
|
|
102
109
|
*/
|
|
103
110
|
export function piUserDir(): string {
|
|
104
|
-
|
|
111
|
+
const override = expandHome(process.env.PI_CODING_AGENT_DIR?.trim() || "");
|
|
112
|
+
return resolve(rootAnchored(override, process.platform === "win32") ? override : expandHome("~/.pi/agent"));
|
|
105
113
|
}
|
|
106
114
|
|
|
107
115
|
/**
|
|
108
|
-
* Isolated mode (`CLAUDE_BRIDGE_ISOLATED=1`): a host app embedding the bridge
|
|
116
|
+
* Isolated mode (`CLAUDE_BRIDGE_ISOLATED=1`) : a host app embedding the bridge
|
|
109
117
|
* declares that nothing outside its explicitly configured dirs may be read.
|
|
110
118
|
* Disables every cwd/home discovery fallback — all AGENTS.md discovery,
|
|
111
119
|
* extension-manager settings, project `.pi/` settings + claude-bridge.json,
|
|
@@ -138,14 +146,14 @@ function projectSettingsPath(cwd: string): string {
|
|
|
138
146
|
while (true) {
|
|
139
147
|
const candidate = join(current, ".pi", "settings.json");
|
|
140
148
|
if (existsSync(candidate)) return candidate;
|
|
141
|
-
if (existsSync(join(current, ".pi")) || existsSync(join(current, ".git")) || existsSync(join(current, ".
|
|
149
|
+
if (existsSync(join(current, ".pi")) || existsSync(join(current, ".git")) || existsSync(join(current, ".kendex-lock.json"))) return candidate;
|
|
142
150
|
const parent = dirname(current);
|
|
143
151
|
if (parent === current) return join(resolve(cwd), ".pi", "settings.json");
|
|
144
152
|
current = parent;
|
|
145
153
|
}
|
|
146
154
|
}
|
|
147
155
|
|
|
148
|
-
const PROJECT_TRUST_SYMBOL = Symbol.for("
|
|
156
|
+
const PROJECT_TRUST_SYMBOL = Symbol.for("kendex.pi.project-trust");
|
|
149
157
|
|
|
150
158
|
interface ProjectTrustRegistry {
|
|
151
159
|
projectSettings?: Map<string, boolean>;
|
|
@@ -212,7 +220,7 @@ function readManagerConfig(cwd: string): SettingsRecord {
|
|
|
212
220
|
if (!existsSync(path)) continue;
|
|
213
221
|
try {
|
|
214
222
|
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
215
|
-
const configRoot = asRecord(asRecord(asRecord(parsed?.
|
|
223
|
+
const configRoot = asRecord(asRecord(asRecord(parsed?.kendex)?.extensionManager)?.config);
|
|
216
224
|
const config = asRecord(configRoot?.[PACKAGE_ID]);
|
|
217
225
|
if (config) mergeDeep(merged, path === userPath ? config : withoutUserScopeOnlyKeys(config));
|
|
218
226
|
} catch (error) {
|
package/src/connector-audit.ts
CHANGED
|
@@ -2,11 +2,9 @@
|
|
|
2
2
|
//
|
|
3
3
|
// A claude.ai connector tool runs INSIDE the child, on the child's own MCP
|
|
4
4
|
// servers, and is deliberately never mirrored into the Pi stream (see
|
|
5
|
-
// isChildExecutedTool).
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// the call happened at all, so "did it really look that up?" could only be
|
|
9
|
-
// answered from the child's own transcript.
|
|
5
|
+
// isChildExecutedTool). Mirroring would write `Tool <name> not found` into the
|
|
6
|
+
// transcript for a successful call. Not mirroring leaves the Pi session with no
|
|
7
|
+
// record that the call happened, so the audit entry records it without dispatch.
|
|
10
8
|
//
|
|
11
9
|
// A pi `CustomEntry` closes that gap without reintroducing the bug: it is
|
|
12
10
|
// persisted in the session file, is NOT a content block, and is documented as
|
|
@@ -91,17 +89,15 @@ let auditSink: ConnectorCallAuditSink | undefined;
|
|
|
91
89
|
* Install (or clear, with `undefined`) a host sink for connector-call records.
|
|
92
90
|
*
|
|
93
91
|
* **The sink ADDS a destination, it never replaces `appendEntry`.** A host that
|
|
94
|
-
* drives real `AgentSession`s gets
|
|
95
|
-
* replacing sink would
|
|
96
|
-
*
|
|
97
|
-
* session-backed, and 122 of their app-chat session files carry the bridge's
|
|
98
|
-
* `claude-bridge-session` markers). A host with both a session and a sink has
|
|
92
|
+
* drives real `AgentSession`s gets transcript-local entries automatically. A
|
|
93
|
+
* replacing sink would remove those entries and make the session record
|
|
94
|
+
* incomplete. A host with both a session and a sink has
|
|
99
95
|
* asked for both and gets both.
|
|
100
96
|
*
|
|
101
97
|
* It exists because the OTHER embedding shape gets nothing at all: drovr loads
|
|
102
98
|
* the bundle through a throwaway resource loader over
|
|
103
99
|
* `createAgentSessionServices` with no session, so `extensionApi` is undefined
|
|
104
|
-
* and every record it appended went nowhere (
|
|
100
|
+
* and every record it appended went nowhere (measured live). The
|
|
105
101
|
* sink is the seam such a host can reach without one.
|
|
106
102
|
*
|
|
107
103
|
* A callback rather than another `Symbol.for` global on purpose: the bundle
|