@iislee/opencodex 2.11.0 → 2.31.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 (675) hide show
  1. package/AGENTS_INSTALL.md +109 -0
  2. package/README.md +114 -19
  3. package/bin/ocx.mjs +164 -36
  4. package/bin/package-main.mjs +1 -1
  5. package/gui/dist/assets/index-BF38heuV.js +104 -0
  6. package/gui/dist/assets/index-DMiI18Kv.css +1 -0
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  9. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  10. package/gui/dist/provider-icons/claude-color.svg +1 -1
  11. package/gui/dist/provider-icons/cline-color.svg +16 -0
  12. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  13. package/gui/dist/provider-icons/commandcode-color.svg +1 -0
  14. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  15. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  16. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  17. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  18. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  19. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  20. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  21. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  22. package/gui/dist/provider-icons/grok.svg +1 -1
  23. package/gui/dist/provider-icons/groq-color.svg +1 -1
  24. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  25. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  26. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  27. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  28. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  29. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  30. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  31. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  32. package/gui/dist/provider-icons/openai.svg +1 -1
  33. package/gui/dist/provider-icons/opencode.svg +2 -1
  34. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  35. package/gui/dist/provider-icons/pi.svg +2 -2
  36. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  37. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  38. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  39. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  40. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  41. package/package.json +19 -10
  42. package/src/adapters/anthropic-output-schema.ts +137 -0
  43. package/src/adapters/anthropic.ts +376 -52
  44. package/src/adapters/base.ts +54 -7
  45. package/src/adapters/client-fingerprint.ts +18 -12
  46. package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +69 -0
  47. package/src/adapters/command-code.ts +601 -0
  48. package/src/adapters/cursor/checkpoint-store.ts +303 -0
  49. package/src/adapters/cursor/cursor-errors.ts +129 -6
  50. package/src/adapters/cursor/discovery.ts +61 -4
  51. package/src/adapters/cursor/effort-map.ts +27 -3
  52. package/src/adapters/cursor/framing.ts +39 -0
  53. package/src/adapters/cursor/h2-pool.ts +123 -0
  54. package/src/adapters/cursor/http1-bidi.ts +361 -0
  55. package/src/adapters/cursor/images.ts +704 -0
  56. package/src/adapters/cursor/live-models.ts +174 -58
  57. package/src/adapters/cursor/live-transport.ts +609 -170
  58. package/src/adapters/cursor/native-exec-common.ts +23 -2
  59. package/src/adapters/cursor/native-exec-fs.ts +10 -7
  60. package/src/adapters/cursor/native-exec-network.ts +1 -1
  61. package/src/adapters/cursor/native-exec-shell.ts +0 -1
  62. package/src/adapters/cursor/native-exec.ts +101 -14
  63. package/src/adapters/cursor/protobuf-events.ts +829 -11
  64. package/src/adapters/cursor/protobuf-request.ts +383 -65
  65. package/src/adapters/cursor/request-builder.ts +237 -31
  66. package/src/adapters/cursor/tool-definitions.ts +242 -12
  67. package/src/adapters/cursor/tool-result-normalize.ts +92 -0
  68. package/src/adapters/cursor/transport.ts +22 -0
  69. package/src/adapters/cursor/types.ts +28 -1
  70. package/src/adapters/cursor.ts +133 -7
  71. package/src/adapters/google-antigravity-replay.ts +558 -34
  72. package/src/adapters/google-antigravity-wire.ts +43 -10
  73. package/src/adapters/google-http.ts +38 -10
  74. package/src/adapters/google-tool-schema.ts +80 -15
  75. package/src/adapters/google-truncation.ts +11 -0
  76. package/src/adapters/google.ts +618 -74
  77. package/src/adapters/identity.ts +39 -6
  78. package/src/adapters/kiro-errors.ts +11 -0
  79. package/src/adapters/kiro-events.ts +19 -1
  80. package/src/adapters/kiro-thinking.ts +18 -2
  81. package/src/adapters/kiro-tools.ts +10 -1
  82. package/src/adapters/kiro.ts +92 -54
  83. package/src/adapters/mimo-free.ts +17 -0
  84. package/src/adapters/openai-chat-url.ts +11 -0
  85. package/src/adapters/openai-chat.ts +1311 -336
  86. package/src/adapters/openai-responses-url.ts +16 -0
  87. package/src/adapters/openai-responses.ts +830 -56
  88. package/src/adapters/registry.ts +175 -0
  89. package/src/adapters/responses-tool-schema.ts +67 -0
  90. package/src/adapters/tool-call-id.ts +119 -0
  91. package/src/adapters/tool-catalog-nudge.ts +104 -21
  92. package/src/adapters/xai-web-search.ts +185 -0
  93. package/src/bridge.ts +428 -61
  94. package/src/chat/inbound.ts +43 -19
  95. package/src/chat/outbound.ts +82 -26
  96. package/src/claude/agents-inject.ts +32 -9
  97. package/src/claude/context-windows.ts +21 -5
  98. package/src/claude/desktop-3p.ts +243 -9
  99. package/src/claude/gateway-cache.ts +41 -4
  100. package/src/claude/inbound.ts +72 -3
  101. package/src/claude/model-info.ts +38 -15
  102. package/src/claude/outbound.ts +70 -16
  103. package/src/cli/account-api.ts +35 -1
  104. package/src/cli/account-auth.ts +33 -6
  105. package/src/cli/account-catalog-refresh.ts +14 -0
  106. package/src/cli/account-extended.ts +389 -2
  107. package/src/cli/account-main.ts +317 -0
  108. package/src/cli/account.ts +30 -6
  109. package/src/cli/agent.ts +45 -1
  110. package/src/cli/claude-agent-startup-sync.ts +73 -0
  111. package/src/cli/claude-desktop.ts +29 -4
  112. package/src/cli/claude.ts +103 -34
  113. package/src/cli/codex-log-guard-doctor.ts +103 -0
  114. package/src/cli/codex-shim-autorestore.ts +2 -0
  115. package/src/cli/codex-shim-readiness.ts +76 -0
  116. package/src/cli/combo.ts +8 -0
  117. package/src/cli/config-command.ts +74 -10
  118. package/src/cli/dispatch.ts +593 -0
  119. package/src/cli/doctor.ts +315 -43
  120. package/src/cli/ensure-desired-integrations.ts +152 -0
  121. package/src/cli/export-command.ts +46 -20
  122. package/src/cli/help.ts +20 -271
  123. package/src/cli/index.ts +378 -519
  124. package/src/cli/init.ts +4 -17
  125. package/src/cli/integrations.ts +120 -2
  126. package/src/cli/lab.ts +607 -0
  127. package/src/cli/launcher-context.ts +77 -0
  128. package/src/cli/minimax.ts +497 -0
  129. package/src/cli/models-runtime.ts +35 -2
  130. package/src/cli/models.ts +100 -14
  131. package/src/cli/observe.ts +92 -3
  132. package/src/cli/opencode.ts +4 -2
  133. package/src/cli/provider-runtime.ts +18 -1
  134. package/src/cli/provider.ts +24 -3
  135. package/src/cli/ready.ts +301 -0
  136. package/src/cli/registry.ts +437 -0
  137. package/src/cli/root.ts +86 -0
  138. package/src/cli/route-policy.ts +92 -0
  139. package/src/cli/runtime-api.ts +6 -3
  140. package/src/cli/star-prompt.ts +71 -15
  141. package/src/cli/status.ts +10 -3
  142. package/src/cli/system-restart-client.ts +146 -0
  143. package/src/cli/tray-proxy.ts +153 -6
  144. package/src/cli/v2.ts +105 -10
  145. package/src/cli.ts +1 -1
  146. package/src/clients/config-export.ts +1358 -21
  147. package/src/codex/account-label.ts +14 -1
  148. package/src/codex/account-lifecycle.ts +130 -13
  149. package/src/codex/account-namespaces.ts +49 -3
  150. package/src/codex/account-priority.ts +83 -0
  151. package/src/codex/account-store.ts +29 -2
  152. package/src/codex/account-usability.ts +25 -2
  153. package/src/codex/admission.ts +256 -0
  154. package/src/codex/affinity-debug.ts +162 -0
  155. package/src/codex/app-server-processes.ts +493 -106
  156. package/src/codex/app-server-restart-service.ts +232 -0
  157. package/src/codex/auth-api.ts +849 -242
  158. package/src/codex/auth-collision.ts +5 -3
  159. package/src/codex/auth-context.ts +345 -32
  160. package/src/codex/autostart-health.ts +8 -1
  161. package/src/codex/catalog/account-models.ts +67 -0
  162. package/src/codex/catalog/aggregation.ts +68 -10
  163. package/src/codex/catalog/bundled.ts +331 -33
  164. package/src/codex/catalog/effort.ts +121 -30
  165. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  166. package/src/codex/catalog/kinds.ts +2 -0
  167. package/src/codex/catalog/metadata.ts +529 -45
  168. package/src/codex/catalog/native-models.ts +72 -0
  169. package/src/codex/catalog/parsing.ts +224 -30
  170. package/src/codex/catalog/provider-fetch.ts +1460 -134
  171. package/src/codex/catalog/sync.ts +1449 -186
  172. package/src/codex/catalog-admission.ts +199 -0
  173. package/src/codex/catalog-refresh-status.ts +105 -0
  174. package/src/codex/catalog-write-serialization.ts +242 -0
  175. package/src/codex/catalog.ts +6 -3
  176. package/src/codex/codex-write-lock.ts +384 -0
  177. package/src/codex/convergence-types.ts +614 -0
  178. package/src/codex/convergence.ts +651 -0
  179. package/src/codex/coordinator-doctor.ts +332 -0
  180. package/src/codex/custom-model-catalog-migration.ts +176 -0
  181. package/src/codex/desired-state.ts +230 -0
  182. package/src/codex/features.ts +636 -39
  183. package/src/codex/generation.ts +202 -0
  184. package/src/codex/history-job.ts +407 -0
  185. package/src/codex/history-lock.ts +242 -0
  186. package/src/codex/history-migration-guardian.ts +26 -20
  187. package/src/codex/history-provider.ts +231 -28
  188. package/src/codex/history-transition.ts +105 -0
  189. package/src/codex/history-worker.ts +220 -0
  190. package/src/codex/inject-coordination.ts +290 -0
  191. package/src/codex/inject.ts +1073 -152
  192. package/src/codex/injected-marker.ts +37 -3
  193. package/src/codex/integration-record.ts +266 -0
  194. package/src/codex/internal/catalog-writer.ts +203 -0
  195. package/src/codex/internal/history-writer.ts +80 -0
  196. package/src/codex/journal.ts +66 -4
  197. package/src/codex/log-guard/inspect.ts +506 -0
  198. package/src/codex/log-guard/lock.ts +150 -0
  199. package/src/codex/log-guard/maintenance.ts +403 -0
  200. package/src/codex/log-guard/path-safety.ts +88 -0
  201. package/src/codex/log-guard/policy.ts +44 -0
  202. package/src/codex/log-guard/processes.ts +205 -0
  203. package/src/codex/log-guard/protection.ts +489 -0
  204. package/src/codex/log-guard/sqlite-errors.ts +9 -0
  205. package/src/codex/main-account-cache.ts +24 -0
  206. package/src/codex/main-account.ts +29 -1
  207. package/src/codex/management-convergence.ts +167 -0
  208. package/src/codex/model-cache.ts +56 -10
  209. package/src/codex/model-entitlements.ts +353 -0
  210. package/src/codex/native-main-admission.ts +47 -0
  211. package/src/codex/native-main-auth-temp.ts +187 -0
  212. package/src/codex/native-main-claim.ts +178 -0
  213. package/src/codex/native-main-lock-file.ts +162 -0
  214. package/src/codex/native-main-owner.ts +329 -0
  215. package/src/codex/native-profile-api.ts +247 -0
  216. package/src/codex/native-profile-manager.ts +1531 -0
  217. package/src/codex/native-profile-processes.ts +121 -0
  218. package/src/codex/native-profile-recovery.ts +99 -0
  219. package/src/codex/native-profile-stage-store.ts +387 -0
  220. package/src/codex/native-profile-startup.ts +492 -0
  221. package/src/codex/native-profile-store.ts +855 -0
  222. package/src/codex/native-profile-types.ts +120 -0
  223. package/src/codex/native-residue.ts +682 -0
  224. package/src/codex/paths.ts +80 -1
  225. package/src/codex/plan-from-token.ts +140 -0
  226. package/src/codex/plan.ts +40 -0
  227. package/src/codex/plugins-doctor.ts +1 -1
  228. package/src/codex/pool-rotation.ts +74 -4
  229. package/src/codex/project-config-warnings.ts +20 -6
  230. package/src/codex/prompt-journal.ts +352 -0
  231. package/src/codex/prompt-layers.ts +967 -0
  232. package/src/codex/prompt-lock.ts +143 -0
  233. package/src/codex/quota-rejection.ts +298 -0
  234. package/src/codex/quota.ts +175 -13
  235. package/src/codex/refresh.ts +11 -2
  236. package/src/codex/reset-credit-recovery.ts +1044 -0
  237. package/src/codex/routing.ts +505 -94
  238. package/src/codex/runtime.ts +159 -38
  239. package/src/codex/shim.ts +1009 -28
  240. package/src/codex/subagent-model-fallback.ts +350 -35
  241. package/src/codex/sync.ts +191 -2
  242. package/src/codex/transition-state.ts +612 -0
  243. package/src/codex/upstream-host-health.ts +368 -0
  244. package/src/codex/user-identity.ts +557 -0
  245. package/src/codex/warmup.ts +187 -81
  246. package/src/codex/write-coordination.ts +114 -0
  247. package/src/combos/failover.ts +20 -0
  248. package/src/combos/index.ts +4 -0
  249. package/src/combos/request.ts +32 -0
  250. package/src/combos/types.ts +81 -9
  251. package/src/config/provider-name.ts +24 -0
  252. package/src/config.ts +1762 -140
  253. package/src/generated/compatibility-version.json +3164 -0
  254. package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +19 -17
  255. package/src/grok/inject.ts +16 -5
  256. package/src/grok/inspect.ts +45 -0
  257. package/src/grok/sync.ts +2 -2
  258. package/src/images/loop.ts +152 -29
  259. package/src/images/plan.ts +23 -13
  260. package/src/integrations/config-io.ts +269 -0
  261. package/src/integrations/journal.ts +315 -0
  262. package/src/integrations/merge.ts +135 -0
  263. package/src/integrations/mutation-flight.ts +71 -0
  264. package/src/integrations/native/ownership-preflight.ts +202 -0
  265. package/src/integrations/omp-yaml-source.ts +358 -0
  266. package/src/integrations/owned-refresh.ts +74 -0
  267. package/src/integrations/ownership.ts +111 -0
  268. package/src/integrations/registry.ts +159 -0
  269. package/src/integrations/serialize.ts +314 -0
  270. package/src/integrations/state.ts +361 -0
  271. package/src/integrations/store.ts +103 -0
  272. package/src/integrations/writer-lock.ts +98 -0
  273. package/src/integrations/writer.ts +691 -0
  274. package/src/lab/artifacts/sanitize.ts +586 -0
  275. package/src/lab/artifacts/secure-fs.ts +475 -0
  276. package/src/lab/artifacts/store.ts +310 -0
  277. package/src/lab/automation/budgets.ts +78 -0
  278. package/src/lab/automation/config-persistence.ts +256 -0
  279. package/src/lab/automation/constants.ts +39 -0
  280. package/src/lab/automation/cooldown.ts +103 -0
  281. package/src/lab/automation/dispatch.ts +211 -0
  282. package/src/lab/automation/index.ts +13 -0
  283. package/src/lab/automation/orchestrator.ts +499 -0
  284. package/src/lab/automation/persistence.ts +512 -0
  285. package/src/lab/automation/planner.ts +371 -0
  286. package/src/lab/automation/policy.ts +136 -0
  287. package/src/lab/automation/queue.ts +191 -0
  288. package/src/lab/automation/recovery.ts +24 -0
  289. package/src/lab/automation/route-context.ts +21 -0
  290. package/src/lab/automation/run-key.ts +44 -0
  291. package/src/lab/automation/runs-query.ts +34 -0
  292. package/src/lab/automation/types.ts +160 -0
  293. package/src/lab/conformance/assertion.ts +325 -0
  294. package/src/lab/conformance/digest.ts +22 -0
  295. package/src/lab/conformance/executor.ts +741 -0
  296. package/src/lab/conformance/fixture-provider.ts +27 -0
  297. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  298. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  299. package/src/lab/conformance/harness-budget.ts +47 -0
  300. package/src/lab/conformance/index.ts +5 -0
  301. package/src/lab/conformance/jcs.ts +64 -0
  302. package/src/lab/conformance/json-pointer.ts +39 -0
  303. package/src/lab/conformance/manifest.ts +180 -0
  304. package/src/lab/conformance/mcp-stub.ts +179 -0
  305. package/src/lab/conformance/negative-controls.ts +164 -0
  306. package/src/lab/conformance/observation.ts +355 -0
  307. package/src/lab/conformance/runner.ts +68 -0
  308. package/src/lab/conformance/sse-normalize.ts +59 -0
  309. package/src/lab/conformance/suite-manifest.ts +78 -0
  310. package/src/lab/conformance/types.ts +214 -0
  311. package/src/lab/constants.ts +126 -0
  312. package/src/lab/digest.ts +64 -0
  313. package/src/lab/events/errors.ts +9 -0
  314. package/src/lab/events/limits.ts +117 -0
  315. package/src/lab/events/types.ts +229 -0
  316. package/src/lab/events/validate.ts +781 -0
  317. package/src/lab/fabric/constants.ts +40 -0
  318. package/src/lab/fabric/executor.ts +492 -0
  319. package/src/lab/fabric/index.ts +80 -0
  320. package/src/lab/fabric/manifest.ts +222 -0
  321. package/src/lab/fabric/observe.ts +489 -0
  322. package/src/lab/fabric/patch.ts +79 -0
  323. package/src/lab/fabric/producer-child.ts +139 -0
  324. package/src/lab/fabric/producer-isolate.ts +276 -0
  325. package/src/lab/fabric/producer-protocol.ts +61 -0
  326. package/src/lab/fabric/scratch.ts +439 -0
  327. package/src/lab/fabric/subject.ts +106 -0
  328. package/src/lab/fabric/types.ts +134 -0
  329. package/src/lab/fabric/verifier.ts +98 -0
  330. package/src/lab/index.ts +54 -0
  331. package/src/lab/ledger/artifact-refs.ts +127 -0
  332. package/src/lab/ledger/invalidation.ts +136 -0
  333. package/src/lab/ledger/purge.ts +310 -0
  334. package/src/lab/ledger/store.ts +532 -0
  335. package/src/lab/live/credential-lease.ts +53 -0
  336. package/src/lab/live/destination.ts +155 -0
  337. package/src/lab/live/executor.ts +336 -0
  338. package/src/lab/live/inert-tools.ts +56 -0
  339. package/src/lab/live/manifest.ts +85 -0
  340. package/src/lab/live/mcp-loopback.ts +57 -0
  341. package/src/lab/live/runner.ts +19 -0
  342. package/src/lab/live/sandbox.ts +61 -0
  343. package/src/lab/live/suite-manifest.ts +41 -0
  344. package/src/lab/live/transport.ts +118 -0
  345. package/src/lab/live/types.ts +197 -0
  346. package/src/lab/observe/from-conformance.ts +301 -0
  347. package/src/lab/observe/from-live.ts +117 -0
  348. package/src/lab/paths.ts +153 -0
  349. package/src/lab/projection/rebuild.ts +495 -0
  350. package/src/lab/projection/schema.ts +135 -0
  351. package/src/lab/projection/verdicts.ts +474 -0
  352. package/src/lab/projection/verification.ts +412 -0
  353. package/src/lab/public/bundle.ts +217 -0
  354. package/src/lab/public/community-authority.ts +175 -0
  355. package/src/lab/public/community-files.ts +29 -0
  356. package/src/lab/public/community.ts +479 -0
  357. package/src/lab/public/file-safety.ts +155 -0
  358. package/src/lab/public/ids.ts +26 -0
  359. package/src/lab/public/index.ts +16 -0
  360. package/src/lab/public/mutation-lock.ts +424 -0
  361. package/src/lab/public/operator.ts +353 -0
  362. package/src/lab/public/origin-purge.ts +79 -0
  363. package/src/lab/public/origin.ts +203 -0
  364. package/src/lab/public/privacy.ts +143 -0
  365. package/src/lab/public/private-file.ts +261 -0
  366. package/src/lab/public/project.ts +124 -0
  367. package/src/lab/public/purge-test-fault.ts +21 -0
  368. package/src/lab/public/purge.ts +223 -0
  369. package/src/lab/public/registry.ts +44 -0
  370. package/src/lab/public/revocation.ts +252 -0
  371. package/src/lab/public/signature.ts +243 -0
  372. package/src/lab/public/storage.ts +105 -0
  373. package/src/lab/public/strict-json.ts +206 -0
  374. package/src/lab/public/time.ts +26 -0
  375. package/src/lab/public/types.ts +172 -0
  376. package/src/lab/public/validate.ts +391 -0
  377. package/src/lab/query/catalog.ts +101 -0
  378. package/src/lab/query/connection.ts +107 -0
  379. package/src/lab/query/constants.ts +4 -0
  380. package/src/lab/query/cursor.ts +132 -0
  381. package/src/lab/query/dto-map.ts +277 -0
  382. package/src/lab/query/errors.ts +22 -0
  383. package/src/lab/query/freshness.ts +53 -0
  384. package/src/lab/query/index.ts +45 -0
  385. package/src/lab/query/latest-observation.ts +59 -0
  386. package/src/lab/query/passive-production.ts +159 -0
  387. package/src/lab/query/queries.ts +444 -0
  388. package/src/lab/query/types.ts +266 -0
  389. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  390. package/src/lab/subject/installation-salt.ts +112 -0
  391. package/src/lab/subject/protocol-subject.ts +80 -0
  392. package/src/lab/subject/route-subject.ts +74 -0
  393. package/src/lib/app-owned-memory-stores.ts +22 -0
  394. package/src/lib/bounded-body.ts +153 -9
  395. package/src/lib/bun-runtime.ts +125 -12
  396. package/src/lib/bun-stream-caps.ts +13 -9
  397. package/src/lib/codex-restart-contract.ts +120 -0
  398. package/src/lib/config-ownership.ts +6 -2
  399. package/src/lib/destination-policy.ts +65 -1
  400. package/src/lib/errors.ts +44 -2
  401. package/src/lib/fabric-task-execution-authority.ts +7 -0
  402. package/src/lib/fabric-task-host.ts +29 -0
  403. package/src/lib/lab-activation.ts +223 -0
  404. package/src/lib/lab-live-execution-authority.ts +13 -0
  405. package/src/lib/lab-live-host.ts +30 -0
  406. package/src/lib/lab-live-pinned-sender.ts +56 -0
  407. package/src/lib/lab-live-route-production.ts +130 -0
  408. package/src/lib/lab-passive-linker-registration.ts +26 -0
  409. package/src/lib/local-management-attestation.ts +51 -0
  410. package/src/lib/local-management-capability.ts +100 -0
  411. package/src/lib/local-provider-reload-contract.ts +100 -0
  412. package/src/lib/optional-shutdown-hooks.ts +57 -0
  413. package/src/lib/pinned-http.ts +145 -26
  414. package/src/lib/process-control.ts +4 -1
  415. package/src/lib/provider-outbound.ts +49 -9
  416. package/src/lib/redact.ts +419 -3
  417. package/src/lib/self-launch-argv.ts +15 -0
  418. package/src/lib/server-resource-ownership.ts +71 -0
  419. package/src/lib/shadow-call.ts +35 -4
  420. package/src/lib/sse-decoder.ts +41 -0
  421. package/src/lib/state-store-registrations.ts +10 -2
  422. package/src/lib/system-restart-contract.ts +73 -0
  423. package/src/lib/token-estimate.ts +19 -2
  424. package/src/lib/tool-argument-integers.ts +202 -0
  425. package/src/lib/translator-budget.ts +44 -0
  426. package/src/lib/upstream-http-version.ts +57 -0
  427. package/src/lib/upstream-reachability.ts +95 -0
  428. package/src/lib/upstream-retry.ts +156 -3
  429. package/src/lib/windows-atomic-replace.ts +155 -0
  430. package/src/lib/windows-elevation.ts +70 -2
  431. package/src/lib/windows-secret-acl.ts +409 -69
  432. package/src/lib/windows-service-wrappers.ts +72 -0
  433. package/src/lib/windows-text.ts +106 -0
  434. package/src/lib/windows-user-principal.ts +341 -0
  435. package/src/lib/winsw.ts +33 -5
  436. package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
  437. package/src/oauth/account-import/index.ts +15 -0
  438. package/src/oauth/account-import/parser.ts +83 -0
  439. package/src/oauth/account-import/registry.ts +18 -0
  440. package/src/oauth/account-import/service.ts +75 -0
  441. package/src/oauth/account-import/types.ts +91 -0
  442. package/src/oauth/anthropic.ts +12 -1
  443. package/src/oauth/callback-server.ts +8 -2
  444. package/src/oauth/chatgpt.ts +12 -1
  445. package/src/oauth/command-code.ts +239 -0
  446. package/src/oauth/cursor.ts +46 -5
  447. package/src/oauth/google-antigravity.ts +35 -3
  448. package/src/oauth/health.ts +20 -12
  449. package/src/oauth/index.ts +398 -66
  450. package/src/oauth/key-providers.ts +16 -0
  451. package/src/oauth/kimi.ts +16 -2
  452. package/src/oauth/kiro.ts +50 -6
  453. package/src/oauth/local-token-detect.ts +11 -2
  454. package/src/oauth/log.ts +3 -1
  455. package/src/oauth/login-cli.ts +88 -28
  456. package/src/oauth/nous.ts +798 -0
  457. package/src/oauth/store.ts +119 -21
  458. package/src/oauth/token-guardian.ts +9 -3
  459. package/src/pi/models.ts +2 -2
  460. package/src/providers/alibaba-region-migration.ts +1 -1
  461. package/src/providers/antigravity-models.ts +521 -31
  462. package/src/providers/base-url-choices.ts +10 -0
  463. package/src/providers/codex-capacity.ts +292 -0
  464. package/src/providers/command-code-efforts.ts +144 -0
  465. package/src/providers/context-cap.ts +22 -5
  466. package/src/providers/cursor-pool.ts +72 -0
  467. package/src/providers/derive.ts +253 -6
  468. package/src/providers/fastwire.ts +501 -0
  469. package/src/providers/free-directory.ts +10 -7
  470. package/src/providers/google-vertex-location.ts +14 -0
  471. package/src/providers/key-failover.ts +71 -3
  472. package/src/providers/label.ts +1 -1
  473. package/src/providers/model-discovery-limits.ts +16 -0
  474. package/src/providers/model-discovery.ts +115 -22
  475. package/src/providers/model-rename-migration.ts +255 -0
  476. package/src/providers/model-rename-startup.ts +28 -0
  477. package/src/providers/openai-sidecar.ts +72 -4
  478. package/src/providers/openai-tier-startup.ts +31 -2
  479. package/src/providers/openai-tiers.ts +119 -4
  480. package/src/providers/openai-virtual-models.ts +1 -0
  481. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  482. package/src/providers/provider-id-rewrite.ts +29 -0
  483. package/src/providers/quota.ts +1319 -38
  484. package/src/providers/registry.ts +1429 -111
  485. package/src/providers/request-pacing.ts +310 -0
  486. package/src/providers/service-tier.ts +277 -0
  487. package/src/providers/slug-codec.ts +42 -6
  488. package/src/providers/static-model-discovery.ts +86 -0
  489. package/src/providers/xai-responses-opt-in.ts +15 -0
  490. package/src/providers/xai-transport.ts +11 -4
  491. package/src/reasoning-effort.ts +49 -1
  492. package/src/responses/compaction.ts +26 -1
  493. package/src/responses/custom-tool-compat.ts +266 -0
  494. package/src/responses/hosted-tool-policy.ts +9 -0
  495. package/src/responses/namespace-tool-compat.ts +355 -0
  496. package/src/responses/parser.ts +220 -38
  497. package/src/responses/provider-continuation.ts +98 -0
  498. package/src/responses/provider-opaque-metadata.ts +73 -0
  499. package/src/responses/reasoning-envelope.ts +9 -1
  500. package/src/responses/reasoning-replay-cache.ts +426 -0
  501. package/src/responses/schema.ts +7 -1
  502. package/src/responses/spill-store.ts +75 -10
  503. package/src/responses/state.ts +565 -27
  504. package/src/responses/thought-signature-replay.ts +347 -0
  505. package/src/responses/tool-search-compat.ts +301 -0
  506. package/src/responses/truncated-stop-reason.ts +60 -0
  507. package/src/router.ts +366 -30
  508. package/src/routing/analytics.ts +378 -0
  509. package/src/routing/capability.ts +244 -0
  510. package/src/routing/compatibility/assemble.ts +73 -0
  511. package/src/routing/compatibility/behavior.ts +278 -0
  512. package/src/routing/compatibility/catalog.ts +99 -0
  513. package/src/routing/compatibility/endpoint.ts +52 -0
  514. package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
  515. package/src/routing/compatibility/policy.ts +181 -0
  516. package/src/routing/compatibility/provider-slot.ts +56 -0
  517. package/src/routing/compatibility/reader.ts +110 -0
  518. package/src/routing/compatibility/subject.ts +191 -0
  519. package/src/routing/compatibility/types.ts +64 -0
  520. package/src/routing/compatibility/version.ts +104 -0
  521. package/src/routing/cost.ts +77 -0
  522. package/src/routing/evaluator.ts +495 -0
  523. package/src/routing/health.ts +412 -0
  524. package/src/routing/history/cursor.ts +43 -0
  525. package/src/routing/history/indexer.ts +605 -0
  526. package/src/routing/history/schema.ts +72 -0
  527. package/src/routing/profile-namespace.ts +15 -0
  528. package/src/routing/profile.ts +547 -0
  529. package/src/routing/quota.ts +145 -0
  530. package/src/routing/request-evidence.ts +45 -0
  531. package/src/routing/trace.ts +776 -0
  532. package/src/server/adapter-resolve.ts +2 -29
  533. package/src/server/auth-cors.ts +267 -46
  534. package/src/server/background-lifecycle.ts +182 -0
  535. package/src/server/chat-completions.ts +130 -56
  536. package/src/server/chat-native-sse.ts +331 -0
  537. package/src/server/chat-native.ts +426 -0
  538. package/src/server/claude-messages.ts +159 -43
  539. package/src/server/direct-local-http.ts +347 -0
  540. package/src/server/effort-policy.ts +18 -0
  541. package/src/server/github-copilot-responses-repair.ts +338 -0
  542. package/src/server/gui-static.ts +39 -10
  543. package/src/server/images.ts +94 -12
  544. package/src/server/index.ts +865 -181
  545. package/src/server/lifecycle.ts +284 -13
  546. package/src/server/live.ts +136 -17
  547. package/src/server/local-management-read-client.ts +90 -0
  548. package/src/server/local-provider-reload-client.ts +137 -0
  549. package/src/server/management/agent-settings-routes.ts +398 -103
  550. package/src/server/management/api-key-usage.ts +31 -5
  551. package/src/server/management/body.ts +6 -0
  552. package/src/server/management/combo-routes.ts +62 -24
  553. package/src/server/management/config-routes.ts +464 -51
  554. package/src/server/management/context.ts +80 -2
  555. package/src/server/management/integration-routes.ts +498 -0
  556. package/src/server/management/lab-automation-routes.ts +206 -0
  557. package/src/server/management/lab-routes.ts +563 -0
  558. package/src/server/management/logs-usage-routes.ts +101 -32
  559. package/src/server/management/model-routes.ts +189 -131
  560. package/src/server/management/model-rows.ts +163 -0
  561. package/src/server/management/native-integration-routes.ts +769 -0
  562. package/src/server/management/oauth-account-routes.ts +80 -4
  563. package/src/server/management/provider-capability-config.ts +48 -0
  564. package/src/server/management/provider-routes.ts +764 -157
  565. package/src/server/management/request-history-routes.ts +191 -0
  566. package/src/server/management/routing-analytics-routes.ts +74 -0
  567. package/src/server/management/routing-profile-routes.ts +380 -0
  568. package/src/server/management/shared.ts +27 -11
  569. package/src/server/management/sidebar-routes.ts +47 -31
  570. package/src/server/management/storage-log-guard-routes.ts +186 -0
  571. package/src/server/management/sync-response.ts +69 -0
  572. package/src/server/management/system-restart.ts +288 -32
  573. package/src/server/management/system-routes.ts +77 -0
  574. package/src/server/management/usage-summary-cache.ts +9 -1
  575. package/src/server/management/vision-sidecar-options.ts +167 -0
  576. package/src/server/management/web-search-sidecar-options.ts +120 -0
  577. package/src/server/management-api.ts +115 -14
  578. package/src/server/management-auth.ts +220 -5
  579. package/src/server/passive-route-linker.ts +66 -0
  580. package/src/server/ports.ts +41 -1
  581. package/src/server/proxy-liveness.ts +132 -5
  582. package/src/server/readiness.ts +99 -0
  583. package/src/server/relay-eager.ts +82 -42
  584. package/src/server/relay.ts +236 -76
  585. package/src/server/request-decompress.ts +113 -6
  586. package/src/server/request-log.ts +235 -22
  587. package/src/server/responses/agent-task-recovery-cache.ts +143 -0
  588. package/src/server/responses/agent-task-recovery.ts +465 -0
  589. package/src/server/responses/collaboration.ts +204 -35
  590. package/src/server/responses/compact.ts +442 -55
  591. package/src/server/responses/core.ts +2872 -331
  592. package/src/server/responses/empty-completion-guard.ts +276 -0
  593. package/src/server/responses/encrypted-payload.ts +62 -39
  594. package/src/server/responses/fetch-helpers.ts +79 -4
  595. package/src/server/responses/input-admission.ts +185 -0
  596. package/src/server/responses/pacing-overload.ts +13 -0
  597. package/src/server/responses/policy-fallback.ts +178 -0
  598. package/src/server/responses/responses-field-backfill.ts +251 -0
  599. package/src/server/responses/terminal-guard.ts +26 -5
  600. package/src/server/responses/upstream-error.ts +5 -0
  601. package/src/server/responses/ws-upstream.ts +308 -0
  602. package/src/server/responses-custom-tool-repair.ts +282 -0
  603. package/src/server/responses-item-id-repair.ts +54 -6
  604. package/src/server/responses-json-events.ts +90 -0
  605. package/src/server/responses-model-rewrite.ts +29 -0
  606. package/src/server/responses-reasoning-summary-rewrite.ts +178 -0
  607. package/src/server/responses-snapshot-repair.ts +621 -0
  608. package/src/server/responses-terminal-repair.ts +342 -0
  609. package/src/server/responses-tool-search-repair.ts +267 -0
  610. package/src/server/responses-undeclared-tool-guard.ts +153 -0
  611. package/src/server/responses.ts +18 -2
  612. package/src/server/search.ts +78 -13
  613. package/src/server/sse-frame-buffer.ts +292 -0
  614. package/src/server/sse-payload-rewrite.ts +110 -22
  615. package/src/server/startup-action-control.ts +8 -1
  616. package/src/server/startup-health-cache.ts +19 -1
  617. package/src/server/system-env.ts +80 -9
  618. package/src/server/ws-bridge.ts +39 -38
  619. package/src/service-manager-probe.ts +892 -0
  620. package/src/service.ts +1111 -90
  621. package/src/sidecar/auth.ts +92 -0
  622. package/src/sidecar/candidates.ts +83 -0
  623. package/src/storage/cleanup.ts +2 -2
  624. package/src/storage/scanner.ts +1 -1
  625. package/src/storage/worker-lifecycle.ts +14 -14
  626. package/src/tray/windows-tray.ps1 +83 -9
  627. package/src/tray/windows.ts +43 -16
  628. package/src/types/accounts.ts +37 -0
  629. package/src/types/config.ts +845 -0
  630. package/src/types/provider.ts +545 -0
  631. package/src/types/request.ts +384 -0
  632. package/src/types/tools.ts +131 -0
  633. package/src/types/wire.ts +80 -0
  634. package/src/types.ts +104 -1236
  635. package/src/update/index.ts +32 -19
  636. package/src/update/job.ts +442 -67
  637. package/src/update/notify.ts +12 -6
  638. package/src/update/npm-cache-preflight.d.mts +47 -0
  639. package/src/update/npm-cache-preflight.mjs +201 -0
  640. package/src/update/transactional-install.d.mts +22 -0
  641. package/src/update/transactional-install.mjs +259 -0
  642. package/src/usage/cost.ts +0 -0
  643. package/src/usage/expected-prices.ts +268 -16
  644. package/src/usage/log.ts +606 -41
  645. package/src/usage/summary.ts +177 -9
  646. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  647. package/src/usage/user-cost-overlays.ts +314 -0
  648. package/src/vision/anthropic-describe.ts +10 -6
  649. package/src/vision/backends.ts +97 -0
  650. package/src/vision/describe.ts +9 -3
  651. package/src/vision/eligibility.ts +250 -0
  652. package/src/vision/index.ts +238 -24
  653. package/src/vision/reasoning.ts +55 -0
  654. package/src/vision/routed-describe.ts +175 -0
  655. package/src/vision/timeout-bounds.ts +9 -0
  656. package/src/web-search/anthropic-executor.ts +13 -7
  657. package/src/web-search/backends.ts +108 -0
  658. package/src/web-search/exa-executor.ts +88 -0
  659. package/src/web-search/executor.ts +11 -3
  660. package/src/web-search/gemini-executor.ts +141 -0
  661. package/src/web-search/index.ts +150 -15
  662. package/src/web-search/loop.ts +279 -50
  663. package/src/web-search/parse.ts +125 -30
  664. package/src/web-search/sources.ts +60 -0
  665. package/src/web-search/xai-executor.ts +219 -0
  666. package/gui/dist/assets/index-DTpMHS4F.js +0 -67
  667. package/gui/dist/assets/index-ZNVDE3C7.css +0 -1
  668. package/gui/dist/provider-icons/antigravity.svg +0 -1
  669. package/gui/dist/provider-icons/claude.svg +0 -1
  670. package/gui/dist/provider-icons/copilot.svg +0 -1
  671. package/gui/dist/provider-icons/cursor.svg +0 -2
  672. package/gui/dist/provider-icons/gemini.svg +0 -1
  673. package/gui/dist/provider-icons/grok-color.svg +0 -1
  674. package/gui/dist/provider-icons/kiro.svg +0 -14
  675. package/src/cli/internal-dispatch.ts +0 -20
