@bitkyc08/opencodex 2.88.0 → 2.90.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 (291) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-DgxkE6Md.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-DbTNc_88.js} +1 -1
  6. package/gui/dist/assets/{index-B5bsvxor.js → index-TmaHa54u.js} +15 -15
  7. package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +2 -2
  10. package/src/adapters/anthropic/cache-retention.ts +117 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -4
  12. package/src/adapters/anthropic.ts +10 -3
  13. package/src/adapters/base.ts +4 -0
  14. package/src/adapters/claude-cli/adapter.ts +13 -7
  15. package/src/adapters/claude-cli/usage-admission.ts +14 -2
  16. package/src/adapters/coding-agent/protocol.ts +6 -0
  17. package/src/adapters/coding-agent/turn.ts +14 -4
  18. package/src/adapters/cursor/protobuf-request.ts +3 -0
  19. package/src/adapters/google-http.ts +3 -2
  20. package/src/adapters/google-progress-history.ts +19 -1
  21. package/src/adapters/google-vertex-endpoint.ts +67 -0
  22. package/src/adapters/google.ts +62 -37
  23. package/src/adapters/image.ts +5 -3
  24. package/src/adapters/input-media-guard.ts +5 -2
  25. package/src/adapters/kiro-refusal.ts +41 -1
  26. package/src/adapters/ollama-native.ts +1 -0
  27. package/src/adapters/openai-chat/messages.ts +34 -4
  28. package/src/adapters/openai-chat/passthrough.ts +37 -4
  29. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  30. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  31. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  32. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  33. package/src/adapters/openai-chat.ts +7 -6
  34. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  35. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  36. package/src/adapters/openai-responses/passthrough.ts +22 -13
  37. package/src/adapters/openai-responses/reasoning.ts +68 -1
  38. package/src/adapters/openai-responses/transport.ts +13 -1
  39. package/src/adapters/registry.ts +1 -1
  40. package/src/adapters/upstream-http-error.ts +3 -2
  41. package/src/adapters/zed.ts +7 -4
  42. package/src/bridge/internal.ts +11 -1
  43. package/src/bridge/response-json.ts +2 -2
  44. package/src/bridge/sse.ts +10 -4
  45. package/src/claude/desktop-3p-library.ts +43 -3
  46. package/src/claude/desktop-3p.ts +20 -10
  47. package/src/claude/desktop-picker-profile.ts +18 -9
  48. package/src/claude/desktop-remote-store-io.ts +2 -2
  49. package/src/claude/inbound.ts +12 -26
  50. package/src/claude/outbound.ts +5 -0
  51. package/src/cli/account-api.ts +13 -1
  52. package/src/cli/account-extended.ts +17 -5
  53. package/src/cli/account-key-quota.ts +4 -1
  54. package/src/cli/account.ts +1 -1
  55. package/src/cli/capabilities-agents-routing.ts +45 -8
  56. package/src/cli/capabilities-base.ts +5 -5
  57. package/src/cli/capabilities-provider-models.ts +5 -4
  58. package/src/cli/combo-result.ts +8 -2
  59. package/src/cli/combo.ts +17 -0
  60. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  61. package/src/cli/help.ts +1 -1
  62. package/src/cli/message-command.ts +4 -1
  63. package/src/cli/message-remote-args.ts +69 -0
  64. package/src/cli/message-remote-command.ts +77 -0
  65. package/src/cli/provider-runtime.ts +69 -1
  66. package/src/cli/registry.ts +3 -3
  67. package/src/codex/account-lifecycle.ts +19 -6
  68. package/src/codex/account-pause.ts +6 -1
  69. package/src/codex/account-runtime-state.ts +5 -0
  70. package/src/codex/account-store.ts +55 -9
  71. package/src/codex/account-usability.ts +4 -1
  72. package/src/codex/auth-api/account-list.ts +13 -5
  73. package/src/codex/auth-api/account-pause-group.ts +2 -22
  74. package/src/codex/auth-api/login-collision.ts +59 -0
  75. package/src/codex/auth-api/login-flow.ts +12 -7
  76. package/src/codex/auth-api/main-account-probe.ts +3 -2
  77. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  78. package/src/codex/auth-collision.ts +31 -4
  79. package/src/codex/auth-context.ts +57 -10
  80. package/src/codex/catalog/aggregation.ts +15 -0
  81. package/src/codex/catalog/auto-review.ts +2 -1
  82. package/src/codex/catalog/build-entries.ts +17 -7
  83. package/src/codex/catalog/derive-entry.ts +3 -1
  84. package/src/codex/catalog/metadata.ts +2 -0
  85. package/src/codex/catalog/model-visibility.ts +0 -2
  86. package/src/codex/catalog/ownership.ts +71 -0
  87. package/src/codex/catalog/parsing.ts +12 -3
  88. package/src/codex/catalog/retained-sync.ts +2 -1
  89. package/src/codex/catalog/routed-removal.ts +4 -4
  90. package/src/codex/cli-update-plan.ts +18 -3
  91. package/src/codex/collision-identity.ts +78 -0
  92. package/src/codex/convergence.ts +2 -1
  93. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  94. package/src/codex/low-quota-protection.ts +57 -31
  95. package/src/codex/main-account-cache.ts +77 -3
  96. package/src/codex/main-account-hard-lock.ts +14 -0
  97. package/src/codex/main-account.ts +6 -2
  98. package/src/codex/native-residue.ts +2 -3
  99. package/src/codex/quota.ts +26 -2
  100. package/src/codex/routing/cooldown-math.ts +5 -0
  101. package/src/codex/routing/failure-window.ts +61 -9
  102. package/src/codex/routing.ts +4 -3
  103. package/src/codex/shim-diagnostics.ts +2 -1
  104. package/src/codex/shim-fingerprint.ts +11 -2
  105. package/src/codex/shim-templates.ts +16 -5
  106. package/src/codex/shim-windows-refresh.ts +224 -0
  107. package/src/codex/shim.ts +45 -21
  108. package/src/combos/failover.ts +10 -2
  109. package/src/combos/jev-model-backend.ts +30 -7
  110. package/src/combos/jev-model-request.ts +20 -0
  111. package/src/combos/jev-quota-activation.ts +153 -0
  112. package/src/combos/jev-quota-config.ts +62 -0
  113. package/src/combos/jev-quota-route.ts +26 -0
  114. package/src/combos/jev-quota.ts +88 -0
  115. package/src/combos/jev.ts +25 -12
  116. package/src/combos/types.ts +27 -0
  117. package/src/compatibility/index.ts +3 -0
  118. package/src/compatibility/openai-responses.ts +8 -1
  119. package/src/compatibility/opengateway-responses.ts +61 -0
  120. package/src/config/account-auto-pause-revisions.ts +136 -0
  121. package/src/config/diagnostics.ts +13 -0
  122. package/src/config/live-reconcile.ts +9 -0
  123. package/src/config/persist-unlocked.ts +10 -1
  124. package/src/config/provider-validation.ts +7 -2
  125. package/src/config/removed-opencode-free.ts +135 -0
  126. package/src/config/schema/account-auto-pause.ts +14 -0
  127. package/src/config/schema/config-schema.ts +15 -0
  128. package/src/config/schema/leaf-validators.ts +7 -0
  129. package/src/config.ts +10 -3
  130. package/src/generated/compatibility-version.json +476 -216
  131. package/src/generated/model-metadata.ts +1 -1
  132. package/src/images/loop.ts +34 -11
  133. package/src/lib/account-auto-pause-response.ts +16 -0
  134. package/src/lib/bun-runtime.ts +18 -1
  135. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  136. package/src/lib/bun-transpiler-cache-scope.mjs +82 -0
  137. package/src/lib/decision-quota-sink.ts +76 -0
  138. package/src/lib/provider-admission-wait.ts +14 -0
  139. package/src/lib/service-secrets.ts +37 -2
  140. package/src/lib/state-store-registrations.ts +25 -0
  141. package/src/lib/translator-budget.ts +1 -1
  142. package/src/lib/upstream-retry.ts +15 -0
  143. package/src/link/ssh-argv.ts +3 -0
  144. package/src/messaging/envelope.ts +6 -3
  145. package/src/messaging/remote-auth.ts +83 -0
  146. package/src/messaging/remote-bridge.ts +270 -0
  147. package/src/messaging/remote-contract.ts +108 -0
  148. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  149. package/src/messaging/remote-enrollment.ts +244 -0
  150. package/src/messaging/remote-envelope.ts +40 -0
  151. package/src/messaging/remote-files.ts +117 -0
  152. package/src/messaging/remote-owner.ts +169 -0
  153. package/src/messaging/remote-ports.ts +56 -0
  154. package/src/messaging/remote-process.ts +131 -0
  155. package/src/messaging/remote-replies.ts +98 -0
  156. package/src/messaging/remote-rpc-admission.ts +32 -0
  157. package/src/messaging/remote-send.ts +72 -0
  158. package/src/messaging/remote-store.ts +111 -0
  159. package/src/messaging/remote-tunnels.ts +65 -0
  160. package/src/messaging/rpc.ts +13 -1
  161. package/src/oauth/account-auto-pause-policy.ts +66 -0
  162. package/src/oauth/account-auto-pause.ts +171 -0
  163. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  164. package/src/oauth/account-import/parser.ts +15 -2
  165. package/src/oauth/account-import/types.ts +7 -0
  166. package/src/oauth/account-quota-rank.ts +2 -1
  167. package/src/oauth/anthropic-account-refusal.ts +6 -0
  168. package/src/oauth/anthropic-model-family.ts +8 -0
  169. package/src/oauth/anthropic-model-quota.ts +2 -6
  170. package/src/oauth/api-key-pool-preserve.ts +37 -0
  171. package/src/oauth/chatgpt-shared.ts +10 -1
  172. package/src/oauth/dsh.ts +45 -2
  173. package/src/oauth/index.ts +5 -35
  174. package/src/oauth/kiro.ts +44 -1
  175. package/src/oauth/login-flow-state.ts +11 -2
  176. package/src/oauth/store.ts +35 -8
  177. package/src/oauth/types.ts +2 -0
  178. package/src/plugins/upstream-hooks.ts +14 -1
  179. package/src/providers/api-key-pause.ts +32 -0
  180. package/src/providers/api-key-resolve.ts +10 -4
  181. package/src/providers/api-key-selection.ts +6 -1
  182. package/src/providers/api-keys.ts +8 -4
  183. package/src/providers/claude-cli-usage.ts +25 -5
  184. package/src/providers/initial-model-selection.ts +2 -2
  185. package/src/providers/key-failover.ts +21 -13
  186. package/src/providers/key-store.ts +8 -3
  187. package/src/providers/mistral-transport.ts +207 -0
  188. package/src/providers/model-rename-fields.ts +1 -0
  189. package/src/providers/openai-sidecar.ts +5 -0
  190. package/src/providers/opencode-zen-rate-limit.ts +6 -72
  191. package/src/providers/quota/account-cache.ts +15 -1
  192. package/src/providers/quota/anthropic-account-quota.ts +20 -7
  193. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  194. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  195. package/src/providers/quota/antigravity.ts +45 -8
  196. package/src/providers/quota/failure-codes.ts +18 -0
  197. package/src/providers/quota/report-cache.ts +14 -0
  198. package/src/providers/quota/vendor-probes-oauth.ts +15 -6
  199. package/src/providers/quota-decision-publication.ts +77 -0
  200. package/src/providers/quota-decision-snapshot.ts +145 -0
  201. package/src/providers/quota-routing-cache.ts +4 -1
  202. package/src/providers/quota-types.ts +6 -1
  203. package/src/providers/quota.ts +8 -3
  204. package/src/providers/registry/entries-extended.ts +14 -53
  205. package/src/providers/registry/model-seeds.ts +1 -1
  206. package/src/providers/registry.ts +1 -2
  207. package/src/providers/routed-config.ts +1 -1
  208. package/src/providers/stale-vision-classification-migration.ts +1 -1
  209. package/src/providers/xai-transport.ts +2 -1
  210. package/src/responses/audio.ts +46 -0
  211. package/src/responses/input-media.ts +37 -15
  212. package/src/responses/parser-content.ts +22 -27
  213. package/src/responses/parser.ts +18 -10
  214. package/src/responses/reasoning-retention.ts +218 -11
  215. package/src/responses/schema.ts +3 -4
  216. package/src/responses/web-search-replay.ts +147 -0
  217. package/src/routing/compatibility/behavior.ts +4 -1
  218. package/src/server/auth-cors.ts +7 -0
  219. package/src/server/background-lifecycle.ts +2 -1
  220. package/src/server/chat-native.ts +36 -20
  221. package/src/server/claude-messages.ts +2 -1
  222. package/src/server/index/optional-listeners.ts +6 -0
  223. package/src/server/index/serve-options.ts +8 -2
  224. package/src/server/index/websocket-handler.ts +10 -0
  225. package/src/server/lifecycle.ts +17 -1
  226. package/src/server/management/combo-routes.ts +17 -2
  227. package/src/server/management/oauth-account-routes.ts +12 -2
  228. package/src/server/management/provider-completion-test.ts +289 -0
  229. package/src/server/management/provider-routes.ts +9 -1
  230. package/src/server/management/route-registry.ts +2 -0
  231. package/src/server/messages-native.ts +29 -4
  232. package/src/server/messages-shared.ts +4 -1
  233. package/src/server/request-log.ts +23 -9
  234. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  235. package/src/server/responses/adapter-continuation.ts +2 -2
  236. package/src/server/responses/adapter-dispatch.ts +79 -30
  237. package/src/server/responses/codex-ws-exchange.ts +3 -0
  238. package/src/server/responses/codex-ws-metadata.ts +44 -0
  239. package/src/server/responses/codex-ws-session.ts +21 -1
  240. package/src/server/responses/combo-session-recall.ts +25 -0
  241. package/src/server/responses/combo-stream-preflight.ts +10 -3
  242. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  243. package/src/server/responses/compact.ts +10 -5
  244. package/src/server/responses/context-overflow.ts +141 -2
  245. package/src/server/responses/core-auth.ts +20 -0
  246. package/src/server/responses/core-codex-account.ts +28 -15
  247. package/src/server/responses/core-combo.ts +34 -5
  248. package/src/server/responses/core-opaque-recovery.ts +20 -11
  249. package/src/server/responses/core.ts +12 -9
  250. package/src/server/responses/deepseek-image-files.ts +121 -0
  251. package/src/server/responses/empty-completion-guard.ts +10 -0
  252. package/src/server/responses/fetch-helpers.ts +65 -14
  253. package/src/server/responses/input-admission.ts +6 -2
  254. package/src/server/responses/jev-model-invoke.ts +13 -14
  255. package/src/server/responses/main-hard-lock-routing.ts +33 -8
  256. package/src/server/responses/native-steering-log.ts +2 -2
  257. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  258. package/src/server/responses/passthrough-completion.ts +41 -0
  259. package/src/server/responses/passthrough-delivery.ts +5 -2
  260. package/src/server/responses/passthrough-dispatch.ts +42 -45
  261. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  262. package/src/server/responses/passthrough-observations.ts +38 -0
  263. package/src/server/responses/reasoning-archive-error.ts +21 -0
  264. package/src/server/responses/request-compression.ts +98 -0
  265. package/src/server/responses/request-prepare.ts +11 -1
  266. package/src/server/responses/request-transport.ts +92 -5
  267. package/src/server/responses/run-turn-execution.ts +2 -0
  268. package/src/server/responses/sidecar-execution.ts +16 -3
  269. package/src/server/responses/terminal-guard.ts +2 -0
  270. package/src/server/startup-action-control.ts +2 -2
  271. package/src/server/startup-health-cache.ts +2 -2
  272. package/src/server/windows-tray-control.ts +2 -2
  273. package/src/service/definition.ts +9 -1
  274. package/src/service/orchestration.ts +17 -0
  275. package/src/types/account-auto-pause.ts +15 -0
  276. package/src/types/config.ts +23 -1
  277. package/src/types/provider.ts +11 -5
  278. package/src/types/request.ts +36 -3
  279. package/src/types.ts +2 -0
  280. package/src/usage/cost.ts +17 -1
  281. package/src/usage/expected-prices.ts +3 -1
  282. package/src/usage/jev-stats.ts +4 -0
  283. package/src/usage/log.ts +2 -0
  284. package/src/usage/summary.ts +7 -0
  285. package/src/web-search/loop.ts +39 -13
  286. package/src/web-search/progress-stream.ts +4 -1
  287. package/src/web-search/run-turn-loop.ts +9 -3
  288. package/src/web-search/sidecar-search-replay.ts +96 -0
  289. package/gui/dist/assets/App-CHC89EXQ.js +0 -58
  290. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
  291. package/gui/dist/assets/quota-summary-D8y2wTsB.js +0 -1
