opencode-anthropic-fix 0.0.32 → 0.0.34

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
@@ -821,7 +821,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
821
821
  },
822
822
  "adaptive-context": () => {
823
823
  const enabled = value === "on" || value === "true" || value === "1";
824
- saveConfig({ adaptive_context: { enabled } });
824
+ saveConfig({ adaptive_context: { ...config.adaptive_context, enabled } });
825
825
  if (!config.adaptive_context)
826
826
  config.adaptive_context = {
827
827
  enabled: false,
@@ -833,6 +833,7 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
833
833
  if (!enabled) {
834
834
  adaptiveContextState.active = false;
835
835
  adaptiveContextState.escalatedByError = false;
836
+ adaptiveContextState.lastTransitionTurn = sessionMetrics.turns;
836
837
  }
837
838
  },
838
839
  };
@@ -1825,6 +1826,8 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
1825
1826
  let serviceWideRetryCount = 0; // Track 529/503 retries (max 2 per RE doc §5.5)
1826
1827
  let shouldRetryCount = 0; // Track x-should-retry forced retries (cap at 3)
1827
1828
  let consecutive529Count = 0;
1829
+ let _adaptiveDecisionMade = false; // Ensure adaptive context decision is made only once per logical request
1830
+ let _adaptiveOverrideForRequest; // Cached adaptive override for all retry attempts
1828
1831
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
1829
1832
  // Select account — use pinned account on first attempt if available
1830
1833
  const account =
@@ -1974,30 +1977,34 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
1974
1977
  );
1975
1978
  const _reqProvider = detectProvider(requestUrl);
1976
1979
 
