opencode-anthropic-fix 0.0.21 → 0.0.22

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/README.md CHANGED
@@ -298,7 +298,7 @@ Example workflow:
298
298
  Upload, list, download, and delete files via the Anthropic Files API. The `files-api-2025-04-14` beta is auto-included in every request.
299
299
 
300
300
  ```text
301
- /anthropic files # list uploaded files
301
+ /anthropic files # list files across ALL accounts
302
302
  /anthropic files upload ./report.pdf # upload a file
303
303
  /anthropic files get file_abc123 # get file metadata
304
304
  /anthropic files download file_abc123 # download to current directory
@@ -308,6 +308,16 @@ Upload, list, download, and delete files via the Anthropic Files API. The `files
308
308
 
309
309
  Supported formats: PDF, DOCX, TXT, CSV, Excel, Markdown, images (max 350 MB per file). Uploaded files can be referenced by `file_id` in Messages API requests.
310
310
 
311
+ **Multi-account behavior:** Files on Anthropic are per-account. With multiple accounts:
312
+
313
+ - `/anthropic files list` (no `--account`) queries **all** enabled accounts, labeling each file with its owner email
314
+ - Use `--account <email|index>` to target a specific account for any action:
315
+ ```text
316
+ /anthropic files list --account alice@example.com
317
+ /anthropic files upload ./data.csv --account 2
318
+ ```
319
+ - **Auto-pinning:** When you upload or list files, the plugin remembers which account owns each `file_id`. If a subsequent Messages API request references that `file_id`, the plugin automatically routes it to the correct account — even with round-robin or hybrid strategies.
320
+
311
321
  ### OAuth flows from slash command
312
322
 
313
323
  Login and reauth are two-step flows in slash mode:
@@ -333,11 +343,11 @@ Pending slash OAuth flows expire after 10 minutes. If completion fails with an e
333
343
 
334
344
  Control how the plugin picks which account to use for each request.
335
345
 
