@vanillagreen/pi-claude-bridge 1.2.0 → 1.4.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, type Config } 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 ---
@@ -426,6 +530,140 @@ let extensionApi: ExtensionAPI | undefined;
426
530
  let piUI: ExtensionUIContext | undefined;
427
531
  let extraUsageHelperInFlight: Promise<string> | null = null;
428
532
 
533
+ const RATE_LIMIT_AUTO_RESUME_EVENT = "vstack:rate-limit";
534
+ const RATE_LIMIT_TOKEN = "\x1b[31m[rate-limit]\x1b[39m";
535
+ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 90_000;
536
+ export const STREAM_IDLE_BACKOFF_HINT_MS = 60_000;
537
+ export const STREAM_IDLE_TIMEOUT_ENV = "CLAUDE_BRIDGE_STREAM_IDLE_TIMEOUT";
538
+
539
+ type TimerHandle = ReturnType<typeof setTimeout>;
540
+
541
+ export interface StreamIdleWatchdogState {
542
+ activeQuery: unknown | null;
543
+ currentPiStream: AssistantMessageEventStream | null;
544
+ turnOutput: AssistantMessage | null;
545
+ turnSawStreamEvent: boolean;
546
+ turnStarted: boolean;
547
+ }
548
+
549
+ export interface StreamIdleTimeoutInfo {
550
+ idleMs: number;
551
+ timeoutMs: number;
552
+ }
553
+
554
+ export interface StreamIdleWatchdog {
555
+ dispose: () => void;
556
+ noteChunk: () => void;
557
+ refresh: () => void;
558
+ timedOut: () => boolean;
559
+ }
560
+
561
+ const activeStreamIdleWatchdogs = new WeakMap<QueryContext, StreamIdleWatchdog>();
562
+
563
+ function parseDurationLiteralMs(value: string, defaultUnit: "ms" | "s" = "s"): number | undefined {
564
+ const text = value.trim().toLowerCase();
565
+ if (!text) return undefined;
566
+ if (["off", "false", "disabled", "disable"].includes(text)) return 0;
567
+ const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|msec|msecs|milliseconds?|s|sec|secs|seconds?|m|min|mins|minutes?)?$/i);
568
+ if (!match) return undefined;
569
+ const amount = Number(match[1]);
570
+ if (!Number.isFinite(amount) || amount < 0) return undefined;
571
+ const unit = (match[2] ?? defaultUnit).toLowerCase();
572
+ const multiplier = ["ms", "msec", "msecs", "millisecond", "milliseconds"].includes(unit)
573
+ ? 1
574
+ : ["s", "sec", "secs", "second", "seconds"].includes(unit)
575
+ ? 1000
576
+ : ["m", "min", "mins", "minute", "minutes"].includes(unit)
577
+ ? 60_000
578
+ : undefined;
579
+ if (multiplier === undefined) return undefined;
580
+ const ms = Math.round(amount * multiplier);
581
+ return Number.isFinite(ms) ? ms : undefined;
582
+ }
583
+
584
+ export function streamIdleTimeoutMsFromEnv(env: NodeJS.ProcessEnv = process.env): number {
585
+ const raw = env[STREAM_IDLE_TIMEOUT_ENV]?.trim();
586
+ if (!raw) return DEFAULT_STREAM_IDLE_TIMEOUT_MS;
587
+ return parseDurationLiteralMs(raw, "s") ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
588
+ }
589
+
590
+ function formatDurationShort(ms: number): string {
591
+ if (ms < 180_000 && ms % 1000 === 0) return `${ms / 1000}s`;
592
+ if (ms % 60_000 === 0) return `${ms / 60_000}m`;
593
+ if (ms % 1000 === 0) return `${ms / 1000}s`;
594
+ return `${ms}ms`;
595
+ }
596
+
597
+ export function buildStreamIdleTimeoutErrorMessage(timeoutMs: number): string {
598
+ return `Claude Code stream idle timeout after ${formatDurationShort(timeoutMs)} with no assistant/tool output; treating stalled stream as retryable 529 overloaded/rate limit condition. Retry after ${formatDurationShort(STREAM_IDLE_BACKOFF_HINT_MS)}.`;
599
+ }
600
+
601
+ export function createStreamIdleWatchdog({
602
+ clearTimer = (timer: TimerHandle) => clearTimeout(timer),
603
+ getState,
604
+ now = () => Date.now(),
605
+ onTimeout,
606
+ setTimer = (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
607
+ timeoutMs,
608
+ }: {
609
+ clearTimer?: (timer: TimerHandle) => void;
610
+ getState: () => StreamIdleWatchdogState;
611
+ now?: () => number;
612
+ onTimeout: (info: StreamIdleTimeoutInfo) => void;
613
+ setTimer?: (fn: () => void, delayMs: number) => TimerHandle;
614
+ timeoutMs: number;
615
+ }): StreamIdleWatchdog {
616
+ let disposed = false;
617
+ let lastChunkAt = now();
618
+ let timer: TimerHandle | null = null;
619
+ let didTimeout = false;
620
+
621
+ const clear = () => {
622
+ if (!timer) return;
623
+ try { clearTimer(timer); } catch { /* best effort */ }
624
+ timer = null;
625
+ };
626
+
627
+ const shouldMonitor = (state: StreamIdleWatchdogState): boolean => Boolean(
628
+ timeoutMs > 0
629
+ && state.activeQuery
630
+ && state.currentPiStream
631
+ && state.turnOutput
632
+ && !state.turnStarted
633
+ && !state.turnSawStreamEvent,
634
+ );
635
+
636
+ const schedule = () => {
637
+ clear();
638
+ if (disposed || didTimeout || timeoutMs <= 0) return;
639
+ const state = getState();
640
+ if (!shouldMonitor(state)) return;
641
+ const turnStartedAt = typeof state.turnOutput?.timestamp === "number" ? state.turnOutput.timestamp : 0;
642
+ const idleStartedAt = Math.max(lastChunkAt, turnStartedAt);
643
+ const idleMs = Math.max(0, now() - idleStartedAt);
644
+ if (idleMs >= timeoutMs) {
645
+ didTimeout = true;
646
+ onTimeout({ idleMs, timeoutMs });
647
+ return;
648
+ }
649
+ timer = setTimer(schedule, Math.max(1, timeoutMs - idleMs));
650
+ (timer as { unref?: () => void }).unref?.();
651
+ };
652
+
653
+ return {
654
+ dispose: () => {
655
+ disposed = true;
656
+ clear();
657
+ },
658
+ noteChunk: () => {
659
+ lastChunkAt = now();
660
+ schedule();
661
+ },
662
+ refresh: schedule,
663
+ timedOut: () => didTimeout,
664
+ };
665
+ }
666
+
429
667
  export function isExtraUsageRequiredMessage(value: unknown): boolean {
430
668
  let text: string;
431
669
  if (typeof value === "string") text = value;
@@ -437,6 +675,64 @@ export function isExtraUsageRequiredMessage(value: unknown): boolean {
437
675
  return /extra[-\s]?usage|overage|extra usage billing|extra usage credits|1M context/i.test(text);
438
676
  }
439
677
 
678
+ export function uniqueNonEmptyLines(values: unknown[]): string[] {
679
+ const seen = new Set<string>();
680
+ const out: string[] = [];
681
+ for (const value of values) {
682
+ const text = typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
683
+ if (!text || seen.has(text)) continue;
684
+ seen.add(text);
685
+ out.push(text);
686
+ }
687
+ return out;
688
+ }
689
+
690
+ export function formatResetTimestamp(value: unknown): string {
691
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : Number.NaN;
692
+ if (!Number.isFinite(parsed)) return "unknown";
693
+ return new Date(parsed).toLocaleString(undefined, {
694
+ day: "numeric",
695
+ hour: "numeric",
696
+ minute: "2-digit",
697
+ month: "short",
698
+ second: "2-digit",
699
+ timeZoneName: "short",
700
+ year: "numeric",
701
+ });
702
+ }
703
+
704
+ export const ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD = 80;
705
+
706
+ export function normalizeRateLimitUtilization(value: unknown): number | undefined {
707
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined;
708
+ if (value === 0) return 0;
709
+ // Claude SDK payloads have appeared as both fractions and percentages.
710
+ // Exact 1 is unit-ambiguous (1% vs 100%), so do not use it for allowed-warning copy.
711
+ if (value > 0 && value < 1) return value * 100;
712
+ if (value > 1 && value <= 100) return value;
713
+ return undefined;
714
+ }
715
+
716
+ function rateLimitTypeLabel(value: unknown): string {
717
+ const text = typeof value === "string" ? value.trim() : "";
718
+ return text || "unknown";
719
+ }
720
+
721
+ export function formatAllowedRateLimitWarning(info: { status?: unknown; utilization?: unknown; rateLimitType?: unknown } | null | undefined): string | undefined {
722
+ if (info?.status !== "allowed_warning") return undefined;
723
+ const utilization = normalizeRateLimitUtilization(info.utilization);
724
+ if (utilization === undefined || utilization < ALLOWED_RATE_LIMIT_WARNING_UTILIZATION_THRESHOLD) return undefined;
725
+ return `Claude rate limit warning: nearing ${rateLimitTypeLabel(info.rateLimitType)} limit; check Claude Code /usage for exact utilization.`;
726
+ }
727
+
728
+ function emitRateLimitEvent(payload: Record<string, unknown>): void {
729
+ try {
730
+ extensionApi?.events?.emit?.(RATE_LIMIT_AUTO_RESUME_EVENT, payload);
731
+ } catch {
732
+ // Cross-extension broker is best-effort only.
733
+ }
734
+ }
735
+
440
736
  function extraUsageAllowed(config: Config): boolean {
441
737
  return config.provider?.allowExtraUsage === true;
442
738
  }
@@ -641,6 +937,7 @@ function convertAndImportMessages(
641
937
  session: ReturnType<typeof createSession>,
642
938
  messages: Context["messages"],
643
939
  customToolNameToSdk?: Map<string, string>,
940
+ cwd?: string,
644
941
  ): void {
645
942
  const { anthropicMessages, sanitizedIds } = convertPiMessages(messages, customToolNameToSdk);
646
943
 
@@ -656,7 +953,17 @@ function convertAndImportMessages(
656
953
  [...sanitizedIds.entries()].map(([orig, clean]) => orig === clean ? orig : `${orig}→${clean}`).join(", "));
657
954
  }
658
955
  // Pre-repair for debug logging; importMessages also repairs internally (idempotent).
956
+ const missingToolResults = findUnpairedToolUses(anthropicMessages);
659
957
  const repaired = repairToolPairing(anthropicMessages);
958
+ if (missingToolResults.length > 0) {
959
+ reportSyntheticToolResultRepair(missingToolResults, {
960
+ cwd,
961
+ messageCount: messages.length,
962
+ anthropicMessageCount: anthropicMessages.length,
963
+ sessionId: session.sessionId,
964
+ jsonlPath: session.jsonlPath,
965
+ });
966
+ }
660
967
  if (repaired.length !== anthropicMessages.length) {
661
968
  debug(`convertAndImportMessages: repairToolPairing ${anthropicMessages.length} → ${repaired.length} msgs`);
662
969
  }
@@ -854,7 +1161,7 @@ function syncSharedSession(
854
1161
  ...(preserveId ? { sessionId: previousSessionId } : {}),
855
1162
  ...(modelId ? { model: modelId } : {}),
856
1163
  });
857
- convertAndImportMessages(session, priorMessages, customToolNameToSdk);
1164
+ convertAndImportMessages(session, priorMessages, customToolNameToSdk, cwd);
858
1165
  session.save();
859
1166
  verifyWrittenSession(session.jsonlPath, session.sessionId, session.messages.length, cwd);
860
1167
  sharedSession = { sessionId: session.sessionId, cursor: priorMessages.length, cwd };
@@ -954,28 +1261,50 @@ function resolveMcpTools(context: Context, excludeToolName?: string): {
954
1261
 
955
1262
  // Creates an MCP server that bridges pi tools to the SDK. Each tool handler
956
1263
  // blocks on a Promise until pi delivers the tool result via streamSimple.
957
- // Handlers are assigned toolCallIds from turnToolCallIds (populated when the SDK
958
- // emits tool_use blocks). Results are matched by ID, not position.
959
- // Handlers close over the captured `queryCtx`, ensuring they operate on the
960
- // correct query's state even across pushContext/popContext calls.
1264
+ // Handlers claim their tool_call id by matching the actual MCP call
1265
+ // (tool name + arguments) against the recorded tool_use blocks, then results
1266
+ // are matched by ID. Handlers close over the captured `queryCtx`, ensuring they
1267
+ // operate on the correct query's state even across pushContext/popContext calls.
961
1268
  function buildMcpServers(tools: Tool[], queryCtx: QueryContext): Record<string, ReturnType<typeof createSdkMcpServer>> | undefined {
962
1269
  if (!tools.length) return undefined;
963
1270
  const mcpTools = tools.map((tool) => ({
964
1271
  name: tool.name,
965
1272
  description: tool.description,
966
1273
  inputSchema: jsonSchemaToZodShape(tool.parameters),
967
- handler: async () => {
968
- const toolCallId = queryCtx.turnToolCallIds[queryCtx.nextHandlerIdx++];
969
- if (!toolCallId) debug(`WARNING: mcp handler ${tool.name} has no toolCallId (idx=${queryCtx.nextHandlerIdx - 1}, available=${queryCtx.turnToolCallIds.length})`);
1274
+ handler: async (args?: Record<string, unknown>) => {
1275
+ const mappedArgs = mapToolArgs(tool.name, args);
1276
+ const claim = queryCtx.claimToolCall(tool.name, mappedArgs);
1277
+ const toolCallId = claim.toolCallId;
1278
+ if (!toolCallId) {
1279
+ debug(`WARNING: mcp handler ${tool.name} has no toolCallId (available=${claim.available})`);
1280
+ diagDump("tool_handler_unmatched", {
1281
+ toolName: tool.name,
1282
+ argKeys: argKeys(mappedArgs),
1283
+ available: claim.available,
1284
+ turnToolCallIds: queryCtx.turnToolCallIds,
1285
+ turnToolCalls: safeToolCallSummary(queryCtx.turnToolCalls),
1286
+ });
1287
+ return { content: [{ type: "text", text: `Claude bridge internal error: no matching tool_call id for ${tool.name}` }], isError: true } satisfies McpResult;
1288
+ }
1289
+ if (claim.match !== "tool-args" || claim.ambiguous) {
1290
+ debug(`mcp handler: ${tool.name} [${toolCallId}] claimed by ${claim.match}${claim.ambiguous ? " (ambiguous)" : ""}`);
1291
+ }
970
1292
  if (toolCallId && queryCtx.pendingResults.has(toolCallId)) {
971
1293
  const result = queryCtx.pendingResults.get(toolCallId)!;
972
1294
  queryCtx.pendingResults.delete(toolCallId);
1295
+ queryCtx.markToolResultResolved(toolCallId);
973
1296
  debug(`mcp handler: ${tool.name} [${toolCallId}] → resolved from queue (${queryCtx.pendingResults.size} remaining)`);
974
1297
  return result;
975
1298
  }
976
1299
  debug(`mcp handler: ${tool.name} [${toolCallId}] → waiting`);
977
1300
  return new Promise<McpResult>((resolve) => {
978
- queryCtx.pendingToolCalls.set(toolCallId, { toolName: tool.name, resolve });
1301
+ queryCtx.pendingToolCalls.set(toolCallId, {
1302
+ toolName: tool.name,
1303
+ resolve: (result) => {
1304
+ queryCtx.markToolResultResolved(toolCallId);
1305
+ resolve(result);
1306
+ },
1307
+ });
979
1308
  });
980
1309
  },
981
1310
  }));
@@ -1004,6 +1333,26 @@ const REASONING_TO_EFFORT: Record<string, EffortLevel> = {
1004
1333
  minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "max",
1005
1334
  };
1006
1335
 
1336
+ function normalizeEffortOverrideModelKey(value: string): string {
1337
+ const key = value.trim().toLowerCase();
1338
+ return key.startsWith(`${PROVIDER_ID}/`) ? key.slice(PROVIDER_ID.length + 1) : key;
1339
+ }
1340
+
1341
+ export function resolveConfiguredEffort(
1342
+ modelId: string,
1343
+ reasoningEffort: EffortLevel | undefined,
1344
+ providerConfig?: Config["provider"],
1345
+ ): EffortLevel | undefined {
1346
+ const target = normalizeEffortOverrideModelKey(modelId);
1347
+ for (const [key, rawEffort] of Object.entries(providerConfig?.modelEffortOverrides ?? {})) {
1348
+ const normalizedKey = normalizeEffortOverrideModelKey(key);
1349
+ if (normalizedKey !== "*" && normalizedKey !== target) continue;
1350
+ const effort = normalizeEffortLevel(rawEffort) as EffortLevel | undefined;
1351
+ if (effort) return effort;
1352
+ }
1353
+ return (normalizeEffortLevel(providerConfig?.forceEffort) as EffortLevel | undefined) ?? reasoningEffort;
1354
+ }
1355
+
1007
1356
  // --- Provider helpers: misc ---
1008
1357
 
1009
1358
  function mapStopReason(reason: string | undefined): "stop" | "length" | "toolUse" {
@@ -1054,24 +1403,28 @@ function finalizeCurrentStream(stopReason?: string): void {
1054
1403
 
1055
1404
  /** Maps Anthropic stream events to pi stream events (text, thinking, toolcall).
1056
1405
  * On message_stop with tool_use: ends currentPiStream so pi can execute the tool. */
1057
- function processStreamEvent(
1406
+ export function processStreamEvent(
1058
1407
  message: SDKMessage,
1059
1408
  customToolNameToPi: Map<string, string>,
1060
1409
  model: Model<any>,
1061
1410
  ): void {
1062
1411
  const c = ctx();
1063
1412
  if (!c.currentPiStream || !c.turnOutput) return;
1064
- c.turnSawStreamEvent = true;
1065
1413
  const event = (message as SDKMessage & { event: any }).event;
1414
+ if (event?.type === "ping") return;
1415
+ if (event?.type === "message_stop" && !c.turnSawToolCall) {
1416
+ debug("processStreamEvent: ignoring bare message_stop with no streamed content/tool call");
1417
+ return;
1418
+ }
1066
1419
 
1067
1420
  if (event?.type === "message_start") {
1068
- c.turnToolCallIds = [];
1069
- c.nextHandlerIdx = 0;
1421
+ c.resetToolTracking();
1070
1422
  if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model);
1071
1423
  return;
1072
1424
  }
1073
1425
 
1074
1426
  if (event?.type === "content_block_start") {
1427
+ c.turnSawStreamEvent = true;
1075
1428
  ensureTurnStarted();
1076
1429
  if (event.content_block?.type === "text") {
1077
1430
  c.turnBlocks.push({ type: "text", text: "", index: event.index });
@@ -1081,10 +1434,11 @@ function processStreamEvent(
1081
1434
  c.currentPiStream!.push({ type: "thinking_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
1082
1435
  } else if (event.content_block?.type === "tool_use") {
1083
1436
  c.turnSawToolCall = true;
1084
- c.turnToolCallIds.push(event.content_block.id);
1437
+ const mappedName = mapToolName(event.content_block.name, customToolNameToPi);
1438
+ c.recordToolCall(event.content_block.id, mappedName, {});
1085
1439
  c.turnBlocks.push({
1086
1440
  type: "toolCall", id: event.content_block.id,
1087
- name: mapToolName(event.content_block.name, customToolNameToPi),
1441
+ name: mappedName,
1088
1442
  arguments: (event.content_block.input as Record<string, unknown>) ?? {},
1089
1443
  partialJson: "", index: event.index,
1090
1444
  });
@@ -1098,7 +1452,11 @@ function processStreamEvent(
1098
1452
  if (event?.type === "content_block_delta") {
1099
1453
  const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
1100
1454
  const block = c.turnBlocks[index];
1101
- if (!block) return;
1455
+ if (!block) {
1456
+ debug("processStreamEvent: ignoring unmatched content_block_delta", event.index);
1457
+ return;
1458
+ }
1459
+ c.turnSawStreamEvent = true;
1102
1460
  if (event.delta?.type === "text_delta" && block.type === "text") {
1103
1461
  block.text += event.delta.text;
1104
1462
  c.currentPiStream!.push({ type: "text_delta", contentIndex: index, delta: event.delta.text, partial: c.turnOutput });
@@ -1120,7 +1478,11 @@ function processStreamEvent(
1120
1478
  if (event?.type === "content_block_stop") {
1121
1479
  const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
1122
1480
  const block = c.turnBlocks[index];
1123
- if (!block) return;
1481
+ if (!block) {
1482
+ debug("processStreamEvent: ignoring unmatched content_block_stop", event.index);
1483
+ return;
1484
+ }
1485
+ c.turnSawStreamEvent = true;
1124
1486
  delete block.index;
1125
1487
  if (block.type === "text") {
1126
1488
  c.currentPiStream!.push({ type: "text_end", contentIndex: index, content: block.text, partial: c.turnOutput });
@@ -1131,6 +1493,7 @@ function processStreamEvent(
1131
1493
  block.arguments = mapToolArgs(
1132
1494
  block.name, parsePartialJson(block.partialJson, block.arguments),
1133
1495
  );
1496
+ c.updateToolCallArgs(block.id, block.arguments);
1134
1497
  delete block.partialJson;
1135
1498
  c.currentPiStream!.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: c.turnOutput });
1136
1499
  }
@@ -1169,13 +1532,73 @@ function processStreamEvent(
1169
1532
  // arrives before any stream_events, this is the primary content path. Must maintain
1170
1533
  // the same stream lifecycle as processStreamEvent — including ending the stream on
1171
1534
  // tool_use to prevent deadlock with the MCP handler.
1172
- function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>): void {
1535
+ function appendMissingToolUsesFromAssistant(
1536
+ assistantMsg: { content?: Array<any>; usage?: Record<string, number | undefined> },
1537
+ model: Model<any>,
1538
+ customToolNameToPi: Map<string, string>,
1539
+ ): boolean {
1540
+ const c = ctx();
1541
+ if (!assistantMsg?.content) return false;
1542
+ let sawToolUse = false;
1543
+ for (const block of assistantMsg.content) {
1544
+ if (block.type !== "tool_use") continue;
1545
+ sawToolUse = true;
1546
+ const existingIdx = c.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === block.id);
1547
+ const name = mapToolName(block.name, customToolNameToPi);
1548
+ const mappedArgs = mapToolArgs(name, block.input);
1549
+ c.recordToolCall(block.id, name, mappedArgs);
1550
+ if (existingIdx >= 0) {
1551
+ const existing = c.turnBlocks[existingIdx] as any;
1552
+ existing.name = name;
1553
+ existing.arguments = mappedArgs;
1554
+ c.updateToolCallArgs(block.id, mappedArgs);
1555
+ if ("partialJson" in existing) {
1556
+ delete existing.partialJson;
1557
+ delete existing.index;
1558
+ c.currentPiStream?.push({ type: "toolcall_end", contentIndex: existingIdx, toolCall: existing, partial: c.turnOutput });
1559
+ }
1560
+ continue;
1561
+ }
1562
+
1563
+ ensureTurnStarted();
1564
+ c.turnBlocks.push({
1565
+ type: "toolCall", id: block.id,
1566
+ name,
1567
+ arguments: mappedArgs,
1568
+ });
1569
+ const idx = c.turnBlocks.length - 1;
1570
+ const toolBlock = c.turnBlocks[idx];
1571
+ c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
1572
+ c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
1573
+ }
1574
+ if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
1575
+ return sawToolUse;
1576
+ }
1577
+
1578
+ export function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>): void {
1173
1579
  const c = ctx();
1174
- if (c.turnSawStreamEvent) return;
1175
1580
  const assistantMsg = (message as any).message;
1176
1581
  if (!assistantMsg?.content) return;
1177
- c.turnToolCallIds = [];
1178
- c.nextHandlerIdx = 0;
1582
+ if (c.turnSawStreamEvent) {
1583
+ // Claude Agent SDK can yield the completed assistant message before (or
1584
+ // instead of) a stream_event message_stop for a tool-use turn. Treat that
1585
+ // assistant message as a hard turn boundary so Pi executes the tool calls
1586
+ // and the MCP handlers stay blocked until real tool results are delivered.
1587
+ // Without this fallback, Claude Code can continue internally with empty MCP
1588
+ // results and Pi only sees the real outputs one render cycle later.
1589
+ if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi)) {
1590
+ c.turnSawToolCall = true;
1591
+ if (c.currentPiStream && c.turnOutput) {
1592
+ c.turnOutput.stopReason = "toolUse";
1593
+ c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
1594
+ c.currentPiStream.end();
1595
+ c.currentPiStream = null;
1596
+ debug("processAssistantMessage boundary: ended streamed tool_use turn from assistant message");
1597
+ }
1598
+ }
1599
+ return;
1600
+ }
1601
+ c.resetToolTracking();
1179
1602
  debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}`);
1180
1603
  for (const block of assistantMsg.content) {
1181
1604
  if (block.type === "text" && block.text) {
@@ -1195,11 +1618,12 @@ function processAssistantMessage(message: SDKMessage, model: Model<any>, customT
1195
1618
  } else if (block.type === "tool_use") {
1196
1619
  ensureTurnStarted();
1197
1620
  c.turnSawToolCall = true;
1198
- c.turnToolCallIds.push(block.id);
1199
- const mappedArgs = mapToolArgs(mapToolName(block.name, customToolNameToPi), block.input);
1621
+ const mappedName = mapToolName(block.name, customToolNameToPi);
1622
+ const mappedArgs = mapToolArgs(mappedName, block.input);
1623
+ c.recordToolCall(block.id, mappedName, mappedArgs);
1200
1624
  c.turnBlocks.push({
1201
1625
  type: "toolCall", id: block.id,
1202
- name: mapToolName(block.name, customToolNameToPi),
1626
+ name: mappedName,
1203
1627
  arguments: mappedArgs,
1204
1628
  });
1205
1629
  const idx = c.turnBlocks.length - 1;
@@ -1238,7 +1662,10 @@ async function consumeQuery(
1238
1662
 
1239
1663
  for await (const message of sdkQuery) {
1240
1664
  if (wasAborted()) break;
1241
- if (!ctx().currentPiStream || !ctx().turnOutput) continue;
1665
+ const queryCtx = ctx();
1666
+ activeStreamIdleWatchdogs.get(queryCtx)?.noteChunk();
1667
+ if (!queryCtx.turnOutput) continue;
1668
+ if (!queryCtx.currentPiStream && !(message.type === "assistant" && queryCtx.turnSawToolCall)) continue;
1242
1669
 
1243
1670
  switch (message.type) {
1244
1671
  case "stream_event":
@@ -1257,8 +1684,10 @@ async function consumeQuery(
1257
1684
  ctx().currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: text, partial: ctx().turnOutput });
1258
1685
  ctx().currentPiStream?.push({ type: "text_end", contentIndex: idx, content: text, partial: ctx().turnOutput });
1259
1686
  } else if (message.subtype !== "success" && isExtraUsageRequiredMessage(message)) {
1260
- const errors = Array.isArray((message as any).errors) ? (message as any).errors.join("\n") : message.subtype;
1687
+ const errorLines = Array.isArray((message as any).errors) ? uniqueNonEmptyLines((message as any).errors) : [];
1688
+ const errors = errorLines.length > 0 ? errorLines.join("\n") : String(message.subtype ?? "Claude Code rate limit");
1261
1689
  const openedExtraUsage = launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "result error");
1690
+ ctx().handledTerminalError = true;
1262
1691
  ctx().turnOutput.stopReason = "error";
1263
1692
  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."}`;
1264
1693
  ctx().currentPiStream?.push({ type: "error", reason: "error", error: ctx().turnOutput });
@@ -1277,12 +1706,25 @@ async function consumeQuery(
1277
1706
  const info = (message as any).rate_limit_info;
1278
1707
  debug("consumeQuery: rate_limit_event", JSON.stringify(info).slice(0, 300));
1279
1708
  if (info?.status === "rejected") {
1280
- const resetsAt = info.resetsAt ? new Date(info.resetsAt).toLocaleTimeString() : "unknown";
1709
+ const resetsAt = formatResetTimestamp(info.resetsAt);
1710
+ const resetAtMs = typeof info.resetsAt === "string" ? Date.parse(info.resetsAt) : undefined;
1281
1711
  const reason = `${info.rateLimitType ?? "unknown"} rate limit`;
1282
1712
  const launchedExtraUsage = isExtraUsageRequiredMessage(info) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, reason);
1283
- piUI?.notify(`Claude rate limited (${reason}) — resets at ${resetsAt}${launchedExtraUsage ? "; opened /extra-usage helper" : ""}`, "warning");
1713
+ emitRateLimitEvent({
1714
+ model: model.id,
1715
+ provider: PROVIDER_ID,
1716
+ rateLimitType: info.rateLimitType,
1717
+ reason,
1718
+ resetAt: info.resetsAt,
1719
+ ...(Number.isFinite(resetAtMs) ? { resetAtMs } : {}),
1720
+ source: "claude-bridge",
1721
+ status: "rejected",
1722
+ });
1723
+ piUI?.notify(`${RATE_LIMIT_TOKEN} Claude ${reason} hit — resets ${resetsAt}${launchedExtraUsage ? "; opened /extra-usage helper" : ""}`, "warning");
1284
1724
  } else if (info?.status === "allowed_warning") {
1285
- piUI?.notify(`Claude rate limit warning: ${Math.round(info.utilization ?? 0)}% used (${info.rateLimitType ?? ""})`, "warning");
1725
+ const warning = formatAllowedRateLimitWarning(info);
1726
+ if (warning) piUI?.notify(warning, "warning");
1727
+ else debug("consumeQuery: suppressed low/ambiguous allowed_warning rate_limit_event", JSON.stringify(info).slice(0, 300));
1286
1728
  }
1287
1729
  break;
1288
1730
  }
@@ -1305,6 +1747,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1305
1747
 
1306
1748
  // DEBUG: trace followUp message triggering
1307
1749
  const lastMsgRole = context.messages[context.messages.length - 1]?.role;
1750
+ const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
1308
1751
  debug(`provider: streamClaudeAgentSdk called, activeQuery=${!!ctx().activeQuery}, lastMsgRole=${lastMsgRole}, isReentrant=${ctx().activeQuery !== null}`);
1309
1752
 
1310
1753
  // --- Tool result delivery ---
@@ -1312,30 +1755,49 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1312
1755
  // (everything after the last assistant message) and match against waiting MCP
1313
1756
  // handlers. Results that arrive before their handler get queued in pendingResults.
1314
1757
  if (ctx().activeQuery) {
1315
- ctx().currentPiStream = stream;
1316
- ctx().resetTurnState(model);
1758
+ const queryCtx = ctx();
1759
+ queryCtx.currentPiStream = stream;
1760
+ queryCtx.resetTurnState(model);
1761
+ activeStreamIdleWatchdogs.get(queryCtx)?.refresh();
1317
1762
  const allResults = extractAllToolResults(context);
1318
- debug(`provider: tool results, ${allResults.length} results, ${ctx().pendingToolCalls.size} waiting handlers, ctx.msgs=${context.messages.length}`);
1763
+ debug(`provider: tool results, ${allResults.length} results, ${queryCtx.pendingToolCalls.size} waiting handlers, ctx.msgs=${context.messages.length}`);
1764
+ const unmatchedResultIds: string[] = [];
1319
1765
  for (const result of allResults) {
1320
1766
  const id = result.toolCallId;
1321
- if (id && ctx().pendingToolCalls.has(id)) {
1322
- const pending = ctx().pendingToolCalls.get(id)!;
1323
- ctx().pendingToolCalls.delete(id);
1767
+ if (id && !queryCtx.hasRecordedToolCall(id)) {
1768
+ queryCtx.markToolResultUnmatched(id);
1769
+ unmatchedResultIds.push(id);
1770
+ debug(`ERROR: tool result [${id}] has no registered tool_call id; refusing to queue or deliver`);
1771
+ continue;
1772
+ }
1773
+ queryCtx.markToolResultDelivered(id);
1774
+ if (id && queryCtx.pendingToolCalls.has(id)) {
1775
+ const pending = queryCtx.pendingToolCalls.get(id)!;
1776
+ queryCtx.pendingToolCalls.delete(id);
1324
1777
  debug(`provider: resolving ${pending.toolName} [${id}]${result.isError ? " (error)" : ""}`, JSON.stringify(result.content).slice(0, 200));
1325
1778
  pending.resolve(result);
1326
1779
  } else if (id) {
1327
- ctx().pendingResults.set(id, result);
1328
- debug(`provider: queued result [${id}] (${ctx().pendingResults.size} pending)`);
1780
+ queryCtx.pendingResults.set(id, result);
1781
+ debug(`provider: queued result [${id}] (${queryCtx.pendingResults.size} pending)`);
1329
1782
  } else {
1330
1783
  debug(`WARNING: tool result without toolCallId, cannot match`);
1331
1784
  }
1332
- if (ctx().pendingToolCalls.size > 0 && ctx().pendingResults.size > 0) {
1333
- debug(`BUG: both maps non-empty! handlers=${ctx().pendingToolCalls.size} results=${ctx().pendingResults.size}`);
1785
+ if (queryCtx.pendingToolCalls.size > 0 && queryCtx.pendingResults.size > 0) {
1786
+ debug(`BUG: both maps non-empty! handlers=${queryCtx.pendingToolCalls.size} results=${queryCtx.pendingResults.size}`);
1334
1787
  }
1335
1788
  }
1336
- if (ctx().pendingToolCalls.size > 0) {
1337
- debug(`WARNING: ${ctx().pendingToolCalls.size} MCP handlers still waiting after delivering ${allResults.length} results`);
1338
- piUI?.notify(`Claude bridge: ${ctx().pendingToolCalls.size} tool handler(s) still waiting provider may be stuck`, "warning");
1789
+ if (unmatchedResultIds.length > 0) {
1790
+ const errorResult: McpResult = {
1791
+ 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 ? ", ..." : ""}` }],
1792
+ isError: true,
1793
+ };
1794
+ for (const pending of queryCtx.pendingToolCalls.values()) pending.resolve(errorResult);
1795
+ queryCtx.pendingToolCalls.clear();
1796
+ reportToolResultMismatch(queryCtx, "unmatched tool result", cwd);
1797
+ }
1798
+ if (queryCtx.pendingToolCalls.size > 0) {
1799
+ debug(`WARNING: ${queryCtx.pendingToolCalls.size} MCP handlers still waiting after delivering ${allResults.length} results`);
1800
+ piUI?.notify(`Claude bridge: ${queryCtx.pendingToolCalls.size} tool handler(s) still waiting — provider may be stuck`, "warning");
1339
1801
  }
1340
1802
 
1341
1803
  // Detect user messages (steer/followUp) that pi injected into context
@@ -1355,7 +1817,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1355
1817
  }
1356
1818
 
1357
1819
  if (sharedSession) sharedSession.cursor = context.messages.length;
1358
- ctx().latestCursor = Math.max(ctx().latestCursor, context.messages.length);
1820
+ queryCtx.latestCursor = Math.max(queryCtx.latestCursor, context.messages.length);
1359
1821
  return stream;
1360
1822
  }
1361
1823
 
@@ -1389,10 +1851,10 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1389
1851
  ctx().pendingResults.clear();
1390
1852
  ctx().deferredUserMessages = [];
1391
1853
  ctx().resetTurnState(model);
1854
+ ctx().resetToolTracking();
1392
1855
  ctx().latestCursor = 0;
1393
1856
 
1394
1857
  const { mcpTools, customToolNameToSdk, customToolNameToPi } = resolveMcpTools(context);
1395
- const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
1396
1858
  const promptBlocks = extractUserPromptBlocks(context.messages);
1397
1859
  let promptText = extractUserPrompt(context.messages) ?? "";
1398
1860
 
@@ -1440,10 +1902,11 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1440
1902
  // Prefer the model's own thinkingLevelMap when present (pi-ai 0.72+ ships
1441
1903
  // per-model overrides — e.g. opus-4-7 wants xhigh→xhigh, not xhigh→max).
1442
1904
  // Fall back to our generic table for older pi-ai or unmapped levels.
1443
- const effort = options?.reasoning
1905
+ const requestedEffort = options?.reasoning
1444
1906
  ? ((model as any).thinkingLevelMap?.[options.reasoning] as EffortLevel | undefined)
1445
1907
  ?? REASONING_TO_EFFORT[options.reasoning]
1446
1908
  : undefined;
1909
+ const effort = resolveConfiguredEffort(model.id, requestedEffort, providerSettings);
1447
1910
 
1448
1911
  const extraArgs: Record<string, string | null> = { model: model.id };
1449
1912
  if (strictMcpConfigEnabled) extraArgs["strict-mcp-config"] = null;
@@ -1468,6 +1931,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1468
1931
  ...CLAUDE_BRIDGE_TOOL_ISOLATION,
1469
1932
  permissionMode: "bypassPermissions",
1470
1933
  includePartialMessages: true,
1934
+ ...(providerSettings.fastMode ? { settings: { fastMode: true } } : {}),
1471
1935
  systemPrompt: {
1472
1936
  type: "preset", preset: "claude_code",
1473
1937
  append: systemPromptAppend ? systemPromptAppend : undefined,
@@ -1485,12 +1949,13 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1485
1949
  debug("provider: fresh query",
1486
1950
  `model=${model.id} msgs=${context.messages.length} tools=${mcpTools.length}`,
1487
1951
  `resume=${resumeSessionId?.slice(0, 8) ?? "none"} effort=${effort ?? "default"}`,
1488
- `appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled}`,
1952
+ `appendSys=${appendSystemPrompt} promptCtx=${promptContextAppend.labels.join(",") || "none"} strictMcp=${strictMcpConfigEnabled} fastMode=${providerSettings.fastMode === true}`,
1489
1953
  `claudeExec=${claudeExecutablePreflight ? `${claudeExecutablePreflight.fileType}:${claudeExecutablePreflight.path}` : "sdk-default"}`,
1490
1954
  `prompt=${promptText.slice(0, 60)}${promptBlocks ? " [+images]" : ""}`);
1491
1955
 
1492
1956
  // 3. Start SDK query and claim it for this context
1493
1957
  let wasAborted = false;
1958
+ let streamIdleTimedOut = false;
1494
1959
  const sdkQuery = query({ prompt, options: queryOptions });
1495
1960
  ctx().activeQuery = sdkQuery;
1496
1961
 
@@ -1503,10 +1968,62 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1503
1968
  void sdkQuery.interrupt().catch(() => {});
1504
1969
  try { sdkQuery.close(); } catch {}
1505
1970
  };
1971
+ const streamIdleTimeoutMs = streamIdleTimeoutMsFromEnv();
1972
+ const streamIdleWatchdog = streamIdleTimeoutMs > 0
1973
+ ? createStreamIdleWatchdog({
1974
+ getState: () => ({
1975
+ activeQuery: abortCtx.activeQuery,
1976
+ currentPiStream: abortCtx.currentPiStream,
1977
+ turnOutput: abortCtx.turnOutput,
1978
+ turnSawStreamEvent: abortCtx.turnSawStreamEvent,
1979
+ turnStarted: abortCtx.turnStarted,
1980
+ }),
1981
+ onTimeout: ({ idleMs, timeoutMs }) => {
1982
+ if (streamIdleTimedOut || wasAborted || options?.signal?.aborted || abortCtx.activeQuery !== sdkQuery) return;
1983
+ streamIdleTimedOut = true;
1984
+ abortCtx.deferredUserMessages = [];
1985
+ abortCtx.handledTerminalError = true;
1986
+ if (sharedSession) sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
1987
+ const errorMessage = buildStreamIdleTimeoutErrorMessage(timeoutMs);
1988
+ debug("provider: stream idle timeout", `model=${model.id}`, `timeout=${timeoutMs}`, `idle=${idleMs}`);
1989
+ emitRateLimitEvent({
1990
+ idleMs,
1991
+ model: model.id,
1992
+ provider: PROVIDER_ID,
1993
+ rateLimitType: "stream_idle",
1994
+ reason: "Claude Code stream idle timeout",
1995
+ retryAfterMs: STREAM_IDLE_BACKOFF_HINT_MS,
1996
+ source: "claude-bridge",
1997
+ status: "rejected",
1998
+ timeoutMs,
1999
+ });
2000
+ piUI?.notify(`${RATE_LIMIT_TOKEN} Claude stream idle timeout after ${formatDurationShort(timeoutMs)} — retrying via rate-limit backoff`, "warning");
2001
+ if (abortCtx.turnOutput) {
2002
+ abortCtx.turnOutput.stopReason = "error";
2003
+ abortCtx.turnOutput.errorMessage = errorMessage;
2004
+ Object.assign(abortCtx.turnOutput as AssistantMessage & Record<string, unknown>, {
2005
+ rateLimitType: "stream_idle",
2006
+ retryAfterMs: STREAM_IDLE_BACKOFF_HINT_MS,
2007
+ streamIdleTimeoutMs: timeoutMs,
2008
+ });
2009
+ }
2010
+ abortCtx.currentPiStream?.push({ type: "error", reason: "error", error: abortCtx.turnOutput! });
2011
+ abortCtx.currentPiStream?.end();
2012
+ abortCtx.currentPiStream = null;
2013
+ requestAbort();
2014
+ },
2015
+ timeoutMs: streamIdleTimeoutMs,
2016
+ })
2017
+ : null;
2018
+ if (streamIdleWatchdog) {
2019
+ activeStreamIdleWatchdogs.set(abortCtx, streamIdleWatchdog);
2020
+ streamIdleWatchdog.refresh();
2021
+ }
1506
2022
  const onAbort = () => {
1507
2023
  wasAborted = true;
1508
2024
  // Prevent stale deferred messages from being replayed by parent on pop
1509
2025
  abortCtx.deferredUserMessages = [];
2026
+ reportToolResultMismatch(abortCtx, "abort", cwd, { forceRotate: true });
1510
2027
  for (const pending of abortCtx.pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Operation aborted" }] }); }
1511
2028
  abortCtx.pendingToolCalls.clear();
1512
2029
  abortCtx.pendingResults.clear();
@@ -1521,6 +2038,11 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1521
2038
  consumeQuery(sdkQuery, customToolNameToPi, model, cwd, bridgeConfig, () => wasAborted)
1522
2039
  .then(async ({ capturedSessionId }) => {
1523
2040
  debug(`provider: consumeQuery completed, stopReason=${ctx().turnOutput?.stopReason}, error=${ctx().turnOutput?.errorMessage}, aborted=${wasAborted}`);
2041
+ if (streamIdleTimedOut) {
2042
+ abortCtx.deferredUserMessages = [];
2043
+ debug("provider: stream idle timeout already surfaced; skipping normal completion");
2044
+ return;
2045
+ }
1524
2046
 
1525
2047
  // --- Abort detection in normal completion path ---
1526
2048
  if (wasAborted || options?.signal?.aborted) {
@@ -1553,6 +2075,7 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1553
2075
  const steerPrompt = ctx().deferredUserMessages.shift()!;
1554
2076
  debug(`provider: replaying deferred user message: ${steerPrompt.slice(0, 60)}`);
1555
2077
  ctx().resetTurnState(model);
2078
+ ctx().resetToolTracking();
1556
2079
 
1557
2080
  const resumeId = sharedSession?.sessionId;
1558
2081
  if (!resumeId) {
@@ -1588,13 +2111,18 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1588
2111
  })
1589
2112
  .catch((error) => {
1590
2113
  debug(`provider: query error, model=${model.id}, aborted=${Boolean(options?.signal?.aborted)}, error=`, error);
1591
- const openedExtraUsage = isExtraUsageRequiredMessage(error) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "query error");
2114
+ const suppressDuplicateError = ctx().handledTerminalError || streamIdleTimedOut;
2115
+ const openedExtraUsage = !suppressDuplicateError && isExtraUsageRequiredMessage(error) && launchExtraUsageHelperIfAllowed(cwd, bridgeConfig, "query error");
1592
2116
  if ((wasAborted || options?.signal?.aborted) && sharedSession) {
1593
2117
  sharedSession = { ...sharedSession, needsRebuild: true, forceRotate: true };
1594
2118
  } else {
1595
2119
  sharedSession = null;
1596
2120
  }
1597
2121
  ctx().deferredUserMessages = [];
2122
+ if (suppressDuplicateError) {
2123
+ debug("provider: suppressing duplicate query error after terminal error was already emitted");
2124
+ return;
2125
+ }
1598
2126
  if (ctx().turnOutput) {
1599
2127
  ctx().turnOutput.stopReason = options?.signal?.aborted ? "aborted" : "error";
1600
2128
  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." : ""}`;
@@ -1604,8 +2132,11 @@ function streamClaudeAgentSdk(model: Model<any>, context: Context, options?: Sim
1604
2132
  ctx().currentPiStream = null;
1605
2133
  })
1606
2134
  .finally(() => {
2135
+ streamIdleWatchdog?.dispose();
2136
+ activeStreamIdleWatchdogs.delete(abortCtx);
1607
2137
  if (options?.signal) options.signal.removeEventListener("abort", onAbort);
1608
2138
  if (ctx().activeQuery === sdkQuery) {
2139
+ reportToolResultMismatch(ctx(), "query teardown", cwd, { forceRotate: wasAborted || options?.signal?.aborted || streamIdleTimedOut });
1609
2140
  // Drain pending handlers for this query
1610
2141
  for (const pending of ctx().pendingToolCalls.values()) { pending.resolve({ content: [{ type: "text", text: "Query ended" }] }); }
1611
2142
  ctx().pendingToolCalls.clear();
@@ -1741,6 +2272,9 @@ export default function (pi: ExtensionAPI) {
1741
2272
  // triggers CC's autocompact-thrashing guard (issue #8). Force the next
1742
2273
  // call down the REBUILD path so CC sees the current history.
1743
2274
  const markRebuild = (event: string) => {
2275
+ if (ctx().activeQuery) {
2276
+ reportToolResultMismatch(ctx(), event, sharedSession?.cwd ?? process.cwd());
2277
+ }
1744
2278
  if (sharedSession) {
1745
2279
  debug(`${event}: marking needsRebuild on session ${sharedSession.sessionId.slice(0, 8)}`);
1746
2280
  sharedSession = { ...sharedSession, needsRebuild: true };