@vanillagreen/pi-claude-bridge 1.9.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.
@@ -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
+ }
@@ -56,6 +56,18 @@ export function drainPendingToolCalls(queryCtx: QueryContext, cause: ToolCallDra
56
56
  return drained;
57
57
  }
58
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
+
59
71
  export interface TurnToolCallRecord {
60
72
  id: string;
61
73
  toolName: string;
@@ -67,6 +79,12 @@ export interface ClaimedToolCall {
67
79
  match: "tool-args" | "tool-name" | "none";
68
80
  ambiguous: boolean;
69
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;
70
88
  }
71
89
 
72
90
  export interface ToolResultProgress {
@@ -132,6 +150,16 @@ export class QueryContext {
132
150
  pendingResults = new Map<string, McpResult>();
133
151
  turnToolCallIds: string[] = [];
134
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>();
135
163
  claimedToolCallIds = new Set<string>();
136
164
  deliveredToolResultIds = new Set<string>();
137
165
  resolvedToolResultIds = new Set<string>();
@@ -139,6 +167,83 @@ export class QueryContext {
139
167
  reportedToolResultMismatch = false;
140
168
  deferredUserMessages: string[] = [];
141
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
+ }
142
247
 
143
248
  // Per-turn (reset together)
144
249
  turnOutput: AssistantMessage | null = null;
@@ -163,6 +268,17 @@ export class QueryContext {
163
268
  this.turnSawStreamEvent = false;
164
269
  this.turnSawToolCall = false;
165
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;
166
282
  // Tool-call tracking is NOT reset here — it persists across the
167
283
  // tool-result delivery callback for the same assistant message. New
168
284
  // assistant messages call resetToolTracking() explicitly.
@@ -176,10 +292,32 @@ export class QueryContext {
176
292
  this.resolvedToolResultIds.clear();
177
293
  this.unmatchedToolResultIds.clear();
178
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);
179
316
  }
180
317
 
181
318
  recordToolCall(id: string | undefined, toolName: string, args: Record<string, unknown> = {}): void {
182
319
  if (!id) return;
320
+ this.queryToolNames.set(id, toolName);
183
321
  if (!this.turnToolCallIds.includes(id)) this.turnToolCallIds.push(id);
184
322
  const existing = this.turnToolCalls.find((call) => call.id === id);
185
323
  if (existing) {
@@ -208,22 +346,57 @@ export class QueryContext {
208
346
  let match: ClaimedToolCall["match"] = "none";
209
347
  let ambiguous = false;
210
348
 
349
+ let argsMismatch = false;
211
350
  if (exact.length > 0) {
212
351
  chosen = exact[0];
213
352
  match = "tool-args";
214
353
  ambiguous = exact.length > 1;
215
- } else if (byName.length === 1 && !hasRecordedArgs(byName[0].arguments)) {
216
- // The SDK can invoke the MCP handler after content_block_start but
217
- // before input_json_delta/content_block_stop finalizes arguments.
218
- // Falling back to the sole same-name, argument-less call preserves that
219
- // race without ever claiming a different tool type.
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.
220
370
  chosen = byName[0];
221
371
  match = "tool-name";
372
+ argsMismatch = hasRecordedArgs(byName[0].arguments);
222
373
  }
223
374
 
224
375
  if (!chosen) return { match: "none", ambiguous: false, available: unclaimed.length };
225
376
  this.claimedToolCallIds.add(chosen.id);
226
- 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;
227
400
  }
228
401
 
229
402
  markToolResultDelivered(id: string | undefined): void {
@@ -252,9 +425,21 @@ export class QueryContext {
252
425
  const unresolvedIds = expectedIds.filter((id) => !this.resolvedToolResultIds.has(id));
253
426
  const affectedIds = new Set([...missingDeliveredIds, ...unresolvedIds, ...waitingIds, ...queuedIds, ...unmatchedResultIds]);
254
427
  const counts = new Map<string, number>();
255
- for (const call of this.turnToolCalls) {
256
- if (affectedIds.size > 0 && !affectedIds.has(call.id)) continue;
257
- counts.set(call.toolName, (counts.get(call.toolName) ?? 0) + 1);
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
+ }
258
443
  }
259
444
  return {
260
445
  expectedIds,
@@ -298,6 +483,30 @@ export function popContext(): void {
298
483
  _ctx = contextStack.pop()!;
299
484
  }
300
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
+
301
510
  // Test-only: drop all state so test files can start from a clean module.
302
511
  // Not called from production.
303
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
+ }
package/src/rate-limit.ts CHANGED
@@ -1,15 +1,34 @@
1
+ import { USAGE_LIMIT_ERROR_PREFIXES } from "@anthropic-ai/claude-agent-sdk";
2
+
1
3
  export const RATE_LIMIT_AUTO_RESUME_EVENT = "vstack:rate-limit";
2
4
  export const RATE_LIMIT_TOKEN = "\x1b[31m[rate-limit]\x1b[39m";
3
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. */
4
21
  export function isExtraUsageRequiredMessage(value: unknown): boolean {
5
- let text: string;
6
- if (typeof value === "string") text = value;
7
- else if (value instanceof Error) text = value.message;
8
- else {
9
- try { text = JSON.stringify(value ?? ""); }
10
- catch { text = String(value); }
11
- }
12
- return /extra[-\s]?usage|overage|extra usage billing|extra usage credits|1M context/i.test(text);
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));
13
32
  }
14
33
 
15
34
  export function uniqueNonEmptyLines(values: unknown[]): string[] {
@@ -24,9 +43,22 @@ export function uniqueNonEmptyLines(values: unknown[]): string[] {
24
43
  return out;
25
44
  }
26
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
+
27
59
  export function formatResetTimestamp(value: unknown): string {
28
- const parsed = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : Number.NaN;
29
- if (!Number.isFinite(parsed)) return "unknown";
60
+ const parsed = resetTimestampMs(value);
61
+ if (parsed === undefined) return "unknown";
30
62
  return new Date(parsed).toLocaleString(undefined, {
31
63
  day: "numeric",
32
64
  hour: "numeric",
@@ -7,7 +7,7 @@ import { extensionApi, piUI, reportSyntheticToolResultRepair, setSharedSession,
7
7
  import { convertPiMessages } from "./convert.js";
8
8
  import { DEBUG, DEBUG_LOG_PATH, debug, diagDump } from "./debug.js";
9
9
  import { verifyWrittenSession as _verifyWrittenSession } from "./session-verify.js";
10
- import { findUnpairedToolUses } from "./tool-pairing-audit.js";
10
+ import { findUnpairedToolUses, insertLostToolResultPlaceholders } from "./tool-pairing-audit.js";
11
11
 
12
12
  // --- Session persistence ---
13
13
 
@@ -167,8 +167,13 @@ function convertAndImportMessages(
167
167
  debug(`convertAndImportMessages: sanitized ${sanitizedIds.size} tool IDs:`,
168
168
  [...sanitizedIds.entries()].map(([orig, clean]) => orig === clean ? orig : `${orig}→${clean}`).join(", "));
169
169
  }
170
- // Pre-repair for debug logging; importMessages also repairs internally (idempotent).
170
+ // Pre-repair: pair every orphaned tool_use with an EXPLICIT bridge-authored
171
+ // error result before cc-session-io's repairToolPairing can backfill its bare
172
+ // "[no tool result recorded]" placeholder — which the model reads as tool
173
+ // output and silently reasons on. Ours is is_error and says what to do.
174
+ // repairToolPairing still runs after (idempotent; finds nothing left).
171
175
  const missingToolResults = findUnpairedToolUses(anthropicMessages);
176
+ if (missingToolResults.length > 0) insertLostToolResultPlaceholders(anthropicMessages, missingToolResults);
172
177
  const repaired = repairToolPairing(anthropicMessages);
173
178
  if (missingToolResults.length > 0) {
174
179
  reportSyntheticToolResultRepair(missingToolResults, {
@@ -52,6 +52,54 @@ export function findUnpairedToolUses(messages: Array<{ role?: string; content?:
52
52
  return missing;
53
53
  }
54
54
 
55
+ export const LOST_TOOL_RESULT_TEXT =
56
+ "Claude bridge: the result of this tool call was lost before the session was rebuilt "
57
+ + "(the turn was interrupted). Treat the call as failed — it may or may not have executed. "
58
+ + "Re-run the tool if its output is still needed.";
59
+
60
+ /**
61
+ * Insert explicit, bridge-authored error results for every unpaired tool_use,
62
+ * IN PLACE, before cc-session-io's repairToolPairing runs.
63
+ *
64
+ * repairToolPairing backfills with a bare "[no tool result recorded]" — a
65
+ * placeholder the model reads as tool OUTPUT and keeps reasoning on (observed:
66
+ * two bash calls in the 2026-07-28 token test, silently treated as if they had
67
+ * returned). An is_error result that says what happened and what to do turns a
68
+ * silent correctness hazard into a recoverable failure.
69
+ *
70
+ * Results are prepended to the immediately following user message (tool_result
71
+ * blocks must lead a user message), or a new user message is inserted when none
72
+ * follows. `missing` must come from findUnpairedToolUses on the same array.
73
+ */
74
+ export function insertLostToolResultPlaceholders(
75
+ messages: Array<{ role?: string; content?: unknown }>,
76
+ missing: MissingToolResult[],
77
+ ): void {
78
+ const block = (id: string) => ({ type: "tool_result", tool_use_id: id, content: LOST_TOOL_RESULT_TEXT, is_error: true });
79
+ const byAssistant = new Map<number, MissingToolResult[]>();
80
+ for (const item of missing) {
81
+ const group = byAssistant.get(item.assistantIndex) ?? [];
82
+ group.push(item);
83
+ byAssistant.set(item.assistantIndex, group);
84
+ }
85
+ // Descending order so inserting a new user message never shifts an index a
86
+ // later (earlier-in-array) group still needs.
87
+ for (const assistantIndex of [...byAssistant.keys()].sort((a, b) => b - a)) {
88
+ const group = byAssistant.get(assistantIndex)!;
89
+ const blocks = group.map((item) => block(item.id));
90
+ const userIndex = group[0].userIndex;
91
+ if (userIndex != null && messages[userIndex]?.role === "user") {
92
+ const user = messages[userIndex] as { role: string; content: unknown };
93
+ const existing = typeof user.content === "string"
94
+ ? (user.content ? [{ type: "text", text: user.content }] : [])
95
+ : Array.isArray(user.content) ? user.content : [];
96
+ user.content = [...blocks, ...existing];
97
+ } else {
98
+ messages.splice(assistantIndex + 1, 0, { role: "user", content: blocks });
99
+ }
100
+ }
101
+ }
102
+
55
103
  export function summarizeMissingToolNames(missing: MissingToolResult[]): Array<{ name: string; count: number }> {
56
104
  const counts = new Map<string, number>();
57
105
  for (const item of missing) counts.set(item.toolName, (counts.get(item.toolName) ?? 0) + 1);
@@ -28,9 +28,15 @@ export function jsonSchemaPropertyToZod(prop: Record<string, unknown>): z.ZodTyp
28
28
  }
29
29
  case "object": {
30
30
  if (prop.properties && typeof prop.properties === "object" && !Array.isArray(prop.properties)) {
31
- base = z.object(jsonSchemaToZodShape(prop));
32
- if (prop.additionalProperties === false) base = (base as z.ZodObject<Record<string, z.ZodTypeAny>>).strict();
33
- else if (prop.additionalProperties === true) base = (base as z.ZodObject<Record<string, z.ZodTypeAny>>).passthrough();
31
+ const obj = z.object(jsonSchemaToZodShape(prop));
32
+ // JSON Schema's default is PERMISSIVE (additionalProperties omitted
33
+ // means allowed); zod's default is to silently STRIP unknown keys.
34
+ // Stripping matters here: the MCP handler compares its validated
35
+ // input against the raw streamed tool_use input to claim a call id,
36
+ // so a silently dropped key made the two diverge and the claim fail
37
+ // (stranding the call — see claimToolCall). Only an explicit
38
+ // additionalProperties:false may reject/strip.
39
+ base = prop.additionalProperties === false ? obj.strict() : obj.passthrough();
34
40
  } else {
35
41
  base = z.record(z.string(), z.unknown());
36
42
  }