@bitkyc08/opencodex 2.55.0 → 2.57.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 (256) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  3. package/gui/dist/assets/{index-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -3
  6. package/src/adapters/base.ts +21 -0
  7. package/src/adapters/codebuddy/adapter.ts +2 -1
  8. package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
  9. package/src/adapters/command-code.ts +1 -1
  10. package/src/adapters/cursor/envelope-echo.ts +8 -2
  11. package/src/adapters/cursor/transport-retry.ts +46 -1
  12. package/src/adapters/cursor.ts +4 -0
  13. package/src/adapters/google.ts +7 -7
  14. package/src/adapters/kiro/adapter.ts +42 -1
  15. package/src/adapters/kiro/payload.ts +17 -3
  16. package/src/adapters/kiro/reasoning.ts +70 -7
  17. package/src/adapters/kiro/stream.ts +8 -2
  18. package/src/adapters/kiro/wire.ts +2 -1
  19. package/src/adapters/kiro-events.ts +21 -13
  20. package/src/adapters/kiro-retry.ts +23 -4
  21. package/src/adapters/openai-chat/errors.ts +116 -0
  22. package/src/adapters/openai-chat/messages.ts +346 -0
  23. package/src/adapters/openai-chat/passthrough.ts +146 -0
  24. package/src/adapters/openai-chat/response-events.ts +117 -0
  25. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  26. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  27. package/src/adapters/openai-chat/tool-schema.ts +495 -0
  28. package/src/adapters/openai-chat/wire.ts +50 -0
  29. package/src/adapters/openai-chat.ts +40 -1452
  30. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  31. package/src/adapters/openai-responses/image-gen.ts +406 -0
  32. package/src/adapters/openai-responses/internal.ts +3 -0
  33. package/src/adapters/openai-responses/passthrough.ts +642 -0
  34. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  35. package/src/adapters/openai-responses/reasoning.ts +220 -0
  36. package/src/adapters/openai-responses/request-strips.ts +185 -0
  37. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  38. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  39. package/src/adapters/openai-responses/web-search.ts +156 -0
  40. package/src/adapters/openai-responses.ts +4 -2625
  41. package/src/bridge/errors.ts +58 -0
  42. package/src/bridge/internal.ts +174 -0
  43. package/src/bridge/response-json.ts +630 -0
  44. package/src/bridge/sse.ts +1462 -0
  45. package/src/bridge.ts +5 -2204
  46. package/src/chat/inbound.ts +12 -1
  47. package/src/claude/desktop-profile.ts +66 -9
  48. package/src/claude/outbound.ts +18 -0
  49. package/src/cli/account-main.ts +1 -1
  50. package/src/cli/capabilities.ts +2 -2
  51. package/src/cli/combo.ts +10 -1
  52. package/src/cli/index.ts +48 -5
  53. package/src/cli/registry.ts +2 -1
  54. package/src/cli/system-command.ts +4 -4
  55. package/src/clients/config-export.ts +7 -3
  56. package/src/codex/account-label.ts +14 -3
  57. package/src/codex/account-lifecycle.ts +3 -0
  58. package/src/codex/account-store.ts +184 -35
  59. package/src/codex/account-usability.ts +21 -0
  60. package/src/codex/auth-api/account-list.ts +507 -0
  61. package/src/codex/auth-api/http.ts +32 -0
  62. package/src/codex/auth-api/login-flow.ts +566 -0
  63. package/src/codex/auth-api/login-state.ts +64 -0
  64. package/src/codex/auth-api/main-account-probe.ts +331 -0
  65. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  66. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  67. package/src/codex/auth-api/reset-credit-service.ts +431 -0
  68. package/src/codex/auth-api/routes.ts +425 -0
  69. package/src/codex/auth-api/runtime-config.ts +48 -0
  70. package/src/codex/auth-api.ts +27 -3118
  71. package/src/codex/auth-context.ts +252 -35
  72. package/src/codex/catalog/aggregation.ts +80 -1
  73. package/src/codex/catalog/auto-review.ts +507 -0
  74. package/src/codex/catalog/build-entries.ts +981 -0
  75. package/src/codex/catalog/combo-member.ts +375 -0
  76. package/src/codex/catalog/derive-entry.ts +229 -0
  77. package/src/codex/catalog/effort.ts +0 -1
  78. package/src/codex/catalog/gated-native-warn.ts +63 -0
  79. package/src/codex/catalog/gather-capture.ts +533 -0
  80. package/src/codex/catalog/model-hints.ts +691 -0
  81. package/src/codex/catalog/model-visibility.ts +305 -0
  82. package/src/codex/catalog/provider-fetch.ts +52 -2942
  83. package/src/codex/catalog/provider-models.ts +685 -0
  84. package/src/codex/catalog/remote.ts +30 -0
  85. package/src/codex/catalog/restore.ts +132 -0
  86. package/src/codex/catalog/retained-sync.ts +714 -0
  87. package/src/codex/catalog/routed-gather.ts +895 -0
  88. package/src/codex/catalog/subagent-roster.ts +176 -0
  89. package/src/codex/catalog/sync.ts +52 -2698
  90. package/src/codex/cli-install-provenance.ts +7 -1
  91. package/src/codex/convergence.ts +7 -2
  92. package/src/codex/desktop-app/types.ts +11 -2
  93. package/src/codex/desktop-app/windows.ts +5 -5
  94. package/src/codex/inject/config-toml.ts +563 -0
  95. package/src/codex/inject/remove.ts +192 -0
  96. package/src/codex/inject/restore.ts +567 -0
  97. package/src/codex/inject/routing-classify.ts +109 -0
  98. package/src/codex/inject/routing-target.ts +125 -0
  99. package/src/codex/inject.ts +89 -1444
  100. package/src/codex/lineage.ts +458 -0
  101. package/src/codex/model-entitlements.ts +152 -15
  102. package/src/codex/pool-refresh-backoff.ts +161 -0
  103. package/src/codex/quota-rejection.ts +104 -15
  104. package/src/codex/routing/active-account.ts +194 -0
  105. package/src/codex/routing/cache-affinity.ts +70 -0
  106. package/src/codex/routing/cooldown-math.ts +285 -0
  107. package/src/codex/routing/health-store.ts +402 -0
  108. package/src/codex/routing/probe-lease.ts +358 -0
  109. package/src/codex/routing/selection.ts +780 -0
  110. package/src/codex/routing/thread-affinity.ts +586 -0
  111. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  112. package/src/codex/routing.ts +370 -2271
  113. package/src/codex/shim-fingerprint.ts +223 -0
  114. package/src/codex/shim-inspect.ts +175 -0
  115. package/src/codex/shim-probe.ts +367 -0
  116. package/src/codex/shim-restore-lock.ts +169 -0
  117. package/src/codex/shim-state-file.ts +151 -0
  118. package/src/codex/shim-templates.ts +265 -0
  119. package/src/codex/shim.ts +48 -1268
  120. package/src/codex/warmup.ts +1 -1
  121. package/src/combos/failover.ts +85 -0
  122. package/src/combos/request.ts +17 -10
  123. package/src/combos/types.ts +23 -2
  124. package/src/config/diagnostics.ts +705 -0
  125. package/src/config/feature-flags.ts +55 -0
  126. package/src/config/live-reconcile.ts +403 -0
  127. package/src/config/load-degrade.ts +880 -0
  128. package/src/config/mutation-lock.ts +244 -0
  129. package/src/config/openai-tier-backup.ts +268 -0
  130. package/src/config/pending-teardown.ts +31 -0
  131. package/src/config/persist-unlocked.ts +92 -0
  132. package/src/config/proxy-env.ts +188 -0
  133. package/src/config/salvage.ts +244 -0
  134. package/src/config/schema/config-schema.ts +640 -0
  135. package/src/config/schema/leaf-validators.ts +855 -0
  136. package/src/config/warn-memo.ts +28 -0
  137. package/src/config.ts +234 -4481
  138. package/src/generated/compatibility-version.json +649 -121
  139. package/src/images/loop.ts +1 -1
  140. package/src/lib/errors.ts +17 -0
  141. package/src/lib/request-execution-budget.ts +198 -23
  142. package/src/lib/spend-reservation-ledger.ts +958 -0
  143. package/src/lib/state-store-registrations.ts +6 -2
  144. package/src/lib/test-home-guard.ts +85 -1
  145. package/src/lib/upstream-retry.ts +132 -21
  146. package/src/lib/windows-elevation.ts +76 -14
  147. package/src/lib/workflow-budget.ts +553 -30
  148. package/src/oauth/index.ts +2 -2
  149. package/src/oauth/key-providers.ts +2 -2
  150. package/src/providers/kiro-models.ts +4 -3
  151. package/src/providers/label.ts +19 -1
  152. package/src/providers/model-discovery.ts +16 -0
  153. package/src/providers/quota/account-cache.ts +441 -0
  154. package/src/providers/quota/antigravity.ts +295 -0
  155. package/src/providers/quota/report-cache.ts +320 -0
  156. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  157. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  158. package/src/providers/quota.ts +324 -3079
  159. package/src/providers/registry/entries-core.ts +1228 -0
  160. package/src/providers/registry/entries-extended.ts +1213 -0
  161. package/src/providers/registry/model-seeds.ts +912 -0
  162. package/src/providers/registry/types.ts +352 -0
  163. package/src/providers/registry.ts +24 -3536
  164. package/src/responses/continuation-ownership.ts +29 -0
  165. package/src/responses/reasoning-envelope.ts +6 -3
  166. package/src/responses/state/replay-fingerprint.ts +80 -0
  167. package/src/responses/state/snapshot-codec.ts +104 -0
  168. package/src/responses/state/spill-failure.ts +118 -0
  169. package/src/responses/state/spill-queue.ts +665 -0
  170. package/src/responses/state/temp-recovery.ts +257 -0
  171. package/src/responses/state.ts +82 -1143
  172. package/src/routing/identity-domains.ts +456 -0
  173. package/src/routing/probe-lease.ts +613 -0
  174. package/src/server/chat-completions.ts +3 -1
  175. package/src/server/chat-native.ts +37 -9
  176. package/src/server/index/bounded-request.ts +88 -0
  177. package/src/server/index/live-sideband.ts +601 -0
  178. package/src/server/index/serve-options.ts +1766 -0
  179. package/src/server/index/startup-warnings.ts +213 -0
  180. package/src/server/index/websocket-handler.ts +339 -0
  181. package/src/server/index.ts +45 -2552
  182. package/src/server/inspection-tee.ts +107 -0
  183. package/src/server/live.ts +46 -1
  184. package/src/server/management/combo-routes.ts +10 -1
  185. package/src/server/management/route-registry.ts +26 -23
  186. package/src/server/management/shared.ts +8 -5
  187. package/src/server/management/workflow-budget-routes.ts +133 -0
  188. package/src/server/management-api.ts +12 -0
  189. package/src/server/relay-eager.ts +2 -0
  190. package/src/server/relay.ts +14 -19
  191. package/src/server/request-log-conversation.ts +9 -7
  192. package/src/server/request-log.ts +372 -4
  193. package/src/server/response-log-body.ts +153 -0
  194. package/src/server/responses/account-change-state.ts +307 -0
  195. package/src/server/responses/adapter-continuation.ts +540 -0
  196. package/src/server/responses/adapter-delivery.ts +208 -0
  197. package/src/server/responses/adapter-dispatch.ts +1042 -0
  198. package/src/server/responses/codex-ws-wire.ts +5 -0
  199. package/src/server/responses/collaboration.ts +74 -4
  200. package/src/server/responses/combo-session-recall.ts +68 -8
  201. package/src/server/responses/compact.ts +113 -17
  202. package/src/server/responses/completion-policy.ts +33 -0
  203. package/src/server/responses/core-auth.ts +529 -0
  204. package/src/server/responses/core-codex-account.ts +907 -0
  205. package/src/server/responses/core-combo-failure.ts +210 -0
  206. package/src/server/responses/core-combo.ts +787 -0
  207. package/src/server/responses/core-errors.ts +170 -0
  208. package/src/server/responses/core-lifetime.ts +95 -0
  209. package/src/server/responses/core-normalize.ts +350 -0
  210. package/src/server/responses/core-opaque-recovery.ts +380 -0
  211. package/src/server/responses/core-options.ts +159 -0
  212. package/src/server/responses/core-replay.ts +298 -0
  213. package/src/server/responses/core.ts +192 -8893
  214. package/src/server/responses/encrypted-payload.ts +0 -1
  215. package/src/server/responses/input-admission.ts +126 -6
  216. package/src/server/responses/passthrough-delivery.ts +869 -0
  217. package/src/server/responses/passthrough-dispatch.ts +1494 -0
  218. package/src/server/responses/passthrough-error.ts +38 -2
  219. package/src/server/responses/passthrough-execution.ts +54 -0
  220. package/src/server/responses/request-prepare.ts +1080 -0
  221. package/src/server/responses/request-send-budget.ts +259 -0
  222. package/src/server/responses/request-sidecar-auth.ts +149 -0
  223. package/src/server/responses/request-spend.ts +147 -0
  224. package/src/server/responses/request-transport.ts +803 -0
  225. package/src/server/responses/response-effects.ts +157 -0
  226. package/src/server/responses/run-turn-execution.ts +476 -0
  227. package/src/server/responses/sidecar-execution.ts +463 -0
  228. package/src/server/responses/terminal-guard.ts +65 -4
  229. package/src/server/responses-image-gen-repair.ts +1 -1
  230. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  231. package/src/server/workflow-refusal.ts +84 -0
  232. package/src/service/windows-ops.ts +210 -16
  233. package/src/service/windows-scheduler.ts +28 -21
  234. package/src/service.ts +1 -1
  235. package/src/types/config.ts +34 -1
  236. package/src/types/request.ts +8 -5
  237. package/src/types/tools.ts +24 -0
  238. package/src/types.ts +2 -0
  239. package/src/update/index.ts +10 -0
  240. package/src/update/stop-contract.d.mts +1 -0
  241. package/src/update/stop-contract.mjs +19 -0
  242. package/src/update/stop-decision.d.mts +1 -1
  243. package/src/update/stop-decision.mjs +12 -3
  244. package/src/usage/log.ts +147 -1
  245. package/src/usage/summary.ts +171 -21
  246. package/src/vision/anthropic-describe.ts +1 -1
  247. package/src/vision/describe.ts +5 -5
  248. package/src/web-search/anthropic-executor.ts +1 -1
  249. package/src/web-search/exa-executor.ts +1 -1
  250. package/src/web-search/executor.ts +1 -1
  251. package/src/web-search/gemini-executor.ts +1 -1
  252. package/src/web-search/loop.ts +1 -1
  253. package/src/web-search/ollama-executor.ts +1 -1
  254. package/src/web-search/parse.ts +67 -14
  255. package/src/web-search/passthrough-bridge.ts +64 -31
  256. package/src/web-search/xai-executor.ts +1 -1
@@ -0,0 +1,507 @@
1
+ import { codexAccountLogLabel } from "../account-label";
2
+ import { getCodexAccountCredential, getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord } from "../account-store";
3
+ import { getAccountQuota, isCodexQuotaExhausted, setAccountQuotaFromParsed, withoutRetiredCodexQuota } from "../quota";
4
+ import type { StoredAccountQuota } from "../quota";
5
+ import { ConfigMutationLockError, mutatePersistedConfig } from "../../config";
6
+ import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle";
7
+ import { isCodexAccountPaused, setCodexAccountPaused } from "../account-pause";
8
+ import { getCodexAccountPriority } from "../account-priority";
9
+ import { clearThreadAccountMapForAccount, isCodexAccountPlanExcluded, reconcileCodexActiveAfterExclusion } from "../routing";
10
+ import { codexPlanValue, isThirtyDayOnlyCodexPlan } from "../plan";
11
+ import { isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state";
12
+ import { getValidMainAccountToken, MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID } from "../main-account";
13
+ import { captureConfigGeneration } from "../../lib/state-store-sweeper";
14
+ import { captureMainAccountIdentityGeneration, getMainAccountCredentialPresence, isMainAccountIdentityGenerationLive } from "../main-account-cache";
15
+ import type { CodexQuotaRefreshOutcome } from "../quota-refresh-outcome";
16
+ import { getMainAccountHardLockStatus } from "../main-account-hard-lock";
17
+ import type { MainAccountHardLockStatus } from "../main-account-hard-lock";
18
+ import { emailMaskingEnabled, projectEmail } from "../../lib/privacy";
19
+ import type { CodexAccount, OcxConfig } from "../../types";
20
+ import { oauthAccountHealthFields, projectCodexAccountHealth } from "../../oauth/health";
21
+ import type { OAuthAccountHealth, OAuthHealthLabel } from "../../oauth/health";
22
+ import { isSelectableCodexPoolAccount } from "../account-id";
23
+ import type { AdmissionLease } from "../../lib/admission";
24
+ import { tryAcquireNativeMainProfileClaim } from "../native-main-admission";
25
+ import { withNativeMainCredentialClaim, isNativeMainClaimUnavailable } from "./http";
26
+ import { fetchMainAccountInfoAttempt, EMPTY_MAIN_ACCOUNT_INFO, mainResetCreditsForCurrentIdentity } from "./main-account-probe";
27
+ import { fetchPoolAccountQuota, PoolQuotaProbeBusyError, POOL_QUOTA_REFRESH_CONCURRENCY } from "./pool-quota-probe";
28
+ import type { PoolQuotaResult } from "./pool-quota-probe";
29
+ import { getRuntimeConfig, configuredPoolAccount, mapWithConcurrency } from "./runtime-config";
30
+
31
+ export function quotaForPlan<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAccountQuota | null>(
32
+ quota: T,
33
+ plan: unknown,
34
+ ): T | null {
35
+ const visible = withoutRetiredCodexQuota(quota);
36
+ if (!visible || !isThirtyDayOnlyCodexPlan(plan)) return visible;
37
+ const quotaWindows = visible;
38
+ return {
39
+ ...(quotaWindows.monthlyPercent !== undefined ? { monthlyPercent: quotaWindows.monthlyPercent } : {}),
40
+ ...(quotaWindows.monthlyResetAt !== undefined ? { monthlyResetAt: quotaWindows.monthlyResetAt } : {}),
41
+ // A 30-day plan can still carry a burst window, and it blocks the account on its own.
42
+ // Dropping it here would show a healthy card for an account upstream is refusing (#1791).
43
+ ...(quotaWindows.shortPercent !== undefined ? { shortPercent: quotaWindows.shortPercent } : {}),
44
+ ...(quotaWindows.shortResetAt !== undefined ? { shortResetAt: quotaWindows.shortResetAt } : {}),
45
+ ...(quotaWindows.shortWindowSeconds !== undefined ? { shortWindowSeconds: quotaWindows.shortWindowSeconds } : {}),
46
+ ...(quotaWindows.customWindows !== undefined ? { customWindows: quotaWindows.customWindows } : {}),
47
+ ...(quotaWindows.resetCredits !== undefined ? { resetCredits: quotaWindows.resetCredits } : {}),
48
+ ...("updatedAt" in quotaWindows ? { updatedAt: quotaWindows.updatedAt } : {}),
49
+ } as T;
50
+ }
51
+
52
+ /**
53
+ * The main account is the only account whose DTO quota comes from the raw WHAM parse
54
+ * result instead of the merged store: `poolAccountDto` serializes what
55
+ * `commitPoolQuotaResponse` read back out of `getAccountQuota()`, while the main DTO
56
+ * spreads `mainInfo.quota` directly. `/wham/usage` carries `rate_limit_reset_credits`
57
+ * only intermittently, and the store exists to bridge that gap
58
+ * (`setAccountQuotaFromParsed` carries an existing `resetCredits` forward when the new
59
+ * snapshot omits it), so the main card lost its ticket badge on every response that
60
+ * happened to omit the summary while pool cards kept theirs.
61
+ *
62
+ * Only `resetCredits` is carried, deliberately, and only from an identity-tagged
63
+ * in-process observation rather than the alias-keyed store. The window fields have
64
+ * *clearing* semantics — a monthly-only snapshot must drop a stale weekly value (#382) —
65
+ * so reinstating the whole stored object would resurrect a window the parse meant to
66
+ * clear whenever the store write was refused by generation gating. A freshly parsed value
67
+ * always wins, including `0`: zero is defined, so it never takes the fill branch.
68
+ */
69
+ export function mainQuotaWithCarriedResetCredits(
70
+ parsed: Omit<StoredAccountQuota, "updatedAt">,
71
+ ): StoredAccountQuota {
72
+ const carried = parsed.resetCredits === undefined
73
+ ? mainResetCreditsForCurrentIdentity()
74
+ : undefined;
75
+ return {
76
+ ...parsed,
77
+ ...(carried !== undefined ? { resetCredits: carried } : {}),
78
+ updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(),
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Why an account needs the operator. `missing_credential`, `refresh_failed`, and
84
+ * `quota_unauthorized` are the three causes this surface tells apart on its own. `unauthorized`
85
+ * and `forbidden` exist because the shared health projection may return them; today
86
+ * `projectCodexAccountHealth` only ever produces `refresh_failed`, so accepting the full union
87
+ * keeps this field correct if that projection widens rather than silently dropping a reason.
88
+ */
89
+ export type CodexAccountReauthReason =
90
+ | "missing_credential"
91
+ | "refresh_failed"
92
+ | "quota_unauthorized"
93
+ | "unauthorized"
94
+ | "forbidden";
95
+
96
+ export function poolAccountDto(
97
+ config: OcxConfig,
98
+ account: CodexAccount,
99
+ quotaResult: PoolQuotaResult,
100
+ hasCredential: boolean,
101
+ paused: boolean,
102
+ priority: number,
103
+ maskEmails: boolean,
104
+ ): CodexAuthAccountDto {
105
+ const plan = codexPlanValue(account.plan);
106
+ const quota = quotaForPlan(quotaResult.quota, plan);
107
+ const runtimeReauth = isAccountNeedsReauth(account.id);
108
+ const needsReauth = !hasCredential || quotaResult.needsReauth || runtimeReauth;
109
+ const health = projectCodexAccountHealth({ accountId: account.id, needsReauth });
110
+ // `needsReauth` is an OR of three independent causes plus a persisted verdict resolved inside the
111
+ // health projection. Emitting only the boolean is what left #4212's reporter guessing which
112
+ // account took their model away and why, so name the cause they actually have to act on.
113
+ const reauthReason: CodexAccountReauthReason | undefined = !hasCredential
114
+ ? "missing_credential"
115
+ : runtimeReauth
116
+ ? "refresh_failed"
117
+ : quotaResult.needsReauth
118
+ ? "quota_unauthorized"
119
+ : health.status === "reauth_required" ? health.reason : undefined;
120
+ return {
121
+ id: account.id,
122
+ email: projectEmail(account.email, maskEmails) ?? account.email,
123
+ ...(account.alias !== undefined ? { alias: account.alias } : {}),
124
+ ...(plan !== undefined ? { plan } : {}),
125
+ logLabel: codexAccountLogLabel(account),
126
+ isMain: false,
127
+ paused,
128
+ priority,
129
+ quota: quota ? { ...quota } : null,
130
+ needsReauth: needsReauth || health.status === "reauth_required",
131
+ ...(reauthReason !== undefined ? { reauthReason } : {}),
132
+ ...(isCodexAccountPlanExcluded(config, account.id) ? {
133
+ selectionExcludedReason: "plan_excluded" as const,
134
+ selectionExcludedPlan: codexPlanValue(config.codexAccounts?.find(row => row.id === account.id)?.plan),
135
+ } : {}),
136
+ hasCredential,
137
+ ...(quotaResult.quotaProbeSkipped ? { quotaProbeSkipped: true as const } : {}),
138
+ ...oauthAccountHealthFields("codex", account.id, health),
139
+ };
140
+ }
141
+
142
+ export interface CodexAuthAccountDto {
143
+ id: string;
144
+ alias?: string;
145
+ email: string;
146
+ plan?: string | null;
147
+ logLabel?: string;
148
+ isMain: boolean;
149
+ paused: boolean;
150
+ /** Selection order; higher is used earlier. Always present, 0 when unset. */
151
+ priority: number;
152
+ quota: (StoredAccountQuota | (Omit<StoredAccountQuota, "updatedAt"> & { updatedAt: number })) | null;
153
+ needsReauth?: boolean;
154
+ /**
155
+ * Which of the independent causes behind `needsReauth` fired. Present only when the account
156
+ * needs the operator; `/api/oauth/accounts` already carries the same field name.
157
+ */
158
+ reauthReason?: CodexAccountReauthReason;
159
+ /** Automatic selection policy only; explicit routes retain their usual auth checks. */
160
+ selectionExcludedReason?: "plan_excluded";
161
+ selectionExcludedPlan?: string;
162
+ hasCredential: boolean;
163
+ health: OAuthAccountHealth;
164
+ healthLabel: OAuthHealthLabel;
165
+ healthSummary: string;
166
+ healthAction?: string;
167
+ quotaProbeSkipped?: true;
168
+ quotaRefresh?: CodexQuotaRefreshOutcome;
169
+ mainAccountHardLock?: MainAccountHardLockStatus;
170
+ }
171
+
172
+ export interface FreshPoolPlanUpdate {
173
+ accountId: string;
174
+ plan: string;
175
+ credentialGeneration: number;
176
+ }
177
+
178
+ /**
179
+ * Persist only validated plan leaves against the latest disk snapshot. A quota GET must not save
180
+ * the long-lived runtime object wholesale: unrelated manual/provider writes may have landed while
181
+ * WHAM requests were in flight. Missing or malformed files fail closed: a read path must not
182
+ * recreate a deleted config from the server's older in-memory snapshot.
183
+ */
184
+ export function reconcileFreshPoolAccountPlans(runtimeConfig: OcxConfig, updates: FreshPoolPlanUpdate[]): void {
185
+ if (updates.length === 0) return;
186
+ let outcome: ReturnType<typeof mutatePersistedConfig<FreshPoolPlanUpdate[]>>;
187
+ try {
188
+ outcome = mutatePersistedConfig(persistedConfig => {
189
+ const accepted: FreshPoolPlanUpdate[] = [];
190
+ let changed = false;
191
+ for (const update of updates) {
192
+ if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue;
193
+ const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId);
194
+ const persistedAccount = configuredPoolAccount(persistedConfig, update.accountId);
195
+ if (!liveAccount || !persistedAccount) continue;
196
+ accepted.push(update);
197
+ if (persistedAccount.plan !== update.plan) {
198
+ persistedAccount.plan = update.plan;
199
+ // WHAM is the authoritative plan source: stamp provenance so a later JWT
200
+ // reconcile cannot overwrite this observation within the same credential
201
+ // generation (src/codex/plan-from-token.ts jwtMayWritePlan). Stamped only
202
+ // alongside a real plan change: a steady-state refresh whose plan is
203
+ // unchanged must stay write-free (no-config-write contract), and an
204
+ // unchanged value needs no fence — a JWT rewrite to the same text is a
205
+ // no-op under the caller's own equality check.
206
+ persistedAccount.planSource = "wham";
207
+ persistedAccount.planCredentialGeneration = update.credentialGeneration;
208
+ changed = true;
209
+ }
210
+ }
211
+ return { changed, value: accepted };
212
+ });
213
+ } catch (error) {
214
+ // Plan persistence is derived metadata on a read route. Contention must fail closed without
215
+ // turning account listing into a 500; a later refresh can retry against the latest files.
216
+ if (error instanceof ConfigMutationLockError) return;
217
+ throw error;
218
+ }
219
+ if (outcome.status === "unavailable") return;
220
+ for (const update of outcome.value) {
221
+ // A replacement immediately after the durable commit is allowed to supersede the result, but
222
+ // the long-lived object must never be updated from that stale generation.
223
+ if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue;
224
+ const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId);
225
+ if (liveAccount) {
226
+ liveAccount.plan = update.plan;
227
+ liveAccount.planSource = "wham";
228
+ liveAccount.planCredentialGeneration = update.credentialGeneration;
229
+ }
230
+ }
231
+ }
232
+
233
+ export interface CodexAuthAccountsSnapshot {
234
+ accounts: CodexAuthAccountDto[];
235
+ mainIdentityGeneration: number;
236
+ }
237
+
238
+ export async function listCodexAuthAccountsSnapshot(
239
+ config: OcxConfig,
240
+ forceRefresh = false,
241
+ options: { validatePending?: boolean } = {},
242
+ ): Promise<CodexAuthAccountsSnapshot> {
243
+ const runtimeConfig = getRuntimeConfig(config);
244
+ const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
245
+ // One redaction decision for the whole snapshot, read once from the operator's config (#3859).
246
+ const maskEmails = emailMaskingEnabled(runtimeConfig);
247
+ const mainResult = await fetchMainAccountInfoAttempt(forceRefresh, 1);
248
+ const refreshedPool = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
249
+ const cred = getCodexAccountCredential(account.id);
250
+ let quotaResult: PoolQuotaResult;
251
+ if (!cred) {
252
+ quotaResult = { quota: null, needsReauth: true };
253
+ } else {
254
+ try {
255
+ quotaResult = await fetchPoolAccountQuota(account.id, forceRefresh, account.plan, getValidCodexToken, options.validatePending === true);
256
+ } catch (error) {
257
+ if (!(error instanceof PoolQuotaProbeBusyError)) throw error;
258
+ quotaResult = {
259
+ quota: getAccountQuota(account.id),
260
+ needsReauth: false,
261
+ credentialGeneration: readCodexAccountRecord(account.id)?.generation,
262
+ quotaProbeSkipped: true,
263
+ };
264
+ }
265
+ }
266
+ return { accountId: account.id, quotaResult };
267
+ });
268
+
269
+ // WHAM plan_type is authoritative only for the credential generation that fetched it. Collect
270
+ // changes after every parallel read settles, then apply one narrow disk patch for the batch.
271
+ const planUpdates = refreshedPool.flatMap(({ accountId, quotaResult }): FreshPoolPlanUpdate[] => {
272
+ const plan = quotaResult.freshPlan;
273
+ const credentialGeneration = quotaResult.freshCredentialGeneration;
274
+ return plan && credentialGeneration !== undefined
275
+ ? [{ accountId, plan, credentialGeneration }]
276
+ : [];
277
+ });
278
+ reconcileFreshPoolAccountPlans(runtimeConfig, planUpdates);
279
+
280
+ const withQuota = refreshedPool.flatMap(({ accountId, quotaResult }) => {
281
+ const currentAccount = configuredPoolAccount(runtimeConfig, accountId);
282
+ if (!currentAccount) return [];
283
+ const currentCredential = getCodexAccountCredential(accountId);
284
+ if (!currentCredential) {
285
+ return [poolAccountDto(
286
+ runtimeConfig,
287
+ currentAccount,
288
+ { quota: null, needsReauth: true },
289
+ false,
290
+ isCodexAccountPaused(runtimeConfig, accountId),
291
+ getCodexAccountPriority(runtimeConfig, accountId),
292
+ maskEmails,
293
+ )];
294
+ }
295
+ const resultGeneration = quotaResult.credentialGeneration ?? quotaResult.freshCredentialGeneration;
296
+ const generationLive = resultGeneration === undefined
297
+ || isCodexAccountGenerationLive(accountId, resultGeneration);
298
+ const effectiveQuotaResult = !generationLive
299
+ ? { quota: null, needsReauth: false }
300
+ : quotaResult;
301
+ // Response DTO can show the WHAM plan even when disk persistence fails closed (lock busy /
302
+ // missing config). Persistence still remains generation-gated via reconcileFreshPoolAccountPlans.
303
+ const dtoAccount = generationLive && quotaResult.freshPlan
304
+ ? { ...currentAccount, plan: quotaResult.freshPlan }
305
+ : currentAccount;
306
+ return [poolAccountDto(
307
+ runtimeConfig,
308
+ dtoAccount,
309
+ effectiveQuotaResult,
310
+ true,
311
+ isCodexAccountPaused(runtimeConfig, accountId),
312
+ getCodexAccountPriority(runtimeConfig, accountId),
313
+ maskEmails,
314
+ )];
315
+ });
316
+ const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration();
317
+ const mainSnapshotLive = isMainAccountIdentityGenerationLive(fetchedMainGeneration);
318
+ const mainInfo = mainSnapshotLive ? mainResult.info : EMPTY_MAIN_ACCOUNT_INFO;
319
+ const hasMainCredential = mainSnapshotLive && mainResult.credentialChecked
320
+ ? mainResult.hasCredential
321
+ : getMainAccountCredentialPresence() ?? false;
322
+ const mainMissingCredential = mainSnapshotLive && mainResult.credentialChecked && !hasMainCredential;
323
+ const mainNeedsReauth = mainMissingCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
324
+ const mainHealth = projectCodexAccountHealth({
325
+ accountId: MAIN_CODEX_ACCOUNT_ID,
326
+ needsReauth: mainNeedsReauth,
327
+ });
328
+ // The main row carries the same attribution as a pool row. Reaching this point without
329
+ // `mainMissingCredential` means the runtime reauth flag is what set `mainNeedsReauth`, so the
330
+ // cause is a refresh that did not complete.
331
+ const mainReauthReason: CodexAccountReauthReason | undefined = mainMissingCredential
332
+ ? "missing_credential"
333
+ : mainNeedsReauth
334
+ ? "refresh_failed"
335
+ : mainHealth.status === "reauth_required" ? mainHealth.reason : undefined;
336
+ const main: CodexAuthAccountDto = {
337
+ id: MAIN_CODEX_ACCOUNT_ID,
338
+ email: projectEmail(mainInfo.email, maskEmails) ?? "Codex App login",
339
+ plan: mainInfo.plan,
340
+ ...(mainSnapshotLive && mainResult.quotaRefresh && mainResult.quotaRefreshGeneration !== undefined
341
+ && isMainAccountIdentityGenerationLive(mainResult.quotaRefreshGeneration)
342
+ ? { quotaRefresh: mainResult.quotaRefresh } : {}),
343
+ logLabel: "main",
344
+ isMain: true,
345
+ paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
346
+ mainAccountHardLock: getMainAccountHardLockStatus(runtimeConfig),
347
+ priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
348
+ hasCredential: hasMainCredential,
349
+ needsReauth: mainNeedsReauth,
350
+ ...(mainReauthReason !== undefined ? { reauthReason: mainReauthReason } : {}),
351
+ quota: mainInfo.quota
352
+ ? quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan)
353
+ : null,
354
+ ...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
355
+ };
356
+ return {
357
+ accounts: [main, ...withQuota],
358
+ mainIdentityGeneration: mainSnapshotLive
359
+ ? fetchedMainGeneration
360
+ : captureMainAccountIdentityGeneration(),
361
+ };
362
+ }
363
+
364
+ /** One opted-in account's metadata; reuse the bounded WHAM 401 recovery and generation fence. */
365
+ export async function refreshCodexQuotaForActivation(config: OcxConfig, accountId: string): Promise<void> {
366
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) {
367
+ const lease = tryAcquireNativeMainProfileClaim();
368
+ if (!lease) return;
369
+ try {
370
+ reconcileMainCodexAccountRuntimeState();
371
+ if (isAccountNeedsReauth(accountId)) return;
372
+ const identityGeneration = captureMainAccountIdentityGeneration();
373
+ const writerGeneration = captureConfigGeneration();
374
+ try {
375
+ // Refresh may need an exclusive claim; prepare before WHAM takes its shared claim.
376
+ if (!await getValidMainAccountToken({ preserveReauth: true })) return;
377
+ } catch (error) {
378
+ if (error instanceof MainAccountTokenRefreshError && error.reason === "reauth"
379
+ && isMainAccountIdentityGenerationLive(identityGeneration)) {
380
+ markAccountNeedsReauth(accountId, writerGeneration);
381
+ }
382
+ return;
383
+ }
384
+ if (isAccountNeedsReauth(accountId)) return;
385
+ await fetchMainAccountInfoAttempt(true, 1, lease, false, false);
386
+ } finally {
387
+ lease.release();
388
+ }
389
+ return;
390
+ }
391
+ const account = configuredPoolAccount(config, accountId);
392
+ if (!account) return;
393
+ const writerGeneration = captureConfigGeneration();
394
+ const result = await fetchPoolAccountQuota(accountId, true, account.plan);
395
+ if (result.needsReauth && result.credentialGeneration !== undefined) {
396
+ markAccountNeedsReauth(accountId, writerGeneration, result.credentialGeneration);
397
+ }
398
+ }
399
+
400
+ export async function listCodexAuthAccounts(
401
+ config: OcxConfig,
402
+ forceRefresh = false,
403
+ options: { validatePending?: boolean } = {},
404
+ ): Promise<CodexAuthAccountDto[]> {
405
+ return (await listCodexAuthAccountsSnapshot(config, forceRefresh, options)).accounts;
406
+ }
407
+
408
+ export interface PauseExhaustedResult {
409
+ pausedAccountIds: string[];
410
+ checkedAccountCount: number;
411
+ failedAccountCount: number;
412
+ }
413
+
414
+ export function selectFallbackAfterPause(config: OcxConfig, pausedActiveId: string): void {
415
+ reconcileCodexActiveAfterExclusion(config, pausedActiveId);
416
+ }
417
+
418
+ export async function pauseExhaustedCodexAccounts(
419
+ config: OcxConfig,
420
+ persistPausedAccounts: () => void,
421
+ ): Promise<PauseExhaustedResult> {
422
+ const poolAccounts = (config.codexAccounts ?? []).filter(account => !account.isMain);
423
+ const nativeMainLease = tryAcquireNativeMainProfileClaim();
424
+ try {
425
+ const performPause = async (mainLease?: AdmissionLease): Promise<PauseExhaustedResult> => {
426
+ const mainWork = async (): Promise<{
427
+ shouldPause: boolean;
428
+ checkedAccountCount: number;
429
+ failedAccountCount: number;
430
+ }> => {
431
+ if (!mainLease) return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 1 };
432
+ const mainResult = await fetchMainAccountInfoAttempt(true, 1, mainLease, true);
433
+ if (!mainResult.credentialChecked || !mainResult.hasCredential) {
434
+ return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 0 };
435
+ }
436
+ if (!mainResult.freshQuota || !mainResult.info.plan) {
437
+ return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 1 };
438
+ }
439
+ return {
440
+ shouldPause: !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
441
+ && isCodexQuotaExhausted(mainResult.freshQuota, mainResult.info.plan),
442
+ checkedAccountCount: 1,
443
+ failedAccountCount: 0,
444
+ };
445
+ };
446
+ const [mainResult, poolResults] = await Promise.all([
447
+ mainWork(),
448
+ mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
449
+ if (!getCodexAccountCredential(account.id)) return { account, quotaResult: null };
450
+ try {
451
+ return {
452
+ account,
453
+ quotaResult: await fetchPoolAccountQuota(account.id, true, account.plan),
454
+ };
455
+ } catch {
456
+ // Settle each pool probe independently so a busy/failing account cannot
457
+ // abandon an already-confirmed main decision before atomic publication.
458
+ return { account, quotaResult: null };
459
+ }
460
+ }),
461
+ ]);
462
+
463
+ let checkedAccountCount = mainResult.checkedAccountCount;
464
+ let failedAccountCount = mainResult.failedAccountCount;
465
+ const exhaustedIds: string[] = mainResult.shouldPause ? [MAIN_CODEX_ACCOUNT_ID] : [];
466
+ for (const { account, quotaResult } of poolResults) {
467
+ const currentAccount = (config.codexAccounts ?? []).find(candidate => candidate.id === account.id && !candidate.isMain);
468
+ if (!currentAccount) continue;
469
+ const generation = quotaResult?.freshCredentialGeneration;
470
+ const plan = quotaResult?.freshPlan ?? currentAccount.plan;
471
+ if (!quotaResult?.freshQuota || generation === undefined || !isCodexAccountGenerationLive(account.id, generation) || !plan) {
472
+ failedAccountCount += 1;
473
+ continue;
474
+ }
475
+ checkedAccountCount += 1;
476
+ if (!isCodexAccountPaused(config, account.id) && isCodexQuotaExhausted(quotaResult.freshQuota, plan)) {
477
+ exhaustedIds.push(account.id);
478
+ }
479
+ }
480
+
481
+ for (const id of exhaustedIds) {
482
+ setCodexAccountPaused(config, id, true);
483
+ clearThreadAccountMapForAccount(id);
484
+ }
485
+ for (const id of exhaustedIds) selectFallbackAfterPause(config, id);
486
+ const result = {
487
+ pausedAccountIds: exhaustedIds,
488
+ checkedAccountCount,
489
+ failedAccountCount,
490
+ };
491
+ // Persist while both the in-process admission and cross-process shared
492
+ // claim still own the physical-main identity used for the decision.
493
+ if (result.pausedAccountIds.length > 0) persistPausedAccounts();
494
+ return result;
495
+ };
496
+
497
+ if (!nativeMainLease) return await performPause();
498
+ try {
499
+ return await withNativeMainCredentialClaim(() => performPause(nativeMainLease));
500
+ } catch (error) {
501
+ if (isNativeMainClaimUnavailable(error)) return await performPause();
502
+ throw error;
503
+ }
504
+ } finally {
505
+ nativeMainLease?.release();
506
+ }
507
+ }
@@ -0,0 +1,32 @@
1
+ import { withNativeMainSharedClaim } from "../native-main-claim";
2
+ import { resolveNativeProfileContext } from "../native-profile-store";
3
+ import { NativeProfileError } from "../native-profile-types";
4
+
5
+ export function isNativeMainClaimUnavailable(error: unknown): error is NativeProfileError {
6
+ return error instanceof NativeProfileError
7
+ && (error.code === "NATIVE_MAIN_CLAIM_BUSY" || error.code === "NATIVE_MAIN_CLAIM_UNAVAILABLE");
8
+ }
9
+
10
+ export function withNativeMainCredentialClaim<T>(operation: () => Promise<T>): Promise<T> {
11
+ return withNativeMainSharedClaim(resolveNativeProfileContext(), operation);
12
+ }
13
+
14
+ export function jsonResponse(data: unknown, status = 200): Response {
15
+ return new Response(JSON.stringify(data), {
16
+ status,
17
+ headers: { "Content-Type": "application/json" },
18
+ });
19
+ }
20
+
21
+ export function nativeMainProfileBusyResponse(): Response {
22
+ const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503);
23
+ response.headers.set("Retry-After", "1");
24
+ return response;
25
+ }
26
+
27
+ export function manualImportDisabledResponse(): Response {
28
+ return jsonResponse({
29
+ error: "Manual Codex account import is disabled. Use OAuth login to add a pool account.",
30
+ code: "manual_import_disabled",
31
+ }, 403);
32
+ }