@@ -9,6 +9,7 @@ import { hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount } from "./a
9
9
  import type { OcxConfig } from "../types";
10
10
  import { isNativeMainTrafficBlocked } from "./native-profile-startup";
11
11
  import { isMainAccountHardLocked } from "./main-account-hard-lock";
12
+ import { configuredCodexMainDuplicate } from "./collision-identity";
12
13
 
13
14
  export interface CodexAccountUsabilityOptions {
14
15
  /** Route using cached runtime state only; the caller must reject selected main before auth. */
@@ -64,7 +65,8 @@ export type CodexAccountUnusableReason =
64
65
  | "not_in_pool"
65
66
  | "missing_credential"
66
67
  | "deleted"
67
- | "validation_pending";
68
+ | "validation_pending"
69
+ | "duplicate_main";
68
70
 
69
71
  /**
70
72
  * The single source of truth for both selection and its explanation. `isCodexAccountUsable()` is
@@ -110,6 +112,7 @@ export function codexAccountUnusableReason(
110
112
  if (!record?.credential) return "missing_credential";
111
113
  if (record.deletedAt != null) return "deleted";
112
114
  if (record.codexValidationPending) return "validation_pending";
115
+ if (configuredCodexMainDuplicate(config, accountId)) return "duplicate_main";
113
116
  return undefined;
114
117
  }
115
118
 
@@ -1,7 +1,6 @@
1
1
  import { getMainAccountExternalUsageWarning, type MainAccountExternalUsageWarning } from "../main-account-external-usage";
2
2
  import { codexCreditsDtoField, hasCodexCreditsObservation, pruneCodexCredits } from "../credits";
3
3
  import type { CodexCredits } from "../credits";
4
- import { getMainChatgptAccountId } from "../auth-collision";
5
4
  import { codexAccountLogLabel } from "../account-label";
6
5
  import { poolQuotaHistoryIdentity, getCodexAccountCredential, getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord } from "../account-store";
7
6
  import { getAccountQuota, isCodexQuotaExhausted, setAccountQuotaFromParsed, withoutRetiredCodexQuota } from "../quota";
@@ -35,6 +34,7 @@ import { fetchPoolAccountQuota, PoolQuotaProbeBusyError, POOL_QUOTA_REFRESH_CONC
35
34
  import type { PoolQuotaResult } from "./pool-quota-probe";
36
35
  import { getRuntimeConfig, configuredPoolAccount, mapWithConcurrency } from "./runtime-config";
37
36
  import { isPassiveRead } from "../../lib/passive-read";
37
+ import { CODEX_MAIN_DUPLICATE_ACTION, configuredCodexMainDuplicate, getObservedMainCodexCollisionIdentity } from "../collision-identity";
38
38
 
39
39
  export function quotaForPlan<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAccountQuota | null>(
40
40
  quota: T,
@@ -133,6 +133,7 @@ export function poolAccountDto(
133
133
  // health projection. Emitting only the boolean is what left #4212's reporter guessing which
134
134
  // account took their model away and why, so name the cause they actually have to act on.
135
135
  const reauthReason: CodexAccountReauthReason | undefined = rawReauthReason ?? (health.status === "reauth_required" ? health.reason : undefined);
136
+ const duplicateMain = configuredCodexMainDuplicate(config, account.id);
136
137
  return {
137
138
  id: account.id,
138
139
  email: projectEmail(account.email, maskEmails) ?? account.email,
@@ -141,6 +142,7 @@ export function poolAccountDto(
141
142
  logLabel: codexAccountLogLabel(account),
142
143
  isMain: false,
143
144
  paused,
145
+ ...(config.codexAccountPauseState?.[account.id]?.details ? { pauseDetails: config.codexAccountPauseState[account.id]!.details } : {}),
144
146
  priority,
145
147
  autoSwitchThresholdOverride: getCodexAccountAutoSwitchThresholdOverride(config, account.id),
146
148
  creditsAfterLimit: codexAccountUsesCreditsAfterLimit(config, account.id),
@@ -149,7 +151,10 @@ export function poolAccountDto(
149
151
  ...codexCreditsDtoField(config, account.id, poolQuotaHistoryIdentity(account.id) ?? null),
150
152
  needsReauth: needsReauth || health.status === "reauth_required",
151
153
  ...(reauthReason !== undefined ? { reauthReason } : {}),
152
- ...(isCodexAccountPlanExcluded(config, account.id) ? {
154
+ ...(duplicateMain ? {
155
+ selectionExcludedReason: "duplicate_main" as const,
156
+ selectionExcludedAction: CODEX_MAIN_DUPLICATE_ACTION,
157
+ } : isCodexAccountPlanExcluded(config, account.id) ? {
153
158
  selectionExcludedReason: "plan_excluded" as const,
154
159
  selectionExcludedPlan: codexPlanValue(config.codexAccounts?.find(row => row.id === account.id)?.plan),
155
160
  } : {}),
@@ -167,6 +172,7 @@ export interface CodexAuthAccountDto {
167
172
  logLabel?: string;
168
173
  isMain: boolean;
169
174
  paused: boolean;
175
+ pauseDetails?: import("../../types/account-auto-pause").AccountPauseDetails;
170
176
  /** Selection order; higher is used earlier. Always present, 0 when unset. */
171
177
  priority: number;
172
178
  /** Null inherits the global usage-switch threshold; 0 disables it for this account. */
@@ -182,8 +188,9 @@ export interface CodexAuthAccountDto {
182
188
  * needs the operator; `/api/oauth/accounts` already carries the same field name.
183
189
  */
184
190
  reauthReason?: CodexAccountReauthReason;
185
- /** Automatic selection policy only; explicit routes retain their usual auth checks. */
186
- selectionExcludedReason?: "plan_excluded";
191
+ /** Plan policy is automatic-only; duplicate-main exclusions also apply to explicit routes. */
192
+ selectionExcludedReason?: "plan_excluded" | "duplicate_main";
193
+ selectionExcludedAction?: string;
187
194
  selectionExcludedPlan?: string;
188
195
  hasCredential: boolean;
189
196
  health: OAuthAccountHealth;
@@ -403,6 +410,7 @@ export async function listCodexAuthAccountsSnapshot(
403
410
  logLabel: "main",
404
411
  isMain: true,
405
412
  paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
413
+ ...(runtimeConfig.codexAccountPauseState?.[MAIN_CODEX_ACCOUNT_ID]?.details ? { pauseDetails: runtimeConfig.codexAccountPauseState[MAIN_CODEX_ACCOUNT_ID]!.details } : {}),
406
414
  mainAccountHardLock: { ...getMainAccountHardLockStatus(runtimeConfig),
407
415
  externalUsage: getMainAccountExternalUsageWarning(getObservedMainQuotaIdentityKey()) },
408
416
  priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
@@ -415,7 +423,7 @@ export async function listCodexAuthAccountsSnapshot(
415
423
  quota: mainInfo.quota
416
424
  ? quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan)
417
425
  : null,
418
- ...codexCreditsDtoField(runtimeConfig, MAIN_CODEX_ACCOUNT_ID, getMainChatgptAccountId()),
426
+ ...codexCreditsDtoField(runtimeConfig, MAIN_CODEX_ACCOUNT_ID, getObservedMainCodexCollisionIdentity()?.accountId ?? null),
419
427
  ...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
420
428
  };
421
429
  return {
@@ -1,35 +1,15 @@
1
1
  import { observeSignedInCodexIdentity } from "../signed-in-identity";
2
2
  import { lstatSync } from "node:fs";
3
3
  import type { OcxConfig } from "../../types";
4
- import { readCodexTokensResult } from "../auth-collision";
5
- import type { CodexTokenReadResult } from "../auth-collision";
4
+ import { readMainAuth } from "../auth-collision";
6
5
  import { linkedCodexAccountIds } from "../account-identity";
7
6
  import { resolveCodexHomeDir } from "../home";
8
7
  import { tryAcquireNativeMainProfileClaim } from "../native-main-admission";
9
8
  import { withNativeMainSharedClaim } from "../native-main-claim";
10
- import { MAX_AUTH_BYTES, readBounded, resolveNativeProfileContext } from "../native-profile-store";
9
+ import { resolveNativeProfileContext } from "../native-profile-store";
11
10
  import { NativeProfileError } from "../native-profile-types";
12
11
  import { isNativeMainClaimUnavailable, nativeMainProfileBusyResponse } from "./http";
13
12
 
14
- type MainAuth = CodexTokenReadResult | { status: "api-key-only" };
15
-
16
- /** A valid API-key login has no ChatGPT identity; anything else unreadable stays unknown. */
17
- function readMainAuth(authPath: string): MainAuth {
18
- const main = readCodexTokensResult(authPath, { bounded: true });
19
- if (main.status !== "invalid") return main;
20
- try {
21
- const j: unknown = JSON.parse(readBounded(authPath, MAX_AUTH_BYTES).toString("utf-8"));
22
- if (j === null || typeof j !== "object" || Array.isArray(j)) return main;
23
- const envelope = j as { auth_mode?: unknown; OPENAI_API_KEY?: unknown; tokens?: unknown };
24
- const apiKeyOnly = envelope.tokens == null
25
- && (envelope.auth_mode === undefined || envelope.auth_mode === "api_key")
26
- && typeof envelope.OPENAI_API_KEY === "string" && envelope.OPENAI_API_KEY.trim() !== "";
27
- return apiKeyOnly ? { status: "api-key-only" } : main;
28
- } catch {
29
- return main;
30
- }
31
- }
32
-
33
13
  /** Hold admission throughout; claim the physical main whenever its home exists. */
34
14
  export async function withCodexAccountPauseGroup(
35
15
  config: OcxConfig,
@@ -0,0 +1,59 @@
1
+ import { lstatSync } from "node:fs";
2
+ import { extractAccountId, extractAccountIdClaims, extractEmail } from "../../oauth/chatgpt";
3
+ import { checkAccountIdCollision, readMainAuth } from "../auth-collision";
4
+ import { resolveCodexHomeDir } from "../home";
5
+ import { tryAcquireNativeMainProfileClaim } from "../native-main-admission";
6
+ import { withNativeMainSharedClaim } from "../native-main-claim";
7
+ import { resolveNativeProfileContext } from "../native-profile-store";
8
+ import { NativeProfileError } from "../native-profile-types";
9
+ import { extractChatgptPlanType } from "../plan";
10
+ import { isNativeMainClaimUnavailable } from "./http";
11
+
12
+ export class CodexCollisionReadBusyError extends Error {
13
+ constructor() { super("Native Codex login is busy or unreadable; retry login shortly."); }
14
+ }
15
+
16
+ /** Only admission owns physical reads; synchronous collision consumers use observed identity. */
17
+ export async function checkAccountIdCollisionWithNativeMain(
18
+ accountId: string, email?: string | null, plan?: unknown, excludeAccountId?: string | null,
19
+ ): Promise<ReturnType<typeof checkAccountIdCollision>> {
20
+ const lease = tryAcquireNativeMainProfileClaim();
21
+ if (!lease) throw new CodexCollisionReadBusyError();
22
+ try {
23
+ const codexHome = resolveCodexHomeDir();
24
+ try { lstatSync(codexHome); } catch (error) {
25
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
26
+ return checkAccountIdCollision(accountId, email, plan, excludeAccountId, null);
27
+ }
28
+ throw new CodexCollisionReadBusyError();
29
+ }
30
+ const context = resolveNativeProfileContext({ codexHome });
31
+ return await withNativeMainSharedClaim(context, async () => {
32
+ const main = readMainAuth(context.authPath);
33
+ if (main.status === "missing" || main.status === "api-key-only") {
34
+ return checkAccountIdCollision(accountId, email, plan, excludeAccountId, null);
35
+ }
36
+ if (main.status !== "ok") throw new CodexCollisionReadBusyError();
37
+ const tokens = main.tokens;
38
+ const nativeId = extractAccountId(tokens.id_token, tokens.access_token) ?? tokens.account_id;
39
+ const claims = [extractAccountIdClaims(tokens.id_token), extractAccountIdClaims(tokens.access_token)];
40
+ const emails = [extractEmail(tokens.id_token), extractEmail(undefined, tokens.access_token)].filter(Boolean);
41
+ if (!nativeId || typeof tokens.account_id !== "string" || typeof tokens.access_token !== "string"
42
+ || (tokens.id_token !== undefined && typeof tokens.id_token !== "string")
43
+ || claims.some(claim => claim.conflict || (claim.accountId && claim.accountId !== nativeId))
44
+ || (tokens.account_id && tokens.account_id !== nativeId) || new Set(emails).size > 1) {
45
+ throw new CodexCollisionReadBusyError();
46
+ }
47
+ return checkAccountIdCollision(accountId, email, plan, excludeAccountId, {
48
+ accountId: nativeId, email: extractEmail(tokens.id_token, tokens.access_token),
49
+ plan: extractChatgptPlanType(tokens.id_token, tokens.access_token),
50
+ });
51
+ });
52
+ } catch (error) {
53
+ if (isNativeMainClaimUnavailable(error)
54
+ || (error instanceof NativeProfileError && error.code === "CODEX_HOME_UNAVAILABLE")) {
55
+ throw new CodexCollisionReadBusyError();
56
+ }
57
+ throw error;
58
+ } finally { lease.release(); }
59
+ }
@@ -5,7 +5,8 @@ import type { StoredAccountQuota, WhamUsageResponse } from "../quota";
5
5
  import { ConfigMutationLockError, withConfigMutationLockSync } from "../../config";
6
6
  import { appendDefaultCodexAccountNamespace, codexAccountPickerEnabled } from "../account-namespaces";
7
7
  import { catalogRefreshIsPending, normalizeCatalogDisposition } from "../catalog-refresh-status";
8
- import { checkAccountIdCollision } from "../auth-collision";
8
+ import { checkAccountIdCollisionWithNativeMain as checkAccountIdCollision, CodexCollisionReadBusyError } from "./login-collision";
9
+ import { extractChatgptPlanType } from "../plan";
9
10
  import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state";
10
11
  import { clearCodexPoolRefreshFailure } from "../pool-refresh-backoff";
11
12
  import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
@@ -262,8 +263,8 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
262
263
  break;
263
264
  }
264
265
 
265
- let email = cred.email || accountId;
266
- let plan: string | undefined;
266
+ let email = cred.email?.trim() || "";
267
+ let plan = extractChatgptPlanType(undefined, cred.access);
267
268
  let quota: Omit<StoredAccountQuota, "updatedAt"> | null = null;
268
269
  let policyQuota: Omit<StoredAccountQuota, "updatedAt"> | null = null;
269
270
  try {
@@ -274,8 +275,8 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
274
275
  });
275
276
  if (resp.ok) {
276
277
  const data = (await resp.json()) as WhamUsageResponse;
277
- email = data.email ?? email;
278
- plan = nonEmptyPlan(data.plan_type) ?? undefined;
278
+ email = data.email?.trim() || email;
279
+ plan = nonEmptyPlan(data.plan_type) ?? plan;
279
280
  quota = parseUsageQuota(data);
280
281
  policyQuota = isValidWhamHistoryObservation(data) ? quota : null;
281
282
  }
@@ -322,7 +323,7 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
322
323
  }