@@ -3,7 +3,8 @@ 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 { clearCodexAccountPin, codexAccountPriorityLookup, pinnedCodexAccountId } from "./account-priority";
7
+ import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "./account-usability";
7
8
  import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
8
9
  import {
9
10
  POOL_KEY_CODEX,
@@ -14,14 +15,17 @@ import {
14
15
  peekRoundRobinAccount,
15
16
  pickRoundRobinAccount,
16
17
  seedPoolRotationAccount,
18
+ selectPriorityTier,
17
19
  } from "./pool-rotation";
18
20
  import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota";
21
+ import { isThirtyDayOnlyCodexPlan } from "./plan";
19
22
  import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "./main-account";
20
23
  import { isSelectableCodexPoolAccount } from "./account-id";
21
24
  import type { OcxConfig } from "../types";
22
25
  import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
23
26
  import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
24
27
  import { retainedUtf8Bytes } from "../lib/admission";
28
+ import { recordUpstreamHostFailure } from "./upstream-host-health";
25
29
 
26
30
  type ThreadAffinityEntry = {
27
31
  accountId: string;
@@ -122,8 +126,9 @@ const quotaScopedHealth = new Map<string, Map<CodexQuotaScope, CodexUpstreamHeal
122
126
  let lastReconciledGeneration = 0;
123
127
  let liveHealthAccountIds = new Set<string>();
124
128
 
125
- export type CodexUpstreamOutcome = number | "connect_error" | "timeout";
126
- export type CodexUpstreamOutcomeClass = "success" | "credential" | "quota" | "transient" | "caller" | "unknown";
129
+ export type CodexUpstreamOutcome = number | "connect_error" | "timeout" | "connect_neutral";
130
+ export type CodexUpstreamOutcomeClass = "success" | "credential"
131
+ | "workspace" | "quota" | "transient" | "caller" | "neutral" | "unknown";
127
132
  export type CodexCooldownSource = "retry-after" | "reset-derived" | "default";
128
133
  /**
129
134
  * Native Codex quota groups known to be independent upstream. Keep the mapping
@@ -133,6 +138,20 @@ export type CodexCooldownSource = "retry-after" | "reset-derived" | "default";
133
138
  */
134
139
  export type CodexQuotaScope = "shared" | "spark";
135
140
 
141
+ export type CodexQuotaRecoveryProbeClaim = {
142
+ accountId: string;
143
+ scope?: CodexQuotaScope;
144
+ leaseId: string;
145
+ cooldownGeneration: number;
146
+ credentialGeneration: number;
147
+ /** Claim-time `replacedAt`; unchanged after a probe-owned refresh, stamped on external replacement. */
148
+ credentialReplacedAt?: number;
149
+ };
150
+
151
+ export type CodexQuotaRecoveryProbeProof = {
152
+ credentialGeneration?: number;
153
+ };
154
+
136
155
  /**
137
156
  * Requests without a resolved native model retain the historic one-account-per-
138
157
  * thread behavior. Requests with a known quota scope get an independent
@@ -173,10 +192,26 @@ export type CodexUpstreamOutcomeMeta = {
173
192
  retryAfter?: string | null;
174
193
  resetAt?: unknown | unknown[];
175
194
  now?: number;
195
+ /** (provider, host) ledger key for account-neutral reachability failures (#914). */
196
+ hostKey?: string;
197
+ /**
198
+ * Upstream denial evidence for a 403. A workspace/entitlement denial means the CREDENTIAL
199
+ * is fine and the account simply cannot reach this workspace, so it must not be quarantined
200
+ * for reauthentication (#1789). Absent evidence keeps the historical credential handling.
201
+ */
202
+ denial?: "workspace" | "entitlement";
203
+ /** Stable transport code recorded alongside a neutral host failure. */
204
+ lastFailureCode?: string;
176
205
  /** Native model selected for this request; used only for confirmed scoped quotas. */
177
206
  modelId?: string;
178
207
  /** When set, clears affinity for this thread immediately on transient failure. */
179
208
  threadId?: string | null;
209
+ /**
210
+ * Suppress Pool rotation and quota/transient affinity mutations for an account-qualified
211
+ * request. Credential failures still sweep stale affinities because reauthentication is
212
+ * account-wide.
213
+ */
214
+ fixedAccount?: boolean;
180
215
  /**
181
216
  * Probe lease held by this request, when it was admitted through an active
182
217
  * quota cooldown. Only the outcome carrying the current lease may clear the
@@ -195,8 +230,14 @@ export type CodexUpstreamOutcomeMeta = {
195
230
  writerGeneration?: number;
196
231
  };
197
232
 
198
- function hasConfiguredPoolAccount(config: OcxConfig, accountId: string): boolean {
199
- if (accountId === MAIN_CODEX_ACCOUNT_ID) return isCodexAccountUsable(config, accountId);
233
+ function hasConfiguredPoolAccount(
234
+ config: OcxConfig,
235
+ accountId: string,
236
+ selectionOptions?: CodexAccountUsabilityOptions,
237
+ ): boolean {
238
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) {
239
+ return isCodexAccountUsable(config, accountId, selectionOptions);
240
+ }
200
241
  return (config.codexAccounts ?? [])
201
242
  .some(account => isSelectableCodexPoolAccount(account) && account.id === accountId);
202
243
  }
@@ -281,23 +322,42 @@ function deleteScopedHealth(accountId: string, scope: CodexQuotaScope): void {
281
322
  export function computeCodexUsageScore(quota: {
282
323
  weeklyPercent?: number;
283
324
  monthlyPercent?: number;
284
- } | null, plan?: string | null): number {
325
+ shortPercent?: number;
326
+ } | null, plan?: unknown): number {
285
327
  if (!quota) return CODEX_UNKNOWN_USAGE_SCORE;
286
- const normalizedPlan = plan?.trim().toLowerCase();
287
- if (normalizedPlan === "go" || normalizedPlan === "free") {
288
- return typeof quota.monthlyPercent === "number" && Number.isFinite(quota.monthlyPercent)
289
- ? quota.monthlyPercent
290
- : CODEX_UNKNOWN_USAGE_SCORE;
291
- }
292
- const values = [quota.weeklyPercent, quota.monthlyPercent]
293
- .filter((value): value is number => typeof value === "number" && Number.isFinite(value));
294
- return values.length > 0 ? Math.max(...values) : CODEX_UNKNOWN_USAGE_SCORE;
295
- }
296
-
297
- export function classifyCodexUpstreamOutcome(outcome: CodexUpstreamOutcome): CodexUpstreamOutcomeClass {
328
+ const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
329
+ const longWindows = isThirtyDayOnlyCodexPlan(plan)
330
+ ? [quota.monthlyPercent]
331
+ : [quota.weeklyPercent, quota.monthlyPercent];
332
+ const knownLong = longWindows.filter(finite);
333
+ // The short burst window only REFINES a known long-window position; it cannot stand in for
334
+ // one. A snapshot carrying just `shortPercent: 0` would otherwise score a flat 0 and make an
335
+ // account whose weekly/monthly usage is entirely unverified look like the emptiest in the
336
+ // pool, so `pickLowestUsageAmong` would send every request to it. Unknown has to stay
337
+ // unknown until a governing window is actually observed.
338
+ if (knownLong.length === 0) return CODEX_UNKNOWN_USAGE_SCORE;
339
+ const values = finite(quota.shortPercent) ? [...knownLong, quota.shortPercent] : knownLong;
340
+ return Math.max(...values);
341
+ }
342
+
343
+ export function classifyCodexUpstreamOutcome(
344
+ outcome: CodexUpstreamOutcome,
345
+ denial?: "workspace" | "entitlement",
346
+ ): CodexUpstreamOutcomeClass {
347
+ if (outcome === "connect_neutral") return "neutral";
298
348
  if (outcome === "connect_error" || outcome === "timeout") return "transient";
299
349
  if (!Number.isFinite(outcome)) return "unknown";
300
350
  if (outcome >= 200 && outcome < 300) return "success";
351
+ // Explicit 3xx policy (#914): a redirect response is relayed as-is and is
352
+ // never account or host health evidence — it proves the host is reachable
353
+ // and says nothing about the credential. Relayed as the neutral class so a
354
+ // stray 3xx cannot increment an account's transient streak.
355
+ if (outcome >= 300 && outcome < 400) return "neutral";
356
+ // 401 is always a credential problem. A 403 is only a credential problem when nothing
357
+ // tells us otherwise: a workspace/entitlement denial (#1789) means the credential is valid
358
+ // and the account simply lacks access here, so quarantining it for reauth is wrong advice.
359
+ // Absent denial evidence the historical mapping stands, so the change fails safe.
360
+ if (outcome === 403 && denial !== undefined) return "workspace";
301
361
  if (outcome === 401 || outcome === 403) return "credential";
302
362
  // 402 Payment Required is treated as quota exhaustion for pool cooldown/failover
303
363
  // (same-request alternate retry records this outcome for the depleted account).
@@ -405,6 +465,135 @@ function canAcquireQuotaProbeLease(health: CodexUpstreamHealth | undefined, now:
405
465
  return now - origin >= CODEX_QUOTA_PROBE_INTERVAL_MS;
406
466
  }
407
467
 
468
+ /**
469
+ * Claim due reset-derived cooldown probes without consulting account selection.
470
+ * Pool credentials only: the main account has no quota-refresh single-flight.
471
+ */
472
+ export function claimDueCodexQuotaRecoveryProbes(
473
+ config: OcxConfig,
474
+ limit: number,
475
+ now = Date.now(),
476
+ ): CodexQuotaRecoveryProbeClaim[] {
477
+ const boundedLimit = Math.max(0, Math.floor(limit));
478
+ if (boundedLimit === 0) return [];
479
+ const candidates: Array<{
480
+ accountId: string;
481
+ scope?: CodexQuotaScope;
482
+ health: CodexUpstreamHealth;
483
+ credentialGeneration: number;
484
+ credentialReplacedAt?: number;
485
+ order: number;
486
+ }> = [];
487
+ for (const [order, account] of (config.codexAccounts ?? []).entries()) {
488
+ if (!isSelectableCodexPoolAccount(account)
489
+ || isCodexAccountPaused(config, account.id)
490
+ || isAccountNeedsReauth(account.id)) continue;
491
+ const record = readCodexAccountRecord(account.id);
492
+ if (!record?.credential || record.deletedAt != null) continue;
493
+ const due = [
494
+ { scope: undefined, health: upstreamHealth.get(account.id) },
495
+ ...[...(quotaScopedHealth.get(account.id) ?? [])].map(([scope, health]) => ({ scope, health })),
496
+ ].filter((entry): entry is { scope?: CodexQuotaScope; health: CodexUpstreamHealth } =>
497
+ // `spark` is deliberately never claimed. `GET /backend-api/wham/usage` takes no scope
498
+ // parameter and returns generic weekly/monthly windows, so its result can never prove a
499
+ // spark recovery — a claim here would spend an upstream call to settle `false` every
500
+ // time, and (with one claim per account per pass) delay the shared scope that CAN recover.
501
+ entry.scope !== "spark"
502
+ && entry.health?.cooldownSource === "reset-derived"
503
+ && canAcquireQuotaProbeLease(entry.health, now))
504
+ .sort((a, b) =>
505
+ (a.health.lastProbeAt ?? a.health.cooldownSince ?? 0)
506
+ - (b.health.lastProbeAt ?? b.health.cooldownSince ?? 0));
507
+ const candidate = due[0];
508
+ if (candidate) candidates.push({
509
+ accountId: account.id,
510
+ ...(candidate.scope ? { scope: candidate.scope } : {}),
511
+ health: candidate.health,
512
+ credentialGeneration: record.generation,
513
+ ...(record.replacedAt !== undefined ? { credentialReplacedAt: record.replacedAt } : {}),
514
+ order,
515
+ });
516
+ }
517
+ candidates.sort((a, b) => {
518
+ const age = (a.health.lastProbeAt ?? a.health.cooldownSince ?? 0)
519
+ - (b.health.lastProbeAt ?? b.health.cooldownSince ?? 0);
520
+ return age || a.order - b.order;
521
+ });
522
+ return candidates.slice(0, boundedLimit).map(candidate => {
523
+ const leaseId = randomUUID();
524
+ const next = {
525
+ ...candidate.health,
526
+ probeLeaseId: leaseId,
527
+ probeLeaseGeneration: candidate.health.cooldownGeneration ?? 0,
528
+ lastProbeAt: now,
529
+ };
530
+ if (candidate.scope) setScopedHealth(candidate.accountId, candidate.scope, next);
531
+ else upstreamHealth.set(candidate.accountId, next);
532
+ return {
533
+ accountId: candidate.accountId,
534
+ ...(candidate.scope ? { scope: candidate.scope } : {}),
535
+ leaseId,
536
+ cooldownGeneration: candidate.health.cooldownGeneration ?? 0,
537
+ credentialGeneration: candidate.credentialGeneration,
538
+ ...(candidate.credentialReplacedAt !== undefined
539
+ ? { credentialReplacedAt: candidate.credentialReplacedAt }
540
+ : {}),
541
+ };
542
+ });
543
+ }
544
+
545
+ /** Settle one background recovery claim without mutating account-wide outcome state. */
546
+ export function settleCodexQuotaRecoveryProbe(
547
+ claim: CodexQuotaRecoveryProbeClaim,
548
+ recovered: boolean,
549
+ proof: CodexQuotaRecoveryProbeProof,
550
+ now = Date.now(),
551
+ ): boolean {
552
+ const health = claim.scope
553
+ ? scopedHealthFor(claim.accountId, claim.scope)
554
+ : upstreamHealth.get(claim.accountId);
555
+ if (!health || health.probeLeaseId !== claim.leaseId) return false;
556
+ const currentRecord = readCodexAccountRecord(claim.accountId);
557
+ const proofGeneration = proof.credentialGeneration;
558
+ // A probe-owned token refresh (getValidCodexToken) advances the credential generation by
559
+ // exactly one while preserving `replacedAt`; an external credential replacement bumps the
560
+ // generation too but stamps a fresh `replacedAt`. Accept the +1 transition only when the
561
+ // claim-time lineage is intact AND the generation the fresh quota was proven under is live.
562
+ const generationFenced = proofGeneration !== undefined
563
+ && (proofGeneration === claim.credentialGeneration
564
+ ? isCodexAccountGenerationLive(claim.accountId, proofGeneration)
565
+ : proofGeneration === claim.credentialGeneration + 1
566
+ && currentRecord?.replacedAt === claim.credentialReplacedAt
567
+ && isCodexAccountGenerationLive(claim.accountId, proofGeneration));
568
+ const fenced = (health.cooldownGeneration ?? 0) === claim.cooldownGeneration
569
+ && (health.probeLeaseGeneration ?? 0) === claim.cooldownGeneration
570
+ && generationFenced;
571
+ if (!recovered || !fenced) {
572
+ const released = withProbeLeaseReleased(health, now);
573
+ if (claim.scope) setScopedHealth(claim.accountId, claim.scope, released);
574
+ else upstreamHealth.set(claim.accountId, released);
575
+ return false;
576
+ }
577
+ if (claim.scope) {
578
+ deleteScopedHealth(claim.accountId, claim.scope);
579
+ } else {
580
+ const {
581
+ cooldownUntil: _until,
582
+ cooldownSince: _since,
583
+ cooldownSource: _source,
584
+ probeLeaseId: _leaseId,
585
+ probeLeaseGeneration: _leaseGeneration,
586
+ ...rest
587
+ } = health;
588
+ upstreamHealth.set(claim.accountId, {
589
+ ...rest,
590
+ cooldownGeneration: claim.cooldownGeneration + 1,
591
+ lastProbeAt: now,
592
+ });
593
+ }
594
+ return true;
595
+ }
596
+
408
597
  /** Acquire the recovery probe for one confirmed model-specific quota group. */
409
598
  export function tryAcquireCodexQuotaScopeProbeLease(
410
599
  accountId: string,
@@ -624,11 +813,12 @@ function isCodexAccountSelectable(
624
813
  accountId: string,
625
814
  now: number,
626
815
  quotaScope?: CodexQuotaScope,
816
+ selectionOptions?: CodexAccountUsabilityOptions,
627
817
  ): boolean {
628
818
  return !isCodexAccountPaused(config, accountId)
629
819
  && getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null
630
820
  && !isCodexAccountSoftAvoided(accountId, now)
631
- && isCodexAccountUsable(config, accountId);
821
+ && isCodexAccountUsable(config, accountId, selectionOptions);
632
822
  }
633
823
 
634
824
  function threadAffinityScope(quotaScope?: CodexQuotaScope): ThreadAffinityScope {
@@ -736,7 +926,8 @@ function getEligiblePoolAccounts(
736
926
  excludeId?: string,
737
927
  now = Date.now(),
738
928
  quotaScope?: CodexQuotaScope,
739
- ): string[] {
929
+ selectionOptions?: CodexAccountUsabilityOptions,
930
+ ): readonly string[] {
740
931
  const ids = (config.codexAccounts ?? [])
741
932
  .filter(account => isSelectableCodexPoolAccount(account)
742
933
  && account.id !== excludeId
@@ -744,7 +935,7 @@ function getEligiblePoolAccounts(
744
935
  && !isAccountNeedsReauth(account.id))
745
936
  .filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null)
746
937
  .filter(account => !isCodexAccountSoftAvoided(account.id, now))
747
- .filter(account => isCodexAccountUsable(config, account.id))
938
+ .filter(account => isCodexAccountUsable(config, account.id, selectionOptions))
748
939
  .map(account => account.id);
749
940
  // The main Codex account is not stored in config.codexAccounts; include it as a
750
941
  // first-class rotation candidate when its read-only token is usable (Option A).
@@ -754,30 +945,48 @@ function getEligiblePoolAccounts(
754
945
  && !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)
755
946
  && getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null
756
947
  && !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now)
757
- && isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID)
948
+ && isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions)
758
949
  ) {
759
950
  ids.unshift(MAIN_CODEX_ACCOUNT_ID);
760
951
  }
761
- return ids;
952
+ // Single choke point for selection order: every strategy, failover, and preview
953
+ // reaches the pool through here, so tiering applies once rather than per picker.
954
+ // Eligibility above is unchanged — this only narrows an already-eligible list.
955
+ return selectPriorityTier(
956
+ ids,
957
+ codexAccountPriorityLookup(config),
958
+ id => hasCodexQuotaHeadroom(config, id),
959
+ pinnedCodexAccountId(config),
960
+ );
762
961
  }
