opencode-anthropic-fix 0.0.42 → 0.0.44

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/index.mjs CHANGED
@@ -145,19 +145,22 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
145
145
  /** @type {Set<string>} */
146
146
  const idleRefreshInFlight = new Set();
147
147
 
148
- const IDLE_REFRESH_ENABLED = config.idle_refresh.enabled;
149
- const IDLE_REFRESH_WINDOW_MS = config.idle_refresh.window_minutes * 60 * 1000;
150
- const IDLE_REFRESH_MIN_INTERVAL_MS = config.idle_refresh.min_interval_minutes * 60 * 1000;
148
+ // QA fix H3: use getter functions so /anthropic set toggles take effect at runtime
149
+ // (same pattern as getSignatureEmulationEnabled at line 113).
150
+ const getIdleRefreshEnabled = () => config.idle_refresh.enabled;
151
+ const getIdleRefreshWindowMs = () => config.idle_refresh.window_minutes * 60 * 1000;
152
+ const getIdleRefreshMinIntervalMs = () => config.idle_refresh.min_interval_minutes * 60 * 1000;
151
153
 
152
154
  // Willow Mode: detect inactivity and suggest context reset.
153
155
  // Named after the willow tree — when idle, the session "droops" and a gentle
154
156
  // nudge suggests starting fresh rather than accumulating stale context.
155
- const WILLOW_ENABLED = config.willow_mode?.enabled ?? true;
156
- const WILLOW_IDLE_THRESHOLD_MS = (config.willow_mode?.idle_threshold_minutes ?? 30) * 60 * 1000;
157
- const WILLOW_COOLDOWN_MS = (config.willow_mode?.cooldown_minutes ?? 60) * 60 * 1000;
158
- const WILLOW_MIN_TURNS = config.willow_mode?.min_turns_before_suggest ?? 3;
157
+ const getWillowEnabled = () => config.willow_mode?.enabled ?? true;
158
+ const getWillowIdleThresholdMs = () => (config.willow_mode?.idle_threshold_minutes ?? 30) * 60 * 1000;
159
+ const getWillowCooldownMs = () => (config.willow_mode?.cooldown_minutes ?? 60) * 60 * 1000;
160
+ const getWillowMinTurns = () => config.willow_mode?.min_turns_before_suggest ?? 3;
159
161
  let willowLastRequestTime = Date.now();
160
162
  let willowLastSuggestionTime = 0;
163
+ let _lastOAuthPruneTime = 0; // QA fix L-oauthPrune: throttle for periodic prune
161
164
 
162
165
  // Beta header latching: once a beta is sent in a session, it stays on for
163
166
  // the rest of the session to prevent cache key churn (~50-70K tokens per flip).
@@ -645,8 +648,14 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
645
648
  requests: 0,
646
649
  inputTokens: 0,
647
650
  updatedAt: 0,
648
- fiveHour: { utilization: 0, resets_at: null },
649
- sevenDay: { utilization: 0, resets_at: null },
651
+ fiveHour: { utilization: 0, resets_at: null, status: null, surpassedThreshold: null },
652
+ sevenDay: { utilization: 0, resets_at: null, status: null, surpassedThreshold: null },
653
+ overallStatus: null,
654
+ representativeClaim: null,
655
+ fallback: null,
656
+ fallbackPercentage: null,
657
+ overageStatus: null,
658
+ overageReason: null,
650
659
  lastPollAt: 0,
651
660
  };
652
661
  sessionMetrics.lastStopReason = null;
@@ -740,22 +749,25 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
740
749
  // Quota info (if available from rate-limit headers)
741
750
  if (sessionMetrics.lastQuota.updatedAt > 0) {
742
751
  const q = sessionMetrics.lastQuota;
743
- const quotaParts = [];
744
- // QA fix L8: show 0% utilization too — valid data point meaning plenty of capacity
745
- quotaParts.push(`tokens: ${(q.tokens * 100).toFixed(0)}%`);
746
- quotaParts.push(`requests: ${(q.requests * 100).toFixed(0)}%`);
747
- quotaParts.push(`input-tokens: ${(q.inputTokens * 100).toFixed(0)}%`);
748
- lines.push("", `Rate limit utilization: ${quotaParts.join(", ")}`);
749
- }
750
-
751
- // Usage endpoint data (A6)
752
- if (sessionMetrics.lastQuota.lastPollAt > 0) {
753
- const q5h = sessionMetrics.lastQuota.fiveHour;
754
- const q7d = sessionMetrics.lastQuota.sevenDay;
755
- const pollAge = Math.round((Date.now() - sessionMetrics.lastQuota.lastPollAt) / 60_000);
756
- lines.push("", `Usage endpoint (${pollAge}m ago):`);
757
- lines.push(` 5-hour: ${q5h.utilization.toFixed(0)}% used${q5h.resets_at ? ` (resets ${q5h.resets_at})` : ""}`);
758
- lines.push(` 7-day: ${q7d.utilization.toFixed(0)}% used${q7d.resets_at ? ` (resets ${q7d.resets_at})` : ""}`);
752
+ const q5h = q.fiveHour;
753
+ const q7d = q.sevenDay;
754
+ lines.push("", `Rate limit utilization:`);
755
+ lines.push(
756
+ ` 5-hour: ${q5h.utilization.toFixed(0)}% used${q5h.status ? ` [${q5h.status}]` : ""}${q5h.resets_at ? ` (resets ${q5h.resets_at})` : ""}`,
757
+ );
758
+ lines.push(
759
+ ` 7-day: ${q7d.utilization.toFixed(0)}% used${q7d.status ? ` [${q7d.status}]` : ""}${q7d.resets_at ? ` (resets ${q7d.resets_at})` : ""}`,
760
+ );
761
+ if (q.overallStatus)
762
+ lines.push(
763
+ ` Status: ${q.overallStatus}${q.representativeClaim ? ` (claim: ${q.representativeClaim})` : ""}`,
764
+ );
765
+ if (q.fallback)
766
+ lines.push(
767
+ ` Fallback: ${q.fallback}${q.fallbackPercentage != null ? ` (${(q.fallbackPercentage * 100).toFixed(0)}%)` : ""}`,
768
+ );
769
+ if (q.overageStatus)
770
+ lines.push(` Overage: ${q.overageStatus}${q.overageReason ? ` (${q.overageReason})` : ""}`);
759
771
  }
760
772
 
761
773
  // Token budget display (A9)
@@ -789,16 +801,39 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
789
801
  const filled = Math.max(0, Math.min(20, Math.round(pct * 20)));
790
802
  return "[" + "█".repeat(filled) + "░".repeat(20 - filled) + "]";
791
803
  };
