@bitkyc08/opencodex 2.43.0 → 2.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +25 -17
  2. package/gui/dist/assets/index-B7_K1Hsj.js +115 -0
  3. package/gui/dist/assets/index-ltx3L-WS.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/command-code.ts +23 -2
  7. package/src/adapters/cursor/native-exec.ts +63 -11
  8. package/src/adapters/cursor/tool-definitions.ts +1 -1
  9. package/src/adapters/cursor/tool-schemas.ts +59 -0
  10. package/src/adapters/kiro.ts +54 -8
  11. package/src/adapters/openai-chat.ts +23 -6
  12. package/src/adapters/openai-responses.ts +3 -1
  13. package/src/adapters/opencode-go.ts +35 -0
  14. package/src/claude/agents-inject.ts +12 -5
  15. package/src/claude/desktop-3p-library.ts +89 -0
  16. package/src/claude/desktop-3p.ts +166 -95
  17. package/src/claude/desktop-discovery-inputs.ts +44 -0
  18. package/src/claude/desktop-profile.ts +1 -1
  19. package/src/claude/desktop-remote-store-artifact.ts +185 -0
  20. package/src/claude/desktop-remote-store-io.ts +91 -0
  21. package/src/claude/desktop-remote-store-state.ts +127 -0
  22. package/src/claude/desktop-remote-store.ts +269 -0
  23. package/src/claude/inbound-model-options.ts +10 -2
  24. package/src/claude/inbound-records.ts +7 -0
  25. package/src/claude/inbound.ts +1 -1
  26. package/src/claude/model-info.ts +16 -3
  27. package/src/cli/account-api.ts +7 -1
  28. package/src/cli/aside-profiles.ts +17 -0
  29. package/src/cli/capabilities.ts +39 -0
  30. package/src/cli/claude-agent-startup-sync.ts +3 -1
  31. package/src/cli/claude-desktop.ts +127 -18
  32. package/src/cli/connect.ts +27 -14
  33. package/src/cli/dispatch.ts +30 -16
  34. package/src/cli/export-command.ts +3 -1
  35. package/src/cli/index.ts +28 -16
  36. package/src/cli/integrations.ts +46 -12
  37. package/src/cli/opencode.ts +11 -4
  38. package/src/cli/runtime-api.ts +7 -2
  39. package/src/cli/uninstall-client-state.ts +78 -0
  40. package/src/client/connect.ts +476 -223
  41. package/src/client/hub-client.ts +81 -0
  42. package/src/client/hub-relay.ts +38 -6
  43. package/src/client/lifecycle-lock.ts +129 -0
  44. package/src/client/state.ts +71 -42
  45. package/src/clients/aside-profiles.ts +224 -0
  46. package/src/codex/account-lifecycle.ts +5 -9
  47. package/src/codex/auth-api.ts +50 -4
  48. package/src/codex/catalog/metadata.ts +0 -2
  49. package/src/codex/catalog/provider-fetch.ts +7 -5
  50. package/src/codex/catalog/sync.ts +88 -24
  51. package/src/codex/convergence.ts +2 -0
  52. package/src/codex/inject.ts +45 -14
  53. package/src/codex/journal.ts +6 -6
  54. package/src/codex/quota-refresh-outcome.ts +27 -0
  55. package/src/codex/shim.ts +7 -1
  56. package/src/combos/resolve.ts +10 -7
  57. package/src/config.ts +18 -5
  58. package/src/generated/compatibility-version.json +209 -105
  59. package/src/images/loop.ts +5 -2
  60. package/src/integrations/aside-profile-context.ts +270 -0
  61. package/src/integrations/aside-profile-journal.ts +229 -0
  62. package/src/integrations/aside-profiles.ts +176 -0
  63. package/src/integrations/catalog-refresh.ts +40 -0
  64. package/src/integrations/config-io.ts +16 -1
  65. package/src/integrations/owned-refresh.ts +14 -2
  66. package/src/integrations/state.ts +3 -1
  67. package/src/integrations/writer.ts +3 -1
  68. package/src/lab/fabric/producer-isolate.ts +84 -46
  69. package/src/lib/account-selection-events.ts +32 -0
  70. package/src/lib/errors.ts +9 -1
  71. package/src/lib/provider-outbound.ts +2 -45
  72. package/src/lib/proxy-env.ts +67 -0
  73. package/src/oauth/account-quota-rank.ts +9 -1
  74. package/src/oauth/anthropic-routing.ts +91 -23
  75. package/src/oauth/generic-account-failover.ts +15 -53
  76. package/src/oauth/index.ts +67 -68
  77. package/src/oauth/pool-settings-capability.ts +2 -1
  78. package/src/oauth/store.ts +91 -7
  79. package/src/oauth/types.ts +8 -0
  80. package/src/providers/api-key-selection.ts +110 -0
  81. package/src/providers/api-keys.ts +38 -31
  82. package/src/providers/context-cap.ts +27 -3
  83. package/src/providers/key-failover.ts +27 -17
  84. package/src/providers/provider-id-rewrite.ts +10 -8
  85. package/src/providers/registry.ts +1 -0
  86. package/src/responses/custom-tool-compat.ts +10 -0
  87. package/src/responses/function-call-compat.ts +173 -0
  88. package/src/responses/namespace-tool-compat.ts +27 -4
  89. package/src/responses/parser.ts +8 -0
  90. package/src/responses/task-input.ts +36 -0
  91. package/src/responses/tool-name-aliases.ts +79 -0
  92. package/src/router.ts +2 -0
  93. package/src/routing/capability.ts +10 -3
  94. package/src/routing/compatibility/assemble.ts +16 -1
  95. package/src/routing/evaluator.ts +5 -1
  96. package/src/server/auth-cors.ts +3 -0
  97. package/src/server/chat-native.ts +44 -9
  98. package/src/server/claude-messages.ts +71 -36
  99. package/src/server/grok-responses-snapshot-repair.ts +333 -0
  100. package/src/server/index.ts +26 -10
  101. package/src/server/management/account-selection-stream.ts +70 -0
  102. package/src/server/management/aside-profile-routes.ts +231 -0
  103. package/src/server/management/config-routes.ts +36 -41
  104. package/src/server/management/context.ts +3 -0
  105. package/src/server/management/integration-routes.ts +61 -3
  106. package/src/server/management/logs-usage-routes.ts +1 -0
  107. package/src/server/management/model-routes.ts +20 -9
  108. package/src/server/management/model-rows.ts +10 -2
  109. package/src/server/management/oauth-account-routes.ts +7 -0
  110. package/src/server/management/provider-routes.ts +10 -8
  111. package/src/server/management/route-registry.ts +20 -2
  112. package/src/server/management-auth.ts +16 -0
  113. package/src/server/relay-eager.ts +17 -5
  114. package/src/server/relay.ts +43 -4
  115. package/src/server/request-log.ts +29 -1
  116. package/src/server/responses/agent-task-recovery-cache.ts +11 -0
  117. package/src/server/responses/agent-task-recovery.ts +24 -4
  118. package/src/server/responses/codex-ws-pool.ts +4 -4
  119. package/src/server/responses/codex-ws-session.ts +2 -2
  120. package/src/server/responses/collaboration.ts +2 -1
  121. package/src/server/responses/combo-stream-preflight.ts +26 -7
  122. package/src/server/responses/core.ts +668 -151
  123. package/src/server/responses/fetch-helpers.ts +6 -1
  124. package/src/server/responses/ws-upstream.ts +8 -3
  125. package/src/server/responses-custom-tool-repair.ts +29 -9
  126. package/src/server/responses-function-tool-repair.ts +183 -0
  127. package/src/server/responses-snapshot-codec.ts +14 -0
  128. package/src/server/responses-snapshot-repair.ts +1 -13
  129. package/src/server/responses-undeclared-tool-guard.ts +1 -68
  130. package/src/server/sse-payload-rewrite.ts +3 -1
  131. package/src/service.ts +6 -2
  132. package/src/types/config.ts +23 -16
  133. package/src/types/provider.ts +14 -3
  134. package/src/types/request.ts +2 -0
  135. package/src/usage/log.ts +9 -0
  136. package/src/web-search/loop.ts +5 -2
  137. package/gui/dist/assets/index-DS1NE4Jn.css +0 -1
  138. package/gui/dist/assets/index-Djowl68T.js +0 -112