323
324
 
324
325
  // 1.2: Duplicate check is scoped by personal vs workspace plan bucket.
325
- const collision = checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined);
326
+ const collision = await checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined);
326
327
  if (collision.collision) {
327
328
  setCodexLoginState(flowId, {
328
329
  status: "error", error: collision.reason, doneAt: Date.now(),
@@ -331,6 +332,8 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
331
332
  break;
332
333
  }
333
334
 
335
+ email ||= accountId;
336
+
334
337
  // A successful authenticated WHAM read can prove quota is exhausted without
335
338
  // spending an inference request. Store the account, but defer inference validation
336
339
  // and keep it unavailable to routing. Unknown/failed usage reads retain the gate.
@@ -470,7 +473,9 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
470
473
  });
471
474
  }
472
475
  } catch (error) {
473
- const message = error instanceof ConfigMutationLockError
476
+ const message = error instanceof CodexCollisionReadBusyError
477
+ ? "Native Codex login is busy or unreadable; retry login shortly."
478
+ : error instanceof ConfigMutationLockError
474
479
  || error instanceof CodexCredentialRefreshLockTimeoutError
475
480
  ? "Configuration is busy; retry login shortly."
476
481
  : error instanceof CodexCredentialRefreshBusyError || error instanceof CodexCredentialRefreshStaleError
@@ -6,7 +6,7 @@ import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../.
6
6
  import { providerCodexAccountMode } from "../../providers/registry";
7
7
  import { isSelectableCodexPoolAccount } from "../account-id";
8
8
  import type { OcxConfig } from "../../types";
9
- import { parseMainPolicyUsageQuota, parseUsageQuota, setAccountQuotaFromParsed } from "../quota";
9
+ import { isValidWhamHistoryObservation, parseMainPolicyUsageQuota, parseUsageQuota, setAccountQuotaFromParsed } from "../quota";
10
10
  import type { StoredAccountQuota, WhamUsageResponse } from "../quota";
11
11
  import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle";
12
12
  import { getMainChatgptAccountId, readCodexTokensResult } from "../auth-collision";
@@ -278,6 +278,7 @@ export async function fetchMainAccountInfoWhileOwned(
278
278
  let quotaPhase: "request" | "body" | "decode" | "publish" = "request";
279
279
  let quotaRefreshGeneration = captureMainAccountIdentityGeneration();
280
280
  const readState: { owner?: CodexUsageOwner<MainAccountInfoFetchResult>; usable: boolean } = { usable: false };
281
+ /** Perform one single-flight main-account usage read: classify auth failures and publish parsed quota and decision evidence only while the dispatch and credential are still current. */
281
282
  const read = async (): Promise<MainAccountInfoFetchResult> => {
282
283
  try {
283
284
  let dispatchSequence = 0;
@@ -398,7 +399,7 @@ export async function fetchMainAccountInfoWhileOwned(
398
399
  // score and auto-switch the main account exactly like a pool account (Option A).
399
400
  setMainAccountPlan(result.plan);
400
401
  if (result.quota) {
401
- setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration, mainQuotaWriter, policyQuota);
402
+ setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration, mainQuotaWriter, policyQuota, undefined, false, false, isValidWhamHistoryObservation(usage) ? quota : null);
402
403
  }
403
404
  publishQuotaDispatch(dispatchSequence);
404
405
  readState.usable = quota !== null;
@@ -13,7 +13,7 @@ import { isAccountNeedsReauth } from "../account-runtime-state";
13
13
  import { getValidMainAccountToken, MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "../main-account";
14
14
  import { captureConfigGeneration, registerStateSweepAfterTick } from "../../lib/state-store-sweeper";
15
15
  import { observeMainQuotaCredential, getMainQuotaCredentialGeneration, captureMainAccountIdentityGeneration, isMainAccountIdentityGenerationLive } from "../main-account-cache";
16
- import { getMainAccountHardLockStatus } from "../main-account-hard-lock";
16
+ import { getMainAccountHardLockStatus, latestMainBlockingResetAt } from "../main-account-hard-lock";
17
17
  import type { OcxConfig } from "../../types";
18
18
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
19
19
  import { providerCodexAccountMode } from "../../providers/registry";
@@ -78,8 +78,55 @@ export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Da
78
78
 
79
79
  const MAIN_CREDIT_RENEWAL_AGE_MS = 3 * 60_000;
80
80
 
81
+ /** Traffic headers renew usage, never the independent credit observation (#7377). */
82
+ function poolCreditRenewalDue(config: OcxConfig, accountId: string, now = Date.now()): boolean {
83
+ const account = configuredPoolAccount(config, accountId);
84
+ if (!account || !codexAccountUsesCreditsAfterLimit(config, accountId)
85
+ || isCodexAccountPaused(config, accountId) || isAccountNeedsReauth(accountId)) return false;
86
+ const quota = getAccountQuota(accountId);
87
+ if (codexUsageLimitResetAt(quota, account.plan, now) === undefined) return false;
88
+ const observedAt = quota?.credits?.observedAt;
89
+ if (observedAt !== undefined && Number.isFinite(observedAt)
90
+ && now - observedAt >= 0 && now - observedAt < MAIN_CREDIT_RENEWAL_AGE_MS) return false;
91
+ const attempt = poolQuotaPrimeAttemptedAt.get(accountId);
92
+ return !attempt || attempt.generation !== readCodexAccountRecord(accountId)?.generation
93
+ || now - attempt.at >= MAIN_CREDIT_RENEWAL_AGE_MS;
94
+ }
95
+
81
96
  let mainHardLockRecoveryInFlight: Promise<void> | null = null;
82
- let mainHardLockRecoveryAttempt: { identity: number; credential: number; after: number; delay: number } | undefined;
97
+ /** `floor` and `resetBound` apply only while a predicted reset is (or just was) holding the lock. */
98
+ type MainRecoveryAttempt = { identity: number; credential: number; after: number; delay: number; floor?: number; resetBound?: number };
99
+ let mainHardLockRecoveryAttempt: MainRecoveryAttempt | undefined;
100
+ /** The identity/credential that already waited out its first hold interval. */
101
+ let mainHardLockHoldSeen: { identity: number; credential: number } | undefined;
102
+ const MAIN_HOLD_MIN_INTERVAL_MS = nextQuotaQueryDelay();
103
+
104
+ /** The predicted reset of a blocked main, or undefined when no future reset is known. */
105
+ function predictedMainResetHold(status: ReturnType<typeof getMainAccountHardLockStatus>): number | undefined {
106
+ return status.state === "blocked" && status.resetAt !== undefined && status.resetAt > Date.now()
107
+ ? status.resetAt : undefined;
108
+ }
109
+
110
+ /**
111
+ * A hold read is due at its backoff deadline, pulled in to the latest known predicted reset (even
112
+ * one that moved earlier or just passed) but never sooner than one base interval after the last read.
113
+ */
114
+ function mainRecoveryDueAt(attempt: MainRecoveryAttempt, holdResetAt: number | undefined, config: OcxConfig): number {
115
+ // Once a hold elapses the status omits its reset; current evidence may still name a revised one.
116
+ const bound = holdResetAt ?? (attempt.resetBound === undefined ? undefined
117
+ : Math.min(attempt.resetBound, latestMainBlockingResetAt(config) ?? Infinity));
118
+ return bound === undefined ? attempt.after : Math.max(attempt.floor ?? 0, Math.min(attempt.after, bound));
119
+ }
120
+
121
+ /** First sight of a hold for this credential waits one base interval; transient retries do not re-arm it. */
122
+ function armMainResetHold(identity: number, credential: number, holdResetAt: number): void {
123
+ const at = Date.now() + MAIN_HOLD_MIN_INTERVAL_MS;
124
+ mainHardLockHoldSeen = { identity, credential };
125
+ mainHardLockRecoveryAttempt = { identity, credential, delay: MAIN_HOLD_MIN_INTERVAL_MS, after: at, floor: at, resetBound: holdResetAt };
126
+ }
127
+
128
+ const mainHoldSeenFor = (identity: number, credential: number): boolean =>
129
+ mainHardLockHoldSeen?.identity === identity && mainHardLockHoldSeen.credential === credential;
83
130
 
84
131
  /** Metadata-only recovery on the existing sweep; failures retain the observed policy block. */
85
132
  export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise<void> {
@@ -98,10 +145,14 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
98
145
  || (observedAt !== undefined && now - observedAt >= MAIN_CREDIT_RENEWAL_AGE_MS));
99
146
  };
100
147
  const status = getMainAccountHardLockStatus(config);
148
+ if (status.state !== "blocked") mainHardLockHoldSeen = undefined;
101
149
  if (status.state !== "blocked" && !creditRecoveryNeeded()) { mainHardLockRecoveryAttempt = undefined; return; }
102
150
  if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return;
103
- // A predicted reset is a scheduling hint, never proof that the hard lock can be lifted.
104
- if (status.state === "blocked" && status.resetAt !== undefined && status.resetAt > Date.now()) return;
151
+ // A predicted reset is a scheduling hint, never proof that the hard lock can be lifted. The
152
+ // hold is rechecked on the capped WHAM schedule so an early reset redeemed elsewhere is
153
+ // learned from metadata (#7312); a paused main receives no hold rechecks.
154
+ const holdResetAt = predictedMainResetHold(status);
155
+ if (holdResetAt !== undefined && isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)) return;
105
156
  // Reconcile the owned physical credential before skipping work for an older one.
106
157
  reconcileMainCodexAccountRuntimeState();
107
158
  const physical = readCodexTokens();
@@ -109,9 +160,14 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
109
160
  const identity = captureMainAccountIdentityGeneration();
110
161
  const credential = getMainQuotaCredentialGeneration();
111
162
  const previous = mainHardLockRecoveryAttempt;
163
+ if (holdResetAt !== undefined && !mainHoldSeenFor(identity, credential)) {
164
+ armMainResetHold(identity, credential, holdResetAt);
165
+ return;
166
+ }
167
+ const sameAttempt = previous?.identity === identity && previous.credential === credential;
168
+ if (sameAttempt && holdResetAt !== undefined) previous.resetBound = holdResetAt;
112
169
  const queryAfter = nextCodexUsageQueryAt(`main:${captureConfigGeneration()}:${credential}`) ?? 0;
113
- if ((previous?.identity === identity && previous.credential === credential
114
- && previous.after > Date.now()) || queryAfter > Date.now()) return;
170
+ if ((sameAttempt && mainRecoveryDueAt(previous, holdResetAt, config) > Date.now()) || queryAfter > Date.now()) return;
115
171
  const lease = tryAcquireNativeMainProfileClaim();
116
172
  if (!lease) return;
117
173
  mainHardLockRecoveryInFlight = (async () => {
@@ -130,16 +186,20 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
130
186
  // Native refresh retains its own grant-scoped refusal; global quarantine would outlive it.
131
187
  return;
132
188
  }
133
- const currentStatus = getMainAccountHardLockStatus(config);
189
+ const currentHold = predictedMainResetHold(getMainAccountHardLockStatus(config));
134
190
  if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)
135
191
  || !isMainAccountIdentityGenerationLive(identityGeneration)
136
192
  || (passive && !creditRecoveryNeeded())
137
- || (currentStatus.state === "blocked" && currentStatus.resetAt !== undefined
138
- && currentStatus.resetAt > Date.now())) return;
193
+ || (currentHold !== undefined && isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID))) return;
139
194
  const credential = getMainQuotaCredentialGeneration();
