@vanillagreen/pi-claude-bridge 1.1.4 → 1.3.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/src/index.ts CHANGED
@@ -6,7 +6,7 @@ import type { Base64ImageSource, ContentBlockParam, MessageParam } from "@anthro
6
6
  import { createSession, deleteSession, openSession, repairToolPairing } from "cc-session-io";
7
7
  import { spawn as spawnProcess } from "child_process";
8
8
  import { createHash } from "crypto";
9
- import { accessSync, appendFileSync, constants as fsConstants, mkdirSync, readFileSync, realpathSync, statSync } from "fs";
9
+ import { accessSync, appendFileSync, chmodSync, constants as fsConstants, mkdirSync, readFileSync, realpathSync, statSync } from "fs";
10
10
  import { resolve as pathResolve } from "path";
11
11
  import { homedir } from "os";
12
12
  import { delimiter, dirname, join } from "path";
@@ -16,7 +16,8 @@ import { MCP_SERVER_NAME, MCP_TOOL_PREFIX, extractSkillsBlock } from "./skills.j
16
16
  import { verifyWrittenSession as _verifyWrittenSession } from "./session-verify.js";
17
17
  import { extractAllToolResults as _extractAllToolResults, type McpResult } from "./extract-tool-results.js";
18
18
  import { QueryContext, ctx, stackDepth, pushContext, popContext } from "./query-state.js";
19
- import { loadConfig } from "./config.js";
19
+ import { findUnpairedToolUses, summarizeMissingToolNames, type MissingToolResult } from "./tool-pairing-audit.js";
20
+ import { loadConfig, normalizeEffortLevel, type Config } from "./config.js";
20
21
  import { extractAgentsAppend } from "./agents-md.js";
21
22
  import { buildPromptContextAppend } from "./prompt-context.js";
22
23
  import { jsonSchemaToZodShape } from "./typebox-to-zod.js";
@@ -33,13 +34,17 @@ const newAssistantMessageEventStream: () => AssistantMessageEventStream =
33
34
 
34
35
  const DEBUG = process.env.CLAUDE_BRIDGE_DEBUG === "1";
35
36
  const DEBUG_LOG_PATH = process.env.CLAUDE_BRIDGE_DEBUG_PATH || join(homedir(), ".pi", "agent", "claude-bridge.log");
36
- const DIAG_LOG_PATH = join(homedir(), ".pi", "agent", "claude-bridge-diag.log");
37
+ const DEFAULT_DIAG_LOG_PATH = join(homedir(), ".pi", "agent", "claude-bridge-diag.log");
38
+
39
+ function diagLogPath(): string {
40
+ return process.env.CLAUDE_BRIDGE_DIAG_PATH || DEFAULT_DIAG_LOG_PATH;
41
+ }
37
42
 
38
43
  // Ensure log directories exist when debug is enabled
39
44
  if (DEBUG) {
40
45
  try {
41
46
  mkdirSync(dirname(DEBUG_LOG_PATH), { recursive: true });
42
- mkdirSync(dirname(DIAG_LOG_PATH), { recursive: true });
47
+ mkdirSync(dirname(diagLogPath()), { recursive: true, mode: 0o700 });
43
48
  } catch {
44
49
  // If directory creation fails, debug functions will throw on first use
45
50
  }
@@ -57,7 +62,7 @@ function debug(...args: unknown[]) {
57
62
  return JSON.stringify(a);
58
63
  };
59
64
  const msg = args.map(fmt).join(" ");
60
- appendFileSync(DEBUG_LOG_PATH, `[${ts}] [${moduleInstanceId}] ${msg}\n`);
65
+ try { appendFileSync(DEBUG_LOG_PATH, `[${ts}] [${moduleInstanceId}] ${msg}\n`); } catch { /* debug is best effort */ }
61
66
  }
62
67
 
