@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.
@@ -83,7 +83,7 @@ export function buildClaudeQueryOptions(input: BuildClaudeQueryOptionsInput): Bu
83
83
  const connectorWriteMode = connectorWriteModeFor(bridgeConfig);
84
84
  // Declare the account's connected connectors explicitly so `alwaysLoad` can
85
85
  // hold startup until they attach — otherwise the turn-1 manifest is built
86
- // before the CLI has fetched them (vstack#832).
86
+ // before the CLI has fetched them.
87
87
  const connectorServers = enableCloudMcp ? connectorServersSnapshot(accountScope.claudeConfigDir) : {};
88
88
  const appendSystemPrompt = providerSettings.appendSystemPrompt !== false;
89
89
  const agentsAppend = appendSystemPrompt ? extractAgentsAppend() : undefined;
@@ -98,7 +98,7 @@ export function buildClaudeQueryOptions(input: BuildClaudeQueryOptionsInput): Bu
98
98
  // ignores auto-discovered filesystem MCP servers while Pi owns tool execution.
99
99
  // Connectors mode needs settings resolution ON but restricted to USER scope
100
100
  // only — project/local settings files can smuggle `env`/`apiKeyHelper` from
101
- // a hostile checkout (vstack#990). Full rationale on settingSourcesForQuery.
101
+ // a hostile checkout. Full rationale on settingSourcesForQuery.
102
102
  const settingSources: SettingSource[] | undefined = settingSourcesForQuery(
103
103
  enableCloudMcp, appendSystemPrompt, providerSettings.settingSources);
104
104
  const strictMcpConfigEnabled = !appendSystemPrompt && providerSettings.strictMcpConfig !== false;
@@ -113,7 +113,7 @@ export function buildClaudeQueryOptions(input: BuildClaudeQueryOptionsInput): Bu
113
113
 
114
114
  const extraArgs: Record<string, string | null> = {};
115
115
  // Opus 4.7 defaults thinking.display to "omitted" (empty thinking text in stream).
116
- // Force summarized so thinking_delta events arrive. See anthropics/claude-agent-sdk-python#830.
116
+ // Force summarized so thinking_delta events arrive.
117
117
  // Deliberately the raw flag, NOT the typed `thinking` option: every non-disabled
118
118
  // ThinkingConfig also emits `--thinking adaptive` or `--max-thinking-tokens`
119
119
  // (verified in sdk.mjs flag mapping), so the typed form cannot set display
@@ -136,7 +136,7 @@ export function buildClaudeQueryOptions(input: BuildClaudeQueryOptionsInput): Bu
136
136
  // DISABLE_AUTO_COMPACT=1: pi owns context-management and propagates its own
137
137
  // /compact via session_compact (see handler in the extension entry). Letting CC
138
138
  // also autocompact would double-flush the prompt cache and races pi's
139
- // threshold with CC's, including CC's anti-thrashing guard (issue #8).
139
+ // threshold with CC's, including CC's anti-thrashing guard.
140
140
  // Manual /compact in CC still works (we never invoke it).
141
141
  // When connectors are enabled, allow claude.ai cloud MCP servers so the
142
142
  // authenticated account's Gmail/Calendar/Drive tools load. Default stays "0".
@@ -2,9 +2,8 @@
2
2
  //
3
3
  // All per-query and per-turn mutable state lives here. Reentrant queries
4
4
  // (subagents) push the parent context onto a stack and get a fresh instance.
5
- // Adding a new field = one property on the class.
6
5
  //
7
- // Extracted from index.ts so tests can import without activating the extension.
6
+ // Separate from index.ts so tests can import it without activating the extension.
8
7
 
9
8
  import type { ContentBlockParam } from "@anthropic-ai/sdk/resources";
10
9
  import type { AssistantMessage, AssistantMessageEventStream, Model } from "@earendil-works/pi-ai";
