@vanillagreen/pi-claude-bridge 2.0.0 → 3.2.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.
@@ -6,11 +6,47 @@
6
6
  //
7
7
  // Extracted from index.ts so tests can import without activating the extension.
8
8
 
9
+ import type { ContentBlockParam } from "@anthropic-ai/sdk/resources";
9
10
  import type { AssistantMessage, AssistantMessageEventStream, Model } from "@earendil-works/pi-ai";
11
+ import { isConnectorTool } from "./connectors.js";
10
12
  import type { McpResult } from "./extract-tool-results.js";
13
+ import { currentRequestLaneId } from "./request-lane.js";
14
+
15
+ /** A mid-query user run captured for replay after the active query ends.
16
+ * `text` is the joined text form (previews, and the replay prompt when no
17
+ * 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). */
20
+ export interface DeferredUserMessage {
21
+ text: string;
22
+ blocks?: ContentBlockParam[];
23
+ }
24
+
25
+ /** Diag payload for a deferred-message drop: counts, sites, and lengths only.
26
+ * The messages are user-authored prompt text and the diag log sits outside
27
+ * any host app's retention boundary, so no content — not even a preview —
28
+ * may appear in the entry (VST-15). */
29
+ export function summarizeDroppedUserMessages(site: string, dropped: DeferredUserMessage[]): Record<string, unknown> {
30
+ return {
31
+ site,
32
+ count: dropped.length,
33
+ textLengths: dropped.map((message) => message.text.length),
34
+ imageOnlyCount: dropped.filter((message) => !message.text && message.blocks?.length).length,
35
+ };
36
+ }
11
37
 
12
38
  export interface PendingToolCall {
13
39
  toolName: string;
40
+ /** The MCP invocation's schema-validated arguments. The SDK hands the handler
41
+ * the COMPLETE input, so this is the authoritative copy — the grace-timer
42
+ * finalize settles a still-partial streamed block from here instead of from
43
+ * its truncated partial JSON (vstack#1469: a `{}` settle made Pi execute
44
+ * empty-argument calls). */
45
+ args: Record<string, unknown>;
46
+ /** `QueryContext.callbackGeneration` at registration. A handler from an older
47
+ * generation whose id was never forwarded to Pi can never be answered — see
48
+ * drainStrandedToolCalls. */
49
+ generation: number;
14
50
  resolve: (result: McpResult) => void;
15
51
  }
16
52
 
@@ -56,6 +92,74 @@ export function drainPendingToolCalls(queryCtx: QueryContext, cause: ToolCallDra
56
92
  return drained;
57
93
  }
58
94
 