195
+ // A hold that appeared during preparation is first seen now and waits its base interval.
196
+ if (currentHold !== undefined && !mainHoldSeenFor(identityGeneration, credential)) {
197
+ armMainResetHold(identityGeneration, credential, currentHold);
198
+ return;
199
+ }
140
200
  // Reconcile the physical bearer before consulting the previous credential's delay.
141
201
  if (previous?.identity === identityGeneration && previous.credential === credential
142
- && previous.after > Date.now()) return;
202
+ && mainRecoveryDueAt(previous, currentHold, config) > Date.now()) return;
143
203
  const currentQueryAfter = nextCodexUsageQueryAt(`main:${captureConfigGeneration()}:${credential}`) ?? 0;
144
204
  if (currentQueryAfter > Date.now()) {
145
205
  mainHardLockRecoveryAttempt = { identity: identityGeneration, credential,
@@ -162,8 +222,10 @@ export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise
162
222
  if (transientAuth) mainHardLockRecoveryAttempt = undefined;
163
223
  else {
164
224
  const delay = nextQuotaQueryDelay(previousDelay || undefined);
225
+ const hold = predictedMainResetHold(getMainAccountHardLockStatus(config));
165
226
  mainHardLockRecoveryAttempt = getMainAccountHardLockStatus(config).state === "blocked" || creditRecoveryNeeded()
166
- ? { identity: identityGeneration, credential, delay, after: Math.max(Date.now() + delay, queryAfter) }
227
+ ? { identity: identityGeneration, credential, delay, after: Math.max(Date.now() + delay, queryAfter),
228
+ ...(hold !== undefined ? { floor: Date.now() + MAIN_HOLD_MIN_INTERVAL_MS, resetBound: hold } : {}) }
167
229
  : undefined;
168
230
  }
169
231
  } else if (queryAfter > Date.now()) {
@@ -186,6 +248,7 @@ export function registerCodexCooldownRecoveryProbeWorker(config: OcxConfig): voi
186
248
  afterTick: () => {
187
249
  void runCodexCooldownRecoveryProbes(config);
188
250
  void runMainAccountHardLockRecovery(config);
251
+ void primeCodexPoolQuotas(config, "credit-renewal");
189
252
  },
190
253
  });