@@ -3,6 +3,73 @@ export const PROXY_ENV_KEYS = [...OUTBOUND_PROXY_ENV_KEYS, "NO_PROXY"] as const;
3
3
 
4
4
  export type ProxyEnvKey = typeof PROXY_ENV_KEYS[number];
5
5
  export type ProxyEnvMap = Record<string, string | undefined>;
6
+ export type ProxyRoute =
7
+ | { kind: "direct" }
8
+ | { kind: "proxy"; proxy: string }
9
+ | { kind: "fallback" };
10
+
11
+ export function normalizeProxyHostname(hostname: string): string {
12
+ const normalized = hostname.trim().toLowerCase().replace(/\.+$/, "");
13
+ return normalized.startsWith("[") && normalized.endsWith("]")
14
+ ? normalized.slice(1, -1)
15
+ : normalized;
16
+ }
17
+
18
+ export function noProxyMatches(
19
+ url: URL,
20
+ env: ProxyEnvMap = process.env,
21
+ ): boolean {
22
+ const raw = env.NO_PROXY ?? env.no_proxy ?? "";
23
+ const hostname = normalizeProxyHostname(url.hostname);
24
+ const port = url.port || (url.protocol === "https:" || url.protocol === "wss:" ? "443" : "80");
25
+ for (const rawEntry of raw.split(",")) {
26
+ let entry = rawEntry.trim().toLowerCase();
27
+ if (!entry) continue;
28
+ if (entry === "*") return true;
29
+ entry = entry.replace(/^(?:https?|wss?):\/\//, "").split("/", 1)[0]!;
30
+
31
+ let entryHost = entry;
32
+ let entryPort = "";
33
+ const bracketed = /^\[([^\]]+)](?::(\d+))?$/.exec(entry);
34
+ if (bracketed) {
35
+ entryHost = bracketed[1]!;
36
+ entryPort = bracketed[2] ?? "";
37
+ } else if ((entry.match(/:/g)?.length ?? 0) === 1) {
38
+ const separator = entry.lastIndexOf(":");
39
+ const possiblePort = entry.slice(separator + 1);
40
+ if (/^\d+$/.test(possiblePort)) {
41
+ entryHost = entry.slice(0, separator);
42
+ entryPort = possiblePort;
43
+ }
44
+ }
45
+ if (entryPort && entryPort !== port) continue;
46
+ entryHost = normalizeProxyHostname(entryHost.replace(/^\*?\./, ""));
47
+ if (entryHost && (hostname === entryHost || hostname.endsWith(`.${entryHost}`))) return true;
48
+ }
49
+ return false;
50
+ }
51
+
52
+ export function resolveProxyRoute(
53
+ url: URL,
54
+ env: ProxyEnvMap = process.env,
55
+ ): ProxyRoute {
56
+ if (noProxyMatches(url, env)) return { kind: "direct" };
57
+ const key = url.protocol === "https:" || url.protocol === "wss:"
58
+ ? "HTTPS_PROXY"
59
+ : "HTTP_PROXY";
60
+ const proxy = [key, key.toLowerCase(), "ALL_PROXY", "all_proxy"]
61
+ .map(candidate => env[candidate]?.trim())
62
+ .find(Boolean);
63
+ if (!proxy) return { kind: "direct" };
64
+ try {
65
+ const protocol = new URL(proxy).protocol;
66
+ return protocol === "http:" || protocol === "https:"
67
+ ? { kind: "proxy", proxy }
68
+ : { kind: "fallback" };
69
+ } catch {
70
+ return { kind: "fallback" };
71
+ }
72
+ }
6
73
 