95
+ /** The error a stranded handler resolves with: its call never reached Pi and
96
+ * the forward paths have marked it dead, so no result can ever arrive and the
97
+ * call is guaranteed not to have executed on the Pi side. */
98
+ export function strandedToolCallResult(): McpResult {
99
+ 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." }],
101
+ isError: true,
102
+ };
103
+ }
104
+
105
+ /** Fail ONE waiting handler whose call never reached Pi. No-op when the id was
106
+ * forwarded (Pi owes it a result — steer-split deliveries arrive turns later)
107
+ * or nothing is waiting. Marks the id dead so a lagging stream replay can
108
+ * 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).
110
+ * Returns true when a handler was failed. */
111
+ export function failStrandedToolCall(queryCtx: QueryContext, id: string): boolean {
112
+ if (queryCtx.forwardedToolCallIds.has(id)) return false;
113
+ const pending = queryCtx.pendingToolCalls.get(id);
114
+ if (!pending) return false;
115
+ queryCtx.pendingToolCalls.delete(id);
116
+ queryCtx.deadToolCallIds.add(id);
117
+ pending.resolve(strandedToolCallResult());
118
+ return true;
119
+ }
120
+
121
+ /** Fail every waiting handler that provably can never be answered: registered
122
+ * before the CURRENT provider callback (older `generation`) with an id Pi was
123
+ * never told about. Runs at the delivery site, where a fresh callback proves
124
+ * the previous turn is settled. Handlers whose id WAS forwarded stay waiting —
125
+ * Pi may deliver their result in a later callback (steer-split batches).
126
+ * Handlers from the current generation stay untouched: their turn is still
127
+ * streaming and the forward may simply not have happened yet. Failed ids are
128
+ * marked dead exactly like failStrandedToolCall. */
129
+ export function drainStrandedToolCalls(queryCtx: QueryContext): Array<{ id: string; toolName: string }> {
130
+ const stranded: Array<{ id: string; toolName: string }> = [];
131
+ for (const [id, pending] of queryCtx.pendingToolCalls) {
132
+ if (pending.generation >= queryCtx.callbackGeneration) continue;
133
+ if (queryCtx.forwardedToolCallIds.has(id)) continue;
134
+ stranded.push({ id, toolName: pending.toolName });
135
+ }
136
+ for (const { id } of stranded) {
137
+ const pending = queryCtx.pendingToolCalls.get(id)!;
138
+ queryCtx.pendingToolCalls.delete(id);
139
+ queryCtx.deadToolCallIds.add(id);
140
+ pending.resolve(strandedToolCallResult());
141
+ }
142
+ return stranded;
143
+ }
144
+
145
+ /** Consume a result waiting for `id`, checking the live queue first and the
146
+ * reap-parked store second. Late handlers land here: Pi delivers every result
147
+ * of a turn in one callback, while the SDK staggers handler invocations, so a
148
+ * handler can fire after a message boundary already parked its result. */
149
+ export function takeQueuedOrParkedResult(queryCtx: QueryContext, id: string): McpResult | undefined {
150
+ const queued = queryCtx.pendingResults.get(id);
151
+ if (queued !== undefined) {
152
+ queryCtx.pendingResults.delete(id);
153
+ return queued;
154
+ }
155
+ const parked = queryCtx.reapedResults.get(id);
156
+ if (parked !== undefined) {
157
+ queryCtx.reapedResults.delete(id);
158
+ return parked;
159
+ }
160
+ return undefined;
161
+ }
162
+
59
163
  /** One connector call's audit state for the life of a query. `recorded` means an
60
164
  * entry for it has already been appended (or attempted), so neither a re-yielded
61
165
  * result nor the teardown flush can record it twice. */
