@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-claude-bridge",
3
- "version": "3.2.2",
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/vstack/main/pi-extensions/pi-claude-bridge/assets/bridge-demo.png"
18
+ "image": "https://raw.githubusercontent.com/vanillagreencom/kendex/main/pi-extensions/pi-claude-bridge/assets/bridge-demo.png"
19
19
  },
20
- "vstack": {
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.220",
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/vstack.git",
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/vstack/tree/main/pi-extensions/pi-claude-bridge",
175
+ "homepage": "https://github.com/vanillagreencom/kendex/tree/main/pi-extensions/pi-claude-bridge",
175
176
  "bugs": {
176
- "url": "https://github.com/vanillagreencom/vstack/issues"
177
+ "url": "https://github.com/vanillagreencom/kendex/issues"
177
178
  },
178
179
  "author": "vanillagreen",
179
180
  "publishConfig": {
@@ -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. Incompatible future shapes must use a new
8
- // symbol/version instead of mutating this contract in place.
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("vstack.pi.claude-account-router.v1");
17
- export const CLAUDE_BRIDGE_ACCOUNT_HOST_SYMBOL = Symbol.for("vstack.pi.claude-bridge.account-host.v1");
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 a previously issued profile id back to its route. The bridge
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
@@ -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 are replaced, not added. What is
15
- // added is every child message already finished in this Pi turn — see
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 (measured:
74
- // handler invoked ~45ms before message_delta on every tool-use turn). Ending
75
- // the pi stream at either of those early signals therefore froze usage at the
76
- // message_start placeholder values, which is why pi sessions recorded 1–7
77
- // output tokens per tool-use turn while the final text turn recorded hundreds
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 now ends at message_stop, exactly like the streamed-text case,
81
- // and the early signals only ARM a grace timer. The timer is the deadlock
82
- // backstop for the one observed case where the terminal events never arrive
83
- // (pi 0.80 steer draining): pi cannot execute tools before the stream ends, and
84
- // the MCP handler cannot resolve before pi executes, so a stream that has gone
85
- // silent must be ended by force — just 1.5s later instead of immediately.
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 (vstack#1469): a block still
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 (vstack#1469).
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 the 2026-08-17
150
- * deadlock session three of five parallel handlers fired after this point
151
- * (vstack#1469). Parked results stay consumable through
152
- * takeQueuedOrParkedResult; one that is never consumed belongs to a call the
153
- * SDK abandoned client-side (permission denial), which is exactly what the
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
- * Observed with Claude Code under pi 0.80's steer draining (tool result and
184
- * drained steer arrive in one provider call): the NEXT tool turn's tool_use
185
- * streams in, the SDK invokes the MCP handler — and neither terminal event
186
- * ever arrives. The invocation itself proves the assistant turn is committed,
187
- * so end the pi stream like the `message_stop` path — with this handler's
188
- * schema-validated arguments, never a partial parse — after settling every
189
- * sibling whose handler has fired and giving a merely-lagging stream up to
190
- * FINALIZE_MAX_REARMS extra grace periods for the rest (vstack#1469).
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 now is what turns the observed
208
- // multi-hour session deadlock into one retryable error (vstack#1469).
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 is what forwarded `{}`-argument calls that
224
- // Pi then executed and errored (vstack#1469), exactly the divergence
225
- // the synthesize branch below never had.
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 an earlier turn (its result arrives or
234
- // sits parked), or the handler was already failed — either way a second
235
- // dispatch is the one outcome worse than waiting. Do NOT return: this
236
- // firing consumed the stream's only grace timer, so the current turn's own
237
- // blocks must still be settled and the turn still ended below, or a turn
238
- // that loses its terminal events afterwards has no backstop left and Pi
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 a new message proves every result for the previous
318
- // one reached it; anything still queued can never be consumed.
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
- // A new child message begins: bank what the previous one billed before its
322
- // counters are replaced. No-op on the turn's first, and no-op if this same
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
- // A new block owns this index from here on, so release any child-executed
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 (vstack#1469).
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 (vstack#1469).
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 (vstack#1469).
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 an earlier
513
- // turn (or one already failed as stranded). Not a live Pi turn boundary:
514
- // sawToolUse stays false for it, and no block is emitted (vstack#1469).
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 (output ≈ 1–7), and once the done event has
549
- // delivered turnOutput to pi, overwriting its usage with those placeholders
550
- // would corrupt the very figure message_delta got right.
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 (measured this is
596
- // the norm, not a fallback). Record any tool_use blocks the stream hasn't
597
- // delivered yet, but do NOT end the pi stream here: message_delta, which
598
- // arrives tens of ms later, carries the message's real output-token count,
599
- // and message_stop is the normal turn end. Ending here froze usage at the
600
- // message_start placeholders (1–7 output tokens per tool turn). The grace
601
- // timer force-ends the turn if the terminal events never arrive, so pi
602
- // still gets to execute the tools and unblock the MCP handlers.
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 yield used to be
613
- // re-rendered wholesale a rate-limited turn printed "You've hit your
614
- // weekly limit" twice. Same-message yields keep the turn's tracking (a
615
- // reset mid-message would wipe live tool-claim state) and render only
616
- // blocks not already rendered.
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
- // (measured 2026-07-28: one pi message, two byte-identical text blocks), so
632
- // an id-keyed guard alone still rendered it twice. A model legitimately
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 exact path produced
666
- // the observed duplicate dispatches: the completed-message yield lands
667
- // in the callback AFTER a grace finalize already ended the call's
668
- // turn, and per-message dedup cannot see across turns (vstack#1469).
669
- // Not recorded either — a forwarded call must not be claimable again.
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
  }
@@ -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
- * the pre-fix "always available" behavior for Mac subscription users. Honesty
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
-
@@ -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("vstack.pi.extension-manager.open-quick-settings")];
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 (vstack#838). Reports the
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: {
@@ -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
- // vstack#1001) that must run as a clean one-shot without touching this
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("vstack.pi.claude-bridge.shared-session-lanes.v1");
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("vstack.pi.claude-bridge.started-lanes.v1");
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 the render cycle:
173
- * the 2026-07-28 post-mortem found `Error: Claude bridge: …` messages that were
174
- * SHOWN but existed nowhere in the pi session file, making analysis from the
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, vstack#1001)
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. vstack extension
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 vstack extension manager reads to display the value an
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("vstack.pi.extension-config-resolver");
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 set, else `~/.pi/agent`.
100
- * Every bridge default that used to hardcode `~/.pi/agent` routes through this
101
- * so a host app that owns the agent dir owns those paths too.
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
- return resolve(expandHome(process.env.PI_CODING_AGENT_DIR?.trim() || "~/.pi/agent"));
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, ".vstack-lock.json"))) return candidate;
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("vstack.pi.project-trust");
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?.vstack)?.extensionManager)?.config);
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) {
@@ -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). That is the honest behaviour mirroring wrote
6
- // `Tool <name> not found` into the transcript for calls that had SUCCEEDED
7
- // (drovr#311 / memsira#320) but it leaves the Pi session with no record that
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 the transcript-local entries for free, and a
95
- * replacing sink would take those away and reopen the very audit gap this
96
- * feature closes (memsira, 2026-07-28 their `apps/sidecar/src/runtime.ts` is
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 (drovr #317, measured live). The
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