1977
- // --- Adaptive 1M context decision ---
1978
- const _prevAdaptiveState = adaptiveContextState.active;
1979
- const _use1MContext = resolveAdaptiveContext(
1980
- requestInit.body,
1981
- _reqModel,
1982
- config.adaptive_context || {
1983
- enabled: false,
1984
- escalation_threshold: 150_000,
1985
- deescalation_threshold: 100_000,
1986
- },
1987
- );
1988
- // Emit visual cue on state transitions (only when adaptive mode is on)
1989
- if (config.adaptive_context?.enabled && _prevAdaptiveState !== adaptiveContextState.active) {
1990
- const label = adaptiveContextState.active ? "1M context ON" : "1M context OFF";
1991
- const variant = adaptiveContextState.active ? "info" : "success";
1992
- const est = estimatePromptTokens(requestInit.body);
1993
- toast(`⬡ ${label} (est. ${Math.round(est / 1000)}K tokens)`, variant, {
1994
- debounceKey: "adaptive-ctx",
1995
- }).catch(() => {});
1980
+ // --- Adaptive 1M context decision (once per logical request, not per retry) ---
1981
+ if (!_adaptiveDecisionMade) {
1982
+ _adaptiveDecisionMade = true;
1983
+ const _prevAdaptiveState = adaptiveContextState.active;
1984
+ const _use1MContext = resolveAdaptiveContext(
1985
+ requestInit.body,
1986
+ _reqModel,
1987
+ config.adaptive_context || {
1988
+ enabled: true,
1989
+ escalation_threshold: 150_000,
1990
+ deescalation_threshold: 100_000,
1991
+ },
1992
+ );
1993
+ // Emit visual cue on state transitions (only when adaptive mode is on)
1994
+ if (config.adaptive_context?.enabled && _prevAdaptiveState !== adaptiveContextState.active) {
1995
+ const label = adaptiveContextState.active ? "1M context ON" : "1M context OFF";
1996
+ const variant = adaptiveContextState.active ? "info" : "success";
1997
+ const est = estimatePromptTokens(requestInit.body);
1998
+ toast(`⬡ ${label} (est. ${Math.round(est / 1000)}K tokens)`, variant, {
1999
+ debounceKey: "adaptive-ctx",
2000
+ }).catch(() => {});
2001
+ }
2002
+ _adaptiveOverrideForRequest = config.adaptive_context?.enabled
2003
+ ? { use1MContext: _use1MContext }
2004
+ : undefined;
1996
2005
  }
1997
2006
 
1998
- const _adaptiveOverride = config.adaptive_context?.enabled
1999
- ? { use1MContext: _use1MContext }
2000
- : undefined;
2007
+ const _adaptiveOverride = _adaptiveOverrideForRequest;
2001
2008
 
2002
2009
  const computedBetaHeader = buildAnthropicBetaHeader(
2003
2010
  "",
@@ -2207,26 +2214,34 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2207
2214
  try {
2208
2215
  const parsedBody = JSON.parse(body);
2209
2216
  if (Array.isArray(parsedBody.messages) && parsedBody.messages.length > 4) {
2210
- // Keep first 2 messages (initial context) and last N messages (recent work).
2217
+ // Keep first 2 messages (initial context) and last 2 messages (recent work).
2211
2218
  // Ensure the trimmed array never ends with an assistant message (prefill),
2212
2219
  // which would cause "does not support assistant message prefill" errors.
2213
- let tailCount = 2;
2214
2220
  const msgs = parsedBody.messages;
2215
- // Expand tail if it would end with an assistant message
2216
- while (
2217
- tailCount < msgs.length - 2 &&
2218
- msgs[msgs.length - tailCount]?.role !== "user" &&
2219
- msgs.length - tailCount > 0
2220
- ) {
2221
- tailCount++;
2222
- }
2223
- const tail = msgs.slice(-tailCount);
2224
- // If tail still starts/ends badly, force a user wrapper
2221
+ const tail = msgs.slice(-2);
2222
+ // If tail ends with assistant, append a user message to fix the prefill issue.
2223
+ // Check if the assistant's last content block is tool_use — if so, synthesize
2224
+ // a tool_result instead of bare "Continue." to respect the tool protocol.
2225
2225
  if (tail.length > 0 && tail[tail.length - 1]?.role === "assistant") {
2226
- tail.push({
2227
- role: "user",
2228
- content: [{ type: "text", text: "Continue." }],
2229
- });
2226
+ const lastAssistant = tail[tail.length - 1];
2227
+ const lastContent = Array.isArray(lastAssistant.content) ? lastAssistant.content : [];
2228
+ const toolUseBlocks = lastContent.filter((b) => b.type === "tool_use");
2229
+ if (toolUseBlocks.length > 0) {
2230
+ // Synthesize tool_result for each pending tool_use
2231
+ tail.push({
2232
+ role: "user",
2233
+ content: toolUseBlocks.map((tu) => ({
2234
+ type: "tool_result",
2235
+ tool_use_id: tu.id,
2236
+ content: "[Context trimmed — previous result unavailable]",
2237
+ })),
2238
+ });
2239
+ } else {
2240
+ tail.push({
2241
+ role: "user",
2242
+ content: [{ type: "text", text: "Continue." }],
2243
+ });
2244
+ }
2230
2245
  }
2231
2246
  const trimmed = [
2232
2247
  ...msgs.slice(0, 2),
@@ -2635,6 +2650,12 @@ export async function AnthropicAuthPlugin({ client, project, directory, worktree
2635
2650
  ],
2636
2651
  },
2637
2652
  "experimental.session.compacting": async (input, output) => {
2653
+ // Reset adaptive context state on session compaction (new conversation boundary).
2654
+ // This prevents sticky escalation from leaking across conversations.
2655
+ adaptiveContextState.active = false;
2656
+ adaptiveContextState.lastTransitionTurn = sessionMetrics.turns;
2657
+ adaptiveContextState.escalatedByError = false;
2658
+
2638
2659
  // Inject Anthropic-specific context into compaction
2639
2660
  if (!accountManager) return;
2640
2661
  const account = accountManager.getCurrentAccount();
@@ -2706,9 +2727,50 @@ const adaptiveContextState = {
2706
2727
  */
2707
2728
  function estimatePromptTokens(bodyString) {
2708
2729
  if (!bodyString || typeof bodyString !== "string") return 0;
2709
- // The body includes tools, system prompt, and messages.
2710
- // 4 chars/token is a reasonable heuristic for JSON-encoded conversation.
2711
- return Math.ceil(bodyString.length / 4);
2730
+ try {
2731
+ const parsed = JSON.parse(bodyString);
2732
+ let charCount = 0;
2733
+
2734
+ // Count system prompt text
2735
+ if (Array.isArray(parsed.system)) {
2736
+ for (const block of parsed.system) {
2737
+ if (block.type === "text" && typeof block.text === "string") {
2738
+ charCount += block.text.length;
2739
+ }
2740
+ }
2741
+ } else if (typeof parsed.system === "string") {
2742
+ charCount += parsed.system.length;
2743
+ }
2744
+
2745
+ // Count messages content (text blocks, tool results) — skip tool definitions
2746
+ if (Array.isArray(parsed.messages)) {
2747
+ for (const msg of parsed.messages) {
2748
+ if (typeof msg.content === "string") {
2749
+ charCount += msg.content.length;
2750
+ } else if (Array.isArray(msg.content)) {
2751
+ for (const block of msg.content) {
2752
+ if (block.type === "text" && typeof block.text === "string") {
2753
+ charCount += block.text.length;
2754
+ } else if (block.type === "tool_result" && typeof block.content === "string") {
2755
+ charCount += block.content.length;
2756
+ } else if (block.type === "tool_use") {
2757
+ // Count serialized input as tokens
2758
+ charCount += JSON.stringify(block.input || {}).length;
2759
+ } else if (block.type === "image" || block.type === "image_url") {
2760
+ // Images: ~2000 tokens per image (Anthropic tile-based counting)
2761
+ charCount += 8000; // 2000 tokens * 4 chars/token
2762
+ }
2763
+ }
2764
+ }
2765
+ }
2766
+ }
2767
+
2768
+ // 4 chars/token heuristic for text content (reasonable for English + code + JSON)
2769
+ return Math.ceil(charCount / 4);
2770
+ } catch {
2771
+ // Fallback: raw body length / 4 if JSON parsing fails
2772
+ return Math.ceil(bodyString.length / 4);
2773
+ }
2712
2774
  }
2713
2775
 
2714
2776
  /**
@@ -2735,6 +2797,11 @@ function resolveAdaptiveContext(bodyString, model, adaptiveConfig) {
2735
2797
  return hasOneMillionContext(model);
2736
2798
  }
2737
2799
 
2800
+ // If experimental betas are disabled, context-1m will be stripped anyway — skip adaptive logic
2801
+ if (isTruthyEnv(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)) {
2802
+ return false;
2803
+ }
2804
+
2738
2805
  // Model must be eligible for 1M context at all
2739
2806
  if (!hasOneMillionContext(model)) {
2740
2807
  return false;
@@ -2762,6 +2829,10 @@ function resolveAdaptiveContext(bodyString, model, adaptiveConfig) {
2762
2829
  return true;
2763
2830
  } else {
2764
2831
  // Currently inactive — consider escalation
2832
+ // Symmetric hysteresis: require at least 2 turns before re-escalation too
2833
+ if (turnsSinceTransition < 2 && adaptiveContextState.lastTransitionTurn > 0) {
2834
+ return false;
2835
+ }
2765
2836
  if (estimatedTokens > adaptiveConfig.escalation_threshold) {
2766
2837
  // Escalate
2767
2838
  adaptiveContextState.active = true;
@@ -2952,7 +3023,7 @@ class TelemetryEmitter {
2952
3023
  node_version: process.version,
2953
3024
  terminal: process.env.TERM_PROGRAM || process.env.TERM || "",
2954
3025
  version: this.#cliVersion,
2955
- build_time: "", // omit — we don't know the real build time
3026
+ build_time: CLAUDE_CODE_BUILD_TIME,
2956
3027
  is_ci: false,
2957
3028
  },
2958
3029
  ...extras,
@@ -3062,16 +3133,18 @@ process.once("beforeExit", () => {
3062
3133
  // Request building helpers (extracted from original fetch interceptor)
3063
3134
  // ---------------------------------------------------------------------------
3064
3135
 
3065
- const FALLBACK_CLAUDE_CLI_VERSION = "2.1.83";
3136
+ const FALLBACK_CLAUDE_CLI_VERSION = "2.1.84";
3066
3137
  const CLAUDE_CODE_NPM_LATEST_URL = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
3138
+ const CLAUDE_CODE_BUILD_TIME = "2026-03-25T23:48:41Z";
3067
3139
 
3068
- // The @anthropic-ai/sdk version bundled with Claude Code v2.1.83.
3140
+ // The @anthropic-ai/sdk version bundled with Claude Code v2.1.84.
3069
3141
  // This is distinct from the CLI version and goes in X-Stainless-Package-Version.
3070
- // Verified by extracting VERSION="0.208.0" from the bundled cli.js of v2.1.80/2.1.81/2.1.83.
3142
+ // Verified by extracting VERSION="0.208.0" from the bundled cli.js of v2.1.84.
3071
3143
  const ANTHROPIC_SDK_VERSION = "0.208.0";
3072
3144
 
3073
3145
  // Map of CLI version → bundled SDK version (update when CLI version changes)
3074
3146
  const CLI_TO_SDK_VERSION = new Map([
3147
+ ["2.1.84", "0.208.0"],
3075
3148
  ["2.1.83", "0.208.0"],
3076
3149
  ["2.1.81", "0.208.0"],
3077
3150
  ["2.1.80", "0.208.0"],
@@ -3522,9 +3595,9 @@ function buildRequestMetadata(input) {
3522
3595
 
3523
3596
  /**
3524
3597
  * Build the billing header block for Claude Code system prompt injection.
3525
- * Claude Code v2.1.81+: cch computed via NP1() from first user message, cc_version includes model ID.
3598
+ * Claude Code v2.1.84: cch computed via NP1() from first user message, cc_version includes model ID.
3526
3599
  *
3527
- * @param {string} version - CLI version (e.g., "2.1.83")
3600
+ * @param {string} version - CLI version (e.g., "2.1.84")
3528
3601
  * @param {string} [modelId] - Model ID to append (e.g., "claude-opus-4-6")
3529
3602
  * @param {string} [firstUserMessage] - First user message text for cch hash computation
3530
3603
  * @returns {string}
@@ -3849,7 +3922,7 @@ function buildAnthropicBetaHeader(
3849
3922
  const haiku = isHaikuModel(model);
3850
3923
  const isRoundRobin = strategy === "round-robin";
3851
3924
 
3852
- // === ALWAYS-ON BETAS (Claude Code v2.1.83 base set) ===
3925
+ // === ALWAYS-ON BETAS (Claude Code v2.1.84 base set) ===
3853
3926
  // These are ALWAYS included regardless of env vars or feature flags.
3854
3927
  if (!haiku) {
3855
3928
  betas.push(CLAUDE_CODE_BETA_FLAG); // "claude-code-20250219"
@@ -4159,6 +4232,9 @@ function buildRequestHeaders(input, requestInit, accessToken, requestBody, reque
4159
4232
  if (isTruthyEnv(process.env.CLAUDE_CODE_ADDITIONAL_PROTECTION)) {
4160
4233
  requestHeaders.set("x-anthropic-additional-protection", "true");
4161
4234
  }
4235
+
4236
+ // Claude Code v2.1.84: x-client-request-id — unique UUID per request for debugging timeouts.
4237
+ requestHeaders.set("x-client-request-id", randomUUID());
4162
4238
  }
4163
4239
  requestHeaders.delete("x-api-key");
4164
4240
 
@@ -4211,6 +4287,15 @@ function transformRequestBody(body, signature, runtime, betaHeader) {
4211
4287
  if (thinkingActive) {
4212
4288
  // Anthropic API rejects temperature when thinking is enabled
4213
4289
  delete parsed.temperature;
4290
+
4291
+ // Claude Code v2.1.84: inject context_management body field when thinking
4292
+ // is active and context-management beta is in use. This tells the API how
4293
+ // to handle thinking blocks during context management operations.
4294
+ if (!parsed.context_management) {
4295
+ parsed.context_management = {
4296
+ edits: [{ type: "clear_thinking_20251015", keep: "all" }],
4297
+ };
4298
+ }
4214
4299
  } else {
4215
4300
  // Claude Code always uses temperature: 1 for non-thinking requests (RE doc §5.2, never 0)
4216
4301
  parsed.temperature = 1;
@@ -4293,10 +4378,26 @@ function transformRequestBody(body, signature, runtime, betaHeader) {
4293
4378
  if (Array.isArray(parsed.messages) && parsed.messages.length > 0) {
4294
4379
  const lastMsg = parsed.messages[parsed.messages.length - 1];
4295
4380
  if (lastMsg && lastMsg.role === "assistant") {
4296
- parsed.messages.push({
4297
- role: "user",
4298
- content: [{ type: "text", text: "Continue." }],
4299
- });
4381
+ // Check if the assistant message contains tool_use blocks — if so,
4382
+ // synthesize tool_result responses instead of bare "Continue." to
4383
+ // respect the Anthropic tool protocol.
4384
+ const lastContent = Array.isArray(lastMsg.content) ? lastMsg.content : [];
4385
+ const toolUseBlocks = lastContent.filter((b) => b.type === "tool_use");
4386
+ if (toolUseBlocks.length > 0) {
4387
+ parsed.messages.push({
4388
+ role: "user",
4389
+ content: toolUseBlocks.map((tu) => ({
4390
+ type: "tool_result",
4391
+ tool_use_id: tu.id,
4392
+ content: "[Result unavailable — conversation was restructured]",
4393
+ })),
4394
+ });
4395
+ } else {
4396
+ parsed.messages.push({
4397
+ role: "user",
4398
+ content: [{ type: "text", text: "Continue." }],
4399
+ });
4400
+ }
4300
4401
  }
4301
4402
  }
4302
4403
 
@@ -4751,9 +4852,12 @@ async function refreshAccountToken(account, client, source = "foreground", { onT
4751
4852
  const { readCCCredentials } = await import("./lib/cc-credentials.mjs");
4752
4853
  const ccCreds = readCCCredentials();
4753
4854
  const match = ccCreds.find((c) => c.refreshToken === account.refreshToken);
4754
- if (match && match.expiresAt > Date.now()) {
4855
+ // Accept CC credential if:
4856
+ // - expiresAt is in the future (normal case), OR
4857
+ // - expiresAt is 0/missing (CC didn't provide expiry — trust the token, let API 401 if stale)
4858
+ if (match && (match.expiresAt === 0 || match.expiresAt > Date.now())) {
4755
4859
  account.access = match.accessToken;
4756
- account.expires = match.expiresAt;
4860
+ account.expires = match.expiresAt || Date.now() + 3600_000; // default 1h if unknown
4757
4861
  markTokenStateUpdated(account);
4758
4862
  if (onTokensUpdated) {
4759
4863
  try {
package/lib/accounts.mjs CHANGED
@@ -807,6 +807,7 @@ export class AccountManager {
807
807
  lastFailureTime: acc.lastFailureTime,
808
808
  lastSwitchReason: acc.lastSwitchReason || existing?.lastSwitchReason || "initial",
809
809
  stats: acc.stats ?? existing?.stats ?? createDefaultStats(),
810
+ source: acc.source || existing?.source || "oauth",
810
811
  };
811
812
  });
812
813
 
@@ -16,7 +16,7 @@
16
16
  * @module cc-credentials
17
17
  */
18
18
 
19
- import { execSync } from "node:child_process";
19
+ import { execSync, execFileSync } from "node:child_process";
20
20
  import { readFileSync, existsSync } from "node:fs";
21
21
  import { join } from "node:path";
22
22
  import { homedir } from "node:os";
@@ -77,7 +77,7 @@ function listKeychainServices() {
77
77
  */
78
78
  function readKeychainService(service) {
79
79
  try {
80
- const raw = execSync(`security find-generic-password -s "${service}" -w`, {
80
+ const raw = execFileSync("security", ["find-generic-password", "-s", service, "-w"], {
81
81
  timeout: 5_000,
82
82
  encoding: "utf-8",
83
83
  stdio: ["ignore", "pipe", "ignore"],
@@ -12,6 +12,7 @@ import {
12
12
 
13
13
  vi.mock("node:child_process", () => ({
14
14
  execSync: vi.fn(),
15
+ execFileSync: vi.fn(),
15
16
  }));
16
17
 
17
18
  vi.mock("node:fs", () => ({
@@ -23,7 +24,7 @@ vi.mock("node:os", () => ({
23
24
  homedir: vi.fn(() => "/home/testuser"),
24
25
  }));
25
26
 
26
- import { execSync } from "node:child_process";
27
+ import { execSync, execFileSync } from "node:child_process";
27
28
  import { readFileSync, existsSync } from "node:fs";
28
29
  import { homedir } from "node:os";
29
30
 
@@ -149,10 +150,14 @@ describe("readCCCredentialsFromKeychain", () => {
149
150
  it("reads single keychain service on macOS", () => {
150
151
  Object.defineProperty(process, "platform", { value: "darwin" });
151
152
 
153
+ // dump-keychain uses execSync, find-generic-password uses execFileSync
152
154
  execSync.mockImplementation((cmd) => {
153
155
  if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_SINGLE;
154
- if (cmd.includes("find-generic-password")) return JSON.stringify(VALID_CRED);
155
- throw new Error("unexpected cmd");
156
+ throw new Error("unexpected execSync cmd");
157
+ });
158
+ execFileSync.mockImplementation((_bin, args) => {
159
+ if (args && args.includes("find-generic-password")) return JSON.stringify(VALID_CRED);
160
+ throw new Error("unexpected execFileSync cmd");
156
161
  });
157
162
 
158
163
  const result = readCCCredentialsFromKeychain();
@@ -168,12 +173,15 @@ describe("readCCCredentialsFromKeychain", () => {
168
173
  let callIdx = 0;
169
174
  execSync.mockImplementation((cmd) => {
170
175
  if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_MULTI;
171
- if (cmd.includes("find-generic-password")) {
176
+ throw new Error("unexpected execSync cmd");
177
+ });
178
+ execFileSync.mockImplementation((_bin, args) => {
179
+ if (args && args.includes("find-generic-password")) {
172
180
  callIdx++;
173
181
  const cred = callIdx === 1 ? VALID_CRED : VALID_CRED_2;
174
182
  return JSON.stringify(cred);
175
183
  }
176
- throw new Error("unexpected cmd");
184
+ throw new Error("unexpected execFileSync cmd");
177
185
  });
178
186
 
179
187
  const result = readCCCredentialsFromKeychain();
@@ -197,13 +205,13 @@ describe("readCCCredentialsFromKeychain", () => {
197
205
 
198
206
  execSync.mockImplementation((cmd) => {
199
207
  if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_SINGLE;
200
- if (cmd.includes("find-generic-password")) {
201
- const err = new Error("not found");
202
- err.status = 44;
203
- throw err;
204
- }
205
208
  throw new Error("unexpected");
206
209
  });
210
+ execFileSync.mockImplementation(() => {
211
+ const err = new Error("not found");
212
+ err.status = 44;
213
+ throw err;
214
+ });
207
215
 
208
216
  expect(readCCCredentialsFromKeychain()).toEqual([]);
209
217
  });
@@ -317,11 +325,14 @@ describe("readCCCredentials", () => {
317
325
  it("combines keychain and file sources on macOS", () => {
318
326
  Object.defineProperty(process, "platform", { value: "darwin" });
319
327
 
320
- // Keychain returns one credential
328
+ // Keychain: dump-keychain via execSync, find-generic-password via execFileSync
321
329
  execSync.mockImplementation((cmd) => {
322
330
  if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_SINGLE;
323
- if (cmd.includes("find-generic-password")) return JSON.stringify(VALID_CRED);
324
- throw new Error("unexpected");
331
+ throw new Error("unexpected execSync cmd");
332
+ });
333
+ execFileSync.mockImplementation((_bin, args) => {
334
+ if (args && args.includes("find-generic-password")) return JSON.stringify(VALID_CRED);
335
+ throw new Error("unexpected execFileSync cmd");
325
336
  });
326
337
 
327
338
  // File returns a different credential
@@ -343,8 +354,11 @@ describe("readCCCredentials", () => {
343
354
 
344
355
  execSync.mockImplementation((cmd) => {
345
356
  if (cmd === "security dump-keychain") return KEYCHAIN_DUMP_SINGLE;
346
- if (cmd.includes("find-generic-password")) return JSON.stringify(keychainCred);
347
- throw new Error("unexpected");
357
+ throw new Error("unexpected execSync cmd");
358
+ });
359
+ execFileSync.mockImplementation((_bin, args) => {
360
+ if (args && args.includes("find-generic-password")) return JSON.stringify(keychainCred);
361
+ throw new Error("unexpected execFileSync cmd");
348
362
  });
349
363
 
350
364
  existsSync.mockReturnValue(true);
package/lib/config.mjs CHANGED
@@ -150,7 +150,7 @@ export const DEFAULT_CONFIG = {
150
150
  prefer_over_oauth: false,
151
151
  },
152
152
  adaptive_context: {
153
- enabled: false,
153
+ enabled: true,
154
154
  escalation_threshold: 150_000,
155
155
  deescalation_threshold: 100_000,
156
156
  },
@@ -417,6 +417,13 @@ function validateConfig(raw) {
417
417
  DEFAULT_CONFIG.adaptive_context.deescalation_threshold,
418
418
  ),
419
419
  };
420
+ // Cross-validate: deescalation must be strictly less than escalation to maintain hysteresis gap
421
+ if (config.adaptive_context.deescalation_threshold >= config.adaptive_context.escalation_threshold) {
422
+ config.adaptive_context.deescalation_threshold = Math.max(
423
+ 20_000,
424
+ config.adaptive_context.escalation_threshold - 50_000,
425
+ );
426
+ }
420
427
  }
421
428
 
422
429
  return config;
@@ -308,11 +308,11 @@ describe("loadConfig", () => {
308
308
 
309
309
  // --- adaptive_context config tests ---
310
310
 
311
- it("has adaptive_context defaults (disabled)", () => {
311
+ it("has adaptive_context defaults (enabled)", () => {
312
312
  existsSync.mockReturnValue(false);
313
313
  const config = loadConfig();
314
314
  expect(config.adaptive_context).toEqual({
315
- enabled: false,
315
+ enabled: true,
316
316
  escalation_threshold: 150_000,
317
317
  deescalation_threshold: 100_000,
318
318
  });
@@ -349,7 +349,7 @@ describe("loadConfig", () => {
349
349
  existsSync.mockReturnValue(true);
350
350
  readFileSync.mockReturnValue(JSON.stringify({ adaptive_context: { enabled: "yes" } }));
351
351
  const config = loadConfig();
352
- expect(config.adaptive_context.enabled).toBe(false);
352
+ expect(config.adaptive_context.enabled).toBe(true);
353
353
  });
354
354
 
355
355
  it("ignores non-number adaptive_context thresholds", () => {
package/lib/oauth.mjs CHANGED
@@ -13,7 +13,7 @@ const OAUTH_TOKEN_URL = `https://${OAUTH_CONSOLE_HOST}/v1/oauth/token`;
13
13
  const OAUTH_REVOKE_URL = `https://${OAUTH_CONSOLE_HOST}/v1/oauth/revoke`;
14
14
  // Real Claude Code uses axios 1.13.6 for OAuth calls — axios sets User-Agent
15
15
  // automatically and adds Accept header. We must match this fingerprint exactly.
16
- // CLI version profile: 2.1.83 (for reference; not sent on OAuth calls)
16
+ // CLI version profile: 2.1.84 (for reference; not sent on OAuth calls)
17
17
  const OAUTH_AXIOS_VERSION = "1.13.6";
18
18
  const OAUTH_USER_AGENT = `axios/${OAUTH_AXIOS_VERSION}`;
19
19
  const OAUTH_ACCEPT = "application/json, text/plain, */*";
package/lib/storage.mjs CHANGED
@@ -183,6 +183,10 @@ function validateAccount(raw, now) {
183
183
  typeof acc.consecutiveFailures === "number" ? Math.max(0, Math.floor(acc.consecutiveFailures)) : 0,
184
184
  lastFailureTime: typeof acc.lastFailureTime === "number" ? acc.lastFailureTime : null,
185
185
  lastSwitchReason: typeof acc.lastSwitchReason === "string" ? acc.lastSwitchReason : undefined,
186
+ source:
187
+ typeof acc.source === "string" && ["oauth", "cc-keychain", "cc-file"].includes(acc.source)
188
+ ? acc.source
189
+ : undefined,
186
190
  stats: validateStats(acc.stats, now),
187
191
  };
188
192
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.0.32",
3
+ "version": "0.0.34",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "main": "./index.mjs",
6
6
  "files": [