@bitkyc08/opencodex 2.76.0 → 2.78.0-preview.20261005

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 (353) hide show
  1. package/bin/ocx.mjs +11 -10
  2. package/gui/dist/assets/App-CppMz-6w.js +54 -0
  3. package/gui/dist/assets/App-lCIslZ1L.css +1 -0
  4. package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-CJWQqtVv.js} +1 -1
  5. package/gui/dist/assets/index-DQt9srNi.js +94 -0
  6. package/gui/dist/assets/index-DmQB8D_R.css +1 -0
  7. package/gui/dist/assets/quota-summary-BMsyoZH2.js +1 -0
  8. package/gui/dist/index.html +4 -3
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/beta-allowlist.ts +19 -2
  11. package/src/adapters/anthropic/client-identity.ts +13 -4
  12. package/src/adapters/anthropic/native-client-preamble.ts +31 -0
  13. package/src/adapters/anthropic/passthrough.ts +103 -25
  14. package/src/adapters/antigravity-refusal.ts +28 -0
  15. package/src/adapters/cursor/protobuf-request.ts +6 -0
  16. package/src/adapters/cursor/request-builder.ts +3 -0
  17. package/src/adapters/cursor/structured-output.ts +13 -0
  18. package/src/adapters/cursor/types.ts +2 -0
  19. package/src/adapters/devin/cloud-direct/chat.ts +7 -2
  20. package/src/adapters/devin/trajectory.ts +49 -0
  21. package/src/adapters/devin.ts +13 -0
  22. package/src/adapters/ollama-native.ts +62 -19
  23. package/src/adapters/openai-responses/passthrough.ts +133 -103
  24. package/src/adapters/openai-responses/request-strips.ts +10 -4
  25. package/src/adapters/zed.ts +26 -4
  26. package/src/bridge/response-json.ts +14 -14
  27. package/src/bridge/sse.ts +15 -5
  28. package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
  29. package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
  30. package/src/claude/agents-inject.ts +4 -43
  31. package/src/claude/gateway-cache.ts +16 -13
  32. package/src/claude/inbound-model-options.ts +9 -2
  33. package/src/claude/intercept/cli-catalog.ts +27 -16
  34. package/src/claude/intercept/client-hello.ts +103 -0
  35. package/src/claude/intercept/model-bindings.ts +3 -2
  36. package/src/claude/intercept/picker-bootstrap.ts +37 -16
  37. package/src/claude/intercept/picker-budget.ts +41 -0
  38. package/src/claude/intercept/picker-listener.ts +279 -66
  39. package/src/claude/outbound.ts +2 -0
  40. package/src/claude/subagent-force-status.ts +53 -0
  41. package/src/claude/subagent-model.ts +93 -0
  42. package/src/cli/access-audio-input.ts +84 -0
  43. package/src/cli/access-audio-live.ts +99 -0
  44. package/src/cli/access-audio.ts +123 -0
  45. package/src/cli/access-data-client.ts +127 -0
  46. package/src/cli/access-data-plane.ts +104 -0
  47. package/src/cli/access-data-response.ts +76 -0
  48. package/src/cli/access.ts +65 -2
  49. package/src/cli/account-api.ts +33 -6
  50. package/src/cli/account-auth.ts +111 -42
  51. package/src/cli/account-key-quota.ts +118 -0
  52. package/src/cli/account-main.ts +11 -4
  53. package/src/cli/account-next-actions.ts +63 -0
  54. package/src/cli/account-policy-dto.ts +68 -0
  55. package/src/cli/account-policy.ts +167 -0
  56. package/src/cli/account-target.ts +8 -0
  57. package/src/cli/account.ts +37 -9
  58. package/src/cli/agent-runtime-settings.ts +130 -0
  59. package/src/cli/agent-settings.ts +101 -0
  60. package/src/cli/agent.ts +29 -2
  61. package/src/cli/alias.ts +7 -2
  62. package/src/cli/aside-profiles.ts +2 -2
  63. package/src/cli/capabilities-access-remote.ts +147 -0
  64. package/src/cli/capabilities-accounts.ts +195 -0
  65. package/src/cli/capabilities-agents-routing.ts +414 -0
  66. package/src/cli/capabilities-base.ts +783 -0
  67. package/src/cli/capabilities-command.ts +1 -0
  68. package/src/cli/capabilities-integrations.ts +264 -0
  69. package/src/cli/capabilities-lab.ts +214 -0
  70. package/src/cli/capabilities-observe-system.ts +662 -0
  71. package/src/cli/capabilities-provider-models.ts +302 -0
  72. package/src/cli/capabilities.ts +30 -1190
  73. package/src/cli/capability-types.ts +54 -0
  74. package/src/cli/catalog-command-result.ts +57 -0
  75. package/src/cli/chatgpt-command.ts +35 -9
  76. package/src/cli/claude-agent-startup-sync.ts +17 -0
  77. package/src/cli/claude-desktop-profile.ts +100 -0
  78. package/src/cli/claude-desktop.ts +39 -6
  79. package/src/cli/claude.ts +22 -6
  80. package/src/cli/codex-shim-autorestore.ts +2 -1
  81. package/src/cli/codex-shim-readiness.ts +109 -0
  82. package/src/cli/combo-input.ts +88 -0
  83. package/src/cli/combo-result.ts +52 -0
  84. package/src/cli/combo-stats.ts +123 -0
  85. package/src/cli/combo.ts +37 -14
  86. package/src/cli/companion-timeline.ts +142 -0
  87. package/src/cli/companion-usage.ts +146 -0
  88. package/src/cli/companion.ts +12 -3
  89. package/src/cli/config-command.ts +24 -8
  90. package/src/cli/connect.ts +19 -0
  91. package/src/cli/dispatch.ts +55 -82
  92. package/src/cli/doctor.ts +22 -4
  93. package/src/cli/export-command.ts +20 -9
  94. package/src/cli/gui.ts +8 -3
  95. package/src/cli/help-catalog.ts +101 -0
  96. package/src/cli/help-models-context.ts +17 -0
  97. package/src/cli/help-navigation.ts +50 -0
  98. package/src/cli/help-recovery.ts +66 -0
  99. package/src/cli/help.ts +69 -11
  100. package/src/cli/index.ts +38 -27
  101. package/src/cli/injection-follow.ts +35 -0
  102. package/src/cli/inspect.ts +5 -0
  103. package/src/cli/integration-aside-sync.ts +69 -0
  104. package/src/cli/integration-cursor.ts +106 -0
  105. package/src/cli/integration-input.ts +60 -0
  106. package/src/cli/integration-journal.ts +49 -0
  107. package/src/cli/integration-plan-dto.ts +156 -0
  108. package/src/cli/integration-preview.ts +215 -0
  109. package/src/cli/integrations.ts +24 -18
  110. package/src/cli/json-input.ts +134 -0
  111. package/src/cli/link.ts +32 -4
  112. package/src/cli/local-sync-result.ts +41 -0
  113. package/src/cli/log-follow.ts +84 -0
  114. package/src/cli/log-view-filter.ts +146 -0
  115. package/src/cli/logout-command.ts +44 -0
  116. package/src/cli/model-picker-ordering.ts +137 -0
  117. package/src/cli/models-custom-input.ts +107 -0
  118. package/src/cli/models-custom-runtime.ts +112 -0
  119. package/src/cli/models-order.ts +194 -0
  120. package/src/cli/models-runtime-subcommands.ts +2 -0
  121. package/src/cli/models-runtime.ts +9 -1
  122. package/src/cli/models.ts +66 -81
  123. package/src/cli/observe-stream.ts +142 -0
  124. package/src/cli/observe.ts +75 -34
  125. package/src/cli/opencode.ts +54 -10
  126. package/src/cli/provider-batch.ts +77 -0
  127. package/src/cli/provider-lifecycle-runtime.ts +170 -0
  128. package/src/cli/provider-result.ts +124 -0
  129. package/src/cli/provider-runtime.ts +15 -12
  130. package/src/cli/provider-settings.ts +143 -0
  131. package/src/cli/provider.ts +116 -64
  132. package/src/cli/registry.ts +90 -24
  133. package/src/cli/remote-workspace-hub.ts +83 -0
  134. package/src/cli/remote-workspace.ts +6 -1
  135. package/src/cli/restart-failure.ts +27 -0
  136. package/src/cli/root.ts +42 -9
  137. package/src/cli/route-policy-write.ts +137 -0
  138. package/src/cli/route-policy.ts +8 -0
  139. package/src/cli/runtime-api.ts +57 -80
  140. package/src/cli/secret-args.ts +149 -0
  141. package/src/cli/settings-result.ts +30 -0
  142. package/src/cli/storage.ts +77 -7
  143. package/src/cli/system-command.ts +60 -2
  144. package/src/cli/system-restart-client.ts +20 -3
  145. package/src/cli/system-settings-parity.ts +83 -0
  146. package/src/cli/tray-proxy.ts +3 -2
  147. package/src/cli/update-restart-candidate.ts +21 -0
  148. package/src/cli/update-restart-child.ts +81 -0
  149. package/src/cli/update-restart-home.ts +38 -0
  150. package/src/cli/update-restart-transport.ts +193 -0
  151. package/src/cli/update-restart.ts +260 -0
  152. package/src/cli/usage-model-search.ts +58 -0
  153. package/src/cli/usage-report.ts +13 -5
  154. package/src/cli/v2-input.ts +66 -0
  155. package/src/cli/v2-local-output.ts +37 -0
  156. package/src/cli/v2-runtime.ts +90 -0
  157. package/src/cli/v2.ts +102 -144
  158. package/src/clients/config-export/contracts.ts +100 -0
  159. package/src/clients/config-export/droid.ts +54 -2
  160. package/src/clients/config-export/dsh.ts +21 -0
  161. package/src/clients/config-export/kilo.ts +12 -2
  162. package/src/clients/config-export/model-metadata.ts +10 -4
  163. package/src/clients/config-export/reasoning-metadata.ts +47 -0
  164. package/src/clients/config-export.ts +53 -67
  165. package/src/clients/omo-role-models.ts +17 -3
  166. package/src/codex/account-credit-use.ts +4 -2
  167. package/src/codex/account-runtime-state.ts +21 -0
  168. package/src/codex/account-store.ts +11 -47
  169. package/src/codex/account-usability.ts +11 -8
  170. package/src/codex/auth-api/account-list.ts +73 -63
  171. package/src/codex/auth-api/main-account-probe.ts +25 -12
  172. package/src/codex/auth-api/pool-mode-gate.ts +6 -9
  173. package/src/codex/auth-api/pool-quota-probe.ts +2 -4
  174. package/src/codex/auth-api/reset-credit-service.ts +3 -2
  175. package/src/codex/auth-context.ts +30 -11
  176. package/src/codex/catalog/aggregation.ts +3 -0
  177. package/src/codex/catalog/build-entries.ts +1 -1
  178. package/src/codex/catalog/bundled.ts +9 -2
  179. package/src/codex/catalog/heal-observation.ts +66 -0
  180. package/src/codex/catalog/model-hints.ts +2 -1
  181. package/src/codex/catalog/model-visibility.ts +6 -3
  182. package/src/codex/catalog/parsing.ts +2 -0
  183. package/src/codex/catalog/provider-models.ts +13 -4
  184. package/src/codex/catalog/publication-observer.ts +19 -0
  185. package/src/codex/catalog/remote.ts +13 -15
  186. package/src/codex/catalog/restore.ts +19 -6
  187. package/src/codex/catalog/retained-sync.ts +60 -11
  188. package/src/codex/catalog/routed-removal.ts +116 -0
  189. package/src/codex/catalog/write-audit-contract.ts +21 -0
  190. package/src/codex/catalog/write-audit.ts +171 -0
  191. package/src/codex/catalog-self-heal.ts +284 -0
  192. package/src/codex/catalog-write-serialization.ts +71 -7
  193. package/src/codex/chatgpt-refresh-failure.ts +56 -0
  194. package/src/codex/codex-home-owner.ts +124 -0
  195. package/src/codex/convergence.ts +57 -16
  196. package/src/codex/features.ts +8 -1
  197. package/src/codex/inject/restore.ts +46 -4
  198. package/src/codex/inject.ts +11 -1
  199. package/src/codex/internal/catalog-writer.ts +120 -13
  200. package/src/codex/journal.ts +49 -13
  201. package/src/codex/main-account.ts +144 -11
  202. package/src/codex/management-convergence.ts +3 -0
  203. package/src/codex/quota-refresh-outcome.ts +17 -2
  204. package/src/codex/quota-types.ts +36 -1
  205. package/src/codex/quota.ts +67 -16
  206. package/src/codex/refresh.ts +20 -9
  207. package/src/codex/routing/cooldown-math.ts +10 -4
  208. package/src/codex/routing/selection.ts +11 -4
  209. package/src/codex/routing.ts +9 -9
  210. package/src/codex/runtime.ts +7 -1
  211. package/src/codex/shim-diagnostics.ts +80 -0
  212. package/src/codex/shim-fingerprint.ts +7 -2
  213. package/src/codex/shim-migration.ts +153 -0
  214. package/src/codex/shim-overlay.ts +509 -0
  215. package/src/codex/shim-path-resolution.ts +145 -0
  216. package/src/codex/shim-probe.ts +4 -3
  217. package/src/codex/shim-state-file.ts +93 -7
  218. package/src/codex/shim.ts +54 -107
  219. package/src/codex/subagent-model-fallback.ts +2 -1
  220. package/src/codex/sync.ts +24 -4
  221. package/src/combos/failover.ts +101 -1
  222. package/src/combos/index.ts +1 -0
  223. package/src/combos/jev.ts +13 -3
  224. package/src/combos/resolve.ts +12 -0
  225. package/src/config/atomic-write.ts +13 -8
  226. package/src/config/diagnostics.ts +23 -1
  227. package/src/config/load-degrade.ts +7 -0
  228. package/src/config/provider-validation.ts +3 -0
  229. package/src/config/schema/chatgpt-desktop.ts +22 -0
  230. package/src/config/schema/compaction-triggers.ts +8 -0
  231. package/src/config/schema/config-schema.ts +43 -14
  232. package/src/config/schema/leaf-validators.ts +8 -3
  233. package/src/config/subagent-models.ts +12 -0
  234. package/src/generated/compatibility-version.json +645 -237
  235. package/src/grok/session-identity.ts +33 -0
  236. package/src/integrations/catalog-refresh.ts +1 -1
  237. package/src/integrations/merge.ts +13 -5
  238. package/src/integrations/mutation-plan.ts +50 -5
  239. package/src/integrations/omp-yaml-source.ts +181 -1
  240. package/src/integrations/ownership-policy.ts +66 -0
  241. package/src/integrations/registry.ts +50 -1
  242. package/src/integrations/state.ts +106 -19
  243. package/src/integrations/target.ts +25 -2
  244. package/src/integrations/writer.ts +107 -35
  245. package/src/lib/gui-pair-capability.ts +17 -0
  246. package/src/lib/package-tree-integrity.ts +42 -1
  247. package/src/lib/provider-client-headers.ts +62 -0
  248. package/src/lib/proxy-env.ts +3 -0
  249. package/src/lib/test-home-guard.ts +2 -1
  250. package/src/oauth/anthropic-account-refusal.ts +19 -8
  251. package/src/oauth/anthropic-continuity.ts +65 -0
  252. package/src/oauth/anthropic-identity.ts +75 -0
  253. package/src/oauth/anthropic-routing.ts +20 -15
  254. package/src/oauth/anthropic.ts +34 -3
  255. package/src/oauth/generic-account-failover.ts +21 -0
  256. package/src/oauth/health.ts +32 -5
  257. package/src/oauth/index.ts +42 -35
  258. package/src/oauth/local-token-detect.ts +7 -0
  259. package/src/oauth/pool-settings-capability.ts +9 -2
  260. package/src/oauth/refresh-policy.ts +2 -0
  261. package/src/oauth/store.ts +55 -6
  262. package/src/oauth/types.ts +9 -0
  263. package/src/protocols/encoders/messages.ts +4 -3
  264. package/src/protocols/plan-snapshot.ts +1 -1
  265. package/src/protocols/settings.ts +28 -4
  266. package/src/providers/antigravity-effort-families.ts +75 -0
  267. package/src/providers/antigravity-models.ts +54 -16
  268. package/src/providers/antigravity-wire-snapshot.ts +66 -0
  269. package/src/providers/derive.ts +2 -0
  270. package/src/providers/model-rename-fields.ts +3 -0
  271. package/src/providers/new-model-policy.ts +48 -10
  272. package/src/providers/registry/entries-extended.ts +3 -0
  273. package/src/providers/registry/model-ids.ts +2 -0
  274. package/src/providers/registry/model-seeds.ts +5 -0
  275. package/src/providers/registry/types.ts +4 -0
  276. package/src/responses/compaction.ts +19 -0
  277. package/src/router.ts +6 -0
  278. package/src/server/auth-cors.ts +9 -0
  279. package/src/server/caller-session-identity.ts +32 -0
  280. package/src/server/chat-completions.ts +10 -0
  281. package/src/server/claude-messages.ts +29 -15
  282. package/src/server/droid-reasoning-default.ts +50 -0
  283. package/src/server/github-copilot-responses-repair.ts +3 -6
  284. package/src/server/grok-responses-control-frame.ts +2 -2
  285. package/src/server/grok-responses-snapshot-repair.ts +4 -5
  286. package/src/server/gui-session.ts +33 -11
  287. package/src/server/index/claude-intercept-lifecycle.ts +4 -0
  288. package/src/server/index/serve-options.ts +13 -7
  289. package/src/server/index.ts +1 -1
  290. package/src/server/management/agent-settings-routes.ts +12 -134
  291. package/src/server/management/config-routes.ts +1 -1
  292. package/src/server/management/context.ts +8 -1
  293. package/src/server/management/cursor-integration-routes.ts +3 -3
  294. package/src/server/management/integration-routes.ts +69 -11
  295. package/src/server/management/link-routes.ts +20 -13
  296. package/src/server/management/model-routes.ts +3 -3
  297. package/src/server/management/model-row-export-metadata.ts +150 -0
  298. package/src/server/management/model-rows.ts +148 -9
  299. package/src/server/management/native-integration-routes.ts +2 -2
  300. package/src/server/management/oauth-account-routes.ts +96 -30
  301. package/src/server/management/route-registry.ts +29 -27
  302. package/src/server/management/shared.ts +19 -5
  303. package/src/server/management/subagent-model-routes.ts +214 -0
  304. package/src/server/messages-native-eligibility.ts +3 -6
  305. package/src/server/messages-native-oauth.ts +82 -35
  306. package/src/server/messages-native.ts +78 -16
  307. package/src/server/relay.ts +14 -44
  308. package/src/server/request-log-generation-window.ts +38 -0
  309. package/src/server/request-log.ts +13 -0
  310. package/src/server/responses/adapter-dispatch.ts +152 -28
  311. package/src/server/responses/codex-auth-error.ts +10 -2
  312. package/src/server/responses/combo-stream-preflight.ts +29 -3
  313. package/src/server/responses/compaction-routing.ts +10 -1
  314. package/src/server/responses/core-auth.ts +67 -30
  315. package/src/server/responses/core-codex-account.ts +6 -3
  316. package/src/server/responses/core-combo-failure.ts +5 -1
  317. package/src/server/responses/core-combo-native.ts +7 -1
  318. package/src/server/responses/core-combo.ts +64 -7
  319. package/src/server/responses/core-normalize.ts +7 -0
  320. package/src/server/responses/core-options.ts +5 -0
  321. package/src/server/responses/fetch-helpers.ts +9 -0
  322. package/src/server/responses/passthrough-delivery.ts +1 -1
  323. package/src/server/responses/policy-fallback.ts +1 -1
  324. package/src/server/responses/request-prepare.ts +5 -0
  325. package/src/server/responses/request-spend.ts +30 -30
  326. package/src/server/responses-custom-tool-repair.ts +2 -2
  327. package/src/server/responses-hosted-image-display.ts +70 -9
  328. package/src/server/responses-terminal-repair.ts +15 -2
  329. package/src/server/sse-frame-buffer.ts +38 -36
  330. package/src/server/sse-payload-rewrite.ts +78 -49
  331. package/src/server/ws-bridge.ts +1 -11
  332. package/src/service/cli.ts +6 -1
  333. package/src/service/ownership-mutation-lease.d.mts +26 -0
  334. package/src/service/ownership-mutation-lease.mjs +76 -1
  335. package/src/service/windows-taskxml.ts +1 -2
  336. package/src/service-manager-probe.ts +36 -2
  337. package/src/types/config.ts +6 -0
  338. package/src/types/provider.ts +28 -10
  339. package/src/update/index.ts +2 -1
  340. package/src/update/npm-invocation.mjs +35 -3
  341. package/src/update/proxy-liveness-probe.mjs +38 -8
  342. package/src/update/update-failure-guidance.d.mts +6 -0
  343. package/src/update/update-failure-guidance.mjs +30 -5
  344. package/src/usage/expected-prices.ts +9 -0
  345. package/src/usage/log.ts +7 -0
  346. package/src/usage/summary.ts +82 -1
  347. package/src/web-search/loop.ts +11 -8
  348. package/src/web-search/run-turn-loop.ts +10 -7
  349. package/gui/dist/assets/App-CddorHdA.js +0 -51
  350. package/gui/dist/assets/App-DMudtISW.css +0 -1
  351. package/gui/dist/assets/index-Btze1I3T.css +0 -1
  352. package/gui/dist/assets/index-CvnY7E6i.js +0 -86
  353. package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