63
68
  function executableFromPath(name: string): string | undefined {
@@ -342,10 +347,109 @@ function makeCliDebugOptions(tag: string): { debug?: boolean; debugFile?: string
342
347
 
343
348
  /** Unconditional diagnostic dump — for "should never happen" paths */
344
349
  function diagDump(label: string, data: Record<string, unknown>) {
345
- const ts = new Date().toISOString();
346
- const entry = { ts, moduleInstanceId, label, ...data };
347
- appendFileSync(DIAG_LOG_PATH, JSON.stringify(entry) + "\n");
348
- debug(`DIAG: ${label} (see ${DIAG_LOG_PATH})`);
350
+ try {
351
+ const ts = new Date().toISOString();
352
+ const entry = { ts, moduleInstanceId, label, ...data };
353
+ const path = diagLogPath();
354
+ try { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); } catch { /* best effort */ }
355
+ appendFileSync(path, JSON.stringify(entry) + "\n", { mode: 0o600 });
356
+ try { chmodSync(path, 0o600); } catch { /* best effort */ }
357
+ debug(`DIAG: ${label} (see ${path})`);
358
+ } catch (error) {
359
+ debug(`DIAG FAILED: ${label}`, error);
360
+ }
361
+ }
362
+
363
+ function safeNotify(message: string, level: "info" | "warning" | "error" = "warning"): void {
364
+ try { piUI?.notify(message, level); }
365
+ catch (error) { debug("notify failed:", error); }
366
+ }
367
+
368
+ function argKeys(args: Record<string, unknown> | undefined): string[] {
369
+ return Object.keys(args ?? {}).sort();
370
+ }
371
+
372
+ function safeToolCallSummary(calls: Array<{ id: string; toolName: string; arguments?: Record<string, unknown> }>): Array<{ id: string; toolName: string; argKeys: string[] }> {
373
+ return calls.map((call) => ({ id: call.id, toolName: call.toolName, argKeys: argKeys(call.arguments) }));
374
+ }
375
+
376
+ function compactToolNameSummary(names: Array<{ name: string; count: number }>, limit = 12): string[] {
377
+ const shown = names.slice(0, limit).map(({ name, count }) => count > 1 ? `${name}×${count}` : name);
378
+ if (names.length > limit) shown.push(`+${names.length - limit} more`);
379
+ return shown;
380
+ }
381
+
382
+ function reportSyntheticToolResultRepair(missing: MissingToolResult[], context: Record<string, unknown>): void {
383
+ try {
384
+ if (missing.length === 0) return;
385
+ const toolNames = summarizeMissingToolNames(missing);
386
+ const toolNameSummary = compactToolNameSummary(toolNames);
387
+ const sampledToolCallIds = missing.slice(0, 50).map((item) => item.id);
388
+ diagDump("repair_tool_pairing_synthetic_results", {
389
+ count: missing.length,
390
+ toolNames,
391
+ sampledToolCallIds,
392
+ missing: missing.slice(0, 50),
393
+ ...context,
394
+ });
395
+ safeNotify(
396
+ `Claude bridge: ${missing.length} missing tool result(s) repaired with "[no tool result recorded]"` +
397
+ `${toolNameSummary.length ? ` for ${toolNameSummary.join(", ")}` : ""}. ` +
398
+ `Real tool output was lost before Claude session import; see ${diagLogPath()}.`,
399
+ "error",
400
+ );
401
+ } catch (error) {
402
+ debug("reportSyntheticToolResultRepair failed:", error);
403
+ }
404
+ }
405
+
406
+ export function reportToolResultMismatch(queryCtx: QueryContext, reason: string, cwd: string | undefined, opts: { forceRotate?: boolean } = {}): boolean {
407
+ try {
408
+ if (queryCtx.reportedToolResultMismatch) return false;
409
+ const progress = queryCtx.toolResultProgress();
410
+ const hasMismatch = progress.expectedCount > 0
411
+ ? progress.unresolvedIds.length > 0 || progress.waitingCount > 0 || progress.queuedCount > 0 || progress.unmatchedResultCount > 0
412
+ : progress.waitingCount > 0 || progress.queuedCount > 0 || progress.unmatchedResultCount > 0;
413
+ if (!hasMismatch) return false;
414
+ queryCtx.reportedToolResultMismatch = true;
415
+ if (sharedSession) {
416
+ sharedSession = { ...sharedSession, needsRebuild: true, ...(opts.forceRotate ? { forceRotate: true } : {}) };
417
+ }
418
+ const toolNameSummary = compactToolNameSummary(progress.toolNames);
419
+ diagDump("tool_result_delivery_mismatch", {
420
+ reason,
421
+ cwd,
422
+ progress,
423
+ activeQueryExists: queryCtx.activeQuery !== null,
424
+ sharedSession: sharedSession ? {
425
+ sessionId: sharedSession.sessionId.slice(0, 8),
426
+ cursor: sharedSession.cursor,
427
+ needsRebuild: sharedSession.needsRebuild === true,
428
+ forceRotate: sharedSession.forceRotate === true,
429
+ } : null,
430
+ });
431
+ safeNotify(
432
+ `Claude bridge: tool result delivery interrupted during ${reason}; ` +
433
+ `delivered ${progress.deliveredCount}/${progress.expectedCount}, resolved ${progress.resolvedCount}/${progress.expectedCount}, ` +
434
+ `waiting=${progress.waitingCount}, queued=${progress.queuedCount}, unmatched=${progress.unmatchedResultCount}` +
435
+ `${toolNameSummary.length ? `, tools=${toolNameSummary.join(", ")}` : ""}. ` +
436
+ `Claude session will rebuild before the next turn; see ${diagLogPath()}.`,
437
+ "error",
438
+ );
439
+ return true;
440
+ } catch (error) {
441
+ debug("reportToolResultMismatch failed:", error);
442
+ return false;
443
+ }
444
+ }
445
+
446
+ export function __testSetBridgeIntegrityState(state: { ui?: Pick<ExtensionUIContext, "notify"> | null; sharedSession?: SessionState | null }): void {
447
+ if ("ui" in state) piUI = state.ui as ExtensionUIContext | undefined;
448
+ if ("sharedSession" in state) sharedSession = state.sharedSession ?? null;
449
+ }
450
+
451
+ export function __testGetBridgeIntegrityState(): { sharedSession: SessionState | null } {
452
+ return { sharedSession };
349
453
  }
350
454
 
351
455
  // --- Constants ---
@@ -365,6 +469,7 @@ function diagDump(label: string, data: Record<string, unknown>) {
365
469
  // On session_shutdown (including /reload), clearSession() resets this so a fresh
366
470
  // registration can occur for the next session.
367
471
  const ACTIVE_STREAM_SIMPLE_KEY = Symbol.for("claude-bridge:activeStreamSimple");
472
+ const COMMANDS_REGISTERED_KEY = Symbol.for("claude-bridge:commandsRegistered");
368
473
 
369
474
  const SDK_TO_PI_TOOL_NAME: Record<string, string> = {
370
475
  read: "read", write: "write", edit: "edit", bash: "bash",
@@ -422,6 +527,143 @@ interface SessionState {
422
527
 
423
528
  let sharedSession: SessionState | null = null;
424
529
  let extensionApi: ExtensionAPI | undefined;
530
+ let piUI: ExtensionUIContext | undefined;
531
+ let extraUsageHelperInFlight: Promise<string> | null = null;
532
+
533
+ const RATE_LIMIT_AUTO_RESUME_EVENT = "vstack:rate-limit";
534
+ const RATE_LIMIT_TOKEN = "\x1b[31m[rate-limit]\x1b[39m";
535
+
536
+ export function isExtraUsageRequiredMessage(value: unknown): boolean {
537
+ let text: string;
538
+ if (typeof value === "string") text = value;
539
+ else if (value instanceof Error) text = value.message;
540
+ else {
541
+ try { text = JSON.stringify(value ?? ""); }
542
+ catch { text = String(value); }
543
+ }
544
+ return /extra[-\s]?usage|overage|extra usage billing|extra usage credits|1M context/i.test(text);
545
+ }
546
+
547
+ export function uniqueNonEmptyLines(values: unknown[]): string[] {
548
+ const seen = new Set<string>();
549
+ const out: string[] = [];
550
+ for (const value of values) {
551
+ const text = typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
552
+ if (!text || seen.has(text)) continue;
553
+ seen.add(text);
554
+ out.push(text);
555
+ }
556
+ return out;
557
+ }
558
+
559
+ export function formatResetTimestamp(value: unknown): string {
560
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : Number.NaN;
561
+ if (!Number.isFinite(parsed)) return "unknown";
562
+ return new Date(parsed).toLocaleString(undefined, {
563
+ day: "numeric",
564
+ hour: "numeric",
565
+ minute: "2-digit",
566
+ month: "short",
567
+ second: "2-digit",
568
+ timeZoneName: "short",
569
+ year: "numeric",
570
+ });
571
+ }
572
+
573
+ export const ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD = 80;
574
+
575
+ export function normalizeRateLimitUtilization(value: unknown): number | undefined {
576
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined;
577
+ if (value === 0) return 0;
578
+ // Claude SDK payloads have appeared as both fractions and percentages.
579
+ // Exact 1 is unit-ambiguous (1% vs 100%), so do not use it for allowed-warning copy.
580
+ if (value > 0 && value < 1) return value * 100;
581
+ if (value > 1 && value <= 100) return value;
582
+ return undefined;
583
+ }
584
+
585
+ function rateLimitTypeLabel(value: unknown): string {
586
+ const text = typeof value === "string" ? value.trim() : "";
587
+ return text || "unknown";
588
+ }
589
+
590
+ export function formatAllowedRateLimitWarning(info: { status?: unknown; utilization?: unknown; rateLimitType?: unknown } | null | undefined): string | undefined {
591
+ if (info?.status !== "allowed_warning") return undefined;
592
+ const utilization = normalizeRateLimitUtilization(info.utilization);
593
+ if (utilization === undefined || utilization < ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD) return undefined;
594
+ return `Claude rate limit warning: nearing ${rateLimitTypeLabel(info.rateLimitType)} limit; check Claude Code /usage for exact utilization.`;
595
+ }
596
+
597
+ function emitRateLimitEvent(payload: Record<string, unknown>): void {
598
+ try {
599
+ extensionApi?.events?.emit?.(RATE_LIMIT_AUTO_RESUME_EVENT, payload);
600
+ } catch {
601
+ // Cross-extension broker is best-effort only.
602
+ }
603
+ }
604
+
605
+ function extraUsageAllowed(config: Config): boolean {
606
+ return config.provider?.allowExtraUsage === true;
607
+ }
608
+
609
+ function sdkTextFromMessage(message: SDKMessage): string | undefined {
610
+ if (message.type === "result") return (message as any).result;
611
+ if (message.type === "assistant") {
612
+ const content = (message as any).message?.content;
613
+ if (!Array.isArray(content)) return undefined;
614
+ return content
615
+ .map((block) => block?.type === "text" && typeof block.text === "string" ? block.text : "")
616
+ .filter(Boolean)
617
+ .join("\n");
618
+ }
619
+ return undefined;
620
+ }
621
+
622
+ async function runExtraUsageHelper(cwd: string, config = loadConfig(cwd)): Promise<string> {
623
+ const providerSettings = config.provider ?? {};
624
+ const claudeExecutable = resolveClaudeExecutable(providerSettings.pathToClaudeCodeExecutable);
625
+ if (claudeExecutable) preflightClaudeExecutable(claudeExecutable, cwd);
626
+
627
+ const helperQuery = query({
628
+ prompt: "/extra-usage",
629
+ options: {
630
+ cwd,
631
+ env: { ...process.env, ENABLE_CLAUDEAI_MCP_SERVERS: "0", DISABLE_AUTO_COMPACT: "1" },
632
+ maxTurns: 1,
633
+ ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
634
+ spawnClaudeCodeProcess: spawnClaudeCodeWithDiagnostics,
635
+ ...makeCliDebugOptions("extra-usage"),
636
+ },
637
+ });
638
+ const outputs: string[] = [];
639
+ try {
640
+ for await (const message of helperQuery) {
641
+ const text = sdkTextFromMessage(message)?.trim();
642
+ if (text && outputs[outputs.length - 1] !== text) outputs.push(text);
643
+ }
644
+ } finally {
645
+ helperQuery.close();
646
+ }
647
+ return outputs.join("\n").trim() || "Claude Code /extra-usage completed.";
648
+ }
649
+
650
+ function launchExtraUsageHelperIfAllowed(cwd: string, config: Config, reason: string): boolean {
651
+ if (!extraUsageAllowed(config)) return false;
652
+ if (extraUsageHelperInFlight) return true;
653
+ extraUsageHelperInFlight = runExtraUsageHelper(cwd, config)
654
+ .then((message) => {
655
+ piUI?.notify(`Claude extra usage helper: ${message}`, "info");
656
+ return message;
657
+ })
658
+ .catch((error) => {
659
+ const message = error instanceof Error ? error.message : String(error);
660
+ piUI?.notify(`Claude extra usage helper failed after ${reason}: ${message}`, "error");
661
+ throw error;
662
+ })
663
+ .finally(() => { extraUsageHelperInFlight = null; });
664
+ void extraUsageHelperInFlight.catch(() => {});
665
+ return true;
666
+ }
425
667
 
426
668
  const BRIDGE_SESSION_CUSTOM_TYPE = "claude-bridge-session";
427
669
 
@@ -564,6 +806,7 @@ function convertAndImportMessages(
564
806
  session: ReturnType<typeof createSession>,
565
807
  messages: Context["messages"],
566
808
  customToolNameToSdk?: Map<string, string>,
809
+ cwd?: string,
567
810
  ): void {
568
811
  const { anthropicMessages, sanitizedIds } = convertPiMessages(messages, customToolNameToSdk);
569
812
 
@@ -579,7 +822,17 @@ function convertAndImportMessages(
579
822
  [...sanitizedIds.entries()].map(([orig, clean]) => orig === clean ? orig : `${orig}→${clean}`).join(", "));
580
823
  }
581
824
  // Pre-repair for debug logging; importMessages also repairs internally (idempotent).
825
+ const missingToolResults = findUnpairedToolUses(anthropicMessages);
582
826
  const repaired = repairToolPairing(anthropicMessages);
827
+ if (missingToolResults.length > 0) {
828
+ reportSyntheticToolResultRepair(missingToolResults, {
829
+ cwd,
830
+ messageCount: messages.length,
831
+ anthropicMessageCount: anthropicMessages.length,
832
+ sessionId: session.sessionId,
833
+ jsonlPath: session.jsonlPath,
834
+ });
835
+ }
583
836
  if (repaired.length !== anthropicMessages.length) {
584
837
  debug(`convertAndImportMessages: repairToolPairing ${anthropicMessages.length} → ${repaired.length} msgs`);
585
838
  }
@@ -777,7 +1030,7 @@ function syncSharedSession(
777
1030
  ...(preserveId ? { sessionId: previousSessionId } : {}),
778
1031
  ...(modelId ? { model: modelId } : {}),
779
1032
  });
780
- convertAndImportMessages(session, priorMessages, customToolNameToSdk);
1033
+ convertAndImportMessages(session, priorMessages, customToolNameToSdk, cwd);
781
1034
  session.save();
782
1035
  verifyWrittenSession(session.jsonlPath, session.sessionId, session.messages.length, cwd);
783
1036
  sharedSession = { sessionId: session.sessionId, cursor: priorMessages.length, cwd };
@@ -851,9 +1104,6 @@ function mapToolArgs(
851
1104
  // them without activating the extension. `ctx()`, `pushContext()`, `popContext()`
852
1105
  // are imported at the top of this file.
853
1106
 
854
- // Global (not query state):
855
- let piUI: ExtensionUIContext | null = null;
856
-
857
1107
  function resolveMcpTools(context: Context, excludeToolName?: string): {
858
1108
  mcpTools: Tool[];
859
1109
  customToolNameToSdk: Map<string, string>;
@@ -880,28 +1130,50 @@ function resolveMcpTools(context: Context, excludeToolName?: string): {
880
1130
 
881
1131
  // Creates an MCP server that bridges pi tools to the SDK. Each tool handler
882
1132
  // blocks on a Promise until pi delivers the tool result via streamSimple.
883
- // Handlers are assigned toolCallIds from turnToolCallIds (populated when the SDK
884
- // emits tool_use blocks). Results are matched by ID, not position.
885
- // Handlers close over the captured `queryCtx`, ensuring they operate on the
886
- // correct query's state even across pushContext/popContext calls.
1133
+ // Handlers claim their tool_call id by matching the actual MCP call
1134
+ // (tool name + arguments) against the recorded tool_use blocks, then results
1135
+ // are matched by ID. Handlers close over the captured `queryCtx`, ensuring they
1136
+ // operate on the correct query's state even across pushContext/popContext calls.
887
1137
  function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string, ReturnType<typeof createSdkMcpServer>> | undefined {
888
1138
  if (!tools.length) return undefined;
889
1139
  const mcpTools = tools.map((tool) => ({
890
1140
  name: tool.name,
891
1141
  description: tool.description,
892
1142
  inputSchema: jsonSchemaToZodShape(tool.parameters),
893
- handler: async () => {
894
- const toolCallId = queryCtx.turnToolCallIds[queryCtx.nextHandlerIdx++];
895
- if (!toolCallId) debug(`WARNING: mcp handler ${tool.name} has no toolCallId (idx=${queryCtx.nextHandlerIdx - 1}, available=${queryCtx.turnToolCallIds.length})`);
1143
+ handler: async (args?: Record<string, unknown>) => {
1144
+ const mappedArgs = mapToolArgs(tool.name, args);
1145
+ const claim = queryCtx.claimToolCall(tool.name, mappedArgs);
1146
+ const toolCallId = claim.toolCallId;
1147
+ if (!toolCallId) {
1148
+ debug(`WARNING: mcp handler ${tool.name} has no toolCallId (available=${claim.available})`);
1149
+ diagDump("tool_handler_unmatched", {
1150
+ toolName: tool.name,
1151
+ argKeys: argKeys(mappedArgs),
1152
+ available: claim.available,
1153
+ turnToolCallIds: queryCtx.turnToolCallIds,
1154
+ turnToolCalls: safeToolCallSummary(queryCtx.turnToolCalls),
1155
+ });
1156
+ return { content: [{ type: "text", text: `Claude bridge internal error: no matching tool_call id for ${tool.name}` }], isError: true } satisfies McpResult;
1157
+ }
1158
+ if (claim.match !== "tool-args" || claim.ambiguous) {
1159
+ debug(`mcp handler: ${tool.name} [${toolCallId}] claimed by ${claim.match}${claim.ambiguous ? " (ambiguous)" : ""}`);
1160
+ }
896
1161
  if (toolCallId && queryCtx.pendingResults.has(toolCallId)) {
897
1162
  const result = queryCtx.pendingResults.get(toolCallId)!;
898
1163
  queryCtx.pendingResults.delete(toolCallId);
1164
+ queryCtx.markToolResultResolved(toolCallId);
899
1165
  debug(`mcp handler: ${tool.name} [${toolCallId}] → resolved from queue (${queryCtx.pendingResults.size} remaining)`);
900
1166
  return result;
901
1167
  }
902
1168
  debug(`mcp handler: ${tool.name} [${toolCallId}] → waiting`);
903
1169
  return new Promise<McpResult>((resolve) => {
904
- queryCtx.pendingToolCalls.set(toolCallId, { toolName: tool.name, resolve });
1170
+ queryCtx.pendingToolCalls.set(toolCallId, {
1171
+ toolName: tool.name,
1172
+ resolve: (result) => {
1173
+ queryCtx.markToolResultResolved(toolCallId);
1174
+ resolve(result);
1175
+ },
1176
+ });
905
1177
  });
906
1178
  },
907
1179
  }));
@@ -930,6 +1202,26 @@ const REASONING_TO_EFFORT: Record<string, EffortLevel> = {
930
1202
  minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "max",
931
1203
  };
932
1204
 
1205
+ function normalizeEffortOverrideModelKey(value: string): string {
1206
+ const key = value.trim().toLowerCase();
1207
+ return key.startsWith(`${PROVIDER_ID}/`) ? key.slice(PROVIDER_ID.length + 1) : key;
1208
+ }
1209
+
1210
+ export function resolveConfiguredEffort(
1211
+ modelId: string,
1212
+ reasoningEffort: EffortLevel | undefined,
1213
+ providerConfig?: Config["provider"],
1214
+ ): EffortLevel | undefined {
1215
+ const target = normalizeEffortOverrideModelKey(modelId);
1216
+ for (const [key, rawEffort] of Object.entries(providerConfig?.modelEffortOverrides ?? {})) {
1217
+ const normalizedKey = normalizeEffortOverrideModelKey(key);
1218
+ if (normalizedKey !== "*" && normalizedKey !== target) continue;
1219
+ const effort = normalizeEffortLevel(rawEffort) as EffortLevel | undefined;
1220
+ if (effort) return effort;
1221
+ }
1222
+ return (normalizeEffortLevel(providerConfig?.forceEffort) as EffortLevel | undefined) ?? reasoningEffort;
1223
+ }
1224
+
933
1225
  // --- Provider helpers: misc ---
934
1226
 
935
1227
  function mapStopReason(reason: string | undefined): "stop" | "length" | "toolUse" {
@@ -980,24 +1272,28 @@ function finalizeCurrentStream(stopReason?: string): void {
980
1272
 
981
1273
  /** Maps Anthropic stream events to pi stream events (text, thinking, toolcall).
982
1274
  * On message_stop with tool_use: ends currentPiStream so pi can execute the tool. */
983
- function processStreamEvent(
1275
+ export function processStreamEvent(
984
1276
  message: SDKMessage,
985
1277
  customToolNameToPi: Map<string, string>,
986
1278
  model: Model<any>,
987
1279
  ): void {
988
1280
  const c = ctx();
989
1281
  if (!c.currentPiStream || !c.turnOutput) return;
990
- c.turnSawStreamEvent = true;
991
1282
  const event = (message as SDKMessage & { event: any }).event;
1283
+ if (event?.type === "ping") return;
1284
+ if (event?.type === "message_stop" && !c.turnSawToolCall) {
1285
+ debug("processStreamEvent: ignoring bare message_stop with no streamed content/tool call");
1286
+ return;
1287
+ }
992
1288
 
993
1289
  if (event?.type === "message_start") {
994
- c.turnToolCallIds = [];
995
- c.nextHandlerIdx = 0;
1290
+ c.resetToolTracking();
996
1291
  if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model);
997
1292
  return;
998
1293
  }
999
1294
 
1000
1295
  if (event?.type === "content_block_start") {
1296
+ c.turnSawStreamEvent = true;
1001
1297
  ensureTurnStarted();
1002
1298
  if (event.content_block?.type === "text") {
1003
1299
  c.turnBlocks.push({ type: "text", text: "", index: event.index });
@@ -1007,10 +1303,11 @@ function processStreamEvent(
1007
1303
  c.currentPiStream!.push({ type: "thinking_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
1008
1304
  } else if (event.content_block?.type === "tool_use") {
1009
1305
  c.turnSawToolCall = true;
1010
- c.turnToolCallIds.push(event.content_block.id);
1306
+ const mappedName = mapToolName(event.content_block.name, customToolNameToPi);
1307
+ c.recordToolCall(event.content_block.id, mappedName, {});
1011
1308
  c.turnBlocks.push({
1012
1309
  type: "toolCall", id: event.content_block.id,
1013
- name: mapToolName(event.content_block.name, customToolNameToPi),
1310
+ name: mappedName,
1014
1311
  arguments: (event.content_block.input as Record<string, unknown>) ?? {},
1015
1312
  partialJson: "", index: event.index,
1016
1313
  });
@@ -1024,7 +1321,11 @@ function processStreamEvent(
1024
1321
  if (event?.type === "content_block_delta") {
1025
1322
  const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
1026
1323
  const block = c.turnBlocks[index];
1027
- if (!block) return;
1324
+ if (!block) {
1325
+ debug("processStreamEvent: ignoring unmatched content_block_delta", event.index);
1326
+ return;
1327
+ }
1328
+ c.turnSawStreamEvent = true;
1028
1329
  if (event.delta?.type === "text_delta" && block.type === "text") {
1029
1330
  block.text += event.delta.text;
1030
1331
  c.currentPiStream!.push({ type: "text_delta", contentIndex: index, delta: event.delta.text, partial: c.turnOutput });
@@ -1046,7 +1347,11 @@ function processStreamEvent(
1046
1347
  if (event?.type === "content_block_stop") {
1047
1348
  const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
1048
1349
  const block = c.turnBlocks[index];
1049
- if (!block) return;
1350
+ if (!block) {
1351
+ debug("processStreamEvent: ignoring unmatched content_block_stop", event.index);
1352
+ return;
1353
+ }
1354
+ c.turnSawStreamEvent = true;
1050
1355
  delete block.index;
1051
1356
  if (block.type === "text") {
1052
1357
  c.currentPiStream!.push({ type: "text_end", contentIndex: index, content: block.text, partial: c.turnOutput });
@@ -1057,6 +1362,7 @@ function processStreamEvent(
1057
1362
  block.arguments = mapToolArgs(
1058
1363
  block.name, parsePartialJson(block.partialJson, block.arguments),
1059
1364
  );
1365
+ c.updateToolCallArgs(block.id, block.arguments);
1060
1366
  delete block.partialJson;
1061
1367
  c.currentPiStream!.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: c.turnOutput });
1062
1368
  }
@@ -1095,13 +1401,73 @@ function processStreamEvent(
1095
1401
  // arrives before any stream_events, this is the primary content path. Must maintain
1096
1402
  // the same stream lifecycle as processStreamEvent — including ending the stream on
1097
1403
  // tool_use to prevent deadlock with the MCP handler.
1098
- function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>): void {
1404
+ function appendMissingToolUsesFromAssistant(
1405
+ assistantMsg: { content?: Array<any>; usage?: Record<string, number | undefined> },
1406
+ model: Model<any>,
1407
+ customToolNameToPi: Map<string, string>,
1408
+ ): boolean {
1409
+ const c = ctx();
1410
+ if (!assistantMsg?.content) return false;
1411
+ let sawToolUse = false;
1412
+ for (const block of assistantMsg.content) {
1413
+ if (block.type !== "tool_use") continue;
1414
+ sawToolUse = true;
1415
+ const existingIdx = c.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === block.id);
1416
+ const name = mapToolName(block.name, customToolNameToPi);
1417
+ const mappedArgs = mapToolArgs(name, block.input);
1418
+ c.recordToolCall(block.id, name, mappedArgs);
1419
+ if (existingIdx >= 0) {
1420
+ const existing = c.turnBlocks[existingIdx] as any;
1421
+ existing.name = name;
1422
+ existing.arguments = mappedArgs;
1423
+ c.updateToolCallArgs(block.id, mappedArgs);
1424
+ if ("partialJson" in existing) {
1425
+ delete existing.partialJson;
1426
+ delete existing.index;
1427
+ c.currentPiStream?.push({ type: "toolcall_end", contentIndex: existingIdx, toolCall: existing, partial: c.turnOutput });
1428
+ }
1429
+ continue;
1430
+ }
1431
+
1432
+ ensureTurnStarted();
1433
+ c.turnBlocks.push({
1434
+ type: "toolCall", id: block.id,
1435
+ name,
1436
+ arguments: mappedArgs,
1437
+ });
1438
+ const idx = c.turnBlocks.length - 1;
1439
+ const toolBlock = c.turnBlocks[idx];
1440
+ c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
1441
+ c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
1442
+ }
1443
+ if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
1444
+ return sawToolUse;
1445
+ }
1446
+
1447
+ export function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>): void {
1099
1448
  const c = ctx();
1100
- if (c.turnSawStreamEvent) return;
1101
1449
  const assistantMsg = (message as any).message;
1102
1450
  if (!assistantMsg?.content) return;
1103
- c.turnToolCallIds = [];
1104
- c.nextHandlerIdx = 0;
1451
+ if (c.turnSawStreamEvent) {
1452
+ // Claude Agent SDK can yield the completed assistant message before (or
1453
+ // instead of) a stream_event message_stop for a tool-use turn. Treat that
1454
+ // assistant message as a hard turn boundary so Pi executes the tool calls
1455
+ // and the MCP handlers stay blocked until real tool results are delivered.
1456
+ // Without this fallback, Claude Code can continue internally with empty MCP
1457
+ // results and Pi only sees the real outputs one render cycle later.
1458
+ if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi)) {
1459
+ c.turnSawToolCall = true;
1460
+ if (c.currentPiStream && c.turnOutput) {
1461
+ c.turnOutput.stopReason = "toolUse";
1462
+ c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
1463
+ c.currentPiStream.end();
1464
+ c.currentPiStream = null;
1465
+ debug("processAssistantMessage boundary: ended streamed tool_use turn from assistant message");
1466
+ }
1467
+ }
1468
+ return;
1469
+ }
1470
+ c.resetToolTracking();
1105
1471
  debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}`);
1106
1472
  for (const block of assistantMsg.content) {
1107
1473
  if (block.type === "text" && block.text) {
@@ -1121,11 +1487,12 @@ function processAssistantMessage(message: SDKMessage, model: Model<any>, customT
1121
1487
  } else if (block.type === "tool_use") {
1122
1488
  ensureTurnStarted();
1123
1489
  c.turnSawToolCall = true;
1124
- c.turnToolCallIds.push(block.id);
1125
- const mappedArgs = mapToolArgs(mapToolName(block.name, customToolNameToPi), block.input);
1490
+ const mappedName = mapToolName(block.name, customToolNameToPi);
1491
+ const mappedArgs = mapToolArgs(mappedName, block.input);
1492
+ c.recordToolCall(block.id, mappedName, mappedArgs);
1126
1493
  c.turnBlocks.push({
1127
1494
  type: "toolCall", id: block.id,
1128
- name: mapToolName(block.name, customToolNameToPi),
1495
+ name: mappedName,
1129
1496
  arguments: mappedArgs,
1130
1497
  });
1131
1498
  const idx = c.turnBlocks.length - 1;
@@ -1156,13 +1523,17 @@ async function consumeQuery(
1156
1523
  sdkQuery: ReturnType<typeof query>,
1157
1524
  customToolNameToPi: Map<string, string>,
1158
1525
  model: Model<any>,
1526
+ cwd: string,
1527
+ bridgeConfig: Config,
1159
1528
  wasAborted: () => boolean,
1160
1529
  ): Promise<{ capturedSessionId?: string }> {
1161
1530
  let capturedSessionId: string | undefined;
1162
1531
 
1163
1532
  for await (const message of sdkQuery) {
1164
1533
  if (wasAborted()) break;
1165
- if (!ctx().currentPiStream || !ctx().turnOutput) continue;
1534
+ const queryCtx = ctx();
1535
+ if (!queryCtx.turnOutput) continue;
1536
+ if (!queryCtx.currentPiStream && !(message.type === "assistant" && queryCtx.turnSawToolCall)) continue;
1166
1537
 
1167
1538
  switch (message.type) {
1168
1539
  case "stream_event":
@@ -1180,6 +1551,16 @@ async function consumeQuery(
1180
1551
  ctx().currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: ctx().turnOutput });
1181
1552
  ctx().currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: text, partial: ctx().turnOutput });
1182
1553
  ctx().currentPiStream?.push({ type: "text_end", contentIndex: idx, content: text, partial: ctx().turnOutput });
1554
+ } else if (message.subtype !== "success" && isExtraUsageRequiredMessage(message)) {
1555
+ const errorLines = Array.isArray((message as any).errors) ? uniqueNonEmptyLines((message as any).errors) : [];
1556
+ const errors = errorLines.length > 0 ? errorLines.join("\n") : String(message.subtype ?? "Claude Code rate limit");
1557
+ const openedExtraUsage = launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "result error");
1558
+ ctx().handledTerminalError = true;
1559
+ ctx().turnOutput.stopReason = "error";
1560
+ ctx().turnOutput.errorMessage = `${errors}${openedExtraUsage ? "\n\nOpened Claude Code /extra-usage helper. Complete billing/admin flow in the browser, then retry the prompt." : "\n\nRun /claude-bridge:extra, or enable Allow extra usage helper in settings."}`;
1561
+ ctx().currentPiStream?.push({ type: "error", reason: "error", error: ctx().turnOutput });
1562
+ ctx().currentPiStream?.end();
1563
+ ctx().currentPiStream = null;
1183
1564
  }
1184
1565
  break;
1185
1566
  case "system":
@@ -1193,10 +1574,25 @@ async function consumeQuery(
1193
1574
  const info = (message as any).rate_limit_info;
1194
1575
  debug("consumeQuery: rate_limit_event", JSON.stringify(info).slice(0, 300));
1195
1576
  if (info?.status === "rejected") {
1196
- const resetsAt = info.resetsAt ? new Date(info.resetsAt).toLocaleTimeString() : "unknown";
1197
- piUI?.notify(`Claude rate limited (${info.rateLimitType ?? "unknown"}) resets at ${resetsAt}`, "warning");
1577
+ const resetsAt = formatResetTimestamp(info.resetsAt);
1578
+ const resetAtMs = typeof info.resetsAt === "string" ? Date.parse(info.resetsAt) : undefined;
1579
+ const reason = `${info.rateLimitType ?? "unknown"} rate limit`;
1580
+ const launchedExtraUsage = isExtraUsageRequiredMessage(info) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, reason);
1581
+ emitRateLimitEvent({
1582
+ model: model.id,
1583
+ provider: PROVIDER_ID,
1584
+ rateLimitType: info.rateLimitType,
1585
+ reason,
1586
+ resetAt: info.resetsAt,
1587
+ ...(Number.isFinite(resetAtMs) ? { resetAtMs } : {}),
1588
+ source: "claude-bridge",
1589
+ status: "rejected",
1590
+ });
1591
+ piUI?.notify(`${RATE_LIMIT_TOKEN} Claude ${reason} hit — resets ${resetsAt}${launchedExtraUsage ? "; opened /extra-usage helper" : ""}`, "warning");
1198
1592
  } else if (info?.status === "allowed_warning") {
1199
- piUI?.notify(`Claude rate limit warning: ${Math.round(info.utilization ?? 0)}% used (${info.rateLimitType ?? ""})`, "warning");
1593
+ const warning = formatAllowedRateLimitWarning(info);
1594
+ if (warning) piUI?.notify(warning, "warning");
1595
+ else debug("consumeQuery: suppressed low/ambiguous allowed_warning rate_limit_event", JSON.stringify(info).slice(0, 300));
1200
1596
  }
1201
1597
  break;
1202
1598
  }
@@ -1219,6 +1615,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1219
1615
 
1220
1616
  // DEBUG: trace followUp message triggering
1221
1617
  const lastMsgRole = context.messages[context.messages.length - 1]?.role;
1618
+ const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
1222
1619
  debug(`provider: streamClaudeAgentSdk called, activeQuery=${!!ctx().activeQuery}, lastMsgRole=${lastMsgRole}, isReentrant=${ctx().activeQuery !== null}`);
1223
1620
 
1224
1621
  // --- Tool result delivery ---
@@ -1226,30 +1623,48 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1226
1623
  // (everything after the last assistant message) and match against waiting MCP
1227
1624
  // handlers. Results that arrive before their handler get queued in pendingResults.
1228
1625
  if (ctx().activeQuery) {
1229
- ctx().currentPiStream = stream;
1230
- ctx().resetTurnState(model);
1626
+ const queryCtx = ctx();
1627
+ queryCtx.currentPiStream = stream;
1628
+ queryCtx.resetTurnState(model);
1231
1629
  const allResults = extractAllToolResults(context);
1232
- debug(`provider: tool results, ${allResults.length} results, ${ctx().pendingToolCalls.size} waiting handlers, ctx.msgs=${context.messages.length}`);
1630
+ debug(`provider: tool results, ${allResults.length} results, ${queryCtx.pendingToolCalls.size} waiting handlers, ctx.msgs=${context.messages.length}`);
1631
+ const unmatchedResultIds: string[] = [];
1233
1632
  for (const result of allResults) {
1234
1633
  const id = result.toolCallId;
1235
- if (id && ctx().pendingToolCalls.has(id)) {
1236
- const pending = ctx().pendingToolCalls.get(id)!;
1237
- ctx().pendingToolCalls.delete(id);
1634
+ if (id && !queryCtx.hasRecordedToolCall(id)) {
1635
+ queryCtx.markToolResultUnmatched(id);
1636
+ unmatchedResultIds.push(id);
1637
+ debug(`ERROR: tool result [${id}] has no registered tool_call id; refusing to queue or deliver`);
1638
+ continue;
1639
+ }
1640
+ queryCtx.markToolResultDelivered(id);
1641
+ if (id && queryCtx.pendingToolCalls.has(id)) {
1642
+ const pending = queryCtx.pendingToolCalls.get(id)!;
1643
+ queryCtx.pendingToolCalls.delete(id);
1238
1644
  debug(`provider: resolving ${pending.toolName} [${id}]${result.isError ? " (error)" : ""}`, JSON.stringify(result.content).slice(0, 200));
1239
1645
  pending.resolve(result);
1240
1646
  } else if (id) {
1241
- ctx().pendingResults.set(id, result);
1242
- debug(`provider: queued result [${id}] (${ctx().pendingResults.size} pending)`);
1647
+ queryCtx.pendingResults.set(id, result);
1648
+ debug(`provider: queued result [${id}] (${queryCtx.pendingResults.size} pending)`);
1243
1649
  } else {
1244
1650
  debug(`WARNING: tool result without toolCallId, cannot match`);
1245
1651
  }
1246
- if (ctx().pendingToolCalls.size > 0 && ctx().pendingResults.size > 0) {
1247
- debug(`BUG: both maps non-empty! handlers=${ctx().pendingToolCalls.size} results=${ctx().pendingResults.size}`);
1652
+ if (queryCtx.pendingToolCalls.size > 0 && queryCtx.pendingResults.size > 0) {
1653
+ debug(`BUG: both maps non-empty! handlers=${queryCtx.pendingToolCalls.size} results=${queryCtx.pendingResults.size}`);
1248
1654
  }
1249
1655
  }
1250
- if (ctx().pendingToolCalls.size > 0) {
1251
- debug(`WARNING: ${ctx().pendingToolCalls.size} MCP handlers still waiting after delivering ${allResults.length} results`);
1252
- piUI?.notify(`Claude bridge: ${ctx().pendingToolCalls.size} tool handler(s) still waiting provider may be stuck`, "warning");
1656
+ if (unmatchedResultIds.length > 0) {
1657
+ const errorResult: McpResult = {
1658
+ content: [{ type: "text", text: `Claude bridge internal error: ${unmatchedResultIds.length} tool result(s) did not match any registered tool_call id. The turn was stopped to avoid delivering tool output to the wrong call. Unmatched ids: ${unmatchedResultIds.slice(0, 8).join(", ")}${unmatchedResultIds.length > 8 ? ", ..." : ""}` }],
1659
+ isError: true,
1660
+ };
1661
+ for (const pending of queryCtx.pendingToolCalls.values()) pending.resolve(errorResult);
1662
+ queryCtx.pendingToolCalls.clear();
1663
+ reportToolResultMismatch(queryCtx, "unmatched tool result", cwd);
1664
+ }
1665
+ if (queryCtx.pendingToolCalls.size > 0) {
1666
+ debug(`WARNING: ${queryCtx.pendingToolCalls.size} MCP handlers still waiting after delivering ${allResults.length} results`);
1667
+ piUI?.notify(`Claude bridge: ${queryCtx.pendingToolCalls.size} tool handler(s) still waiting — provider may be stuck`, "warning");
1253
1668
  }
1254
1669
 
1255
1670
  // Detect user messages (steer/followUp) that pi injected into context
@@ -1269,7 +1684,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1269
1684
  }
1270
1685
 
1271
1686
  if (sharedSession) sharedSession.cursor = context.messages.length;
1272
- ctx().latestCursor = Math.max(ctx().latestCursor, context.messages.length);
1687
+ queryCtx.latestCursor = Math.max(queryCtx.latestCursor, context.messages.length);
1273
1688
  return stream;
1274
1689
  }
1275
1690
 
@@ -1303,10 +1718,10 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1303
1718
  ctx().pendingResults.clear();
1304
1719
  ctx().deferredUserMessages = [];
1305
1720
  ctx().resetTurnState(model);
1721
+ ctx().resetToolTracking();
1306
1722
  ctx().latestCursor = 0;
1307
1723
 
1308
1724
  const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(context);
1309
- const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
1310
1725
  const promptBlocks = extractUserPromptBlocks(context.messages);
1311
1726
  let promptText = extractUserPrompt(context.messages) ?? "";
1312
1727
 
@@ -1354,10 +1769,11 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1354
1769
  // Prefer the model's own thinkingLevelMap when present (pi-ai 0.72+ ships
1355
1770
  // per-model overrides — e.g. opus-4-7 wants xhigh→xhigh, not xhigh→max).
1356
1771
  // Fall back to our generic table for older pi-ai or unmapped levels.
1357
- const effort = options?.reasoning
1772
+ const requestedEffort = options?.reasoning
1358
1773
  ? ((model as any).thinkingLevelMap?.[options.reasoning] as EffortLevel | undefined)
1359
1774
  ?? REASONING_TO_EFFORT[options.reasoning]
1360
1775
  : undefined;
1776
+ const effort = resolveConfiguredEffort(model.id, requestedEffort, providerSettings);
1361
1777
 
1362
1778
  const extraArgs: Record<string, string | null> = { model: model.id };
1363
1779
  if (strictMcpConfigEnabled) extraArgs["strict-mcp-config"] = null;
@@ -1382,6 +1798,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1382
1798
  ...CLAUDE_BRIDGE_TOOL_ISOLATION,
1383
1799
  permissionMode: "bypassPermissions",
1384
1800
  includePartialMessages: true,
1801
+ ...(providerSettings.fastMode ? { settings: { fastMode: true } } : {}),
1385
1802
  systemPrompt: {
1386
1803
  type: "preset", preset: "claude_code",
1387
1804
  append: systemPromptAppend ? systemPromptAppend : undefined,
@@ -1399,7 +1816,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1399
1816
  debug("provider: fresh query",
1400
1817
  `model=${model.id} msgs=${context.messages.length} tools=${mcpTools.length}`,
1401
1818
  `resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
1402
- `appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled}`,
1819
+ `appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled} fastMode=${providerSettings.fastMode === true}`,
1403
1820
  `claudeExec=${claudeExecutablePreflight ? `${claudeExecutablePreflight.fileType}:${claudeExecutablePreflight.path}` : "sdk-default"}`,
1404
1821
  `prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`);
1405
1822
 
@@ -1421,6 +1838,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1421
1838
  wasAborted = true;
1422
1839
  // Prevent stale deferred messages from being replayed by parent on pop
1423
1840
  abortCtx.deferredUserMessages = [];
1841
+ reportToolResultMismatch(abortCtx, "abort", cwd, { forceRotate: true });
1424
1842
  for (const pending of abortCtx.pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Operation aborted" }] }); }
1425
1843
  abortCtx.pendingToolCalls.clear();
1426
1844
  abortCtx.pendingResults.clear();
@@ -1432,7 +1850,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1432
1850
  }
1433
1851
 
1434
1852
  // Background consumer — runs until query ends
1435
- consumeQuery(sdkQuery, customToolNameToPi, model, () => wasAborted)
1853
+ consumeQuery(sdkQuery, customToolNameToPi, model, cwd, bridgeConfig, () => wasAborted)
1436
1854
  .then(async ({ capturedSessionId }) => {
1437
1855
  debug(`provider: consumeQuery completed, stopReason=${ctx().turnOutput?.stopReason}, error=${ctx().turnOutput?.errorMessage}, aborted=${wasAborted}`);
1438
1856
 
@@ -1467,6 +1885,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1467
1885
  const steerPrompt = ctx().deferredUserMessages.shift()!;
1468
1886
  debug(`provider: replaying deferred user message: ${steerPrompt.slice(0, 60)}`);
1469
1887
  ctx().resetTurnState(model);
1888
+ ctx().resetToolTracking();
1470
1889
 
1471
1890
  const resumeId = sharedSession?.sessionId;
1472
1891
  if (!resumeId) {
@@ -1481,7 +1900,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1481
1900
  debug(`provider: continuation query, model=${model.id}, resume=${resumeId.slice(0, 8)}, prompt=${steerPrompt.slice(0, 60)}`);
1482
1901
 
1483
1902
  try {
1484
- const { capturedSessionId: contSid } = await consumeQuery(contQuery, customToolNameToPi, model, () => wasAborted);
1903
+ const { capturedSessionId: contSid } = await consumeQuery(contQuery, customToolNameToPi, model, cwd, bridgeConfig, () => wasAborted);
1485
1904
  const sid = contSid ?? sharedSession?.sessionId;
1486
1905
  if (sid) {
1487
1906
  sharedSession = { sessionId: sid, cursor: sharedSession?.cursor ?? 0, cwd };
@@ -1502,15 +1921,21 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1502
1921
  })
1503
1922
  .catch((error) => {
1504
1923
  debug(`provider: query error, model=${model.id}, aborted=${Boolean(options?.signal?.aborted)}, error=`, error);
1924
+ const suppressDuplicateError = ctx().handledTerminalError;
1925
+ const openedExtraUsage = !suppressDuplicateError && isExtraUsageRequiredMessage(error) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "query error");
1505
1926
  if ((wasAborted || options?.signal?.aborted) && sharedSession) {
1506
1927
  sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
1507
1928
  } else {
1508
1929
  sharedSession = null;
1509
1930
  }
1510
1931
  ctx().deferredUserMessages = [];
1932
+ if (suppressDuplicateError) {
1933
+ debug("provider: suppressing duplicate query error after terminal error was already emitted");
1934
+ return;
1935
+ }
1511
1936
  if (ctx().turnOutput) {
1512
1937
  ctx().turnOutput.stopReason = options?.signal?.aborted ? "aborted" : "error";
1513
- ctx().turnOutput.errorMessage = error instanceof Error ? error.message : String(error);
1938
+ ctx().turnOutput.errorMessage = `${error instanceof Error ? error.message : String(error)}${openedExtraUsage ? "\n\nOpened Claude Code /extra-usage helper. Complete billing/admin flow in the browser, then retry the prompt." : ""}`;
1514
1939
  }
1515
1940
  ctx().currentPiStream?.push({ type: "error", reason: (ctx().turnOutput?.stopReason ?? "error") as "aborted" | "error", error: ctx().turnOutput! });
1516
1941
  ctx().currentPiStream?.end();
@@ -1519,6 +1944,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1519
1944
  .finally(() => {
1520
1945
  if (options?.signal) options.signal.removeEventListener("abort", onAbort);
1521
1946
  if (ctx().activeQuery === sdkQuery) {
1947
+ reportToolResultMismatch(ctx(), "query teardown", cwd, { forceRotate: wasAborted || options?.signal?.aborted });
1522
1948
  // Drain pending handlers for this query
1523
1949
  for (const pending of ctx().pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Query ended" }] }); }
1524
1950
  ctx().pendingToolCalls.clear();
@@ -1536,6 +1962,70 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1536
1962
  return stream;
1537
1963
  }
1538
1964
 
1965
+ function commandCwd(ctx: unknown): string {
1966
+ const value = (ctx as { cwd?: unknown })?.cwd;
1967
+ return typeof value === "string" && value.length > 0 ? value : process.cwd();
1968
+ }
1969
+
1970
+ async function tryOpenExtensionManagerSettings(ctx: { ui: ExtensionUIContext }): Promise<boolean> {
1971
+ const host = globalThis as unknown as Record<PropertyKey, unknown>;
1972
+ const openQuickSettings = host[Symbol.for("vstack.pi.extension-manager.open-quick-settings")];
1973
+ if (typeof openQuickSettings !== "function") return false;
1974
+ try {
1975
+ await (openQuickSettings as (ctx: unknown, hint?: string) => Promise<void>)(ctx, "@vanillagreen/pi-claude-bridge");
1976
+ return true;
1977
+ } catch {
1978
+ return false;
1979
+ }
1980
+ }
1981
+
1982
+ function showBridgeStatus(ctx: { ui: ExtensionUIContext; cwd?: string }): void {
1983
+ const config = loadConfig(commandCwd(ctx));
1984
+ ctx.ui.notify([
1985
+ `Claude bridge: ${config.enabled === false ? "disabled" : "enabled"}`,
1986
+ `Extra usage auto-helper: ${extraUsageAllowed(config) ? "on" : "off"} (settings)`,
1987
+ `Use /claude-bridge:extra to run Claude Code /extra-usage now.`,
1988
+ ].join("\n"), "info");
1989
+ }
1990
+
1991
+ function registerBridgeCommands(pi: ExtensionAPI): void {
1992
+ const guard = pi as unknown as Record<PropertyKey, unknown>;
1993
+ if (guard[COMMANDS_REGISTERED_KEY]) return;
1994
+ guard[COMMANDS_REGISTERED_KEY] = true;
1995
+
1996
+ const runExtraUsage = async (ctx: { ui: ExtensionUIContext; cwd?: string }) => {
1997
+ const cwd = commandCwd(ctx);
1998
+ if (extraUsageHelperInFlight) {
1999
+ ctx.ui.notify("Claude extra usage helper already running.", "info");
2000
+ await extraUsageHelperInFlight.catch(() => undefined);
2001
+ return;
2002
+ }
2003
+ try {
2004
+ ctx.ui.notify("Claude extra usage helper starting…", "info");
2005
+ extraUsageHelperInFlight = runExtraUsageHelper(cwd)
2006
+ .finally(() => { extraUsageHelperInFlight = null; });
2007
+ const message = await extraUsageHelperInFlight;
2008
+ ctx.ui.notify(`Claude extra usage helper: ${message}`, "info");
2009
+ } catch (error) {
2010
+ const message = error instanceof Error ? error.message : String(error);
2011
+ ctx.ui.notify(`Claude extra usage helper failed: ${message}`, "error");
2012
+ }
2013
+ };
2014
+
2015
+ pi.registerCommand("claude-bridge", {
2016
+ description: "Open Claude bridge settings/status",
2017
+ handler: async (args: string, ctx) => {
2018
+ if (args.trim()) ctx.ui.notify("Unknown /claude-bridge argument. Use /claude-bridge:extra to run Claude Code /extra-usage.", "warning");
2019
+ if (await tryOpenExtensionManagerSettings(ctx)) return;
2020
+ showBridgeStatus(ctx);
2021
+ },
2022
+ });
2023
+ pi.registerCommand("claude-bridge:extra", {
2024
+ description: "Run Claude Code /extra-usage through claude-bridge",
2025
+ handler: async (_args: string, ctx) => runExtraUsage(ctx),
2026
+ });
2027
+ }
2028
+
1539
2029
  // --- Extension registration ---
1540
2030
 
1541
2031
  export default function (pi: ExtensionAPI) {
@@ -1545,6 +2035,7 @@ export default function (pi: ExtensionAPI) {
1545
2035
 
1546
2036
  const config = loadConfig(process.cwd());
1547
2037
  debug("loadConfig:", JSON.stringify(config));
2038
+ registerBridgeCommands(pi);
1548
2039
  if (config.enabled === false) {
1549
2040
  debug("provider: disabled by configuration");
1550
2041
  return;
@@ -1589,6 +2080,9 @@ export default function (pi: ExtensionAPI) {
1589
2080
  // triggers CC's autocompact-thrashing guard (issue #8). Force the next
1590
2081
  // call down the REBUILD path so CC sees the current history.
1591
2082
  const markRebuild = (event: string) => {
2083
+ if (ctx().activeQuery) {
2084
+ reportToolResultMismatch(ctx(), event, sharedSession?.cwd ?? process.cwd());
2085
+ }
1592
2086
  if (sharedSession) {
1593
2087
  debug(`${event}: marking needsRebuild on session ${sharedSession.sessionId.slice(0, 8)}`);
1594
2088
  sharedSession = { ...sharedSession, needsRebuild: true };