@vanillagreen/pi-claude-bridge 4.0.0 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/models.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  // Canonical selection + display order for the model picker.
2
2
  // Extracted from index.ts so tests can import without activating the extension.
3
3
 
4
- export const FABLE_MODEL_ID = "claude-fable-5";
4
+ export const FABLE_MODEL_ID = "claude-fable-5-1";
5
5
  // Opus 4.8 is both a selectable model and the safety-fallback target for the two
6
- // primaries whose classifiers can decline a turn (Fable 5, Opus 5).
6
+ // primaries whose classifiers can decline a turn (Fable 5.1, Opus 5).
7
7
  export const FABLE_FALLBACK_MODEL_ID = "claude-opus-4-8";
8
8
  export const OPUS_5_MODEL_ID = "claude-opus-5";
9
9
  export const SONNET_5_MODEL_ID = "claude-sonnet-5";
@@ -36,7 +36,7 @@ type BridgeModelMetadata = {
36
36
  const FALLBACK_MODELS: Record<string, BridgeModelMetadata> = {
37
37
  [FABLE_MODEL_ID]: {
38
38
  id: FABLE_MODEL_ID,
39
- name: "Claude Fable 5",
39
+ name: "Claude Fable 5.1",
40
40
  reasoning: true,
41
41
  thinkingLevelMap: { xhigh: "xhigh", max: "max" },
42
42
  input: ["text", "image"],
@@ -80,8 +80,8 @@ export function modelDisplayName(modelId: string): string {
80
80
  }
81
81
 
82
82
  // Project pi-ai's model entries down to the fields pi's registerProvider expects,
83
- // keep MODEL_IDS_IN_ORDER ordering, and fill bridge-owned future IDs when pi-ai
84
- // has not shipped metadata for them yet. Unknown missing IDs are still dropped.
83
+ // keep MODEL_IDS_IN_ORDER ordering, and fill bridge-owned metadata for supported
84
+ // IDs absent from pi-ai. Unknown missing IDs are still dropped.
85
85
  export function buildModels<T extends { id: string; [key: string]: any }>(piAiModels: T[]) {
86
86
  return MODEL_IDS_IN_ORDER
87
87
  .map((id) => piAiModels.find((m) => m.id === id) ?? FALLBACK_MODELS[id])
@@ -60,7 +60,9 @@ export function buildNativeProvider(
60
60
  // passes a probe that also accepts a companion account-router pool.
61
61
  hasCredentials: () => boolean = () => hasClaudeCredentials(env),
62
62
  ): unknown {
63
- if (!supportsNativeProvider(piAi)) throw new Error(NATIVE_PROVIDER_UNSUPPORTED_MESSAGE);
63
+ if (!supportsNativeProvider(piAi)) {
64
+ throw Object.assign(new Error(NATIVE_PROVIDER_UNSUPPORTED_MESSAGE), { code: "CLAUDE_BRIDGE_NATIVE_PROVIDER_UNSUPPORTED" });
65
+ }
64
66
  // The legacy config path stamped provider/api/baseUrl onto each model during
65
67
  // composition; createProvider passes models through verbatim, so stamp here.
66
68
  // Stamps win over any provider field the source model carries — the models
@@ -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 (kendex#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 (kendex#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,12 +2,11 @@
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
- import type { AssistantMessage, AssistantMessageEventStream, Model } from "@earendil-works/pi-ai";
9
+ import type { AssistantMessage, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
11
10
  import { isConnectorTool } from "./connectors.js";
12
11
  import type { McpResult } from "./extract-tool-results.js";
13
12
  import { currentRequestLaneId } from "./request-lane.js";
@@ -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
- * (kendex#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,
@@ -35,12 +33,22 @@ export function summarizeDroppedUserMessages(site: string, dropped: DeferredUser
35
33
  };
36
34
  }
37
35
 
36
+ /** A provider call held to replace a query whose Pi history was replaced while
37
+ * it ran: the callback's own model, context, options and stream, so the
38
+ * replacement runs under the current request instead of the dead query's. */
39
+ export interface QueryRestartRequest {
40
+ model: Model<any>;
41
+ context: Context;
42
+ options: SimpleStreamOptions | undefined;
43
+ stream: AssistantMessageEventStream;
44
+ }
45
+
38
46
  export interface PendingToolCall {
39
47
  toolName: string;
40
48
  /** The MCP invocation's schema-validated arguments. The SDK hands the handler
41
49
  * the COMPLETE input, so this is the authoritative copy — the grace-timer
42
50
  * finalize settles a still-partial streamed block from here instead of from
43
- * its truncated partial JSON (kendex#1469: a `{}` settle made Pi execute
51
+ * its truncated partial JSON (a `{}` settle would make Pi execute
44
52
  * empty-argument calls). */
45
53
  args: Record<string, unknown>;
46
54
  /** `QueryContext.callbackGeneration` at registration. A handler from an older
@@ -66,7 +74,7 @@ const DRAIN_CAUSE_TEXT: Record<ToolCallDrainCause, string> = {
66
74
 
67
75
  export function interruptedToolCallResult(cause: ToolCallDrainCause): McpResult {
68
76
  return {
69
- content: [{ type: "text", text: `Claude bridge: ${DRAIN_CAUSE_TEXT[cause]} before this tool call's result was delivered. The call did not complete and produced no output.` }],
77
+ content: [{ type: "text", text: `tool-call-drain=${cause}\nClaude bridge: ${DRAIN_CAUSE_TEXT[cause]} before this tool call's result was delivered. The call did not complete and produced no output.` }],
70
78
  isError: true,
71
79
  };
72
80
  }
@@ -97,7 +105,7 @@ export function drainPendingToolCalls(queryCtx: QueryContext, cause: ToolCallDra
97
105
  * call is guaranteed not to have executed on the Pi side. */
98
106
  export function strandedToolCallResult(): McpResult {
99
107
  return {
100
- content: [{ type: "text", text: "Claude bridge: this tool call was never forwarded to Pi before its turn ended, so it did not execute and no result can arrive. Re-run the tool." }],
108
+ content: [{ type: "text", text: "tool-call-stranded=unforwarded\nClaude bridge: this tool call was never forwarded to Pi before its turn ended, so it did not execute and no result can arrive. Re-run the tool." }],
101
109
  isError: true,
102
110
  };
103
111
  }
@@ -106,7 +114,7 @@ export function strandedToolCallResult(): McpResult {
106
114
  * forwarded (Pi owes it a result — steer-split deliveries arrive turns later)
107
115
  * or nothing is waiting. Marks the id dead so a lagging stream replay can
108
116
  * 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 (kendex#1469).
117
+ * would execute the call a second time behind the model's back.
110
118
  * Returns true when a handler was failed. */
111
119
  export function failStrandedToolCall(queryCtx: QueryContext, id: string): boolean {
112
120
  if (queryCtx.forwardedToolCallIds.has(id)) return false;
@@ -163,10 +171,16 @@ export function takeQueuedOrParkedResult(queryCtx: QueryContext, id: string): Mc
163
171
  /** One connector call's audit state for the life of a query. `recorded` means an
164
172
  * entry for it has already been appended (or attempted), so neither a re-yielded
165
173
  * result nor the teardown flush can record it twice. */
166
- export interface ConnectorCallAuditState {
174
+ /** Why pi's messages can never carry this call: a claude.ai connector the child
175
+ * ran itself, or a foreign MCP tool it loaded from filesystem settings. Only a
176
+ * connector backs the connector audit trail. */
177
+ export type ChildSideCallKind = "connector" | "foreign-mcp";
178
+
179
+ export interface ChildSideCallState {
167
180
  name: string;
181
+ kind: ChildSideCallKind;
168
182
  /** 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
183
+ * continuation query gets its own, and a call is audited against the session
170
184
  * that actually made it. */
171
185
  childSessionId?: string;
172
186
  recorded: boolean;
@@ -249,23 +263,38 @@ export class QueryContext {
249
263
  // Query-scoped (fully isolated per query)
250
264
  activeQuery: unknown | null = null;
251
265
  currentPiStream: AssistantMessageEventStream | null = null;
266
+ /** Pi replaced the history this query's Claude session was built from
267
+ * (compaction, history navigation) while the query was still running.
268
+ * Delivering further tool results into it would keep Claude Code on history
269
+ * Pi no longer holds, so the next provider callback restarts the query from
270
+ * Pi's new context. A query that ENDS while this is set persists its record
271
+ * with needsRebuild, so the next turn rebuilds either way. */
272
+ piHistoryReplaced = false;
273
+ /** The handover this replacement asked for was refused, and the refusal is
274
+ * already reported. Every later callback of the query re-reads
275
+ * `piHistoryReplaced`, which stays set, so without this the same refusal
276
+ * would be recorded once per remaining tool result. */
277
+ reportedHistoryRestartDecline = false;
278
+ /** The provider callback that observed `piHistoryReplaced`. The dying query's
279
+ * own promise chain runs it, after teardown released the query state, and
280
+ * feeds the replacement query's events into that callback's stream. */
281
+ restartRequest: QueryRestartRequest | null = null;
252
282
  latestCursor = 0;
253
283
  pendingToolCalls = new Map<string, PendingToolCall>();
254
284
  pendingResults = new Map<string, McpResult>();
255
285
  /** 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. */
286
+ * poisoning mismatch reports, kept CONSUMABLE for a handler that fires later:
287
+ * Pi delivers a turn's results in one callback while the SDK staggers handler
288
+ * invocations past the next message boundary, so a boundary never proves that
289
+ * no consumer will come. Query-scoped, bounded by the query's tool-call count. */
261
290
  reapedResults = new Map<string, McpResult>();
262
291
  /** Every tool-call id this query has handed to Pi inside an ENDED turn — the
263
292
  * set endToolUseTurn stamps from the turn's content. A forwarded id is one Pi
264
293
  * will execute and answer; it must never be emitted again (a lagging stream
265
294
  * replays the same tool_use into the NEXT turn, and per-message turnBlocks
266
- * dedup cannot see across turns — kendex#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. */
295
+ * dedup cannot see across turns), and a handler waiting on it must be left
296
+ * waiting at the stranded-handler drains. Query-scoped, never reset per
297
+ * message. */
269
298
  forwardedToolCallIds = new Set<string>();
270
299
  /** Ids whose waiting handler was resolved with strandedToolCallResult. The
271
300
  * model has been told these calls failed; forwarding one later would execute
@@ -287,15 +316,15 @@ export class QueryContext {
287
316
  * resets at every message boundary, but `pendingResults` is query-scoped, so a
288
317
  * result stranded there outlives the message that named it. Without this map a
289
318
  * 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.
319
+ * counters — an unactionable record. Bounded by the number of tool calls in
320
+ * one query.
292
321
  */
293
322
  queryToolNames = new Map<string, string>();
294
323
  /** id → last-known arguments, query-scoped like queryToolNames and for the
295
324
  * same reason: a late handler firing after resetToolTracking wiped the
296
325
  * per-message records must still be able to exact-match the parked/queued
297
326
  * 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 (kendex#1469). */
327
+ * sole-same-name, which can hand it a LIVE sibling's id. */
299
328
  queryToolArgs = new Map<string, Record<string, unknown>>();
300
329
  claimedToolCallIds = new Set<string>();
301
330
  deliveredToolResultIds = new Set<string>();
@@ -305,13 +334,13 @@ export class QueryContext {
305
334
  deferredUserMessages: DeferredUserMessage[] = [];
306
335
  handledTerminalError = false;
307
336
  // 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:
337
+ // connector/foreign-MCP dispatch reaches Pi, the request must never be
338
+ // replayed on another account (duplicate side effects). Query-scoped, not per-turn:
310
339
  // resetTurnState must not clear it.
311
340
  committedOutput = false;
312
341
  /** 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
- * (kendex#1001). Every shared-record mutation reachable from this context —
342
+ * record: a reentrant (subagent) query, or a foreign-conversation one-shot.
343
+ * Every shared-record mutation reachable from this context —
315
344
  * reportToolResultMismatch's needsRebuild/forceRotate mark, the cursor
316
345
  * advances on the tool-result-delivery and orphaned-result paths — must
317
346
  * no-op so the PARENT's record stays untouched. Assigned at fresh-query
@@ -335,32 +364,37 @@ export class QueryContext {
335
364
  /** tool_use id → raw SDK tool name. */
336
365
  childExecutedToolCalls = new Map<string, string>();
337
366
  /**
338
- * The same calls, for the connector-call audit trail (see connector-audit.ts).
367
+ * Every call the CHILD executed that pi's messages cannot carry — a claude.ai
368
+ * connector, or a foreign MCP tool the child loaded itself — keyed by tool_use
369
+ * id. Nothing can rebuild these from pi's context, so a history handover is
370
+ * refused while the map is non-empty. Connector entries also back the
371
+ * connector-call audit trail (see connector-audit.ts).
339
372
  *
340
373
  * Query-scoped and deliberately NOT cleared by resetToolTracking: that runs at
341
374
  * every child message boundary, and a call issued in one child message is only
342
375
  * reconciled after that message ends. Clearing it there would make an abandoned
343
376
  * call unrecordable at teardown — which is the one case the trail exists for.
377
+ * Fresh-query setup clears it instead, once teardown has flushed it: a reused
378
+ * top-level context would otherwise answer for calls an earlier query made.
344
379
  */
345
- connectorCallAudit = new Map<string, ConnectorCallAuditState>();
380
+ childSideCalls = new Map<string, ChildSideCallState>();
346
381
  /** Claude Code session id for this query, from the SDK's `system` init message.
347
382
  * Undefined until it arrives; the audit trail omits the field rather than
348
383
  * guessing. */
349
384
  childSessionId: string | undefined;
350
385
  /** Anthropic content-block indexes of the current assistant message that carry
351
386
  * 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. */
387
+ * and an index is released as soon as another block starts there. */
353
388
  childExecutedStreamIndexes = new Set<number>();
354
389
 
355
390
  // Usage accounting for a Pi turn that spans SEVERAL child assistant messages.
356
391
  //
357
392
  // Every child message is a separate billed API call, and each reports its own
358
393
  // 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).
394
+ // Pi turn that ends at its first tool call spans one child message, where
395
+ // replacing is right. A turn containing a child-executed connector call keeps
396
+ // running across the child's follow-up messages, so replacing would silently
397
+ // drop everything the earlier ones billed.
364
398
  //
365
399
  // So: `turnUsageCarry` holds the totals of the child messages already COMPLETE
366
400
  // in this Pi turn, `currentMessageUsage` holds the one in flight, and the Pi
@@ -404,7 +438,7 @@ export class QueryContext {
404
438
  turnSawToolCall = false;
405
439
 
406
440
  get turnBlocks(): Array<any> {
407
- if (!this.turnOutput) throw new Error("turnBlocks accessed before resetTurnState");
441
+ if (!this.turnOutput) throw new Error("turn-state-uninitialized=turnBlocks\nturnBlocks accessed before resetTurnState");
408
442
  return this.turnOutput.content;
409
443
  }
410
444
 
@@ -420,8 +454,8 @@ export class QueryContext {
420
454
  this.turnSawStreamEvent = false;
421
455
  this.turnSawToolCall = false;
422
456
  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.
457
+ // A fresh pi message means the previous turn's stream is done with; an
458
+ // armed end-timer for it must not fire into this turn's state.
425
459
  if (this.scheduledToolUseEnd) {
426
460
  clearTimeout(this.scheduledToolUseEnd.timer);
427
461
  this.scheduledToolUseEnd = null;
@@ -432,8 +466,8 @@ export class QueryContext {
432
466
  this.currentMessageUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
433
467
  this.currentMessageId = undefined;
434
468
  // 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.
469
+ // tool-result delivery callback for the same assistant message. Each
470
+ // assistant message boundary calls resetToolTracking() explicitly.
437
471
  }
438
472
 
439
473
  resetToolTracking(): void {
@@ -470,9 +504,10 @@ export class QueryContext {
470
504
  // Both emission paths can see the same call (streamed block, then the
471
505
  // SDK's completed copy), so never overwrite an existing audit state —
472
506
  // that would resurrect one already recorded.
473
- if (!this.connectorCallAudit.has(id)) {
474
- this.connectorCallAudit.set(id, {
507
+ if (!this.childSideCalls.has(id)) {
508
+ this.childSideCalls.set(id, {
475
509
  name: rawName,
510
+ kind: "connector",
476
511
  ...(this.childSessionId ? { childSessionId: this.childSessionId } : {}),
477
512
  recorded: false,
478
513
  });
@@ -481,6 +516,21 @@ export class QueryContext {
481
516
  if (typeof streamIndex === "number") this.childExecutedStreamIndexes.add(streamIndex);
482
517
  }
483
518
 
519
+ /** A foreign MCP tool the child loaded from filesystem settings and ran
520
+ * itself. Pi never sees the call or its result, so it commits the turn the
521
+ * same way a connector does and joins the same map: a rebuild from pi's
522
+ * context would erase an account-visible operation the model could repeat. */
523
+ noteForeignMcpToolCall(id: string | undefined, rawName: string): void {
524
+ this.markOutputCommitted();
525
+ if (!id || this.childSideCalls.has(id)) return;
526
+ this.childSideCalls.set(id, {
527
+ name: rawName,
528
+ kind: "foreign-mcp",
529
+ ...(this.childSessionId ? { childSessionId: this.childSessionId } : {}),
530
+ recorded: false,
531
+ });
532
+ }
533
+
484
534
  recordToolCall(id: string | undefined, toolName: string, args: Record<string, unknown> = {}): void {
485
535
  if (!id) return;
486
536
  this.queryToolNames.set(id, toolName);
@@ -517,7 +567,7 @@ export class QueryContext {
517
567
  // Ids whose RESULT already sits queued or parked. A handler can fire after
518
568
  // the message boundary wiped the per-message records — by then Pi has
519
569
  // executed its call and only these query-scoped stores still know it
520
- // (kendex#1469: the boundary reap used to make such a handler error out
570
+ // (dropping them at the boundary would make such a handler error out
521
571
  // and the model re-run an already-executed side-effectful call). An
522
572
  // exact-args match here outranks the live sole-same-name fallback below,
523
573
  // so a late handler can never steal a live sibling's id while its own
@@ -556,10 +606,9 @@ export class QueryContext {
556
606
  // - the handler receives the MCP server's schema-VALIDATED copy of
557
607
  // the input (zod may strip unknown keys or apply defaults) while
558
608
  // 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
609
+ // Refusing here strands the call outright: the handler errors into
610
+ // the child while pi's real result sits queued forever. A same-type
611
+ // sole-candidate claim is strictly safer than that. With
563
612
  // SEVERAL same-name candidates and no exact match we still refuse —
564
613
  // cross-pairing two live calls is the one outcome worse than failing.
565
614
  chosen = byName[0];
@@ -585,9 +634,8 @@ export class QueryContext {
585
634
  * assistant fallback). Left in pendingResults, each entry poisons every later
586
635
  * mismatch report for the whole query (queued>0 with 0/0 counters and no tool
587
636
  * 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 (kendex#1469) had three of five parallel
590
- * handlers fire after this reap destroyed their results. So the reap parks
637
+ * prove the handler gave up — the SDK staggers handler invocations, and
638
+ * handlers in a parallel batch routinely fire after it. So the reap parks
591
639
  * instead of dropping: reports stay clean, and a late handler still gets its
592
640
  * real result through takeQueuedOrParkedResult.
593
641
  */
@@ -629,9 +677,9 @@ export class QueryContext {
629
677
  const counts = new Map<string, number>();
630
678
  if (affectedIds.size > 0) {
631
679
  // 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
680
+ // message's records: a queued straggler from a prior child message is
633
681
  // exactly the case a mismatch report exists for, and this message's
634
- // turnToolCalls no longer knows it.
682
+ // turnToolCalls does not know it.
635
683
  for (const id of affectedIds) {
636
684
  const name = this.queryToolNames.get(id)
637
685
  ?? this.turnToolCalls.find((call) => call.id === id)?.toolName
@@ -708,14 +756,14 @@ export function stackDepth(): number { return lane().stack.length; }
708
756
 
709
757
  export function pushContext(): void {
710
758
  const state = lane();
711
- if (!state.current.activeQuery) throw new Error("pushContext() called with no active query");
759
+ if (!state.current.activeQuery) throw new Error("query-stack-push=inactive\npushContext() called with no active query");
712
760
  state.stack.push(state.current);
713
761
  state.current = new QueryContext();
714
762
  }
715
763
 
716
764
  export function popContext(): void {
717
765
  const state = lane();
718
- if (state.stack.length === 0) throw new Error("popContext() called with empty stack");
766
+ if (state.stack.length === 0) throw new Error("query-stack-pop=empty\npopContext() called with empty stack");
719
767
  const parent = state.stack[state.stack.length - 1];
720
768
  parent.deferredUserMessages.push(...state.current.deferredUserMessages);
721
769
  state.current = state.stack.pop()!;
@@ -2,16 +2,32 @@
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";
10
10
  import { debug } from "./debug.js";
11
11
  import { drainPendingToolCalls, popContextFor, type QueryContext, type ToolCallDrainCause } from "./query-state.js";
12
12
 
13
+ /** Close a settled or dying SDK query. Its transport can throw on the way down
14
+ * — a child already gone, a socket already closed — and that throw belongs to
15
+ * the query being closed, never to whatever its caller does next. */
16
+ export function closeSdkQuery(sdkQuery: unknown): void {
17
+ try { (sdkQuery as { close(): void }).close(); }
18
+ catch (error) { debug("provider: closing the sdk query threw; continuing teardown:", error); }
19
+ }
20
+
21
+ /** Stop an in-flight SDK query. `interrupt()` asks the CLI to stop gracefully,
22
+ * `close()` kills it; both are needed, because interrupt alone lets the current
23
+ * API call finish. */
24
+ export function abortSdkQuery(sdkQuery: unknown): void {
25
+ void (sdkQuery as { interrupt(): Promise<void> }).interrupt().catch(() => {});
26
+ closeSdkQuery(sdkQuery);
27
+ }
28
+
13
29
  /** 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
30
+ * is not the context's active one (a continuation replaced it, or teardown
15
31
  * already ran). Returns true when teardown actually ran. */
16
32
  export function teardownQuery(
17
33
  queryCtx: QueryContext,
package/src/rate-limit.ts CHANGED
@@ -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;
@@ -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.
@@ -168,12 +168,12 @@ export function shouldRestorePersistedBridgeEntry(
168
168
  currentPiSessionId: string | undefined,
169
169
  currentCwd: string | undefined,
170
170
  ): string | undefined {
171
- if (!persisted.piSessionId) return "missing piSessionId";
171
+ if (!persisted.piSessionId) return "restore-session-missing=piSessionId\nMissing piSessionId.";
172
172
  if (currentPiSessionId && persisted.piSessionId !== currentPiSessionId) {
173
- return `piSessionId mismatch (persisted=${persisted.piSessionId} current=${currentPiSessionId})`;
173
+ return `restore-session-mismatch=${persisted.piSessionId} current=${currentPiSessionId}\nThe persisted session differs from the active session.`;
174
174
  }
175
175
  if (currentCwd && canonicalize(persisted.cwd) !== canonicalize(currentCwd)) {
176
- return `cwd mismatch (persisted=${persisted.cwd} current=${currentCwd})`;
176
+ return `restore-cwd-mismatch=${persisted.cwd} current=${currentCwd}\nThe persisted working directory differs from the active directory.`;
177
177
  }
178
178
  return undefined;
179
179
  }
@@ -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`);
@@ -419,7 +419,7 @@ export function planIncrementalPromptBatch(
419
419
  // throwing — CC may be more tolerant than our checks, so a false positive
420
420
  // shouldn't block the user. Pure logic is in session-verify.js; this wrapper
421
421
  // fans each warning out to debug log + piUI notify + diagDump.
422
- function verifyWrittenSession(
422
+ export function verifyWrittenSession(
423
423
  jsonlPath: string,
424
424
  expectedSessionId: string,
425
425
  expectedRecordCount: number,
@@ -436,7 +436,7 @@ function verifyWrittenSession(
436
436
  // reason — an absolute cwd carries the username; the diagDump keeps the
437
437
  // absolute forms.
438
438
  safeNotify(
439
- `Session file issue: ${msg}\n` +
439
+ `${msg}\n` +
440
440
  `cwd=${displayPath(cwd)} realpath=${displayPath(safeRealpath(cwd))}\n` +
441
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)`),
@@ -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, kendex#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 (kendex#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
@@ -57,28 +57,28 @@ export function verifyWrittenSession(jsonlPath: string, expectedSessionId: strin
57
57
  try {
58
58
  st = statSync(jsonlPath);
59
59
  } catch (e) {
60
- warnings.push(`file missing after save — path=${jsonlPath} err=${e.message}`);
60
+ warnings.push(`session-file-missing=${jsonlPath}\nFile missing after save: ${e.message}`);
61
61
  return warnings;
62
62
  }
63
63
  let summary;
64
64
  try {
65
65
  summary = summarizeJsonl(jsonlPath);
66
66
  } catch (e) {
67
- warnings.push(`file unreadable — path=${jsonlPath} size=${st.size} err=${e.message}`);
67
+ warnings.push(`session-file-unreadable=${jsonlPath}\nFile unreadable: size=${st.size} error=${e.message}`);
68
68
  return warnings;
69
69
  }
70
70
  if (summary.count !== expectedRecordCount) {
71
- warnings.push(`record count mismatch — expected=${expectedRecordCount} actual=${summary.count} path=${jsonlPath} bytes=${st.size}`);
71
+ warnings.push(`session-record-count=${summary.count} expected=${expectedRecordCount}\nRecord count differs: path=${jsonlPath} bytes=${st.size}`);
72
72
  return warnings;
73
73
  }
74
74
  try {
75
75
  const firstRec = JSON.parse(summary.firstLine ?? "");
76
76
  const lastRec = JSON.parse(summary.lastLine ?? "");
77
77
  if (firstRec.sessionId !== expectedSessionId || lastRec.sessionId !== expectedSessionId) {
78
- warnings.push(`sessionId drift — expected=${expectedSessionId} first=${firstRec.sessionId} last=${lastRec.sessionId}`);
78
+ warnings.push(`session-id-drift=${expectedSessionId} first=${firstRec.sessionId} last=${lastRec.sessionId}\nSession identity differs from the expected identity.`);
79
79
  }
80
80
  } catch (e) {
81
- warnings.push(`malformed JSONL — path=${jsonlPath} err=${e.message}`);
81
+ warnings.push(`session-json-invalid=${jsonlPath}\nMalformed JSONL: ${e.message}`);
82
82
  }
83
83
  return warnings;
84
84
  }