7
74
  export function proxyEnvPresent(
8
75
  key: ProxyEnvKey,
@@ -67,6 +67,14 @@ function headroomOf(provider: string, accountId: string): number | null {
67
67
  return 100 - Math.max(...percents);
68
68
  }
69
69
 
70
+ /** Unknown usage is not exhaustion; Kiro's explicit overage verdict is authoritative. */
71
+ export function isAccountQuotaExhausted(provider: string, accountId: string): boolean {
72
+ const exhaustion = provider === "kiro" ? getKiroAccountExhaustion(`${provider}\u0000${accountId}`) : null;
73
+ if (exhaustion !== null) return exhaustion.exhausted;
74
+ const headroom = headroomOf(provider, accountId);
75
+ return headroom !== null && headroom <= 0;
76
+ }
77
+
70
78
  /**
71
79
  * Order candidates best-first.
72
80
  *
@@ -90,7 +98,7 @@ export function rankAccountsByHeadroom(provider: string, ring: readonly string[]
90
98
  const headroom = headroomOf(provider, id);
91
99
  if (exhaustion !== null || headroom !== null) sawEvidence = true;
92
100
 
93
- if (exhaustion?.exhausted === true) return { id, bucket: RANK_EXHAUSTED, headroom: 0, index };
101
+ if (isAccountQuotaExhausted(provider, id)) return { id, bucket: RANK_EXHAUSTED, headroom: 0, index };
94
102
  if (headroom === null) return { id, bucket: RANK_UNKNOWN, headroom: 0, index };
95
103
  return { id, bucket: RANK_HEALTHY, headroom, index };
96
104
  });
@@ -15,7 +15,8 @@
15
15
  * store (existing OAuth path) so the account is excluded from eligibility.
16
16
  */
17
17
  import { createHash } from "node:crypto";
18
- import { setActiveAccount, getAccountSet, getAccountCredential } from "./store";
18
+ import { captureOAuthAccountSelection, commitOAuthAccountSelection, credentialGeneration, getAccountSet, getAccountCredential, getAccountCredentialWithStatus } from "./store";
19
+ import type { OAuthAccessSnapshot } from "./index";
19
20
  import { getCachedProviderAccountQuota } from "../providers/quota";
20
21
  import { fallbackCodexAccountLogLabel } from "../codex/account-label";
21
22
  import {
@@ -24,6 +25,7 @@ import {
24
25
  notePoolRotationFailure,
25
26
  notePoolRotationSuccess,
26
27
  pickRoundRobinAccount,
28
+ peekRoundRobinAccount,
27
29
  POOL_KEY_ANTHROPIC,
28
30
  seedPoolRotationAccount,
29
31
  } from "../codex/pool-rotation";
@@ -68,6 +70,10 @@ interface AffinityEntry {
68
70
 
69
71
  const upstreamHealth = new Map<string, AccountHealth>();
70
72
  const sessionAffinity = new Map<string, AffinityEntry>();
73
+ type OAuthAccountSelection = NonNullable<ReturnType<typeof captureOAuthAccountSelection>>;
74
+ // Undefined means this runtime has not admitted a selection yet; null means consumed.
75
+ // The startup baseline comes from the authoritative store, never a second persisted pin.
76
+ let manualPreference: OAuthAccountSelection | null | undefined;
71
77
 
72
78
  function normalizeAffinityComponent(value: string | null | undefined): string {
73
79
  const normalized = value?.trim() ?? "";
@@ -152,6 +158,7 @@ export function sweepExpiredAnthropicRoutingHealth(now = Date.now()): number {
152
158
  export function clearAnthropicAccountPoolState(): void {
153
159
  upstreamHealth.clear();
154
160
  sessionAffinity.clear();
161
+ manualPreference = undefined;
155
162
  quorumCache = null;
156
163
  }
157
164
 
@@ -409,7 +416,7 @@ function pickAlternateAnthropicAccount(
409
416
  const strategy = anthropicPoolStrategy(config);
410
417
  const eligible = getEligibleAnthropicAccounts(now).filter(id => id !== excludeId);
411
418
  if (strategy === "round-robin") {
412
- return pickRoundRobinAccount(POOL_KEY_ANTHROPIC, eligible, stickyLimitForPool(config));
419
+ return peekRoundRobinAccount(POOL_KEY_ANTHROPIC, eligible, stickyLimitForPool(config));
413
420
  }
414
421
  if (strategy === "fill-first") {
415
422
  return pickNextFillFirstAnthropicAccount(config, excludeId, eligible);
@@ -434,6 +441,7 @@ export type AnthropicAccountSelectionReason =
434
441
  | "lowest-usage"
435
442
  | "only-eligible"
436
443
  | "round-robin"
444
+ | "manual"
437
445
  | "fill-first"
438
446
  | "none"
439
447
  | "all-cooled";
@@ -500,9 +508,8 @@ function pickUnboundStrategyAccount(
500
508
  if (strategy === "round-robin") {
501
509
  const eligible = getEligibleAnthropicAccounts(now);
502
510
  const limit = stickyLimitForPool(config);
503
- const picked = pickRoundRobinAccount(POOL_KEY_ANTHROPIC, eligible, limit);
511
+ const picked = peekRoundRobinAccount(POOL_KEY_ANTHROPIC, eligible, limit);
504
512
  if (!picked) return null;
505
- notePoolRotationSuccess(POOL_KEY_ANTHROPIC, picked, limit);
506
513
  return { accountId: picked, reason: "round-robin" };
507
514
  }
508
515
 
@@ -528,17 +535,39 @@ export function resolveAnthropicAccountForSession(
528
535
  const set = getAccountSet(PROVIDER);
529
536
  if (!set || set.accounts.length === 0) return { accountId: null, reason: "none" };
530
537
 
538
+ if (manualPreference === undefined) {
539
+ manualPreference = set.selectionRevision !== undefined
540
+ ? { accountId: set.activeAccountId, revision: set.selectionRevision }
541
+ : null;
542
+ }
543
+
531
544
  if (!isAnthropicAccountPoolEnabled(config)) {
532
545
  return { accountId: set.activeAccountId, reason: "pool-disabled" };
533
546
  }
534
547
 
548
+ // A manual choice is a one-dispatch preference, not a lower-priority quota hint.
549
+ // Consume it only after admission commits, so a failed token lookup cannot spend it.
550
+ if (manualPreference) {
551
+ if (manualPreference.accountId !== set.activeAccountId || manualPreference.revision !== set.selectionRevision) {
552
+ manualPreference = null;
553
+ } else {
554
+ const chosen = manualPreference.accountId;
555
+ const quota = getCachedProviderAccountQuota(PROVIDER, chosen);
556
+ const exhausted = [quota?.fiveHourPercent, quota?.weeklyPercent, quota?.monthlyPercent,
557
+ ...(quota?.customWindows ?? []).map(window => window.percent)]
558
+ .some(percent => typeof percent === "number" && percent >= 100);
559
+ if (!exhausted && getEligibleAnthropicAccounts(now).includes(chosen)) {
560
+ return { accountId: chosen, reason: "manual" };
561
+ }
562
+ }
563
+ }
564
+
535
565
  const key = normalizeAffinityComponent(sessionKey);
536
566
  if (key) {
537
567
  const affined = sessionAffinity.get(key);
538
568
  if (affined && now - affined.lastUsedAt <= AFFINITY_IDLE_TTL_MS) {
539
569
  const stillThere = set.accounts.some(a => a.id === affined.accountId && a.needsReauth !== true);
540
570
  if (stillThere && !isCooled(affined.accountId, now) && isPoolCredentialUsable(affined.accountId, now)) {
541
- affined.lastUsedAt = now;
542
571
  return { accountId: affined.accountId, reason: "affinity" };
543
572
  }
544
573
  sessionAffinity.delete(key);
@@ -560,12 +589,6 @@ export function resolveAnthropicAccountForSession(
560
589
 
561
590
  const strategyPick = pickUnboundStrategyAccount(config, now);
562
591
  if (strategyPick) {
563
- // Do not promote active here — token validation may still fail. Callers
564
- // (responses/core) promote after getAnthropicPoolAccessToken succeeds.
565
- if (key && normalizeAffinityComponent(strategyPick.accountId)) {
566
- sessionAffinity.set(key, { accountId: strategyPick.accountId, lastUsedAt: now });
567
- pruneExpiredAffinity(now);
568
- }
569
592
  return { accountId: strategyPick.accountId, reason: strategyPick.reason };
570
593
  }
571
594
 
@@ -611,10 +634,6 @@ export function resolveAnthropicAccountForSession(
611
634
  return { accountId: null, reason: anyCooled ? "all-cooled" : "none" };
612
635
  }
613
636
 
614
- if (key && normalizeAffinityComponent(accountId)) {
615
- sessionAffinity.set(key, { accountId, lastUsedAt: now });
616
- pruneExpiredAffinity(now);
617
- }
618
637
  return { accountId, reason };
619
638
  }
620
639
 
@@ -684,20 +703,57 @@ export function rotateAnthropicAccountOn429(
684
703
  return null;
685
704
  }
686
705
 
687
- const affinityKey = normalizeAffinityComponent(sessionKey);
688
- if (affinityKey && normalizeAffinityComponent(next)) {
689
- sessionAffinity.set(affinityKey, { accountId: next, lastUsedAt: now });
690
- pruneExpiredAffinity(now);
691
- }
692
706
  console.warn(
693
707
  `[anthropic-pool] 429 on ${formatAnthropicAccountOrdinal(failedAccountId)}; failing over to ${formatAnthropicAccountOrdinal(next)}`,
694
708
  );
695
709
  return next;
696
710
  }
697
711
 
698
- /** Promote dashboard active account after a validated failover target is usable. */
699
- export function promoteAnthropicActiveAccount(accountId: string): void {
700
- void setActiveAccount(PROVIDER, accountId).catch(() => { /* best-effort */ });
712
+ export interface AnthropicSelectionRoutingOptions {
713
+ config: OcxConfig;
714
+ sessionKey?: string | null;
715
+ reason?: AnthropicAccountSelectionReason;
716
+ expectedCredentialGeneration?: string;
717
+ }
718
+
719
+ /** Commit the selected account before dispatch; rejected proposals have no routing side effects. */
720
+ export async function promoteAnthropicActiveAccount(
721
+ accountId: string,
722
+ expectedSelection: OAuthAccountSelection | null,
723
+ options: AnthropicSelectionRoutingOptions,
724
+ ): Promise<OAuthAccountSelection | null> {
725
+ if (!expectedSelection || !isPoolCredentialUsable(accountId, Date.now()) || isCooled(accountId, Date.now())) return null;
726
+ const committed = await commitOAuthAccountSelection(PROVIDER, accountId, {
727
+ expectedSelection,
728
+ expectedCredentialGeneration: options.expectedCredentialGeneration,
729
+ requireUsableAccount: true,
730
+ });
731
+ if (!committed) return null;
732
+ return commitAnthropicSelectionRouting(accountId, expectedSelection, committed, options) ? committed : null;
733
+ }
734
+
735
+ /** Main's shared selection owner calls this only after its authoritative commit succeeds. */
736
+ export function commitAnthropicSelectionRouting(
737
+ accountId: string,
738
+ expectedSelection: OAuthAccountSelection,
739
+ committed: OAuthAccountSelection,
740
+ options: AnthropicSelectionRoutingOptions,
741
+ ): boolean {
742
+ if (committed.accountId !== accountId) return false;
743
+ const current = captureOAuthAccountSelection(PROVIDER);
744
+ if (current?.accountId !== committed.accountId || current.revision !== committed.revision) return false;
745
+ if (isAnthropicAccountPoolEnabled(options.config)) {
746
+ if (anthropicPoolStrategy(options.config) === "round-robin" && options.reason !== "affinity") {
747
+ const limit = stickyLimitForPool(options.config);
748
+ const picked = pickRoundRobinAccount(POOL_KEY_ANTHROPIC, getEligibleAnthropicAccounts(), limit);
749
+ if (picked !== accountId) seedPoolRotationAccount(POOL_KEY_ANTHROPIC, accountId);
750
+ notePoolRotationSuccess(POOL_KEY_ANTHROPIC, accountId, limit);
751
+ }
752
+ bindAnthropicSessionAffinity(options.sessionKey, accountId);
753
+ }
754
+ if (manualPreference === undefined || (manualPreference?.accountId === expectedSelection.accountId
755
+ && manualPreference.revision === expectedSelection.revision)) manualPreference = null;
756
+ return true;
701
757
  }
702
758
 
703
759
  /**
@@ -706,6 +762,7 @@ export function promoteAnthropicActiveAccount(accountId: string): void {
706
762
  */
707
763
  export function resetAnthropicRoutingForManualSelection(accountId: string): void {
708
764
  sessionAffinity.clear();
765
+ manualPreference = captureOAuthAccountSelection(PROVIDER);
709
766
  seedPoolRotationAccount(POOL_KEY_ANTHROPIC, accountId);
710
767
  // A manual account selection is an operator statement about the roster; do not answer the
711
768
  // next activation question from a count read before it.
@@ -731,6 +788,17 @@ export async function getAnthropicPoolAccessToken(accountId: string): Promise<st
731
788
  return getValidAccessTokenForAccount(PROVIDER, accountId);
732
789
  }
733
790
 
791
+ /** Resolve through the pool's local-CLI restrictions, then bind the exact returned generation. */
792
+ export async function getAnthropicPoolAccessSnapshot(accountId: string): Promise<OAuthAccessSnapshot> {
793
+ const accessToken = await getAnthropicPoolAccessToken(accountId);
794
+ const row = getAccountCredentialWithStatus(PROVIDER, accountId);
795
+ if (!row || row.needsReauth || row.credential.access !== accessToken
796
+ || row.credential.expires <= Date.now()) {
797
+ throw new Error("Anthropic pool credential changed during account selection");
798
+ }
799
+ return { provider: PROVIDER, accountId, accessToken, generation: credentialGeneration(row.credential) };
800
+ }
801
+
734
802
  /**
735
803
  * Whether the pool may refresh this account's token. Background `local-cli` slots must not
736
804
  * adopt the global Claude CLI credential (same fail-closed rule as quota probes).
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { getAccountSet } from "./store";
18
18
  import { getValidAccessSnapshotForAccount, type OAuthAccessSnapshot } from "./index";
19
- import { exhaustedCooldownMs, hasHeadroomEvidence, rankAccountsByHeadroom } from "./account-quota-rank";
19
+ import { exhaustedCooldownMs, hasHeadroomEvidence, isAccountQuotaExhausted, rankAccountsByHeadroom } from "./account-quota-rank";
20
20
  import { parseRetryAfterMs } from "../combos/failover";
21
21
  import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
22
22
  import type { OcxConfig, OcxProviderConfig } from "../types";
@@ -61,29 +61,12 @@ interface PresenceEntry {
61
61
  readAt: number;
62
62
  }
63
63
 
64
- /**
65
- * Ordered roster plus the active id, for the pre-dispatch preference.
66
- *
67
- * Same reasoning as the presence cache: `getAccountSet` reads through `loadAuthStore`,
68
- * which chmods and re-parses the whole credential file on every call. Selection needs the
69
- * ORDER and the active id, which the presence count cannot supply, so it gets its own
70
- * TTL-bounded row. Ids and an active pointer only — never a credential.
71
- */
72
- interface RosterEntry {
73
- ids: string[];
74
- activeId: string | null;
75
- readAt: number;
76
- }
77
-
78
64
  /** Process-local, like the Anthropic pool's: a restart is allowed to forget a cooldown. */
79
65
  const health = new Map<string, AccountHealth>();
80
66
 
81
67
  /** Provider -> recent eligible-account count. TTL-bounded; never holds credential material. */
82
68
  const presence = new Map<string, PresenceEntry>();
83
69
 
84
- /** Provider -> recently read roster. TTL-bounded; never holds credential material. */
85
- const roster = new Map<string, RosterEntry>();
86
-
87
70
  const healthKey = (provider: string, accountId: string) => `${provider}\u0000${accountId}`;
88
71
 
89
72
  function isCooled(provider: string, accountId: string, now: number): boolean {
@@ -118,24 +101,6 @@ function eligibleAccountCount(providerName: string, now: number): number {
118
101
  return eligible;
119
102
  }
120
103
 
121
- /**
122
- * Roster ids and the active pointer, read at most once per TTL window.
123
- *
124
- * `needsReauth` accounts are excluded for the same reason the presence count excludes
125
- * them: a revoked credential cannot serve the request we are about to send.
126
- */
127
- function cachedRoster(providerName: string, now: number): { ids: string[]; activeId: string | null } {
128
- const cached = roster.get(providerName);
129
- if (cached && now >= cached.readAt && now - cached.readAt < PRESENCE_CACHE_TTL_MS) {
130
- return { ids: cached.ids, activeId: cached.activeId };
131
- }
132
- const set = getAccountSet(providerName);
133
- const ids = set ? set.accounts.filter(a => a.needsReauth !== true).map(a => a.id) : [];
134
- const activeId = set?.activeAccountId ?? null;
135
- roster.set(providerName, { ids, activeId, readAt: now });
136
- return { ids, activeId };
137
- }
138
-
139
104
  /**
140
105
  * Presence IS consent (#2568d).
141
106
  *
@@ -192,8 +157,7 @@ function isProactivePreferenceEnabled(config: OcxConfig, providerName: string, n
192
157
  if (typeof perProvider === "boolean") {
193
158
  return perProvider && hasFailoverAccountQuorum(providerName, now);
194
159
  }
195
- if (config.oauthAccountFailover?.enabled === false) return false;
196
- return hasFailoverAccountQuorum(providerName, now);
160
+ return config.oauthAccountFailover?.enabled === true && hasFailoverAccountQuorum(providerName, now);
197
161
  }
198
162
 
199
163
  /** Accounts that may serve traffic right now: not cooled, not flagged for reauth. */
@@ -240,8 +204,6 @@ export function rotateGenericOAuthAccountOn429(
240
204
  // A rotation means the roster in use just changed; do not answer the next activation question
241
205
  // from a count read before the failure.
242
206
  presence.delete(providerName);
243
- // Same for the selection roster: the next request must not pick from a pre-failure read.
244
- roster.delete(providerName);
245
207
  // Deterministic: start after the failed account so repeated 429s walk the roster instead of
246
208
  // hammering whichever id happens to sort first. The ring is built BEFORE ranking — ranking
247
209
  // the store's own order would change which account a quota-less provider rotates to.
@@ -289,13 +251,19 @@ export function preferredInitialAccount(
289
251
  // The PROACTIVE predicate, not the reactive one: this steers a request upstream has not
290
252
  // refused, so `oauthAccountFailover.enabled: false` must still be able to refuse it.
291
253
  if (!isProactivePreferenceEnabled(config, providerName, now)) return null;
292
- // This runs on the initial resolution of EVERY request, and `loadAuthStore` has no
293
- // cache: each call chmods the config dir, chmods the secret, reads the whole file and
294
- // normalizes it (store.ts:136-151). So the store is consulted at most ONCE here, behind
295
- // the same TTL the presence check uses, and never at all for a single-account provider.
296
- const { ids: order, activeId: active } = cachedRoster(providerName, now);
254
+ // Read the same authoritative selection the management writer commits. Caching the
255
+ // active id separately would delay manual selection and account removal.
256
+ const selected = getAccountSet(providerName);
257
+ if (!selected) return null;
258
+ const active = selected.activeAccountId;
259
+ const order = selected.accounts.filter(account => account.needsReauth !== true).map(account => account.id);
297
260
  if (order.length < 2) return null;
298
261
 
262
+ const activeRow = selected.accounts.find(account => account.id === active);
263
+ if (activeRow && activeRow.needsReauth !== true
264
+ && !isCooled(providerName, activeRow.id, now)
265
+ && !isAccountQuotaExhausted(providerName, activeRow.id)) return null;
266
+
299
267
  // Evidence is required BEFORE eligibility narrows the field. Without this, a provider
300
268
  // with no quota data at all could still be redirected: cool the active account with a
301
269
  // 429 and the eligible list collapses to one candidate, which any ranking returns
@@ -318,11 +286,8 @@ export function preferredInitialAccount(
318
286
  const best = rankAccountsByHeadroom(providerName, candidates)[0] ?? null;
319
287
  // Nothing to do when the ranking agrees with the account we would have used anyway.
320
288
  //
321
- // The roster may be up to PRESENCE_CACHE_TTL_MS old, so this answer is a PREFERENCE the
322
- // caller must be able to abandon: it resolves the account with `requireUsableAccount`,
323
- // which rejects a removed or reauth-flagged account inside the store read it was already
324
- // performing, and falls back to the active account. Validating here instead would mean a
325
- // second uncached read of the credential file on every redirected request.
289
+ // A proposal still needs guarded selection commit after credential resolution: a
290
+ // removal, reauth verdict, or manual choice can arrive during that await.
326
291
  return best && best !== active ? best : null;
327
292
  }
328
293
 
@@ -341,7 +306,6 @@ export function genericFailoverRetryAfterSeconds(providerName: string, now = Dat
341
306
 
342
307
  /** Test seam and manual-recovery hook. */
343
308
  export function forgetGenericFailoverRoster(providerName: string): void {
344
- roster.delete(providerName);
345
309
  presence.delete(providerName);
346
310
  }
347
311
 
@@ -350,11 +314,9 @@ export function clearGenericFailoverHealth(providerName?: string): void {
350
314
  if (!providerName) {
351
315
  health.clear();
352
316
  presence.clear();
353
- roster.clear();
354
317
  return;
355
318
  }
356
319
  presence.delete(providerName);
357
- roster.delete(providerName);
358
320
  for (const key of [...health.keys()]) {
359
321
  if (key.startsWith(`${providerName}\u0000`)) health.delete(key);
360
322
  }
@@ -548,7 +548,7 @@ export async function getValidAccessTokenSnapshot(provider: string): Promise<OAu
548
548
  }
549
549
 
550
550
  /** Providers whose upstream-401 replay path may force a snapshot refresh. */
551
- const FORCE_REFRESH_PROVIDERS = new Set(["xai", "github-copilot", "kiro"]);
551
+ const FORCE_REFRESH_PROVIDERS = new Set(["xai", "github-copilot", "kiro", "google-antigravity"]);
552
552
 
553
553
  export async function forceRefreshOAuthAccessSnapshot(
554
554
  rejected: OAuthAccessSnapshot,
@@ -1239,6 +1239,37 @@ function isLegacyAntigravityStaticCatalog(provider: OcxProviderConfig): boolean
1239
1239
  ]);
1240
1240
  }
1241
1241
 
1242
+ /** Refresh registry-owned catalog fields while preserving valid operator selections. */
1243
+ function applyOAuthPresetCatalog(
1244
+ provider: OcxProviderConfig,
1245
+ preset: OcxProviderConfig,
1246
+ ): void {
1247
+ for (const field of OAUTH_RECONCILE_FIELDS) {
1248
+ if (JSON.stringify(provider[field]) === JSON.stringify(preset[field])) continue;
1249
+ if (preset[field] !== undefined) {
1250
+ provider[field] = cloneProviderField(preset[field]) as never;
1251
+ } else {
1252
+ delete provider[field];
1253
+ }
1254
+ }
1255
+ if (provider.liveModels === undefined && preset.liveModels !== undefined) {
1256
+ provider.liveModels = preset.liveModels;
1257
+ }
1258
+ // Heal only a selection that the refreshed static catalog no longer contains. Providers
1259
+ // with live discovery do not expose an enumerable account catalog here, so their saved
1260
+ // default remains operator-owned.
1261
+ if (
1262
+ provider.liveModels !== true &&
1263
+ provider.defaultModel
1264
+ && preset.defaultModel
1265
+ && preset.models
1266
+ && preset.models.length > 0
1267
+ && !(provider.models ?? []).includes(provider.defaultModel)
1268
+ ) {
1269
+ provider.defaultModel = preset.defaultModel;
1270
+ }
1271
+ }
1272
+
1242
1273
  /** Promote only the versioned canonical static seed; unmarked `liveModels: false` remains user intent. */
1243
1274
  function migrateLegacyAntigravityStaticCatalog(config: OcxConfig): boolean {
1244
1275
  if (config.googleAntigravityStaticCatalogVersion !== 1) return false;
@@ -1277,24 +1308,7 @@ function projectOAuthProviderReconciliation(config: OcxConfig): OAuthReconcilePr
1277
1308
  }
1278
1309
  if (def && prov.authMode === "oauth") {
1279
1310
  const preset = def.providerConfig;
1280
- for (const field of OAUTH_RECONCILE_FIELDS) {
1281
- if (JSON.stringify(prov[field]) === JSON.stringify(preset[field])) continue;
1282
- if (preset[field] !== undefined) {
1283
- prov[field] = cloneProviderField(preset[field]) as never;
1284
- } else {
1285
- delete prov[field];
1286
- }
1287
- }
1288
- if (prov.liveModels === undefined && preset.liveModels !== undefined) {
1289
- prov.liveModels = preset.liveModels;
1290
- }
1291
- // Heal a defaultModel that no longer exists in the refreshed list (e.g. a deprecated snapshot).
1292
- // Skip providers without a static preset `models` list: for live-discovery providers
1293
- // (e.g. command-code OAuth) the account-scoped catalog is not enumerable here, so any
1294
- // persisted defaultModel is a user selection and must not be overwritten by the seed.
1295
- if (prov.defaultModel && preset.defaultModel && preset.models && preset.models.length > 0 && !(prov.models ?? []).includes(prov.defaultModel)) {
1296
- prov.defaultModel = preset.defaultModel;
1297
- }
1311
+ applyOAuthPresetCatalog(prov, preset);
1298
1312
  }
1299
1313
  if (JSON.stringify(prov) !== beforeProvider) {
1300
1314
  changed = true;
@@ -1408,24 +1422,18 @@ function preservableApiKeyPool(value: unknown): NonNullable<OcxProviderConfig["a
1408
1422
  return pool.length > 0 ? pool : undefined;
1409
1423
  }
1410
1424
 
1411
- /**
1412
- * Add/refresh an OAuth provider's config entry on a config object (does not persist).
1413
- *
1414
- * Providers whose registry entry sets `allowKeyAuthOverride` (xai, github-copilot) can be
1415
- * billed through a stored API key instead of the OAuth login (router.ts honors
1416
- * `authMode: "key"` for them). A blind preset overwrite here deletes `apiKey`/`apiKeyPool`
1417
- * on every OAuth login, silently destroying the stored key and forcing a re-paste — and it
1418
- * flips billing back to the subscription without the user asking. Carry the key fields over
1419
- * and keep key billing while usable key material remains and the user was not explicitly on
1420
- * oauth. If the final key was removed and only the old key mode remains, let the OAuth
1421
- * preset restore `authMode: "oauth"` so the newly saved OAuth credential can be used.
1422
- *
1423
- * After preservation, `apiKey` always has exactly one matching pool entry (inserting via the
1424
- * same content-derived id as the API-key manager when the active key was missing from the
1425
- * pool). Key mode reflects stored user intent (explicit `"key"` or omitted mode with safe
1426
- * key material) — never whether the login CLI process can resolve an env reference. Env-backed
1427
- * availability is decided at proxy routing time in `router.ts`.
1428
- */
1425
+ const OAUTH_LOGIN_OWNED_PROVIDER_FIELDS = [
1426
+ "adapter",
1427
+ "baseUrl",
1428
+ "authMode",
1429
+ "headers",
1430
+ "apiKeyTransport",
1431
+ "responsesPath",
1432
+ "googleMode",
1433
+ "keyOptional",
1434
+ ] as const satisfies readonly (keyof OcxProviderConfig)[];
1435
+
1436
+ /** Add/refresh only an OAuth provider's login-owned config fields (does not persist). */
1429
1437
  export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
1430
1438
  if (provider === "chatgpt") return;
1431
1439
  const def = OAUTH_PROVIDERS[provider];
@@ -1434,40 +1442,31 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
1434
1442
  const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider);
1435
1443
  if (namespaceCollision) throw new Error(namespaceCollision);
1436
1444
  const existing = config.providers[provider];
1437
- const next: OcxProviderConfig = { ...def.providerConfig };
1438
- // `liveModels` is a user-facing provider toggle. Preserve either explicit setting across login;
1439
- // Antigravity's CCA discovery now uses its real RPC, so legacy `true` remains a valid choice.
1440
- if (typeof existing?.liveModels === "boolean" && !isLegacyCommandCodeStaticCatalog(existing)) {
1441
- next.liveModels = existing.liveModels;
1442
- }
1443
- // The Command Code protocol-version pin is an operator compatibility control. A re-login,
1444
- // add-account, or reauth rebuilds the row from the preset, which has no version; carry the
1445
- // existing pin so authentication changes do not silently revert the documented control.
1446
- if (existing?.commandCodeVersion !== undefined) {
1447
- next.commandCodeVersion = existing.commandCodeVersion;
1448
- }
1449
- // Reauth/add-account refreshes credentials, not the operator's post-upgrade wire choice.
1450
- if (provider === "xai") {
1451
- if (existing?.modelAdapters !== undefined) next.modelAdapters = { ...existing.modelAdapters };
1452
- if (existing?.xaiResponsesDefaultVersion !== undefined) {
1453
- next.xaiResponsesDefaultVersion = existing.xaiResponsesDefaultVersion;
1454
- }
1455
- }
1456
- // User-configured price overlays are operator data, not preset state; a
1457
- // re-login, add-account, or reauth must not silently drop them from the
1458
- // Logs/Usage estimates.
1459
- if (existing?.modelCosts !== undefined) {
1460
- next.modelCosts = existing.modelCosts;
1445
+ // Clone operator state, including xAI wire choices and their migration version.
1446
+ const next: OcxProviderConfig = structuredClone(existing ?? def.providerConfig);
1447
+ for (const field of OAUTH_LOGIN_OWNED_PROVIDER_FIELDS) {
1448
+ const value = def.providerConfig[field];
1449
+ if (value === undefined) delete next[field];
1450
+ else next[field] = structuredClone(value) as never;
1461
1451
  }
1462
- // The per-provider account-failover opt-out is operator intent about SPENDING, and the login
1463
- // path is exactly where losing it does damage: adding a second account both rebuilds this row
1464
- // from the preset and creates the 2-account quorum that turns presence-driven rotation on
1465
- // (#2568d). Dropping the opt-out here would enable the thing the operator switched off, at the
1466
- // moment they were doing something unrelated.
1467
- if (existing?.oauthAccountFailover !== undefined) {
1468
- next.oauthAccountFailover = existing.oauthAccountFailover;
1452
+ // A login may activate a different account. CCA dispatch must take that account's
1453
+ // project from its credential snapshot, never retain the previous account's project.
1454
+ if (next.googleMode === "cloud-code-assist") delete next.project;
1455
+ // Login used to rebuild the whole row from the preset, so catalog data refreshed
1456
+ // immediately. Keep that timing without overwriting unrelated operator-owned fields.
1457
+ applyOAuthPresetCatalog(next, def.providerConfig);
1458
+ // The original Command Code seed was an implementation-owned static catalog, not an
1459
+ // operator opt-out. Promote that exact legacy shape when OAuth login refreshes the row.
1460
+ if (provider === "command-code" && existing && isLegacyCommandCodeStaticCatalog(existing)) {
1461
+ next.liveModels = def.providerConfig.liveModels;
1469
1462
  }
1463
+ // OAuth-only providers must never retain credentials for a different auth mechanism.
1464
+ delete next.apiKey;
1465
+ delete next.apiKeyPool;
1466
+ delete (next as unknown as Record<string, unknown>).azureCredential;
1470
1467
  if (existing && getProviderRegistryEntry(provider)?.allowKeyAuthOverride === true) {
1468
+ // Retain stored key billing intent without resolving env references in the login process.
1469
+ // An explicit OAuth choice stays OAuth even when usable key material is retained.
1471
1470
  // Shared sanitizeApiKeyValue trim / no-CRLF checks from api-key pool writes.
1472
1471
  let storedApiKey = sanitizeApiKeyValue(existing.apiKey);
1473
1472
  const storedApiKeyPool = preservableApiKeyPool(existing.apiKeyPool);
@@ -10,7 +10,8 @@ import type { OcxProviderConfig } from "../types";
10
10
  *
11
11
  * `strategy` and `autoSwitchThreshold` are still a declared contract the selector does not
12
12
  * consume — that is what `inert` reports. `enabled` is NOT inert any more: an explicit
13
- * `false` refuses the pre-dispatch account preference (`preferredInitialAccount`). What it can
13
+ * `true` enables pre-dispatch exhaustion avoidance (`preferredInitialAccount`); absence is off.
14
+ * Healthy manual selections remain authoritative. What the switch can
14
15
  * no longer do is refuse reactive 429 rotation, which activates on account presence and is not
15
16
  * disableable.
16
17
  */