191
254
  }
@@ -265,6 +328,7 @@ export async function primeCodexPoolQuotas(
265
328
  primeInFlight = trackBackgroundWork(`codex-pool-quota-prime:${reason}`, (async () => {
266
329
  const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
267
330
  const stale = pool.filter(a => {
331
+ if (reason === "credit-renewal") return poolCreditRenewalDue(runtimeConfig, a.id);
268
332
  const q = getAccountQuota(a.id);
269
333
  const observationStale = reason === "priority-failback" && q
270
334
  && !codexQuotaHasFreshUsage(q, a.plan, Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS);
@@ -278,7 +342,10 @@ export async function primeCodexPoolQuotas(
278
342
  if (lastAttempt.generation !== readCodexAccountRecord(a.id)?.generation) return true;
279
343
  return Date.now() - lastAttempt.at >= POOL_CACHE_TTL;
280
344
  });
345
+ if (reason === "credit-renewal" && stale.length === 0) return;
281
346
  const primeMain = async () => {
347
+ // Main renewal owns its physical-identity claims and passive probe separately.
348
+ if (reason === "credit-renewal") return;
282
349
  const mainLease = tryAcquireNativeMainPrimeLease();
283
350
  if (!mainLease) return;
284
351
  try {
@@ -311,11 +378,12 @@ export async function primeCodexPoolQuotas(
311
378
  primeMain(),
312
379
  mapWithConcurrency(stale, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
313
380
  if (!getCodexAccountCredential(a.id)) return;
381
+ if (reason === "credit-renewal" && !poolCreditRenewalDue(getRuntimeConfig(config), a.id)) return;
314
382
  let result: PoolQuotaResult;
315
383
  try {
316
384
  const quota = getAccountQuota(a.id);
317
- const bypassCachedQuota = reason === "priority-failback" && quota !== null
318
- && !codexQuotaHasFreshUsage(quota, a.plan, Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS);
385
+ const bypassCachedQuota = reason === "credit-renewal" || (reason === "priority-failback" && quota !== null
386
+ && !codexQuotaHasFreshUsage(quota, a.plan, Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS));
319
387
  result = await fetchPoolAccountQuota(a.id, bypassCachedQuota, a.plan, getValidPoolTokenForPrime);
320
388
  } catch (error) {
321
389
  // Local quota-flight saturation proves no WHAM request existed for this account.
@@ -4,8 +4,9 @@ import { getCodexAccountCredential } from "./account-store";
4
4
  import { loadConfig } from "../config";
5
5
  import { resolveCodexHomeDir } from "./home";
6
6
  import { extractAccountId } from "../oauth/chatgpt";
7
- import { isSelectableCodexPoolAccount } from "./account-id";
7
+ import { isSelectableCodexPoolAccount, MAIN_CODEX_ACCOUNT_ID } from "./account-id";
8
8
  import { codexPlanKey } from "./plan";
9
+ import { getObservedMainCodexCollisionIdentity, sameCodexCollisionIdentity, type CodexCollisionIdentity } from "./collision-identity";
9
10
  import { MAX_AUTH_BYTES, readBounded } from "./native-profile-store";
10
11
 
11
12
  export interface CodexTokens {
@@ -94,17 +95,23 @@ function isWorkspacePlan(plan: unknown): boolean {
94
95
  return !!key && /team|business|enterprise|workspace|edu/.test(key);
95
96
  }
96
97
 
97
- // Main login and managed pool accounts are separate duplicate buckets.
98
- // Inside the pool, personal and workspace subscriptions are also separate buckets.
99
- // Within each pool bucket, keep the original ChatGPT account id + email collision guard.
98
+ // Main login and pool accounts share the account id + email collision guard.
99
+ // Personal and workspace subscriptions remain separate buckets.
100
100
  export function checkAccountIdCollision(
101
101
  chatgptAccountId: string,
102
102
  email?: string | null,
103
103
  plan?: unknown,
104
104
  excludeAccountId?: string | null,
105
+ main: CodexCollisionIdentity | null = getObservedMainCodexCollisionIdentity() ?? null,
105
106
  ): { collision: true; reason: string } | { collision: false } {
106
107
  const candidateEmail = normalizedEmail(email);
107
108
  const candidateWorkspace = isWorkspacePlan(plan);
109
+
110
+ if (excludeAccountId !== MAIN_CODEX_ACCOUNT_ID && main
111
+ && sameCodexCollisionIdentity({ accountId: chatgptAccountId, email, plan }, main)) {
112
+ return { collision: true, reason: "Account is already configured as the native Codex main account." };
113
+ }
114
+
108
115
  for (const account of loadConfig().codexAccounts ?? []) {
109
116
  if (excludeAccountId && account.id === excludeAccountId) continue;
110
117
  if (!isSelectableCodexPoolAccount(account)) continue;
@@ -117,3 +124,23 @@ export function checkAccountIdCollision(
117
124
  }
118
125
  return { collision: false };
119
126
  }
127
+
128
+ export type MainAuth = CodexTokenReadResult | { status: "api-key-only" };
129
+
130
+ /** A valid API-key login has no ChatGPT identity; anything else unreadable stays unknown. */
131
+ export function readMainAuth(authPath = join(resolveCodexHomeDir(), "auth.json")): MainAuth {
132
+ const main = readCodexTokensResult(authPath, { bounded: true });
133
+ if (main.status !== "invalid") return main;
134
+ try {
135
+ const j: unknown = JSON.parse(readBounded(authPath, MAX_AUTH_BYTES).toString("utf-8"));
136
+ if (j === null || typeof j !== "object" || Array.isArray(j)) return main;
137
+ const envelope = j as { auth_mode?: unknown; OPENAI_API_KEY?: unknown; tokens?: unknown };
138
+ const apiKeyOnly = envelope.tokens == null
139
+ && (envelope.auth_mode === undefined || envelope.auth_mode === "api_key")
140
+ && typeof envelope.OPENAI_API_KEY === "string" && envelope.OPENAI_API_KEY.trim() !== "";
141
+ return apiKeyOnly ? { status: "api-key-only" } : main;
142
+ } catch {
143
+ return main;
144
+ }
145
+ }
146
+