804
+ const q5h = q.fiveHour;
805
+ const q7d = q.sevenDay;
792
806
  const lines = [
793
807
  "▣ Anthropic Rate Limit Quota",
794
808
  "",
795
- `Tokens: ${bar(q.tokens)} ${(q.tokens * 100).toFixed(0)}%`,
796
- `Requests: ${bar(q.requests)} ${(q.requests * 100).toFixed(0)}%`,
797
- `Input tokens: ${bar(q.inputTokens)} ${(q.inputTokens * 100).toFixed(0)}%`,
809
+ `5-hour window:`,
810
+ ` ${bar(q5h.utilization / 100)} ${q5h.utilization.toFixed(0)}%${q5h.status ? ` [${q5h.status}]` : ""}`,
811
+ q5h.resets_at ? ` Resets: ${q5h.resets_at}` : null,
812
+ q5h.surpassedThreshold != null ? ` Surpassed threshold: ${(q5h.surpassedThreshold * 100).toFixed(0)}%` : null,
798
813
  "",
799
- `Last updated: ${agoStr}`,
800
- ];
801
- const maxUtil = Math.max(q.tokens, q.requests, q.inputTokens);
814
+ `7-day window:`,
815
+ ` ${bar(q7d.utilization / 100)} ${q7d.utilization.toFixed(0)}%${q7d.status ? ` [${q7d.status}]` : ""}`,
816
+ q7d.resets_at ? ` Resets: ${q7d.resets_at}` : null,
817
+ q7d.surpassedThreshold != null ? ` Surpassed threshold: ${(q7d.surpassedThreshold * 100).toFixed(0)}%` : null,
818
+ "",
819
+ ].filter(Boolean);
820
+
821
+ if (q.overallStatus) {
822
+ lines.push(
823
+ `Overall status: ${q.overallStatus}${q.representativeClaim ? ` (claim: ${q.representativeClaim})` : ""}`,
824
+ );
825
+ }
826
+ if (q.fallback) {
827
+ lines.push(
828
+ `Fallback: ${q.fallback}${q.fallbackPercentage != null ? ` (${(q.fallbackPercentage * 100).toFixed(0)}% capacity)` : ""}`,
829
+ );
830
+ }
831
+ if (q.overageStatus) {
832
+ lines.push(`Overage: ${q.overageStatus}${q.overageReason ? ` (${q.overageReason})` : ""}`);
833
+ }
834
+ lines.push("", `Last updated: ${agoStr}`);
835
+
836
+ const maxUtil = Math.max(q5h.utilization, q7d.utilization) / 100;
802
837
  if (maxUtil >= 0.9) {
803
838
  lines.push("", "⚠ High utilization — consider slowing request rate or rotating accounts");
804
839
  } else if (maxUtil >= 0.7) {
@@ -902,16 +937,38 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
902
937
  const value = (args[2] || "").toLowerCase();
903
938
  /** @type {Record<string, () => void>} */
904
939
  const setters = {
905
- emulation: () =>
906
- saveConfig({ signature_emulation: { enabled: value === "on" || value === "1" || value === "true" } }),
907
- compaction: () =>
908
- saveConfig({ signature_emulation: { prompt_compaction: value === "off" ? "off" : "minimal" } }),
909
- "1m-context": () =>
910
- saveConfig({ override_model_limits: { enabled: value === "on" || value === "1" || value === "true" } }),
911
- "idle-refresh": () =>
912
- saveConfig({ idle_refresh: { enabled: value === "on" || value === "1" || value === "true" } }),
913
- debug: () => saveConfig({ debug: value === "on" || value === "1" || value === "true" }),
914
- quiet: () => saveConfig({ toasts: { quiet: value === "on" || value === "1" || value === "true" } }),
940
+ emulation: () => {
941
+ const enabled = value === "on" || value === "1" || value === "true";
942
+ saveConfig({ signature_emulation: { enabled } });
943
+ config.signature_emulation.enabled = enabled;
944
+ },
945
+ compaction: () => {
946
+ const mode = value === "off" ? "off" : "minimal";
947
+ saveConfig({ signature_emulation: { prompt_compaction: mode } });
948
+ config.signature_emulation.prompt_compaction = mode;
949
+ },
950
+ "1m-context": () => {
951
+ const enabled = value === "on" || value === "1" || value === "true";
952
+ saveConfig({ override_model_limits: { enabled } });
953
+ if (!config.override_model_limits) config.override_model_limits = { enabled: false };
954
+ config.override_model_limits.enabled = enabled;
955
+ },
956
+ "idle-refresh": () => {
957
+ const enabled = value === "on" || value === "1" || value === "true";
958
+ saveConfig({ idle_refresh: { enabled } });
959
+ if (!config.idle_refresh) config.idle_refresh = { enabled: false };
960
+ config.idle_refresh.enabled = enabled;
961
+ },
962
+ debug: () => {
963
+ const enabled = value === "on" || value === "1" || value === "true";
964
+ saveConfig({ debug: enabled });
965
+ config.debug = enabled;
966
+ },
967
+ quiet: () => {
968
+ const enabled = value === "on" || value === "1" || value === "true";
969
+ saveConfig({ toasts: { quiet: enabled } });
970
+ config.toasts.quiet = enabled;
971
+ },
915
972
  strategy: () => {
916
973
  const valid = ["sticky", "round-robin", "hybrid"];
917
974
  if (valid.includes(value)) {
@@ -920,12 +977,18 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
920
977
  strategyState.mode = "CONFIGURED";
921
978
  } else throw new Error(`Invalid strategy. Valid: ${valid.join(", ")}`);
922
979
  },
923
- boundary: () =>
924
- saveConfig({ cache_policy: { boundary_marker: value === "on" || value === "1" || value === "true" } }),
980
+ boundary: () => {
981
+ const enabled = value === "on" || value === "1" || value === "true";
982
+ saveConfig({ cache_policy: { boundary_marker: enabled } });
983
+ if (!config.cache_policy) config.cache_policy = {};
984
+ config.cache_policy.boundary_marker = enabled;
985
+ },
925
986
  "cache-ttl": () => {
926
987
  const valid = ["1h", "5m", "off"];
927
- if (valid.includes(value)) saveConfig({ cache_policy: { ttl: value } });
928
- else throw new Error(`Invalid TTL. Valid: ${valid.join(", ")}`);
988
+ if (!valid.includes(value)) throw new Error(`Invalid TTL. Valid: ${valid.join(", ")}`);
989
+ saveConfig({ cache_policy: { ttl: value } });
990
+ if (!config.cache_policy) config.cache_policy = {};
991
+ config.cache_policy.ttl = value;
929
992
  },
930
993
  fast: () => {
931
994
  const enabled = value === "on" || value === "true" || value === "1";
@@ -1876,8 +1939,10 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
1876
1939
  }
1877
1940
  debouncedToastTimestamps.set(options.debounceKey, now);
1878
1941
  // QA fix M2: prune stale entries to prevent unbounded growth
1942
+ // QA fix L-debounce: use fixed 5-minute cutoff instead of config-dependent minGapMs*2
1943
+ // to avoid entries surviving longer than intended if debounce_seconds changes at runtime
1879
1944
  if (debouncedToastTimestamps.size > 200) {
1880
- const cutoff = now - minGapMs * 2;
1945
+ const cutoff = now - 300_000; // 5 minutes — generous for any realistic debounce window
1881
1946
  for (const [k, ts] of debouncedToastTimestamps) {
1882
1947
  if (ts < cutoff) debouncedToastTimestamps.delete(k);
1883
1948
  }
@@ -2133,16 +2198,16 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2133
2198
  * @param {import('./lib/accounts.mjs').ManagedAccount} activeAccount
2134
2199
  */
2135
2200
  function maybeRefreshIdleAccounts(activeAccount) {
2136
- if (!IDLE_REFRESH_ENABLED || !accountManager) return;
2201
+ if (!getIdleRefreshEnabled() || !accountManager) return;
2137
2202
 
2138
2203
  const now = Date.now();
2139
2204
  const excluded = new Set([activeAccount.index]);
2140
2205
  const candidates = accountManager
2141
2206
  .getEnabledAccounts(excluded)
2142
- .filter((acc) => !acc.expires || acc.expires <= now + IDLE_REFRESH_WINDOW_MS)
2207
+ .filter((acc) => !acc.expires || acc.expires <= now + getIdleRefreshWindowMs())
2143
2208
  .filter((acc) => {
2144
2209
  const last = idleRefreshLastAttempt.get(acc.id) ?? 0;
2145
- return now - last >= IDLE_REFRESH_MIN_INTERVAL_MS;
2210
+ return now - last >= getIdleRefreshMinIntervalMs();
2146
2211
  })
2147
2212
  .sort((a, b) => (a.expires ?? 0) - (b.expires ?? 0));
2148
2213
 
@@ -2324,14 +2389,24 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2324
2389
  await accountManager.syncActiveIndexFromDisk();
2325
2390
  }
2326
2391
 
2392
+ // QA fix L-oauthPrune: periodically prune expired pending OAuth flows on API requests
2393
+ // (throttled to at most once per 60 seconds) to avoid PKCE verifiers living in memory indefinitely.
2394
+ {
2395
+ const _now = Date.now();
2396
+ if (_now - _lastOAuthPruneTime > 60_000) {
2397
+ _lastOAuthPruneTime = _now;
2398
+ pruneExpiredPendingOAuth();
2399
+ }
2400
+ }
2401
+
2327
2402
  // Willow Mode: if the session has been idle for longer than the
2328
2403
  // configured threshold and has enough turns, show a gentle toast
2329
2404
  // suggesting the user consider starting a fresh context.
2330
- if (WILLOW_ENABLED && showUsageToast) {
2405
+ if (getWillowEnabled() && showUsageToast) {
2331
2406
  const now = Date.now();
2332
2407
  const idleMs = now - willowLastRequestTime;
2333
- const cooldownOk = now - willowLastSuggestionTime >= WILLOW_COOLDOWN_MS;
2334
- if (idleMs >= WILLOW_IDLE_THRESHOLD_MS && cooldownOk && sessionMetrics.turns >= WILLOW_MIN_TURNS) {
2408
+ const cooldownOk = now - willowLastSuggestionTime >= getWillowCooldownMs();
2409
+ if (idleMs >= getWillowIdleThresholdMs() && cooldownOk && sessionMetrics.turns >= getWillowMinTurns()) {
2335
2410
  const idleMin = Math.round(idleMs / 60_000);
2336
2411
  willowLastSuggestionTime = now;
2337
2412
  toast(
@@ -2354,28 +2429,35 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2354
2429
  // pin the first attempt to that account so files are accessible.
2355
2430
  // Without this, round-robin could route to an account that doesn't
2356
2431
  // have the referenced files, causing file_not_found errors.
2357
- let pinnedAccount = null;
2358
- if (typeof requestInit.body === "string" && fileAccountMap.size > 0) {
2432
+ // Parse body ONCE before the retry loop and reuse for all downstream logic.
2433
+ // This eliminates 4-5 redundant JSON.parse calls per turn (file pinning,
2434
+ // parseRequestBodyMetadata, adaptive context, toast, microcompact).
2435
+ let _parsedBodyOnce = null;
2436
+ if (typeof requestInit.body === "string") {
2359
2437
  try {
2360
- const bodyObj = JSON.parse(requestInit.body);
2361
- const fileIds = extractFileIds(bodyObj);
2362
- for (const fid of fileIds) {
2363
- const pinnedIndex = fileAccountMap.get(fid);
2364
- if (pinnedIndex !== undefined) {
2365
- const candidates = accountManager.getEnabledAccounts();
2366
- pinnedAccount = candidates.find((a) => a.index === pinnedIndex) ?? null;
2367
- if (pinnedAccount) {
2368
- debugLog("file-id pinning: routing to account", {
2369
- fileId: fid,
2370
- accountIndex: pinnedIndex,
2371
- email: pinnedAccount.email,
2372
- });
2373
- break;
2374
- }
2438
+ _parsedBodyOnce = JSON.parse(requestInit.body);
2439
+ } catch {
2440
+ // Non-JSON body — downstream code handles gracefully
2441
+ }
2442
+ }
2443
+
2444
+ let pinnedAccount = null;
2445
+ if (_parsedBodyOnce && fileAccountMap.size > 0) {
2446
+ const fileIds = extractFileIds(_parsedBodyOnce);
2447
+ for (const fid of fileIds) {
2448
+ const pinnedIndex = fileAccountMap.get(fid);
2449
+ if (pinnedIndex !== undefined) {
2450
+ const candidates = accountManager.getEnabledAccounts();
2451
+ pinnedAccount = candidates.find((a) => a.index === pinnedIndex) ?? null;
2452
+ if (pinnedAccount) {
2453
+ debugLog("file-id pinning: routing to account", {
2454
+ fileId: fid,
2455
+ accountIndex: pinnedIndex,
2456
+ email: pinnedAccount.email,
2457
+ });
2458
+ break;
2375
2459
  }
2376
2460
  }
2377
- } catch {
2378
- // Non-JSON body or parse error — skip pinning
2379
2461
  }
2380
2462
  }
2381
2463
 
@@ -2394,6 +2476,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2394
2476
  let _adaptiveDecisionMade = false; // Ensure adaptive context decision is made only once per logical request
2395
2477
  let _adaptiveOverrideForRequest; // Cached adaptive override for all retry attempts
2396
2478
  let _overloadRecoveryAttempted = false; // Guard: only one quota-aware switch per request
2479
+ let _connectionResetRetries = 0; // Cap ECONNRESET/EPIPE retries to prevent infinite loop
2397
2480
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
2398
2481
  // Select account — use pinned account on first attempt if available
2399
2482
  const account =
@@ -2538,8 +2621,10 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2538
2621
 
2539
2622
  // Pre-compute the beta header so it can be injected into both the
2540
2623
  // request body (betas field) and the anthropic-beta header.
2624
+ // Reuse _parsedBodyOnce to avoid redundant JSON.parse.
2541
2625
  const { model: _reqModel, hasFileReferences: _reqHasFileRefs } = parseRequestBodyMetadata(
2542
2626
  requestInit.body,
2627
+ _parsedBodyOnce,
2543
2628
  );
2544
2629
  const _reqProvider = detectProvider(requestUrl);
2545
2630
 
@@ -2555,12 +2640,15 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2555
2640
  escalation_threshold: 150_000,
2556
2641
  deescalation_threshold: 100_000,
2557
2642
  },
2643
+ _parsedBodyOnce,
2558
2644
  );
2559
2645
  // Emit visual cue on state transitions (only when adaptive mode is on)
2560
2646
  if (config.adaptive_context?.enabled && _prevAdaptiveState !== adaptiveContextState.active) {
2561
2647
  const label = adaptiveContextState.active ? "1M context ON" : "1M context OFF";
2562
2648
  const variant = adaptiveContextState.active ? "info" : "success";
2563
- const est = estimatePromptTokens(requestInit.body);
2649
+ const est = _parsedBodyOnce
2650
+ ? estimatePromptTokensFromParsed(_parsedBodyOnce)
2651
+ : estimatePromptTokens(requestInit.body);
2564
2652
  toast(`⬡ ${label} (est. ${Math.round(est / 1000)}K tokens)`, variant, {
2565
2653
  debounceKey: "adaptive-ctx",
2566
2654
  }).catch(() => {});
@@ -2578,7 +2666,9 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2578
2666
  // Microcompact: inject clear betas at high context utilization
2579
2667
  let _microcompactBetas = null;
2580
2668
  if (requestInit.body) {
2581
- const estimatedTokens = estimatePromptTokens(requestInit.body);
2669
+ const estimatedTokens = _parsedBodyOnce
2670
+ ? estimatePromptTokensFromParsed(_parsedBodyOnce)
2671
+ : estimatePromptTokens(requestInit.body);
2582
2672
  if (shouldMicrocompact(estimatedTokens, config)) {
2583
2673
  _microcompactBetas = buildMicrocompactBetas();
2584
2674
  if (!microcompactState.active) {
@@ -2642,7 +2732,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2642
2732
  const effectiveCachePolicy = latchedCachePolicy ||
2643
2733
  config.cache_policy || { ttl: "1h", ttl_supported: true };
2644
2734
 
2645
- let body = transformRequestBody(
2735
+ const body = transformRequestBody(
2646
2736
  requestInit.body,
2647
2737
  {
2648
2738
  enabled: getSignatureEmulationEnabled(),
@@ -2722,11 +2812,13 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2722
2812
  errMsg.includes("socket hang up") ||
2723
2813
  errMsg.includes("network socket disconnected");
2724
2814
 
2725
- if (isConnectionReset) {
2815
+ if (isConnectionReset && _connectionResetRetries < 3) {
2816
+ _connectionResetRetries++;
2726
2817
  requestInit._disableKeepalive = true;
2727
2818
  debugLog("connection reset detected, disabling keepalive for retry", {
2728
2819
  code: errCode,
2729
2820
  message: errMsg,
2821
+ retryCount: _connectionResetRetries,
2730
2822
  });
2731
2823
  // Don't mark the account as failed — this is a transport issue, not auth.
2732
2824
  // Retry the same account with keepalive disabled.
@@ -2751,34 +2843,90 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2751
2843
  throw fetchError;
2752
2844
  }
2753
2845
 
2846
+ // Debug: log all response headers to file for diagnosis
2847
+ // Placed BEFORE the response.ok guard so we capture headers on ALL responses
2848
+ if (config.debug) {
2849
+ const rlHeaders = {};
2850
+ const allHeaders = {};
2851
+ response.headers.forEach((value, key) => {
2852
+ allHeaders[key] = value;
2853
+ if (key.includes("ratelimit") || key.includes("retry") || key.includes("x-should")) {
2854
+ rlHeaders[key] = value;
2855
+ }
2856
+ });
2857
+ debugLog(
2858
+ "response status:",
2859
+ response.status,
2860
+ "ok:",
2861
+ response.ok,
2862
+ "account:",
2863
+ !!account,
2864
+ "accountManager:",
2865
+ !!accountManager,
2866
+ );
2867
+ debugLog("ALL response headers:", allHeaders);
2868
+ // Write to file for reliable access
2869
+ try {
2870
+ const { writeFileSync } = await import("node:fs");
2871
+ const { join } = await import("node:path");
2872
+ const debugFile = join(getConfigDir(), "debug-headers.log");
2873
+ const ts = new Date().toISOString();
2874
+ const entry = [
2875
+ `\n=== ${ts} | status=${response.status} ok=${response.ok} account=${!!account} mgr=${!!accountManager} ===`,
2876
+ `Rate-limit headers: ${JSON.stringify(rlHeaders, null, 2)}`,
2877
+ `All headers: ${JSON.stringify(allHeaders, null, 2)}`,
2878
+ "",
2879
+ ].join("\n");
2880
+ writeFileSync(debugFile, entry, { flag: "a" });
2881
+ } catch (e) {
2882
+ debugLog("failed to write debug-headers.log", e);
2883
+ }
2884
+ }
2885
+
2754
2886
  // Proactive rate limit detection from response headers
2755
- // Check all rate-limit subtypes: tokens, requests, input-tokens (RE doc §5.6)
2887
+ // Anthropic sends window-based unified headers: 5h and 7d windows
2756
2888
  if (response.ok && account && accountManager) {
2757
- const RATE_LIMIT_SUBTYPES = ["tokens", "requests", "input-tokens"];
2889
+ const RATE_LIMIT_WINDOWS = [
2890
+ { key: "5h", field: "fiveHour", windowMs: 5 * 3600 * 1000 },
2891
+ { key: "7d", field: "sevenDay", windowMs: 7 * 24 * 3600 * 1000 },
2892
+ ];
2758
2893
  let maxUtilization = 0;
2759
- let maxUtilizationSubtype = "";
2894
+ let maxUtilizationWindow = "";
2760
2895
  let anySurpassed = false;
2761
2896
  let surpassedResetAt = null;
2762
2897
 
2763
- for (const subtype of RATE_LIMIT_SUBTYPES) {
2764
- const utilizationStr = response.headers.get(`anthropic-ratelimit-unified-${subtype}-utilization`);
2898
+ // Also capture overall status and fallback info
2899
+ const overallStatus = response.headers.get("anthropic-ratelimit-unified-status");
2900
+ const representativeClaim = response.headers.get("anthropic-ratelimit-unified-representative-claim");
2901
+ const fallbackStatus = response.headers.get("anthropic-ratelimit-unified-fallback");
2902
+ const fallbackPct = response.headers.get("anthropic-ratelimit-unified-fallback-percentage");
2903
+ const overageStatus = response.headers.get("anthropic-ratelimit-unified-overage-status");
2904
+ const overageReason = response.headers.get("anthropic-ratelimit-unified-overage-disabled-reason");
2905
+
2906
+ for (const win of RATE_LIMIT_WINDOWS) {
2907
+ const utilizationStr = response.headers.get(`anthropic-ratelimit-unified-${win.key}-utilization`);
2908
+ const status = response.headers.get(`anthropic-ratelimit-unified-${win.key}-status`);
2765
2909
  const surpassed = response.headers.get(
2766
- `anthropic-ratelimit-unified-${subtype}-surpassed-threshold`,
2910
+ `anthropic-ratelimit-unified-${win.key}-surpassed-threshold`,
2767
2911
  );
2768
- const resetAt = response.headers.get(`anthropic-ratelimit-unified-${subtype}-reset`);
2912
+ const resetAt = response.headers.get(`anthropic-ratelimit-unified-${win.key}-reset`);
2769
2913
 
2770
2914
  if (utilizationStr) {
2771
2915
  const utilization = parseFloat(utilizationStr);
2772
2916
  if (!isNaN(utilization)) {
2773
- // Store per-subtype quota for user display
2774
- if (subtype === "tokens") sessionMetrics.lastQuota.tokens = utilization;
2775
- else if (subtype === "requests") sessionMetrics.lastQuota.requests = utilization;
2776
- else if (subtype === "input-tokens") sessionMetrics.lastQuota.inputTokens = utilization;
2917
+ // Store per-window quota for user display
2918
+ const resetDate = resetAt ? new Date(parseInt(resetAt) * 1000).toISOString() : null;
2919
+ sessionMetrics.lastQuota[win.field] = {
2920
+ utilization: utilization * 100, // store as percentage 0-100
2921
+ resets_at: resetDate,
2922
+ status: status || null,
2923
+ surpassedThreshold: surpassed ? parseFloat(surpassed) : null,
2924
+ };
2777
2925
  sessionMetrics.lastQuota.updatedAt = Date.now();
2778
2926
 
2779
2927
  if (utilization > maxUtilization) {
2780
2928
  maxUtilization = utilization;
2781
- maxUtilizationSubtype = subtype;
2929
+ maxUtilizationWindow = win.key;
2782
2930
  }
2783
2931
  }
2784
2932
  }
@@ -2789,12 +2937,30 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2789
2937
  }
2790
2938
  }
2791
2939
 
2940
+ // Store overall/fallback/overage info
2941
+ if (overallStatus) {
2942
+ sessionMetrics.lastQuota.overallStatus = overallStatus;
2943
+ sessionMetrics.lastQuota.representativeClaim = representativeClaim;
2944
+ sessionMetrics.lastQuota.fallback = fallbackStatus;
2945
+ sessionMetrics.lastQuota.fallbackPercentage = fallbackPct ? parseFloat(fallbackPct) : null;
2946
+ sessionMetrics.lastQuota.overageStatus = overageStatus;
2947
+ sessionMetrics.lastQuota.overageReason = overageReason;
2948
+ }
2949
+
2950
+ // Back-compat: also update tokens/requests/inputTokens from the highest window
2951
+ // so existing code that reads these fields still works
2952
+ if (maxUtilization > 0) {
2953
+ sessionMetrics.lastQuota.tokens = maxUtilization;
2954
+ sessionMetrics.lastQuota.requests = maxUtilization;
2955
+ sessionMetrics.lastQuota.inputTokens = maxUtilization;
2956
+ }
2957
+
2792
2958
  if (maxUtilization > 0.8) {
2793
2959
  const penalty = Math.round((maxUtilization - 0.8) * 50); // 0-10 points
2794
2960
  accountManager.applyUtilizationPenalty(account, penalty);
2795
2961
  debugLog("high rate limit utilization", {
2796
2962
  accountIndex: account.index,
2797
- subtype: maxUtilizationSubtype,
2963
+ window: maxUtilizationWindow,
2798
2964
  utilization: (maxUtilization * 100).toFixed(1) + "%",
2799
2965
  penalty,
2800
2966
  });
@@ -2811,7 +2977,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2811
2977
  // Toast at 90%+ utilization to warn user before rate limit hits
2812
2978
  if (maxUtilization >= 0.9 && !config.toasts?.quiet) {
2813
2979
  toast(
2814
- `Rate limit ${maxUtilizationSubtype}: ${(maxUtilization * 100).toFixed(0)}% utilized`,
2980
+ `Rate limit ${maxUtilizationWindow} window: ${(maxUtilization * 100).toFixed(0)}% utilized`,
2815
2981
  "warning",
2816
2982
  { debounceKey: "quota-warn" },
2817
2983
  ).catch(() => {});
@@ -2821,21 +2987,19 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2821
2987
  // Parse reset timestamps to compute time-weighted risk
2822
2988
  if (maxUtilization > 0.6 && accountManager.getAccountCount() > 1) {
2823
2989
  let highestRisk = 0;
2824
- for (const subtype of RATE_LIMIT_SUBTYPES) {
2825
- const utilizationStr = response.headers.get(`anthropic-ratelimit-unified-${subtype}-utilization`);
2826
- const resetAtStr = response.headers.get(`anthropic-ratelimit-unified-${subtype}-reset`);
2990
+ for (const win of RATE_LIMIT_WINDOWS) {
2991
+ const utilizationStr = response.headers.get(`anthropic-ratelimit-unified-${win.key}-utilization`);
2992
+ const resetAtStr = response.headers.get(`anthropic-ratelimit-unified-${win.key}-reset`);
2827
2993
  if (!utilizationStr || !resetAtStr) continue;
2828
2994
 
2829
2995
  const utilization = parseFloat(utilizationStr);
2830
- const resetAt = new Date(resetAtStr).getTime();
2831
- if (isNaN(utilization) || isNaN(resetAt)) continue;
2996
+ const resetEpoch = parseInt(resetAtStr) * 1000; // unix epoch seconds → ms
2997
+ if (isNaN(utilization) || isNaN(resetEpoch)) continue;
2832
2998
 
2833
- // Estimate window duration from reset time
2834
- // 5h window = 18000s, 7d window = 604800s
2835
- const timeUntilReset = Math.max(0, resetAt - Date.now());
2999
+ const timeUntilReset = Math.max(0, resetEpoch - Date.now());
2836
3000
  // Risk formula: how fast we're burning through the quota
2837
3001
  // Higher utilization + less time remaining = higher risk
2838
- const timeRemainingFraction = Math.max(0.01, timeUntilReset / (5 * 3600 * 1000)); // assume 5h window
3002
+ const timeRemainingFraction = Math.max(0.01, timeUntilReset / win.windowMs);
2839
3003
  const risk = utilization / timeRemainingFraction;
2840
3004
  if (risk > highestRisk) highestRisk = risk;
2841
3005
  }
@@ -2845,7 +3009,9 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2845
3009
  const currentName = account.email || `Account ${account.index + 1}`;
2846
3010
  const nextAccount = accountManager.peekNextAccount?.();
2847
3011
  const nextName = nextAccount?.email || "next account";
2848
- accountManager.markRateLimited(account, "RATE_LIMIT_EXCEEDED", null);
3012
+ // QA fix L-predictive: use markPreemptiveSwitch instead of markRateLimited
3013
+ // — the request succeeded (200), so don't penalise consecutiveFailures or health.
3014
+ accountManager.markPreemptiveSwitch(account);
2849
3015
  toast(
2850
3016
  `Predictive switch: ${currentName} at high burn rate, switching to ${nextName}`,
2851
3017
  "warning",
@@ -2863,7 +3029,28 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2863
3029
  if (!response.ok && accountManager && account) {
2864
3030
  let errorBody = null;
2865
3031
  try {
2866
- errorBody = await response.clone().text();
3032
+ // QA fix L-errorBody: size-bound the read (16 KB) to avoid OOM on large error responses,
3033
+ // and add a 5s timeout so streaming error bodies don't stall the retry logic.
3034
+ const cloned = response.clone();
3035
+ const reader = cloned.body?.getReader();
3036
+ if (reader) {
3037
+ const chunks = [];
3038
+ let totalLen = 0;
3039
+ const maxLen = 16_384;
3040
+ const deadline = Date.now() + 5_000;
3041
+ while (totalLen < maxLen && Date.now() < deadline) {
3042
+ const { done, value } = await reader.read();
3043
+ if (done) break;
3044
+ chunks.push(value);
3045
+ totalLen += value.byteLength;
3046
+ }
3047
+ reader.cancel().catch(() => {});
3048
+ errorBody = new TextDecoder()
3049
+ .decode(chunks.length === 1 ? chunks[0] : Buffer.concat(chunks))
3050
+ .slice(0, maxLen);
3051
+ } else {
3052
+ errorBody = await cloned.text();
3053
+ }
2867
3054
  } catch {
2868
3055
  // Ignore read errors in debug logging path.
2869
3056
  }
@@ -2895,11 +3082,12 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2895
3082
  margin,
2896
3083
  });
2897
3084
  try {
2898
- const recoveryBody = JSON.parse(body);
3085
+ // QA fix: parse from requestInit.body (pre-transform) to avoid
3086
+ // double-transformation (mcp_ prefix, system blocks, metadata).
3087
+ const recoveryBody = JSON.parse(requestInit.body);
2899
3088
  recoveryBody.max_tokens = safeMaxTokens;
2900
3089
  requestInit.body = JSON.stringify(recoveryBody);
2901
- // Update `body` variable for subsequent iterations
2902
- body = requestInit.body;
3090
+ _parsedBodyOnce = null; // Invalidate stale parsed cache
2903
3091
  requestInit._overflowRecoveryAttempted = true;
2904
3092
  attempt--;
2905
3093
  toast(
@@ -2929,7 +3117,9 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2929
3117
  }
2930
3118
  }
2931
3119
  try {
2932
- const parsedBody = JSON.parse(body);
3120
+ // QA fix: parse from requestInit.body (pre-transform) to avoid
3121
+ // double-transformation (mcp_ prefix, system blocks, metadata).
3122
+ const parsedBody = JSON.parse(requestInit.body);
2933
3123
  if (Array.isArray(parsedBody.messages) && parsedBody.messages.length > 4) {
2934
3124
  // Keep first 2 messages (initial context) and last 2 messages (recent work).
2935
3125
  // Ensure the trimmed array never ends with an assistant message (prefill),
@@ -2978,6 +3168,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2978
3168
  // tool_result was in a trimmed middle message).
2979
3169
  parsedBody.messages = repairOrphanedToolUseBlocks(trimmed);
2980
3170
  requestInit.body = JSON.stringify(parsedBody);
3171
+ _parsedBodyOnce = null; // Invalidate stale parsed cache
2981
3172
  requestInit._reactiveCompactAttempted = true;
2982
3173
  // Retry with trimmed messages (decrement attempt to not consume account slot)
2983
3174
  attempt--;
@@ -3111,9 +3302,11 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3111
3302
  // Track consecutive 529s for model fallback
3112
3303
  if (response.status === 529) {
3113
3304
  consecutive529Count++;
3114
- if (consecutive529Count >= 3 && body) {
3305
+ if (consecutive529Count >= 3 && requestInit.body) {
3115
3306
  try {
3116
- const parsedForFallback = JSON.parse(body);
3307
+ // QA fix: parse from requestInit.body (pre-transform) to avoid
3308
+ // double-transformation (mcp_ prefix, system blocks, metadata).
3309
+ const parsedForFallback = JSON.parse(requestInit.body);
3117
3310
  const currentModel = parsedForFallback.model || "";
3118
3311
  let fallbackModel = null;
3119
3312
  if (/opus-4-6|opus-4/i.test(currentModel))
@@ -3124,7 +3317,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3124
3317
  if (fallbackModel) {
3125
3318
  parsedForFallback.model = fallbackModel;
3126
3319
  requestInit.body = JSON.stringify(parsedForFallback);
3127
- body = requestInit.body;
3320
+ _parsedBodyOnce = null; // Invalidate stale parsed cache
3128
3321
  toast(
3129
3322
  `Model fallback: ${currentModel} → ${fallbackModel} after ${consecutive529Count} overloads`,
3130
3323
  "warning",
@@ -3144,7 +3337,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3144
3337
  consecutive529Count = 0;
3145
3338
  }
3146
3339
 
3147
- const baseDelay = Math.min(0.5 * Math.pow(2, serviceWideRetryCount), 8);
3340
+ const baseDelay = Math.min(0.5 * Math.pow(2, serviceWideRetryCount), 3);
3148
3341
  const jitter = 1 - Math.random() * 0.25;
3149
3342
  const sleepMs = Math.round(baseDelay * jitter * 1000);
3150
3343
  const retryLabel = response.status === 529 ? "overloaded" : "unavailable";
@@ -3566,7 +3759,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
3566
3759
  // Session-level cache & cost tracking (Phase 4)
3567
3760
  // ---------------------------------------------------------------------------
3568
3761
 
3569
- /** @type {{turns: number, totalInput: number, totalOutput: number, totalCacheRead: number, totalCacheWrite: number, totalWebSearchRequests: number, recentCacheRates: number[], sessionCostUsd: number, costBreakdown: {input: number, output: number, cacheRead: number, cacheWrite: number}, sessionStartTime: number, lastQuota: {tokens: number, requests: number, inputTokens: number, updatedAt: number, fiveHour: {utilization: number, resets_at: string|null}, sevenDay: {utilization: number, resets_at: string|null}, lastPollAt: number}, lastStopReason: string | null, perModel: Record<string, {input: number, output: number, cacheRead: number, cacheWrite: number, costUsd: number, turns: number}>, lastModelId: string | null, lastRequestBody: string | null, tokenBudget: {limit: number, used: number, continuations: number, outputHistory: number[]}}} */
3762
+ /** @type {{turns: number, totalInput: number, totalOutput: number, totalCacheRead: number, totalCacheWrite: number, totalWebSearchRequests: number, recentCacheRates: number[], sessionCostUsd: number, costBreakdown: {input: number, output: number, cacheRead: number, cacheWrite: number}, sessionStartTime: number, lastQuota: {tokens: number, requests: number, inputTokens: number, updatedAt: number, fiveHour: {utilization: number, resets_at: string|null, status: string|null, surpassedThreshold: number|null}, sevenDay: {utilization: number, resets_at: string|null, status: string|null, surpassedThreshold: number|null}, overallStatus: string|null, representativeClaim: string|null, fallback: string|null, fallbackPercentage: number|null, overageStatus: string|null, overageReason: string|null, lastPollAt: number}, lastStopReason: string | null, perModel: Record<string, {input: number, output: number, cacheRead: number, cacheWrite: number, costUsd: number, turns: number}>, lastModelId: string | null, lastRequestBody: string | null, tokenBudget: {limit: number, used: number, continuations: number, outputHistory: number[]}}} */
3570
3763
  const sessionMetrics = {
3571
3764
  turns: 0,
3572
3765
  totalInput: 0,
@@ -3583,9 +3776,17 @@ const sessionMetrics = {
3583
3776
  requests: 0,
3584
3777
  inputTokens: 0,
3585
3778
  updatedAt: 0,
3779
+ // Window-based unified headers from response
3780
+ fiveHour: { utilization: 0, resets_at: null, status: null, surpassedThreshold: null },
3781
+ sevenDay: { utilization: 0, resets_at: null, status: null, surpassedThreshold: null },
3782
+ // Overall/fallback/overage from response headers
3783
+ overallStatus: null,
3784
+ representativeClaim: null,
3785
+ fallback: null,
3786
+ fallbackPercentage: null,
3787
+ overageStatus: null,
3788
+ overageReason: null,
3586
3789
  // Usage endpoint polling (A6)
3587
- fiveHour: { utilization: 0, resets_at: null },
3588
- sevenDay: { utilization: 0, resets_at: null },
3589
3790
  lastPollAt: 0,
3590
3791
  },
3591
3792
  lastStopReason: null, // tracks most recent stop_reason for output cap escalation
@@ -3711,12 +3912,14 @@ async function pollOAuthUsage(config, accessToken) {
3711
3912
  const data = await resp.json();
3712
3913
  if (data.five_hour) {
3713
3914
  sessionMetrics.lastQuota.fiveHour = {
3915
+ ...sessionMetrics.lastQuota.fiveHour,
3714
3916
  utilization: data.five_hour.utilization ?? 0,
3715
3917
  resets_at: data.five_hour.resets_at ?? null,
3716
3918
  };
3717
3919
  }
3718
3920
  if (data.seven_day) {
3719
3921
  sessionMetrics.lastQuota.sevenDay = {
3922
+ ...sessionMetrics.lastQuota.sevenDay,
3720
3923
  utilization: data.seven_day.utilization ?? 0,
3721
3924
  resets_at: data.seven_day.resets_at ?? null,
3722
3925
  };
@@ -3864,10 +4067,10 @@ function tryQuotaAwareAccountSwitch(account, accountManager, config) {
3864
4067
  * @param {string} bodyStr - JSON request body
3865
4068
  * @returns {Map<string, string>} source_id → hash
3866
4069
  */
3867
- function extractCacheSourceHashes(bodyStr) {
4070
+ function extractCacheSourceHashes(bodyStr, parsedBody = undefined) {
3868
4071
  const hashes = new Map();
3869
4072
  try {
3870
- const parsed = JSON.parse(bodyStr);
4073
+ const parsed = parsedBody ?? JSON.parse(bodyStr);
3871
4074
 
3872
4075
  // Hash system prompt (excluding token budget blocks injected by injectTokenBudgetBlock)
3873
4076
  if (Array.isArray(parsed.system)) {
@@ -4120,48 +4323,59 @@ function estimatePromptTokens(bodyString) {
4120
4323
  if (!bodyString || typeof bodyString !== "string") return 0;
4121
4324
  try {
4122
4325
  const parsed = JSON.parse(bodyString);
4123
- let charCount = 0;
4326
+ return estimatePromptTokensFromParsed(parsed);
4327
+ } catch {
4328
+ // Fallback: raw body length / 4 if JSON parsing fails
4329
+ return Math.ceil(bodyString.length / 4);
4330
+ }
4331
+ }
4124
4332
 
4125
- // Count system prompt text
4126
- if (Array.isArray(parsed.system)) {
4127
- for (const block of parsed.system) {
4128
- if (block.type === "text" && typeof block.text === "string") {
4129
- charCount += block.text.length;
4130
- }
4333
+ /**
4334
+ * Estimate prompt tokens from an already-parsed request body object.
4335
+ * Avoids redundant JSON.parse when the caller already has the parsed object.
4336
+ * @param {object} parsed - The parsed request body
4337
+ * @returns {number} Estimated token count
4338
+ */
4339
+ function estimatePromptTokensFromParsed(parsed) {
4340
+ if (!parsed || typeof parsed !== "object") return 0;
4341
+ let charCount = 0;
4342
+
4343
+ // Count system prompt text
4344
+ if (Array.isArray(parsed.system)) {
4345
+ for (const block of parsed.system) {
4346
+ if (block.type === "text" && typeof block.text === "string") {
4347
+ charCount += block.text.length;
4131
4348
  }
4132
- } else if (typeof parsed.system === "string") {
4133
- charCount += parsed.system.length;
4134
4349
  }
4350
+ } else if (typeof parsed.system === "string") {
4351
+ charCount += parsed.system.length;
4352
+ }
4135
4353
 
4136
- // Count messages content (text blocks, tool results) — skip tool definitions
4137
- if (Array.isArray(parsed.messages)) {
4138
- for (const msg of parsed.messages) {
4139
- if (typeof msg.content === "string") {
4140
- charCount += msg.content.length;
4141
- } else if (Array.isArray(msg.content)) {
4142
- for (const block of msg.content) {
4143
- if (block.type === "text" && typeof block.text === "string") {
4144
- charCount += block.text.length;
4145
- } else if (block.type === "tool_result" && typeof block.content === "string") {
4146
- charCount += block.content.length;
4147
- } else if (block.type === "tool_use") {
4148
- // Count serialized input as tokens
4149
- charCount += JSON.stringify(block.input || {}).length;
4150
- } else if (block.type === "image" || block.type === "image_url") {
4151
- // Images: ~2000 tokens per image (Anthropic tile-based counting)
4152
- charCount += 8000; // 2000 tokens * 4 chars/token
4153
- }
4354
+ // Count messages content (text blocks, tool results) — skip tool definitions
4355
+ if (Array.isArray(parsed.messages)) {
4356
+ for (const msg of parsed.messages) {
4357
+ if (typeof msg.content === "string") {
4358
+ charCount += msg.content.length;
4359
+ } else if (Array.isArray(msg.content)) {
4360
+ for (const block of msg.content) {
4361
+ if (block.type === "text" && typeof block.text === "string") {
4362
+ charCount += block.text.length;
4363
+ } else if (block.type === "tool_result" && typeof block.content === "string") {
4364
+ charCount += block.content.length;
4365
+ } else if (block.type === "tool_use") {
4366
+ // Count serialized input as tokens
4367
+ charCount += JSON.stringify(block.input || {}).length;
4368
+ } else if (block.type === "image" || block.type === "image_url") {
4369
+ // Images: ~2000 tokens per image (Anthropic tile-based counting)
4370
+ charCount += 8000; // 2000 tokens * 4 chars/token
4154
4371
  }
4155
4372
  }
4156
4373
  }
4157
4374
  }
4158
-
4159
- // 4 chars/token heuristic for text content (reasonable for English + code + JSON)
4160
- return Math.ceil(charCount / 4);
4161
- } catch {
4162
- // Fallback: raw body length / 4 if JSON parsing fails
4163
- return Math.ceil(bodyString.length / 4);
4164
4375
  }
4376
+
4377
+ // 4 chars/token heuristic for text content (reasonable for English + code + JSON)
4378
+ return Math.ceil(charCount / 4);
4165
4379
  }
4166
4380
 
4167
4381
  /**
@@ -4284,7 +4498,7 @@ function analyzeRequestContext(bodyStr) {
4284
4498
  * @param {import('./lib/config.mjs').AdaptiveContextConfig} adaptiveConfig
4285
4499
  * @returns {boolean}
4286
4500
  */
4287
- function resolveAdaptiveContext(bodyString, model, adaptiveConfig) {
4501
+ function resolveAdaptiveContext(bodyString, model, adaptiveConfig, parsedBody) {
4288
4502
  // Non-adaptive: use static check (only explicit "1m" models)
4289
4503
  if (!adaptiveConfig.enabled) {
4290
4504
  return hasOneMillionContext(model);
@@ -4300,7 +4514,7 @@ function resolveAdaptiveContext(bodyString, model, adaptiveConfig) {
4300
4514
  return false;
4301
4515
  }
4302
4516
 
4303
- const estimatedTokens = estimatePromptTokens(bodyString);
4517
+ const estimatedTokens = parsedBody ? estimatePromptTokensFromParsed(parsedBody) : estimatePromptTokens(bodyString);
4304
4518
  const turnsSinceTransition = sessionMetrics.turns - adaptiveContextState.lastTransitionTurn;
4305
4519
 
4306
4520
  if (adaptiveContextState.active) {
@@ -4661,10 +4875,12 @@ const SESSION_START_TIME = Date.now();
4661
4875
  const liveTokenRef = { token: "" };
4662
4876
 
4663
4877
  // Best-effort exit telemetry (QA fix M10: use 'once' to prevent listener stacking on re-import)
4664
- process.once("beforeExit", () => {
4878
+ // QA fix L-beforeExit: store handler reference for cleanup; prevents leaked refs to telemetryEmitter
4879
+ const _beforeExitHandler = () => {
4665
4880
  const duration = Date.now() - SESSION_START_TIME;
4666
4881
  telemetryEmitter.sendExitEvent(liveTokenRef.token, duration).catch(() => {});
4667
- });
4882
+ };
4883
+ process.once("beforeExit", _beforeExitHandler);
4668
4884
 
4669
4885
  // ---------------------------------------------------------------------------
4670
4886
  // Request building helpers (extracted from original fetch interceptor)
@@ -5231,21 +5447,27 @@ function detectProvider(requestUrl) {
5231
5447
  * @param {any} body
5232
5448
  * @returns {{model: string, tools: any[], messages: any[], hasFileReferences: boolean}}
5233
5449
  */
5234
- function parseRequestBodyMetadata(body) {
5235
- if (!body || typeof body !== "string") {
5450
+ function parseRequestBodyMetadata(body, parsedBody) {
5451
+ const parsed =
5452
+ parsedBody ||
5453
+ (typeof body === "string"
5454
+ ? (() => {
5455
+ try {
5456
+ return JSON.parse(body);
5457
+ } catch {
5458
+ return null;
5459
+ }
5460
+ })()
5461
+ : null);
5462
+ if (!parsed) {
5236
5463
  return { model: "", tools: [], messages: [], hasFileReferences: false };
5237
5464
  }
5238
5465
 
5239
- try {
5240
- const parsed = JSON.parse(body);
5241
- const model = typeof parsed?.model === "string" ? parsed.model : "";
5242
- const tools = Array.isArray(parsed?.tools) ? parsed.tools : [];
5243
- const messages = Array.isArray(parsed?.messages) ? parsed.messages : [];
5244
- const hasFileReferences = extractFileIds(parsed).length > 0;
5245
- return { model, tools, messages, hasFileReferences };
5246
- } catch {
5247
- return { model: "", tools: [], messages: [], hasFileReferences: false };
5248
- }
5466
+ const model = typeof parsed?.model === "string" ? parsed.model : "";
5467
+ const tools = Array.isArray(parsed?.tools) ? parsed.tools : [];
5468
+ const messages = Array.isArray(parsed?.messages) ? parsed.messages : [];
5469
+ const hasFileReferences = extractFileIds(parsed).length > 0;
5470
+ return { model, tools, messages, hasFileReferences };
5249
5471
  }
5250
5472
 
5251
5473
  /**
@@ -5972,20 +6194,19 @@ function buildRequestHeaders(
5972
6194
  );
5973
6195
  // x-stainless-timeout: sent only for non-streaming requests.
5974
6196
  // Claude Code sends 600 (10 minutes) as the default timeout in seconds.
5975
- // We detect streaming from the request body.
5976
- let xStainlessTimeout = "600";
6197
+ // For streaming requests, the SDK omits this header entirely.
5977
6198
  if (requestBody) {
5978
6199
  try {
5979
6200
  const parsed = JSON.parse(requestBody);
5980
- const isStreaming = parsed.stream !== false;
5981
- if (!isStreaming) {
5982
- xStainlessTimeout = "600";
6201
+ // stream defaults to true in Claude Code; only explicitly false means non-streaming
6202
+ if (parsed.stream === false) {
6203
+ requestHeaders.set("x-stainless-timeout", "600");
5983
6204
  }
6205
+ // Streaming requests: omit x-stainless-timeout (real SDK behavior)
5984
6206
  } catch {
5985
- // Non-JSON body or parse error — assume streaming
6207
+ // Non-JSON body or parse error — omit header (safe default)
5986
6208
  }
5987
6209
  }
5988
- requestHeaders.set("x-stainless-timeout", xStainlessTimeout);
5989
6210
  const stainlessHelpers = buildStainlessHelperHeader(tools, messages);
5990
6211
  if (stainlessHelpers) {
5991
6212
  requestHeaders.set("x-stainless-helper", stainlessHelpers);
@@ -6027,6 +6248,10 @@ function buildRequestHeaders(
6027
6248
  function resolveMaxTokens(body, config) {
6028
6249
  if (!config.output_cap?.enabled) return body.max_tokens; // passthrough
6029
6250
  if (body.max_tokens != null) return body.max_tokens; // caller-specified wins
6251
+ // QA note L-escalation: lastStopReason is set by extractUsageFromSSEEvent AFTER the stream
6252
+ // completes. The timing works correctly for "escalate for one turn" because this function runs
6253
+ // BEFORE the next request's stream starts. If the response pipeline changes to update stop
6254
+ // reason mid-stream or before response completion, this ordering assumption would break.
6030
6255
  const escalated = sessionMetrics.lastStopReason === "max_tokens";
6031
6256
  const result = escalated
6032
6257
  ? (config.output_cap.escalated_max_tokens ?? 64_000)
@@ -6856,18 +7081,21 @@ function parseCommandArgs(raw) {
6856
7081
  function extractFileIds(body) {
6857
7082
  const ids = [];
6858
7083
  if (!body || typeof body !== "object") return ids;
6859
- function walk(obj) {
7084
+ // QA fix L-depth: cap recursion depth to prevent stack overflow on pathological payloads
7085
+ const MAX_DEPTH = 20;
7086
+ function walk(obj, depth) {
7087
+ if (depth > MAX_DEPTH) return;
6860
7088
  if (Array.isArray(obj)) {
6861
- for (const item of obj) walk(item);
7089
+ for (const item of obj) walk(item, depth + 1);
6862
7090
  } else if (obj && typeof obj === "object") {
6863
7091
  if (obj.source?.file_id) ids.push(obj.source.file_id);
6864
7092
  for (const val of Object.values(obj)) {
6865
- if (val && typeof val === "object") walk(val);
7093
+ if (val && typeof val === "object") walk(val, depth + 1);
6866
7094
  }
6867
7095
  }
6868
7096
  }
6869
- walk(body.messages);
6870
- walk(body.system);
7097
+ walk(body.messages, 0);
7098
+ walk(body.system, 0);
6871
7099
  return ids;
6872
7100
  }
6873
7101