@vanillagreen/pi-claude-bridge 1.8.0 → 2.0.0
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 +78 -15
- package/bundle/connector-inventory.js +147 -0
- package/bundle/index.js +9367 -8348
- package/package.json +11 -7
- package/src/agents-md.ts +5 -7
- package/src/assistant-stream.ts +574 -0
- package/src/auth-presence.ts +6 -50
- package/src/bridge-state.ts +178 -0
- package/src/claude-executable.ts +264 -0
- package/src/config.ts +13 -7
- package/src/connector-audit.ts +203 -0
- package/src/connector-cache.ts +118 -0
- package/src/connector-inventory.ts +333 -0
- package/src/connectors.ts +500 -0
- package/src/convert.ts +14 -0
- package/src/debug.ts +80 -0
- package/src/index.ts +424 -1736
- package/src/models.ts +22 -1
- package/src/native-provider.ts +89 -0
- package/src/query-state.ts +260 -9
- package/src/query-teardown.ts +45 -0
- package/src/rate-limit.ts +95 -0
- package/src/session-persistence.ts +334 -0
- package/src/stream-idle-watchdog.ts +134 -0
- package/src/tool-mapping.ts +53 -0
- package/src/tool-pairing-audit.ts +48 -0
- package/src/typebox-to-zod.ts +9 -3
package/src/models.ts
CHANGED
|
@@ -3,15 +3,19 @@
|
|
|
3
3
|
// Extracted from index.ts so tests can import without activating the extension.
|
|
4
4
|
|
|
5
5
|
export const FABLE_MODEL_ID = "claude-fable-5";
|
|
6
|
+
// Opus 4.8 is both a selectable model and the safety-fallback target for the two
|
|
7
|
+
// primaries whose classifiers can decline a turn (Fable 5, Opus 5).
|
|
6
8
|
export const FABLE_FALLBACK_MODEL_ID = "claude-opus-4-8";
|
|
9
|
+
export const OPUS_5_MODEL_ID = "claude-opus-5";
|
|
7
10
|
export const SONNET_5_MODEL_ID = "claude-sonnet-5";
|
|
8
11
|
|
|
9
12
|
export function fallbackModelForPrimaryModel(modelId: string): string | undefined {
|
|
10
|
-
return modelId === FABLE_MODEL_ID ? FABLE_FALLBACK_MODEL_ID : undefined;
|
|
13
|
+
return modelId === FABLE_MODEL_ID || modelId === OPUS_5_MODEL_ID ? FABLE_FALLBACK_MODEL_ID : undefined;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
export const MODEL_IDS_IN_ORDER = [
|
|
14
17
|
FABLE_MODEL_ID,
|
|
18
|
+
OPUS_5_MODEL_ID,
|
|
15
19
|
FABLE_FALLBACK_MODEL_ID,
|
|
16
20
|
"claude-opus-4-7",
|
|
17
21
|
"claude-opus-4-6",
|
|
@@ -40,6 +44,15 @@ const FALLBACK_MODELS: Record<string, BridgeModelMetadata> = {
|
|
|
40
44
|
contextWindow: 1000000,
|
|
41
45
|
maxTokens: 128000,
|
|
42
46
|
},
|
|
47
|
+
[OPUS_5_MODEL_ID]: {
|
|
48
|
+
id: OPUS_5_MODEL_ID,
|
|
49
|
+
name: "Claude Opus 5",
|
|
50
|
+
reasoning: true,
|
|
51
|
+
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
|
|
52
|
+
input: ["text", "image"],
|
|
53
|
+
contextWindow: 1000000,
|
|
54
|
+
maxTokens: 128000,
|
|
55
|
+
},
|
|
43
56
|
[FABLE_FALLBACK_MODEL_ID]: {
|
|
44
57
|
id: FABLE_FALLBACK_MODEL_ID,
|
|
45
58
|
name: "Claude Opus 4.8",
|
|
@@ -53,12 +66,20 @@ const FALLBACK_MODELS: Record<string, BridgeModelMetadata> = {
|
|
|
53
66
|
id: SONNET_5_MODEL_ID,
|
|
54
67
|
name: "Claude Sonnet 5",
|
|
55
68
|
reasoning: true,
|
|
69
|
+
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
|
|
56
70
|
input: ["text", "image"],
|
|
57
71
|
contextWindow: 1000000,
|
|
58
72
|
maxTokens: 128000,
|
|
59
73
|
},
|
|
60
74
|
};
|
|
61
75
|
|
|
76
|
+
// Human label for the safety-fallback notice. Every id that participates in a
|
|
77
|
+
// fallbackModelForPrimaryModel pairing has an entry above; the raw id is the
|
|
78
|
+
// last-resort label so an unmapped pairing still reads sensibly.
|
|
79
|
+
export function modelDisplayName(modelId: string): string {
|
|
80
|
+
return FALLBACK_MODELS[modelId]?.name ?? modelId;
|
|
81
|
+
}
|
|
82
|
+
|
|
62
83
|
// Project pi-ai's model entries down to the fields pi's registerProvider expects,
|
|
63
84
|
// keep MODEL_IDS_IN_ORDER ordering, and fill bridge-owned future IDs when pi-ai
|
|
64
85
|
// has not shipped metadata for them yet. Unknown missing IDs are still dropped.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Native pi >=0.81 provider construction (bridge 2.x).
|
|
2
|
+
//
|
|
3
|
+
// Bridge 1.x could not register unconditionally: pi's legacy
|
|
4
|
+
// ModelRegistry.hasConfiguredAuth() treated the dummy `apiKey: "not-used"` as
|
|
5
|
+
// "configured", so the models looked connected while every request failed at
|
|
6
|
+
// spawn. 1.x therefore gated register/unregister on real credential presence
|
|
7
|
+
// (decideRegistration). The native Provider form inverts that: the provider is
|
|
8
|
+
// ALWAYS registered, and `auth.apiKey.check/resolve` report configured-ness
|
|
9
|
+
// from the same existence-only probes, so pi itself hides claude-bridge models
|
|
10
|
+
// while no Claude credentials are present and shows them when they appear.
|
|
11
|
+
//
|
|
12
|
+
// What the native form does NOT change (see DEVELOPMENT.md "Provider
|
|
13
|
+
// registration"): the process-global primary-instance/stream-guard tokens stay
|
|
14
|
+
// (pi's registerNativeProvider is replace-by-id, so an unguarded subagent
|
|
15
|
+
// re-registration would still swap in its own streamSimple), and the pre-spawn
|
|
16
|
+
// credential fail-fast in streamSimple stays (a mid-session logout must fail
|
|
17
|
+
// the turn with an actionable message even if the picker snapshot is stale).
|
|
18
|
+
//
|
|
19
|
+
// SECURITY: like auth-presence.ts, this module only reports credential
|
|
20
|
+
// EXISTENCE. resolve() hands pi the same dummy key the legacy config carried —
|
|
21
|
+
// the Claude Code subprocess does its own authentication; pi never needs a
|
|
22
|
+
// real secret, so none is read or exposed.
|
|
23
|
+
|
|
24
|
+
import { hasClaudeCredentials } from "./auth-presence.js";
|
|
25
|
+
import { PROVIDER_ID } from "./convert.js";
|
|
26
|
+
|
|
27
|
+
export const NATIVE_PROVIDER_UNSUPPORTED_MESSAGE =
|
|
28
|
+
"Claude bridge 2.x requires pi >= 0.81 (native provider API). Upgrade the host pi, or pin @vanillagreen/pi-claude-bridge@1.x.";
|
|
29
|
+
|
|
30
|
+
/** pi-ai gained createProvider in 0.81 alongside the object-form
|
|
31
|
+
* registerProvider; its presence is the capability signal for both. */
|
|
32
|
+
export function supportsNativeProvider(piAi: unknown): boolean {
|
|
33
|
+
return typeof (piAi as { createProvider?: unknown })?.createProvider === "function";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Auth source label for pi's status UI, chosen by the same existence-only
|
|
37
|
+
* probes hasClaudeCredentials uses. Never reads credential contents. */
|
|
38
|
+
export function claudeAuthSourceLabel(env: NodeJS.ProcessEnv = process.env): string {
|
|
39
|
+
if (env.CLAUDE_CODE_OAUTH_TOKEN?.trim()) return "CLAUDE_CODE_OAUTH_TOKEN";
|
|
40
|
+
if (env.ANTHROPIC_API_KEY?.trim()) return "ANTHROPIC_API_KEY";
|
|
41
|
+
if (env.ANTHROPIC_AUTH_TOKEN?.trim()) return "ANTHROPIC_AUTH_TOKEN";
|
|
42
|
+
return "Claude Code login";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build the Provider object for pi.registerProvider(provider).
|
|
47
|
+
*
|
|
48
|
+
* `piAi` is the HOST's pi-ai namespace (the bundle externalizes it), passed in
|
|
49
|
+
* rather than imported so a pre-0.81 host fails the supportsNativeProvider()
|
|
50
|
+
* check with a clear message instead of crashing module load on a missing
|
|
51
|
+
* named export. `env` is bindable for tests; the credential probes themselves
|
|
52
|
+
* run at check/resolve CALL time, so a login/logout between calls is seen.
|
|
53
|
+
*/
|
|
54
|
+
export function buildNativeProvider(
|
|
55
|
+
piAi: unknown,
|
|
56
|
+
models: Array<Record<string, unknown>>,
|
|
57
|
+
streamSimple: (...args: unknown[]) => unknown,
|
|
58
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
59
|
+
): unknown {
|
|
60
|
+
if (!supportsNativeProvider(piAi)) throw new Error(NATIVE_PROVIDER_UNSUPPORTED_MESSAGE);
|
|
61
|
+
// The legacy config path stamped provider/api/baseUrl onto each model during
|
|
62
|
+
// composition; createProvider passes models through verbatim, so stamp here.
|
|
63
|
+
const stamped = models.map((model) => ({ api: "claude-bridge", baseUrl: "claude-bridge", provider: PROVIDER_ID, ...model }));
|
|
64
|
+
// The Claude Code subprocess router IS the implementation for both stream
|
|
65
|
+
// entry points — there is no raw-API shape to dispatch to.
|
|
66
|
+
const streams = {
|
|
67
|
+
stream: streamSimple,
|
|
68
|
+
streamSimple,
|
|
69
|
+
};
|
|
70
|
+
return (piAi as { createProvider: (input: unknown) => unknown }).createProvider({
|
|
71
|
+
id: PROVIDER_ID,
|
|
72
|
+
name: "Claude (Claude Code)",
|
|
73
|
+
baseUrl: "claude-bridge",
|
|
74
|
+
auth: {
|
|
75
|
+
apiKey: {
|
|
76
|
+
name: "Claude Code credentials",
|
|
77
|
+
// check() exists so pi's availability pass never has to call
|
|
78
|
+
// resolve(): both are existence-only, but check is the documented
|
|
79
|
+
// side-effect-free probe.
|
|
80
|
+
check: async () => (hasClaudeCredentials(env) ? { type: "api_key" as const, source: claudeAuthSourceLabel(env) } : undefined),
|
|
81
|
+
resolve: async () => (hasClaudeCredentials(env)
|
|
82
|
+
? { auth: { apiKey: "not-used" }, source: claudeAuthSourceLabel(env) }
|
|
83
|
+
: undefined),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
models: stamped,
|
|
87
|
+
api: streams,
|
|
88
|
+
});
|
|
89
|
+
}
|
package/src/query-state.ts
CHANGED
|
@@ -14,6 +14,60 @@ export interface PendingToolCall {
|
|
|
14
14
|
resolve: (result: McpResult) => void;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// Why pending MCP handlers were drained without a real tool result. A drained
|
|
18
|
+
// handler is waiting on a result pi will now never deliver, so the drain must
|
|
19
|
+
// resolve as an error — never as a successful result whose text merely says the
|
|
20
|
+
// turn died, which a consumer cannot tell apart from a tool that genuinely
|
|
21
|
+
// returned that string. The cause is carried because an abort, an idle timeout,
|
|
22
|
+
// and a plain end-with-stragglers are different things to act on.
|
|
23
|
+
export type ToolCallDrainCause = "abort" | "stream-idle-timeout" | "query-end";
|
|
24
|
+
|
|
25
|
+
const DRAIN_CAUSE_TEXT: Record<ToolCallDrainCause, string> = {
|
|
26
|
+
"abort": "the turn was aborted",
|
|
27
|
+
"stream-idle-timeout": "the Claude Code stream went idle and the turn timed out",
|
|
28
|
+
"query-end": "the query ended",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function interruptedToolCallResult(cause: ToolCallDrainCause): McpResult {
|
|
32
|
+
return {
|
|
33
|
+
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.` }],
|
|
34
|
+
isError: true,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Precedence matches the forceRotate expression at the query-teardown site: an
|
|
39
|
+
// explicit abort (pi's signal or our own abort handler) outranks a stream-idle
|
|
40
|
+
// timeout, which outranks a plain end with stragglers.
|
|
41
|
+
export function toolCallDrainCause(flags: { wasAborted?: boolean; signalAborted?: boolean; streamIdleTimedOut?: boolean }): ToolCallDrainCause {
|
|
42
|
+
if (flags.wasAborted || flags.signalAborted) return "abort";
|
|
43
|
+
if (flags.streamIdleTimedOut) return "stream-idle-timeout";
|
|
44
|
+
return "query-end";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Resolves every handler still waiting on `queryCtx` with an error result naming
|
|
48
|
+
* `cause`, clears the map, and returns how many were drained. Scoped to the one
|
|
49
|
+
* context it is given — never touches a sibling or parent query's handlers. */
|
|
50
|
+
export function drainPendingToolCalls(queryCtx: QueryContext, cause: ToolCallDrainCause): number {
|
|
51
|
+
const drained = queryCtx.pendingToolCalls.size;
|
|
52
|
+
if (drained === 0) return 0;
|
|
53
|
+
const result = interruptedToolCallResult(cause);
|
|
54
|
+
for (const pending of queryCtx.pendingToolCalls.values()) pending.resolve(result);
|
|
55
|
+
queryCtx.pendingToolCalls.clear();
|
|
56
|
+
return drained;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One connector call's audit state for the life of a query. `recorded` means an
|
|
60
|
+
* entry for it has already been appended (or attempted), so neither a re-yielded
|
|
61
|
+
* result nor the teardown flush can record it twice. */
|
|
62
|
+
export interface ConnectorCallAuditState {
|
|
63
|
+
name: string;
|
|
64
|
+
/** The child session that issued it, captured when the call was seen — a
|
|
65
|
+
* continuation query gets a new one, and a call is audited against the session
|
|
66
|
+
* that actually made it. */
|
|
67
|
+
childSessionId?: string;
|
|
68
|
+
recorded: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
17
71
|
export interface TurnToolCallRecord {
|
|
18
72
|
id: string;
|
|
19
73
|
toolName: string;
|
|
@@ -25,6 +79,12 @@ export interface ClaimedToolCall {
|
|
|
25
79
|
match: "tool-args" | "tool-name" | "none";
|
|
26
80
|
ambiguous: boolean;
|
|
27
81
|
available: number;
|
|
82
|
+
/** True when the claim went through the sole-same-name fallback even though
|
|
83
|
+
* the recorded call had (different) arguments. Recorded args come from the
|
|
84
|
+
* raw streamed input while the handler receives the MCP server's
|
|
85
|
+
* schema-validated copy, so a benign divergence (stripped unknown key,
|
|
86
|
+
* applied default) must not strand the call — but it is worth a diagnostic. */
|
|
87
|
+
argsMismatch?: boolean;
|
|
28
88
|
}
|
|
29
89
|
|
|
30
90
|
export interface ToolResultProgress {
|
|
@@ -90,6 +150,16 @@ export class QueryContext {
|
|
|
90
150
|
pendingResults = new Map<string, McpResult>();
|
|
91
151
|
turnToolCallIds: string[] = [];
|
|
92
152
|
turnToolCalls: TurnToolCallRecord[] = [];
|
|
153
|
+
/**
|
|
154
|
+
* id → Pi tool name for every tool call this QUERY recorded, across all child
|
|
155
|
+
* messages. Deliberately NOT cleared by resetToolTracking: per-message tracking
|
|
156
|
+
* resets at every message boundary, but `pendingResults` is query-scoped, so a
|
|
157
|
+
* result stranded there outlives the message that named it. Without this map a
|
|
158
|
+
* teardown report can only say "1 queued" with empty toolNames and 0/0
|
|
159
|
+
* counters — which is exactly the unactionable record the 2026-07-28 diag log
|
|
160
|
+
* showed. Bounded by the number of tool calls in one query.
|
|
161
|
+
*/
|
|
162
|
+
queryToolNames = new Map<string, string>();
|
|
93
163
|
claimedToolCallIds = new Set<string>();
|
|
94
164
|
deliveredToolResultIds = new Set<string>();
|
|
95
165
|
resolvedToolResultIds = new Set<string>();
|
|
@@ -97,6 +167,83 @@ export class QueryContext {
|
|
|
97
167
|
reportedToolResultMismatch = false;
|
|
98
168
|
deferredUserMessages: string[] = [];
|
|
99
169
|
handledTerminalError = false;
|
|
170
|
+
/** Armed grace timer for ending a tool_use turn whose terminal stream events
|
|
171
|
+
* (message_delta/message_stop) never arrive. The normal path ends the turn at
|
|
172
|
+
* message_stop, AFTER message_delta delivered the real output-token count;
|
|
173
|
+
* this is the deadlock backstop for streams that go silent instead. Managed
|
|
174
|
+
* by schedule/cancelToolUseTurnEnd in assistant-stream.ts. */
|
|
175
|
+
scheduledToolUseEnd: { stream: unknown; timer: ReturnType<typeof setTimeout> } | null = null;
|
|
176
|
+
|
|
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).
|
|
183
|
+
/** tool_use id → raw SDK tool name. */
|
|
184
|
+
childExecutedToolCalls = new Map<string, string>();
|
|
185
|
+
/**
|
|
186
|
+
* The same calls, for the connector-call audit trail (see connector-audit.ts).
|
|
187
|
+
*
|
|
188
|
+
* Query-scoped and deliberately NOT cleared by resetToolTracking: that runs at
|
|
189
|
+
* every child message boundary, and a call issued in one child message is only
|
|
190
|
+
* reconciled after that message ends. Clearing it there would make an abandoned
|
|
191
|
+
* call unrecordable at teardown — which is the one case the trail exists for.
|
|
192
|
+
*/
|
|
193
|
+
connectorCallAudit = new Map<string, ConnectorCallAuditState>();
|
|
194
|
+
/** Claude Code session id for this query, from the SDK's `system` init message.
|
|
195
|
+
* Undefined until it arrives; the audit trail omits the field rather than
|
|
196
|
+
* guessing. */
|
|
197
|
+
childSessionId: string | undefined;
|
|
198
|
+
/** Anthropic content-block indexes of the current assistant message that carry
|
|
199
|
+
* a child-executed tool_use. Scoped to one message: cleared at message_start,
|
|
200
|
+
* and an index is released as soon as a new block starts there. */
|
|
201
|
+
childExecutedStreamIndexes = new Set<number>();
|
|
202
|
+
|
|
203
|
+
// Usage accounting for a Pi turn that spans SEVERAL child assistant messages.
|
|
204
|
+
//
|
|
205
|
+
// Every child message is a separate billed API call, and each reports its own
|
|
206
|
+
// counters — `message_start`/`message_delta` REPLACE rather than accumulate. A
|
|
207
|
+
// Pi turn used to end at the first tool call, so one Pi message meant one child
|
|
208
|
+
// message and replacing was right. A turn containing a child-executed connector
|
|
209
|
+
// call now keeps running across the child's follow-up messages, so replacing
|
|
210
|
+
// would silently drop everything the earlier ones billed (measured: 55,685
|
|
211
|
+
// cache-write tokens lost on a single connector turn).
|
|
212
|
+
//
|
|
213
|
+
// So: `turnUsageCarry` holds the totals of the child messages already COMPLETE
|
|
214
|
+
// in this Pi turn, `currentMessageUsage` holds the one in flight, and the Pi
|
|
215
|
+
// message reports their sum. Summing is the correct model for input and cache
|
|
216
|
+
// too — each call bills its own.
|
|
217
|
+
turnUsageCarry = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
218
|
+
currentMessageUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
219
|
+
/** Anthropic id of the child message `currentMessageUsage` describes. */
|
|
220
|
+
currentMessageId: string | undefined;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Declare which child message the following usage belongs to, banking the
|
|
224
|
+
* previous one's counters into the turn total.
|
|
225
|
+
*
|
|
226
|
+
* Keyed on the MESSAGE ID rather than on the call site, because both paths
|
|
227
|
+
* that see a message boundary can fire for the SAME message: `message_start`
|
|
228
|
+
* arrives on the stream, and the SDK then yields that message again in
|
|
229
|
+
* completed form. Banking per call site double-counted whenever the completed
|
|
230
|
+
* copy took the no-stream-events branch — which it does whenever a message
|
|
231
|
+
* produced no content blocks, since `turnSawStreamEvent` only tracks those.
|
|
232
|
+
*
|
|
233
|
+
* With no id on either side (older/streamless shapes) this degrades to
|
|
234
|
+
* banking on every call, which is what each caller means when it cannot
|
|
235
|
+
* prove otherwise.
|
|
236
|
+
*/
|
|
237
|
+
beginChildMessage(messageId?: unknown): void {
|
|
238
|
+
const id = typeof messageId === "string" && messageId.length > 0 ? messageId : undefined;
|
|
239
|
+
if (id !== undefined && id === this.currentMessageId) return; // same message
|
|
240
|
+
this.turnUsageCarry.input += this.currentMessageUsage.input;
|
|
241
|
+
this.turnUsageCarry.output += this.currentMessageUsage.output;
|
|
242
|
+
this.turnUsageCarry.cacheRead += this.currentMessageUsage.cacheRead;
|
|
243
|
+
this.turnUsageCarry.cacheWrite += this.currentMessageUsage.cacheWrite;
|
|
244
|
+
this.currentMessageUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
245
|
+
this.currentMessageId = id;
|
|
246
|
+
}
|
|
100
247
|
|
|
101
248
|
// Per-turn (reset together)
|
|
102
249
|
turnOutput: AssistantMessage | null = null;
|
|
@@ -121,6 +268,17 @@ export class QueryContext {
|
|
|
121
268
|
this.turnSawStreamEvent = false;
|
|
122
269
|
this.turnSawToolCall = false;
|
|
123
270
|
this.handledTerminalError = false;
|
|
271
|
+
// A new pi message means the previous turn's stream is done with; an armed
|
|
272
|
+
// end-timer for it must not fire into the new turn's state.
|
|
273
|
+
if (this.scheduledToolUseEnd) {
|
|
274
|
+
clearTimeout(this.scheduledToolUseEnd.timer);
|
|
275
|
+
this.scheduledToolUseEnd = null;
|
|
276
|
+
}
|
|
277
|
+
// Usage accounting IS per-Pi-message, so it resets with the message it
|
|
278
|
+
// describes — unlike tool-call tracking below.
|
|
279
|
+
this.turnUsageCarry = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
280
|
+
this.currentMessageUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
281
|
+
this.currentMessageId = undefined;
|
|
124
282
|
// Tool-call tracking is NOT reset here — it persists across the
|
|
125
283
|
// tool-result delivery callback for the same assistant message. New
|
|
126
284
|
// assistant messages call resetToolTracking() explicitly.
|
|
@@ -134,10 +292,32 @@ export class QueryContext {
|
|
|
134
292
|
this.resolvedToolResultIds.clear();
|
|
135
293
|
this.unmatchedToolResultIds.clear();
|
|
136
294
|
this.reportedToolResultMismatch = false;
|
|
295
|
+
this.childExecutedToolCalls.clear();
|
|
296
|
+
this.childExecutedStreamIndexes.clear();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** 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. */
|
|
301
|
+
noteChildExecutedToolCall(id: string | undefined, rawName: string, streamIndex?: number): void {
|
|
302
|
+
if (id) {
|
|
303
|
+
this.childExecutedToolCalls.set(id, rawName);
|
|
304
|
+
// Both emission paths can see the same call (streamed block, then the
|
|
305
|
+
// SDK's completed copy), so never overwrite an existing audit state —
|
|
306
|
+
// that would resurrect one already recorded.
|
|
307
|
+
if (!this.connectorCallAudit.has(id)) {
|
|
308
|
+
this.connectorCallAudit.set(id, {
|
|
309
|
+
name: rawName,
|
|
310
|
+
...(this.childSessionId ? { childSessionId: this.childSessionId } : {}),
|
|
311
|
+
recorded: false,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (typeof streamIndex === "number") this.childExecutedStreamIndexes.add(streamIndex);
|
|
137
316
|
}
|
|
138
317
|
|
|
139
318
|
recordToolCall(id: string | undefined, toolName: string, args: Record<string, unknown> = {}): void {
|
|
140
319
|
if (!id) return;
|
|
320
|
+
this.queryToolNames.set(id, toolName);
|
|
141
321
|
if (!this.turnToolCallIds.includes(id)) this.turnToolCallIds.push(id);
|
|
142
322
|
const existing = this.turnToolCalls.find((call) => call.id === id);
|
|
143
323
|
if (existing) {
|
|
@@ -166,22 +346,57 @@ export class QueryContext {
|
|
|
166
346
|
let match: ClaimedToolCall["match"] = "none";
|
|
167
347
|
let ambiguous = false;
|
|
168
348
|
|
|
349
|
+
let argsMismatch = false;
|
|
169
350
|
if (exact.length > 0) {
|
|
170
351
|
chosen = exact[0];
|
|
171
352
|
match = "tool-args";
|
|
172
353
|
ambiguous = exact.length > 1;
|
|
173
|
-
} else if (byName.length === 1
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
354
|
+
} else if (byName.length === 1) {
|
|
355
|
+
// A single unclaimed call of this tool type is the only call this
|
|
356
|
+
// handler can possibly belong to, so claim it even when the recorded
|
|
357
|
+
// arguments differ. Two known benign sources of divergence:
|
|
358
|
+
// - the SDK can invoke the handler after content_block_start but
|
|
359
|
+
// before input_json_delta/content_block_stop finalizes arguments,
|
|
360
|
+
// so the record still holds a partial parse;
|
|
361
|
+
// - the handler receives the MCP server's schema-VALIDATED copy of
|
|
362
|
+
// the input (zod may strip unknown keys or apply defaults) while
|
|
363
|
+
// the record holds the raw streamed input.
|
|
364
|
+
// Refusing here stranded the call outright: the handler errored into
|
|
365
|
+
// the child while pi's real result sat queued forever (diag log
|
|
366
|
+
// 2026-07-28, `edit` with argKeys [edits, path] on both sides). A
|
|
367
|
+
// same-type sole-candidate claim is strictly safer than that. With
|
|
368
|
+
// SEVERAL same-name candidates and no exact match we still refuse —
|
|
369
|
+
// cross-pairing two live calls is the one outcome worse than failing.
|
|
178
370
|
chosen = byName[0];
|
|
179
371
|
match = "tool-name";
|
|
372
|
+
argsMismatch = hasRecordedArgs(byName[0].arguments);
|
|
180
373
|
}
|
|
181
374
|
|
|
182
375
|
if (!chosen) return { match: "none", ambiguous: false, available: unclaimed.length };
|
|
183
376
|
this.claimedToolCallIds.add(chosen.id);
|
|
184
|
-
return { toolCallId: chosen.id, match, ambiguous, available: unclaimed.length };
|
|
377
|
+
return { toolCallId: chosen.id, match, ambiguous, available: unclaimed.length, ...(argsMismatch ? { argsMismatch } : {}) };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Drain results still queued in `pendingResults` and report what was dropped.
|
|
382
|
+
*
|
|
383
|
+
* 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.
|
|
391
|
+
*/
|
|
392
|
+
takeStaleQueuedResults(): Array<{ id: string; toolName: string }> {
|
|
393
|
+
if (this.pendingResults.size === 0) return [];
|
|
394
|
+
const stale = [...this.pendingResults.keys()].map((id) => ({
|
|
395
|
+
id,
|
|
396
|
+
toolName: this.queryToolNames.get(id) ?? "unknown",
|
|
397
|
+
}));
|
|
398
|
+
this.pendingResults.clear();
|
|
399
|
+
return stale;
|
|
185
400
|
}
|
|
186
401
|
|
|
187
402
|
markToolResultDelivered(id: string | undefined): void {
|
|
@@ -210,9 +425,21 @@ export class QueryContext {
|
|
|
210
425
|
const unresolvedIds = expectedIds.filter((id) => !this.resolvedToolResultIds.has(id));
|
|
211
426
|
const affectedIds = new Set([...missingDeliveredIds, ...unresolvedIds, ...waitingIds, ...queuedIds, ...unmatchedResultIds]);
|
|
212
427
|
const counts = new Map<string, number>();
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
428
|
+
if (affectedIds.size > 0) {
|
|
429
|
+
// Name the affected ids from the query-scoped map, not just this
|
|
430
|
+
// message's records: a queued straggler from an earlier child message is
|
|
431
|
+
// exactly the case a mismatch report exists for, and this message's
|
|
432
|
+
// turnToolCalls no longer knows it.
|
|
433
|
+
for (const id of affectedIds) {
|
|
434
|
+
const name = this.queryToolNames.get(id)
|
|
435
|
+
?? this.turnToolCalls.find((call) => call.id === id)?.toolName
|
|
436
|
+
?? "unknown";
|
|
437
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
438
|
+
}
|
|
439
|
+
} else {
|
|
440
|
+
for (const call of this.turnToolCalls) {
|
|
441
|
+
counts.set(call.toolName, (counts.get(call.toolName) ?? 0) + 1);
|
|
442
|
+
}
|
|
216
443
|
}
|
|
217
444
|
return {
|
|
218
445
|
expectedIds,
|
|
@@ -256,6 +483,30 @@ export function popContext(): void {
|
|
|
256
483
|
_ctx = contextStack.pop()!;
|
|
257
484
|
}
|
|
258
485
|
|
|
486
|
+
/** Pop the context that belongs to ONE specific query, wherever it sits.
|
|
487
|
+
*
|
|
488
|
+
* The common case is `target === ctx()` and this is exactly popContext(). The
|
|
489
|
+
* reason this exists: a reentrant parent query can end ABNORMALLY (abort, child
|
|
490
|
+
* process death) while its own subagent's context is still pushed above it. A
|
|
491
|
+
* bare popContext() there would discard the live grandchild's context and
|
|
492
|
+
* merge the wrong deferred messages. Instead, splice `target` out of the stack
|
|
493
|
+
* and hand its deferred messages to its own parent (the element below it), so
|
|
494
|
+
* the still-live contexts above keep their positions and later pops restore
|
|
495
|
+
* the correct lineage. Returns false when `target` is nowhere in the state —
|
|
496
|
+
* already popped — so callers can treat that as "someone else tore this down". */
|
|
497
|
+
export function popContextFor(target: QueryContext): boolean {
|
|
498
|
+
if (_ctx === target) {
|
|
499
|
+
popContext();
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
const idx = contextStack.indexOf(target);
|
|
503
|
+
if (idx < 0) return false;
|
|
504
|
+
const parent = idx > 0 ? contextStack[idx - 1] : undefined;
|
|
505
|
+
parent?.deferredUserMessages.push(...target.deferredUserMessages);
|
|
506
|
+
contextStack.splice(idx, 1);
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
509
|
+
|
|
259
510
|
// Test-only: drop all state so test files can start from a clean module.
|
|
260
511
|
// Not called from production.
|
|
261
512
|
export function resetStack(): void {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// End-of-query teardown, extracted from streamClaudeAgentSdk's .finally so it
|
|
2
|
+
// operates on the ONE context captured at query start — never the live ctx().
|
|
3
|
+
// The two only differ while a reentrant (subagent) context is pushed, which is
|
|
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.
|
|
7
|
+
|
|
8
|
+
import { reportToolResultMismatch } from "./bridge-state.js";
|
|
9
|
+
import { flushConnectorCallAudit } from "./connector-audit.js";
|
|
10
|
+
import { debug } from "./debug.js";
|
|
11
|
+
import { drainPendingToolCalls, popContextFor, type QueryContext, type ToolCallDrainCause } from "./query-state.js";
|
|
12
|
+
|
|
13
|
+
/** Tear down `queryCtx` after its SDK query settled. No-ops when the query is
|
|
14
|
+
* no longer the context's active one (a continuation replaced it, or teardown
|
|
15
|
+
* already ran). Returns true when teardown actually ran. */
|
|
16
|
+
export function teardownQuery(
|
|
17
|
+
queryCtx: QueryContext,
|
|
18
|
+
sdkQuery: unknown,
|
|
19
|
+
cause: ToolCallDrainCause,
|
|
20
|
+
cwd: string,
|
|
21
|
+
isReentrant: boolean,
|
|
22
|
+
): boolean {
|
|
23
|
+
if (queryCtx.activeQuery !== sdkQuery) return false;
|
|
24
|
+
reportToolResultMismatch(queryCtx, "query teardown", cwd, { forceRotate: cause !== "query-end" });
|
|
25
|
+
// Drain pending handlers for this query as errors naming the cause —
|
|
26
|
+
// their results are never coming.
|
|
27
|
+
const drained = drainPendingToolCalls(queryCtx, cause);
|
|
28
|
+
if (drained > 0) debug(`provider: query teardown drained ${drained} waiting MCP handler(s) as errors (cause=${cause})`);
|
|
29
|
+
queryCtx.pendingResults.clear();
|
|
30
|
+
|
|
31
|
+
// Same idea for calls the CHILD owned: one whose result never came back
|
|
32
|
+
// is recorded as unobserved rather than left silent, so an answer in the
|
|
33
|
+
// transcript is never the only evidence a connector call was made.
|
|
34
|
+
const unobserved = flushConnectorCallAudit(queryCtx, cause);
|
|
35
|
+
if (unobserved > 0) debug(`provider: query teardown recorded ${unobserved} connector call(s) with no observed result (cause=${cause})`);
|
|
36
|
+
|
|
37
|
+
if (isReentrant) {
|
|
38
|
+
// Merges deferred messages and restores/repairs the stack. popContextFor
|
|
39
|
+
// (not popContext): a live subagent context may sit above this one.
|
|
40
|
+
if (!popContextFor(queryCtx)) debug("provider: query teardown found context already popped; skipping pop");
|
|
41
|
+
} else {
|
|
42
|
+
queryCtx.activeQuery = null;
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { USAGE_LIMIT_ERROR_PREFIXES } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
|
|
3
|
+
export const RATE_LIMIT_AUTO_RESUME_EVENT = "vstack:rate-limit";
|
|
4
|
+
export const RATE_LIMIT_TOKEN = "\x1b[31m[rate-limit]\x1b[39m";
|
|
5
|
+
|
|
6
|
+
// The SDK export is @alpha — degrade to "no match" (pre-0.3.220 behavior) if a
|
|
7
|
+
// future release drops it, instead of crashing message classification.
|
|
8
|
+
const USAGE_LIMIT_PREFIXES: readonly string[] = Array.isArray(USAGE_LIMIT_ERROR_PREFIXES as unknown)
|
|
9
|
+
? USAGE_LIMIT_ERROR_PREFIXES
|
|
10
|
+
: [];
|
|
11
|
+
|
|
12
|
+
function coerceMessageText(value: unknown): string {
|
|
13
|
+
if (typeof value === "string") return value;
|
|
14
|
+
if (value instanceof Error) return value.message;
|
|
15
|
+
try { return JSON.stringify(value ?? ""); }
|
|
16
|
+
catch { return String(value); }
|
|
17
|
+
}
|
|
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
|
+
/** Broad test: any "a usage limit was genuinely reached" message, matched
|
|
26
|
+
* against the CLI's own copy (SDK `USAGE_LIMIT_ERROR_PREFIXES`, e.g. "You've
|
|
27
|
+
* hit your weekly limit…"). Substring rather than prefix match because the
|
|
28
|
+
* text usually arrives embedded in a result payload's errors array. */
|
|
29
|
+
export function isUsageLimitMessage(value: unknown): boolean {
|
|
30
|
+
const text = coerceMessageText(value);
|
|
31
|
+
return USAGE_LIMIT_PREFIXES.some((prefix) => text.includes(prefix));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function uniqueNonEmptyLines(values: unknown[]): string[] {
|
|
35
|
+
const seen = new Set<string>();
|
|
36
|
+
const out: string[] = [];
|
|
37
|
+
for (const value of values) {
|
|
38
|
+
const text = typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
|
|
39
|
+
if (!text || seen.has(text)) continue;
|
|
40
|
+
seen.add(text);
|
|
41
|
+
out.push(text);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Epoch milliseconds from an SDK reset timestamp, or undefined.
|
|
47
|
+
* `SDKRateLimitInfo.resetsAt` is a bare number in epoch SECONDS (measured:
|
|
48
|
+
* treating it as ms rendered "resets Jan 21, 1970" for a Jul 2026 reset).
|
|
49
|
+
* 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. */
|
|
52
|
+
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;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function formatResetTimestamp(value: unknown): string {
|
|
60
|
+
const parsed = resetTimestampMs(value);
|
|
61
|
+
if (parsed === undefined) return "unknown";
|
|
62
|
+
return new Date(parsed).toLocaleString(undefined, {
|
|
63
|
+
day: "numeric",
|
|
64
|
+
hour: "numeric",
|
|
65
|
+
minute: "2-digit",
|
|
66
|
+
month: "short",
|
|
67
|
+
second: "2-digit",
|
|
68
|
+
timeZoneName: "short",
|
|
69
|
+
year: "numeric",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD = 80;
|
|
74
|
+
|
|
75
|
+
export function normalizeRateLimitUtilization(value: unknown): number | undefined {
|
|
76
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined;
|
|
77
|
+
if (value === 0) return 0;
|
|
78
|
+
// 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;
|
|
81
|
+
if (value > 1 && value <= 100) return value;
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function rateLimitTypeLabel(value: unknown): string {
|
|
86
|
+
const text = typeof value === "string" ? value.trim() : "";
|
|
87
|
+
return text || "unknown";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function formatAllowedRateLimitWarning(info: { status?: unknown; utilization?: unknown; rateLimitType?: unknown } | null | undefined): string | undefined {
|
|
91
|
+
if (info?.status !== "allowed_warning") return undefined;
|
|
92
|
+
const utilization = normalizeRateLimitUtilization(info.utilization);
|
|
93
|
+
if (utilization === undefined || utilization < ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD) return undefined;
|
|
94
|
+
return `Claude rate limit warning: nearing ${rateLimitTypeLabel(info.rateLimitType)} limit; check Claude Code /usage for exact utilization.`;
|
|
95
|
+
}
|