@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
@@ -1,5 +1,11 @@
1
- import { ConfigMutationLockError, loadConfig, mutatePersistedConfig, saveConfigPreservingClaudeCode } from "../config";
2
- import { withCodexAccountLogLabel } from "./account-label";
1
+ import {
2
+ ConfigMutationLockError,
3
+ loadConfig,
4
+ mutatePersistedConfig,
5
+ saveConfigPreservingClaudeCode,
6
+ withConfigMutationLockSync,
7
+ } from "../config";
8
+ import { codexAccountLogLabel, withCodexAccountLogLabel } from "./account-label";
3
9
  import {
4
10
  getCodexAccountCredential,
5
11
  getValidCodexToken,
@@ -14,27 +20,52 @@ import {
14
20
  TokenRefreshError,
15
21
  } from "./account-store";
16
22
  import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
23
+ import {
24
+ appendDefaultCodexAccountNamespace,
25
+ codexAccountPickerEnabled,
26
+ } from "./account-namespaces";
27
+ import {
28
+ catalogRefreshIsPending,
29
+ normalizeCatalogDisposition,
30
+ } from "./catalog-refresh-status";
17
31
  import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause";
18
32
  import {
33
+ clearCodexAccountPin,
34
+ getCodexAccountPriority,
35
+ isCodexAccountPriorityKey,
36
+ pinnedCodexAccountId,
37
+ setCodexAccountPin,
38
+ setCodexAccountPriority,
39
+ } from "./account-priority";
40
+ import {
41
+ claimDueCodexQuotaRecoveryProbes,
19
42
  clearCodexAccountCooldown,
20
43
  clearThreadAccountMapForAccount,
21
44
  getEffectiveActiveCodexAccountId,
45
+ isEffectiveCodexAccountPinned,
22
46
  reconcileCodexActiveAfterExclusion,
23
47
  resetCodexRoutingForManualSelection,
48
+ settleCodexQuotaRecoveryProbe,
24
49
  } from "./routing";
25
50
  import {
51
+ DEFAULT_ACCOUNT_PRIORITY,
52
+ MAX_ACCOUNT_PRIORITY,
53
+ MIN_ACCOUNT_PRIORITY,
26
54
  normalizeAccountPoolStickyLimit,
27
55
  normalizeAccountPoolStrategy,
28
56
  parseAccountPoolStickyLimit,
29
57
  parseAccountPoolStrategy,
58
+ parseAccountPriority,
30
59
  } from "./pool-rotation";
31
60
  import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision";
61
+ import { codexPlanValue, isThirtyDayOnlyCodexPlan } from "./plan";
32
62
  export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision";
33
63
  export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
34
64
  import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
35
65
  import {
36
66
  clearAccountQuota,
37
67
  getAccountQuota,
68
+ isCompleteCodexQuotaRecoverySnapshot,
38
69
  isCodexQuotaExhausted,
39
70
  listAccountQuotas,
40
71
  parseUsageQuota,
@@ -51,24 +82,30 @@ export {
51
82
  setAccountQuotaFromParsed,
52
83
  updateAccountQuota,
53
84
  } from "./quota";
54
- import { extractAccountId, decodeJwtPayload } from "../oauth/chatgpt";
55
- import { getMainAccountPlan, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
56
- import { captureConfigGeneration } from "../lib/state-store-sweeper";
85
+ import { extractAccountId } from "../oauth/chatgpt";
86
+ import { getMainAccountPlan, isMainAccountTokenVerifiablyLive, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
87
+ import { captureConfigGeneration, registerStateSweepAfterTick } from "../lib/state-store-sweeper";
57
88
  import { reconcileLiveStateStores } from "../lib/state-store-registrations";
58
89
  import {
90
+ captureMainAccountIdentityGeneration,
59
91
  clearMainAccountInfoCache,
92
+ getMainAccountCredentialPresence,
60
93
  getMainAccountInfoCache,
94
+ isMainAccountIdentityGenerationLive,
95
+ setMainAccountCredentialPresence,
61
96
  setMainAccountInfoCache,
62
97
  type MainAccountInfo,
63
98
  } from "./main-account-cache";
64
99
  export { clearMainAccountInfoCache } from "./main-account-cache";
65
100
  import { maskEmail } from "../lib/privacy";
66
- import { CodexWarmupError, codexWarmupFailureReason, warmCodexAccount } from "./warmup";
101
+ import { codexWarmupFailureReason, warmCodexAccount } from "./warmup";
67
102
  export { maskEmail } from "../lib/privacy";
68
- import type { CodexAccount, OcxConfig } from "../types";
103
+ import type { CodexAccount, CodexAccountCredentials, OcxConfig } from "../types";
104
+ import type { CatalogDisposition } from "./convergence-types";
69
105
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
70
106
  import { providerCodexAccountMode } from "../providers/registry";
71
- import { readBoundedResponseBody } from "../lib/bounded-body";
107
+ import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBody } from "../lib/bounded-body";
108
+ import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort";
72
109
  import {
73
110
  oauthAccountHealthFields,
74
111
  projectCodexAccountHealth,
@@ -82,7 +119,20 @@ import {
82
119
  isValidCodexAccountId,
83
120
  } from "./account-id";
84
121
  import { codexAccountIdNamespaceCollisionError } from "./account-namespace-match";
85
- import { ResourceAdmissionError } from "../lib/admission";
122
+ import { ResourceAdmissionError, type AdmissionLease } from "../lib/admission";
123
+ import { tryAcquireNativeMainProfileClaim } from "./native-main-admission";
124
+ import { withNativeMainSharedClaim } from "./native-main-claim";
125
+ import { resolveNativeProfileContext } from "./native-profile-store";
126
+ import { NativeProfileError } from "./native-profile-types";
127
+
128
+ function isNativeMainClaimUnavailable(error: unknown): error is NativeProfileError {
129
+ return error instanceof NativeProfileError
130
+ && (error.code === "NATIVE_MAIN_CLAIM_BUSY" || error.code === "NATIVE_MAIN_CLAIM_UNAVAILABLE");
131
+ }
132
+
133
+ function withNativeMainCredentialClaim<T>(operation: () => Promise<T>): Promise<T> {
134
+ return withNativeMainSharedClaim(resolveNativeProfileContext(), operation);
135
+ }
86
136
 
87
137
  function jsonResponse(data: unknown, status = 200): Response {
88
138
  return new Response(JSON.stringify(data), {
@@ -91,11 +141,28 @@ function jsonResponse(data: unknown, status = 200): Response {
91
141
  });
92
142
  }
93
143
 
94
- const MANUAL_IMPORT_ENV = "OPENCODEX_ENABLE_UNVERIFIED_CODEX_IMPORT";
144
+ function nativeMainProfileBusyResponse(): Response {
145
+ const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503);
146
+ response.headers.set("Retry-After", "1");
147
+ return response;
148
+ }
149
+
150
+ const CODEX_CREDENTIAL_PERSISTENCE_ERROR = "Account was saved, but credential setup did not complete. Reauthenticate or remove the account.";
151
+ const CODEX_CREDENTIAL_PERSISTENCE_CODE = "codex_credential_persistence_failed";
95
152
 
96
153
  const MAX_CODEX_LOGIN_STATE_ROWS = 32;
97
154
  const CODEX_LOGIN_TERMINAL_TTL_MS = 300_000;
98
- interface CodexLoginStateRow { status: string; startedAt: number; accountId?: string; email?: string; error?: string; doneAt?: number }
155
+ interface CodexLoginStateRow {
156
+ status: string;
157
+ startedAt: number;
158
+ accountId?: string;
159
+ email?: string;
160
+ error?: string;
161
+ code?: string;
162
+ needsReauth?: boolean;
163
+ catalogRefreshPending?: boolean;
164
+ doneAt?: number;
165
+ }
99
166
  const codexAuthLoginState = new Map<string, CodexLoginStateRow>();
100
167
  export class CodexLoginStateBusyError extends ResourceAdmissionError {
101
168
  constructor() { super("codex_login_state_rows", MAX_CODEX_LOGIN_STATE_ROWS); this.name = "CodexLoginStateBusyError"; }
@@ -142,21 +209,59 @@ function codexAccountPersistenceConflict(
142
209
  : undefined;
143
210
  }
144
211
 
145
- function isThirtyDayOnlyPlan(plan: string | null | undefined): boolean {
146
- const normalized = plan?.trim().toLowerCase();
147
- return normalized === "go" || normalized === "free";
212
+ /**
213
+ * The exact label `parseUsageQuota` emits for the Codex Spark window (quota.ts).
214
+ * Matching on the label rather than on "is a custom window" is load-bearing: the same array
215
+ * carries Cursor's First-party models / API usage, Anthropic's Fable / Opus / Sonnet,
216
+ * Antigravity's Gem / Cla, Kimi's subscription credits and a dozen dynamic provider meters.
217
+ */
218
+ const CODEX_SPARK_WINDOW_LABEL = "GPT-5.3-Codex-Spark Weekly";
219
+
220
+ /**
221
+ * Drop the Spark window unless the operator asked for it (default hidden).
222
+ *
223
+ * Applied at the DTO boundary, never at parse or cache time: custom windows participate in
224
+ * quota-presence checks, snapshot reconciliation and capacity aggregation, so removing Spark
225
+ * upstream of this point would change routing state rather than display.
226
+ *
227
+ * Both GUI surfaces funnel through here — the Codex Auth rows directly, and /api/provider-quotas
228
+ * via listCodexAuthAccountsSnapshot — so one filter covers both. Filtering only one would leave
229
+ * the other still rendering the row the operator switched off.
230
+ */
231
+ export function withSparkVisibility<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAccountQuota | null>(
232
+ quota: T,
233
+ ): T {
234
+ if (!quota?.customWindows?.length) return quota;
235
+ if (loadConfig().showCodexSparkQuota === true) return quota;
236
+ const kept = quota.customWindows.filter(window => window.label !== CODEX_SPARK_WINDOW_LABEL);
237
+ if (kept.length === quota.customWindows.length) return quota;
238
+ // An empty list is dropped rather than serialized: an absent field and an empty array should
239
+ // not be two different ways of saying "no custom windows" on the wire.
240
+ const next = { ...quota } as Record<string, unknown>;
241
+ if (kept.length > 0) next.customWindows = kept;
242
+ else delete next.customWindows;
243
+ return next as T;
148
244
  }
149
245
 
246
+
150
247
  function quotaForPlan<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAccountQuota | null>(
151
248
  quota: T,
152
- plan: string | null | undefined,
249
+ plan: unknown,
153
250
  ): T {
154
- if (!quota || !isThirtyDayOnlyPlan(plan)) return quota;
251
+ const visible = withSparkVisibility(quota);
252
+ if (!visible || !isThirtyDayOnlyCodexPlan(plan)) return visible;
253
+ const quotaWindows = visible;
155
254
  return {
156
- ...(quota.monthlyPercent !== undefined ? { monthlyPercent: quota.monthlyPercent } : {}),
157
- ...(quota.monthlyResetAt !== undefined ? { monthlyResetAt: quota.monthlyResetAt } : {}),
158
- ...(quota.resetCredits !== undefined ? { resetCredits: quota.resetCredits } : {}),
159
- ...("updatedAt" in quota ? { updatedAt: quota.updatedAt } : {}),
255
+ ...(quotaWindows.monthlyPercent !== undefined ? { monthlyPercent: quotaWindows.monthlyPercent } : {}),
256
+ ...(quotaWindows.monthlyResetAt !== undefined ? { monthlyResetAt: quotaWindows.monthlyResetAt } : {}),
257
+ // A 30-day plan can still carry a burst window, and it blocks the account on its own.
258
+ // Dropping it here would show a healthy card for an account upstream is refusing (#1791).
259
+ ...(quotaWindows.shortPercent !== undefined ? { shortPercent: quotaWindows.shortPercent } : {}),
260
+ ...(quotaWindows.shortResetAt !== undefined ? { shortResetAt: quotaWindows.shortResetAt } : {}),
261
+ ...(quotaWindows.shortWindowSeconds !== undefined ? { shortWindowSeconds: quotaWindows.shortWindowSeconds } : {}),
262
+ ...(quotaWindows.customWindows !== undefined ? { customWindows: quotaWindows.customWindows } : {}),
263
+ ...(quotaWindows.resetCredits !== undefined ? { resetCredits: quotaWindows.resetCredits } : {}),
264
+ ...("updatedAt" in quotaWindows ? { updatedAt: quotaWindows.updatedAt } : {}),
160
265
  } as T;
161
266
  }
162
267
 
@@ -165,18 +270,21 @@ function poolAccountDto(
165
270
  quotaResult: PoolQuotaResult,
166
271
  hasCredential: boolean,
167
272
  paused: boolean,
273
+ priority: number,
168
274
  ): CodexAuthAccountDto {
169
- const quota = quotaForPlan(quotaResult.quota, account.plan);
275
+ const plan = codexPlanValue(account.plan);
276
+ const quota = quotaForPlan(quotaResult.quota, plan);
170
277
  const needsReauth = !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id);
171
278
  const health = projectCodexAccountHealth({ accountId: account.id, needsReauth });
172
279
  return {
173
280
  id: account.id,
174
- email: account.email,
281
+ email: maskEmail(account.email) ?? account.email,
175
282
  ...(account.alias !== undefined ? { alias: account.alias } : {}),
176
- ...(account.plan !== undefined ? { plan: account.plan } : {}),
177
- ...(account.logLabel !== undefined ? { logLabel: account.logLabel } : {}),
283
+ ...(plan !== undefined ? { plan } : {}),
284
+ logLabel: codexAccountLogLabel(account),
178
285
  isMain: false,
179
286
  paused,
287
+ priority,
180
288
  quota: quota ? { ...quota } : null,
181
289
  needsReauth,
182
290
  hasCredential,
@@ -185,20 +293,52 @@ function poolAccountDto(
185
293
  };
186
294
  }
187
295
 
188
- async function resolveResetCreditAuth(
296
+ interface ResetCreditAuth {
297
+ isMain: boolean;
298
+ accessToken: string;
299
+ chatgptAccountId: string;
300
+ nativeMainLease?: AdmissionLease;
301
+ nativeMainSharedClaimHeld?: true;
302
+ }
303
+
304
+ async function withResetCreditAuth<T>(
189
305
  runtimeConfig: OcxConfig,
190
306
  accountId: string,
191
- ): Promise<
192
- | { ok: true; isMain: boolean; accessToken: string; chatgptAccountId: string }
193
- | { ok: false; response: Response }
194
- > {
307
+ operation: (auth: ResetCreditAuth) => Promise<T>,
308
+ ): Promise<{ ok: true; value: T } | { ok: false; response: Response }> {
195
309
  if (accountId === MAIN_CODEX_ACCOUNT_ID) {
196
310
  if (hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) {
197
311
  return { ok: false, response: jsonResponse({ error: "Remove the legacy __main__ pool row before using the Desktop account" }, 409) };
198
312
  }
199
- const tokens = readCodexTokens();
200
- if (!tokens) return { ok: false, response: jsonResponse({ error: "Main Codex account not logged in" }, 401) };
201
- return { ok: true, isMain: true, accessToken: tokens.access_token, chatgptAccountId: tokens.account_id };
313
+ const nativeMainLease = tryAcquireNativeMainProfileClaim();
314
+ if (!nativeMainLease) return { ok: false, response: nativeMainProfileBusyResponse() };
315
+ try {
316
+ try {
317
+ return await withNativeMainCredentialClaim(async () => {
318
+ const tokens = readCodexTokens();
319
+ if (!tokens) {
320
+ return { ok: false, response: jsonResponse({ error: "Main Codex account not logged in" }, 401) };
321
+ }
322
+ return {
323
+ ok: true,
324
+ value: await operation({
325
+ isMain: true,
326
+ accessToken: tokens.access_token,
327
+ chatgptAccountId: tokens.account_id,
328
+ nativeMainLease,
329
+ nativeMainSharedClaimHeld: true,
330
+ }),
331
+ };
332
+ });
333
+ } catch (error) {
334
+ if (isNativeMainClaimUnavailable(error)) {
335
+ return { ok: false, response: nativeMainProfileBusyResponse() };
336
+ }
337
+ throw error;
338
+ }
339
+ } finally {
340
+ nativeMainLease.release();
341
+ }
202
342
  }
203
343
  if (!isValidCodexAccountId(accountId)) {
204
344
  return { ok: false, response: jsonResponse({ error: "Invalid account id format" }, 400) };
@@ -207,7 +347,14 @@ async function resolveResetCreditAuth(
207
347
  return { ok: false, response: jsonResponse({ error: "Unknown Codex account" }, 404) };
208
348
  }
209
349
  const cred = await getValidCodexToken(accountId);
210
- return { ok: true, isMain: false, accessToken: cred.accessToken, chatgptAccountId: cred.chatgptAccountId };
350
+ return {
351
+ ok: true,
352
+ value: await operation({
353
+ isMain: false,
354
+ accessToken: cred.accessToken,
355
+ chatgptAccountId: cred.chatgptAccountId,
356
+ }),
357
+ };
211
358
  }
212
359
 
213
360
  function safeResetCreditsDto(input: unknown): { credits: { granted_at: string; expires_at: string }[]; available_count?: number } {
@@ -233,8 +380,41 @@ function safeResetCreditConsumeDto(input: unknown): { code: string } {
233
380
  return { code: typeof obj.code === "string" ? obj.code : "unknown" };
234
381
  }
235
382
 
236
- export function isUnverifiedCodexImportEnabled(): boolean {
237
- return process.env[MANUAL_IMPORT_ENV] === "1";
383
+ type ResetCreditJsonRead =
384
+ | { ok: true; value: unknown }
385
+ | { ok: false };
386
+
387
+ function cancelResponseBodyWithoutWaiting(body: ReadableStream<Uint8Array> | null): void {
388
+ if (!body) return;
389
+ try {
390
+ void body.cancel().catch(() => undefined);
391
+ } catch {
392
+ // Some stream implementations throw synchronously from cancel().
393
+ }
394
+ }
395
+
396
+ async function readResetCreditJson(
397
+ response: Response,
398
+ signal: AbortSignal,
399
+ ): Promise<ResetCreditJsonRead> {
400
+ const declaredLength = Number(response.headers.get("content-length"));
401
+ if (Number.isSafeInteger(declaredLength)
402
+ && declaredLength >= 0
403
+ && declaredLength > BOUNDED_BODY_MAX_BYTES) {
404
+ cancelResponseBodyWithoutWaiting(response.body);
405
+ return { ok: false };
406
+ }
407
+ try {
408
+ const body = await readBoundedResponseBody(response, {
409
+ signal,
410
+ maxBytes: BOUNDED_BODY_MAX_BYTES,
411
+ fatalUtf8: true,
412
+ });
413
+ if (!body.displaySafe || body.truncated || !body.text.trim()) return { ok: false };
414
+ return { ok: true, value: JSON.parse(body.text) as unknown };
415
+ } catch {
416
+ return { ok: false };
417
+ }
238
418
  }
239
419
 
240
420
  function manualImportDisabledResponse(): Response {
@@ -254,13 +434,10 @@ async function verifyCodexAccountWarmup(
254
434
  return { ok: true, validatedAt: Date.now() };
255
435
  } catch (err) {
256
436
  const reason = codexWarmupFailureReason(err);
257
- const upstream = err instanceof CodexWarmupError ? err.upstreamDetail : undefined;
258
437
  return {
259
438
  ok: false,
260
439
  response: jsonResponse({
261
- error: upstream
262
- ? `Codex account warmup failed: ${upstream}`
263
- : "Codex account warmup failed. Reauthenticate the account and try again.",
440
+ error: "Codex account warmup failed. Reauthenticate the account and try again.",
264
441
  code: "codex_warmup_failed",
265
442
  reason,
266
443
  accountId,
@@ -291,7 +468,7 @@ const POOL_CACHE_TTL = 5 * 60_000;
291
468
  const POOL_QUOTA_REFRESH_CONCURRENCY = 4;
292
469
 
293
470
  function nonEmptyPlan(value: unknown): string | null {
294
- return typeof value === "string" && value.trim() !== "" ? value : null;
471
+ return codexPlanValue(value) ?? null;
295
472
  }
296
473
 
297
474
  function isRuntimeConfig(config: OcxConfig): boolean {
@@ -311,6 +488,100 @@ function saveRuntimeConfig(sourceConfig: OcxConfig, nextConfig: OcxConfig): void
311
488
  Object.assign(sourceConfig, nextConfig);
312
489
  }
313
490
 
491
+ interface StagedNewCodexAccountState {
492
+ credential: CodexAccountCredentials;
493
+ validatedAt: number;
494
+ }
495
+
496
+ type PersistNewCodexAccountOutcome =
497
+ | { status: "committed"; pickerVisibilityChanged: boolean }
498
+ | { status: "publication-failed"; pickerVisibilityChanged: boolean };
499
+
500
+ function codexCredentialPersistenceFailure(accountId: string, catalogRefreshPending: boolean) {
501
+ return {
502
+ error: CODEX_CREDENTIAL_PERSISTENCE_ERROR,
503
+ code: CODEX_CREDENTIAL_PERSISTENCE_CODE,
504
+ accountId,
505
+ needsReauth: true as const,
506
+ ...(catalogRefreshPending ? { catalogRefreshPending: true as const } : {}),
507
+ };
508
+ }
509
+
510
+ /** Persist config before publishing secret or runtime state under the shared mutation coordinator. */
511
+ function persistNewCodexAccount(
512
+ sourceConfig: OcxConfig,
513
+ runtimeConfig: OcxConfig,
514
+ addedAccount: CodexAccount,
515
+ staged: StagedNewCodexAccountState,
516
+ ): PersistNewCodexAccountOutcome {
517
+ return withConfigMutationLockSync(() => {
518
+ const previousConfig = { ...runtimeConfig };
519
+ let pickerVisibilityChanged: boolean;
520
+ try {
521
+ const accounts = [...(runtimeConfig.codexAccounts ?? [])];
522
+ const retainedPickerBindingRestored = codexAccountPickerEnabled(runtimeConfig)
523
+ && Object.values(runtimeConfig.codexAccountNamespaces ?? {}).includes(addedAccount.id);
524
+ accounts.push(addedAccount);
525
+ runtimeConfig.codexAccounts = accounts;
526
+
527
+ // Presence of the explicit flag distinguishes a dashboard-managed map from
528
+ // a hand-authored legacy map. Preserve manual maps exactly.
529
+ const tracksPickerNamespaces = runtimeConfig.codexAccountPickerEnabled !== undefined;
530
+ if (tracksPickerNamespaces && runtimeConfig.codexAccountNamespaces) {
531
+ runtimeConfig.codexAccountNamespaces = { ...runtimeConfig.codexAccountNamespaces };
532
+ }
533
+ const namespaceAdded = tracksPickerNamespaces
534
+ && appendDefaultCodexAccountNamespace(runtimeConfig, addedAccount);
535
+ pickerVisibilityChanged = namespaceAdded || retainedPickerBindingRestored;
536
+ saveRuntimeConfig(sourceConfig, runtimeConfig);
537
+ } catch (error) {
538
+ for (const key of Object.keys(runtimeConfig) as Array<keyof OcxConfig>) {
539
+ delete runtimeConfig[key];
540
+ }
541
+ Object.assign(runtimeConfig, previousConfig);
542
+ throw error;
543
+ }
544
+
545
+ try {
546
+ saveCodexAccountCredential(addedAccount.id, staged.credential);
547
+ markCodexAccountValidated(addedAccount.id, staged.validatedAt);
548
+ clearAccountNeedsReauth(addedAccount.id);
549
+ } catch {
550
+ // Config is already durable. Return the failure outcome through the coordinator so its
551
+ // generation commit is not rolled back while config.json remains changed.
552
+ return { status: "publication-failed" as const, pickerVisibilityChanged };
553
+ }
554
+ return { status: "committed" as const, pickerVisibilityChanged };
555
+ });
556
+ }
557
+
558
+ /** Bounded catalog-convergence callback supplied by the management dispatcher. */
559
+ export type CodexAuthCatalogConvergence = () => Promise<CatalogDisposition>;
560
+
561
+ interface AccountNamespaceCatalogRefresh {
562
+ catalogRefreshPending: boolean;
563
+ }
564
+
565
+ /** Collapse post-persistence convergence into the one public recovery bit. */
566
+ async function convergeAccountNamespaceCatalog(
567
+ config: OcxConfig,
568
+ changed: boolean,
569
+ convergeCodexCatalog?: CodexAuthCatalogConvergence,
570
+ ): Promise<AccountNamespaceCatalogRefresh> {
571
+ if (!changed || !codexAccountPickerEnabled(config)) {
572
+ return { catalogRefreshPending: false };
573
+ }
574
+ if (!convergeCodexCatalog) return { catalogRefreshPending: true };
575
+
576
+ try {
577
+ const catalogRefresh = normalizeCatalogDisposition(await convergeCodexCatalog());
578
+ if (!catalogRefresh) return { catalogRefreshPending: true };
579
+ return { catalogRefreshPending: catalogRefreshIsPending(catalogRefresh) };
580
+ } catch {
581
+ return { catalogRefreshPending: true };
582
+ }
583
+ }
584
+
314
585
  async function mapWithConcurrency<T, R>(
315
586
  items: T[],
316
587
  concurrency: number,
@@ -333,12 +604,31 @@ const MAIN_TERMINAL_AUTH_CODES = new Set([
333
604
  "invalid_refresh_token",
334
605
  ]);
335
606
 
336
- async function isTerminalMainAuthResponse(resp: Response): Promise<boolean> {
337
- if (resp.status === 401) return true;
607
+ /**
608
+ * A WHAM 401 is not itself proof the local credential died. Upstream edges can
609
+ * transiently reject a still-valid access token (region/anti-abuse/rotation
610
+ * races), and fail-closing on every bare 401 makes a healthy main account flip
611
+ * needs-reauth on the next GUI quota poll. Only treat the response as terminal
612
+ * when the body carries a known terminal code or the local access token is not
613
+ * verifiably live (`accessTokenLive`). Liveness must be strict: a JWT whose
614
+ * `exp` cannot be decoded is NOT live — an undecodable token that vouched for
615
+ * itself would make a real 401 permanently transient.
616
+ */
617
+ async function isTerminalMainAuthResponse(resp: Response, accessTokenLive: boolean): Promise<boolean> {
618
+ if (resp.status === 401) {
619
+ if (!accessTokenLive) return true;
620
+ const code = await readMainAuthErrorCode(resp);
621
+ return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
622
+ }
338
623
  if (resp.status !== 403) return false;
624
+ const code = await readMainAuthErrorCode(resp);
625
+ return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
626
+ }
627
+
628
+ async function readMainAuthErrorCode(resp: Response): Promise<unknown> {
339
629
  try {
340
630
  const body = await readBoundedResponseBody(resp, { totalTimeoutMs: 1_000, inactivityTimeoutMs: 1_000 });
341
- if (!body.displaySafe) return false;
631
+ if (!body.displaySafe) return undefined;
342
632
  const parsed = JSON.parse(body.text) as {
343
633
  detail?: { code?: unknown } | string;
344
634
  error?: { code?: unknown } | string;
@@ -349,23 +639,41 @@ async function isTerminalMainAuthResponse(resp: Response): Promise<boolean> {
349
639
  : typeof parsed.error === "object" && parsed.error !== null
350
640
  ? parsed.error.code
351
641
  : parsed.code;
352
- return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
642
+ return code;
353
643
  } catch {
354
- return false;
644
+ return undefined;
355
645
  }
356
646
  }
357
647
 
358
648
  interface MainAccountInfoFetchResult {
359
649
  info: MainAccountInfo;
650
+ /** Whether this attempt safely inspected the physical native-main credential. */
651
+ credentialChecked: boolean;
652
+ /** Meaningful only when credentialChecked is true. */
653
+ hasCredential: boolean;
654
+ /** Main identity generation captured while the native-main claim was held. */
655
+ identityGeneration?: number;
360
656
  /** Present only when this call freshly parsed a WHAM usage response. */
361
657
  freshQuota?: Omit<StoredAccountQuota, "updatedAt">;
362
658
  /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
363
659
  freshResetCredits?: number;
364
660
  }
365
661
 
662
+ export interface MainAccountInfoSnapshot {
663
+ info: MainAccountInfo;
664
+ mainIdentityGeneration: number;
665
+ }
666
+
667
+ export async function fetchMainAccountInfoSnapshot(forceRefresh = false): Promise<MainAccountInfoSnapshot> {
668
+ const result = await fetchMainAccountInfoAttempt(forceRefresh, 1);
669
+ return {
670
+ info: result.info,
671
+ mainIdentityGeneration: result.identityGeneration ?? captureMainAccountIdentityGeneration(),
672
+ };
673
+ }
674
+
366
675
  export async function fetchMainAccountInfo(forceRefresh = false): Promise<MainAccountInfo> {
367
- const { info } = await fetchMainAccountInfoAttempt(forceRefresh, 1);
368
- return info;
676
+ return (await fetchMainAccountInfoSnapshot(forceRefresh)).info;
369
677
  }
370
678
 
371
679
  const EMPTY_MAIN_ACCOUNT_INFO: MainAccountInfo = { email: null, plan: null, quota: null };
@@ -373,19 +681,72 @@ const EMPTY_MAIN_ACCOUNT_INFO: MainAccountInfo = { email: null, plan: null, quot
373
681
  async function retryMainAccountInfoIfIdentityChanged(
374
682
  requestAccountId: string | null,
375
683
  retriesRemaining: number,
684
+ nativeMainLease: AdmissionLease,
685
+ explicitRefresh: boolean,
376
686
  ): Promise<MainAccountInfoFetchResult | null> {
377
687
  const currentAccountId = getMainChatgptAccountId();
378
688
  if (currentAccountId === null || currentAccountId === requestAccountId) return null;
379
689
  reconcileMainCodexAccountRuntimeState();
380
690
  return retriesRemaining > 0
381
- ? fetchMainAccountInfoAttempt(true, retriesRemaining - 1)
382
- : { info: EMPTY_MAIN_ACCOUNT_INFO };
691
+ ? fetchMainAccountInfoWhileOwned(true, retriesRemaining - 1, nativeMainLease, explicitRefresh)
692
+ : { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
383
693
  }
384
694
 
385
- async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaining: number): Promise<MainAccountInfoFetchResult> {
695
+ async function fetchMainAccountInfoAttempt(
696
+ forceRefresh: boolean,
697
+ retriesRemaining: number,
698
+ existingNativeMainLease?: AdmissionLease,
699
+ nativeMainSharedClaimHeld = false,
700
+ ): Promise<MainAccountInfoFetchResult> {
701
+ const nativeMainLease = existingNativeMainLease ?? tryAcquireNativeMainProfileClaim();
702
+ if (!nativeMainLease) {
703
+ return {
704
+ info: EMPTY_MAIN_ACCOUNT_INFO,
705
+ credentialChecked: false,
706
+ hasCredential: false,
707
+ identityGeneration: captureMainAccountIdentityGeneration(),
708
+ };
709
+ }
710
+ try {
711
+ const operation = async () => ({
712
+ ...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease),
713
+ identityGeneration: captureMainAccountIdentityGeneration(),
714
+ });
715
+ if (nativeMainSharedClaimHeld) return await operation();
716
+ try {
717
+ return await withNativeMainCredentialClaim(operation);
718
+ } catch (error) {
719
+ if (isNativeMainClaimUnavailable(error)) {
720
+ return {
721
+ info: EMPTY_MAIN_ACCOUNT_INFO,
722
+ credentialChecked: false,
723
+ hasCredential: false,
724
+ identityGeneration: captureMainAccountIdentityGeneration(),
725
+ };
726
+ }
727
+ throw error;
728
+ }
729
+ } finally {
730
+ if (!existingNativeMainLease) nativeMainLease.release();
731
+ }
732
+ }
733
+
734
+ async function fetchMainAccountInfoWhileOwned(
735
+ forceRefresh: boolean,
736
+ retriesRemaining: number,
737
+ nativeMainLease: AdmissionLease,
738
+ /**
739
+ * Whether the *caller* asked for this refresh. `forceRefresh` also means "bypass the
740
+ * cache", and `retryMainAccountInfoIfIdentityChanged` re-enters with it set purely to
741
+ * re-read after the identity changed. Keeping the two apart stops that retry from
742
+ * promoting a background poll into operator intent below.
743
+ */
744
+ explicitRefresh: boolean = forceRefresh,
745
+ ): Promise<MainAccountInfoFetchResult> {
386
746
  const writerGeneration = captureConfigGeneration();
387
747
  reconcileMainCodexAccountRuntimeState();
388
748
  const tokenRead = readCodexTokensResult();
749
+ setMainAccountCredentialPresence(tokenRead.status === "ok");
389
750
  if (tokenRead.status !== "ok") {
390
751
  // A local read failure is NOT proof of sign-out: a missing file can be a non-atomic rewrite
391
752
  // gap, and malformed JSON can be a half-written file. Clearing the cache and marking the
@@ -394,13 +755,13 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
394
755
  // routing stays fail-closed because getMainAccountToken() re-reads the file itself, and the
395
756
  // account DTO still reports hasCredential=false while the file is unreadable.
396
757
  const preserved = getMainAccountInfoCache();
397
- return { info: preserved ?? EMPTY_MAIN_ACCOUNT_INFO };
758
+ return { info: preserved ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: false };
398
759
  }
399
760
  const tokens = tokenRead.tokens;
400
761
  const requestAccountId = extractAccountId(tokens.id_token, tokens.access_token) ?? (tokens.account_id || null);
401
762
  const cached = getMainAccountInfoCache();
402
763
  if (!forceRefresh && cached && Date.now() - cached.ts < MAIN_CACHE_TTL) {
403
- return { info: cached };
764
+ return { info: cached, credentialChecked: true, hasCredential: true };
404
765
  }
405
766
  try {
406
767
  const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
@@ -408,17 +769,17 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
408
769
  signal: AbortSignal.timeout(8000),
409
770
  });
410
771
  if (!resp.ok) {
411
- const terminalAuthFailure = await isTerminalMainAuthResponse(resp);
412
- const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
772
+ const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive());
773
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
413
774
  if (retried) return retried;
414
775
  if (terminalAuthFailure) {
415
776
  clearMainAccountInfoCache();
416
777
  markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration);
417
778
  }
418
- return { info: EMPTY_MAIN_ACCOUNT_INFO };
779
+ return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
419
780
  }
420
781
  const data = (await resp.json()) as WhamUsageResponse;
421
- const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
782
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
422
783
  if (retried) return retried;
423
784
  const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan());
424
785
  const quota = parseUsageQuota({ ...data, ...(plan ? { plan_type: plan } : {}) });
@@ -430,7 +791,16 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
430
791
  ts: Date.now(),
431
792
  };
432
793
  setMainAccountInfoCache(result);
433
- clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
794
+ // Only an explicit refresh may retract a reauth quarantine. A 200 from
795
+ // /wham/usage proves the token authenticates to the usage endpoint; it does not
796
+ // prove the account can serve Responses traffic, which is a different backend path
797
+ // and still answers 403 for a workspace the token may no longer select (#327).
798
+ // Letting the background poll clear the flag put such an account straight back into
799
+ // rotation: the next request failed the same way and re-marked it, so needsReauth
800
+ // never settled and the dashboard kept showing nothing — the symptom #327 reported.
801
+ // An explicit refresh is an operator asking to re-evaluate, normally right after
802
+ // signing in again, so it stays authoritative.
803
+ if (explicitRefresh) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
434
804
  // Mirror main quota + plan into the shared stores so the rotation engine can
435
805
  // score and auto-switch the main account exactly like a pool account (Option A).
436
806
  setMainAccountPlan(result.plan);
@@ -439,12 +809,14 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
439
809
  }
440
810
  return {
441
811
  info: result,
812
+ credentialChecked: true,
813
+ hasCredential: true,
442
814
  ...(quota ? { freshQuota: quota } : {}),
443
815
  ...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
444
816
  };
445
817
  } catch {
446
- const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
447
- return retried ?? { info: EMPTY_MAIN_ACCOUNT_INFO };
818
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
819
+ return retried ?? { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
448
820
  }
449
821
  }
450
822
 
@@ -514,6 +886,8 @@ export interface CodexAuthAccountDto {
514
886
  logLabel?: string;
515
887
  isMain: boolean;
516
888
  paused: boolean;
889
+ /** Selection order; higher is used earlier. Always present, 0 when unset. */
890
+ priority: number;
517
891
  quota: (StoredAccountQuota | (Omit<StoredAccountQuota, "updatedAt"> & { updatedAt: number })) | null;
518
892
  needsReauth?: boolean;
519
893
  hasCredential: boolean;
@@ -551,6 +925,15 @@ function reconcileFreshPoolAccountPlans(runtimeConfig: OcxConfig, updates: Fresh
551
925
  accepted.push(update);
552
926
  if (persistedAccount.plan !== update.plan) {
553
927
  persistedAccount.plan = update.plan;
928
+ // WHAM is the authoritative plan source: stamp provenance so a later JWT
929
+ // reconcile cannot overwrite this observation within the same credential
930
+ // generation (src/codex/plan-from-token.ts jwtMayWritePlan). Stamped only
931
+ // alongside a real plan change: a steady-state refresh whose plan is
932
+ // unchanged must stay write-free (no-config-write contract), and an
933
+ // unchanged value needs no fence — a JWT rewrite to the same text is a
934
+ // no-op under the caller's own equality check.
935
+ persistedAccount.planSource = "wham";
936
+ persistedAccount.planCredentialGeneration = update.credentialGeneration;
554
937
  changed = true;
555
938
  }
556
939
  }
@@ -570,6 +953,8 @@ function reconcileFreshPoolAccountPlans(runtimeConfig: OcxConfig, updates: Fresh
570
953
  const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId);
571
954
  if (liveAccount) {
572
955
  liveAccount.plan = update.plan;
956
+ liveAccount.planSource = "wham";
957
+ liveAccount.planCredentialGeneration = update.credentialGeneration;
573
958
  }
574
959
  }
575
960
  }
@@ -689,6 +1074,60 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co
689
1074
  }
690
1075
 
691
1076
  let primeInFlight: Promise<void> | null = null;
1077
+ let cooldownRecoveryInFlight: Promise<void> | null = null;
1078
+
1079
+ export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Date.now()): Promise<void> {
1080
+ const openai = config.providers[OPENAI_CODEX_PROVIDER_ID];
1081
+ if (!openai
1082
+ || openai.disabled === true
1083
+ || !isCanonicalOpenAiForwardProvider(openai)
1084
+ || providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool") return;
1085
+ if (cooldownRecoveryInFlight) return cooldownRecoveryInFlight;
1086
+ cooldownRecoveryInFlight = (async () => {
1087
+ const claims = claimDueCodexQuotaRecoveryProbes(config, POOL_QUOTA_REFRESH_CONCURRENCY, now);
1088
+ await mapWithConcurrency(claims, POOL_QUOTA_REFRESH_CONCURRENCY, async claim => {
1089
+ const account = configuredPoolAccount(config, claim.accountId);
1090
+ if (!account) {
1091
+ settleCodexQuotaRecoveryProbe(claim, false, {}, now);
1092
+ return;
1093
+ }
1094
+ try {
1095
+ const result = await fetchPoolAccountQuota(claim.accountId, true, account.plan);
1096
+ // Defence in depth: `spark` is already excluded at the claim site, since generic WHAM
1097
+ // cannot prove a spark recovery. Keep the settle-side guard so a future claim change
1098
+ // cannot silently start clearing spark on generic evidence.
1099
+ const recovered = claim.scope !== "spark"
1100
+ && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? account.plan);
1101
+ settleCodexQuotaRecoveryProbe(claim, recovered, {
1102
+ credentialGeneration: result.freshCredentialGeneration,
1103
+ }, now);
1104
+ } catch {
1105
+ settleCodexQuotaRecoveryProbe(claim, false, {}, now);
1106
+ }
1107
+ });
1108
+ })().catch(() => {
1109
+ // Background recovery is best-effort; routing keeps the cooldown on failure.
1110
+ }).finally(() => { cooldownRecoveryInFlight = null; });
1111
+ return cooldownRecoveryInFlight;
1112
+ }
1113
+
1114
+ export function registerCodexCooldownRecoveryProbeWorker(config: OcxConfig): void {
1115
+ registerStateSweepAfterTick({
1116
+ name: "codex-cooldown-recovery",
1117
+ afterTick: () => { void runCodexCooldownRecoveryProbes(config); },
1118
+ });
1119
+ }
1120
+
1121
+ export interface PrimeCodexPoolQuotasOptions {
1122
+ /** Test seams for proving fenced/recovery priming performs no native-main work. */
1123
+ reconcileMainAccount?: typeof reconcileMainCodexAccountRuntimeState;
1124
+ readMainTokens?: typeof readCodexTokens;
1125
+ fetchMainInfo?: typeof fetchMainAccountInfo;
1126
+ }
1127
+
1128
+ function tryAcquireNativeMainPrimeLease(): AdmissionLease | null {
1129
+ return tryAcquireNativeMainProfileClaim();
1130
+ }
692
1131
 
693
1132
  /**
694
1133
  * Best-effort prime of pool-account (and main) quota so the rotation engine has
@@ -705,7 +1144,11 @@ let primeInFlight: Promise<void> | null = null;
705
1144
  * cost, so the worst case is one WHAM call per account per TTL window. Failures
706
1145
  * are swallowed: a blocked WSL network must never crash startup or a request.
707
1146
  */
708
- export async function primeCodexPoolQuotas(config: OcxConfig, reason: string): Promise<void> {
1147
+ export async function primeCodexPoolQuotas(
1148
+ config: OcxConfig,
1149
+ reason: string,
1150
+ options: PrimeCodexPoolQuotasOptions = {},
1151
+ ): Promise<void> {
709
1152
  const openai = config.providers[OPENAI_CODEX_PROVIDER_ID];
710
1153
  if (
711
1154
  !openai
@@ -714,10 +1157,6 @@ export async function primeCodexPoolQuotas(config: OcxConfig, reason: string): P
714
1157
  || providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool"
715
1158
  ) return;
716
1159
  if (primeInFlight) return primeInFlight;
717
- // Seed the observed physical main identity before startup/lazy priming can populate quota or
718
- // plan state. Otherwise the first post-startup account switch sees no previous identity and
719
- // skips the purge that protects the stable __main__ alias.
720
- reconcileMainCodexAccountRuntimeState();
721
1160
  primeInFlight = (async () => {
722
1161
  const runtimeConfig = getRuntimeConfig(config);
723
1162
  const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
@@ -725,10 +1164,30 @@ export async function primeCodexPoolQuotas(config: OcxConfig, reason: string): P
725
1164
  const q = getAccountQuota(a.id);
726
1165
  return !q || Date.now() - q.updatedAt >= POOL_CACHE_TTL;
727
1166
  });
728
- const primeMain = !!readCodexTokens() && !getAccountQuota(MAIN_CODEX_ACCOUNT_ID);
1167
+ const primeMain = async () => {
1168
+ const mainLease = tryAcquireNativeMainPrimeLease();
1169
+ if (!mainLease) return;
1170
+ try {
1171
+ try {
1172
+ await withNativeMainCredentialClaim(async () => {
1173
+ // Keep one local owner and one cross-process reader from physical
1174
+ // identity reconciliation through WHAM and all quota publication.
1175
+ (options.reconcileMainAccount ?? reconcileMainCodexAccountRuntimeState)();
1176
+ if (getAccountQuota(MAIN_CODEX_ACCOUNT_ID)) return;
1177
+ if (!(options.readMainTokens ?? readCodexTokens)()) return;
1178
+ if (options.fetchMainInfo) await options.fetchMainInfo(false);
1179
+ else await fetchMainAccountInfoAttempt(false, 1, mainLease, true);
1180
+ });
1181
+ } catch (error) {
1182
+ if (!isNativeMainClaimUnavailable(error)) throw error;
1183
+ }
1184
+ } finally {
1185
+ mainLease.release();
1186
+ }
1187
+ };
729
1188
  try {
730
1189
  await Promise.allSettled([
731
- primeMain ? fetchMainAccountInfo(false) : Promise.resolve(),
1190
+ primeMain(),
732
1191
  mapWithConcurrency(stale, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
733
1192
  if (!getCodexAccountCredential(a.id)) return;
734
1193
  await fetchPoolAccountQuota(a.id, false, a.plan);
@@ -750,10 +1209,27 @@ export function clearCodexQuotaPrimeState(): void {
750
1209
  primeInFlight = null;
751
1210
  }
752
1211
 
753
- export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = false): Promise<CodexAuthAccountDto[]> {
1212
+ /** Test-only reset for the worker-level single-flight. */
1213
+ export function clearCodexCooldownRecoveryProbeState(): void {
1214
+ cooldownRecoveryInFlight = null;
1215
+ }
1216
+
1217
+ export function effectiveCodexAuthAccountId(config: OcxConfig): string {
1218
+ return getEffectiveActiveCodexAccountId(config) ?? MAIN_CODEX_ACCOUNT_ID;
1219
+ }
1220
+
1221
+ export interface CodexAuthAccountsSnapshot {
1222
+ accounts: CodexAuthAccountDto[];
1223
+ mainIdentityGeneration: number;
1224
+ }
1225
+
1226
+ export async function listCodexAuthAccountsSnapshot(
1227
+ config: OcxConfig,
1228
+ forceRefresh = false,
1229
+ ): Promise<CodexAuthAccountsSnapshot> {
754
1230
  const runtimeConfig = getRuntimeConfig(config);
755
1231
  const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
756
- const mainInfo = await fetchMainAccountInfo(forceRefresh);
1232
+ const mainResult = await fetchMainAccountInfoAttempt(forceRefresh, 1);
757
1233
  const refreshedPool = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
758
1234
  const cred = getCodexAccountCredential(account.id);
759
1235
  let quotaResult: PoolQuotaResult;
@@ -796,6 +1272,7 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa
796
1272
  { quota: null, needsReauth: true },
797
1273
  false,
798
1274
  isCodexAccountPaused(runtimeConfig, accountId),
1275
+ getCodexAccountPriority(runtimeConfig, accountId),
799
1276
  )];
800
1277
  }
801
1278
  const resultGeneration = quotaResult.credentialGeneration ?? quotaResult.freshCredentialGeneration;
@@ -814,10 +1291,17 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa
814
1291
  effectiveQuotaResult,
815
1292
  true,
816
1293
  isCodexAccountPaused(runtimeConfig, accountId),
1294
+ getCodexAccountPriority(runtimeConfig, accountId),
817
1295
  )];
818
1296
  });
819
- const hasMainCredential = readCodexTokens() !== null;
820
- const mainNeedsReauth = !hasMainCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
1297
+ const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration();
1298
+ const mainSnapshotLive = isMainAccountIdentityGenerationLive(fetchedMainGeneration);
1299
+ const mainInfo = mainSnapshotLive ? mainResult.info : EMPTY_MAIN_ACCOUNT_INFO;
1300
+ const hasMainCredential = mainSnapshotLive && mainResult.credentialChecked
1301
+ ? mainResult.hasCredential
1302
+ : getMainAccountCredentialPresence() ?? false;
1303
+ const mainNeedsReauth = (mainSnapshotLive && mainResult.credentialChecked && !hasMainCredential)
1304
+ || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
821
1305
  const mainHealth = projectCodexAccountHealth({
822
1306
  accountId: MAIN_CODEX_ACCOUNT_ID,
823
1307
  needsReauth: mainNeedsReauth,
@@ -826,14 +1310,30 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa
826
1310
  id: MAIN_CODEX_ACCOUNT_ID,
827
1311
  email: mainInfo.email ?? "Codex App login",
828
1312
  plan: mainInfo.plan,
1313
+ logLabel: "main",
829
1314
  isMain: true,
830
1315
  paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
1316
+ priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
831
1317
  hasCredential: hasMainCredential,
832
1318
  needsReauth: mainNeedsReauth,
833
- quota: mainInfo.quota ? { ...quotaForPlan({ ...mainInfo.quota, updatedAt: Date.now() }, mainInfo.plan) } : null,
1319
+ quota: mainInfo.quota ? {
1320
+ ...quotaForPlan({
1321
+ ...mainInfo.quota,
1322
+ updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(),
1323
+ }, mainInfo.plan),
1324
+ } : null,
834
1325
  ...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
835
1326
  };
836
- return [main, ...withQuota];
1327
+ return {
1328
+ accounts: [main, ...withQuota],
1329
+ mainIdentityGeneration: mainSnapshotLive
1330
+ ? fetchedMainGeneration
1331
+ : captureMainAccountIdentityGeneration(),
1332
+ };
1333
+ }
1334
+
1335
+ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = false): Promise<CodexAuthAccountDto[]> {
1336
+ return (await listCodexAuthAccountsSnapshot(config, forceRefresh)).accounts;
837
1337
  }
838
1338
 
839
1339
  interface PauseExhaustedResult {
@@ -846,63 +1346,102 @@ function selectFallbackAfterPause(config: OcxConfig, pausedActiveId: string): vo
846
1346
  reconcileCodexActiveAfterExclusion(config, pausedActiveId);
847
1347
  }
848
1348
 
849
- async function pauseExhaustedCodexAccounts(config: OcxConfig): Promise<PauseExhaustedResult> {
1349
+ async function pauseExhaustedCodexAccounts(
1350
+ config: OcxConfig,
1351
+ persistPausedAccounts: () => void,
1352
+ ): Promise<PauseExhaustedResult> {
850
1353
  const poolAccounts = (config.codexAccounts ?? []).filter(account => !account.isMain);
851
- const mainAttempted = readCodexTokens() !== null;
852
- const [mainResult, poolResults] = await Promise.all([
853
- fetchMainAccountInfoAttempt(true, 1),
854
- mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
855
- if (!getCodexAccountCredential(account.id)) return { account, quotaResult: null };
856
- return {
857
- account,
858
- quotaResult: await fetchPoolAccountQuota(account.id, true, account.plan),
1354
+ const nativeMainLease = tryAcquireNativeMainProfileClaim();
1355
+ try {
1356
+ const performPause = async (mainLease?: AdmissionLease): Promise<PauseExhaustedResult> => {
1357
+ const mainWork = async (): Promise<{
1358
+ shouldPause: boolean;
1359
+ checkedAccountCount: number;
1360
+ failedAccountCount: number;
1361
+ }> => {
1362
+ if (!mainLease) return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 1 };
1363
+ const mainResult = await fetchMainAccountInfoAttempt(true, 1, mainLease, true);
1364
+ if (!mainResult.credentialChecked || !mainResult.hasCredential) {
1365
+ return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 0 };
1366
+ }
1367
+ if (!mainResult.freshQuota || !mainResult.info.plan) {
1368
+ return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 1 };
1369
+ }
1370
+ return {
1371
+ shouldPause: !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
1372
+ && isCodexQuotaExhausted(mainResult.freshQuota, mainResult.info.plan),
1373
+ checkedAccountCount: 1,
1374
+ failedAccountCount: 0,
1375
+ };
859
1376
  };
860
- }),
861
- ]);
862
-
863
- let checkedAccountCount = 0;
864
- let failedAccountCount = 0;
865
- const exhaustedIds: string[] = [];
866
- if (mainAttempted) {
867
- if (mainResult.freshQuota && mainResult.info.plan) {
868
- checkedAccountCount += 1;
869
- if (
870
- !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
871
- && isCodexQuotaExhausted(mainResult.freshQuota, mainResult.info.plan)
872
- ) {
873
- exhaustedIds.push(MAIN_CODEX_ACCOUNT_ID);
1377
+ const [mainResult, poolResults] = await Promise.all([
1378
+ mainWork(),
1379
+ mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
1380
+ if (!getCodexAccountCredential(account.id)) return { account, quotaResult: null };
1381
+ try {
1382
+ return {
1383
+ account,
1384
+ quotaResult: await fetchPoolAccountQuota(account.id, true, account.plan),
1385
+ };
1386
+ } catch {
1387
+ // Settle each pool probe independently so a busy/failing account cannot
1388
+ // abandon an already-confirmed main decision before atomic publication.
1389
+ return { account, quotaResult: null };
1390
+ }
1391
+ }),
1392
+ ]);
1393
+
1394
+ let checkedAccountCount = mainResult.checkedAccountCount;
1395
+ let failedAccountCount = mainResult.failedAccountCount;
1396
+ const exhaustedIds: string[] = mainResult.shouldPause ? [MAIN_CODEX_ACCOUNT_ID] : [];
1397
+ for (const { account, quotaResult } of poolResults) {
1398
+ const currentAccount = (config.codexAccounts ?? []).find(candidate => candidate.id === account.id && !candidate.isMain);
1399
+ if (!currentAccount) continue;
1400
+ const generation = quotaResult?.freshCredentialGeneration;
1401
+ const plan = quotaResult?.freshPlan ?? currentAccount.plan;
1402
+ if (!quotaResult?.freshQuota || generation === undefined || !isCodexAccountGenerationLive(account.id, generation) || !plan) {
1403
+ failedAccountCount += 1;
1404
+ continue;
1405
+ }
1406
+ checkedAccountCount += 1;
1407
+ if (!isCodexAccountPaused(config, account.id) && isCodexQuotaExhausted(quotaResult.freshQuota, plan)) {
1408
+ exhaustedIds.push(account.id);
1409
+ }
874
1410
  }
875
- } else {
876
- failedAccountCount += 1;
877
- }
878
- }
879
- for (const { account, quotaResult } of poolResults) {
880
- const currentAccount = (config.codexAccounts ?? []).find(candidate => candidate.id === account.id && !candidate.isMain);
881
- if (!currentAccount) continue;
882
- const generation = quotaResult?.freshCredentialGeneration;
883
- const plan = quotaResult?.freshPlan ?? currentAccount.plan;
884
- if (!quotaResult?.freshQuota || generation === undefined || !isCodexAccountGenerationLive(account.id, generation) || !plan) {
885
- failedAccountCount += 1;
886
- continue;
887
- }
888
- checkedAccountCount += 1;
889
- if (!isCodexAccountPaused(config, account.id) && isCodexQuotaExhausted(quotaResult.freshQuota, plan)) {
890
- exhaustedIds.push(account.id);
891
- }
892
- }
893
1411
 
894
- for (const id of exhaustedIds) {
895
- setCodexAccountPaused(config, id, true);
896
- clearThreadAccountMapForAccount(id);
1412
+ for (const id of exhaustedIds) {
1413
+ setCodexAccountPaused(config, id, true);
1414
+ clearThreadAccountMapForAccount(id);
1415
+ }
1416
+ for (const id of exhaustedIds) selectFallbackAfterPause(config, id);
1417
+ const result = {
1418
+ pausedAccountIds: exhaustedIds,
1419
+ checkedAccountCount,
1420
+ failedAccountCount,
1421
+ };
1422
+ // Persist while both the in-process admission and cross-process shared
1423
+ // claim still own the physical-main identity used for the decision.
1424
+ if (result.pausedAccountIds.length > 0) persistPausedAccounts();
1425
+ return result;
1426
+ };
1427
+
1428
+ if (!nativeMainLease) return await performPause();
1429
+ try {
1430
+ return await withNativeMainCredentialClaim(() => performPause(nativeMainLease));
1431
+ } catch (error) {
1432
+ if (isNativeMainClaimUnavailable(error)) return await performPause();
1433
+ throw error;
1434
+ }
1435
+ } finally {
1436
+ nativeMainLease?.release();
897
1437
  }
898
- for (const id of exhaustedIds) selectFallbackAfterPause(config, id);
899
- return { pausedAccountIds: exhaustedIds, checkedAccountCount, failedAccountCount };
900
1438
  }
901
1439
 
902
1440
  export async function handleCodexAuthAPI(
903
1441
  req: Request,
904
1442
  url: URL,
905
1443
  config: OcxConfig,
1444
+ convergeCodexCatalog?: CodexAuthCatalogConvergence,
906
1445
  ): Promise<Response | null> {
907
1446
 
908
1447
  if (url.pathname === "/api/codex-auth/accounts" && req.method === "GET") {
@@ -911,50 +1450,7 @@ export async function handleCodexAuthAPI(
911
1450
  }
912
1451
 
913
1452
  if (url.pathname === "/api/codex-auth/accounts" && req.method === "POST") {
914
- if (!isUnverifiedCodexImportEnabled()) return manualImportDisabledResponse();
915
-
916
- let body: { id: string; email: string; plan?: string; accessToken: string; refreshToken: string; chatgptAccountId: string };
917
- try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
918
- if (!body.id || !body.email || !body.accessToken || !body.refreshToken || !body.chatgptAccountId) {
919
- return jsonResponse({ error: "Missing required fields" }, 400);
920
- }
921
- if (!isValidCodexAccountId(body.id)) {
922
- return jsonResponse({ error: "Invalid account id format" }, 400);
923
- }
924
- if (body.accessToken.length > 10_000 || body.refreshToken.length > 10_000) {
925
- return jsonResponse({ error: "Input too large" }, 400);
926
- }
927
- const runtimeConfig = getRuntimeConfig(config);
928
- const preflightConflict = codexAccountPersistenceConflict(runtimeConfig, body.id, "create");
929
- if (preflightConflict) return jsonResponse({ error: preflightConflict }, 400);
930
- // 1.1: Duplicate check is scoped by personal vs workspace plan bucket.
931
- const derivedAccountId = extractAccountId(undefined, body.accessToken) ?? body.chatgptAccountId;
932
- const collision = checkAccountIdCollision(derivedAccountId, body.email, body.plan);
933
- if (collision.collision) {
934
- return jsonResponse({ error: collision.reason }, 400);
935
- }
936
- // 4.2: use JWT exp for expiresAt instead of hardcoded 1 hour
937
- const payload = decodeJwtPayload(body.accessToken);
938
- const exp = typeof payload?.exp === "number" ? payload.exp * 1000 : Date.now() + 3600_000;
939
- const warmup = await verifyCodexAccountWarmup(body.id, body.accessToken, derivedAccountId);
940
- if (!warmup.ok) return warmup.response;
941
- const latestConfig = getRuntimeConfig(config);
942
- const commitConflict = codexAccountPersistenceConflict(latestConfig, body.id, "create");
943
- if (commitConflict) return jsonResponse({ error: commitConflict }, 400);
944
- saveCodexAccountCredential(body.id, {
945
- accessToken: body.accessToken,
946
- refreshToken: body.refreshToken,
947
- expiresAt: exp,
948
- chatgptAccountId: derivedAccountId,
949
- });
950
- markCodexAccountValidated(body.id, warmup.validatedAt);
951
- clearAccountNeedsReauth(body.id);
952
- const accounts = latestConfig.codexAccounts ?? [];
953
- accounts.push(withCodexAccountLogLabel({ id: body.id, email: body.email, plan: body.plan, isMain: false }, accounts));
954
- latestConfig.codexAccounts = accounts;
955
- saveRuntimeConfig(config, latestConfig);
956
- reconcileLiveStateStores();
957
- return jsonResponse({ ok: true });
1453
+ return manualImportDisabledResponse();
958
1454
  }
959
1455
 
960
1456
  if (url.pathname === "/api/codex-auth/accounts" && req.method === "DELETE") {
@@ -966,10 +1462,15 @@ export async function handleCodexAuthAPI(
966
1462
  if (!isValidCodexAccountId(id) && !isLegacyPoolAccount) {
967
1463
  return jsonResponse({ error: "Invalid account id format" }, 400);
968
1464
  }
969
- deleteCodexAccount(runtimeConfig, id);
1465
+ const pickerVisibilityChanged = deleteCodexAccount(runtimeConfig, id);
970
1466
  saveRuntimeConfig(config, runtimeConfig);
971
1467
  reconcileLiveStateStores();
972
- return jsonResponse({ ok: true });
1468
+ const catalogRefresh = await convergeAccountNamespaceCatalog(
1469
+ runtimeConfig,
1470
+ pickerVisibilityChanged,
1471
+ convergeCodexCatalog,
1472
+ );
1473
+ return jsonResponse({ ok: true, ...catalogRefresh });
973
1474
  }
974
1475
 
975
1476
  if (url.pathname === "/api/codex-auth/accounts/alias" && req.method === "PUT") {
@@ -1018,9 +1519,59 @@ export async function handleCodexAuthAPI(
1018
1519
  });
1019
1520
  }
1020
1521
 
1522
+ // Deliberately a route of its own rather than a field on the alias PATCH: aliases
1523
+ // are display-only and reject __main__, while selection order is routing metadata
1524
+ // that the Desktop account must be able to carry. Re-ordering never kicks a live
1525
+ // thread, so there is no affinity clearing and no appliesImmediately here.
1526
+ if (url.pathname === "/api/codex-auth/accounts/priority" && req.method === "PUT") {
1527
+ let parsedBody: unknown;
1528
+ try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
1529
+ if (typeof parsedBody !== "object" || parsedBody === null || Array.isArray(parsedBody)) {
1530
+ return jsonResponse({ error: "body must be an object" }, 400);
1531
+ }
1532
+ const body = parsedBody as { id?: unknown; priority?: unknown };
1533
+ const id = typeof body.id === "string" ? body.id.trim() : "";
1534
+ if (!isCodexAccountPriorityKey(id)) {
1535
+ return jsonResponse({ error: "Invalid account id format" }, 400);
1536
+ }
1537
+
1538
+ let priority = DEFAULT_ACCOUNT_PRIORITY;
1539
+ if (body.priority !== null) {
1540
+ const parsed = parseAccountPriority(body.priority);
1541
+ if (parsed === null) {
1542
+ return jsonResponse({
1543
+ error: `priority must be null or an integer ${MIN_ACCOUNT_PRIORITY}-${MAX_ACCOUNT_PRIORITY}`,
1544
+ }, 400);
1545
+ }
1546
+ priority = parsed;
1547
+ }
1548
+
1549
+ const runtimeConfig = getRuntimeConfig(config);
1550
+ const exists = id === MAIN_CODEX_ACCOUNT_ID
1551
+ || (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id);
1552
+ if (!exists) return jsonResponse({ error: "Account not found" }, 404);
1553
+
1554
+ setCodexAccountPriority(runtimeConfig, id, priority);
1555
+ // Both a pin and an order are the operator saying which account to use, so the newer
1556
+ // statement wins. Without this a pin made before any order existed — an ordinary
1557
+ // account switch — would outrank the order forever: it blocks preemption and caps
1558
+ // every eligibility list at its own tier until that account drains or is paused.
1559
+ clearCodexAccountPin(runtimeConfig);
1560
+ saveRuntimeConfig(config, runtimeConfig);
1561
+ return jsonResponse({
1562
+ ok: true,
1563
+ id,
1564
+ priority,
1565
+ activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
1566
+ });
1567
+ }
1568
+
1021
1569
  if (url.pathname === "/api/codex-auth/accounts/pause-exhausted" && req.method === "PUT") {
1022
1570
  const runtimeConfig = getRuntimeConfig(config);
1023
- const result = await pauseExhaustedCodexAccounts(runtimeConfig);
1571
+ const result = await pauseExhaustedCodexAccounts(
1572
+ runtimeConfig,
1573
+ () => saveRuntimeConfig(config, runtimeConfig),
1574
+ );
1024
1575
  const { pausedAccountIds, checkedAccountCount, failedAccountCount } = result;
1025
1576
  if (checkedAccountCount === 0 && failedAccountCount > 0) {
1026
1577
  return jsonResponse({
@@ -1030,7 +1581,6 @@ export async function handleCodexAuthAPI(
1030
1581
  failedAccountCount,
1031
1582
  }, 502);
1032
1583
  }
1033
- if (pausedAccountIds.length > 0) saveRuntimeConfig(config, runtimeConfig);
1034
1584
  return jsonResponse({
1035
1585
  ok: true,
1036
1586
  pausedAccountIds,
@@ -1078,6 +1628,15 @@ export async function handleCodexAuthAPI(
1078
1628
  if (!exists) return jsonResponse({ error: "Account not found" }, 400);
1079
1629
  }
1080
1630
  runtimeConfig.activeCodexAccountId = body.accountId ?? undefined;
1631
+ // "Use this account now" outranks selection order until the account is spent:
1632
+ // persisted here rather than in resetCodexRoutingForManualSelection, which is
1633
+ // runtime state only. A null id clears the selection instead of making one, so it
1634
+ // must release the pin rather than record one: pinning the `targetAccountId`
1635
+ // fallback would leave a pin that no effective active account matches, which
1636
+ // `isEffectiveCodexAccountPinned` reports as unpinned while the tier filter still
1637
+ // honours it as a ceiling — invisibly capping the pool at the main account's tier.
1638
+ if (body.accountId == null) clearCodexAccountPin(runtimeConfig);
1639
+ else setCodexAccountPin(runtimeConfig, targetAccountId);
1081
1640
  resetCodexRoutingForManualSelection(targetAccountId);
1082
1641
  saveRuntimeConfig(config, runtimeConfig);
1083
1642
  return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true });
@@ -1087,6 +1646,13 @@ export async function handleCodexAuthAPI(
1087
1646
  const runtimeConfig = getRuntimeConfig(config);
1088
1647
  return jsonResponse({
1089
1648
  activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
1649
+ pinned: isEffectiveCodexAccountPinned(runtimeConfig),
1650
+ // Which account carries the pin, not just whether the active one does. Under
1651
+ // round-robin or fill-first the pin caps the tier ceiling at its own tier while the
1652
+ // strategy cursor moves freely inside that tier, so `pinned` alone goes false on a
1653
+ // sibling's turn even though the pin is still suppressing every higher tier. The id
1654
+ // lets a surface mark the account the operator actually chose.
1655
+ pinnedAccountId: pinnedCodexAccountId(runtimeConfig) ?? null,
1090
1656
  autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80,
1091
1657
  upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3,
1092
1658
  accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
@@ -1169,24 +1735,47 @@ export async function handleCodexAuthAPI(
1169
1735
  if (!accountId) return jsonResponse({ error: "accountId required" }, 400);
1170
1736
 
1171
1737
  try {
1172
- const auth = await resolveResetCreditAuth(getRuntimeConfig(config), accountId);
1173
- if (!auth.ok) return auth.response;
1174
-
1175
- const resp = await fetch(
1176
- "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits",
1177
- {
1178
- headers: {
1179
- Authorization: `Bearer ${auth.accessToken}`,
1180
- "ChatGPT-Account-Id": auth.chatgptAccountId,
1181
- },
1182
- signal: AbortSignal.timeout(8000),
1183
- },
1184
- );
1185
- if (!resp.ok) {
1186
- await resp.body?.cancel().catch(() => {});
1187
- return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
1188
- }
1189
- return jsonResponse(safeResetCreditsDto(await resp.json()));
1738
+ const result = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => {
1739
+ const linkedSignal = signalWithTimeout(8000, req.signal);
1740
+ let detachBodyAbort = () => {};
1741
+ try {
1742
+ let resp: Response;
1743
+ try {
1744
+ resp = await fetch(
1745
+ "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits",
1746
+ {
1747
+ headers: {
1748
+ Authorization: `Bearer ${auth.accessToken}`,
1749
+ "ChatGPT-Account-Id": auth.chatgptAccountId,
1750
+ },
1751
+ signal: linkedSignal.signal,
1752
+ },
1753
+ );
1754
+ } catch (error) {
1755
+ if (linkedSignal.signal.aborted) {
1756
+ return jsonResponse({ error: "Invalid upstream reset-credit response" }, 502);
1757
+ }
1758
+ throw error;
1759
+ }
1760
+ // Own the response body before the bounded reader attaches. If the client
1761
+ // disconnects in that narrow window, Bun otherwise tears down the native
1762
+ // body off the awaited path and can report an unhandled rejection.
1763
+ detachBodyAbort = cancelBodyOnAbort(resp.body, linkedSignal.signal);
1764
+ if (!resp.ok) {
1765
+ await resp.body?.cancel().catch(() => {});
1766
+ return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
1767
+ }
1768
+ const parsed = await readResetCreditJson(resp, linkedSignal.signal);
1769
+ if (!parsed.ok) {
1770
+ return jsonResponse({ error: "Invalid upstream reset-credit response" }, 502);
1771
+ }
1772
+ return jsonResponse(safeResetCreditsDto(parsed.value));
1773
+ } finally {
1774
+ detachBodyAbort();
1775
+ linkedSignal.cleanup();
1776
+ }
1777
+ });
1778
+ return result.ok ? result.value : result.response;
1190
1779
  } catch (e) {
1191
1780
  return jsonResponse({ error: e instanceof Error ? e.message : "Reset credit lookup failed" }, 500);
1192
1781
  }
@@ -1195,49 +1784,55 @@ export async function handleCodexAuthAPI(
1195
1784
  if (url.pathname === "/api/codex-auth/reset-credits/consume" && req.method === "POST") {
1196
1785
  const body = (await req.json().catch(() => ({}))) as { accountId?: string };
1197
1786
  if (!body.accountId) return jsonResponse({ error: "accountId required" }, 400);
1787
+ const accountId = body.accountId;
1198
1788
 
1199
1789
  try {
1200
- const auth = await resolveResetCreditAuth(getRuntimeConfig(config), body.accountId);
1201
- if (!auth.ok) return auth.response;
1202
-
1203
- const idempotencyKey = crypto.randomUUID();
1204
- const resp = await fetch(
1205
- "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume",
1206
- {
1207
- method: "POST",
1208
- headers: {
1209
- Authorization: `Bearer ${auth.accessToken}`,
1210
- "ChatGPT-Account-Id": auth.chatgptAccountId,
1211
- "Content-Type": "application/json",
1790
+ const operation = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => {
1791
+ const idempotencyKey = crypto.randomUUID();
1792
+ const resp = await fetch(
1793
+ "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume",
1794
+ {
1795
+ method: "POST",
1796
+ headers: {
1797
+ Authorization: `Bearer ${auth.accessToken}`,
1798
+ "ChatGPT-Account-Id": auth.chatgptAccountId,
1799
+ "Content-Type": "application/json",
1800
+ },
1801
+ body: JSON.stringify({ redeem_request_id: idempotencyKey }),
1802
+ signal: AbortSignal.timeout(10_000),
1212
1803
  },
1213
- body: JSON.stringify({ redeem_request_id: idempotencyKey }),
1214
- signal: AbortSignal.timeout(10_000),
1215
- },
1216
- );
1217
- if (!resp.ok) {
1218
- await resp.body?.cancel().catch(() => {});
1219
- return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
1220
- }
1221
- const result = safeResetCreditConsumeDto(await resp.json());
1222
- // After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage
1223
- // and return remaining only when that refresh freshly parsed available_count.
1224
- // Do not fall back to a preserved cached resetCredits (failed/omitted refresh).
1225
- if (result.code === "reset" || result.code === "already_redeemed") {
1226
- let freshResetCredits: number | undefined;
1227
- if (auth.isMain) {
1228
- ({ freshResetCredits } = await fetchMainAccountInfoAttempt(true, 1));
1229
- } else {
1230
- const account = configuredPoolAccount(getRuntimeConfig(config), body.accountId);
1231
- ({ freshResetCredits } = await fetchPoolAccountQuota(body.accountId, true, account?.plan));
1804
+ );
1805
+ if (!resp.ok) {
1806
+ await resp.body?.cancel().catch(() => {});
1807
+ return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
1232
1808
  }
1233
- return jsonResponse({
1234
- code: result.code,
1235
- ...(typeof freshResetCredits === "number" && Number.isFinite(freshResetCredits)
1236
- ? { remaining: freshResetCredits }
1237
- : {}),
1238
- });
1239
- }
1240
- return jsonResponse(result);
1809
+ const result = safeResetCreditConsumeDto(await resp.json());
1810
+ // After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage
1811
+ // and return remaining only when that refresh freshly parsed available_count.
1812
+ // Do not fall back to a preserved cached resetCredits (failed/omitted refresh).
1813
+ if (result.code === "reset" || result.code === "already_redeemed") {
1814
+ let freshResetCredits: number | undefined;
1815
+ if (auth.isMain) {
1816
+ ({ freshResetCredits } = await fetchMainAccountInfoAttempt(
1817
+ true,
1818
+ 1,
1819
+ auth.nativeMainLease,
1820
+ auth.nativeMainSharedClaimHeld === true,
1821
+ ));
1822
+ } else {
1823
+ const account = configuredPoolAccount(getRuntimeConfig(config), accountId);
1824
+ ({ freshResetCredits } = await fetchPoolAccountQuota(accountId, true, account?.plan));
1825
+ }
1826
+ return jsonResponse({
1827
+ code: result.code,
1828
+ ...(typeof freshResetCredits === "number" && Number.isFinite(freshResetCredits)
1829
+ ? { remaining: freshResetCredits }
1830
+ : {}),
1831
+ });
1832
+ }
1833
+ return jsonResponse(result);
1834
+ });
1835
+ return operation.ok ? operation.value : operation.response;
1241
1836
  } catch (e) {
1242
1837
  if (e instanceof PoolQuotaProbeBusyError) {
1243
1838
  const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503);
@@ -1249,7 +1844,7 @@ export async function handleCodexAuthAPI(
1249
1844
  }
1250
1845
 
1251
1846
  if (url.pathname === "/api/codex-auth/login" && req.method === "POST") {
1252
- const body = (await req.json().catch(() => ({}))) as { id?: string; reauth?: boolean };
1847
+ const body = (await req.json().catch(() => ({}))) as { id?: string; reauth?: boolean; openBrowser?: unknown };
1253
1848
  const requestedAccountId = body.id?.trim();
1254
1849
  const reauth = body.reauth === true;
1255
1850
  if (requestedAccountId && !isValidCodexAccountId(requestedAccountId)) {
@@ -1278,12 +1873,14 @@ export async function handleCodexAuthAPI(
1278
1873
  const loginOwner: CodexLoginStateRow = { status: "starting", startedAt: Date.now() };
1279
1874
  codexAuthLoginState.set(flowId, loginOwner);
1280
1875
  try {
1281
- const { startLoginFlow, getLoginStatus } = await import("../oauth");
1876
+ const { startLoginFlow, getLoginStatus, publicOAuthAuthenticationErrorMessage } = await import("../oauth");
1282
1877
  const result = await startLoginFlow("chatgpt", { forceLogin: true });
1283
1878
 
1284
1879
  // Open the browser server-side (same pattern as /api/oauth/login in management-api.ts).
1285
1880
  // The GUI's window.open is popup-blocked because it runs after an await, not a direct click.
1286
- if (result.url) {
1881
+ // Both login routes share one resolver so this surface cannot drift from the other.
1882
+ const { shouldOpenBrowserForLogin } = await import("../oauth/open-browser-choice");
1883
+ if (result.url && shouldOpenBrowserForLogin(body.openBrowser, runtimeConfig)) {
1287
1884
  const { openUrl } = await import("../lib/open-url");
1288
1885
  openUrl(result.url);
1289
1886
  }
@@ -1391,6 +1988,8 @@ export async function handleCodexAuthAPI(
1391
1988
  const latestConfig = getRuntimeConfig(config);
1392
1989
  const accounts = latestConfig.codexAccounts ?? [];
1393
1990
  const existingIdx = accounts.findIndex(account => account.id === accountId);
1991
+ let pickerVisibilityChanged = false;
1992
+ let newAccountPersistence: PersistNewCodexAccountOutcome | null = null;
1394
1993
  const commitConflict = codexAccountPersistenceConflict(
1395
1994
  latestConfig,
1396
1995
  accountId,
@@ -1406,22 +2005,21 @@ export async function handleCodexAuthAPI(
1406
2005
  break;
1407
2006
  }
1408
2007
 
1409
- saveCodexAccountCredential(accountId, {
2008
+ const credential: CodexAccountCredentials = {
1410
2009
  accessToken: cred.access,
1411
2010
  refreshToken: cred.refresh,
1412
2011
  expiresAt: cred.expires,
1413
2012
  chatgptAccountId: oauthAccountId,
1414
- });
1415
- // A successful reauthentication replaces the credential generation. Do not let a
1416
- // failed optional WHAM probe make the replacement inherit quota from the old record.
1417
- if (reauth) clearAccountQuota(accountId);
1418
- markCodexAccountValidated(accountId, warmup.validatedAt);
1419
- clearAccountNeedsReauth(accountId);
1420
- if (quota) {
1421
- setAccountQuotaFromParsed(accountId, quota);
1422
- }
2013
+ };
1423
2014
 
1424
2015
  if (existingIdx >= 0) {
2016
+ saveCodexAccountCredential(accountId, credential);
2017
+ // A successful reauthentication replaces the credential generation. Do not let a
2018
+ // failed optional WHAM probe make the replacement inherit quota from the old record.
2019
+ if (reauth) clearAccountQuota(accountId);
2020
+ markCodexAccountValidated(accountId, warmup.validatedAt);
2021
+ clearAccountNeedsReauth(accountId);
2022
+ if (quota) setAccountQuotaFromParsed(accountId, quota);
1425
2023
  // Keep the pool id stable; refresh display metadata after a successful login/reauth.
1426
2024
  accounts[existingIdx] = withCodexAccountLogLabel({
1427
2025
  ...accounts[existingIdx],
@@ -1432,18 +2030,60 @@ export async function handleCodexAuthAPI(
1432
2030
  latestConfig.codexAccounts = accounts;
1433
2031
  saveRuntimeConfig(config, latestConfig);
1434
2032
  } else {
1435
- accounts.push(withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts));
1436
- latestConfig.codexAccounts = accounts;
1437
- saveRuntimeConfig(config, latestConfig);
2033
+ const addedAccount = withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts);
2034
+ newAccountPersistence = persistNewCodexAccount(
2035
+ config,
2036
+ latestConfig,
2037
+ addedAccount,
2038
+ {
2039
+ credential,
2040
+ validatedAt: warmup.validatedAt,
2041
+ },
2042
+ );
2043
+ pickerVisibilityChanged = newAccountPersistence.pickerVisibilityChanged;
1438
2044
  }
1439
2045
  reconcileLiveStateStores();
1440
- setCodexLoginState(flowId, { status: "done", accountId, email, doneAt: Date.now() });
1441
- completed = true;
2046
+ if (newAccountPersistence?.status === "publication-failed") {
2047
+ markAccountNeedsReauth(accountId);
2048
+ }
2049
+ // A new quota row is generation-gated by live account ownership. Reconcile the
2050
+ // durable config owner first so a partial prior sweep cannot reject this write.
2051
+ if (newAccountPersistence?.status === "committed" && quota) {
2052
+ setAccountQuotaFromParsed(accountId, quota);
2053
+ }
2054
+ const { catalogRefreshPending } = await convergeAccountNamespaceCatalog(
2055
+ latestConfig,
2056
+ pickerVisibilityChanged,
2057
+ convergeCodexCatalog,
2058
+ );
2059
+ if (newAccountPersistence?.status === "publication-failed") {
2060
+ setCodexLoginState(flowId, {
2061
+ status: "error",
2062
+ ...codexCredentialPersistenceFailure(accountId, catalogRefreshPending),
2063
+ doneAt: Date.now(),
2064
+ });
2065
+ completed = true;
2066
+ } else {
2067
+ setCodexLoginState(flowId, {
2068
+ status: "done",
2069
+ accountId,
2070
+ email,
2071
+ ...(catalogRefreshPending ? { catalogRefreshPending: true } : {}),
2072
+ doneAt: Date.now(),
2073
+ });
2074
+ completed = true;
2075
+ }
1442
2076
  }
1443
2077
  break;
1444
2078
  }
1445
2079
  if (st.done && st.error) {
1446
- setCodexLoginState(flowId, { status: "error", error: st.error, doneAt: Date.now() });
2080
+ setCodexLoginState(flowId, {
2081
+ status: "error",
2082
+ // startLoginFlow projects background failures before storing login status, so
2083
+ // fixed actionable OAuth messages retain their type-derived remediation here.
2084
+ error: st.error,
2085
+ doneAt: Date.now(),
2086
+ });
1447
2087
  completed = true;
1448
2088
  break;
1449
2089
  }
@@ -1461,7 +2101,7 @@ export async function handleCodexAuthAPI(
1461
2101
  ? "Configuration is busy; retry login shortly."
1462
2102
  : error instanceof CodexCredentialRefreshBusyError || error instanceof CodexCredentialRefreshStaleError
1463
2103
  ? "Credential refresh is busy; retry login shortly."
1464
- : error instanceof Error ? error.message : String(error);
2104
+ : publicOAuthAuthenticationErrorMessage(error);
1465
2105
  setCodexLoginState(flowId, {
1466
2106
  status: "error",
1467
2107
  error: message,
@@ -1478,7 +2118,7 @@ export async function handleCodexAuthAPI(
1478
2118
  } catch (e) {
1479
2119
  if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId);
1480
2120
  const msg = e instanceof Error ? e.message : String(e);
1481
- if (msg.includes("already in progress")) {
2121
+ if (msg === "A login for chatgpt is already in progress") {
1482
2122
  return jsonResponse({ error: msg, status: "pending" }, 409);
1483
2123
  }
1484
2124
  if (e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) {
@@ -1486,7 +2126,8 @@ export async function handleCodexAuthAPI(
1486
2126
  response.headers.set("Retry-After", "1");
1487
2127
  return response;
1488
2128
  }
1489
- return jsonResponse({ error: msg }, 500);
2129
+ const { publicOAuthAuthenticationErrorMessage } = await import("../oauth");
2130
+ return jsonResponse({ error: publicOAuthAuthenticationErrorMessage(e) }, 500);
1490
2131
  }
1491
2132
  }
1492
2133
 
@@ -1524,7 +2165,13 @@ export async function handleCodexAuthAPI(
1524
2165
  const reauthStatus = url.searchParams.get("reauth") === "1";
1525
2166
  if (flowId) {
1526
2167
  const st = codexAuthLoginState.get(flowId);
1527
- if (!st && accountId && !reauthStatus && getCodexAccountCredential(accountId)) {
2168
+ if (
2169
+ !st
2170
+ && accountId
2171
+ && !reauthStatus
2172
+ && !isAccountNeedsReauth(accountId)
2173
+ && getCodexAccountCredential(accountId)
2174
+ ) {
1528
2175
  return jsonResponse({ status: "done", accountId });
1529
2176
  }
1530
2177
  return jsonResponse(st ? { ...st, email: st.email ?? undefined } : { status: "expired" });