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