@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.
- package/README.md +40 -135
- package/bundle/connector-inventory.js +6 -3
- package/bundle/index.js +2432 -1123
- package/package.json +11 -20
- package/src/account-host.ts +112 -0
- package/src/account-router.ts +272 -0
- package/src/agents-md.ts +54 -10
- package/src/assistant-stream.ts +189 -50
- package/src/bridge-commands.ts +86 -0
- package/src/bridge-state.ts +157 -14
- package/src/config.ts +170 -20
- package/src/connector-cache.ts +43 -13
- package/src/connector-inventory.ts +14 -7
- package/src/connector-runtime.ts +158 -0
- package/src/connectors.ts +286 -40
- package/src/consume-query.ts +312 -0
- package/src/convert.ts +6 -10
- package/src/debug.ts +64 -6
- package/src/index.ts +769 -702
- package/src/models.ts +0 -7
- package/src/native-provider.ts +9 -4
- package/src/prompt-context.ts +5 -1
- package/src/query-options.ts +183 -0
- package/src/query-state.ts +296 -40
- package/src/rate-limit.ts +17 -14
- package/src/request-lane.ts +36 -0
- package/src/sdk-query.ts +16 -0
- package/src/session-persistence.ts +369 -54
- package/src/tool-pairing-audit.ts +69 -0
package/src/query-state.ts
CHANGED
|
@@ -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:
|
|
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 (
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
// `user` message
|
|
182
|
-
//
|
|
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 (
|
|
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
|
-
*
|
|
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)
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
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.
|
|
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
|
-
|
|
467
|
-
|
|
668
|
+
interface QueryLaneState {
|
|
669
|
+
current: QueryContext;
|
|
670
|
+
stack: QueryContext[];
|
|
671
|
+
}
|
|
468
672
|
|
|
469
|
-
|
|
673
|
+
interface QueryLaneStoreV1 {
|
|
674
|
+
defaultLane: QueryLaneState;
|
|
675
|
+
sessionLanes: Map<string, QueryLaneState>;
|
|
676
|
+
}
|
|
470
677
|
|
|
471
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
parent.
|
|
483
|
-
|
|
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
|
-
|
|
736
|
+
const state = lane();
|
|
737
|
+
if (state.current === target) {
|
|
499
738
|
popContext();
|
|
500
739
|
return true;
|
|
501
740
|
}
|
|
502
|
-
const idx =
|
|
741
|
+
const idx = state.stack.indexOf(target);
|
|
503
742
|
if (idx < 0) return false;
|
|
504
|
-
const parent = idx > 0 ?
|
|
743
|
+
const parent = idx > 0 ? state.stack[idx - 1] : undefined;
|
|
505
744
|
parent?.deferredUserMessages.push(...target.deferredUserMessages);
|
|
506
|
-
|
|
745
|
+
state.stack.splice(idx, 1);
|
|
507
746
|
return true;
|
|
508
747
|
}
|
|
509
748
|
|
|
510
|
-
// Test-only: drop
|
|
511
|
-
// Not called from production.
|
|
749
|
+
// Test-only: drop every lane so test files can start clean.
|
|
512
750
|
export function resetStack(): void {
|
|
513
|
-
|
|
514
|
-
|
|
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
|
|
51
|
-
*
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return
|
|
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%)
|
|
80
|
-
|
|
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
|
+
}
|
package/src/sdk-query.ts
ADDED
|
@@ -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
|
+
}
|