336
- | Strategy | Behavior | Best For |
337
- | --------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------- |
338
- | **`round-robin`** (default) | Rotate through accounts on every request | Spreading load evenly across accounts |
339
- | **`sticky`** | Stay on one account until it fails or is rate-limited | Single account, or when you want predictable behavior |
340
- | **`hybrid`** | Score-based selection with stickiness bias. Considers health, token budget, and freshness | Multiple accounts with varying rate limits |
346
+ | Strategy | Behavior | Best For |
347
+ | ---------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
348
+ | **`sticky`** (default) | Stay on one account until it fails or is rate-limited | Single account, predictable behavior, full feature compatibility |
349
+ | **`round-robin`** | Rotate through accounts on every request | Spreading load evenly across accounts (see [limitations](#round-robin-limitations)) |
350
+ | **`hybrid`** | Score-based selection with stickiness bias. Considers health, token budget, and freshness | Multiple accounts with varying rate limits |
341
351
 
342
352
  ### Change Strategy
343
353
 
@@ -352,14 +362,37 @@ export OPENCODE_ANTHROPIC_STRATEGY=hybrid
352
362
  # Edit ~/.config/opencode/anthropic-auth.json
353
363
  ```
354
364
 
365
+ ### Round-Robin Limitations
366
+
367
+ Some Anthropic API features maintain server-side per-account state that breaks when requests alternate between accounts:
368
+
369
+ | Feature | Impact | Plugin Mitigation |
370
+ | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
371
+ | **Files API** (`files-api-2025-04-14`) | `file_id` is per-account; referencing Account A's file from Account B = `file_not_found` | **Auto-pinning**: the plugin tracks which account owns each `file_id` and routes the request accordingly |
372
+ | **Prompt Caching** (`prompt-caching-scope-2026-01-05`) | Cache is per-workspace; alternating accounts means zero cache hits, doubling token costs | **Auto-skipped** in round-robin: the `prompt-caching-scope` beta is excluded from the header |
373
+ | **Code Execution** (`code-execution-2025-08-25`) | Sandbox state is ephemeral per-request; multi-step workflows lose files/state when routed to a different account | **Auto-skipped** in round-robin: the `code-execution` beta is excluded from the header |
374
+ | **Message Batches** (`message-batches-2024-09-24`) | `batch_id` is per-account; polling from wrong account = 404 | No automatic mitigation (not auto-included) |
375
+
376
+ **Recommendation for round-robin users:** If you need prompt caching or code execution, pin each OpenCode session to a single account:
377
+
378
+ ```bash
379
+ # Terminal 1 — uses account 1
380
+ OPENCODE_ANTHROPIC_INITIAL_ACCOUNT=1 opencode
381
+
382
+ # Terminal 2 — uses account 2
383
+ OPENCODE_ANTHROPIC_INITIAL_ACCOUNT=2 opencode
384
+ ```
385
+
386
+ This automatically overrides the strategy to `sticky` for that session, re-enabling all betas. Other sessions are unaffected.
387
+
355
388
  ## Configuration
356
389
 
357
390
  Configuration is stored at `~/.config/opencode/anthropic-auth.json`. All settings are optional &mdash; defaults work well for most users.
358
391
 
359
392
  ```jsonc
360
393
  {
361
- // Account selection strategy: "round-robin" | "sticky" | "hybrid"
362
- "account_selection_strategy": "round-robin",
394
+ // Account selection strategy: "sticky" | "round-robin" | "hybrid"
395
+ "account_selection_strategy": "sticky",
363
396
 
364
397
  // Seconds before consecutive failure count resets (60-7200)
365
398
  "failure_ttl_seconds": 3600,
@@ -427,16 +460,17 @@ Configuration is stored at `~/.config/opencode/anthropic-auth.json`. All setting
427
460
 
428
461
  ### Environment Variables
429
462
 
430
- | Variable | Description |
431
- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
432
- | `OPENCODE_ANTHROPIC_STRATEGY` | Override the account selection strategy at runtime. |
433
- | `OPENCODE_ANTHROPIC_DEBUG` | Set to `1` to enable debug logging. |
434
- | `OPENCODE_ANTHROPIC_QUIET` | Set to `1` to suppress non-error toasts (account status, switching). |
435
- | `OPENCODE_ANTHROPIC_EMULATE_CLAUDE_CODE_SIGNATURE` | Set to `0` to disable Claude signature emulation (legacy mode). |
436
- | `OPENCODE_ANTHROPIC_FETCH_CLAUDE_CODE_VERSION` | Set to `0` to skip npm version lookup at startup. |
437
- | `OPENCODE_ANTHROPIC_PROMPT_COMPACTION` | Set to `off` to disable default minimal system prompt compaction. |
438
- | `OPENCODE_ANTHROPIC_DEBUG_SYSTEM_PROMPT` | Set to `1` to log the final transformed `system` prompt to stderr (title-generator requests are skipped). |
439
- | `OPENCODE_ANTHROPIC_OVERRIDE_MODEL_LIMITS` | Set to `0` to disable context limit overrides for 1M-window models (e.g. when models.dev has been updated). |
463
+ | Variable | Description |
464
+ | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
465
+ | `OPENCODE_ANTHROPIC_STRATEGY` | Override the account selection strategy at runtime. |
466
+ | `OPENCODE_ANTHROPIC_DEBUG` | Set to `1` to enable debug logging. |
467
+ | `OPENCODE_ANTHROPIC_QUIET` | Set to `1` to suppress non-error toasts (account status, switching). |
468
+ | `OPENCODE_ANTHROPIC_EMULATE_CLAUDE_CODE_SIGNATURE` | Set to `0` to disable Claude signature emulation (legacy mode). |
469
+ | `OPENCODE_ANTHROPIC_FETCH_CLAUDE_CODE_VERSION` | Set to `0` to skip npm version lookup at startup. |
470
+ | `OPENCODE_ANTHROPIC_PROMPT_COMPACTION` | Set to `off` to disable default minimal system prompt compaction. |
471
+ | `OPENCODE_ANTHROPIC_DEBUG_SYSTEM_PROMPT` | Set to `1` to log the final transformed `system` prompt to stderr (title-generator requests are skipped). |
472
+ | `OPENCODE_ANTHROPIC_OVERRIDE_MODEL_LIMITS` | Set to `0` to disable context limit overrides for 1M-window models (e.g. when models.dev has been updated). |
473
+ | `OPENCODE_ANTHROPIC_INITIAL_ACCOUNT` | Pin this session to a specific account (1-based index or email). Overrides strategy to `sticky`. See [Round-Robin Limitations](#round-robin-limitations). |
440
474
 
441
475
  ### OAuth-only behavior
442
476
 
package/index.mjs CHANGED
@@ -662,9 +662,10 @@ function buildSystemPromptBlocks(system, signature, messages) {
662
662
  * @param {string} model
663
663
  * @param {"anthropic" | "bedrock" | "vertex" | "foundry"} provider
664
664
  * @param {string[]} [customBetas]
665
+ * @param {import('./lib/config.mjs').AccountSelectionStrategy} [strategy]
665
666
  * @returns {string}
666
667
  */
667
- function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provider, customBetas) {
668
+ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provider, customBetas, strategy) {
668
669
  const incomingBetasList = incomingBeta
669
670
  .split(",")
670
671
  .map((b) => b.trim())
@@ -679,13 +680,19 @@ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provide
679
680
 
680
681
  const nonInteractive = isNonInteractiveMode();
681
682
  const haiku = isHaikuModel(model);
683
+ const isRoundRobin = strategy === "round-robin";
682
684
 
683
685
  if (!haiku) {
684
686
  betas.push(CLAUDE_CODE_BETA_FLAG);
685
- betas.push("code-execution-2025-08-25");
687
+ // Code execution sandbox state is per-account; skip in round-robin to avoid
688
+ // losing sandbox state mid-workflow when requests rotate between accounts.
689
+ if (!isRoundRobin) {
690
+ betas.push("code-execution-2025-08-25");
691
+ }
686
692
  }
687
693
 
688
694
  // Files API is model-independent; enables /v1/files endpoint and file_id references.
695
+ // Safe in round-robin because file-ID account pinning routes requests to the correct account.
689
696
  betas.push("files-api-2025-04-14");
690
697
 
691
698
  if (isOpus46Model(model)) {
@@ -723,7 +730,9 @@ function buildAnthropicBetaHeader(incomingBeta, signatureEnabled, model, provide
723
730
  betas.push("web-search-2025-03-05");
724
731
  }
725
732
 
726
- if (nonInteractive) {
733
+ // Prompt caching is per-workspace (since Feb 2026); round-robin across accounts
734
+ // means zero cache hits and doubled token costs. Skip in round-robin.
735
+ if (nonInteractive && !isRoundRobin) {
727
736
  betas.push("prompt-caching-scope-2026-01-05");
728
737
  }
729
738
 
@@ -864,7 +873,7 @@ async function fetchLatestClaudeCodeVersion(timeoutMs = 1200) {
864
873
  * @param {string} accessToken
865
874
  * @param {string | undefined} requestBody
866
875
  * @param {URL | null} requestUrl
867
- * @param {{enabled: boolean, claudeCliVersion: string}} signature
876
+ * @param {{enabled: boolean, claudeCliVersion: string, strategy?: import('./lib/config.mjs').AccountSelectionStrategy}} signature
868
877
  * @returns {Headers}
869
878
  */
870
879
  function buildRequestHeaders(input, requestInit, accessToken, requestBody, requestUrl, signature) {
@@ -898,7 +907,14 @@ function buildRequestHeaders(input, requestInit, accessToken, requestBody, reque
898
907
  const incomingBeta = requestHeaders.get("anthropic-beta") || "";
899
908
  const { model, tools, messages } = parseRequestBodyMetadata(requestBody);
900
909
  const provider = detectProvider(requestUrl);
901
- const mergedBetas = buildAnthropicBetaHeader(incomingBeta, signature.enabled, model, provider, signature.customBetas);
910
+ const mergedBetas = buildAnthropicBetaHeader(
911
+ incomingBeta,
912
+ signature.enabled,
913
+ model,
914
+ provider,
915
+ signature.customBetas,
916
+ signature.strategy,
917
+ );
902
918
 
903
919
  const authTokenOverride = process.env.ANTHROPIC_AUTH_TOKEN?.trim();
904
920
  const bearerToken = authTokenOverride || accessToken;
@@ -1566,6 +1582,30 @@ function parseCommandArgs(raw) {
1566
1582
  return parts;
1567
1583
  }
1568
1584
 
1585
+ /**
1586
+ * Extract all file_id values from a parsed Messages API request body.
1587
+ * Walks messages and system arrays looking for file content blocks.
1588
+ * @param {any} body - Parsed JSON body
1589
+ * @returns {string[]}
1590
+ */
1591
+ function extractFileIds(body) {
1592
+ const ids = [];
1593
+ if (!body || typeof body !== "object") return ids;
1594
+ function walk(obj) {
1595
+ if (Array.isArray(obj)) {
1596
+ for (const item of obj) walk(item);
1597
+ } else if (obj && typeof obj === "object") {
1598
+ if (obj.source?.file_id) ids.push(obj.source.file_id);
1599
+ for (const val of Object.values(obj)) {
1600
+ if (val && typeof val === "object") walk(val);
1601
+ }
1602
+ }
1603
+ }
1604
+ walk(body.messages);
1605
+ walk(body.system);
1606
+ return ids;
1607
+ }
1608
+
1569
1609
  /**
1570
1610
  * @type {import('@opencode-ai/plugin').Plugin}
1571
1611
  */
@@ -1596,12 +1636,27 @@ export async function AnthropicAuthPlugin({ client }) {
1596
1636
  const IDLE_REFRESH_WINDOW_MS = config.idle_refresh.window_minutes * 60 * 1000;
1597
1637
  const IDLE_REFRESH_MIN_INTERVAL_MS = config.idle_refresh.min_interval_minutes * 60 * 1000;
1598
1638
 
1639
+ /**
1640
+ * Whether OPENCODE_ANTHROPIC_INITIAL_ACCOUNT env var pinned this session to a
1641
+ * specific account. When true, syncActiveIndexFromDisk is skipped and strategy
1642
+ * is forced to sticky.
1643
+ */
1644
+ let initialAccountPinned = false;
1645
+
1599
1646
  /**
1600
1647
  * Pending slash-command OAuth flows keyed by session ID.
1601
1648
  * @type {Map<string, { mode: "login" | "reauth", verifier: string, targetIndex?: number, createdAt: number }>}
1602
1649
  */
1603
1650
  const pendingSlashOAuth = new Map();
1604
1651
 
1652
+ /**
1653
+ * In-memory mapping of file_id → account index for file-ID account pinning.
1654
+ * Populated by /anthropic files commands, consumed by the fetch interceptor
1655
+ * to route Messages API requests referencing file_ids to the correct account.
1656
+ * @type {Map<string, number>}
1657
+ */
1658
+ const fileAccountMap = new Map();
1659
+
1605
1660
  /**
1606
1661
  * Send an informational message into the current session.
1607
1662
  * @param {string} sessionID
@@ -1978,6 +2033,7 @@ export async function AnthropicAuthPlugin({ client }) {
1978
2033
 
1979
2034
  if (!action || action === "list") {
1980
2035
  const fresh = loadConfigFresh();
2036
+ const strategy = fresh.account_selection_strategy || config.account_selection_strategy;
1981
2037
  const lines = [
1982
2038
  "▣ Anthropic Betas",
1983
2039
  "",
@@ -1985,15 +2041,16 @@ export async function AnthropicAuthPlugin({ client }) {
1985
2041
  " oauth-2025-04-20, claude-code-20250219,",
1986
2042
  " interleaved-thinking-2025-05-14 OR adaptive-thinking-2026-01-28,",
1987
2043
  " fine-grained-tool-streaming-2025-05-14,",
1988
- " code-execution-2025-08-25 (non-Haiku),",
1989
- " files-api-2025-04-14",
2044
+ ` code-execution-2025-08-25 (non-Haiku${strategy === "round-robin" ? ", skipped in round-robin" : ""}),`,
2045
+ " files-api-2025-04-14,",
2046
+ ` prompt-caching-scope-2026-01-05 (non-interactive${strategy === "round-robin" ? ", skipped in round-robin" : ""})`,
1990
2047
  "",
2048
+ `Strategy: ${strategy}${initialAccountPinned ? " (pinned via OPENCODE_ANTHROPIC_INITIAL_ACCOUNT)" : ""}`,
1991
2049
  `Custom betas: ${fresh.custom_betas.length ? fresh.custom_betas.join(", ") : "(none)"}`,
1992
2050
  "",
1993
2051
  "Toggleable presets:",
1994
2052
  " /anthropic betas add structured-outputs-2025-12-15",
1995
2053
  " /anthropic betas add context-management-2025-06-27",
1996
- " /anthropic betas add prompt-caching-scope-2026-01-05",
1997
2054
  " /anthropic betas add tool-examples-2025-10-29",
1998
2055
  " /anthropic betas add web-search-2025-03-05",
1999
2056
  " /anthropic betas add compact-2026-01-12",
@@ -2046,12 +2103,23 @@ export async function AnthropicAuthPlugin({ client }) {
2046
2103
  }
2047
2104
 
2048
2105
  // /anthropic files [list|upload|get|delete|download] — Files API management
2106
+ // Supports --account <email|index> to target a specific account.
2107
+ // Without --account, list aggregates from ALL accounts; other actions use the current account.
2049
2108
  if (primary === "files") {
2050
- const action = (args[1] || "").toLowerCase();
2109
+ // Parse --account flag from args
2110
+ let targetAccountId = null;
2111
+ const filteredArgs = [];
2112
+ for (let i = 0; i < args.length; i++) {
2113
+ if (args[i] === "--account" && i + 1 < args.length) {
2114
+ targetAccountId = args[i + 1];
2115
+ i++;
2116
+ } else {
2117
+ filteredArgs.push(args[i]);
2118
+ }
2119
+ }
2120
+ const action = (filteredArgs[1] || "").toLowerCase();
2051
2121
 
2052
- // Get current account and ensure valid token
2053
- const account = accountManager?.getCurrentAccount();
2054
- if (!account) {
2122
+ if (!accountManager || accountManager.getAccountCount() === 0) {
2055
2123
  await sendCommandMessage(
2056
2124
  input.sessionID,
2057
2125
  "▣ Anthropic Files (error)\n\nNo accounts configured. Use /anthropic login first.",
@@ -2059,54 +2127,152 @@ export async function AnthropicAuthPlugin({ client }) {
2059
2127
  return;
2060
2128
  }
2061
2129
 
2062
- let token = account.access;
2063
- if (!token || !account.expires || account.expires < Date.now()) {
2064
- try {
2065
- token = await refreshAccountTokenSingleFlight(account);
2066
- } catch (err) {
2067
- await sendCommandMessage(
2068
- input.sessionID,
2069
- `▣ Anthropic Files (error)\n\nToken refresh failed: ${err.message}`,
2070
- );
2071
- return;
2130
+ /**
2131
+ * Resolve a single account by email or 1-based index.
2132
+ * If identifier is null, falls back to the current account.
2133
+ * @param {string | null} identifier
2134
+ * @returns {{ account: import('./lib/accounts.mjs').ManagedAccount, label: string } | null}
2135
+ */
2136
+ function resolveTargetAccount(identifier) {
2137
+ const accounts = accountManager.getEnabledAccounts();
2138
+ if (identifier) {
2139
+ // Try by email
2140
+ const byEmail = accounts.find((a) => a.email === identifier);
2141
+ if (byEmail) return { account: byEmail, label: byEmail.email || `Account ${byEmail.index + 1}` };
2142
+ // Try by 1-based index
2143
+ const idx = parseInt(identifier, 10);
2144
+ if (!isNaN(idx) && idx >= 1) {
2145
+ const byIdx = accounts.find((a) => a.index === idx - 1);
2146
+ if (byIdx) return { account: byIdx, label: byIdx.email || `Account ${byIdx.index + 1}` };
2147
+ }
2148
+ return null;
2149
+ }
2150
+ // Default to current
2151
+ const current = accountManager.getCurrentAccount();
2152
+ if (!current) return null;
2153
+ return { account: current, label: current.email || `Account ${current.index + 1}` };
2154
+ }
2155
+
2156
+ /**
2157
+ * Get authenticated headers for a specific account, refreshing token if needed.
2158
+ * @param {import('./lib/accounts.mjs').ManagedAccount} acct
2159
+ */
2160
+ async function getFilesAuth(acct) {
2161
+ let tok = acct.access;
2162
+ if (!tok || !acct.expires || acct.expires < Date.now()) {
2163
+ tok = await refreshAccountTokenSingleFlight(acct);
2072
2164
  }
2165
+ return {
2166
+ authorization: `Bearer ${tok}`,
2167
+ "anthropic-beta": "files-api-2025-04-14",
2168
+ };
2073
2169
  }
2074
2170
 
2075
2171
  const apiBase = "https://api.anthropic.com";
2076
- const authHeaders = {
2077
- authorization: `Bearer ${token}`,
2078
- "anthropic-beta": "files-api-2025-04-14",
2079
- };
2080
2172
 
2081
2173
  try {
2082
- // /anthropic files list — list all uploaded files
2174
+ // /anthropic files list — list uploaded files
2083
2175
  if (!action || action === "list") {
2084
- const res = await fetch(`${apiBase}/v1/files`, { headers: authHeaders });
2085
- if (!res.ok) {
2086
- const errBody = await res.text();
2087
- await sendCommandMessage(input.sessionID, `▣ Anthropic Files (error)\n\nHTTP ${res.status}: ${errBody}`);
2176
+ if (targetAccountId) {
2177
+ // List for a specific account
2178
+ const resolved = resolveTargetAccount(targetAccountId);
2179
+ if (!resolved) {
2180
+ await sendCommandMessage(
2181
+ input.sessionID,
2182
+ `▣ Anthropic Files (error)\n\nAccount not found: ${targetAccountId}`,
2183
+ );
2184
+ return;
2185
+ }
2186
+ const { account, label } = resolved;
2187
+ const headers = await getFilesAuth(account);
2188
+ const res = await fetch(`${apiBase}/v1/files`, { headers });
2189
+ if (!res.ok) {
2190
+ const errBody = await res.text();
2191
+ await sendCommandMessage(
2192
+ input.sessionID,
2193
+ `▣ Anthropic Files (error) [${label}]\n\nHTTP ${res.status}: ${errBody}`,
2194
+ );
2195
+ return;
2196
+ }
2197
+ const data = await res.json();
2198
+ const files = data.data || [];
2199
+ for (const f of files) fileAccountMap.set(f.id, account.index);
2200
+ if (files.length === 0) {
2201
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Files [${label}]\n\nNo files uploaded.`);
2202
+ return;
2203
+ }
2204
+ const lines = [`▣ Anthropic Files [${label}]`, "", `${files.length} file(s):`, ""];
2205
+ for (const f of files) {
2206
+ const sizeKB = (f.size / 1024).toFixed(1);
2207
+ lines.push(` ${f.id} ${f.filename} (${sizeKB} KB, ${f.purpose})`);
2208
+ }
2209
+ await sendCommandMessage(input.sessionID, lines.join("\n"));
2088
2210
  return;
2089
2211
  }
2090
- const data = await res.json();
2091
- const files = data.data || [];
2092
- if (files.length === 0) {
2093
- await sendCommandMessage(input.sessionID, "▣ Anthropic Files\n\nNo files uploaded.");
2094
- return;
2212
+
2213
+ // List files from ALL enabled accounts
2214
+ const accounts = accountManager.getEnabledAccounts();
2215
+ const allLines = ["▣ Anthropic Files (all accounts)", ""];
2216
+ let totalFiles = 0;
2217
+ for (const acct of accounts) {
2218
+ const label = acct.email || `Account ${acct.index + 1}`;
2219
+ try {
2220
+ const headers = await getFilesAuth(acct);
2221
+ const res = await fetch(`${apiBase}/v1/files`, { headers });
2222
+ if (!res.ok) {
2223
+ allLines.push(`[${label}] Error: HTTP ${res.status}`);
2224
+ allLines.push("");
2225
+ continue;
2226
+ }
2227
+ const data = await res.json();
2228
+ const files = data.data || [];
2229
+ for (const f of files) fileAccountMap.set(f.id, acct.index);
2230
+ totalFiles += files.length;
2231
+ if (files.length === 0) {
2232
+ allLines.push(`[${label}] No files`);
2233
+ } else {
2234
+ allLines.push(`[${label}] ${files.length} file(s):`);
2235
+ for (const f of files) {
2236
+ const sizeKB = (f.size / 1024).toFixed(1);
2237
+ allLines.push(` ${f.id} ${f.filename} (${sizeKB} KB, ${f.purpose})`);
2238
+ }
2239
+ }
2240
+ allLines.push("");
2241
+ } catch (err) {
2242
+ allLines.push(`[${label}] Error: ${err.message}`);
2243
+ allLines.push("");
2244
+ }
2095
2245
  }
2096
- const lines = ["▣ Anthropic Files", "", `${files.length} file(s):`, ""];
2097
- for (const f of files) {
2098
- const sizeKB = (f.size / 1024).toFixed(1);
2099
- lines.push(` ${f.id} ${f.filename} (${sizeKB} KB, ${f.purpose})`);
2246
+ if (totalFiles === 0 && accounts.length > 0) {
2247
+ allLines.push(`Total: No files across ${accounts.length} account(s).`);
2248
+ } else {
2249
+ allLines.push(`Total: ${totalFiles} file(s) across ${accounts.length} account(s).`);
2100
2250
  }
2101
- await sendCommandMessage(input.sessionID, lines.join("\n"));
2251
+ if (accounts.length > 1) {
2252
+ allLines.push("", "Tip: Use --account <email> to target a specific account.");
2253
+ }
2254
+ await sendCommandMessage(input.sessionID, allLines.join("\n"));
2102
2255
  return;
2103
2256
  }
2104
2257
 
2258
+ // For all non-list actions, resolve to a single account
2259
+ const resolved = resolveTargetAccount(targetAccountId);
2260
+ if (!resolved) {
2261
+ const errMsg = targetAccountId ? `Account not found: ${targetAccountId}` : "No accounts available.";
2262
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Files (error)\n\n${errMsg}`);
2263
+ return;
2264
+ }
2265
+ const { account, label } = resolved;
2266
+ const authHeaders = await getFilesAuth(account);
2267
+
2105
2268
  // /anthropic files upload <path> — upload a file
2106
2269
  if (action === "upload") {
2107
- const filePath = args.slice(2).join(" ").trim();
2270
+ const filePath = filteredArgs.slice(2).join(" ").trim();
2108
2271
  if (!filePath) {
2109
- await sendCommandMessage(input.sessionID, "▣ Anthropic Files\n\nUsage: /anthropic files upload <path>");
2272
+ await sendCommandMessage(
2273
+ input.sessionID,
2274
+ "▣ Anthropic Files\n\nUsage: /anthropic files upload <path> [--account <email>]",
2275
+ );
2110
2276
  return;
2111
2277
  }
2112
2278
  const resolvedPath = resolve(filePath);
@@ -2124,7 +2290,7 @@ export async function AnthropicAuthPlugin({ client }) {
2124
2290
  const res = await fetch(`${apiBase}/v1/files`, {
2125
2291
  method: "POST",
2126
2292
  headers: {
2127
- authorization: `Bearer ${token}`,
2293
+ authorization: authHeaders.authorization,
2128
2294
  "anthropic-beta": "files-api-2025-04-14",
2129
2295
  },
2130
2296
  body: form,
@@ -2133,35 +2299,44 @@ export async function AnthropicAuthPlugin({ client }) {
2133
2299
  const errBody = await res.text();
2134
2300
  await sendCommandMessage(
2135
2301
  input.sessionID,
2136
- `▣ Anthropic Files (error)\n\nUpload failed (HTTP ${res.status}): ${errBody}`,
2302
+ `▣ Anthropic Files (error) [${label}]\n\nUpload failed (HTTP ${res.status}): ${errBody}`,
2137
2303
  );
2138
2304
  return;
2139
2305
  }
2140
2306
  const file = await res.json();
2141
2307
  const sizeKB = ((file.size || 0) / 1024).toFixed(1);
2308
+ // Cache file_id → account mapping for auto-pinning
2309
+ fileAccountMap.set(file.id, account.index);
2142
2310
  await sendCommandMessage(
2143
2311
  input.sessionID,
2144
- `▣ Anthropic Files\n\nUploaded: ${file.id}\n Filename: ${file.filename}\n Size: ${sizeKB} KB`,
2312
+ `▣ Anthropic Files [${label}]\n\nUploaded: ${file.id}\n Filename: ${file.filename}\n Size: ${sizeKB} KB`,
2145
2313
  );
2146
2314
  return;
2147
2315
  }
2148
2316
 
2149
2317
  // /anthropic files get <file_id> — get file metadata
2150
2318
  if (action === "get" || action === "info") {
2151
- const fileId = args[2]?.trim();
2319
+ const fileId = filteredArgs[2]?.trim();
2152
2320
  if (!fileId) {
2153
- await sendCommandMessage(input.sessionID, "▣ Anthropic Files\n\nUsage: /anthropic files get <file_id>");
2321
+ await sendCommandMessage(
2322
+ input.sessionID,
2323
+ "▣ Anthropic Files\n\nUsage: /anthropic files get <file_id> [--account <email>]",
2324
+ );
2154
2325
  return;
2155
2326
  }
2156
2327
  const res = await fetch(`${apiBase}/v1/files/${encodeURIComponent(fileId)}`, { headers: authHeaders });
2157
2328
  if (!res.ok) {
2158
2329
  const errBody = await res.text();
2159
- await sendCommandMessage(input.sessionID, `▣ Anthropic Files (error)\n\nHTTP ${res.status}: ${errBody}`);
2330
+ await sendCommandMessage(
2331
+ input.sessionID,
2332
+ `▣ Anthropic Files (error) [${label}]\n\nHTTP ${res.status}: ${errBody}`,
2333
+ );
2160
2334
  return;
2161
2335
  }
2162
2336
  const file = await res.json();
2337
+ fileAccountMap.set(file.id, account.index);
2163
2338
  const lines = [
2164
- "▣ Anthropic Files",
2339
+ `▣ Anthropic Files [${label}]`,
2165
2340
  "",
2166
2341
  ` ID: ${file.id}`,
2167
2342
  ` Filename: ${file.filename}`,
@@ -2176,9 +2351,12 @@ export async function AnthropicAuthPlugin({ client }) {
2176
2351
 
2177
2352
  // /anthropic files delete <file_id> — delete a file
2178
2353
  if (action === "delete" || action === "rm") {
2179
- const fileId = args[2]?.trim();
2354
+ const fileId = filteredArgs[2]?.trim();
2180
2355
  if (!fileId) {
2181
- await sendCommandMessage(input.sessionID, "▣ Anthropic Files\n\nUsage: /anthropic files delete <file_id>");
2356
+ await sendCommandMessage(
2357
+ input.sessionID,
2358
+ "▣ Anthropic Files\n\nUsage: /anthropic files delete <file_id> [--account <email>]",
2359
+ );
2182
2360
  return;
2183
2361
  }
2184
2362
  const res = await fetch(`${apiBase}/v1/files/${encodeURIComponent(fileId)}`, {
@@ -2187,24 +2365,28 @@ export async function AnthropicAuthPlugin({ client }) {
2187
2365
  });
2188
2366
  if (!res.ok) {
2189
2367
  const errBody = await res.text();
2190
- await sendCommandMessage(input.sessionID, `▣ Anthropic Files (error)\n\nHTTP ${res.status}: ${errBody}`);
2368
+ await sendCommandMessage(
2369
+ input.sessionID,
2370
+ `▣ Anthropic Files (error) [${label}]\n\nHTTP ${res.status}: ${errBody}`,
2371
+ );
2191
2372
  return;
2192
2373
  }
2193
- await sendCommandMessage(input.sessionID, `▣ Anthropic Files\n\nDeleted: ${fileId}`);
2374
+ fileAccountMap.delete(fileId);
2375
+ await sendCommandMessage(input.sessionID, `▣ Anthropic Files [${label}]\n\nDeleted: ${fileId}`);
2194
2376
  return;
2195
2377
  }
2196
2378
 
2197
2379
  // /anthropic files download <file_id> [output_path] — download file content
2198
2380
  if (action === "download" || action === "dl") {
2199
- const fileId = args[2]?.trim();
2381
+ const fileId = filteredArgs[2]?.trim();
2200
2382
  if (!fileId) {
2201
2383
  await sendCommandMessage(
2202
2384
  input.sessionID,
2203
- "▣ Anthropic Files\n\nUsage: /anthropic files download <file_id> [output_path]",
2385
+ "▣ Anthropic Files\n\nUsage: /anthropic files download <file_id> [output_path] [--account <email>]",
2204
2386
  );
2205
2387
  return;
2206
2388
  }
2207
- const outputPath = args.slice(3).join(" ").trim();
2389
+ const outputPath = filteredArgs.slice(3).join(" ").trim();
2208
2390
 
2209
2391
  // Get file metadata first for the filename
2210
2392
  const metaRes = await fetch(`${apiBase}/v1/files/${encodeURIComponent(fileId)}`, {
@@ -2214,7 +2396,7 @@ export async function AnthropicAuthPlugin({ client }) {
2214
2396
  const errBody = await metaRes.text();
2215
2397
  await sendCommandMessage(
2216
2398
  input.sessionID,
2217
- `▣ Anthropic Files (error)\n\nHTTP ${metaRes.status}: ${errBody}`,
2399
+ `▣ Anthropic Files (error) [${label}]\n\nHTTP ${metaRes.status}: ${errBody}`,
2218
2400
  );
2219
2401
  return;
2220
2402
  }
@@ -2229,7 +2411,7 @@ export async function AnthropicAuthPlugin({ client }) {
2229
2411
  const errBody = await res.text();
2230
2412
  await sendCommandMessage(
2231
2413
  input.sessionID,
2232
- `▣ Anthropic Files (error)\n\nDownload failed (HTTP ${res.status}): ${errBody}`,
2414
+ `▣ Anthropic Files (error) [${label}]\n\nDownload failed (HTTP ${res.status}): ${errBody}`,
2233
2415
  );
2234
2416
  return;
2235
2417
  }
@@ -2238,7 +2420,7 @@ export async function AnthropicAuthPlugin({ client }) {
2238
2420
  const sizeKB = (buffer.length / 1024).toFixed(1);
2239
2421
  await sendCommandMessage(
2240
2422
  input.sessionID,
2241
- `▣ Anthropic Files\n\nDownloaded: ${meta.filename}\n Saved to: ${savePath}\n Size: ${sizeKB} KB`,
2423
+ `▣ Anthropic Files [${label}]\n\nDownloaded: ${meta.filename}\n Saved to: ${savePath}\n Size: ${sizeKB} KB`,
2242
2424
  );
2243
2425
  return;
2244
2426
  }
@@ -2247,17 +2429,23 @@ export async function AnthropicAuthPlugin({ client }) {
2247
2429
  const helpLines = [
2248
2430
  "▣ Anthropic Files",
2249
2431
  "",
2250
- "Usage: /anthropic files <action>",
2432
+ "Usage: /anthropic files <action> [--account <email|index>]",
2251
2433
  "",
2252
2434
  "Actions:",
2253
- " list List uploaded files",
2435
+ " list List uploaded files (all accounts if no --account)",
2254
2436
  " upload <path> Upload a file (max 350MB)",
2255
2437
  " get <file_id> Get file metadata",
2256
2438
  " delete <file_id> Delete a file",
2257
2439
  " download <file_id> [path] Download file content",
2258
2440
  "",
2441
+ "Options:",
2442
+ " --account <email|index> Target a specific account (1-based index)",
2443
+ "",
2259
2444
  "Supported formats: PDF, DOCX, TXT, CSV, Excel, Markdown, images",
2260
2445
  "Files can be referenced by file_id in Messages API requests.",
2446
+ "",
2447
+ "When using round-robin, file_ids are automatically pinned to the",
2448
+ "account that owns them for Messages API requests.",
2261
2449
  ];
2262
2450
  await sendCommandMessage(input.sessionID, helpLines.join("\n"));
2263
2451
  return;
@@ -2584,6 +2772,39 @@ export async function AnthropicAuthPlugin({ client }) {
2584
2772
  await accountManager.saveToDisk();
2585
2773
  }
2586
2774
 
2775
+ // OPENCODE_ANTHROPIC_INITIAL_ACCOUNT: pin this session to a specific account.
2776
+ // Accepts 1-based index or email. Overrides strategy to sticky and disables
2777
+ // syncActiveIndexFromDisk so other sessions can't override this one.
2778
+ // Use case: terminal 1 with INITIAL_ACCOUNT=1, terminal 2 with =2.
2779
+ const initialAccountEnv = process.env.OPENCODE_ANTHROPIC_INITIAL_ACCOUNT?.trim();
2780
+ if (initialAccountEnv && accountManager.getAccountCount() > 1) {
2781
+ const accounts = accountManager.getEnabledAccounts();
2782
+ let target = null;
2783
+
2784
+ // Try as 1-based index
2785
+ const asIndex = parseInt(initialAccountEnv, 10);
2786
+ if (!isNaN(asIndex) && asIndex >= 1 && asIndex <= accounts.length) {
2787
+ target = accounts[asIndex - 1];
2788
+ }
2789
+
2790
+ // Try as email
2791
+ if (!target) {
2792
+ target = accounts.find((a) => a.email && a.email.toLowerCase() === initialAccountEnv.toLowerCase());
2793
+ }
2794
+
2795
+ if (target && accountManager.forceCurrentIndex(target.index)) {
2796
+ config.account_selection_strategy = "sticky";
2797
+ initialAccountPinned = true;
2798
+ debugLog("OPENCODE_ANTHROPIC_INITIAL_ACCOUNT: pinned to account", {
2799
+ index: target.index + 1,
2800
+ email: target.email,
2801
+ strategy: "sticky (overridden)",
2802
+ });
2803
+ } else {
2804
+ debugLog("OPENCODE_ANTHROPIC_INITIAL_ACCOUNT: could not resolve account", initialAccountEnv);
2805
+ }
2806
+ }
2807
+
2587
2808
  return {
2588
2809
  apiKey: "",
2589
2810
  /**
@@ -2612,7 +2833,9 @@ export async function AnthropicAuthPlugin({ client }) {
2612
2833
  const transientRefreshSkips = new Set();
2613
2834
 
2614
2835
  // Sync with CLI changes at request start.
2615
- if (accountManager) {
2836
+ // Skip when OPENCODE_ANTHROPIC_INITIAL_ACCOUNT pinned this session —
2837
+ // other sessions' CLI changes must not override the pinned account.
2838
+ if (accountManager && !initialAccountPinned) {
2616
2839
  await accountManager.syncActiveIndexFromDisk();
2617
2840
  }
2618
2841
 
@@ -2620,9 +2843,42 @@ export async function AnthropicAuthPlugin({ client }) {
2620
2843
  // switch to the next account. If it's service-wide, return immediately.
2621
2844
  const maxAttempts = accountManager.getTotalAccountCount();
2622
2845
 
2846
+ // File-ID account pinning: if the request body references file_ids
2847
+ // that we've mapped to a specific account (via /anthropic files),
2848
+ // pin the first attempt to that account so files are accessible.
2849
+ // Without this, round-robin could route to an account that doesn't
2850
+ // have the referenced files, causing file_not_found errors.
2851
+ let pinnedAccount = null;
2852
+ if (typeof requestInit.body === "string" && fileAccountMap.size > 0) {
2853
+ try {
2854
+ const bodyObj = JSON.parse(requestInit.body);
2855
+ const fileIds = extractFileIds(bodyObj);
2856
+ for (const fid of fileIds) {
2857
+ const pinnedIndex = fileAccountMap.get(fid);
2858
+ if (pinnedIndex !== undefined) {
2859
+ const candidates = accountManager.getEnabledAccounts();
2860
+ pinnedAccount = candidates.find((a) => a.index === pinnedIndex) ?? null;
2861
+ if (pinnedAccount) {
2862
+ debugLog("file-id pinning: routing to account", {
2863
+ fileId: fid,
2864
+ accountIndex: pinnedIndex,
2865
+ email: pinnedAccount.email,
2866
+ });
2867
+ break;
2868
+ }
2869
+ }
2870
+ }
2871
+ } catch {
2872
+ // Non-JSON body or parse error — skip pinning
2873
+ }
2874
+ }
2875
+
2623
2876
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
2624
- // Select account
2625
- const account = accountManager.getCurrentAccount(transientRefreshSkips);
2877
+ // Select account — use pinned account on first attempt if available
2878
+ const account =
2879
+ attempt === 0 && pinnedAccount && !transientRefreshSkips.has(pinnedAccount.index)
2880
+ ? pinnedAccount
2881
+ : accountManager.getCurrentAccount(transientRefreshSkips);
2626
2882
 
2627
2883
  // Toast account usage on first use and whenever the account changes
2628
2884
  if (showUsageToast && account && accountManager) {
@@ -2756,6 +3012,7 @@ export async function AnthropicAuthPlugin({ client }) {
2756
3012
  enabled: signatureEmulationEnabled,
2757
3013
  claudeCliVersion,
2758
3014
  customBetas: config.custom_betas,
3015
+ strategy: config.account_selection_strategy,
2759
3016
  });
2760
3017
  // Execute the request
2761
3018
  let response;
package/lib/accounts.mjs CHANGED
@@ -193,6 +193,20 @@ export class AccountManager {
193
193
  return this.#currentIndex;
194
194
  }
195
195
 
196
+ /**
197
+ * Force the active account to a specific index.
198
+ * Used by OPENCODE_ANTHROPIC_INITIAL_ACCOUNT to pin a session to one account.
199
+ * @param {number} index
200
+ * @returns {boolean} Whether the index was valid and set
201
+ */
202
+ forceCurrentIndex(index) {
203
+ const account = this.#accounts[index];
204
+ if (!account || !account.enabled) return false;
205
+ this.#currentIndex = index;
206
+ this.#cursor = index;
207
+ return true;
208
+ }
209
+
196
210
  /**
197
211
  * Get enabled account references for internal plugin operations.
198
212
  * Returned objects are mutable managed accounts.
package/lib/config.mjs CHANGED
@@ -61,7 +61,7 @@ import { homedir } from "node:os";
61
61
 
62
62
  /** @type {AnthropicAuthConfig} */
63
63
  export const DEFAULT_CONFIG = {
64
- account_selection_strategy: "round-robin",
64
+ account_selection_strategy: "sticky",
65
65
  failure_ttl_seconds: 3600,
66
66
  debug: false,
67
67
  signature_emulation: {
@@ -10,7 +10,7 @@ vi.mock("node:fs", () => ({
10
10
 
11
11
  describe("DEFAULT_CONFIG", () => {
12
12
  it("has expected default strategy", () => {
13
- expect(DEFAULT_CONFIG.account_selection_strategy).toBe("round-robin");
13
+ expect(DEFAULT_CONFIG.account_selection_strategy).toBe("sticky");
14
14
  });
15
15
 
16
16
  it("has expected health score defaults", () => {
@@ -114,7 +114,7 @@ describe("loadConfig", () => {
114
114
  existsSync.mockReturnValue(true);
115
115
  readFileSync.mockReturnValue(JSON.stringify({ account_selection_strategy: "invalid" }));
116
116
  const config = loadConfig();
117
- expect(config.account_selection_strategy).toBe("round-robin");
117
+ expect(config.account_selection_strategy).toBe("sticky");
118
118
  });
119
119
 
120
120
  it("accepts boolean debug", () => {
@@ -199,7 +199,7 @@ describe("loadConfig", () => {
199
199
  existsSync.mockReturnValue(false);
200
200
  process.env.OPENCODE_ANTHROPIC_STRATEGY = "invalid";
201
201
  const config = loadConfig();
202
- expect(config.account_selection_strategy).toBe("round-robin");
202
+ expect(config.account_selection_strategy).toBe("sticky");
203
203
  });
204
204
 
205
205
  it("enables debug from OPENCODE_ANTHROPIC_DEBUG=1", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-anthropic-fix",
3
- "version": "0.0.21",
3
+ "version": "0.0.22",
4
4
  "main": "./index.mjs",
5
5
  "files": [
6
6
  "index.mjs",