@@ -4,6 +4,7 @@ import { isThirtyDayOnlyCodexPlan } from "./plan";
4
4
  import {
5
5
  CODEX_EXHAUSTED_USAGE_PERCENT,
6
6
  isTerminalShortWindow,
7
+ hasSpendableCodexCredits,
7
8
  resetAtToMs,
8
9
  type StoredAccountQuota,
9
10
  } from "./quota-types";
@@ -14,7 +15,7 @@ import {
14
15
  * Upstream does not refuse an account that holds ChatGPT credits at 100%: it serves the request
15
16
  * and draws the balance. Selection only takes an account off on quota after a refusal, so an
16
17
  * account with credits was never moved off. Spending is opt-in: only the ids in
17
- * `creditCodexAccountIds` may keep serving from credits, and every other account leaves rotation
18
+ * `creditCodexAccountIds` may keep serving from fresh spendable credits, and every other account leaves rotation
18
19
  * at 100% and comes back after its reset. Pool accounts and the `__main__` login both carry the
19
20
  * switch; the main login is also checked where the main-account hard lock is.
20
21
  */
@@ -95,5 +96,6 @@ export function isCodexAccountHeldForCredits(
95
96
  plan: unknown,
96
97
  now: number,
97
98
  ): boolean {
98
- return !codexAccountUsesCreditsAfterLimit(config, accountId) && isCodexUsageLimitReached(quota, plan, now);
99
+ return !(codexAccountUsesCreditsAfterLimit(config, accountId) && hasSpendableCodexCredits(quota, now))
100
+ && isCodexUsageLimitReached(quota, plan, now);
99
101
  }
@@ -18,6 +18,27 @@ const reauthAccounts = new Map<string, number | undefined>();
18
18
  let lastReconciledGeneration = 0;
19
19
  let liveAccountIds = new Set<string>();
20
20
 
21
+ // Refusal evidence is independent of the ordinary quarantine flag. Profile switches make a key
22
+ // inapplicable, not forgotten; the oldest of at most 64 records is evicted to bound process state.
23
+ const refusedMainRefreshGrants = new Set<string>();
24
+ const MAX_REFUSED_MAIN_REFRESH_GRANTS = 64;
25
+
26
+ export function markMainRefreshGrantRejected(key: string): void {
27
+ refusedMainRefreshGrants.add(key);
28
+ if (refusedMainRefreshGrants.size > MAX_REFUSED_MAIN_REFRESH_GRANTS) {
29
+ refusedMainRefreshGrants.delete(refusedMainRefreshGrants.values().next().value!);
30
+ }
31
+ }
32
+
33
+ export function isMainRefreshGrantRejected(key: string): boolean {
34
+ return refusedMainRefreshGrants.has(key);
35
+ }
36
+
37
+ export function clearMainRefreshGrantRejection(key: string): void {
38
+ refusedMainRefreshGrants.delete(key);
39
+ }
40
+
41
+
21
42
  export function markAccountNeedsReauth(
22
43
  id: string,
23
44
  writerGeneration = captureConfigGeneration(),
@@ -1,3 +1,6 @@
1
+ import { classifyChatgptRefreshFailure, noteChatgptRefreshFailure, type ChatgptRefreshDiagnosticCode } from "./chatgpt-refresh-failure";
2
+ import { readBoundedResponseBody } from "../lib/bounded-body";
3
+ export { classifyChatgptRefreshFailure } from "./chatgpt-refresh-failure";
1
4
  import { readOrcaAuthSource } from "./orca-auth-source";
2
5
  import { createHash } from "node:crypto";
3
6
  import { closeSync, existsSync, fstatSync, readFileSync, mkdirSync, openSync, statSync, unlinkSync, writeFileSync } from "node:fs";
@@ -526,7 +529,8 @@ const CHATGPT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
526
529
 
527
530
  export class TokenRefreshError extends Error {
528
531
  reason: "expired" | "revoked" | "unknown";
529
- constructor(reason: "expired" | "revoked" | "unknown", message: string) {
532
+ constructor(reason: "expired" | "revoked" | "unknown", message: string,
533
+ readonly status?: number, readonly code?: ChatgptRefreshDiagnosticCode) {
530
534
  super(message);
531
535
  this.name = "TokenRefreshError";
532
536
  this.reason = reason;
@@ -1322,52 +1326,12 @@ async function resolveCodexToken(
1322
1326
  signal,
1323
1327
  });
1324
1328
  if (!res.ok) {
1325
- const errText = await res.text().catch(() => "");
1326
- let errDesc: string;
1327
- let errCodeExact: string | undefined;
1328
- try {
1329
- const parsed = JSON.parse(errText) as {
1330
- error?: string | { code?: string; message?: string };
1331
- error_description?: string;
1332
- };
1333
- if (typeof parsed.error === "string") {
1334
- errCodeExact = parsed.error.trim();
1335
- errDesc = [parsed.error, parsed.error_description].filter(Boolean).join(": ");
1336
- } else if (parsed.error && typeof parsed.error === "object") {
1337
- errCodeExact = typeof parsed.error.code === "string" ? parsed.error.code.trim() : undefined;
1338
- errDesc = [parsed.error.code, parsed.error.message, parsed.error_description].filter(Boolean).join(": ");
1339
- } else {
1340
- errDesc = parsed.error_description || `HTTP ${res.status}`;
1341
- }
1342
- if (!errDesc) errDesc = `HTTP ${res.status}`;
1343
- } catch { errDesc = `HTTP ${res.status}`; }
1344
- // `invalid_grant` is the standard OAuth code for a refresh token that is no longer
1345
- // usable, and upstream sends it bare with no description. Without it here the dead
1346
- // grant is classified "unknown", which callers treat as transient — so the account
1347
- // is never retired and every request repeats the same doomed refresh (#2887).
1348
- //
1349
- // Matched on the exact `error` CODE, not anywhere in the combined text: a transient
1350
- // `server_error` whose description happens to mention invalid_grant would otherwise
1351
- // retire a healthy account, which is the failure this whole change exists to remove.
1352
- //
1353
- // That rule binds the DESCRIPTION words too. "invalidated", "revoked" and "expired" read
1354
- // as terminal prose, but upstream puts arbitrary text there: a `server_error` whose
1355
- // description says "token was revoked" or "session expired" is still a 5xx blip, and
1356
- // retiring the account on it is exactly the false quarantine #2887 exists to prevent.
1357
- // So a body that carries a structured code is classified by that code ALONE. The
1358
- // substring fallback survives only where there is no structured code to read at all --
1359
- // a description-only body, or one this parser could not decode -- because there the
1360
- // prose is the only signal upstream gave us.
1361
- const structuredCode = errCodeExact ? errCodeExact : undefined;
1362
- const proseIsOnlySignal = structuredCode === undefined;
1363
- const reason = structuredCode === "invalid_grant"
1364
- || structuredCode === "refresh_token_invalidated"
1365
- || (proseIsOnlySignal
1366
- && (errDesc.includes("invalidated") || errDesc.includes("revoked"))) ? "revoked" as const
1367
- : structuredCode === "refresh_token_expired"
1368
- || (proseIsOnlySignal && errDesc.includes("expired")) ? "expired" as const
1369
- : "unknown" as const;
1370
- throw new TokenRefreshError(reason, `Codex token refresh failed (${reason}); reauthenticate the account.`);
1329
+ const body = await readBoundedResponseBody(res, { signal, maxBytes: 16_384, fatalUtf8: true })
1330
+ .catch(() => undefined);
1331
+ const failure = classifyChatgptRefreshFailure(res.status, body?.displaySafe ? body.text : "");
1332
+ const { reason } = failure;
1333
+ noteChatgptRefreshFailure("pool", res.status, failure);
1334
+ throw new TokenRefreshError(reason, `Codex token refresh failed (${reason}); reauthenticate the account.`, res.status, failure.code);
1371
1335
  }
1372
1336
  const data = (await res.json()) as { access_token: string; refresh_token?: string; expires_in: number };
1373
1337
  // Guard against a missing/non-finite/negative expires_in (malformed upstream
@@ -2,8 +2,7 @@ import { readCodexAccountRecord } from "./account-store";
2
2
  import { isAccountNeedsReauth } from "./account-runtime-state";
3
3
  import {
4
4
  MAIN_CODEX_ACCOUNT_ID,
5
- hasMainAccountRefreshGrant,
6
- isMainAccountCredentialUsable,
5
+ getMainAccountCredentialStatus,
7
6
  isMainAccountTokenLive,
8
7
  } from "./main-account";
9
8
  import { hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount } from "./account-id";
@@ -14,7 +13,7 @@ import { isMainAccountHardLocked } from "./main-account-hard-lock";
14
13
  export interface CodexAccountUsabilityOptions {
15
14
  /** Route using cached runtime state only; the caller must reject selected main before auth. */
16
15
  nativeMainSelectionOnly?: boolean;
17
- /** Test seam for proving whether routing attempted a physical native-token read. */
16
+ /** Liveness override; ownership/read fences independently control physical credential reads. */
18
17
  isMainAccountTokenLive?: typeof isMainAccountTokenLive;
19
18
  /** Confirmed account ids for an account-gated model; omitted for ordinary native models. */
20
19
  modelEligibleAccountIds?: ReadonlySet<string>;
@@ -88,15 +87,19 @@ export function codexAccountUnusableReason(
88
87
  // A legacy pool row with the sentinel makes an active `__main__` ambiguous.
89
88
  // Fail closed until the authenticated compatibility-delete path removes it.
90
89
  if (hasLegacyMainCodexPoolAccount(config.codexAccounts)) return "legacy_pool_sentinel";
91
- if (isAccountNeedsReauth(accountId) && !hasMainAccountRefreshGrant()) return "needs_reauth";
92
- // A selection-only caller owns the recovery/drain fence and will reject main
93
- // before reservation or token materialization. Treat cached main as a routing
94
- // candidate without touching the credential file so affinity is not rebound.
90
+ // These scopes cannot consume physical stored-main state, even indirectly through an
91
+ // ordinary quarantine plus hasMainAccountRefreshGrant(). Caller liveness is request-owned.
95
92
  if (options.nativeMainSelectionOnly) return undefined;
93
+ if (options.requestOwnedMainCredential) {
94
+ return options.isMainAccountTokenLive?.() === false ? "main_credential_unavailable" : undefined;
95
+ }
96
+ const credential = getMainAccountCredentialStatus();
97
+ if (credential.refreshGrantRejected) return "needs_reauth";
98
+ if (isAccountNeedsReauth(accountId) && !credential.hasRefreshGrant) return "needs_reauth";
96
99
  // Main account: a refresh grant is enough to route; materialization refreshes before I/O.
97
100
  const mainLive = options.isMainAccountTokenLive
98
101
  ? options.isMainAccountTokenLive()
99
- : isMainAccountCredentialUsable();
102
+ : credential.usable;
100
103
  return mainLive ? undefined : "main_credential_unavailable";
101
104
  }
102
105
  const exists = (config.codexAccounts ?? [])
@@ -15,7 +15,7 @@ import { getCodexAccountAutoSwitchThresholdOverride } from "../account-auto-swit
15
15
  import { clearThreadAccountMapForAccount, isCodexAccountPlanExcluded, reconcileCodexActiveAfterExclusion } from "../routing";
16
16
  import { codexPlanValue, isThirtyDayOnlyCodexPlan } from "../plan";
17
17
  import { isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state";
18
- import { getValidMainAccountToken, MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID } from "../main-account";
18
+ import { getValidMainAccountToken, isMainAccountRefreshGrantRejected, MAIN_CODEX_ACCOUNT_ID } from "../main-account";
19
19
  import { captureConfigGeneration } from "../../lib/state-store-sweeper";
20
20
  import { captureMainAccountIdentityGeneration, getMainAccountCredentialPresence, getMainAccountInfoCache, getObservedMainQuotaIdentityKey, isMainAccountIdentityGenerationLive } from "../main-account-cache";
21
21
  import type { CodexQuotaRefreshOutcome } from "../quota-refresh-outcome";
@@ -342,60 +342,76 @@ export async function listCodexAuthAccountsSnapshot(
342
342
  maskEmails,
343
343
  )];
344
344
  });
345
- const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration();
346
- const mainSnapshotLive = isMainAccountIdentityGenerationLive(fetchedMainGeneration);
347
- // An ordinary same-account return can be parsed after its credential was replaced.
348
- // The card and hard-lock status must describe the same published quota snapshot.
349
- const mainInfo = mainSnapshotLive
350
- ? mainResult.infoUnpublished ? getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO : mainResult.info
351
- : EMPTY_MAIN_ACCOUNT_INFO;
352
- const hasMainCredential = mainSnapshotLive && mainResult.credentialChecked
353
- ? mainResult.hasCredential
354
- : getMainAccountCredentialPresence() ?? false;
355
- const mainMissingCredential = mainSnapshotLive && mainResult.credentialChecked && !hasMainCredential;
356
- const mainNeedsReauth = mainMissingCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
357
- const mainHealth = projectCodexAccountHealth({
358
- accountId: MAIN_CODEX_ACCOUNT_ID,
359
- needsReauth: mainNeedsReauth,
360
- });
361
- // The main row carries the same attribution as a pool row. Reaching this point without
362
- // `mainMissingCredential` means the runtime reauth flag is what set `mainNeedsReauth`, so the
363
- // cause is a refresh that did not complete.
364
- const mainReauthReason: CodexAccountReauthReason | undefined = mainMissingCredential
365
- ? "missing_credential"
366
- : mainNeedsReauth
367
- ? "refresh_failed"
368
- : mainHealth.status === "reauth_required" ? mainHealth.reason : undefined;
369
- const main: CodexAuthAccountDto = {
370
- id: MAIN_CODEX_ACCOUNT_ID,
371
- email: projectEmail(mainInfo.email, maskEmails) ?? "Codex App login",
372
- plan: mainInfo.plan,
373
- ...(mainSnapshotLive && mainResult.quotaRefresh && mainResult.quotaRefreshGeneration !== undefined
345
+ const projectSnapshot = (mainRefreshRefused = false): CodexAuthAccountsSnapshot => {
346
+ const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration();
347
+ const mainSnapshotLive = isMainAccountIdentityGenerationLive(fetchedMainGeneration);
348
+ // An ordinary same-account return can be parsed after its credential was replaced.
349
+ // The card and hard-lock status must describe the same published quota snapshot.
350
+ const mainInfo = mainSnapshotLive
351
+ ? mainResult.infoUnpublished ? getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO : mainResult.info
352
+ : EMPTY_MAIN_ACCOUNT_INFO;
353
+ const hasMainCredential = mainSnapshotLive && mainResult.credentialChecked
354
+ ? mainResult.hasCredential
355
+ : getMainAccountCredentialPresence() ?? false;
356
+ const mainMissingCredential = mainSnapshotLive && mainResult.credentialChecked && !hasMainCredential;
357
+ const liveQuotaRefresh = mainSnapshotLive && mainResult.quotaRefreshGeneration !== undefined
374
358
  && isMainAccountIdentityGenerationLive(mainResult.quotaRefreshGeneration)
375
- ? { quotaRefresh: mainResult.quotaRefresh } : {}),
376
- logLabel: "main",
377
- isMain: true,
378
- paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
379
- mainAccountHardLock: { ...getMainAccountHardLockStatus(runtimeConfig),
380
- externalUsage: getMainAccountExternalUsageWarning(getObservedMainQuotaIdentityKey()) },
381
- priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
382
- autoSwitchThresholdOverride: getCodexAccountAutoSwitchThresholdOverride(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
383
- creditsAfterLimit: codexAccountUsesCreditsAfterLimit(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
384
- hasCredential: hasMainCredential,
385
- needsReauth: mainNeedsReauth,
386
- ...(mainReauthReason !== undefined ? { reauthReason: mainReauthReason } : {}),
387
- quota: mainInfo.quota
388
- ? quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan)
389
- : null,
390
- ...codexCreditsDtoField(runtimeConfig, MAIN_CODEX_ACCOUNT_ID, getMainChatgptAccountId()),
391
- ...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
392
- };
393
- return {
394
- accounts: [main, ...withQuota],
395
- mainIdentityGeneration: mainSnapshotLive
396
- ? fetchedMainGeneration
397
- : captureMainAccountIdentityGeneration(),
359
+ ? mainResult.quotaRefresh : undefined;
360
+ const mainNeedsReauth = mainMissingCredential || mainRefreshRefused || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
361
+ const mainHealth = projectCodexAccountHealth({
362
+ accountId: MAIN_CODEX_ACCOUNT_ID,
363
+ needsReauth: mainNeedsReauth,
364
+ });
365
+ // The main row carries the same attribution as a pool row. Reaching this point without
366
+ // Only a current terminal usage probe can attribute the reauth cause. A transient status
367
+ // never replaces a refresh failure, including a separately recorded refused grant.
368
+ const mainReauthReason: CodexAccountReauthReason | undefined = mainMissingCredential
369
+ ? "missing_credential"
370
+ : mainNeedsReauth
371
+ ? mainResult.terminalAuthFailure === true && liveQuotaRefresh?.status === "http_error"
372
+ ? "unauthorized" : "refresh_failed"
373
+ : mainHealth.status === "reauth_required" ? mainHealth.reason : undefined;
374
+ const main: CodexAuthAccountDto = {
375
+ id: MAIN_CODEX_ACCOUNT_ID,
376
+ email: projectEmail(mainInfo.email, maskEmails) ?? "Codex App login",
377
+ plan: mainInfo.plan,
378
+ ...(liveQuotaRefresh ? { quotaRefresh: liveQuotaRefresh } : {}),
379
+ logLabel: "main",
380
+ isMain: true,
381
+ paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
382
+ mainAccountHardLock: { ...getMainAccountHardLockStatus(runtimeConfig),
383
+ externalUsage: getMainAccountExternalUsageWarning(getObservedMainQuotaIdentityKey()) },
384
+ priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
385
+ autoSwitchThresholdOverride: getCodexAccountAutoSwitchThresholdOverride(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
386
+ creditsAfterLimit: codexAccountUsesCreditsAfterLimit(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
387
+ hasCredential: hasMainCredential,
388
+ needsReauth: mainNeedsReauth,
389
+ ...(mainReauthReason !== undefined ? { reauthReason: mainReauthReason } : {}),
390
+ quota: mainInfo.quota
391
+ ? quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan)
392
+ : null,
393
+ ...codexCreditsDtoField(runtimeConfig, MAIN_CODEX_ACCOUNT_ID, getMainChatgptAccountId()),
394
+ ...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
395
+ };
396
+ return {
397
+ accounts: [main, ...withQuota],
398
+ mainIdentityGeneration: mainSnapshotLive
399
+ ? fetchedMainGeneration
400
+ : captureMainAccountIdentityGeneration(),
401
+ };
398
402
  };
403
+ // Project the refusal and DTO in the same owned synchronous observation. A same-account grant
404
+ // replacement during earlier pool probes must not inherit the previous grant's refusal.
405
+ const projectionLease = tryAcquireNativeMainProfileClaim();
406
+ if (!projectionLease) return projectSnapshot();
407
+ try {
408
+ return await withNativeMainCredentialClaim(async () => projectSnapshot(isMainAccountRefreshGrantRejected()));
409
+ } catch (error) {
410
+ if (isNativeMainClaimUnavailable(error)) return projectSnapshot();
411
+ throw error;
412
+ } finally {
413
+ projectionLease.release();
414
+ }
399
415
  }
400
416
 
401
417
  /** One opted-in account's metadata; reuse the bounded WHAM 401 recovery and generation fence. */
@@ -406,16 +422,10 @@ export async function refreshCodexQuotaForActivation(config: OcxConfig, accountI
406
422
  try {
407
423
  reconcileMainCodexAccountRuntimeState();
408
424
  if (isAccountNeedsReauth(accountId)) return;
409
- const identityGeneration = captureMainAccountIdentityGeneration();
410
- const writerGeneration = captureConfigGeneration();
411
425
  try {
412
- // Refresh may need an exclusive claim; prepare before WHAM takes its shared claim.
426
+ // Refresh owns credential-scoped refusal evidence; do not recreate a global quarantine.
413
427
  if (!await getValidMainAccountToken({ preserveReauth: true })) return;
414
- } catch (error) {
415
- if (error instanceof MainAccountTokenRefreshError && error.reason === "reauth"
416
- && isMainAccountIdentityGenerationLive(identityGeneration)) {
417
- markAccountNeedsReauth(accountId, writerGeneration);
418
- }
428
+ } catch {
419
429
  return;
420
430
  }
421
431
  if (isAccountNeedsReauth(accountId)) return;
@@ -476,7 +486,7 @@ export async function pauseExhaustedCodexAccounts(
476
486
  }
477
487
  return {
478
488
  shouldPause: !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
479
- && isCodexQuotaExhausted(mainResult.freshQuota, mainResult.info.plan),
489
+ && isCodexQuotaExhausted(mainResult.freshQuota, mainResult.info.plan, codexAccountUsesCreditsAfterLimit(config, MAIN_CODEX_ACCOUNT_ID)),
480
490
  checkedAccountCount: 1,
481
491
  failedAccountCount: 0,
482
492
  };
@@ -511,7 +521,7 @@ export async function pauseExhaustedCodexAccounts(
511
521
  continue;
512
522
  }
513
523
  checkedAccountCount += 1;
514
- if (!isCodexAccountPaused(config, account.id) && isCodexQuotaExhausted(quotaResult.freshQuota, plan)) {
524
+ if (!isCodexAccountPaused(config, account.id) && isCodexQuotaExhausted(quotaResult.freshQuota, plan, codexAccountUsesCreditsAfterLimit(config, account.id))) {
515
525
  exhaustedIds.push(account.id);
516
526
  }
517
527
  }
@@ -16,14 +16,15 @@ import { getMainAccountPlan, isMainAccountTokenVerifiablyLive, MAIN_CODEX_ACCOUN
16
16
  import { captureConfigGeneration } from "../../lib/state-store-sweeper";
17
17
  import { captureMainAccountIdentityGeneration, clearMainAccountInfoCache, getMainAccountInfoCache, getMainQuotaCredentialGeneration, isMainAccountIdentityGenerationLive, isMainQuotaWriterLive, matchesMainQuotaCredential, observeMainQuotaCredential, setMainAccountCredentialPresence, setMainAccountInfoCache } from "../main-account-cache";
18
18
  import type { MainQuotaWriter, MainAccountInfo } from "../main-account-cache";
19
- import type { CodexQuotaRefreshOutcome } from "../quota-refresh-outcome";
19
+ import { isCodexTerminalAuthCode } from "../quota-refresh-outcome";
20
+ import type { CodexQuotaRefreshOutcome, CodexTerminalAuthCode } from "../quota-refresh-outcome";
20
21
  import { observeMainReserveRevocation } from "../reserve-availability";
21
22
  import type { AdmissionLease } from "../../lib/admission";
22
23
  import { nonEmptyPlan } from "./runtime-config";
23
24
  import { tryAcquireNativeMainProfileClaim } from "../native-main-admission";
24
25
  import { WHAM_REQUEST_TIMEOUT_MS } from "../quota-recovery-timing";
25
26
  import { withNativeMainCredentialClaim, isNativeMainClaimUnavailable } from "./http";
26
- import { MAIN_TERMINAL_AUTH_CODES, readMainAuthErrorCode, currentQuotaDispatchSequence, nextQuotaDispatchSequence, isQuotaDispatchCurrent, publishQuotaDispatch } from "./pool-quota-probe";
27
+ import { readMainAuthErrorCode, currentQuotaDispatchSequence, nextQuotaDispatchSequence, isQuotaDispatchCurrent, publishQuotaDispatch } from "./pool-quota-probe";
27
28
 
28
29
  /**
29
30
  * Last reset-credit count this process parsed for the main account, tagged with the
@@ -79,14 +80,18 @@ function sharedMainQuotaPacingEnabled(config: OcxConfig): boolean {
79
80
  * itself would make a real 401 permanently transient.
80
81
  */
81
82
  export async function isTerminalMainAuthResponse(resp: Response, accessTokenLive: boolean): Promise<boolean> {
82
- if (resp.status === 401) {
83
- if (!accessTokenLive) return true;
84
- const code = await readMainAuthErrorCode(resp);
85
- return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
86
- }
87
- if (resp.status !== 403) return false;
83
+ return (await classifyMainAuthResponse(resp, accessTokenLive)).terminal;
84
+ }
85
+
86
+ /** As `isTerminalMainAuthResponse`, also naming the provider code that made it terminal. */
87
+ async function classifyMainAuthResponse(
88
+ resp: Response,
89
+ accessTokenLive: boolean,
90
+ ): Promise<{ terminal: boolean; code?: CodexTerminalAuthCode }> {
91
+ if (resp.status !== 401 && resp.status !== 403) return { terminal: false };
88
92
  const code = await readMainAuthErrorCode(resp);
89
- return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
93
+ if (isCodexTerminalAuthCode(code)) return { terminal: true, code };
94
+ return { terminal: resp.status === 401 && !accessTokenLive };
90
95
  }
91
96
 
92
97
  export interface MainResetQuotaProof {
@@ -103,6 +108,8 @@ export interface MainAccountInfoFetchResult {
103
108
  quotaRefresh?: CodexQuotaRefreshOutcome;
104
109
  /** Internal dispatch fence for diagnostics only; never copied into a public DTO or cache. */
105
110
  quotaRefreshGeneration?: number;
111
+ /** This current-credential usage response supplied terminal auth evidence; never public. */
112
+ terminalAuthFailure?: true;
106
113
  /** Whether this attempt safely inspected the physical native-main credential. */
107
114
  credentialChecked: boolean;
108
115
  /** Meaningful only when credentialChecked is true. */
@@ -288,7 +295,8 @@ export async function fetchMainAccountInfoWhileOwned(
288
295
  const resp = admission.response;
289
296
  quotaPhase = "publish";
290
297
  if (!resp.ok) {
291
- const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive());
298
+ const authFailure = await classifyMainAuthResponse(resp, isMainAccountTokenVerifiablyLive());
299
+ const terminalAuthFailure = authFailure.terminal;
292
300
  const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh, paced);
293
301
  if (retried) return retried;
294
302
  if (!isQuotaDispatchCurrent(dispatchSequence) || !credentialIsCurrent()) {
@@ -302,10 +310,15 @@ export async function fetchMainAccountInfoWhileOwned(
302
310
  if (diagnosticStillLive) quotaRefreshGeneration = captureMainAccountIdentityGeneration();
303
311
  markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration);
304
312
  }
313
+ // Keep the last-known plan visible: after a plan change revokes the session, the plan the
314
+ // account HAD is what tells the operator why it stopped working.
305
315
  return {
306
- info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true,
307
- quotaRefresh: { status: "http_error", httpStatus: resp.status },
316
+ info: { ...EMPTY_MAIN_ACCOUNT_INFO, plan: nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan()) },
317
+ credentialChecked: true, hasCredential: true,
318
+ quotaRefresh: { status: "http_error", httpStatus: resp.status,
319
+ ...(authFailure.code ? { code: authFailure.code } : {}) },
308
320
  quotaRefreshGeneration,
321
+ ...(terminalAuthFailure ? { terminalAuthFailure: true as const } : {}),
309
322
  };
310
323
  }
311
324
  quotaPhase = "body";
@@ -1,3 +1,4 @@
1
+ import { codexAccountUsesCreditsAfterLimit } from "../account-credit-use";
1
2
  import { nextCodexUsageQueryAt, nextQuotaQueryDelay, pruneRemovedCodexPoolUsageAccounts } from "../quota-query-backoff";
2
3
  import { CODEX_PRIORITY_FAILBACK_REFRESH_MS } from "../account-priority";
3
4
  import { codexQuotaHasFreshUsage } from "../quota-observation-freshness";
@@ -6,8 +7,8 @@ import { getAccountQuota, isCompleteCodexQuotaRecoverySnapshot } from "../quota"
6
7
  import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle";
7
8
  import { claimDueCodexQuotaRecoveryProbes, settleCodexQuotaRecoveryProbe } from "../routing";
8
9
  import { readCodexTokens } from "../auth-collision";
9
- import { isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state";
10
- import { getValidMainAccountToken, MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "../main-account";
10
+ import { isAccountNeedsReauth } from "../account-runtime-state";
11
+ import { getValidMainAccountToken, MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "../main-account";
11
12
  import { captureConfigGeneration, registerStateSweepAfterTick } from "../../lib/state-store-sweeper";
12
13
  import { observeMainQuotaCredential, getMainQuotaCredentialGeneration, captureMainAccountIdentityGeneration, isMainAccountIdentityGenerationLive } from "../main-account-cache";
13
14
  import { getMainAccountHardLockStatus } from "../main-account-hard-lock";
@@ -58,7 +59,7 @@ export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Da
58
59
  // Defence in depth: independent scopes are already excluded at the claim site.
59
60
  // Generic WHAM must never clear Reserve even if claim selection changes.
60
61
  const recovered = (claim.scope === undefined || claim.scope === "shared")
61
- && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? account.plan);
62
+ && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? account.plan, codexAccountUsesCreditsAfterLimit(config, claim.accountId));
62
63
  settleCodexQuotaRecoveryProbe(claim, recovered, {
63
64
  credentialGeneration: result.freshCredentialGeneration,
64
65
  }, now);
@@ -100,18 +101,14 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
100
101
  if (getMainAccountHardLockStatus(config).state !== "blocked"
101
102
  || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return;
102
103
  const identityGeneration = captureMainAccountIdentityGeneration();
103
- const writerGeneration = captureConfigGeneration();
104
104
  try {
105
105
  // Refresh can require an exclusive credential claim: never hold WHAM's shared
106
106
  // claim while obtaining a valid token. The runtime lease spans both operations.
107
107
  const prepared = await getValidMainAccountToken({ preserveReauth: true });
108
108
  if (!prepared) return;
109
109
  observeMainQuotaCredential(prepared.accessToken, prepared.chatgptAccountId);
110
- } catch (error) {
111
- if (error instanceof MainAccountTokenRefreshError && error.reason === "reauth"
112
- && isMainAccountIdentityGenerationLive(identityGeneration)) {
113
- markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration);
114
- }
110
+ } catch {
111
+ // Native refresh retains its own grant-scoped refusal; global quarantine would outlive it.
115
112
  return;
116
113
  }
117
114
  if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return;
@@ -15,14 +15,12 @@ import { WHAM_REQUEST_TIMEOUT_MS } from "../quota-recovery-timing";
15
15
  import { claimQuotaRecovery, fencePropagatedQuotaRecovery, quotaRecoveryTerminalFor, releaseQuotaRecovery, settleQuotaRecovery, settleQuotaRecoveryTerminal } from "../quota-401-recovery";
16
16
  import { seedLoginRowsForTests } from "./login-state";
17
17
  import { nonEmptyPlan } from "./runtime-config";
18
+ import { CODEX_TERMINAL_AUTH_CODES } from "../quota-refresh-outcome";
18
19
 
19
20
  export const POOL_CACHE_TTL = 5 * 60_000;
20
21
  export const POOL_QUOTA_REFRESH_CONCURRENCY = 4;
21
22
 
22
- export const MAIN_TERMINAL_AUTH_CODES = new Set([
23
- "invalid_workspace_selected",
24
- "invalid_refresh_token",
25
- ]);
23
+ export const MAIN_TERMINAL_AUTH_CODES: ReadonlySet<string> = new Set(CODEX_TERMINAL_AUTH_CODES);
26
24
 
27
25
  export async function readMainAuthErrorCode(resp: Response): Promise<unknown> {
28
26
  try {
@@ -1,3 +1,4 @@
1
+ import { codexAccountUsesCreditsAfterLimit } from "../account-credit-use";
1
2
  import { getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord, CodexCredentialGenerationConflictError } from "../account-store";
2
3
  import { isCompleteCodexQuotaRecoverySnapshot } from "../quota";
3
4
  import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle";
@@ -233,7 +234,7 @@ async function refreshAfterManualReset(
233
234
  && proof.credentialGeneration === auth.mainProof.credentialGeneration
234
235
  && proof.writer.identityKey === auth.mainProof.writer.identityKey
235
236
  && proof.writer.identityGeneration === auth.mainProof.writer.identityGeneration
236
- && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.info.plan);
237
+ && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.info.plan, codexAccountUsesCreditsAfterLimit(getRuntimeConfig(config), accountId));
237
238
  for (const claim of claims) settleManualResetCooldown(getRuntimeConfig(config), claim, recovered);
238
239
  return manualResetAuthStillLive(accountId, auth) ? result.freshResetCredits : undefined;
239
240
  }
@@ -255,7 +256,7 @@ async function refreshAfterManualReset(
255
256
  const record = readCodexAccountRecord(accountId);
256
257
  const recovered = didReset && record?.credential?.chatgptAccountId === auth.chatgptAccountId
257
258
  && (result.quotaProbeAttempted?.dispatchSequence ?? 0) > afterDispatchSequence
258
- && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? account.plan);
259
+ && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? account.plan, codexAccountUsesCreditsAfterLimit(getRuntimeConfig(config), accountId));
259
260
  for (const claim of claims) settleManualResetCooldown(getRuntimeConfig(config), claim, recovered, {
260
261
  credentialGeneration: result.freshCredentialGeneration,
261
262
  refreshLineage: result.resetRefreshLineage,
@@ -1,3 +1,4 @@
1
+ import { hasSpendableCodexCredits } from "./quota-types";
1
2
  import { noteMainAccountActivity } from "./main-account-external-usage";
2
3
  import { codexAccountPriorityFailbackEnabled } from "./account-priority";
3
4
  import type { PoolQuotaWriter } from "./quota-types";
@@ -16,7 +17,7 @@ import {
16
17
  import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
17
18
  import { ConfigMutationLockError } from "../config";
18
19
  import { NativeProfileError } from "./native-profile-types";
19
- import { isCodexAccountUsable } from "./account-usability";
20
+ import { codexAccountUnusableReason, isCodexAccountUsable } from "./account-usability";
20
21
  import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
21
22
  import {
22
23
  MAIN_CODEX_ACCOUNT_ID,
@@ -102,7 +103,7 @@ import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch";
102
103
  function requestOwnedMainPinHasQuotaHeadroom(config: OcxConfig): boolean {
103
104
  const threshold = getEffectiveCodexAutoSwitchThreshold(config, MAIN_CODEX_ACCOUNT_ID);
104
105
  if (threshold <= 0) return true;
105
- const usage = computeCodexUsageScore(getAccountQuota(MAIN_CODEX_ACCOUNT_ID));
106
+ const usage = computeCodexUsageScore(getAccountQuota(MAIN_CODEX_ACCOUNT_ID), undefined, Date.now(), codexAccountUsesCreditsAfterLimit(config, MAIN_CODEX_ACCOUNT_ID));
106
107
  return usage >= CODEX_UNKNOWN_USAGE_SCORE || usage < threshold;
107
108
  }
108
109
 
@@ -378,9 +379,11 @@ export class CodexAuthContextError extends Error {
378
379
  }
379
380
 
380
381
  export class CodexPoolAuthenticationError extends Error {
381
- constructor(message = "OpenAI account pool has no usable account credential") {
382
+ readonly quarantinedMain: boolean;
383
+ constructor(message = "OpenAI account pool has no usable account credential", options?: { quarantinedMain?: boolean }) {
382
384
  super(message);
383
385
  this.name = "CodexPoolAuthenticationError";
386
+ this.quarantinedMain = options?.quarantinedMain === true;
384
387
  }
385
388
  }
386
389
 
@@ -530,7 +533,7 @@ export class CodexMainAccountCreditsOffError extends CodexAccountCooldownError {
530
533
  super(MAIN_CODEX_ACCOUNT_ID, resetAt ?? 0);
531
534
  this.name = "CodexMainAccountCreditsOffError";
532
535
  this.resetAt = resetAt;
533
- this.message = "Codex main account reached its usage limit, and spending ChatGPT credits is off for it."
536
+ this.message = "Codex main account reached its usage limit, and spending ChatGPT credits is off or no fresh spendable balance is available."
534
537
  + " Choose another account, wait for the limit to reset, or allow the main account under \"Use credits\" in Codex Auth.";
535
538
  }
536
539
  }
@@ -723,7 +726,8 @@ export function unwrapUpstreamRetryEvidenceError(error: unknown): unknown {
723
726
  * file, which several callers are forbidden to open, so every long window counts instead.
724
727
  */
725
728
  function mainCreditsHoldResetAt(config: Pick<OcxConfig, "creditCodexAccountIds">): number | undefined {
726
- if (codexAccountUsesCreditsAfterLimit(config, MAIN_CODEX_ACCOUNT_ID)) return undefined;
729
+ if (codexAccountUsesCreditsAfterLimit(config, MAIN_CODEX_ACCOUNT_ID)
730
+ && hasSpendableCodexCredits(getMainPolicyQuota())) return undefined;
727
731
  return codexUsageLimitResetAt(getMainPolicyQuota(), undefined, Date.now());
728
732
  }
729
733
 
@@ -920,7 +924,7 @@ export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown):
920
924
  && !(cause instanceof CodexCredentialRefreshBusyError)
921
925
  && !(cause instanceof CodexCredentialRefreshStaleError)
922
926
  && !(cause instanceof MainAuthJsonChangedDuringRefreshError)
923
- && !(cause instanceof MainAccountTokenRefreshError && cause.reason === "transient")
927
+ && !(cause instanceof MainAccountTokenRefreshError)
924
928
  && !(cause instanceof NativeProfileError && cause.retryable)
925
929
  && !(cause instanceof DOMException && cause.name === "AbortError")
926
930
  && !(cause instanceof ConfigMutationLockError);
@@ -1314,7 +1318,12 @@ export async function resolveCodexAuthContext(
1314
1318
  "Selected Codex account does not support this model",
1315
1319
  );
1316
1320
  }
1317
- throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
1321
+ const mainReason = fixedAccountId === MAIN_CODEX_ACCOUNT_ID && !nativeMainReadsForbidden
1322
+ && !policy.pausedCodexAccountIds?.includes(fixedAccountId)
1323
+ ? codexAccountUnusableReason(config, fixedAccountId, selectionOptions) : undefined;
1324
+ throw new CodexPoolAuthenticationError("Selected Codex account is unavailable", {
1325
+ quarantinedMain: mainReason === "needs_reauth",
1326
+ });
1318
1327
  }
1319
1328
  // Recovery or a turn drain deliberately makes physical main unobservable.
1320
1329
  // If no healthy pool route is available, report the temporary fence rather
@@ -1339,7 +1348,12 @@ export async function resolveCodexAuthContext(
1339
1348
  : "Codex accounts that support this model are currently unavailable",
1340
1349
  );
1341
1350
  }
1342
- throw new CodexPoolAuthenticationError();
1351
+ throw new CodexPoolAuthenticationError(undefined, {
1352
+ quarantinedMain: !nativeMainReadsForbidden
1353
+ && options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID
1354
+ && !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID)
1355
+ && codexAccountUnusableReason(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions) === "needs_reauth",
1356
+ });
1343
1357
  }
1344
1358
  accountId = selected;
1345
1359
  if (accountId === MAIN_CODEX_ACCOUNT_ID) assertMainAccountPolicy(policy);
@@ -1370,10 +1384,15 @@ export async function resolveCodexAuthContext(
1370
1384
  throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
1371
1385
  }
1372
1386
  if (isAccountNeedsReauth(accountId)) {
1373
- throw new CodexPoolAuthenticationError("Selected Codex account needs reauthentication");
1387
+ throw new CodexPoolAuthenticationError("Selected Codex account needs reauthentication", {
1388
+ quarantinedMain: accountId === MAIN_CODEX_ACCOUNT_ID && !nativeMainReadsForbidden,
1389
+ });
1374
1390
  }
1375
- if (!isCodexAccountUsable(config, accountId, selectionOptions)) {
1376
- throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
1391
+ const unusableReason = codexAccountUnusableReason(config, accountId, selectionOptions);
1392
+ if (unusableReason !== undefined) {
1393
+ throw new CodexPoolAuthenticationError("Selected Codex account is unavailable", {
1394
+ quarantinedMain: accountId === MAIN_CODEX_ACCOUNT_ID && unusableReason === "needs_reauth",
1395
+ });
1377
1396
  }
1378
1397
  }
1379
1398
  } catch (cause) {