@@ -148,6 +252,33 @@ export class QueryContext {
148
252
  latestCursor = 0;
149
253
  pendingToolCalls = new Map<string, PendingToolCall>();
150
254
  pendingResults = new Map<string, McpResult>();
255
+ /** 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. */
261
+ reapedResults = new Map<string, McpResult>();
262
+ /** Every tool-call id this query has handed to Pi inside an ENDED turn — the
263
+ * set endToolUseTurn stamps from the turn's content. A forwarded id is one Pi
264
+ * will execute and answer; it must never be emitted again (a lagging stream
265
+ * 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. */
269
+ forwardedToolCallIds = new Set<string>();
270
+ /** Ids whose waiting handler was resolved with strandedToolCallResult. The
271
+ * model has been told these calls failed; forwarding one later would execute
272
+ * it behind the model's back, so every forward path skips them. */
273
+ deadToolCallIds = new Set<string>();
274
+ /** Streamed block indexes suppressed as duplicate or dead tool_use blocks —
275
+ * their deltas and stops must be ignored the same way child-executed indexes
276
+ * are. Per message; reset by resetToolTracking. */
277
+ suppressedStreamIndexes = new Set<number>();
278
+ /** Bumped at every provider callback for this query. Stamped onto handlers at
279
+ * registration so the stranded-handler drain can tell "registered before this
280
+ * callback, provably settled" from "racing this callback's own stream". */
281
+ callbackGeneration = 0;
151
282
  turnToolCallIds: string[] = [];
152
283
  turnToolCalls: TurnToolCallRecord[] = [];
153
284
  /**
@@ -160,13 +291,33 @@ export class QueryContext {
160
291
  * showed. Bounded by the number of tool calls in one query.
161
292
  */
162
293
  queryToolNames = new Map<string, string>();
294
+ /** id → last-known arguments, query-scoped like queryToolNames and for the
295
+ * same reason: a late handler firing after resetToolTracking wiped the
296
+ * per-message records must still be able to exact-match the parked/queued
297
+ * 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). */
299
+ queryToolArgs = new Map<string, Record<string, unknown>>();
163
300
  claimedToolCallIds = new Set<string>();
164
301
  deliveredToolResultIds = new Set<string>();
165
302
  resolvedToolResultIds = new Set<string>();
166
303
  unmatchedToolResultIds = new Set<string>();
167
304
  reportedToolResultMismatch = false;
168
- deferredUserMessages: string[] = [];
305
+ deferredUserMessages: DeferredUserMessage[] = [];
169
306
  handledTerminalError = false;
307
+ // 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:
310
+ // resetTurnState must not clear it.
311
+ committedOutput = false;
312
+ /** 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 —
315
+ * reportToolResultMismatch's needsRebuild/forceRotate mark, the cursor
316
+ * advances on the tool-result-delivery and orphaned-result paths — must
317
+ * no-op so the PARENT's record stays untouched. Assigned at fresh-query
318
+ * setup; deliberately NOT cleared at query end, so a late orphaned tool
319
+ * result arriving after this query settled is still attributed to it. */
320
+ detachedFromSharedSession = false;
170
321
  /** Armed grace timer for ending a tool_use turn whose terminal stream events
171
322
  * (message_delta/message_stop) never arrive. The normal path ends the turn at
172
323
  * message_stop, AFTER message_delta delivered the real output-token count;
@@ -174,12 +325,13 @@ export class QueryContext {
174
325
  * by schedule/cancelToolUseTurnEnd in assistant-stream.ts. */
175
326
  scheduledToolUseEnd: { stream: unknown; timer: ReturnType<typeof setTimeout> } | null = null;
176
327
 
177
- // Tool calls the CHILD executes itself (claude.ai connectors — see
178
- // isChildExecutedTool). Deliberately NOT in turnToolCalls/turnToolCallIds:
179
- // those track calls Pi owes a result for, and Pi owes nothing here. Kept only
180
- // so the child's real result can be recognized when it comes back on the SDK's
181
- // `user` message, and so the streamed block's deltas can be skipped silently
182
- // instead of logging as "unmatched" (which reads like a bug).
328
+ // Tool calls the CHILD executes itself (see isChildExecutedTool).
329
+ // Deliberately NOT in turnToolCalls/turnToolCallIds: those track calls Pi
330
+ // owes a result for, and Pi owes nothing here. CONNECTORS ONLY — kept so the
331
+ // child's real result can be recognized when it comes back on the SDK's
332
+ // `user` message and audited. A child-internal built-in (ToolSearch et al.)
333
+ // never enters this map: its result needs no recognition and no audit, only
334
+ // its streamed deltas need skipping (childExecutedStreamIndexes below).
183
335
  /** tool_use id → raw SDK tool name. */
184
336
  childExecutedToolCalls = new Map<string, string>();
185
337
  /**
@@ -294,12 +446,26 @@ export class QueryContext {
294
446
  this.reportedToolResultMismatch = false;
295
447
  this.childExecutedToolCalls.clear();
296
448
  this.childExecutedStreamIndexes.clear();
449
+ this.suppressedStreamIndexes.clear();
297
450
  }
298
451
 
299
452
  /** Note a tool_use the child runs itself. `streamIndex` is present only on the
300
- * streamed path, where later deltas/stops for that block must be skipped. */
453
+ * streamed path, where later deltas/stops for that block must be skipped
454
+ * that skip applies to every child-executed call. Result recognition and the
455
+ * connector-call audit apply to CONNECTORS only: a child-internal built-in
456
+ * (ToolSearch et al.) is tool plumbing, not account-data access, so nothing
457
+ * about it belongs in the audit trail and no result needs matching. */
301
458
  noteChildExecutedToolCall(id: string | undefined, rawName: string, streamIndex?: number): void {
302
- if (id) {
459
+ if (isConnectorTool(rawName)) {
460
+ // A connector call is an account-visible side effect the child may run
461
+ // before any Pi-visible event; crossing it permanently forbids account
462
+ // replay even when the result or a later text delta never arrives.
463
+ // Child-internal built-ins (ToolSearch et al.) are pure plumbing and
464
+ // deliberately do NOT commit — an early ToolSearch must not make the
465
+ // whole turn non-rotatable.
466
+ this.markOutputCommitted();
467
+ }
468
+ if (id && isConnectorTool(rawName)) {
303
469
  this.childExecutedToolCalls.set(id, rawName);
304
470
  // Both emission paths can see the same call (streamed block, then the
305
471
  // SDK's completed copy), so never overwrite an existing audit state —
@@ -318,6 +484,7 @@ export class QueryContext {
318
484
  recordToolCall(id: string | undefined, toolName: string, args: Record<string, unknown> = {}): void {
319
485
  if (!id) return;
320
486
  this.queryToolNames.set(id, toolName);
487
+ this.queryToolArgs.set(id, args);
321
488
  if (!this.turnToolCallIds.includes(id)) this.turnToolCallIds.push(id);
322
489
  const existing = this.turnToolCalls.find((call) => call.id === id);
323
490
  if (existing) {
@@ -330,6 +497,7 @@ export class QueryContext {
330
497
 
331
498
  updateToolCallArgs(id: string | undefined, args: Record<string, unknown>): void {
332
499
  if (!id) return;
500
+ this.queryToolArgs.set(id, args);
333
501
  const existing = this.turnToolCalls.find((call) => call.id === id);
334
502
  if (existing) existing.arguments = args;
335
503
  }
@@ -338,10 +506,35 @@ export class QueryContext {
338
506
  return Boolean(id && (this.turnToolCallIds.includes(id) || this.turnToolCalls.some((call) => call.id === id)));
339
507
  }
340
508
 
509
+ markOutputCommitted(): void {
510
+ this.committedOutput = true;
511
+ }
512
+
341
513
  claimToolCall(toolName: string, args: Record<string, unknown> = {}): ClaimedToolCall {
342
514
  const unclaimed = this.turnToolCalls.filter((call) => !this.claimedToolCallIds.has(call.id));
343
515
  const byName = unclaimed.filter((call) => call.toolName === toolName);
344
516
  const exact = byName.filter((call) => sameArgs(call.arguments, args));
517
+ // Ids whose RESULT already sits queued or parked. A handler can fire after
518
+ // the message boundary wiped the per-message records — by then Pi has
519
+ // 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
521
+ // and the model re-run an already-executed side-effectful call). An
522
+ // exact-args match here outranks the live sole-same-name fallback below,
523
+ // so a late handler can never steal a live sibling's id while its own
524
+ // result waits; without an exact match it is only a last resort.
525
+ const resultBacked = [...new Set([...this.pendingResults.keys(), ...this.reapedResults.keys()])]
526
+ .filter((id) => !this.claimedToolCallIds.has(id) && this.queryToolNames.get(id) === toolName);
527
+ const backedExact = resultBacked.filter((id) => sameArgs(this.queryToolArgs.get(id), args));
528
+ const claimBacked = (id: string, viaExact: boolean): ClaimedToolCall => {
529
+ this.claimedToolCallIds.add(id);
530
+ return {
531
+ toolCallId: id,
532
+ match: viaExact ? "tool-args" : "tool-name",
533
+ ambiguous: viaExact && backedExact.length > 1,
534
+ available: unclaimed.length,
535
+ ...(!viaExact && hasRecordedArgs(this.queryToolArgs.get(id)) ? { argsMismatch: true } : {}),
536
+ };
537
+ };
345
538
  let chosen: TurnToolCallRecord | undefined;
346
539
  let match: ClaimedToolCall["match"] = "none";
347
540
  let ambiguous = false;
@@ -351,6 +544,8 @@ export class QueryContext {
351
544
  chosen = exact[0];
352
545
  match = "tool-args";
353
546
  ambiguous = exact.length > 1;
547
+ } else if (backedExact.length > 0) {
548
+ return claimBacked(backedExact[0], true);
354
549
  } else if (byName.length === 1) {
355
550
  // A single unclaimed call of this tool type is the only call this
356
551
  // handler can possibly belong to, so claim it even when the recorded
@@ -372,29 +567,36 @@ export class QueryContext {
372
567
  argsMismatch = hasRecordedArgs(byName[0].arguments);
373
568
  }
374
569
 
570
+ // Last resort: nothing live matched and no exact result-backed pairing —
571
+ // a sole result-backed same-name id is still this handler's only possible
572
+ // owner, same reasoning as the live sole-candidate fallback above.
573
+ if (!chosen && resultBacked.length === 1) return claimBacked(resultBacked[0], false);
574
+
375
575
  if (!chosen) return { match: "none", ambiguous: false, available: unclaimed.length };
376
576
  this.claimedToolCallIds.add(chosen.id);
377
577
  return { toolCallId: chosen.id, match, ambiguous, available: unclaimed.length, ...(argsMismatch ? { argsMismatch } : {}) };
378
578
  }
379
579
 
380
580
  /**
381
- * Drain results still queued in `pendingResults` and report what was dropped.
581
+ * Move results still queued in `pendingResults` into the parked store and
582
+ * report what moved.
382
583
  *
383
584
  * Called at a child MESSAGE boundary (message_start / the no-stream-events
384
- * assistant fallback): by then the child has necessarily received every tool
385
- * result for the previous message a handler that matched resolved its result
386
- * directly or from this queue, and one that never matched already returned an
387
- * error. Whatever is still queued therefore belongs to a call whose handler
388
- * gave up, and no consumer will ever come for it. Left in place, each entry
389
- * poisons every later mismatch report for the whole query (queued>0 with 0/0
390
- * counters and no tool names) and forces a session rebuild per turn.
585
+ * assistant fallback). Left in pendingResults, each entry poisons every later
586
+ * mismatch report for the whole query (queued>0 with 0/0 counters and no tool
587
+ * 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
591
+ * instead of dropping: reports stay clean, and a late handler still gets its
592
+ * real result through takeQueuedOrParkedResult.
391
593
  */
392
594
  takeStaleQueuedResults(): Array<{ id: string; toolName: string }> {
393
595
  if (this.pendingResults.size === 0) return [];
394
- const stale = [...this.pendingResults.keys()].map((id) => ({
395
- id,
396
- toolName: this.queryToolNames.get(id) ?? "unknown",
397
- }));
596
+ const stale = [...this.pendingResults.entries()].map(([id, result]) => {
597
+ this.reapedResults.set(id, result);
598
+ return { id, toolName: this.queryToolNames.get(id) ?? "unknown" };
599
+ });
398
600
  this.pendingResults.clear();
399
601
  return stale;
400
602
  }
@@ -463,24 +665,60 @@ export class QueryContext {
463
665
  }
464
666
  }
465
667
 
466
- let _ctx = new QueryContext();
467
- const contextStack: QueryContext[] = [];
668
+ interface QueryLaneState {
669
+ current: QueryContext;
670
+ stack: QueryContext[];
671
+ }
468
672
 
469
- export function ctx(): QueryContext { return _ctx; }
673
+ interface QueryLaneStoreV1 {
674
+ defaultLane: QueryLaneState;
675
+ sessionLanes: Map<string, QueryLaneState>;
676
+ }
470
677
 
471
- export function stackDepth(): number { return contextStack.length; }
678
+ const QUERY_LANES_SYMBOL = Symbol.for("vstack.pi.claude-bridge.query-lanes.v1");
679
+
680
+ function queryLaneStore(): QueryLaneStoreV1 {
681
+ const host = globalThis as Record<symbol, unknown>;
682
+ let store = host[QUERY_LANES_SYMBOL] as QueryLaneStoreV1 | undefined;
683
+ if (!store) {
684
+ store = {
685
+ defaultLane: { current: new QueryContext(), stack: [] },
686
+ sessionLanes: new Map(),
687
+ };
688
+ host[QUERY_LANES_SYMBOL] = store;
689
+ }
690
+ return store;
691
+ }
692
+
693
+ function lane(): QueryLaneState {
694
+ const store = queryLaneStore();
695
+ const sessionId = currentRequestLaneId();
696
+ if (sessionId === undefined) return store.defaultLane;
697
+ let state = store.sessionLanes.get(sessionId);
698
+ if (!state) {
699
+ state = { current: new QueryContext(), stack: [] };
700
+ store.sessionLanes.set(sessionId, state);
701
+ }
702
+ return state;
703
+ }
704
+
705
+ export function ctx(): QueryContext { return lane().current; }
706
+
707
+ export function stackDepth(): number { return lane().stack.length; }
472
708
 
473
709
  export function pushContext(): void {
474
- if (!_ctx.activeQuery) throw new Error("pushContext() called with no active query");
475
- contextStack.push(_ctx);
476
- _ctx = new QueryContext();
710
+ const state = lane();
711
+ if (!state.current.activeQuery) throw new Error("pushContext() called with no active query");
712
+ state.stack.push(state.current);
713
+ state.current = new QueryContext();
477
714
  }
478
715
 
479
716
  export function popContext(): void {
480
- if (contextStack.length === 0) throw new Error("popContext() called with empty stack");
481
- const parent = contextStack[contextStack.length - 1];
482
- parent.deferredUserMessages.push(..._ctx.deferredUserMessages);
483
- _ctx = contextStack.pop()!;
717
+ const state = lane();
718
+ if (state.stack.length === 0) throw new Error("popContext() called with empty stack");
719
+ const parent = state.stack[state.stack.length - 1];
720
+ parent.deferredUserMessages.push(...state.current.deferredUserMessages);
721
+ state.current = state.stack.pop()!;
484
722
  }
485
723
 
486
724
  /** Pop the context that belongs to ONE specific query, wherever it sits.
@@ -495,21 +733,39 @@ export function popContext(): void {
495
733
  * the correct lineage. Returns false when `target` is nowhere in the state —
496
734
  * already popped — so callers can treat that as "someone else tore this down". */
497
735
  export function popContextFor(target: QueryContext): boolean {
498
- if (_ctx === target) {
736
+ const state = lane();
737
+ if (state.current === target) {
499
738
  popContext();
500
739
  return true;
501
740
  }
502
- const idx = contextStack.indexOf(target);
741
+ const idx = state.stack.indexOf(target);
503
742
  if (idx < 0) return false;
504
- const parent = idx > 0 ? contextStack[idx - 1] : undefined;
743
+ const parent = idx > 0 ? state.stack[idx - 1] : undefined;
505
744
  parent?.deferredUserMessages.push(...target.deferredUserMessages);
506
- contextStack.splice(idx, 1);
745
+ state.stack.splice(idx, 1);
507
746
  return true;
508
747
  }
509
748
 
510
- // Test-only: drop all state so test files can start from a clean module.
511
- // Not called from production.
749
+ // Test-only: drop every lane so test files can start clean.
512
750
  export function resetStack(): void {
513
- _ctx = new QueryContext();
514
- contextStack.length = 0;
751
+ clearQueryLanes();
752
+ }
753
+
754
+ export function deleteQueryLane(sessionId: string | undefined): void {
755
+ const store = queryLaneStore();
756
+ if (sessionId === undefined) {
757
+ store.defaultLane.current = new QueryContext();
758
+ store.defaultLane.stack.length = 0;
759
+ } else store.sessionLanes.delete(sessionId);
760
+ }
761
+
762
+ export function clearQueryLanes(): void {
763
+ const store = queryLaneStore();
764
+ store.sessionLanes.clear();
765
+ store.defaultLane.current = new QueryContext();
766
+ store.defaultLane.stack.length = 0;
767
+ }
768
+
769
+ export function __testQueryLaneCount(): number {
770
+ return queryLaneStore().sessionLanes.size;
515
771
  }
package/src/rate-limit.ts CHANGED
@@ -16,12 +16,6 @@ function coerceMessageText(value: unknown): string {
16
16
  catch { return String(value); }
17
17
  }
18
18
 
19
- /** Narrow test: this message is about EXTRA usage specifically — the paid
20
- * beyond-plan pool the /extra-usage helper flow can enable. */
21
- export function isExtraUsageRequiredMessage(value: unknown): boolean {
22
- return /extra[-\s]?usage|overage|extra usage billing|extra usage credits|1M context/i.test(coerceMessageText(value));
23
- }
24
-
25
19
  /** Broad test: any "a usage limit was genuinely reached" message, matched
26
20
  * against the CLI's own copy (SDK `USAGE_LIMIT_ERROR_PREFIXES`, e.g. "You've
27
21
  * hit your weekly limit…"). Substring rather than prefix match because the
@@ -47,13 +41,18 @@ export function uniqueNonEmptyLines(values: unknown[]): string[] {
47
41
  * `SDKRateLimitInfo.resetsAt` is a bare number in epoch SECONDS (measured:
48
42
  * treating it as ms rendered "resets Jan 21, 1970" for a Jul 2026 reset).
49
43
  * The unit is undocumented, so detect by magnitude — epoch seconds stay below
50
- * 1e12 until the year 33658, epoch ms passed 1e12 in 2001 and accept ISO
51
- * strings for older payloads. */
44
+ * 1e12 until the year 33658, epoch ms passed 1e12 in 2001. A numeric STRING
45
+ * gets the same magnitude treatment (rate_limit_event payloads have carried
46
+ * both), and anything else falls back to Date.parse for ISO strings. */
52
47
  export function resetTimestampMs(value: unknown): number | undefined {
53
- let parsed = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : Number.NaN;
54
- if (!Number.isFinite(parsed)) return undefined;
55
- if (typeof value === "number" && Math.abs(parsed) < 1e12) parsed *= 1000;
56
- return parsed;
48
+ if (typeof value === "number" && Number.isFinite(value)) {
49
+ return Math.abs(value) < 1e12 ? value * 1000 : value;
50
+ }
51
+ if (typeof value !== "string" || !value.trim()) return undefined;
52
+ const numeric = Number(value);
53
+ if (Number.isFinite(numeric)) return Math.abs(numeric) < 1e12 ? numeric * 1000 : numeric;
54
+ const parsed = Date.parse(value);
55
+ return Number.isFinite(parsed) ? parsed : undefined;
57
56
  }
58
57
 
59
58
  export function formatResetTimestamp(value: unknown): string {
@@ -76,8 +75,12 @@ export function normalizeRateLimitUtilization(value: unknown): number | undefine
76
75
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined;
77
76
  if (value === 0) return 0;
78
77
  // Claude SDK payloads have appeared as both fractions and percentages.
79
- // Exact 1 is unit-ambiguous (1% vs 100%), so do not use it for allowed-warning copy.
80
- if (value > 0 && value < 1) return value * 100;
78
+ // Exact 1 is unit-ambiguous (1% vs 100%); read it as the fractional form
79
+ // (100%) because that is the fail-closed direction under the fraction
80
+ // convention 1 is the fully-consumed case the warning exists to surface,
81
+ // while under the percent convention 1% sits below the threshold anyway,
82
+ // so nothing is lost by warning (VST-16).
83
+ if (value > 0 && value <= 1) return value * 100;
81
84
  if (value > 1 && value <= 100) return value;
82
85
  return undefined;
83
86
  }
@@ -0,0 +1,36 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ /**
4
+ * Selects the Pi agent session whose provider request is currently executing.
5
+ *
6
+ * Pi forwards a stable `SimpleStreamOptions.sessionId` on every model request,
7
+ * including tool-result continuations. The bridge may serve the parent agent
8
+ * and several in-process subagents concurrently, so process-global query state
9
+ * cannot identify the request that a callback belongs to. AsyncLocalStorage
10
+ * keeps that identity attached to every promise, SDK iterator, and timer born
11
+ * during the provider call without threading the id through every helper.
12
+ */
13
+ const REQUEST_LANE_SYMBOL = Symbol.for("vstack.pi.claude-bridge.request-lane.v1");
14
+
15
+ function requestLaneStorage(): AsyncLocalStorage<string> {
16
+ const host = globalThis as Record<symbol, unknown>;
17
+ let storage = host[REQUEST_LANE_SYMBOL] as AsyncLocalStorage<string> | undefined;
18
+ if (!storage) {
19
+ storage = new AsyncLocalStorage<string>();
20
+ host[REQUEST_LANE_SYMBOL] = storage;
21
+ }
22
+ return storage;
23
+ }
24
+
25
+ /** Run `callback` in the lane for `sessionId`. `undefined` selects the default
26
+ * (direct-host) lane even when a named lane is active — a listener that fires
27
+ * inside another request's context must not inherit that request's lane. */
28
+ export function runInRequestLane<T>(sessionId: string | undefined, callback: () => T): T {
29
+ const storage = requestLaneStorage();
30
+ if (sessionId !== undefined) return storage.run(sessionId, callback);
31
+ return storage.getStore() === undefined ? callback() : storage.exit(callback);
32
+ }
33
+
34
+ export function currentRequestLaneId(): string | undefined {
35
+ return requestLaneStorage().getStore();
36
+ }
@@ -0,0 +1,16 @@
1
+ // Shared mutable SDK query factory + its test seam. In its own module so both
2
+ // the provider entry (index.ts) and the account host spawn children through
3
+ // the same seam — tests swap the factory once and every spawn path honors it.
4
+
5
+ import { query } from "@anthropic-ai/claude-agent-sdk";
6
+
7
+ export type SdkQueryFactory = typeof query;
8
+
9
+ // ESM live binding: importers read the CURRENT factory at call time.
10
+ export let sdkQueryFactory: SdkQueryFactory = query;
11
+
12
+ /** Test seam for exercising the real bridge retry/session orchestration without
13
+ * spending Claude usage. Production never calls this. */
14
+ export function __testSetSdkQueryFactory(factory?: SdkQueryFactory): void {
15
+ sdkQueryFactory = factory ?? query;
16
+ }