@bitkyc08/opencodex 2.10.0 → 2.10.1

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 (274) hide show
  1. package/AGENTS_INSTALL.md +77 -0
  2. package/README.md +4 -10
  3. package/bin/ocx.mjs +71 -18
  4. package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
  5. package/gui/dist/assets/index-ChZQsmBY.js +70 -0
  6. package/gui/dist/index.html +2 -2
  7. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  8. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  9. package/gui/dist/provider-icons/claude-color.svg +1 -1
  10. package/gui/dist/provider-icons/cline-color.svg +16 -0
  11. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  12. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  13. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  14. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  15. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  16. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  17. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  18. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  19. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  20. package/gui/dist/provider-icons/grok.svg +1 -1
  21. package/gui/dist/provider-icons/groq-color.svg +1 -1
  22. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  23. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  24. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  25. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  26. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  27. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  28. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  29. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  30. package/gui/dist/provider-icons/openai.svg +1 -1
  31. package/gui/dist/provider-icons/opencode.svg +2 -1
  32. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  33. package/gui/dist/provider-icons/pi.svg +2 -2
  34. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  35. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  36. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  37. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  38. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  39. package/package.json +8 -4
  40. package/src/adapters/anthropic.ts +208 -14
  41. package/src/adapters/base.ts +16 -5
  42. package/src/adapters/cursor/effort-map.ts +3 -2
  43. package/src/adapters/cursor/framing.ts +39 -0
  44. package/src/adapters/cursor/live-transport.ts +105 -95
  45. package/src/adapters/cursor/native-exec.ts +32 -6
  46. package/src/adapters/cursor/protobuf-request.ts +20 -15
  47. package/src/adapters/cursor/request-builder.ts +21 -7
  48. package/src/adapters/cursor/types.ts +7 -0
  49. package/src/adapters/google-antigravity-replay.ts +237 -21
  50. package/src/adapters/google-truncation.ts +11 -0
  51. package/src/adapters/google.ts +50 -9
  52. package/src/adapters/identity.ts +39 -6
  53. package/src/adapters/kiro-errors.ts +11 -0
  54. package/src/adapters/kiro-events.ts +19 -1
  55. package/src/adapters/kiro-thinking.ts +10 -2
  56. package/src/adapters/kiro-tools.ts +10 -1
  57. package/src/adapters/kiro.ts +37 -11
  58. package/src/adapters/openai-chat.ts +284 -83
  59. package/src/adapters/openai-responses.ts +182 -24
  60. package/src/bridge.ts +177 -7
  61. package/src/chat/outbound.ts +78 -23
  62. package/src/claude/agents-inject.ts +27 -5
  63. package/src/claude/inbound.ts +11 -1
  64. package/src/claude/model-info.ts +13 -10
  65. package/src/claude/outbound.ts +17 -0
  66. package/src/cli/account-api.ts +24 -0
  67. package/src/cli/account-auth.ts +31 -6
  68. package/src/cli/account-main.ts +317 -0
  69. package/src/cli/account.ts +5 -0
  70. package/src/cli/claude.ts +2 -1
  71. package/src/cli/doctor.ts +93 -22
  72. package/src/cli/export-command.ts +26 -12
  73. package/src/cli/help.ts +8 -6
  74. package/src/cli/index.ts +56 -22
  75. package/src/cli/integrations.ts +84 -1
  76. package/src/cli/observe.ts +54 -1
  77. package/src/cli/opencode.ts +2 -1
  78. package/src/cli/provider-runtime.ts +18 -1
  79. package/src/cli/route-policy.ts +92 -0
  80. package/src/cli/runtime-api.ts +6 -3
  81. package/src/cli/star-prompt.ts +71 -15
  82. package/src/cli/status.ts +1 -1
  83. package/src/cli/v2.ts +36 -9
  84. package/src/clients/config-export.ts +687 -10
  85. package/src/codex/account-lifecycle.ts +30 -5
  86. package/src/codex/account-usability.ts +22 -2
  87. package/src/codex/admission.ts +255 -0
  88. package/src/codex/auth-api.ts +427 -140
  89. package/src/codex/auth-context.ts +155 -30
  90. package/src/codex/autostart-health.ts +8 -1
  91. package/src/codex/catalog/account-models.ts +62 -0
  92. package/src/codex/catalog/aggregation.ts +14 -1
  93. package/src/codex/catalog/bundled.ts +282 -32
  94. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  95. package/src/codex/catalog/metadata.ts +51 -6
  96. package/src/codex/catalog/parsing.ts +6 -3
  97. package/src/codex/catalog/provider-fetch.ts +576 -41
  98. package/src/codex/catalog/sync.ts +505 -66
  99. package/src/codex/catalog-admission.ts +197 -0
  100. package/src/codex/catalog-write-serialization.ts +241 -0
  101. package/src/codex/catalog.ts +2 -1
  102. package/src/codex/codex-write-lock.ts +372 -0
  103. package/src/codex/convergence-types.ts +593 -0
  104. package/src/codex/convergence.ts +441 -0
  105. package/src/codex/desired-state.ts +177 -0
  106. package/src/codex/features.ts +52 -8
  107. package/src/codex/generation.ts +202 -0
  108. package/src/codex/history-job.ts +257 -0
  109. package/src/codex/history-lock.ts +241 -0
  110. package/src/codex/history-migration-guardian.ts +18 -5
  111. package/src/codex/history-provider.ts +9 -2
  112. package/src/codex/history-transition.ts +105 -0
  113. package/src/codex/history-worker.ts +176 -0
  114. package/src/codex/inject-coordination.ts +245 -0
  115. package/src/codex/inject.ts +605 -124
  116. package/src/codex/integration-record.ts +266 -0
  117. package/src/codex/internal/catalog-writer.ts +203 -0
  118. package/src/codex/internal/history-writer.ts +80 -0
  119. package/src/codex/journal.ts +10 -1
  120. package/src/codex/main-account-cache.ts +24 -0
  121. package/src/codex/management-convergence.ts +114 -0
  122. package/src/codex/native-main-admission.ts +47 -0
  123. package/src/codex/native-main-auth-temp.ts +187 -0
  124. package/src/codex/native-main-claim.ts +167 -0
  125. package/src/codex/native-main-lock-file.ts +158 -0
  126. package/src/codex/native-main-owner.ts +315 -0
  127. package/src/codex/native-profile-api.ts +247 -0
  128. package/src/codex/native-profile-manager.ts +1512 -0
  129. package/src/codex/native-profile-processes.ts +121 -0
  130. package/src/codex/native-profile-recovery.ts +99 -0
  131. package/src/codex/native-profile-stage-store.ts +387 -0
  132. package/src/codex/native-profile-startup.ts +340 -0
  133. package/src/codex/native-profile-store.ts +855 -0
  134. package/src/codex/native-profile-types.ts +120 -0
  135. package/src/codex/native-residue.ts +557 -0
  136. package/src/codex/project-config-warnings.ts +18 -4
  137. package/src/codex/prompt-journal.ts +311 -0
  138. package/src/codex/prompt-layers.ts +967 -0
  139. package/src/codex/prompt-lock.ts +143 -0
  140. package/src/codex/quota-rejection.ts +224 -0
  141. package/src/codex/quota.ts +86 -3
  142. package/src/codex/routing.ts +299 -62
  143. package/src/codex/runtime.ts +159 -38
  144. package/src/codex/shim.ts +39 -13
  145. package/src/codex/subagent-model-fallback.ts +73 -12
  146. package/src/codex/transition-state.ts +604 -0
  147. package/src/codex/upstream-host-health.ts +70 -0
  148. package/src/codex/user-identity.ts +266 -0
  149. package/src/codex/write-coordination.ts +114 -0
  150. package/src/config.ts +562 -26
  151. package/src/generated/jawcode-model-metadata.ts +2 -2
  152. package/src/grok/inject.ts +15 -4
  153. package/src/grok/inspect.ts +45 -0
  154. package/src/images/loop.ts +113 -20
  155. package/src/integrations/config-io.ts +151 -0
  156. package/src/integrations/journal.ts +315 -0
  157. package/src/integrations/merge.ts +135 -0
  158. package/src/integrations/native/ownership-preflight.ts +165 -0
  159. package/src/integrations/ownership.ts +111 -0
  160. package/src/integrations/registry.ts +101 -0
  161. package/src/integrations/serialize.ts +235 -0
  162. package/src/integrations/state.ts +290 -0
  163. package/src/integrations/store.ts +103 -0
  164. package/src/integrations/writer.ts +492 -0
  165. package/src/lib/bounded-body.ts +46 -8
  166. package/src/lib/bun-runtime.ts +110 -1
  167. package/src/lib/bun-stream-caps.ts +2 -1
  168. package/src/lib/redact.ts +407 -2
  169. package/src/lib/shadow-call.ts +24 -0
  170. package/src/lib/translator-budget.ts +10 -0
  171. package/src/lib/upstream-reachability.ts +91 -0
  172. package/src/lib/upstream-retry.ts +154 -2
  173. package/src/lib/windows-secret-acl.ts +212 -11
  174. package/src/lib/winsw.ts +9 -3
  175. package/src/oauth/index.ts +61 -3
  176. package/src/oauth/key-providers.ts +4 -0
  177. package/src/oauth/kiro.ts +50 -6
  178. package/src/oauth/store.ts +31 -6
  179. package/src/oauth/token-guardian.ts +9 -3
  180. package/src/providers/codex-capacity.ts +288 -0
  181. package/src/providers/derive.ts +33 -1
  182. package/src/providers/free-directory.ts +3 -1
  183. package/src/providers/key-failover.ts +71 -3
  184. package/src/providers/openai-sidecar.ts +64 -4
  185. package/src/providers/openai-virtual-models.ts +1 -0
  186. package/src/providers/quota.ts +334 -26
  187. package/src/providers/registry.ts +284 -16
  188. package/src/providers/xai-transport.ts +11 -4
  189. package/src/responses/compaction.ts +8 -1
  190. package/src/responses/hosted-tool-policy.ts +9 -0
  191. package/src/responses/parser.ts +44 -2
  192. package/src/responses/reasoning-envelope.ts +9 -1
  193. package/src/responses/reasoning-replay-cache.ts +105 -0
  194. package/src/responses/spill-store.ts +45 -8
  195. package/src/responses/state.ts +161 -17
  196. package/src/router.ts +302 -16
  197. package/src/routing/analytics.ts +377 -0
  198. package/src/routing/capability.ts +204 -0
  199. package/src/routing/cost.ts +77 -0
  200. package/src/routing/evaluator.ts +444 -0
  201. package/src/routing/health.ts +401 -0
  202. package/src/routing/history/cursor.ts +43 -0
  203. package/src/routing/history/indexer.ts +590 -0
  204. package/src/routing/history/schema.ts +72 -0
  205. package/src/routing/profile.ts +423 -0
  206. package/src/routing/quota.ts +124 -0
  207. package/src/routing/request-evidence.ts +45 -0
  208. package/src/routing/trace.ts +686 -0
  209. package/src/server/auth-cors.ts +46 -6
  210. package/src/server/chat-completions.ts +28 -13
  211. package/src/server/claude-messages.ts +23 -15
  212. package/src/server/gui-static.ts +39 -10
  213. package/src/server/images.ts +10 -1
  214. package/src/server/index.ts +238 -52
  215. package/src/server/lifecycle.ts +228 -9
  216. package/src/server/live.ts +14 -3
  217. package/src/server/management/agent-settings-routes.ts +64 -14
  218. package/src/server/management/combo-routes.ts +5 -5
  219. package/src/server/management/config-routes.ts +1 -1
  220. package/src/server/management/context.ts +42 -2
  221. package/src/server/management/integration-routes.ts +538 -0
  222. package/src/server/management/logs-usage-routes.ts +1 -1
  223. package/src/server/management/model-routes.ts +32 -113
  224. package/src/server/management/model-rows.ts +117 -0
  225. package/src/server/management/native-integration-routes.ts +587 -0
  226. package/src/server/management/oauth-account-routes.ts +1 -1
  227. package/src/server/management/provider-routes.ts +218 -117
  228. package/src/server/management/request-history-routes.ts +191 -0
  229. package/src/server/management/routing-analytics-routes.ts +74 -0
  230. package/src/server/management/routing-profile-routes.ts +177 -0
  231. package/src/server/management/shared.ts +2 -2
  232. package/src/server/management/sidebar-routes.ts +47 -31
  233. package/src/server/management/sync-response.ts +69 -0
  234. package/src/server/management/system-restart.ts +276 -24
  235. package/src/server/management/system-routes.ts +4 -0
  236. package/src/server/management-api.ts +84 -9
  237. package/src/server/management-auth.ts +43 -5
  238. package/src/server/relay-eager.ts +82 -42
  239. package/src/server/relay.ts +120 -6
  240. package/src/server/request-log.ts +26 -6
  241. package/src/server/responses/collaboration.ts +63 -8
  242. package/src/server/responses/compact.ts +272 -41
  243. package/src/server/responses/core.ts +730 -132
  244. package/src/server/responses/fetch-helpers.ts +15 -1
  245. package/src/server/responses-item-id-repair.ts +32 -3
  246. package/src/server/responses-json-events.ts +52 -0
  247. package/src/server/responses-snapshot-repair.ts +621 -0
  248. package/src/server/search.ts +51 -6
  249. package/src/server/sse-payload-rewrite.ts +89 -12
  250. package/src/server/startup-health-cache.ts +7 -1
  251. package/src/server/ws-bridge.ts +11 -17
  252. package/src/service-manager-probe.ts +297 -0
  253. package/src/service.ts +222 -32
  254. package/src/tray/windows-tray.ps1 +9 -0
  255. package/src/tray/windows.ts +15 -7
  256. package/src/types.ts +194 -14
  257. package/src/update/index.ts +13 -13
  258. package/src/update/job.ts +24 -21
  259. package/src/update/notify.ts +7 -3
  260. package/src/usage/cost.ts +0 -0
  261. package/src/usage/expected-prices.ts +129 -10
  262. package/src/usage/log.ts +50 -15
  263. package/src/usage/summary.ts +4 -4
  264. package/src/vision/index.ts +6 -1
  265. package/src/web-search/loop.ts +161 -34
  266. package/gui/dist/assets/index-OY43ubAq.css +0 -1
  267. package/gui/dist/assets/index-YwNnKZcL.js +0 -67
  268. package/gui/dist/provider-icons/antigravity.svg +0 -1
  269. package/gui/dist/provider-icons/claude.svg +0 -1
  270. package/gui/dist/provider-icons/copilot.svg +0 -1
  271. package/gui/dist/provider-icons/cursor.svg +0 -2
  272. package/gui/dist/provider-icons/gemini.svg +0 -1
  273. package/gui/dist/provider-icons/grok-color.svg +0 -1
  274. package/gui/dist/provider-icons/kiro.svg +0 -14