@@ -15,8 +14,7 @@ import { currentRequestLaneId } from "./request-lane.js";
15
14
  /** A mid-query user run captured for replay after the active query ends.
16
15
  * `text` is the joined text form (previews, and the replay prompt when no
17
16
  * image blocks were captured). `blocks` is present when the run carried
18
- * images — the replay must send the blocks or the images are silently lost
19
- * (vstack#993). */
17
+ * images — the replay must send the blocks or the images are silently lost. */
20
18
  export interface DeferredUserMessage {
21
19
  text: string;
22
20
  blocks?: ContentBlockParam[];
@@ -25,7 +23,7 @@ export interface DeferredUserMessage {
25
23
  /** Diag payload for a deferred-message drop: counts, sites, and lengths only.
26
24
  * The messages are user-authored prompt text and the diag log sits outside
27
25
  * any host app's retention boundary, so no content — not even a preview —
28
- * may appear in the entry (VST-15). */
26
+ * may appear in the entry. */
29
27
  export function summarizeDroppedUserMessages(site: string, dropped: DeferredUserMessage[]): Record<string, unknown> {
30
28
  return {
31
29
  site,
@@ -40,7 +38,7 @@ export interface PendingToolCall {
40
38
  /** The MCP invocation's schema-validated arguments. The SDK hands the handler
41
39
  * the COMPLETE input, so this is the authoritative copy — the grace-timer
42
40
  * finalize settles a still-partial streamed block from here instead of from
43
- * its truncated partial JSON (vstack#1469: a `{}` settle made Pi execute
41
+ * its truncated partial JSON (a `{}` settle would make Pi execute
44
42
  * empty-argument calls). */
45
43
  args: Record<string, unknown>;
46
44
  /** `QueryContext.callbackGeneration` at registration. A handler from an older
@@ -106,7 +104,7 @@ export function strandedToolCallResult(): McpResult {
106
104
  * forwarded (Pi owes it a result — steer-split deliveries arrive turns later)
107
105
  * or nothing is waiting. Marks the id dead so a lagging stream replay can
108
106
  * never forward it AFTER the model was told it failed — that late forward
109
- * would execute the call a second time behind the model's back (vstack#1469).
107
+ * would execute the call a second time behind the model's back.
110
108
  * Returns true when a handler was failed. */
111
109
  export function failStrandedToolCall(queryCtx: QueryContext, id: string): boolean {
112
110
  if (queryCtx.forwardedToolCallIds.has(id)) return false;
@@ -166,7 +164,7 @@ export function takeQueuedOrParkedResult(queryCtx: QueryContext, id: string): Mc
166
164
  export interface ConnectorCallAuditState {
167
165
  name: string;
168
166
  /** The child session that issued it, captured when the call was seen — a
169
- * continuation query gets a new one, and a call is audited against the session
167
+ * continuation query gets its own, and a call is audited against the session
170
168
  * that actually made it. */
171
169
  childSessionId?: string;
172
170
  recorded: boolean;
@@ -253,19 +251,18 @@ export class QueryContext {
253
251
  pendingToolCalls = new Map<string, PendingToolCall>();
254
252
  pendingResults = new Map<string, McpResult>();
255
253
  /** Results a message-boundary reap moved OUT of pendingResults so they stop
256
- * poisoning mismatch reports, kept CONSUMABLE for a handler that fires later.
257
- * The 2026-08-17 deadlock session showed the reap's "no consumer will ever
258
- * come" assumption failing routinely: Pi delivers a turn's results in one
259
- * callback while the SDK staggers handler invocations past the next message
260
- * boundary. Query-scoped, bounded by the query's tool-call count. */
254
+ * poisoning mismatch reports, kept CONSUMABLE for a handler that fires later:
255
+ * Pi delivers a turn's results in one callback while the SDK staggers handler
256
+ * invocations past the next message boundary, so a boundary never proves that
257
+ * no consumer will come. Query-scoped, bounded by the query's tool-call count. */
261
258
  reapedResults = new Map<string, McpResult>();
262
259
  /** Every tool-call id this query has handed to Pi inside an ENDED turn — the
263
260
  * set endToolUseTurn stamps from the turn's content. A forwarded id is one Pi
264
261
  * will execute and answer; it must never be emitted again (a lagging stream
265
262
  * replays the same tool_use into the NEXT turn, and per-message turnBlocks
266
- * dedup cannot see across turns vstack#1469's duplicate executions), and a
267
- * handler waiting on it must be left waiting at the stranded-handler drains.
268
- * Query-scoped, never reset per message. */
263
+ * dedup cannot see across turns), and a handler waiting on it must be left
264
+ * waiting at the stranded-handler drains. Query-scoped, never reset per
265
+ * message. */
269
266
  forwardedToolCallIds = new Set<string>();
270
267
  /** Ids whose waiting handler was resolved with strandedToolCallResult. The
271
268
  * model has been told these calls failed; forwarding one later would execute
@@ -287,15 +284,15 @@ export class QueryContext {
287
284
  * resets at every message boundary, but `pendingResults` is query-scoped, so a
288
285
  * result stranded there outlives the message that named it. Without this map a
289
286
  * teardown report can only say "1 queued" with empty toolNames and 0/0
290
- * counters — which is exactly the unactionable record the 2026-07-28 diag log
291
- * showed. Bounded by the number of tool calls in one query.
287
+ * counters — an unactionable record. Bounded by the number of tool calls in
288
+ * one query.
292
289
  */
293
290
  queryToolNames = new Map<string, string>();
294
291
  /** id → last-known arguments, query-scoped like queryToolNames and for the
295
292
  * same reason: a late handler firing after resetToolTracking wiped the
296
293
  * per-message records must still be able to exact-match the parked/queued
297
294
  * result of ITS OWN call — without stored args the only fallback is
298
- * sole-same-name, which can hand it a LIVE sibling's id (vstack#1469). */
295
+ * sole-same-name, which can hand it a LIVE sibling's id. */
299
296
  queryToolArgs = new Map<string, Record<string, unknown>>();
300
297
  claimedToolCallIds = new Set<string>();
301
298
  deliveredToolResultIds = new Set<string>();
@@ -305,13 +302,13 @@ export class QueryContext {
305
302
  deferredUserMessages: DeferredUserMessage[] = [];
306
303
  handledTerminalError = false;
307
304
  // Once visible text/thinking, a complete tool call, or a child-executed
308
- // CONNECTOR dispatch reaches Pi, the request must never be replayed on
309
- // another account (duplicate side effects). Query-scoped, not per-turn:
305
+ // connector/foreign-MCP dispatch reaches Pi, the request must never be
306
+ // replayed on another account (duplicate side effects). Query-scoped, not per-turn:
310
307
  // resetTurnState must not clear it.
311
308
  committedOutput = false;
312
309
  /** True when this query holds NO claim on the module-level shared session
313
- * record: a reentrant (subagent) query, or a foreign-conversation one-shot
314
- * (vstack#1001). Every shared-record mutation reachable from this context —
310
+ * record: a reentrant (subagent) query, or a foreign-conversation one-shot.
311
+ * Every shared-record mutation reachable from this context —
315
312
  * reportToolResultMismatch's needsRebuild/forceRotate mark, the cursor
316
313
  * advances on the tool-result-delivery and orphaned-result paths — must
317
314
  * no-op so the PARENT's record stays untouched. Assigned at fresh-query
@@ -349,18 +346,17 @@ export class QueryContext {
349
346
  childSessionId: string | undefined;
350
347
  /** Anthropic content-block indexes of the current assistant message that carry
351
348
  * a child-executed tool_use. Scoped to one message: cleared at message_start,
352
- * and an index is released as soon as a new block starts there. */
349
+ * and an index is released as soon as another block starts there. */
353
350
  childExecutedStreamIndexes = new Set<number>();
354
351
 
355
352
  // Usage accounting for a Pi turn that spans SEVERAL child assistant messages.
356
353
  //
357
354
  // Every child message is a separate billed API call, and each reports its own
358
355
  // counters — `message_start`/`message_delta` REPLACE rather than accumulate. A
359
- // Pi turn used to end at the first tool call, so one Pi message meant one child
360
- // message and replacing was right. A turn containing a child-executed connector
361
- // call now keeps running across the child's follow-up messages, so replacing
362
- // would silently drop everything the earlier ones billed (measured: 55,685
363
- // cache-write tokens lost on a single connector turn).
356
+ // Pi turn that ends at its first tool call spans one child message, where
357
+ // replacing is right. A turn containing a child-executed connector call keeps
358
+ // running across the child's follow-up messages, so replacing would silently
359
+ // drop everything the earlier ones billed.
364
360
  //
365
361
  // So: `turnUsageCarry` holds the totals of the child messages already COMPLETE
366
362
  // in this Pi turn, `currentMessageUsage` holds the one in flight, and the Pi
@@ -420,8 +416,8 @@ export class QueryContext {
420
416
  this.turnSawStreamEvent = false;
421
417
  this.turnSawToolCall = false;
422
418
  this.handledTerminalError = false;
423
- // A new pi message means the previous turn's stream is done with; an armed
424
- // end-timer for it must not fire into the new turn's state.
419
+ // A fresh pi message means the previous turn's stream is done with; an
420
+ // armed end-timer for it must not fire into this turn's state.
425
421
  if (this.scheduledToolUseEnd) {
426
422
  clearTimeout(this.scheduledToolUseEnd.timer);
427
423
  this.scheduledToolUseEnd = null;
@@ -432,8 +428,8 @@ export class QueryContext {
432
428
  this.currentMessageUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
433
429
  this.currentMessageId = undefined;
434
430
  // Tool-call tracking is NOT reset here — it persists across the
435
- // tool-result delivery callback for the same assistant message. New
436
- // assistant messages call resetToolTracking() explicitly.
431
+ // tool-result delivery callback for the same assistant message. Each
432
+ // assistant message boundary calls resetToolTracking() explicitly.
437
433
  }
438
434
 
439
435
  resetToolTracking(): void {
@@ -517,7 +513,7 @@ export class QueryContext {
517
513
  // Ids whose RESULT already sits queued or parked. A handler can fire after
518
514
  // the message boundary wiped the per-message records — by then Pi has
519
515
  // executed its call and only these query-scoped stores still know it
520
- // (vstack#1469: the boundary reap used to make such a handler error out
516
+ // (dropping them at the boundary would make such a handler error out
521
517
  // and the model re-run an already-executed side-effectful call). An
522
518
  // exact-args match here outranks the live sole-same-name fallback below,
523
519
  // so a late handler can never steal a live sibling's id while its own
@@ -556,10 +552,9 @@ export class QueryContext {
556
552
  // - the handler receives the MCP server's schema-VALIDATED copy of
557
553
  // the input (zod may strip unknown keys or apply defaults) while
558
554
  // the record holds the raw streamed input.
559
- // Refusing here stranded the call outright: the handler errored into
560
- // the child while pi's real result sat queued forever (diag log
561
- // 2026-07-28, `edit` with argKeys [edits, path] on both sides). A
562
- // same-type sole-candidate claim is strictly safer than that. With
555
+ // Refusing here strands the call outright: the handler errors into
556
+ // the child while pi's real result sits queued forever. A same-type
557
+ // sole-candidate claim is strictly safer than that. With
563
558
  // SEVERAL same-name candidates and no exact match we still refuse —
564
559
  // cross-pairing two live calls is the one outcome worse than failing.
565
560
  chosen = byName[0];
@@ -585,9 +580,8 @@ export class QueryContext {
585
580
  * assistant fallback). Left in pendingResults, each entry poisons every later
586
581
  * mismatch report for the whole query (queued>0 with 0/0 counters and no tool
587
582
  * names) and forces a session rebuild per turn. But the boundary does NOT
588
- * prove the handler gave up — the SDK staggers handler invocations, and the
589
- * 2026-08-17 deadlock session (vstack#1469) had three of five parallel
590
- * handlers fire after this reap destroyed their results. So the reap parks
583
+ * prove the handler gave up — the SDK staggers handler invocations, and
584
+ * handlers in a parallel batch routinely fire after it. So the reap parks
591
585
  * instead of dropping: reports stay clean, and a late handler still gets its
592
586
  * real result through takeQueuedOrParkedResult.
593
587
  */
@@ -629,9 +623,9 @@ export class QueryContext {
629
623
  const counts = new Map<string, number>();
630
624
  if (affectedIds.size > 0) {
631
625
  // Name the affected ids from the query-scoped map, not just this
632
- // message's records: a queued straggler from an earlier child message is
626
+ // message's records: a queued straggler from a prior child message is
633
627
  // exactly the case a mismatch report exists for, and this message's
634
- // turnToolCalls no longer knows it.
628
+ // turnToolCalls does not know it.
635
629
  for (const id of affectedIds) {
636
630
  const name = this.queryToolNames.get(id)
637
631
  ?? this.turnToolCalls.find((call) => call.id === id)?.toolName
@@ -675,7 +669,7 @@ interface QueryLaneStoreV1 {
675
669
  sessionLanes: Map<string, QueryLaneState>;
676
670
  }
677
671
 
678
- const QUERY_LANES_SYMBOL = Symbol.for("vstack.pi.claude-bridge.query-lanes.v1");
672
+ const QUERY_LANES_SYMBOL = Symbol.for("kendex.pi.claude-bridge.query-lanes.v1");
679
673
 
680
674
  function queryLaneStore(): QueryLaneStoreV1 {
681
675
  const host = globalThis as Record<symbol, unknown>;
@@ -2,8 +2,8 @@
2
2
  // operates on the ONE context captured at query start — never the live ctx().
3
3
  // The two only differ while a reentrant (subagent) context is pushed, which is
4
4
  // exactly when a parent query ending abnormally (abort, child process death)
5
- // used to run this against the subagent's state: the parent's drain, audit
6
- // flush, and activeQuery clear were skipped, leaking its pending MCP handlers.
5
+ // teardown must run against the parent state. Using the subagent state skips
6
+ // the parent's drain, audit flush, and activeQuery clear, which leaks handlers.
7
7
 
8
8
  import { reportToolResultMismatch } from "./bridge-state.js";
9
9
  import { flushConnectorCallAudit } from "./connector-audit.js";
@@ -11,7 +11,7 @@ import { debug } from "./debug.js";
11
11
  import { drainPendingToolCalls, popContextFor, type QueryContext, type ToolCallDrainCause } from "./query-state.js";
12
12
 
13
13
  /** Tear down `queryCtx` after its SDK query settled. No-ops when the query is
14
- * no longer the context's active one (a continuation replaced it, or teardown
14
+ * is not the context's active one (a continuation replaced it, or teardown
15
15
  * already ran). Returns true when teardown actually ran. */
16
16
  export function teardownQuery(
17
17
  queryCtx: QueryContext,
package/src/rate-limit.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { USAGE_LIMIT_ERROR_PREFIXES } from "@anthropic-ai/claude-agent-sdk";
2
2
 
3
- export const RATE_LIMIT_AUTO_RESUME_EVENT = "vstack:rate-limit";
3
+ export const RATE_LIMIT_AUTO_RESUME_EVENT = "kendex:rate-limit";
4
4
  export const RATE_LIMIT_TOKEN = "\x1b[31m[rate-limit]\x1b[39m";
5
5
 
6
6
  // The SDK export is @alpha — degrade to "no match" (pre-0.3.220 behavior) if a
@@ -79,7 +79,7 @@ export function normalizeRateLimitUtilization(value: unknown): number | undefine
79
79
  // (100%) because that is the fail-closed direction — under the fraction
80
80
  // convention 1 is the fully-consumed case the warning exists to surface,
81
81
  // while under the percent convention 1% sits below the threshold anyway,
82
- // so nothing is lost by warning (VST-16).
82
+ // so nothing is lost by warning.
83
83
  if (value > 0 && value <= 1) return value * 100;
84
84
  if (value > 1 && value <= 100) return value;
85
85
  return undefined;
@@ -10,7 +10,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
10
10
  * keeps that identity attached to every promise, SDK iterator, and timer born
11
11
  * during the provider call without threading the id through every helper.
12
12
  */
13
- const REQUEST_LANE_SYMBOL = Symbol.for("vstack.pi.claude-bridge.request-lane.v1");
13
+ const REQUEST_LANE_SYMBOL = Symbol.for("kendex.pi.claude-bridge.request-lane.v1");
14
14
 
15
15
  function requestLaneStorage(): AsyncLocalStorage<string> {
16
16
  const host = globalThis as Record<symbol, unknown>;
@@ -155,7 +155,7 @@ function canonicalize(p: string | undefined): string | undefined {
155
155
  // Decides whether a persisted bridge-session marker is safe to restore.
156
156
  //
157
157
  // The fork case is the load-bearing one: pi/core's createBranchedSession copies
158
- // every non-label entry from root→leaf into the new session file. That includes
158
+ // every non-label entry from root→leaf into the fork session file. That includes
159
159
  // our claude-bridge-session markers from the parent. Restoring from them would
160
160
  // --resume parent's Claude jsonl on the fork's first turn, leaking conversation
161
161
  // past the fork point.
@@ -226,7 +226,7 @@ export function restoreSharedSessionFromPi(ctx: { sessionManager?: unknown; cwd?
226
226
  // instance can differ. Scheduling again for the same manager REPLACES the
227
227
  // pending timer: each fire appends the record's state as of its schedule and
228
228
  // restore reads the last marker, so the superseded entry is a stale duplicate.
229
- const SCHEDULED_PERSISTENCE_SYMBOL = Symbol.for("vstack.pi.claude-bridge.scheduled-persistence.v1");
229
+ const SCHEDULED_PERSISTENCE_SYMBOL = Symbol.for("kendex.pi.claude-bridge.scheduled-persistence.v1");
230
230
 
231
231
  type PersistenceTimer = ReturnType<typeof setTimeout>;
232
232
 
@@ -287,7 +287,7 @@ export function schedulePersistSharedSession(ctxLike?: { sessionManager?: unknow
287
287
  // A failed persist means the next startup restores a stale (or no)
288
288
  // bridge marker and silently rebuilds — worth a diagnostic entry.
289
289
  // Like all diagDump output this lands only under CLAUDE_BRIDGE_DEBUG=1
290
- // (VST-15); the failure itself stays non-fatal either way.
290
+ // and the failure itself stays non-fatal either way.
291
291
  diagDump("persist_shared_session_failed", {
292
292
  sessionId: snapshot.sessionId.slice(0, 8),
293
293
  cursor: snapshot.cursor,
@@ -391,7 +391,7 @@ export function planIncrementalPromptBatch(
391
391
 
392
392
  // A cursor past the end is PROOF this messages array is not the conversation
393
393
  // the cursor describes (e.g. a reentrant subagent's short context arriving
394
- // while the parent's cursor is large). Clamping it used to fabricate a REUSE
394
+ // while the parent's cursor is large). Clamping it would fabricate a REUSE
395
395
  // plan against foreign history — reject so the caller takes the rebuild path.
396
396
  if (cursor > lastIndex) {
397
397
  debug(`planIncrementalPromptBatch: rejected — cursor=${cursor} beyond last index ${lastIndex}; messages are not the conversation this cursor describes`);
@@ -438,7 +438,7 @@ function verifyWrittenSession(
438
438
  safeNotify(
439
439
  `Session file issue: ${msg}\n` +
440
440
  `cwd=${displayPath(cwd)} realpath=${displayPath(safeRealpath(cwd))}\n` +
441
- `Please copy and paste this message into a new issue at https://github.com/vanillagreencom/vstack/issues/new` +
441
+ `Please copy and paste this message into a new issue at https://github.com/vanillagreencom/kendex/issues/new` +
442
442
  (DEBUG ? ` and attach ${DEBUG_LOG_PATH}` : ` (rerun with CLAUDE_BRIDGE_DEBUG=1 to capture a debug log)`),
443
443
  "warning",
444
444
  );
@@ -479,7 +479,7 @@ function debugSessionPaths(label: string, cwd: string, jsonlPath: string, claude
479
479
  // promptStart can never land on a user message Claude already persisted:
480
480
  // Claude owns [0, cursor), promptStart starts at the cursor and only ever
481
481
  // advances (past the one optional assistant), so everything from
482
- // promptStart on is new input. Returns the existing sessionId. Keeps CC's
482
+ // promptStart on is uncaptured input. Returns the existing sessionId. Keeps CC's
483
483
  // prompt cache warm.
484
484
  // REBUILD — no session yet, or pi's history has diverged (non-trailing
485
485
  // missed messages, e.g. another provider took a turn). Wipes the existing
@@ -509,12 +509,12 @@ export function syncSharedSession(
509
509
  account?: AccountSessionScope,
510
510
  ): SyncResult {
511
511
  const sharedSession = getSharedSession();
512
- const priorMessages = messages.slice(0, -1); // everything before the new user prompt
512
+ const priorMessages = messages.slice(0, -1); // everything before the current user prompt
513
513
  const accountProfileId = account?.accountProfileId;
514
514
  const scopeConfigDir = account?.claudeConfigDir; // resolved dir for managed, undefined for legacy
515
515
  // What cc-session-io reads/writes. Managed requests always carry a resolved
516
516
  // dir (accountSessionScope) so this never falls back to the process env the
517
- // child no longer sees; legacy keeps the env rule unchanged.
517
+ // child does not see; legacy keeps the env rule unchanged.
518
518
  const claudeDir = scopeConfigDir ?? process.env.CLAUDE_CONFIG_DIR;
519
519
  const sameAccount = Boolean(
520
520
  sharedSession &&
@@ -523,7 +523,7 @@ export function syncSharedSession(
523
523
  );
524
524
  const incomingFingerprint = conversationFingerprint(messages);
525
525
 
526
- // FOREIGN-CONVERSATION guard (Case 6, vstack#1001). A subagent-shaped query
526
+ // FOREIGN-CONVERSATION guard. A subagent-shaped query
527
527
  // arriving while the parent is IDLE is not reentrant, so it lands here as an
528
528
  // outermost query. Without an identity check its short foreign context takes
529
529
  // the REBUILD path — rewriting the PARENT's session file from foreign
@@ -568,7 +568,7 @@ export function syncSharedSession(
568
568
  // Read the pre-update cursor first: setSharedSession reassigns the live
569
569
  // binding, so comparing against sharedSession.cursor afterwards would
570
570
  // always be equal and the "advanced past trailing assistant" debug
571
- // branch could never print (vstack#993).
571
+ // branch could never print.
572
572
  const cursorBeforeUpdate = sharedSession.cursor;
573
573
  // A REUSE match proves identity, so the anchor may only strengthen here:
574
574
  // a pre-3.1.1 record adopts it outright, and a turn-1 user-only anchor
@@ -1,11 +1,48 @@
1
+ import { isMcpResourceTool } from "./connectors.js";
1
2
  import { MCP_SERVER_NAME, MCP_TOOL_PREFIX } from "./skills.js";
2
3
 
3
4
  const SDK_TO_PI_TOOL_NAME: Record<string, string> = {
4
5
  read: "read", write: "write", edit: "edit", bash: "bash",
5
6
  };
6
7
 
8
+ const BRIDGED_TOOL_PREFIXES = [
9
+ MCP_TOOL_PREFIX,
10
+ `mcp__${MCP_SERVER_NAME.replace(/-/g, "_")}__`,
11
+ `mcp/${MCP_SERVER_NAME}/`,
12
+ `mcp/${MCP_SERVER_NAME.replace(/-/g, "_")}/`,
13
+ ];
14
+
7
15
  // --- Provider helpers: tool name mapping ---
8
16
 
17
+ function bridgedToolSuffix(normalized: string): string | undefined {
18
+ const prefix = BRIDGED_TOOL_PREFIXES.find((candidate) => normalized.startsWith(candidate));
19
+ return prefix ? normalized.slice(prefix.length) : undefined;
20
+ }
21
+
22
+ export function isForeignMcpTool(name: unknown): boolean {
23
+ if (typeof name !== "string") return false;
24
+ const normalized = name.toLowerCase();
25
+ return (normalized.startsWith("mcp__") || normalized.startsWith("mcp/")) && bridgedToolSuffix(normalized) === undefined;
26
+ }
27
+
28
+ export function isPiDispatchable(name: unknown, customToolNameToPi?: Map<string, string>): boolean {
29
+ if (typeof name !== "string" || !name) return false;
30
+ const normalized = name.toLowerCase();
31
+ const hasManifest = Boolean(customToolNameToPi?.size);
32
+ if (customToolNameToPi?.has(name) || customToolNameToPi?.has(normalized)) return true;
33
+ const bridgedSuffix = bridgedToolSuffix(normalized);
34
+ if (bridgedSuffix !== undefined) {
35
+ if (!hasManifest) return true;
36
+ return customToolNameToPi?.has(`${MCP_TOOL_PREFIX}${bridgedSuffix}`) ?? false;
37
+ }
38
+ // A foreign MCP namespace belongs to a child-loaded server, not Pi's bridge.
39
+ if (isForeignMcpTool(name)) return false;
40
+ // Resource discovery is deliberately mirrored as Pi's account-access audit.
41
+ if (isMcpResourceTool(name)) return true;
42
+ // A populated manifest is authoritative: every other bare name is a naming slip.
43
+ return !hasManifest;
44
+ }
45
+
9
46
  export function mapToolName(name: string, customToolNameToPi?: Map<string, string>): string {
10
47
  const normalized = name.toLowerCase();
11
48
  const builtin = SDK_TO_PI_TOOL_NAME[normalized];
@@ -14,19 +51,15 @@ export function mapToolName(name: string, customToolNameToPi?: Map<string, strin
14
51
  const mapped = customToolNameToPi.get(name) ?? customToolNameToPi.get(normalized);
15
52
  if (mapped) return mapped;
16
53
  }
17
- for (const prefix of [
18
- MCP_TOOL_PREFIX,
19
- `mcp__${MCP_SERVER_NAME.replace(/-/g, "_")}__`,
20
- `mcp/${MCP_SERVER_NAME}/`,
21
- `mcp/${MCP_SERVER_NAME.replace(/-/g, "_")}/`,
22
- ]) {
23
- if (normalized.startsWith(prefix)) return normalized.slice(prefix.length);
54
+ const bridgedSuffix = bridgedToolSuffix(normalized);
55
+ if (bridgedSuffix !== undefined) {
56
+ return customToolNameToPi?.get(`${MCP_TOOL_PREFIX}${bridgedSuffix}`) ?? bridgedSuffix;
24
57
  }
25
58
  return name;
26
59
  }
27
60
 
28
61
  // Renames for Claude Code SDK param names that differ from pi's native names.
29
- // Keys not listed here pass through unchanged, so new pi params work automatically.
62
+ // Keys not listed here pass through unchanged, so additional Pi parameters work automatically.
30
63
  const SDK_KEY_RENAMES: Record<string, Record<string, string>> = {
31
64
  read: { file_path: "path" },
32
65
  write: { file_path: "path" },
@@ -131,13 +131,12 @@ export const LOST_TOOL_RESULT_TEXT =
131
131
  * IN PLACE, before cc-session-io's repairToolPairing runs.
132
132
  *
133
133
  * repairToolPairing backfills with a bare "[no tool result recorded]" — a
134
- * placeholder the model reads as tool OUTPUT and keeps reasoning on (observed:
135
- * two bash calls in the 2026-07-28 token test, silently treated as if they had
136
- * returned). An is_error result that says what happened and what to do turns a
134
+ * placeholder the model reads as tool OUTPUT and continues to reason from.
135
+ * An is_error result that says what happened and what to do turns a
137
136
  * silent correctness hazard into a recoverable failure.
138
137
  *
139
138
  * Results are prepended to the immediately following user message (tool_result
140
- * blocks must lead a user message), or a new user message is inserted when none
139
+ * blocks must lead a user message), or a synthetic user message is inserted when none
141
140
  * follows. `missing` must come from findUnpairedToolUses on the same array.
142
141
  */
143
142
  export function insertLostToolResultPlaceholders(
@@ -151,8 +150,8 @@ export function insertLostToolResultPlaceholders(
151
150
  group.push(item);
152
151
  byAssistant.set(item.assistantIndex, group);
153
152
  }
154
- // Descending order so inserting a new user message never shifts an index a
155
- // later (earlier-in-array) group still needs.
153
+ // Descending order ensures that inserting a user message does not shift an index
154
+ // for a group that is earlier in the array.
156
155
  for (const assistantIndex of [...byAssistant.keys()].sort((a, b) => b - a)) {
157
156
  const group = byAssistant.get(assistantIndex)!;
158
157
  const blocks = group.map((item) => block(item.id));