763
962
 
764
963
  function listEligibleCodexAccountIds(
765
964
  config: OcxConfig,
766
965
  now: number,
767
966
  quotaScope?: CodexQuotaScope,
768
- ): string[] {
769
- return getEligiblePoolAccounts(config, undefined, now, quotaScope);
967
+ selectionOptions?: CodexAccountUsabilityOptions,
968
+ ): readonly string[] {
969
+ return getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions);
770
970
  }
771
971
 
772
972
  function stickyLimitForConfig(config: OcxConfig): number {
773
973
  return normalizeAccountPoolStickyLimit(config.accountPoolStickyLimit);
774
974
  }
775
975
 
776
- function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string): boolean {
976
+ /**
977
+ * Whether an account still has quota to give under the auto-switch threshold.
978
+ *
979
+ * Fill-first and the priority tier filter share this predicate, and share both of
980
+ * its escape hatches. A disabled threshold means only health, pause, and reauth
981
+ * may drain an account; unknown usage is a guess, so it must neither force
982
+ * fill-first off the active account nor drain a tier that was simply never
983
+ * primed. A genuinely exhausted account 429s into cooldown and leaves
984
+ * eligibility on its own.
985
+ */
986
+ function hasCodexQuotaHeadroom(config: OcxConfig, accountId: string): boolean {
777
987
  const threshold = config.autoSwitchThreshold ?? 80;
778
988
  if (threshold <= 0) return true;
779
989
  const usage = computeCodexUsageScore(getAccountQuota(accountId), getPoolAccountPlan(config, accountId));
780
- // Unknown usage must not force fill-first to abandon the active account.
781
990
  if (isUnknownUsage(usage)) return true;
782
991
  return usage < threshold;
783
992
  }
@@ -790,37 +999,39 @@ function pickFillFirstCodexAccount(
790
999
  config: OcxConfig,
791
1000
  now: number,
792
1001
  quotaScope?: CodexQuotaScope,
1002
+ selectionOptions?: CodexAccountUsabilityOptions,
793
1003
  ): string | null {
794
- const eligible = listEligibleCodexAccountIds(config, now, quotaScope);
1004
+ const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions);
795
1005
  if (eligible.length === 0) return null;
796
1006
 
797
1007
  const active = getEffectiveActiveCodexAccountId(config);
798
- if (active && eligible.includes(active) && isActiveUnderFillFirstThreshold(config, active)) {
1008
+ if (active && eligible.includes(active) && hasCodexQuotaHeadroom(config, active)) {
799
1009
  return active;
800
1010
  }
801
1011
 
802
- return pickNextFillFirstCodexAccount(config, active ?? null, eligible, now);
1012
+ return pickNextFillFirstCodexAccount(config, active ?? null, eligible, now, selectionOptions);
803
1013
  }
804
1014
 
805
1015
  /** Next eligible account in stable order after `afterId` (wrapping). */
806
1016
  function pickNextFillFirstCodexAccount(
807
1017
  config: OcxConfig,
808
1018
  afterId: string | null,
809
- eligible = listEligibleCodexAccountIds(config, Date.now()),
1019
+ eligible: readonly string[] = listEligibleCodexAccountIds(config, Date.now()),
810
1020
  _now = Date.now(),
1021
+ selectionOptions?: CodexAccountUsabilityOptions,
811
1022
  ): string | null {
812
1023
  if (eligible.length === 0) return null;
813
1024
  const ordered = [...eligible].sort((a, b) => a.localeCompare(b));
814
1025
  if (!afterId) {
815
1026
  // Prefer an under-threshold account when starting with no active cursor.
816
1027
  for (const id of ordered) {
817
- if (isActiveUnderFillFirstThreshold(config, id)) return id;
1028
+ if (hasCodexQuotaHeadroom(config, id)) return id;
818
1029
  }
819
1030
  return ordered[0] ?? null;
820
1031
  }
821
1032
 
822
1033
  const allConfigured = [
823
- ...(isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID) || afterId === MAIN_CODEX_ACCOUNT_ID
1034
+ ...(isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions) || afterId === MAIN_CODEX_ACCOUNT_ID
824
1035
  ? [MAIN_CODEX_ACCOUNT_ID]
825
1036
  : []),
826
1037
  ...(config.codexAccounts ?? []).filter(account => !account.isMain).map(account => account.id),
@@ -829,7 +1040,7 @@ function pickNextFillFirstCodexAccount(
829
1040
  const startIdx = stableAll.indexOf(afterId);
830
1041
  if (startIdx < 0) {
831
1042
  for (const id of ordered) {
832
- if (isActiveUnderFillFirstThreshold(config, id)) return id;
1043
+ if (hasCodexQuotaHeadroom(config, id)) return id;
833
1044
  }
834
1045
  return ordered[0] ?? null;
835
1046
  }
@@ -840,7 +1051,7 @@ function pickNextFillFirstCodexAccount(
840
1051
  const candidate = stableAll[(startIdx + step) % stableAll.length]!;
841
1052
  if (!eligible.includes(candidate)) continue;
842
1053
  if (!fallback) fallback = candidate;
843
- if (isActiveUnderFillFirstThreshold(config, candidate)) return candidate;
1054
+ if (hasCodexQuotaHeadroom(config, candidate)) return candidate;
844
1055
  }
845
1056
  return fallback ?? ordered[0] ?? null;
846
1057
  }
@@ -866,6 +1077,7 @@ function pickUnboundStrategyAccount(
866
1077
  now: number,
867
1078
  commit: boolean,
868
1079
  quotaScope?: CodexQuotaScope,
1080
+ selectionOptions?: CodexAccountUsabilityOptions,
869
1081
  ): string | null {
870
1082
  const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
871
1083
  if (strategy === "quota") return null;
@@ -873,7 +1085,7 @@ function pickUnboundStrategyAccount(
873
1085
 
874
1086
  let picked: string | null = null;
875
1087
  if (strategy === "round-robin") {
876
- const eligible = listEligibleCodexAccountIds(config, now, quotaScope);
1088
+ const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions);
877
1089
  const limit = stickyLimitForConfig(config);
878
1090
  if (!commit) {
879
1091
  return peekRoundRobinAccount(poolKey, eligible, limit);
@@ -887,7 +1099,7 @@ function pickUnboundStrategyAccount(
887
1099
  }
888
1100
 
889
1101
  if (strategy === "fill-first") {
890
- picked = pickFillFirstCodexAccount(config, now, quotaScope);
1102
+ picked = pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions);
891
1103
  if (!picked) return null;
892
1104
  if (commit) {
893
1105
  if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked);
@@ -911,10 +1123,11 @@ function pickLowerUsageAccount(
911
1123
  activeUsage: number,
912
1124
  now: number,
913
1125
  quotaScope?: CodexQuotaScope,
1126
+ selectionOptions?: CodexAccountUsabilityOptions,
914
1127
  ): string {
915
1128
  let best = active;
916
1129
  let bestUsage = activeUsage;
917
- for (const id of getEligiblePoolAccounts(config, active, now, quotaScope)) {
1130
+ for (const id of getEligiblePoolAccounts(config, active, now, quotaScope, selectionOptions)) {
918
1131
  const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id));
919
1132
  if (usage < bestUsage) {
920
1133
  best = id;
@@ -924,15 +1137,11 @@ function pickLowerUsageAccount(
924
1137
  return best;
925
1138
  }
926
1139
 
927
- export function pickLowestUsageCodexAccount(
928
- config: OcxConfig,
929
- excludeId?: string,
930
- now = Date.now(),
931
- quotaScope?: CodexQuotaScope,
932
- ): string | null {
1140
+ /** Coolest account in an already-selected candidate list; first index wins ties. */
1141
+ function pickLowestUsageAmong(config: OcxConfig, ids: readonly string[]): string | null {
933
1142
  let best: string | null = null;
934
1143
  let bestUsage = Number.POSITIVE_INFINITY;
935
- for (const id of getEligiblePoolAccounts(config, excludeId, now, quotaScope)) {
1144
+ for (const id of ids) {
936
1145
  const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id));
937
1146
  if (usage < bestUsage) {
938
1147
  best = id;
@@ -942,6 +1151,16 @@ export function pickLowestUsageCodexAccount(
942
1151
  return best;
943
1152
  }
944
1153
 
1154
+ export function pickLowestUsageCodexAccount(
1155
+ config: OcxConfig,
1156
+ excludeId?: string,
1157
+ now = Date.now(),
1158
+ quotaScope?: CodexQuotaScope,
1159
+ selectionOptions?: CodexAccountUsabilityOptions,
1160
+ ): string | null {
1161
+ return pickLowestUsageAmong(config, getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions));
1162
+ }
1163
+
945
1164
  /**
946
1165
  * Strategy-aware alternate after a cooled/excluded account (same-request 429 retry
947
1166
  * and active promotion). Quota keeps lowest-usage; fill-first advances stable order;
@@ -952,17 +1171,22 @@ export function pickAlternateCodexAccount(
952
1171
  excludeId: string,
953
1172
  now = Date.now(),
954
1173
  quotaScope?: CodexQuotaScope,
1174
+ selectionOptions?: CodexAccountUsabilityOptions,
955
1175
  ): string | null {
956
1176
  const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
1177
+ // The exclusion is passed into eligibility rather than post-filtered off its
1178
+ // result: when the excluded account is the only healthy member of the top
1179
+ // tier, the tier walk must be free to descend instead of selecting that tier
1180
+ // and then handing back an empty list.
957
1181
  if (strategy === "round-robin") {
958
- const eligible = listEligibleCodexAccountIds(config, now, quotaScope).filter(id => id !== excludeId);
1182
+ const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions);
959
1183
  return pickRoundRobinAccount(codexPoolKeyForScope(quotaScope), eligible, stickyLimitForConfig(config));
960
1184
  }
961
1185
  if (strategy === "fill-first") {
962
- const eligible = listEligibleCodexAccountIds(config, now, quotaScope).filter(id => id !== excludeId);
963
- return pickNextFillFirstCodexAccount(config, excludeId, eligible, now);
1186
+ const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions);
1187
+ return pickNextFillFirstCodexAccount(config, excludeId, eligible, now, selectionOptions);
964
1188
  }
965
- return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope);
1189
+ return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope, selectionOptions);
966
1190
  }
967
1191
 
968
1192
  /** Effective active: automatic runtime cursor, else operator/persisted selection. */
@@ -970,6 +1194,17 @@ export function getEffectiveActiveCodexAccountId(config: OcxConfig): string | un
970
1194
  return runtimeActiveCodexAccountId ?? config.activeCodexAccountId;
971
1195
  }
972
1196
 
1197
+ /**
1198
+ * Whether the account routing is currently on is there because an operator asked
1199
+ * for it, rather than because a strategy landed on it. Surfaces read this instead
1200
+ * of comparing the stored pin themselves, which would report a pin that a later
1201
+ * automatic pick has already moved past.
1202
+ */
1203
+ export function isEffectiveCodexAccountPinned(config: OcxConfig): boolean {
1204
+ const pinned = pinnedCodexAccountId(config);
1205
+ return pinned !== undefined && pinned === getEffectiveActiveCodexAccountId(config);
1206
+ }
1207
+
973
1208
  /**
974
1209
  * Automatic strategy / failover cursor only — never mutates `config.activeCodexAccountId`
975
1210
  * so an unrelated `saveConfig` cannot persist transient rotation as operator selection.
@@ -978,10 +1213,22 @@ function rememberActiveCodexAccount(_config: OcxConfig, accountId: string): void
978
1213
  runtimeActiveCodexAccountId = accountId;
979
1214
  }
980
1215
 
1216
+ /**
1217
+ * End the manual pin when routing moves to a different account. Returns whether
1218
+ * the pin changed so the caller can fold it into a write it was already making.
1219
+ */
1220
+ function releaseCodexAccountPinFor(config: OcxConfig, accountId: string): boolean {
1221
+ const pinned = pinnedCodexAccountId(config);
1222
+ if (pinned === undefined || pinned === accountId) return false;
1223
+ clearCodexAccountPin(config);
1224
+ return true;
1225
+ }
1226
+
981
1227
  /** Persist operator (or quota-strategy) active selection to config + disk. */
982
1228
  function setActiveCodexAccount(config: OcxConfig, accountId: string): void {
983
1229
  runtimeActiveCodexAccountId = undefined;
984
- if (config.activeCodexAccountId === accountId) return;
1230
+ const releasedPin = releaseCodexAccountPinFor(config, accountId);
1231
+ if (config.activeCodexAccountId === accountId && !releasedPin) return;
985
1232
  config.activeCodexAccountId = accountId;
986
1233
  saveConfigPreservingClaudeCode(config);
987
1234
  }
@@ -992,6 +1239,10 @@ function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void {
992
1239
  setActiveCodexAccount(config, accountId);
993
1240
  return;
994
1241
  }
1242
+ // Runtime-only, like the cursor itself: a caller that persists (pause, delete)
1243
+ // saves this release with its own write; a transient failover does not, so the
1244
+ // pin survives a restart that also clears the failure history behind it.
1245
+ releaseCodexAccountPinFor(config, accountId);
995
1246
  rememberActiveCodexAccount(config, accountId);
996
1247
  }
997
1248
 
@@ -1010,6 +1261,10 @@ export function reconcileCodexActiveAfterExclusion(
1010
1261
  if (config.activeCodexAccountId === excludedAccountId) {
1011
1262
  config.activeCodexAccountId = undefined;
1012
1263
  }
1264
+ // Excluding an account revokes any manual pin on it even when it was not the
1265
+ // effective active — otherwise a paused account keeps acting as a tier ceiling,
1266
+ // suppressing every higher-ordered account while being unusable itself.
1267
+ clearCodexAccountPin(config, excludedAccountId);
1013
1268
  if (!wasEffective) return getEffectiveActiveCodexAccountId(config) ?? null;
1014
1269
 
1015
1270
  runtimeActiveCodexAccountId = undefined;
@@ -1022,11 +1277,60 @@ function isUnknownUsage(usage: number): boolean {
1022
1277
  return usage >= CODEX_UNKNOWN_USAGE_SCORE;
1023
1278
  }
1024
1279
 
1280
+ /**
1281
+ * Move an unbound request back up when a higher tier regains headroom — the
1282
+ * weekly-reset case. Returns null when nothing should change.
1283
+ *
1284
+ * Downward moves are deliberately left to {@link applyQuotaAutoSwitch}: this only
1285
+ * fires when the tier filter has already excluded `active`, and only toward a
1286
+ * tier that strictly outranks it. Threads bound by affinity never reach here.
1287
+ */
1288
+ function pickPriorityPreemption(
1289
+ config: OcxConfig,
1290
+ active: string,
1291
+ now: number,
1292
+ quotaScope?: CodexQuotaScope,
1293
+ selectionOptions?: CodexAccountUsabilityOptions,
1294
+ ): string | null {
1295
+ const eligible = getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions);
1296
+ if (eligible.length === 0 || eligible.includes(active)) return null;
1297
+ const pinned = pinnedCodexAccountId(config);
1298
+ // A live pin already lowered the tier ceiling; never preempt past an explicit
1299
+ // operator choice. Same liveness test the tier filter applies, so preview and
1300
+ // resolve agree even before the pin is garbage-collected.
1301
+ if (pinned !== undefined && eligible.includes(pinned) && hasCodexQuotaHeadroom(config, pinned)) return null;
1302
+ const priorityOf = codexAccountPriorityLookup(config);
1303
+ if (priorityOf(eligible[0]!) <= priorityOf(active)) return null;
1304
+ // Members without headroom are in the tier only because a sibling has some;
1305
+ // picking one would hand the request straight back to a drained account.
1306
+ return pickLowestUsageAmong(config, eligible.filter(id => hasCodexQuotaHeadroom(config, id)));
1307
+ }
1308
+
1309
+ /**
1310
+ * Release a pin whose account is durably drained. "Use this account now" ends
1311
+ * when the account crosses the auto-switch threshold or stops being selectable
1312
+ * at all — never on a transient cooldown or soft-avoid, which it recovers from
1313
+ * on its own. Clearing the pin also removes the condition, so this writes at
1314
+ * most once per pin.
1315
+ */
1316
+ function releaseDrainedCodexAccountPin(config: OcxConfig): void {
1317
+ const pinned = pinnedCodexAccountId(config);
1318
+ if (pinned === undefined) return;
1319
+ const drained = !isCodexAccountUsable(config, pinned)
1320
+ || isAccountNeedsReauth(pinned)
1321
+ || isCodexAccountPaused(config, pinned)
1322
+ || !hasCodexQuotaHeadroom(config, pinned);
1323
+ if (!drained) return;
1324
+ clearCodexAccountPin(config);
1325
+ saveConfigPreservingClaudeCode(config);
1326
+ }
1327
+
1025
1328
  function applyQuotaAutoSwitch(
1026
1329
  config: OcxConfig,
1027
1330
  active: string,
1028
1331
  now: number,
1029
1332
  quotaScope?: CodexQuotaScope,
1333
+ selectionOptions?: CodexAccountUsabilityOptions,
1030
1334
  ): string {
1031
1335
  const threshold = config.autoSwitchThreshold ?? 80;
1032
1336
  if (threshold <= 0) return active;
@@ -1036,7 +1340,7 @@ function applyQuotaAutoSwitch(
1036
1340
  // threshold. Wait for quota priming instead of rotating among guesses.
1037
1341
  if (isUnknownUsage(activeUsage)) return active;
1038
1342
  if (activeUsage < threshold) return active;
1039
- const best = pickLowerUsageAccount(config, active, activeUsage, now, quotaScope);
1343
+ const best = pickLowerUsageAccount(config, active, activeUsage, now, quotaScope, selectionOptions);
1040
1344
  if (best !== active) {
1041
1345
  if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, best);
1042
1346
  return best;
@@ -1053,11 +1357,23 @@ function shouldFailover(config: OcxConfig, accountId: string, now: number): bool
1053
1357
  return !!health && health.consecutiveFailures >= threshold;
1054
1358
  }
1055
1359
 
1056
- function applyFailureFailover(config: OcxConfig, active: string, now: number): string {
1360
+ function applyFailureFailover(
1361
+ config: OcxConfig,
1362
+ active: string,
1363
+ now: number,
1364
+ quotaScope?: CodexQuotaScope,
1365
+ selectionOptions?: CodexAccountUsabilityOptions,
1366
+ ): string {
1057
1367
  if (!shouldFailover(config, active, now)) return active;
1058
- const best = pickAlternateCodexAccount(config, active, now);
1368
+ const best = pickAlternateCodexAccount(config, active, now, quotaScope, selectionOptions);
1059
1369
  if (best) {
1060
- promoteActiveCodexAccount(config, best);
1370
+ // The scope still routes away from the failing account — that is this request's
1371
+ // own decision — but an independent one must not persist a new shared active
1372
+ // account. recordCodexUpstreamOutcome only suppresses the promotion it makes at
1373
+ // the moment of the failure; the streak outlives the soft avoid, so a later
1374
+ // scoped resolve reaches here with the streak still tripped and would otherwise
1375
+ // move the shared cursor after all.
1376
+ if (!isIndependentCodexQuotaScope(quotaScope)) promoteActiveCodexAccount(config, best);
1061
1377
  return best;
1062
1378
  }
1063
1379
  return active;
@@ -1086,13 +1402,14 @@ export function previewCodexAccountForRequest(
1086
1402
  config: OcxConfig,
1087
1403
  now = Date.now(),
1088
1404
  quotaScope?: CodexQuotaScope,
1405
+ selectionOptions?: CodexAccountUsabilityOptions,
1089
1406
  ): string | null {
1090
1407
  const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined;
1091
1408
  if (threadId && entry) {
1092
1409
  if (
1093
1410
  !isThreadAffinityExpired(entry, now)
1094
1411
  && isThreadAffinityGenerationLive(entry)
1095
- && isCodexAccountSelectable(config, entry.accountId, now, quotaScope)
1412
+ && isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions)
1096
1413
  && !shouldFailover(config, entry.accountId, now)
1097
1414
  ) {
1098
1415
  // Quota strategy only: non-quota strategies keep affinity for ongoing threads
@@ -1106,7 +1423,14 @@ export function previewCodexAccountForRequest(
1106
1423
  getPoolAccountPlan(config, entry.accountId),
1107
1424
  );
1108
1425
  if (!isUnknownUsage(usage) && usage >= threshold) {
1109
- const best = pickLowerUsageAccount(config, entry.accountId, usage, now, quotaScope);
1426
+ const best = pickLowerUsageAccount(
1427
+ config,
1428
+ entry.accountId,
1429
+ usage,
1430
+ now,
1431
+ quotaScope,
1432
+ selectionOptions,
1433
+ );
1110
1434
  if (best !== entry.accountId) return best;
1111
1435
  }
1112
1436
  }
@@ -1116,37 +1440,48 @@ export function previewCodexAccountForRequest(
1116
1440
  // Stale/unusable affinity is ignored for preview (no map mutation).
1117
1441
  }
1118
1442
 
1119
- const strategyPick = pickUnboundStrategyAccount(config, threadId, now, false, quotaScope);
1443
+ const strategyPick = pickUnboundStrategyAccount(
1444
+ config,
1445
+ threadId,
1446
+ now,
1447
+ false,
1448
+ quotaScope,
1449
+ selectionOptions,
1450
+ );
1120
1451
  if (strategyPick) return strategyPick;
1121
1452
 
1122
1453
  let active = getEffectiveActiveCodexAccountId(config) ?? null;
1123
1454
  if (!active) {
1124
- return pickLowestUsageCodexAccount(config, undefined, now, quotaScope);
1455
+ return pickLowestUsageCodexAccount(config, undefined, now, quotaScope, selectionOptions);
1125
1456
  }
1126
- if (!isCodexAccountSelectable(config, active, now, quotaScope)) {
1127
- const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope);
1457
+ if (!isCodexAccountSelectable(config, active, now, quotaScope, selectionOptions)) {
1458
+ const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions);
1128
1459
  if (fallback) active = fallback;
1129
- else if (hasConfiguredPoolAccount(config, active) && !isCodexAccountPaused(config, active)) return active;
1460
+ else if (
1461
+ hasConfiguredPoolAccount(config, active, selectionOptions)
1462
+ && !isCodexAccountPaused(config, active)
1463
+ ) return active;
1130
1464
  else return null;
1131
1465
  }
1466
+ active = pickPriorityPreemption(config, active, now, quotaScope, selectionOptions) ?? active;
1132
1467
 
1133
1468
  const threshold = config.autoSwitchThreshold ?? 80;
1134
1469
  if (threshold > 0) {
1135
1470
  const usage = computeCodexUsageScore(getAccountQuota(active), getPoolAccountPlan(config, active));
1136
1471
  if (!isUnknownUsage(usage) && usage >= threshold) {
1137
- active = pickLowerUsageAccount(config, active, usage, now, quotaScope);
1472
+ active = pickLowerUsageAccount(config, active, usage, now, quotaScope, selectionOptions);
1138
1473
  }
1139
1474
  }
1140
1475
  if (shouldFailover(config, active, now)) {
1141
- const best = pickLowestUsageCodexAccount(config, active, now, quotaScope);
1476
+ const best = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions);
1142
1477
  if (best) active = best;
1143
1478
  }
1144
- if (!isCodexAccountUsable(config, active)) {
1145
- return hasConfiguredPoolAccount(config, active) ? active : null;
1479
+ if (!isCodexAccountUsable(config, active, selectionOptions)) {
1480
+ return hasConfiguredPoolAccount(config, active, selectionOptions) ? active : null;
1146
1481
  }
1147
1482
  if (isCodexAccountPaused(config, active)) return null;
1148
1483
  if (getCodexQuotaHealthSnapshot(active, quotaScope, now)) {
1149
- return hasConfiguredPoolAccount(config, active) ? active : null;
1484
+ return hasConfiguredPoolAccount(config, active, selectionOptions) ? active : null;
1150
1485
  }
1151
1486
  return active;
1152
1487
  }
@@ -1156,7 +1491,14 @@ export function resolveCodexAccountForThreadDetailed(
1156
1491
  config: OcxConfig,
1157
1492
  now = Date.now(),
1158
1493
  quotaScope?: CodexQuotaScope,
1494
+ selectionOptions?: CodexAccountUsabilityOptions,
1159
1495
  ): CodexThreadResolution {
1496
+ // Retiring a spent manual pin is independent of affinity: an existing thread
1497
+ // keeps its account below, but the operator's tier ceiling must not silently
1498
+ // revive after quota resets. Independent model scopes must never persist a
1499
+ // change to shared routing state.
1500
+ if (!isIndependentCodexQuotaScope(quotaScope)) releaseDrainedCodexAccountPin(config);
1501
+
1160
1502
  const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined;
1161
1503
  if (threadId && entry) {
1162
1504
  if (isThreadAffinityExpired(entry, now)) {
@@ -1165,7 +1507,7 @@ export function resolveCodexAccountForThreadDetailed(
1165
1507
  }
1166
1508
  if (
1167
1509
  isThreadAffinityGenerationLive(entry)
1168
- && isCodexAccountSelectable(config, entry.accountId, now, quotaScope)
1510
+ && isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions)
1169
1511
  // Affined threads must leave a failing account once the streak trips failover
1170
1512
  // (soft-avoid covers the first-hit case; this catches post-avoid residual streaks).
1171
1513
  && !shouldFailover(config, entry.accountId, now)
@@ -1192,7 +1534,7 @@ export function resolveCodexAccountForThreadDetailed(
1192
1534
  if (overThreshold || now - entry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS) {
1193
1535
  entry.lastReevalAt = now;
1194
1536
  if (overThreshold) {
1195
- const best = pickLowerUsageAccount(config, entry.accountId, usage, now, quotaScope);
1537
+ const best = pickLowerUsageAccount(config, entry.accountId, usage, now, quotaScope, selectionOptions);
1196
1538
  if (best !== entry.accountId) {
1197
1539
  if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, best);
1198
1540
  bindThreadAffinity(threadId, best, now, quotaScope); // rebinds + resets clocks
@@ -1206,35 +1548,53 @@ export function resolveCodexAccountForThreadDetailed(
1206
1548
  deleteThreadAffinity(threadId, quotaScope);
1207
1549
  }
1208
1550
 
1209
- const strategyPick = pickUnboundStrategyAccount(config, threadId, now, true, quotaScope);
1551
+ const strategyPick = pickUnboundStrategyAccount(config, threadId, now, true, quotaScope, selectionOptions);
1210
1552
  if (strategyPick) return { status: "selected", accountId: strategyPick };
1211
1553
 
1212
1554
  let active = getEffectiveActiveCodexAccountId(config);
1213
1555
  if (!active) {
1214
- const selected = pickLowestUsageCodexAccount(config, undefined, now, quotaScope);
1556
+ const selected = pickLowestUsageCodexAccount(config, undefined, now, quotaScope, selectionOptions);
1215
1557
  if (!selected) return { status: "none" };
1216
1558
  if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, selected);
1217
1559
  active = selected;
1218
1560
  }
1219
- if (!isCodexAccountSelectable(config, active, now, quotaScope)) {
1220
- const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope);
1561
+ if (!isCodexAccountSelectable(config, active, now, quotaScope, selectionOptions)) {
1562
+ const fallback = pickLowestUsageCodexAccount(config, active, now, quotaScope, selectionOptions);
1221
1563
  if (fallback) {
1222
1564
  if (!isIndependentCodexQuotaScope(quotaScope)) setActiveCodexAccount(config, fallback);
1223
1565
  active = fallback;
1224
- } else if (hasConfiguredPoolAccount(config, active) && !isCodexAccountPaused(config, active)) {
1566
+ } else if (
1567
+ hasConfiguredPoolAccount(config, active, selectionOptions)
1568
+ && !isCodexAccountPaused(config, active)
1569
+ ) {
1225
1570
  return { status: "selected", accountId: active };
1226
1571
  } else {
1227
1572
  return { status: "none" };
1228
1573
  }
1229
1574
  }
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" };
1575
+ // Before applyQuotaAutoSwitch: its sync disk write would otherwise persist a
1576
+ // move inside the drained tier that preemption immediately overrides.
1577
+ const preempted = pickPriorityPreemption(config, active, now, quotaScope, selectionOptions);
1578
+ if (preempted) {
1579
+ // Runtime-only, like every other automatic pick: config.activeCodexAccountId
1580
+ // stays the operator's selection and getEffectiveActiveCodexAccountId is what
1581
+ // surfaces this to the API and dashboard. An independent quota group must not
1582
+ // move the shared cursor at all — its ordering decision is its own.
1583
+ if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, preempted);
1584
+ active = preempted;
1585
+ }
1586
+ active = applyQuotaAutoSwitch(config, active, now, quotaScope, selectionOptions);
1587
+ active = applyFailureFailover(config, active, now, quotaScope, selectionOptions);
1588
+ if (!isCodexAccountUsable(config, active, selectionOptions)) {
1589
+ return hasConfiguredPoolAccount(config, active, selectionOptions)
1590
+ ? { status: "selected", accountId: active }
1591
+ : { status: "none" };
1234
1592
  }
1235
1593
  if (isCodexAccountPaused(config, active)) return { status: "none" };
1236
1594
  if (getCodexQuotaHealthSnapshot(active, quotaScope, now)) {
1237
- return hasConfiguredPoolAccount(config, active) ? { status: "selected", accountId: active } : { status: "none" };
1595
+ return hasConfiguredPoolAccount(config, active, selectionOptions)
1596
+ ? { status: "selected", accountId: active }
1597
+ : { status: "none" };
1238
1598
  }
1239
1599
  if (threadId) bindThreadAffinity(threadId, active, now, quotaScope);
1240
1600
  return { status: "selected", accountId: active };
@@ -1246,11 +1606,18 @@ export function recordCodexUpstreamOutcome(
1246
1606
  outcome: CodexUpstreamOutcome,
1247
1607
  meta: CodexUpstreamOutcomeMeta = {},
1248
1608
  ): void {
1609
+ // Host-level evidence is account-independent (#914): a pre-connection
1610
+ // reachability failure is recorded in the (provider, host) ledger even when
1611
+ // there is no account to attribute, or the account's writer generation is
1612
+ // stale — the early returns below must not gate it.
1613
+ if (outcome === "connect_neutral" && meta.hostKey) {
1614
+ recordUpstreamHostFailure(meta.hostKey, { code: meta.lastFailureCode, now: meta.now ?? Date.now() });
1615
+ }
1249
1616
  if (!accountId) return;
1250
1617
  const writerGeneration = meta.writerGeneration ?? captureConfigGeneration();
1251
1618
  if (writerGeneration < lastReconciledGeneration && !liveHealthAccountIds.has(accountId)) return;
1252
1619
  const now = meta.now ?? Date.now();
1253
- const outcomeClass = classifyCodexUpstreamOutcome(outcome);
1620
+ const outcomeClass = classifyCodexUpstreamOutcome(outcome, meta.denial);
1254
1621
  const quotaScope = codexQuotaScopeForModel(meta.modelId);
1255
1622
  if (outcomeClass === "success") {
1256
1623
  const scopedProbe = meta.probeQuotaScope
@@ -1310,7 +1677,38 @@ export function recordCodexUpstreamOutcome(
1310
1677
  return;
1311
1678
  }
1312
1679
 
1680
+ if (outcomeClass === "neutral") {
1681
+ // A proven pre-connection reachability failure (DNS / TCP refusal) or a
1682
+ // relayed 3xx is host-level, not account evidence: rotation cannot repair
1683
+ // it and must not happen (#914). Conclude any owned probe lease, record the
1684
+ // failure under the (provider, host) ledger when one is named, and leave
1685
+ // account health, thread affinity, and the active account untouched.
1686
+ const current = upstreamHealth.get(accountId);
1687
+ const scopedProbe = meta.probeQuotaScope
1688
+ ? scopedHealthFor(accountId, meta.probeQuotaScope)
1689
+ : undefined;
1690
+ if (scopedProbe && meta.probeQuotaScope && ownsProbeLease(scopedProbe, meta)) {
1691
+ setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now));
1692
+ }
1693
+ if (ownsProbeLease(current, meta)) {
1694
+ upstreamHealth.set(accountId, withProbeLeaseReleased(current!, now));
1695
+ }
1696
+ return;
1697
+ }
1698
+
1313
1699
  const lastFailureStatus = typeof outcome === "number" ? outcome : 0;
1700
+ if (outcomeClass === "workspace") {
1701
+ // The credential is valid; this account just cannot reach this workspace (#1789).
1702
+ // Record the failure so routing stops preferring it, but do not mark it for
1703
+ // reauthentication and do not sweep its thread affinities: telling the user to
1704
+ // re-login is wrong advice that cannot fix a workspace grant.
1705
+ upstreamHealth.set(accountId, {
1706
+ consecutiveFailures: (upstreamHealth.get(accountId)?.consecutiveFailures ?? 0) + 1,
1707
+ lastFailureStatus,
1708
+ lastFailureAt: now,
1709
+ });
1710
+ return;
1711
+ }
1314
1712
  if (outcomeClass === "credential") {
1315
1713
  // 401/403 quarantines the account for reauth. That supersedes quota state
1316
1714
  // entirely: a cooldown (and any probe lease) on an unusable account is moot.
@@ -1354,7 +1752,7 @@ export function recordCodexUpstreamOutcome(
1354
1752
  // The shared native scope is the existing account-wide native behavior:
1355
1753
  // threads must leave it and new requests should prefer an eligible account.
1356
1754
  // Spark remains isolated so a same-account Terra/Luna combo fallback can run.
1357
- if (quotaScope === "shared") {
1755
+ if (quotaScope === "shared" && !meta.fixedAccount) {
1358
1756
  clearThreadAccountMapForAccount(accountId);
1359
1757
  notePoolRotationFailure(POOL_KEY_CODEX, accountId);
1360
1758
  if (getEffectiveActiveCodexAccountId(config) === accountId) {
@@ -1399,17 +1797,24 @@ export function recordCodexUpstreamOutcome(
1399
1797
  ...(prior?.lastProbeAt !== undefined ? { lastProbeAt: prior.lastProbeAt } : {}),
1400
1798
  }),
1401
1799
  });
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);
1800
+ if (!meta.fixedAccount) {
1801
+ clearThreadAccountMapForAccount(accountId);
1802
+ // An independent native quota request may discover an account-wide throttle,
1803
+ // but it still must not advance the shared RR ring or active cursor. The next
1804
+ // shared request observes the cooldown and chooses its own fallback.
1805
+ if (!isIndependentCodexQuotaScope(quotaScope)) {
1806
+ notePoolRotationFailure(POOL_KEY_CODEX, accountId);
1807
+ const effectiveActive = getEffectiveActiveCodexAccountId(config);
1808
+ if (effectiveActive === accountId) {
1809
+ // Same-request 429 retry already picked via excludeAccountId — reuse it so
1810
+ // round-robin does not advance the ring a second time.
1811
+ const reused = meta.promoteAccountId && meta.promoteAccountId !== accountId
1812
+ ? meta.promoteAccountId
1813
+ : null;
1814
+ const fallback = reused ?? pickAlternateCodexAccount(config, accountId, now, quotaScope);
1815
+ if (fallback) promoteActiveCodexAccount(config, fallback);
1816
+ }
1817
+ }
1413
1818
  }
1414
1819
  return;
1415
1820
  }
@@ -1454,15 +1859,21 @@ export function recordCodexUpstreamOutcome(
1454
1859
  // thread is still pinned to the FAILING account — a late failure from account A
1455
1860
  // must not delete a newer healthy binding to account B (race: T→A, A fails,
1456
1861
  // T→B, late A failure must not delete B's mapping).
1457
- if (failoverReady && meta.threadId) {
1862
+ if (!meta.fixedAccount && failoverReady && meta.threadId) {
1458
1863
  deleteThreadAffinitiesForAccount(meta.threadId, accountId);
1459
1864
  }
1460
1865
  // Once the account is past the failover streak, clear every thread still pinned
1461
1866
  // to it — matching 429 affinity behavior so "continue" cannot stay on a bad peer.
1462
- if (shouldFailover(config, accountId, now)) {
1867
+ if (!meta.fixedAccount && shouldFailover(config, accountId, now)) {
1463
1868
  clearThreadAccountMapForAccount(accountId);
1464
1869
  }
1465
- if (getEffectiveActiveCodexAccountId(config) === accountId) applyFailureFailover(config, accountId, now);
1870
+ if (
1871
+ !meta.fixedAccount
1872
+ && !isIndependentCodexQuotaScope(quotaScope)
1873
+ && getEffectiveActiveCodexAccountId(config) === accountId
1874
+ ) {
1875
+ applyFailureFailover(config, accountId, now, quotaScope);
1876
+ }
1466
1877
  }
1467
1878
 
1468
1879
  export function formatCodexProviderForLog(providerName: string, accountId: string | null, config: OcxConfig): string {