@@ -3,7 +3,7 @@ import { saveConfigPreservingClaudeCode } from "../config";
3
3
  import { isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store";
4
4
  import { codexAccountLogLabel } from "./account-label";
5
5
  import { isCodexAccountPaused } from "./account-pause";
6
- import { isCodexAccountUsable } from "./account-usability";
6
+ import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "./account-usability";
7
7
  import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
8
8
  import {
9
9
  POOL_KEY_CODEX,
@@ -22,6 +22,7 @@ import type { OcxConfig } from "../types";
22
22
  import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
23
23
  import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
24
24
  import { retainedUtf8Bytes } from "../lib/admission";
25
+ import { recordUpstreamHostFailure } from "./upstream-host-health";
25
26
 
26
27
  type ThreadAffinityEntry = {
27
28
  accountId: string;
@@ -122,8 +123,8 @@ const quotaScopedHealth = new Map<string, Map<CodexQuotaScope, CodexUpstreamHeal
122
123
  let lastReconciledGeneration = 0;
123
124
  let liveHealthAccountIds = new Set<string>();
124
125
 
125
- export type CodexUpstreamOutcome = number | "connect_error" | "timeout";
126
- export type CodexUpstreamOutcomeClass = "success" | "credential" | "quota" | "transient" | "caller" | "unknown";
126
+ export type CodexUpstreamOutcome = number | "connect_error" | "timeout" | "connect_neutral";
127
+ export type CodexUpstreamOutcomeClass = "success" | "credential" | "quota" | "transient" | "caller" | "neutral" | "unknown";
127
128
  export type CodexCooldownSource = "retry-after" | "reset-derived" | "default";
128
129
  /**
129
130
  * Native Codex quota groups known to be independent upstream. Keep the mapping
@@ -133,6 +134,20 @@ export type CodexCooldownSource = "retry-after" | "reset-derived" | "default";
133
134
  */
134
135
  export type CodexQuotaScope = "shared" | "spark";
135
136
 
137
+ export type CodexQuotaRecoveryProbeClaim = {
138
+ accountId: string;
139
+ scope?: CodexQuotaScope;
140
+ leaseId: string;
141
+ cooldownGeneration: number;
142
+ credentialGeneration: number;
143
+ /** Claim-time `replacedAt`; unchanged after a probe-owned refresh, stamped on external replacement. */
144
+ credentialReplacedAt?: number;
145
+ };
146
+
147
+ export type CodexQuotaRecoveryProbeProof = {
148
+ credentialGeneration?: number;
149
+ };
150
+
136
151
  /**
137
152
  * Requests without a resolved native model retain the historic one-account-per-
138
153
  * thread behavior. Requests with a known quota scope get an independent
@@ -173,10 +188,20 @@ export type CodexUpstreamOutcomeMeta = {
173
188
  retryAfter?: string | null;
174
189
  resetAt?: unknown | unknown[];
175
190
  now?: number;
191
+ /** (provider, host) ledger key for account-neutral reachability failures (#914). */
192
+ hostKey?: string;
193
+ /** Stable transport code recorded alongside a neutral host failure. */
194
+ lastFailureCode?: string;
176
195
  /** Native model selected for this request; used only for confirmed scoped quotas. */
177
196
  modelId?: string;
178
197
  /** When set, clears affinity for this thread immediately on transient failure. */
179
198
  threadId?: string | null;
199
+ /**
200
+ * Suppress Pool rotation and quota/transient affinity mutations for an account-qualified
201
+ * request. Credential failures still sweep stale affinities because reauthentication is
202
+ * account-wide.
203
+ */
204
+ fixedAccount?: boolean;
180
205
  /**
181
206
  * Probe lease held by this request, when it was admitted through an active
182
207
  * quota cooldown. Only the outcome carrying the current lease may clear the
@@ -195,8 +220,14 @@ export type CodexUpstreamOutcomeMeta = {
195
220
  writerGeneration?: number;
196
221
  };
197
222
 
198
- function hasConfiguredPoolAccount(config: OcxConfig, accountId: string): boolean {
199
- if (accountId === MAIN_CODEX_ACCOUNT_ID) return isCodexAccountUsable(config, accountId);
223
+ function hasConfiguredPoolAccount(
224
+ config: OcxConfig,
225
+ accountId: string,
226
+ selectionOptions?: CodexAccountUsabilityOptions,
227
+ ): boolean {
228
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) {
229
+ return isCodexAccountUsable(config, accountId, selectionOptions);
230
+ }
200
231
  return (config.codexAccounts ?? [])
201
232
  .some(account => isSelectableCodexPoolAccount(account) && account.id === accountId);
202
233
  }
@@ -295,9 +326,15 @@ export function computeCodexUsageScore(quota: {
295
326
  }
296
327
 
297
328
  export function classifyCodexUpstreamOutcome(outcome: CodexUpstreamOutcome): CodexUpstreamOutcomeClass {
329
+ if (outcome === "connect_neutral") return "neutral";
298
330
  if (outcome === "connect_error" || outcome === "timeout") return "transient";
299
331
  if (!Number.isFinite(outcome)) return "unknown";
300
332
  if (outcome >= 200 && outcome < 300) return "success";
333
+ // Explicit 3xx policy (#914): a redirect response is relayed as-is and is
334
+ // never account or host health evidence — it proves the host is reachable
335
+ // and says nothing about the credential. Relayed as the neutral class so a
336
+ // stray 3xx cannot increment an account's transient streak.
337
+ if (outcome >= 300 && outcome < 400) return "neutral";
301
338
  if (outcome === 401 || outcome === 403) return "credential";
302
339
  // 402 Payment Required is treated as quota exhaustion for pool cooldown/failover
303
340
  // (same-request alternate retry records this outcome for the depleted account).
@@ -405,6 +442,135 @@ function canAcquireQuotaProbeLease(health: CodexUpstreamHealth | undefined, now:
405
442
  return now - origin >= CODEX_QUOTA_PROBE_INTERVAL_MS;
406
443
  }
407
444
 
445
+ /**
446
+ * Claim due reset-derived cooldown probes without consulting account selection.
447
+ * Pool credentials only: the main account has no quota-refresh single-flight.
448
+ */
449
+ export function claimDueCodexQuotaRecoveryProbes(
450
+ config: OcxConfig,
451
+ limit: number,
452
+ now = Date.now(),
453
+ ): CodexQuotaRecoveryProbeClaim[] {
454
+ const boundedLimit = Math.max(0, Math.floor(limit));
455
+ if (boundedLimit === 0) return [];
456
+ const candidates: Array<{
457
+ accountId: string;
458
+ scope?: CodexQuotaScope;
459
+ health: CodexUpstreamHealth;
460
+ credentialGeneration: number;
461
+ credentialReplacedAt?: number;
462
+ order: number;
463
+ }> = [];
464
+ for (const [order, account] of (config.codexAccounts ?? []).entries()) {
465
+ if (!isSelectableCodexPoolAccount(account)
466
+ || isCodexAccountPaused(config, account.id)
467
+ || isAccountNeedsReauth(account.id)) continue;
468
+ const record = readCodexAccountRecord(account.id);
469
+ if (!record?.credential || record.deletedAt != null) continue;
470
+ const due = [
471
+ { scope: undefined, health: upstreamHealth.get(account.id) },
472
+ ...[...(quotaScopedHealth.get(account.id) ?? [])].map(([scope, health]) => ({ scope, health })),
473
+ ].filter((entry): entry is { scope?: CodexQuotaScope; health: CodexUpstreamHealth } =>
474
+ // `spark` is deliberately never claimed. `GET /backend-api/wham/usage` takes no scope
475
+ // parameter and returns generic weekly/monthly windows, so its result can never prove a
476
+ // spark recovery — a claim here would spend an upstream call to settle `false` every
477
+ // time, and (with one claim per account per pass) delay the shared scope that CAN recover.
478
+ entry.scope !== "spark"
479
+ && entry.health?.cooldownSource === "reset-derived"
480
+ && canAcquireQuotaProbeLease(entry.health, now))
481
+ .sort((a, b) =>
482
+ (a.health.lastProbeAt ?? a.health.cooldownSince ?? 0)
483
+ - (b.health.lastProbeAt ?? b.health.cooldownSince ?? 0));
484
+ const candidate = due[0];
485
+ if (candidate) candidates.push({
486
+ accountId: account.id,
487
+ ...(candidate.scope ? { scope: candidate.scope } : {}),
488
+ health: candidate.health,
489
+ credentialGeneration: record.generation,
490
+ ...(record.replacedAt !== undefined ? { credentialReplacedAt: record.replacedAt } : {}),
491
+ order,
492
+ });
493
+ }
494
+ candidates.sort((a, b) => {
495
+ const age = (a.health.lastProbeAt ?? a.health.cooldownSince ?? 0)
496
+ - (b.health.lastProbeAt ?? b.health.cooldownSince ?? 0);
497
+ return age || a.order - b.order;
498
+ });
499
+ return candidates.slice(0, boundedLimit).map(candidate => {
500
+ const leaseId = randomUUID();
501
+ const next = {
502
+ ...candidate.health,
503
+ probeLeaseId: leaseId,
504
+ probeLeaseGeneration: candidate.health.cooldownGeneration ?? 0,
505
+ lastProbeAt: now,
506
+ };
507
+ if (candidate.scope) setScopedHealth(candidate.accountId, candidate.scope, next);
508
+ else upstreamHealth.set(candidate.accountId, next);
509
+ return {
510
+ accountId: candidate.accountId,
511
+ ...(candidate.scope ? { scope: candidate.scope } : {}),
512
+ leaseId,
513
+ cooldownGeneration: candidate.health.cooldownGeneration ?? 0,
514
+ credentialGeneration: candidate.credentialGeneration,
515
+ ...(candidate.credentialReplacedAt !== undefined
516
+ ? { credentialReplacedAt: candidate.credentialReplacedAt }
517
+ : {}),
518
+ };
519
+ });
520
+ }
521
+
522
+ /** Settle one background recovery claim without mutating account-wide outcome state. */
523
+ export function settleCodexQuotaRecoveryProbe(
524
+ claim: CodexQuotaRecoveryProbeClaim,
525
+ recovered: boolean,
526
+ proof: CodexQuotaRecoveryProbeProof,
527
+ now = Date.now(),
528
+ ): boolean {
529
+ const health = claim.scope
530
+ ? scopedHealthFor(claim.accountId, claim.scope)
531
+ : upstreamHealth.get(claim.accountId);
532
+ if (!health || health.probeLeaseId !== claim.leaseId) return false;
533
+ const currentRecord = readCodexAccountRecord(claim.accountId);
534
+ const proofGeneration = proof.credentialGeneration;
535
+ // A probe-owned token refresh (getValidCodexToken) advances the credential generation by
536
+ // exactly one while preserving `replacedAt`; an external credential replacement bumps the
537
+ // generation too but stamps a fresh `replacedAt`. Accept the +1 transition only when the
538
+ // claim-time lineage is intact AND the generation the fresh quota was proven under is live.
539
+ const generationFenced = proofGeneration !== undefined
540
+ && (proofGeneration === claim.credentialGeneration
541
+ ? isCodexAccountGenerationLive(claim.accountId, proofGeneration)
542
+ : proofGeneration === claim.credentialGeneration + 1
543
+ && currentRecord?.replacedAt === claim.credentialReplacedAt
544
+ && isCodexAccountGenerationLive(claim.accountId, proofGeneration));
545
+ const fenced = (health.cooldownGeneration ?? 0) === claim.cooldownGeneration
546
+ && (health.probeLeaseGeneration ?? 0) === claim.cooldownGeneration
547
+ && generationFenced;
548
+ if (!recovered || !fenced) {
549
+ const released = withProbeLeaseReleased(health, now);
550
+ if (claim.scope) setScopedHealth(claim.accountId, claim.scope, released);
551
+ else upstreamHealth.set(claim.accountId, released);
552
+ return false;
553
+ }
554
+ if (claim.scope) {
555
+ deleteScopedHealth(claim.accountId, claim.scope);
556
+ } else {
557
+ const {
558
+ cooldownUntil: _until,
559
+ cooldownSince: _since,
560
+ cooldownSource: _source,
561
+ probeLeaseId: _leaseId,
562
+ probeLeaseGeneration: _leaseGeneration,
563
+ ...rest
564
+ } = health;
565
+ upstreamHealth.set(claim.accountId, {
566
+ ...rest,
567
+ cooldownGeneration: claim.cooldownGeneration + 1,
568
+ lastProbeAt: now,
569
+ });
570
+ }
571
+ return true;
572
+ }
573
+
408
574
  /** Acquire the recovery probe for one confirmed model-specific quota group. */
409
575
  export function tryAcquireCodexQuotaScopeProbeLease(
410
576
  accountId: string,
@@ -624,11 +790,12 @@ function isCodexAccountSelectable(
624
790
  accountId: string,
625
791
  now: number,
626
792
  quotaScope?: CodexQuotaScope,
793
+ selectionOptions?: CodexAccountUsabilityOptions,
627
794
  ): boolean {
628
795
  return !isCodexAccountPaused(config, accountId)
629
796
  && getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null
630
797
  && !isCodexAccountSoftAvoided(accountId, now)
631
- && isCodexAccountUsable(config, accountId);
798
+ && isCodexAccountUsable(config, accountId, selectionOptions);
632
799
  }
633
800
 
634
801
  function threadAffinityScope(quotaScope?: CodexQuotaScope): ThreadAffinityScope {
@@ -736,6 +903,7 @@ function getEligiblePoolAccounts(
736
903
  excludeId?: string,
737
904
  now = Date.now(),
738
905
  quotaScope?: CodexQuotaScope,
906
+ selectionOptions?: CodexAccountUsabilityOptions,
739
907
  ): string[] {
740
908
  const ids = (config.codexAccounts ?? [])
741
909
  .filter(account => isSelectableCodexPoolAccount(account)
@@ -744,7 +912,7 @@ function getEligiblePoolAccounts(
744
912
  && !isAccountNeedsReauth(account.id))
745
913
  .filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null)
746
914
  .filter(account => !isCodexAccountSoftAvoided(account.id, now))
747
- .filter(account => isCodexAccountUsable(config, account.id))
915
+ .filter(account => isCodexAccountUsable(config, account.id, selectionOptions))
748
916
  .map(account => account.id);
749
917
  // The main Codex account is not stored in config.codexAccounts; include it as a
750
918
  // first-class rotation candidate when its read-only token is usable (Option A).
@@ -754,7 +922,7 @@ function getEligiblePoolAccounts(
754
922
  && !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)
755
923
  && getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null
756
924
  && !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now)
757
- && isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID)
925
+ && isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions)
758
926
  ) {
759
927
  ids.unshift(MAIN_CODEX_ACCOUNT_ID);
760
928
  }
@@ -765,8 +933,9 @@ function listEligibleCodexAccountIds(
765
933
  config: OcxConfig,
766
934
  now: number,
767
935
  quotaScope?: CodexQuotaScope,
936
+ selectionOptions?: CodexAccountUsabilityOptions,
768
937
  ): string[] {
769
- return getEligiblePoolAccounts(config, undefined, now, quotaScope);
938
+ return getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions);
770
939
  }
771
940
 
772
941
  function stickyLimitForConfig(config: OcxConfig): number {
@@ -790,8 +959,9 @@ function pickFillFirstCodexAccount(
790
959
  config: OcxConfig,
791
960
  now: number,
792
961
  quotaScope?: CodexQuotaScope,
962
+ selectionOptions?: CodexAccountUsabilityOptions,
793
963
  ): string | null {
794
- const eligible = listEligibleCodexAccountIds(config, now, quotaScope);
964
+ const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions);
795
965
  if (eligible.length === 0) return null;
796
966
 
797
967
  const active = getEffectiveActiveCodexAccountId(config);
@@ -799,7 +969,7 @@ function pickFillFirstCodexAccount(
799
969
  return active;
800
970
  }
801
971
 
802
- return pickNextFillFirstCodexAccount(config, active ?? null, eligible, now);
972
+ return pickNextFillFirstCodexAccount(config, active ?? null, eligible, now, selectionOptions);
803
973
  }
804
974
 
805
975
  /** Next eligible account in stable order after `afterId` (wrapping). */
@@ -808,6 +978,7 @@ function pickNextFillFirstCodexAccount(
808
978
  afterId: string | null,
809
979
  eligible = listEligibleCodexAccountIds(config, Date.now()),
810
980
  _now = Date.now(),
981
+ selectionOptions?: CodexAccountUsabilityOptions,
811
982
  ): string | null {
812
983
  if (eligible.length === 0) return null;
813
984
  const ordered = [...eligible].sort((a, b) => a.localeCompare(b));
@@ -820,7 +991,7 @@ function pickNextFillFirstCodexAccount(
820
991
  }
821
992
 
822
993
  const allConfigured = [
823
- ...(isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID) || afterId === MAIN_CODEX_ACCOUNT_ID
994
+ ...(isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions) || afterId === MAIN_CODEX_ACCOUNT_ID
824
995
  ? [MAIN_CODEX_ACCOUNT_ID]
825
996
  : []),
826
997
  ...(config.codexAccounts ?? []).filter(account => !account.isMain).map(account => account.id),
@@ -866,6 +1037,7 @@ function pickUnboundStrategyAccount(
866
1037
  now: number,
867
1038
  commit: boolean,
868
1039
  quotaScope?: CodexQuotaScope,
1040
+ selectionOptions?: CodexAccountUsabilityOptions,
869
1041
  ): string | null {
870
1042
  const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
871
1043
  if (strategy === "quota") return null;
@@ -873,7 +1045,7 @@ function pickUnboundStrategyAccount(
873
1045
 
874
1046
  let picked: string | null = null;
875
1047
  if (strategy === "round-robin") {
876
- const eligible = listEligibleCodexAccountIds(config, now, quotaScope);
1048
+ const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions);
877
1049
  const limit = stickyLimitForConfig(config);
878
1050
  if (!commit) {
879
1051
  return peekRoundRobinAccount(poolKey, eligible, limit);
@@ -887,7 +1059,7 @@ function pickUnboundStrategyAccount(
887
1059
  }
888
1060
 
889
1061
  if (strategy === "fill-first") {
890
- picked = pickFillFirstCodexAccount(config, now, quotaScope);
1062
+ picked = pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions);
891
1063
  if (!picked) return null;
892
1064
  if (commit) {
893
1065
  if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked);
@@ -911,10 +1083,11 @@ function pickLowerUsageAccount(
911
1083
  activeUsage: number,
912
1084
  now: number,
913
1085
  quotaScope?: CodexQuotaScope,
1086
+ selectionOptions?: CodexAccountUsabilityOptions,
914
1087
  ): string {
915
1088
  let best = active;
916
1089
  let bestUsage = activeUsage;
917
- for (const id of getEligiblePoolAccounts(config, active, now, quotaScope)) {
1090
+ for (const id of getEligiblePoolAccounts(config, active, now, quotaScope, selectionOptions)) {
918
1091
  const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id));
919
1092
  if (usage < bestUsage) {
920
1093
  best = id;
@@ -929,10 +1102,11 @@ export function pickLowestUsageCodexAccount(
929
1102
  excludeId?: string,
930
1103
  now = Date.now(),
931
1104
  quotaScope?: CodexQuotaScope,
1105
+ selectionOptions?: CodexAccountUsabilityOptions,
932
1106
  ): string | null {
933
1107
  let best: string | null = null;
934
1108
  let bestUsage = Number.POSITIVE_INFINITY;
935
- for (const id of getEligiblePoolAccounts(config, excludeId, now, quotaScope)) {
1109
+ for (const id of getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions)) {
936
1110
  const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id));
937
1111
  if (usage < bestUsage) {
938
1112
  best = id;
@@ -952,17 +1126,18 @@ export function pickAlternateCodexAccount(
952
1126
  excludeId: string,
953
1127
  now = Date.now(),
954
1128
  quotaScope?: CodexQuotaScope,
1129
+ selectionOptions?: CodexAccountUsabilityOptions,
955
1130
  ): string | null {
956
1131
  const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
957
1132
  if (strategy === "round-robin") {
958
- const eligible = listEligibleCodexAccountIds(config, now, quotaScope).filter(id => id !== excludeId);
1133
+ const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions).filter(id => id !== excludeId);
959
1134
  return pickRoundRobinAccount(codexPoolKeyForScope(quotaScope), eligible, stickyLimitForConfig(config));
960
1135
  }
961
1136
  if (strategy === "fill-first") {
962
- const eligible = listEligibleCodexAccountIds(config, now, quotaScope).filter(id => id !== excludeId);
963
- return pickNextFillFirstCodexAccount(config, excludeId, eligible, now);
1137
+ const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions).filter(id => id !== excludeId);
1138
+ return pickNextFillFirstCodexAccount(config, excludeId, eligible, now, selectionOptions);
964
1139
  }
965
- return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope);
1140
+ return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope, selectionOptions);
966
1141
  }
967
1142
 
968
1143
  /** Effective active: automatic runtime cursor, else operator/persisted selection. */
@@ -1027,6 +1202,7 @@ function applyQuotaAutoSwitch(
1027
1202
  active: string,
1028
1203
  now: number,
1029
1204
  quotaScope?: CodexQuotaScope,
1205
+ selectionOptions?: CodexAccountUsabilityOptions,
1030
1206
  ): string {
1031
1207
  const threshold = config.autoSwitchThreshold ?? 80;
1032
1208
  if (threshold <= 0) return active;
@@ -1036,7 +1212,7 @@ function applyQuotaAutoSwitch(
1036
1212
  // threshold. Wait for quota priming instead of rotating among guesses.
1037
1213
  if (isUnknownUsage(activeUsage)) return active;
1038
1214
  if (activeUsage < threshold) return active;
1039
- const best = pickLowerUsageAccount(config, active, activeUsage, now, quotaScope);
1215
+ const best = pickLowerUsageAccount(config, active, activeUsage, now, quotaScope, selectionOptions);
1040
1216
  if (best !== active) {
1041
1217
  if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, best);
1042
1218
  return best;
@@ -1053,9 +1229,14 @@ function shouldFailover(config: OcxConfig, accountId: string, now: number): bool
1053
1229
  return !!health && health.consecutiveFailures >= threshold;
1054
1230
  }
1055
1231
 
1056
- function applyFailureFailover(config: OcxConfig, active: string, now: number): string {
1232
+ function applyFailureFailover(
1233
+ config: OcxConfig,
1234
+ active: string,
1235
+ now: number,
1236
+ selectionOptions?: CodexAccountUsabilityOptions,
1237
+ ): string {
1057
1238
  if (!shouldFailover(config, active, now)) return active;
1058
- const best = pickAlternateCodexAccount(config, active, now);
1239
+ const best = pickAlternateCodexAccount(config, active, now, undefined, selectionOptions);
1059
1240
  if (best) {
1060
1241
  promoteActiveCodexAccount(config, best);
1061
1242
  return best;
@@ -1086,13 +1267,14 @@ export function previewCodexAccountForRequest(
1086
1267
  config: OcxConfig,
1087
1268
  now = Date.now(),
1088
1269
  quotaScope?: CodexQuotaScope,
1270
+ selectionOptions?: CodexAccountUsabilityOptions,
1089
1271
  ): string | null {
1090
1272
  const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined;
1091
1273
  if (threadId && entry) {
1092
1274
  if (
1093
1275
  !isThreadAffinityExpired(entry, now)
1094
1276
  && isThreadAffinityGenerationLive(entry)
1095
- && isCodexAccountSelectable(config, entry.accountId, now, quotaScope)
1277
+ && isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions)
1096
1278
  && !shouldFailover(config, entry.accountId, now)
1097
1279
  ) {
1098
1280
  // Quota strategy only: non-quota strategies keep affinity for ongoing threads
@@ -1106,7 +1288,14 @@ export function previewCodexAccountForRequest(
1106
1288
  getPoolAccountPlan(config, entry.accountId),
1107
1289
  );
1108
1290
  if (!isUnknownUsage(usage) && usage >= threshold) {
1109
- const best = pickLowerUsageAccount(config, entry.accountId, usage, now, quotaScope);
1291
+ const best = pickLowerUsageAccount(
1292
+ config,
1293
+ entry.accountId,
1294
+ usage,
1295
+ now,
1296
+ quotaScope,
1297
+ selectionOptions,
1298
+ );
1110
1299
  if (best !== entry.accountId) return best;
1111
1300
  }
1112
1301
  }
@@ -1116,17 +1305,27 @@ export function previewCodexAccountForRequest(
1116
1305
  // Stale/unusable affinity is ignored for preview (no map mutation).
1117
1306
  }
1118
1307
 
1119
- const strategyPick = pickUnboundStrategyAccount(config, threadId, now, false, quotaScope);
1308
+ const strategyPick = pickUnboundStrategyAccount(
1309
+ config,
1310
+ threadId,
1311
+ now,
1312
+ false,
1313
+ quotaScope,
1314
+ selectionOptions,
1315
+ );
1120
1316
  if (strategyPick) return strategyPick;
1121
1317
 
1122
1318
  let active = getEffectiveActiveCodexAccountId(config) ?? null;
1123
1319
  if (!active) {
1124
- return pickLowestUsageCodexAccount(config, undefined, now, quotaScope);
1320
+ return pickLowestUsageCodexAccount(config, undefined, now, quotaScope, selectionOptions);
1125
1321
  }
1126
- if (!isCodexAccountSelectable(config, active, now, quotaScope)) {
1127
- const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope);
1322
+ if (!isCodexAccountSelectable(config, active, now, quotaScope, selectionOptions)) {
1323
+ const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions);
1128
1324
  if (fallback) active = fallback;
1129
- else if (hasConfiguredPoolAccount(config, active) && !isCodexAccountPaused(config, active)) return active;
1325
+ else if (
1326
+ hasConfiguredPoolAccount(config, active, selectionOptions)
1327
+ && !isCodexAccountPaused(config, active)
1328
+ ) return active;
1130
1329
  else return null;
1131
1330
  }
1132
1331
 
@@ -1134,19 +1333,19 @@ export function previewCodexAccountForRequest(
1134
1333
  if (threshold > 0) {
1135
1334
  const usage = computeCodexUsageScore(getAccountQuota(active), getPoolAccountPlan(config, active));
1136
1335
  if (!isUnknownUsage(usage) && usage >= threshold) {
1137
- active = pickLowerUsageAccount(config, active, usage, now, quotaScope);
1336
+ active = pickLowerUsageAccount(config, active, usage, now, quotaScope, selectionOptions);
1138
1337
  }
1139
1338
  }
1140
1339
  if (shouldFailover(config, active, now)) {
1141
- const best = pickLowestUsageCodexAccount(config, active, now, quotaScope);
1340
+ const best = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions);
1142
1341
  if (best) active = best;
1143
1342
  }
1144
- if (!isCodexAccountUsable(config, active)) {
1145
- return hasConfiguredPoolAccount(config, active) ? active : null;
1343
+ if (!isCodexAccountUsable(config, active, selectionOptions)) {
1344
+ return hasConfiguredPoolAccount(config, active, selectionOptions) ? active : null;
1146
1345
  }
1147
1346
  if (isCodexAccountPaused(config, active)) return null;
1148
1347
  if (getCodexQuotaHealthSnapshot(active, quotaScope, now)) {
1149
- return hasConfiguredPoolAccount(config, active) ? active : null;
1348
+ return hasConfiguredPoolAccount(config, active, selectionOptions) ? active : null;
1150
1349
  }
1151
1350
  return active;
1152
1351
  }
@@ -1156,6 +1355,7 @@ export function resolveCodexAccountForThreadDetailed(
1156
1355
  config: OcxConfig,
1157
1356
  now = Date.now(),
1158
1357
  quotaScope?: CodexQuotaScope,
1358
+ selectionOptions?: CodexAccountUsabilityOptions,
1159
1359
  ): CodexThreadResolution {
1160
1360
  const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined;
1161
1361
  if (threadId && entry) {
@@ -1165,7 +1365,7 @@ export function resolveCodexAccountForThreadDetailed(
1165
1365
  }
1166
1366
  if (
1167
1367
  isThreadAffinityGenerationLive(entry)
1168
- && isCodexAccountSelectable(config, entry.accountId, now, quotaScope)
1368
+ && isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions)
1169
1369
  // Affined threads must leave a failing account once the streak trips failover
1170
1370
  // (soft-avoid covers the first-hit case; this catches post-avoid residual streaks).
1171
1371
  && !shouldFailover(config, entry.accountId, now)
@@ -1192,7 +1392,7 @@ export function resolveCodexAccountForThreadDetailed(
1192
1392
  if (overThreshold || now - entry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS) {
1193
1393
  entry.lastReevalAt = now;
1194
1394
  if (overThreshold) {
1195
- const best = pickLowerUsageAccount(config, entry.accountId, usage, now, quotaScope);
1395
+ const best = pickLowerUsageAccount(config, entry.accountId, usage, now, quotaScope, selectionOptions);
1196
1396
  if (best !== entry.accountId) {
1197
1397
  if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, best);
1198
1398
  bindThreadAffinity(threadId, best, now, quotaScope); // rebinds + resets clocks
@@ -1206,35 +1406,42 @@ export function resolveCodexAccountForThreadDetailed(
1206
1406
  deleteThreadAffinity(threadId, quotaScope);
1207
1407
  }
1208
1408
 
1209
- const strategyPick = pickUnboundStrategyAccount(config, threadId, now, true, quotaScope);
1409
+ const strategyPick = pickUnboundStrategyAccount(config, threadId, now, true, quotaScope, selectionOptions);
1210
1410
  if (strategyPick) return { status: "selected", accountId: strategyPick };
1211
1411
 
1212
1412
  let active = getEffectiveActiveCodexAccountId(config);
1213
1413
  if (!active) {
1214
- const selected = pickLowestUsageCodexAccount(config, undefined, now, quotaScope);
1414
+ const selected = pickLowestUsageCodexAccount(config, undefined, now, quotaScope, selectionOptions);
1215
1415
  if (!selected) return { status: "none" };
1216
1416
  if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, selected);
1217
1417
  active = selected;
1218
1418
  }
1219
- if (!isCodexAccountSelectable(config, active, now, quotaScope)) {
1220
- const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope);
1419
+ if (!isCodexAccountSelectable(config, active, now, quotaScope, selectionOptions)) {
1420
+ const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions);
1221
1421
  if (fallback) {
1222
1422
  if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, fallback);
1223
1423
  active = fallback;
1224
- } else if (hasConfiguredPoolAccount(config, active) && !isCodexAccountPaused(config, active)) {
1424
+ } else if (
1425
+ hasConfiguredPoolAccount(config, active, selectionOptions)
1426
+ && !isCodexAccountPaused(config, active)
1427
+ ) {
1225
1428
  return { status: "selected", accountId: active };
1226
1429
  } else {
1227
1430
  return { status: "none" };
1228
1431
  }
1229
1432
  }
1230
- active = applyQuotaAutoSwitch(config, active, now, quotaScope);
1231
- active = applyFailureFailover(config, active, now);
1232
- if (!isCodexAccountUsable(config, active)) {
1233
- return hasConfiguredPoolAccount(config, active) ? { status: "selected", accountId: active } : { status: "none" };
1433
+ active = applyQuotaAutoSwitch(config, active, now, quotaScope, selectionOptions);
1434
+ active = applyFailureFailover(config, active, now, selectionOptions);
1435
+ if (!isCodexAccountUsable(config, active, selectionOptions)) {
1436
+ return hasConfiguredPoolAccount(config, active, selectionOptions)
1437
+ ? { status: "selected", accountId: active }
1438
+ : { status: "none" };
1234
1439
  }
1235
1440
  if (isCodexAccountPaused(config, active)) return { status: "none" };
1236
1441
  if (getCodexQuotaHealthSnapshot(active, quotaScope, now)) {
1237
- return hasConfiguredPoolAccount(config, active) ? { status: "selected", accountId: active } : { status: "none" };
1442
+ return hasConfiguredPoolAccount(config, active, selectionOptions)
1443
+ ? { status: "selected", accountId: active }
1444
+ : { status: "none" };
1238
1445
  }
1239
1446
  if (threadId) bindThreadAffinity(threadId, active, now, quotaScope);
1240
1447
  return { status: "selected", accountId: active };
@@ -1246,6 +1453,13 @@ export function recordCodexUpstreamOutcome(
1246
1453
  outcome: CodexUpstreamOutcome,
1247
1454
  meta: CodexUpstreamOutcomeMeta = {},
1248
1455
  ): void {
1456
+ // Host-level evidence is account-independent (#914): a pre-connection
1457
+ // reachability failure is recorded in the (provider, host) ledger even when
1458
+ // there is no account to attribute, or the account's writer generation is
1459
+ // stale — the early returns below must not gate it.
1460
+ if (outcome === "connect_neutral" && meta.hostKey) {
1461
+ recordUpstreamHostFailure(meta.hostKey, { code: meta.lastFailureCode, now: meta.now ?? Date.now() });
1462
+ }
1249
1463
  if (!accountId) return;
1250
1464
  const writerGeneration = meta.writerGeneration ?? captureConfigGeneration();
1251
1465
  if (writerGeneration < lastReconciledGeneration && !liveHealthAccountIds.has(accountId)) return;
@@ -1310,6 +1524,25 @@ export function recordCodexUpstreamOutcome(
1310
1524
  return;
1311
1525
  }
1312
1526
 
1527
+ if (outcomeClass === "neutral") {
1528
+ // A proven pre-connection reachability failure (DNS / TCP refusal) or a
1529
+ // relayed 3xx is host-level, not account evidence: rotation cannot repair
1530
+ // it and must not happen (#914). Conclude any owned probe lease, record the
1531
+ // failure under the (provider, host) ledger when one is named, and leave
1532
+ // account health, thread affinity, and the active account untouched.
1533
+ const current = upstreamHealth.get(accountId);
1534
+ const scopedProbe = meta.probeQuotaScope
1535
+ ? scopedHealthFor(accountId, meta.probeQuotaScope)
1536
+ : undefined;
1537
+ if (scopedProbe && meta.probeQuotaScope && ownsProbeLease(scopedProbe, meta)) {
1538
+ setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now));
1539
+ }
1540
+ if (ownsProbeLease(current, meta)) {
1541
+ upstreamHealth.set(accountId, withProbeLeaseReleased(current!, now));
1542
+ }
1543
+ return;
1544
+ }
1545
+
1313
1546
  const lastFailureStatus = typeof outcome === "number" ? outcome : 0;
1314
1547
  if (outcomeClass === "credential") {
1315
1548
  // 401/403 quarantines the account for reauth. That supersedes quota state
@@ -1354,7 +1587,7 @@ export function recordCodexUpstreamOutcome(
1354
1587
  // The shared native scope is the existing account-wide native behavior:
1355
1588
  // threads must leave it and new requests should prefer an eligible account.
1356
1589
  // Spark remains isolated so a same-account Terra/Luna combo fallback can run.
1357
- if (quotaScope === "shared") {
1590
+ if (quotaScope === "shared" && !meta.fixedAccount) {
1358
1591
  clearThreadAccountMapForAccount(accountId);
1359
1592
  notePoolRotationFailure(POOL_KEY_CODEX, accountId);
1360
1593
  if (getEffectiveActiveCodexAccountId(config) === accountId) {
@@ -1399,17 +1632,19 @@ export function recordCodexUpstreamOutcome(
1399
1632
  ...(prior?.lastProbeAt !== undefined ? { lastProbeAt: prior.lastProbeAt } : {}),
1400
1633
  }),
1401
1634
  });
1402
- clearThreadAccountMapForAccount(accountId);
1403
- notePoolRotationFailure(POOL_KEY_CODEX, accountId);
1404
- const effectiveActive = getEffectiveActiveCodexAccountId(config);
1405
- if (effectiveActive === accountId) {
1406
- // Same-request 429 retry already picked via excludeAccountId — reuse it so
1407
- // round-robin does not advance the ring a second time.
1408
- const reused = meta.promoteAccountId && meta.promoteAccountId !== accountId
1409
- ? meta.promoteAccountId
1410
- : null;
1411
- const fallback = reused ?? pickAlternateCodexAccount(config, accountId, now);
1412
- if (fallback) promoteActiveCodexAccount(config, fallback);
1635
+ if (!meta.fixedAccount) {
1636
+ clearThreadAccountMapForAccount(accountId);
1637
+ notePoolRotationFailure(POOL_KEY_CODEX, accountId);
1638
+ const effectiveActive = getEffectiveActiveCodexAccountId(config);
1639
+ if (effectiveActive === accountId) {
1640
+ // Same-request 429 retry already picked via excludeAccountId reuse it so
1641
+ // round-robin does not advance the ring a second time.
1642
+ const reused = meta.promoteAccountId && meta.promoteAccountId !== accountId
1643
+ ? meta.promoteAccountId
1644
+ : null;
1645
+ const fallback = reused ?? pickAlternateCodexAccount(config, accountId, now);
1646
+ if (fallback) promoteActiveCodexAccount(config, fallback);
1647
+ }
1413
1648
  }
1414
1649
  return;
1415
1650
  }
@@ -1454,15 +1689,17 @@ export function recordCodexUpstreamOutcome(
1454
1689
  // thread is still pinned to the FAILING account — a late failure from account A
1455
1690
  // must not delete a newer healthy binding to account B (race: T→A, A fails,
1456
1691
  // T→B, late A failure must not delete B's mapping).
1457
- if (failoverReady && meta.threadId) {
1692
+ if (!meta.fixedAccount && failoverReady && meta.threadId) {
1458
1693
  deleteThreadAffinitiesForAccount(meta.threadId, accountId);
1459
1694
  }
1460
1695
  // Once the account is past the failover streak, clear every thread still pinned
1461
1696
  // to it — matching 429 affinity behavior so "continue" cannot stay on a bad peer.
1462
- if (shouldFailover(config, accountId, now)) {
1697
+ if (!meta.fixedAccount && shouldFailover(config, accountId, now)) {
1463
1698
  clearThreadAccountMapForAccount(accountId);
1464
1699
  }
1465
- if (getEffectiveActiveCodexAccountId(config) === accountId) applyFailureFailover(config, accountId, now);
1700
+ if (!meta.fixedAccount && getEffectiveActiveCodexAccountId(config) === accountId) {
1701
+ applyFailureFailover(config, accountId, now);
1702
+ }
1466
1703
  }
1467
1704
 
1468
1705
  export function formatCodexProviderForLog(providerName: string, accountId: string | null, config: OcxConfig): string {