@bitkyc08/opencodex 2.10.0 → 2.10.1

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 (274) hide show
  1. package/AGENTS_INSTALL.md +77 -0
  2. package/README.md +4 -10
  3. package/bin/ocx.mjs +71 -18
  4. package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
  5. package/gui/dist/assets/index-ChZQsmBY.js +70 -0
  6. package/gui/dist/index.html +2 -2
  7. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  8. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  9. package/gui/dist/provider-icons/claude-color.svg +1 -1
  10. package/gui/dist/provider-icons/cline-color.svg +16 -0
  11. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  12. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  13. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  14. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  15. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  16. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  17. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  18. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  19. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  20. package/gui/dist/provider-icons/grok.svg +1 -1
  21. package/gui/dist/provider-icons/groq-color.svg +1 -1
  22. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  23. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  24. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  25. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  26. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  27. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  28. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  29. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  30. package/gui/dist/provider-icons/openai.svg +1 -1
  31. package/gui/dist/provider-icons/opencode.svg +2 -1
  32. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  33. package/gui/dist/provider-icons/pi.svg +2 -2
  34. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  35. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  36. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  37. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  38. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  39. package/package.json +8 -4
  40. package/src/adapters/anthropic.ts +208 -14
  41. package/src/adapters/base.ts +16 -5
  42. package/src/adapters/cursor/effort-map.ts +3 -2
  43. package/src/adapters/cursor/framing.ts +39 -0
  44. package/src/adapters/cursor/live-transport.ts +105 -95
  45. package/src/adapters/cursor/native-exec.ts +32 -6
  46. package/src/adapters/cursor/protobuf-request.ts +20 -15
  47. package/src/adapters/cursor/request-builder.ts +21 -7
  48. package/src/adapters/cursor/types.ts +7 -0
  49. package/src/adapters/google-antigravity-replay.ts +237 -21
  50. package/src/adapters/google-truncation.ts +11 -0
  51. package/src/adapters/google.ts +50 -9
  52. package/src/adapters/identity.ts +39 -6
  53. package/src/adapters/kiro-errors.ts +11 -0
  54. package/src/adapters/kiro-events.ts +19 -1
  55. package/src/adapters/kiro-thinking.ts +10 -2
  56. package/src/adapters/kiro-tools.ts +10 -1
  57. package/src/adapters/kiro.ts +37 -11
  58. package/src/adapters/openai-chat.ts +284 -83
  59. package/src/adapters/openai-responses.ts +182 -24
  60. package/src/bridge.ts +177 -7
  61. package/src/chat/outbound.ts +78 -23
  62. package/src/claude/agents-inject.ts +27 -5
  63. package/src/claude/inbound.ts +11 -1
  64. package/src/claude/model-info.ts +13 -10
  65. package/src/claude/outbound.ts +17 -0
  66. package/src/cli/account-api.ts +24 -0
  67. package/src/cli/account-auth.ts +31 -6
  68. package/src/cli/account-main.ts +317 -0
  69. package/src/cli/account.ts +5 -0
  70. package/src/cli/claude.ts +2 -1
  71. package/src/cli/doctor.ts +93 -22
  72. package/src/cli/export-command.ts +26 -12
  73. package/src/cli/help.ts +8 -6
  74. package/src/cli/index.ts +56 -22
  75. package/src/cli/integrations.ts +84 -1
  76. package/src/cli/observe.ts +54 -1
  77. package/src/cli/opencode.ts +2 -1
  78. package/src/cli/provider-runtime.ts +18 -1
  79. package/src/cli/route-policy.ts +92 -0
  80. package/src/cli/runtime-api.ts +6 -3
  81. package/src/cli/star-prompt.ts +71 -15
  82. package/src/cli/status.ts +1 -1
  83. package/src/cli/v2.ts +36 -9
  84. package/src/clients/config-export.ts +687 -10
  85. package/src/codex/account-lifecycle.ts +30 -5
  86. package/src/codex/account-usability.ts +22 -2
  87. package/src/codex/admission.ts +255 -0
  88. package/src/codex/auth-api.ts +427 -140
  89. package/src/codex/auth-context.ts +155 -30
  90. package/src/codex/autostart-health.ts +8 -1
  91. package/src/codex/catalog/account-models.ts +62 -0
  92. package/src/codex/catalog/aggregation.ts +14 -1
  93. package/src/codex/catalog/bundled.ts +282 -32
  94. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  95. package/src/codex/catalog/metadata.ts +51 -6
  96. package/src/codex/catalog/parsing.ts +6 -3
  97. package/src/codex/catalog/provider-fetch.ts +576 -41
  98. package/src/codex/catalog/sync.ts +505 -66
  99. package/src/codex/catalog-admission.ts +197 -0
  100. package/src/codex/catalog-write-serialization.ts +241 -0
  101. package/src/codex/catalog.ts +2 -1
  102. package/src/codex/codex-write-lock.ts +372 -0
  103. package/src/codex/convergence-types.ts +593 -0
  104. package/src/codex/convergence.ts +441 -0
  105. package/src/codex/desired-state.ts +177 -0
  106. package/src/codex/features.ts +52 -8
  107. package/src/codex/generation.ts +202 -0
  108. package/src/codex/history-job.ts +257 -0
  109. package/src/codex/history-lock.ts +241 -0
  110. package/src/codex/history-migration-guardian.ts +18 -5
  111. package/src/codex/history-provider.ts +9 -2
  112. package/src/codex/history-transition.ts +105 -0
  113. package/src/codex/history-worker.ts +176 -0
  114. package/src/codex/inject-coordination.ts +245 -0
  115. package/src/codex/inject.ts +605 -124
  116. package/src/codex/integration-record.ts +266 -0
  117. package/src/codex/internal/catalog-writer.ts +203 -0
  118. package/src/codex/internal/history-writer.ts +80 -0
  119. package/src/codex/journal.ts +10 -1
  120. package/src/codex/main-account-cache.ts +24 -0
  121. package/src/codex/management-convergence.ts +114 -0
  122. package/src/codex/native-main-admission.ts +47 -0
  123. package/src/codex/native-main-auth-temp.ts +187 -0
  124. package/src/codex/native-main-claim.ts +167 -0
  125. package/src/codex/native-main-lock-file.ts +158 -0
  126. package/src/codex/native-main-owner.ts +315 -0
  127. package/src/codex/native-profile-api.ts +247 -0
  128. package/src/codex/native-profile-manager.ts +1512 -0
  129. package/src/codex/native-profile-processes.ts +121 -0
  130. package/src/codex/native-profile-recovery.ts +99 -0
  131. package/src/codex/native-profile-stage-store.ts +387 -0
  132. package/src/codex/native-profile-startup.ts +340 -0
  133. package/src/codex/native-profile-store.ts +855 -0
  134. package/src/codex/native-profile-types.ts +120 -0
  135. package/src/codex/native-residue.ts +557 -0
  136. package/src/codex/project-config-warnings.ts +18 -4
  137. package/src/codex/prompt-journal.ts +311 -0
  138. package/src/codex/prompt-layers.ts +967 -0
  139. package/src/codex/prompt-lock.ts +143 -0
  140. package/src/codex/quota-rejection.ts +224 -0
  141. package/src/codex/quota.ts +86 -3
  142. package/src/codex/routing.ts +299 -62
  143. package/src/codex/runtime.ts +159 -38
  144. package/src/codex/shim.ts +39 -13
  145. package/src/codex/subagent-model-fallback.ts +73 -12
  146. package/src/codex/transition-state.ts +604 -0
  147. package/src/codex/upstream-host-health.ts +70 -0
  148. package/src/codex/user-identity.ts +266 -0
  149. package/src/codex/write-coordination.ts +114 -0
  150. package/src/config.ts +562 -26
  151. package/src/generated/jawcode-model-metadata.ts +2 -2
  152. package/src/grok/inject.ts +15 -4
  153. package/src/grok/inspect.ts +45 -0
  154. package/src/images/loop.ts +113 -20
  155. package/src/integrations/config-io.ts +151 -0
  156. package/src/integrations/journal.ts +315 -0
  157. package/src/integrations/merge.ts +135 -0
  158. package/src/integrations/native/ownership-preflight.ts +165 -0
  159. package/src/integrations/ownership.ts +111 -0
  160. package/src/integrations/registry.ts +101 -0
  161. package/src/integrations/serialize.ts +235 -0
  162. package/src/integrations/state.ts +290 -0
  163. package/src/integrations/store.ts +103 -0
  164. package/src/integrations/writer.ts +492 -0
  165. package/src/lib/bounded-body.ts +46 -8
  166. package/src/lib/bun-runtime.ts +110 -1
  167. package/src/lib/bun-stream-caps.ts +2 -1
  168. package/src/lib/redact.ts +407 -2
  169. package/src/lib/shadow-call.ts +24 -0
  170. package/src/lib/translator-budget.ts +10 -0
  171. package/src/lib/upstream-reachability.ts +91 -0
  172. package/src/lib/upstream-retry.ts +154 -2
  173. package/src/lib/windows-secret-acl.ts +212 -11
  174. package/src/lib/winsw.ts +9 -3
  175. package/src/oauth/index.ts +61 -3
  176. package/src/oauth/key-providers.ts +4 -0
  177. package/src/oauth/kiro.ts +50 -6
  178. package/src/oauth/store.ts +31 -6
  179. package/src/oauth/token-guardian.ts +9 -3
  180. package/src/providers/codex-capacity.ts +288 -0
  181. package/src/providers/derive.ts +33 -1
  182. package/src/providers/free-directory.ts +3 -1
  183. package/src/providers/key-failover.ts +71 -3
  184. package/src/providers/openai-sidecar.ts +64 -4
  185. package/src/providers/openai-virtual-models.ts +1 -0
  186. package/src/providers/quota.ts +334 -26
  187. package/src/providers/registry.ts +284 -16
  188. package/src/providers/xai-transport.ts +11 -4
  189. package/src/responses/compaction.ts +8 -1
  190. package/src/responses/hosted-tool-policy.ts +9 -0
  191. package/src/responses/parser.ts +44 -2
  192. package/src/responses/reasoning-envelope.ts +9 -1
  193. package/src/responses/reasoning-replay-cache.ts +105 -0
  194. package/src/responses/spill-store.ts +45 -8
  195. package/src/responses/state.ts +161 -17
  196. package/src/router.ts +302 -16
  197. package/src/routing/analytics.ts +377 -0
  198. package/src/routing/capability.ts +204 -0
  199. package/src/routing/cost.ts +77 -0
  200. package/src/routing/evaluator.ts +444 -0
  201. package/src/routing/health.ts +401 -0
  202. package/src/routing/history/cursor.ts +43 -0
  203. package/src/routing/history/indexer.ts +590 -0
  204. package/src/routing/history/schema.ts +72 -0
  205. package/src/routing/profile.ts +423 -0
  206. package/src/routing/quota.ts +124 -0
  207. package/src/routing/request-evidence.ts +45 -0
  208. package/src/routing/trace.ts +686 -0
  209. package/src/server/auth-cors.ts +46 -6
  210. package/src/server/chat-completions.ts +28 -13
  211. package/src/server/claude-messages.ts +23 -15
  212. package/src/server/gui-static.ts +39 -10
  213. package/src/server/images.ts +10 -1
  214. package/src/server/index.ts +238 -52
  215. package/src/server/lifecycle.ts +228 -9
  216. package/src/server/live.ts +14 -3
  217. package/src/server/management/agent-settings-routes.ts +64 -14
  218. package/src/server/management/combo-routes.ts +5 -5
  219. package/src/server/management/config-routes.ts +1 -1
  220. package/src/server/management/context.ts +42 -2
  221. package/src/server/management/integration-routes.ts +538 -0
  222. package/src/server/management/logs-usage-routes.ts +1 -1
  223. package/src/server/management/model-routes.ts +32 -113
  224. package/src/server/management/model-rows.ts +117 -0
  225. package/src/server/management/native-integration-routes.ts +587 -0
  226. package/src/server/management/oauth-account-routes.ts +1 -1
  227. package/src/server/management/provider-routes.ts +218 -117
  228. package/src/server/management/request-history-routes.ts +191 -0
  229. package/src/server/management/routing-analytics-routes.ts +74 -0
  230. package/src/server/management/routing-profile-routes.ts +177 -0
  231. package/src/server/management/shared.ts +2 -2
  232. package/src/server/management/sidebar-routes.ts +47 -31
  233. package/src/server/management/sync-response.ts +69 -0
  234. package/src/server/management/system-restart.ts +276 -24
  235. package/src/server/management/system-routes.ts +4 -0
  236. package/src/server/management-api.ts +84 -9
  237. package/src/server/management-auth.ts +43 -5
  238. package/src/server/relay-eager.ts +82 -42
  239. package/src/server/relay.ts +120 -6
  240. package/src/server/request-log.ts +26 -6
  241. package/src/server/responses/collaboration.ts +63 -8
  242. package/src/server/responses/compact.ts +272 -41
  243. package/src/server/responses/core.ts +730 -132
  244. package/src/server/responses/fetch-helpers.ts +15 -1
  245. package/src/server/responses-item-id-repair.ts +32 -3
  246. package/src/server/responses-json-events.ts +52 -0
  247. package/src/server/responses-snapshot-repair.ts +621 -0
  248. package/src/server/search.ts +51 -6
  249. package/src/server/sse-payload-rewrite.ts +89 -12
  250. package/src/server/startup-health-cache.ts +7 -1
  251. package/src/server/ws-bridge.ts +11 -17
  252. package/src/service-manager-probe.ts +297 -0
  253. package/src/service.ts +222 -32
  254. package/src/tray/windows-tray.ps1 +9 -0
  255. package/src/tray/windows.ts +15 -7
  256. package/src/types.ts +194 -14
  257. package/src/update/index.ts +13 -13
  258. package/src/update/job.ts +24 -21
  259. package/src/update/notify.ts +7 -3
  260. package/src/usage/cost.ts +0 -0
  261. package/src/usage/expected-prices.ts +129 -10
  262. package/src/usage/log.ts +50 -15
  263. package/src/usage/summary.ts +4 -4
  264. package/src/vision/index.ts +6 -1
  265. package/src/web-search/loop.ts +161 -34
  266. package/gui/dist/assets/index-OY43ubAq.css +0 -1
  267. package/gui/dist/assets/index-YwNnKZcL.js +0 -67
  268. package/gui/dist/provider-icons/antigravity.svg +0 -1
  269. package/gui/dist/provider-icons/claude.svg +0 -1
  270. package/gui/dist/provider-icons/copilot.svg +0 -1
  271. package/gui/dist/provider-icons/cursor.svg +0 -2
  272. package/gui/dist/provider-icons/gemini.svg +0 -1
  273. package/gui/dist/provider-icons/grok-color.svg +0 -1
  274. package/gui/dist/provider-icons/kiro.svg +0 -14
@@ -16,11 +16,13 @@ import {
16
16
  import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
17
17
  import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause";
18
18
  import {
19
+ claimDueCodexQuotaRecoveryProbes,
19
20
  clearCodexAccountCooldown,
20
21
  clearThreadAccountMapForAccount,
21
22
  getEffectiveActiveCodexAccountId,
22
23
  reconcileCodexActiveAfterExclusion,
23
24
  resetCodexRoutingForManualSelection,
25
+ settleCodexQuotaRecoveryProbe,
24
26
  } from "./routing";
25
27
  import {
26
28
  normalizeAccountPoolStickyLimit,
@@ -35,6 +37,7 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth }
35
37
  import {
36
38
  clearAccountQuota,
37
39
  getAccountQuota,
40
+ isCompleteCodexQuotaRecoverySnapshot,
38
41
  isCodexQuotaExhausted,
39
42
  listAccountQuotas,
40
43
  parseUsageQuota,
@@ -53,11 +56,15 @@ export {
53
56
  } from "./quota";
54
57
  import { extractAccountId, decodeJwtPayload } from "../oauth/chatgpt";
55
58
  import { getMainAccountPlan, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
56
- import { captureConfigGeneration } from "../lib/state-store-sweeper";
59
+ import { captureConfigGeneration, registerStateSweepAfterTick } from "../lib/state-store-sweeper";
57
60
  import { reconcileLiveStateStores } from "../lib/state-store-registrations";
58
61
  import {
62
+ captureMainAccountIdentityGeneration,
59
63
  clearMainAccountInfoCache,
64
+ getMainAccountCredentialPresence,
60
65
  getMainAccountInfoCache,
66
+ isMainAccountIdentityGenerationLive,
67
+ setMainAccountCredentialPresence,
61
68
  setMainAccountInfoCache,
62
69
  type MainAccountInfo,
63
70
  } from "./main-account-cache";
@@ -82,7 +89,20 @@ import {
82
89
  isValidCodexAccountId,
83
90
  } from "./account-id";
84
91
  import { codexAccountIdNamespaceCollisionError } from "./account-namespace-match";
85
- import { ResourceAdmissionError } from "../lib/admission";
92
+ import { ResourceAdmissionError, type AdmissionLease } from "../lib/admission";
93
+ import { tryAcquireNativeMainProfileClaim } from "./native-main-admission";
94
+ import { withNativeMainSharedClaim } from "./native-main-claim";
95
+ import { resolveNativeProfileContext } from "./native-profile-store";
96
+ import { NativeProfileError } from "./native-profile-types";
97
+
98
+ function isNativeMainClaimUnavailable(error: unknown): error is NativeProfileError {
99
+ return error instanceof NativeProfileError
100
+ && (error.code === "NATIVE_MAIN_CLAIM_BUSY" || error.code === "NATIVE_MAIN_CLAIM_UNAVAILABLE");
101
+ }
102
+
103
+ function withNativeMainCredentialClaim<T>(operation: () => Promise<T>): Promise<T> {
104
+ return withNativeMainSharedClaim(resolveNativeProfileContext(), operation);
105
+ }
86
106
 
87
107
  function jsonResponse(data: unknown, status = 200): Response {
88
108
  return new Response(JSON.stringify(data), {
@@ -91,6 +111,12 @@ function jsonResponse(data: unknown, status = 200): Response {
91
111
  });
92
112
  }
93
113
 
114
+ function nativeMainProfileBusyResponse(): Response {
115
+ const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503);
116
+ response.headers.set("Retry-After", "1");
117
+ return response;
118
+ }
119
+
94
120
  const MANUAL_IMPORT_ENV = "OPENCODEX_ENABLE_UNVERIFIED_CODEX_IMPORT";
95
121
 
96
122
  const MAX_CODEX_LOGIN_STATE_ROWS = 32;
@@ -185,20 +211,52 @@ function poolAccountDto(
185
211
  };
186
212
  }
187
213
 
188
- async function resolveResetCreditAuth(
214
+ interface ResetCreditAuth {
215
+ isMain: boolean;
216
+ accessToken: string;
217
+ chatgptAccountId: string;
218
+ nativeMainLease?: AdmissionLease;
219
+ nativeMainSharedClaimHeld?: true;
220
+ }
221
+
222
+ async function withResetCreditAuth<T>(
189
223
  runtimeConfig: OcxConfig,
190
224
  accountId: string,
191
- ): Promise<
192
- | { ok: true; isMain: boolean; accessToken: string; chatgptAccountId: string }
193
- | { ok: false; response: Response }
194
- > {
225
+ operation: (auth: ResetCreditAuth) => Promise<T>,
226
+ ): Promise<{ ok: true; value: T } | { ok: false; response: Response }> {
195
227
  if (accountId === MAIN_CODEX_ACCOUNT_ID) {
196
228
  if (hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) {
197
229
  return { ok: false, response: jsonResponse({ error: "Remove the legacy __main__ pool row before using the Desktop account" }, 409) };
198
230
  }
199
- const tokens = readCodexTokens();
200
- if (!tokens) return { ok: false, response: jsonResponse({ error: "Main Codex account not logged in" }, 401) };
201
- return { ok: true, isMain: true, accessToken: tokens.access_token, chatgptAccountId: tokens.account_id };
231
+ const nativeMainLease = tryAcquireNativeMainProfileClaim();
232
+ if (!nativeMainLease) return { ok: false, response: nativeMainProfileBusyResponse() };
233
+ try {
234
+ try {
235
+ return await withNativeMainCredentialClaim(async () => {
236
+ const tokens = readCodexTokens();
237
+ if (!tokens) {
238
+ return { ok: false, response: jsonResponse({ error: "Main Codex account not logged in" }, 401) };
239
+ }
240
+ return {
241
+ ok: true,
242
+ value: await operation({
243
+ isMain: true,
244
+ accessToken: tokens.access_token,
245
+ chatgptAccountId: tokens.account_id,
246
+ nativeMainLease,
247
+ nativeMainSharedClaimHeld: true,
248
+ }),
249
+ };
250
+ });
251
+ } catch (error) {
252
+ if (isNativeMainClaimUnavailable(error)) {
253
+ return { ok: false, response: nativeMainProfileBusyResponse() };
254
+ }
255
+ throw error;
256
+ }
257
+ } finally {
258
+ nativeMainLease.release();
259
+ }
202
260
  }
203
261
  if (!isValidCodexAccountId(accountId)) {
204
262
  return { ok: false, response: jsonResponse({ error: "Invalid account id format" }, 400) };
@@ -207,7 +265,14 @@ async function resolveResetCreditAuth(
207
265
  return { ok: false, response: jsonResponse({ error: "Unknown Codex account" }, 404) };
208
266
  }
209
267
  const cred = await getValidCodexToken(accountId);
210
- return { ok: true, isMain: false, accessToken: cred.accessToken, chatgptAccountId: cred.chatgptAccountId };
268
+ return {
269
+ ok: true,
270
+ value: await operation({
271
+ isMain: false,
272
+ accessToken: cred.accessToken,
273
+ chatgptAccountId: cred.chatgptAccountId,
274
+ }),
275
+ };
211
276
  }
212
277
 
213
278
  function safeResetCreditsDto(input: unknown): { credits: { granted_at: string; expires_at: string }[]; available_count?: number } {
@@ -357,15 +422,33 @@ async function isTerminalMainAuthResponse(resp: Response): Promise<boolean> {
357
422
 
358
423
  interface MainAccountInfoFetchResult {
359
424
  info: MainAccountInfo;
425
+ /** Whether this attempt safely inspected the physical native-main credential. */
426
+ credentialChecked: boolean;
427
+ /** Meaningful only when credentialChecked is true. */
428
+ hasCredential: boolean;
429
+ /** Main identity generation captured while the native-main claim was held. */
430
+ identityGeneration?: number;
360
431
  /** Present only when this call freshly parsed a WHAM usage response. */
361
432
  freshQuota?: Omit<StoredAccountQuota, "updatedAt">;
362
433
  /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
363
434
  freshResetCredits?: number;
364
435
  }
365
436
 
437
+ export interface MainAccountInfoSnapshot {
438
+ info: MainAccountInfo;
439
+ mainIdentityGeneration: number;
440
+ }
441
+
442
+ export async function fetchMainAccountInfoSnapshot(forceRefresh = false): Promise<MainAccountInfoSnapshot> {
443
+ const result = await fetchMainAccountInfoAttempt(forceRefresh, 1);
444
+ return {
445
+ info: result.info,
446
+ mainIdentityGeneration: result.identityGeneration ?? captureMainAccountIdentityGeneration(),
447
+ };
448
+ }
449
+
366
450
  export async function fetchMainAccountInfo(forceRefresh = false): Promise<MainAccountInfo> {
367
- const { info } = await fetchMainAccountInfoAttempt(forceRefresh, 1);
368
- return info;
451
+ return (await fetchMainAccountInfoSnapshot(forceRefresh)).info;
369
452
  }
370
453
 
371
454
  const EMPTY_MAIN_ACCOUNT_INFO: MainAccountInfo = { email: null, plan: null, quota: null };
@@ -373,19 +456,64 @@ const EMPTY_MAIN_ACCOUNT_INFO: MainAccountInfo = { email: null, plan: null, quot
373
456
  async function retryMainAccountInfoIfIdentityChanged(
374
457
  requestAccountId: string | null,
375
458
  retriesRemaining: number,
459
+ nativeMainLease: AdmissionLease,
376
460
  ): Promise<MainAccountInfoFetchResult | null> {
377
461
  const currentAccountId = getMainChatgptAccountId();
378
462
  if (currentAccountId === null || currentAccountId === requestAccountId) return null;
379
463
  reconcileMainCodexAccountRuntimeState();
380
464
  return retriesRemaining > 0
381
- ? fetchMainAccountInfoAttempt(true, retriesRemaining - 1)
382
- : { info: EMPTY_MAIN_ACCOUNT_INFO };
465
+ ? fetchMainAccountInfoWhileOwned(true, retriesRemaining - 1, nativeMainLease)
466
+ : { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
467
+ }
468
+
469
+ async function fetchMainAccountInfoAttempt(
470
+ forceRefresh: boolean,
471
+ retriesRemaining: number,
472
+ existingNativeMainLease?: AdmissionLease,
473
+ nativeMainSharedClaimHeld = false,
474
+ ): Promise<MainAccountInfoFetchResult> {
475
+ const nativeMainLease = existingNativeMainLease ?? tryAcquireNativeMainProfileClaim();
476
+ if (!nativeMainLease) {
477
+ return {
478
+ info: EMPTY_MAIN_ACCOUNT_INFO,
479
+ credentialChecked: false,
480
+ hasCredential: false,
481
+ identityGeneration: captureMainAccountIdentityGeneration(),
482
+ };
483
+ }
484
+ try {
485
+ const operation = async () => ({
486
+ ...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease),
487
+ identityGeneration: captureMainAccountIdentityGeneration(),
488
+ });
489
+ if (nativeMainSharedClaimHeld) return await operation();
490
+ try {
491
+ return await withNativeMainCredentialClaim(operation);
492
+ } catch (error) {
493
+ if (isNativeMainClaimUnavailable(error)) {
494
+ return {
495
+ info: EMPTY_MAIN_ACCOUNT_INFO,
496
+ credentialChecked: false,
497
+ hasCredential: false,
498
+ identityGeneration: captureMainAccountIdentityGeneration(),
499
+ };
500
+ }
501
+ throw error;
502
+ }
503
+ } finally {
504
+ if (!existingNativeMainLease) nativeMainLease.release();
505
+ }
383
506
  }
384
507
 
385
- async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaining: number): Promise<MainAccountInfoFetchResult> {
508
+ async function fetchMainAccountInfoWhileOwned(
509
+ forceRefresh: boolean,
510
+ retriesRemaining: number,
511
+ nativeMainLease: AdmissionLease,
512
+ ): Promise<MainAccountInfoFetchResult> {
386
513
  const writerGeneration = captureConfigGeneration();
387
514
  reconcileMainCodexAccountRuntimeState();
388
515
  const tokenRead = readCodexTokensResult();
516
+ setMainAccountCredentialPresence(tokenRead.status === "ok");
389
517
  if (tokenRead.status !== "ok") {
390
518
  // A local read failure is NOT proof of sign-out: a missing file can be a non-atomic rewrite
391
519
  // gap, and malformed JSON can be a half-written file. Clearing the cache and marking the
@@ -394,13 +522,13 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
394
522
  // routing stays fail-closed because getMainAccountToken() re-reads the file itself, and the
395
523
  // account DTO still reports hasCredential=false while the file is unreadable.
396
524
  const preserved = getMainAccountInfoCache();
397
- return { info: preserved ?? EMPTY_MAIN_ACCOUNT_INFO };
525
+ return { info: preserved ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: false };
398
526
  }
399
527
  const tokens = tokenRead.tokens;
400
528
  const requestAccountId = extractAccountId(tokens.id_token, tokens.access_token) ?? (tokens.account_id || null);
401
529
  const cached = getMainAccountInfoCache();
402
530
  if (!forceRefresh && cached && Date.now() - cached.ts < MAIN_CACHE_TTL) {
403
- return { info: cached };
531
+ return { info: cached, credentialChecked: true, hasCredential: true };
404
532
  }
405
533
  try {
406
534
  const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
@@ -409,16 +537,16 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
409
537
  });
410
538
  if (!resp.ok) {
411
539
  const terminalAuthFailure = await isTerminalMainAuthResponse(resp);
412
- const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
540
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease);
413
541
  if (retried) return retried;
414
542
  if (terminalAuthFailure) {
415
543
  clearMainAccountInfoCache();
416
544
  markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration);
417
545
  }
418
- return { info: EMPTY_MAIN_ACCOUNT_INFO };
546
+ return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
419
547
  }
420
548
  const data = (await resp.json()) as WhamUsageResponse;
421
- const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
549
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease);
422
550
  if (retried) return retried;
423
551
  const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan());
424
552
  const quota = parseUsageQuota({ ...data, ...(plan ? { plan_type: plan } : {}) });
@@ -439,12 +567,14 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
439
567
  }
440
568
  return {
441
569
  info: result,
570
+ credentialChecked: true,
571
+ hasCredential: true,
442
572
  ...(quota ? { freshQuota: quota } : {}),
443
573
  ...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
444
574
  };
445
575
  } catch {
446
- const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
447
- return retried ?? { info: EMPTY_MAIN_ACCOUNT_INFO };
576
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease);
577
+ return retried ?? { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
448
578
  }
449
579
  }
450
580
 
@@ -689,6 +819,60 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co
689
819
  }
690
820
 
691
821
  let primeInFlight: Promise<void> | null = null;
822
+ let cooldownRecoveryInFlight: Promise<void> | null = null;
823
+
824
+ export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Date.now()): Promise<void> {
825
+ const openai = config.providers[OPENAI_CODEX_PROVIDER_ID];
826
+ if (!openai
827
+ || openai.disabled === true
828
+ || !isCanonicalOpenAiForwardProvider(openai)
829
+ || providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool") return;
830
+ if (cooldownRecoveryInFlight) return cooldownRecoveryInFlight;
831
+ cooldownRecoveryInFlight = (async () => {
832
+ const claims = claimDueCodexQuotaRecoveryProbes(config, POOL_QUOTA_REFRESH_CONCURRENCY, now);
833
+ await mapWithConcurrency(claims, POOL_QUOTA_REFRESH_CONCURRENCY, async claim => {
834
+ const account = configuredPoolAccount(config, claim.accountId);
835
+ if (!account) {
836
+ settleCodexQuotaRecoveryProbe(claim, false, {}, now);
837
+ return;
838
+ }
839
+ try {
840
+ const result = await fetchPoolAccountQuota(claim.accountId, true, account.plan);
841
+ // Defence in depth: `spark` is already excluded at the claim site, since generic WHAM
842
+ // cannot prove a spark recovery. Keep the settle-side guard so a future claim change
843
+ // cannot silently start clearing spark on generic evidence.
844
+ const recovered = claim.scope !== "spark"
845
+ && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? account.plan);
846
+ settleCodexQuotaRecoveryProbe(claim, recovered, {
847
+ credentialGeneration: result.freshCredentialGeneration,
848
+ }, now);
849
+ } catch {
850
+ settleCodexQuotaRecoveryProbe(claim, false, {}, now);
851
+ }
852
+ });
853
+ })().catch(() => {
854
+ // Background recovery is best-effort; routing keeps the cooldown on failure.
855
+ }).finally(() => { cooldownRecoveryInFlight = null; });
856
+ return cooldownRecoveryInFlight;
857
+ }
858
+
859
+ export function registerCodexCooldownRecoveryProbeWorker(config: OcxConfig): void {
860
+ registerStateSweepAfterTick({
861
+ name: "codex-cooldown-recovery",
862
+ afterTick: () => { void runCodexCooldownRecoveryProbes(config); },
863
+ });
864
+ }
865
+
866
+ export interface PrimeCodexPoolQuotasOptions {
867
+ /** Test seams for proving fenced/recovery priming performs no native-main work. */
868
+ reconcileMainAccount?: typeof reconcileMainCodexAccountRuntimeState;
869
+ readMainTokens?: typeof readCodexTokens;
870
+ fetchMainInfo?: typeof fetchMainAccountInfo;
871
+ }
872
+
873
+ function tryAcquireNativeMainPrimeLease(): AdmissionLease | null {
874
+ return tryAcquireNativeMainProfileClaim();
875
+ }
692
876
 
693
877
  /**
694
878
  * Best-effort prime of pool-account (and main) quota so the rotation engine has
@@ -705,7 +889,11 @@ let primeInFlight: Promise<void> | null = null;
705
889
  * cost, so the worst case is one WHAM call per account per TTL window. Failures
706
890
  * are swallowed: a blocked WSL network must never crash startup or a request.
707
891
  */
708
- export async function primeCodexPoolQuotas(config: OcxConfig, reason: string): Promise<void> {
892
+ export async function primeCodexPoolQuotas(
893
+ config: OcxConfig,
894
+ reason: string,
895
+ options: PrimeCodexPoolQuotasOptions = {},
896
+ ): Promise<void> {
709
897
  const openai = config.providers[OPENAI_CODEX_PROVIDER_ID];
710
898
  if (
711
899
  !openai
@@ -714,10 +902,6 @@ export async function primeCodexPoolQuotas(config: OcxConfig, reason: string): P
714
902
  || providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool"
715
903
  ) return;
716
904
  if (primeInFlight) return primeInFlight;
717
- // Seed the observed physical main identity before startup/lazy priming can populate quota or
718
- // plan state. Otherwise the first post-startup account switch sees no previous identity and
719
- // skips the purge that protects the stable __main__ alias.
720
- reconcileMainCodexAccountRuntimeState();
721
905
  primeInFlight = (async () => {
722
906
  const runtimeConfig = getRuntimeConfig(config);
723
907
  const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
@@ -725,10 +909,30 @@ export async function primeCodexPoolQuotas(config: OcxConfig, reason: string): P
725
909
  const q = getAccountQuota(a.id);
726
910
  return !q || Date.now() - q.updatedAt >= POOL_CACHE_TTL;
727
911
  });
728
- const primeMain = !!readCodexTokens() && !getAccountQuota(MAIN_CODEX_ACCOUNT_ID);
912
+ const primeMain = async () => {
913
+ const mainLease = tryAcquireNativeMainPrimeLease();
914
+ if (!mainLease) return;
915
+ try {
916
+ try {
917
+ await withNativeMainCredentialClaim(async () => {
918
+ // Keep one local owner and one cross-process reader from physical
919
+ // identity reconciliation through WHAM and all quota publication.
920
+ (options.reconcileMainAccount ?? reconcileMainCodexAccountRuntimeState)();
921
+ if (getAccountQuota(MAIN_CODEX_ACCOUNT_ID)) return;
922
+ if (!(options.readMainTokens ?? readCodexTokens)()) return;
923
+ if (options.fetchMainInfo) await options.fetchMainInfo(false);
924
+ else await fetchMainAccountInfoAttempt(false, 1, mainLease, true);
925
+ });
926
+ } catch (error) {
927
+ if (!isNativeMainClaimUnavailable(error)) throw error;
928
+ }
929
+ } finally {
930
+ mainLease.release();
931
+ }
932
+ };
729
933
  try {
730
934
  await Promise.allSettled([
731
- primeMain ? fetchMainAccountInfo(false) : Promise.resolve(),
935
+ primeMain(),
732
936
  mapWithConcurrency(stale, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
733
937
  if (!getCodexAccountCredential(a.id)) return;
734
938
  await fetchPoolAccountQuota(a.id, false, a.plan);
@@ -750,10 +954,27 @@ export function clearCodexQuotaPrimeState(): void {
750
954
  primeInFlight = null;
751
955
  }
752
956
 
753
- export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = false): Promise<CodexAuthAccountDto[]> {
957
+ /** Test-only reset for the worker-level single-flight. */
958
+ export function clearCodexCooldownRecoveryProbeState(): void {
959
+ cooldownRecoveryInFlight = null;
960
+ }
961
+
962
+ export function effectiveCodexAuthAccountId(config: OcxConfig): string {
963
+ return getEffectiveActiveCodexAccountId(config) ?? MAIN_CODEX_ACCOUNT_ID;
964
+ }
965
+
966
+ export interface CodexAuthAccountsSnapshot {
967
+ accounts: CodexAuthAccountDto[];
968
+ mainIdentityGeneration: number;
969
+ }
970
+
971
+ export async function listCodexAuthAccountsSnapshot(
972
+ config: OcxConfig,
973
+ forceRefresh = false,
974
+ ): Promise<CodexAuthAccountsSnapshot> {
754
975
  const runtimeConfig = getRuntimeConfig(config);
755
976
  const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
756
- const mainInfo = await fetchMainAccountInfo(forceRefresh);
977
+ const mainResult = await fetchMainAccountInfoAttempt(forceRefresh, 1);
757
978
  const refreshedPool = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
758
979
  const cred = getCodexAccountCredential(account.id);
759
980
  let quotaResult: PoolQuotaResult;
@@ -816,8 +1037,14 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa
816
1037
  isCodexAccountPaused(runtimeConfig, accountId),
817
1038
  )];
818
1039
  });
819
- const hasMainCredential = readCodexTokens() !== null;
820
- const mainNeedsReauth = !hasMainCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
1040
+ const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration();
1041
+ const mainSnapshotLive = isMainAccountIdentityGenerationLive(fetchedMainGeneration);
1042
+ const mainInfo = mainSnapshotLive ? mainResult.info : EMPTY_MAIN_ACCOUNT_INFO;
1043
+ const hasMainCredential = mainSnapshotLive && mainResult.credentialChecked
1044
+ ? mainResult.hasCredential
1045
+ : getMainAccountCredentialPresence() ?? false;
1046
+ const mainNeedsReauth = (mainSnapshotLive && mainResult.credentialChecked && !hasMainCredential)
1047
+ || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
821
1048
  const mainHealth = projectCodexAccountHealth({
822
1049
  accountId: MAIN_CODEX_ACCOUNT_ID,
823
1050
  needsReauth: mainNeedsReauth,
@@ -830,10 +1057,24 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa
830
1057
  paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
831
1058
  hasCredential: hasMainCredential,
832
1059
  needsReauth: mainNeedsReauth,
833
- quota: mainInfo.quota ? { ...quotaForPlan({ ...mainInfo.quota, updatedAt: Date.now() }, mainInfo.plan) } : null,
1060
+ quota: mainInfo.quota ? {
1061
+ ...quotaForPlan({
1062
+ ...mainInfo.quota,
1063
+ updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(),
1064
+ }, mainInfo.plan),
1065
+ } : null,
834
1066
  ...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
835
1067
  };
836
- return [main, ...withQuota];
1068
+ return {
1069
+ accounts: [main, ...withQuota],
1070
+ mainIdentityGeneration: mainSnapshotLive
1071
+ ? fetchedMainGeneration
1072
+ : captureMainAccountIdentityGeneration(),
1073
+ };
1074
+ }
1075
+
1076
+ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = false): Promise<CodexAuthAccountDto[]> {
1077
+ return (await listCodexAuthAccountsSnapshot(config, forceRefresh)).accounts;
837
1078
  }
838
1079
 
839
1080
  interface PauseExhaustedResult {
@@ -846,57 +1087,95 @@ function selectFallbackAfterPause(config: OcxConfig, pausedActiveId: string): vo
846
1087
  reconcileCodexActiveAfterExclusion(config, pausedActiveId);
847
1088
  }
848
1089
 
849
- async function pauseExhaustedCodexAccounts(config: OcxConfig): Promise<PauseExhaustedResult> {
1090
+ async function pauseExhaustedCodexAccounts(
1091
+ config: OcxConfig,
1092
+ persistPausedAccounts: () => void,
1093
+ ): Promise<PauseExhaustedResult> {
850
1094
  const poolAccounts = (config.codexAccounts ?? []).filter(account => !account.isMain);
851
- const mainAttempted = readCodexTokens() !== null;
852
- const [mainResult, poolResults] = await Promise.all([
853
- fetchMainAccountInfoAttempt(true, 1),
854
- mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
855
- if (!getCodexAccountCredential(account.id)) return { account, quotaResult: null };
856
- return {
857
- account,
858
- quotaResult: await fetchPoolAccountQuota(account.id, true, account.plan),
1095
+ const nativeMainLease = tryAcquireNativeMainProfileClaim();
1096
+ try {
1097
+ const performPause = async (mainLease?: AdmissionLease): Promise<PauseExhaustedResult> => {
1098
+ const mainWork = async (): Promise<{
1099
+ shouldPause: boolean;
1100
+ checkedAccountCount: number;
1101
+ failedAccountCount: number;
1102
+ }> => {
1103
+ if (!mainLease) return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 1 };
1104
+ const mainResult = await fetchMainAccountInfoAttempt(true, 1, mainLease, true);
1105
+ if (!mainResult.credentialChecked || !mainResult.hasCredential) {
1106
+ return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 0 };
1107
+ }
1108
+ if (!mainResult.freshQuota || !mainResult.info.plan) {
1109
+ return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 1 };
1110
+ }
1111
+ return {
1112
+ shouldPause: !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
1113
+ && isCodexQuotaExhausted(mainResult.freshQuota, mainResult.info.plan),
1114
+ checkedAccountCount: 1,
1115
+ failedAccountCount: 0,
1116
+ };
859
1117
  };
860
- }),
861
- ]);
862
-
863
- let checkedAccountCount = 0;
864
- let failedAccountCount = 0;
865
- const exhaustedIds: string[] = [];
866
- if (mainAttempted) {
867
- if (mainResult.freshQuota && mainResult.info.plan) {
868
- checkedAccountCount += 1;
869
- if (
870
- !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
871
- && isCodexQuotaExhausted(mainResult.freshQuota, mainResult.info.plan)
872
- ) {
873
- exhaustedIds.push(MAIN_CODEX_ACCOUNT_ID);
1118
+ const [mainResult, poolResults] = await Promise.all([
1119
+ mainWork(),
1120
+ mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
1121
+ if (!getCodexAccountCredential(account.id)) return { account, quotaResult: null };
1122
+ try {
1123
+ return {
1124
+ account,
1125
+ quotaResult: await fetchPoolAccountQuota(account.id, true, account.plan),
1126
+ };
1127
+ } catch {
1128
+ // Settle each pool probe independently so a busy/failing account cannot
1129
+ // abandon an already-confirmed main decision before atomic publication.
1130
+ return { account, quotaResult: null };
1131
+ }
1132
+ }),
1133
+ ]);
1134
+
1135
+ let checkedAccountCount = mainResult.checkedAccountCount;
1136
+ let failedAccountCount = mainResult.failedAccountCount;
1137
+ const exhaustedIds: string[] = mainResult.shouldPause ? [MAIN_CODEX_ACCOUNT_ID] : [];
1138
+ for (const { account, quotaResult } of poolResults) {
1139
+ const currentAccount = (config.codexAccounts ?? []).find(candidate => candidate.id === account.id && !candidate.isMain);
1140
+ if (!currentAccount) continue;
1141
+ const generation = quotaResult?.freshCredentialGeneration;
1142
+ const plan = quotaResult?.freshPlan ?? currentAccount.plan;
1143
+ if (!quotaResult?.freshQuota || generation === undefined || !isCodexAccountGenerationLive(account.id, generation) || !plan) {
1144
+ failedAccountCount += 1;
1145
+ continue;
1146
+ }
1147
+ checkedAccountCount += 1;
1148
+ if (!isCodexAccountPaused(config, account.id) && isCodexQuotaExhausted(quotaResult.freshQuota, plan)) {
1149
+ exhaustedIds.push(account.id);
1150
+ }
874
1151
  }
875
- } else {
876
- failedAccountCount += 1;
877
- }
878
- }
879
- for (const { account, quotaResult } of poolResults) {
880
- const currentAccount = (config.codexAccounts ?? []).find(candidate => candidate.id === account.id && !candidate.isMain);
881
- if (!currentAccount) continue;
882
- const generation = quotaResult?.freshCredentialGeneration;
883
- const plan = quotaResult?.freshPlan ?? currentAccount.plan;
884
- if (!quotaResult?.freshQuota || generation === undefined || !isCodexAccountGenerationLive(account.id, generation) || !plan) {
885
- failedAccountCount += 1;
886
- continue;
887
- }
888
- checkedAccountCount += 1;
889
- if (!isCodexAccountPaused(config, account.id) && isCodexQuotaExhausted(quotaResult.freshQuota, plan)) {
890
- exhaustedIds.push(account.id);
891
- }
892
- }
893
1152
 
894
- for (const id of exhaustedIds) {
895
- setCodexAccountPaused(config, id, true);
896
- clearThreadAccountMapForAccount(id);
1153
+ for (const id of exhaustedIds) {
1154
+ setCodexAccountPaused(config, id, true);
1155
+ clearThreadAccountMapForAccount(id);
1156
+ }
1157
+ for (const id of exhaustedIds) selectFallbackAfterPause(config, id);
1158
+ const result = {
1159
+ pausedAccountIds: exhaustedIds,
1160
+ checkedAccountCount,
1161
+ failedAccountCount,
1162
+ };
1163
+ // Persist while both the in-process admission and cross-process shared
1164
+ // claim still own the physical-main identity used for the decision.
1165
+ if (result.pausedAccountIds.length > 0) persistPausedAccounts();
1166
+ return result;
1167
+ };
1168
+
1169
+ if (!nativeMainLease) return await performPause();
1170
+ try {
1171
+ return await withNativeMainCredentialClaim(() => performPause(nativeMainLease));
1172
+ } catch (error) {
1173
+ if (isNativeMainClaimUnavailable(error)) return await performPause();
1174
+ throw error;
1175
+ }
1176
+ } finally {
1177
+ nativeMainLease?.release();
897
1178
  }
898
- for (const id of exhaustedIds) selectFallbackAfterPause(config, id);
899
- return { pausedAccountIds: exhaustedIds, checkedAccountCount, failedAccountCount };
900
1179
  }
901
1180
 
902
1181
  export async function handleCodexAuthAPI(
@@ -1020,7 +1299,10 @@ export async function handleCodexAuthAPI(
1020
1299
 
1021
1300
  if (url.pathname === "/api/codex-auth/accounts/pause-exhausted" && req.method === "PUT") {
1022
1301
  const runtimeConfig = getRuntimeConfig(config);
1023
- const result = await pauseExhaustedCodexAccounts(runtimeConfig);
1302
+ const result = await pauseExhaustedCodexAccounts(
1303
+ runtimeConfig,
1304
+ () => saveRuntimeConfig(config, runtimeConfig),
1305
+ );
1024
1306
  const { pausedAccountIds, checkedAccountCount, failedAccountCount } = result;
1025
1307
  if (checkedAccountCount === 0 && failedAccountCount > 0) {
1026
1308
  return jsonResponse({
@@ -1030,7 +1312,6 @@ export async function handleCodexAuthAPI(
1030
1312
  failedAccountCount,
1031
1313
  }, 502);
1032
1314
  }
1033
- if (pausedAccountIds.length > 0) saveRuntimeConfig(config, runtimeConfig);
1034
1315
  return jsonResponse({
1035
1316
  ok: true,
1036
1317
  pausedAccountIds,
@@ -1169,24 +1450,24 @@ export async function handleCodexAuthAPI(
1169
1450
  if (!accountId) return jsonResponse({ error: "accountId required" }, 400);
1170
1451
 
1171
1452
  try {
1172
- const auth = await resolveResetCreditAuth(getRuntimeConfig(config), accountId);
1173
- if (!auth.ok) return auth.response;
1174
-
1175
- const resp = await fetch(
1176
- "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits",
1177
- {
1178
- headers: {
1179
- Authorization: `Bearer ${auth.accessToken}`,
1180
- "ChatGPT-Account-Id": auth.chatgptAccountId,
1453
+ const result = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => {
1454
+ const resp = await fetch(
1455
+ "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits",
1456
+ {
1457
+ headers: {
1458
+ Authorization: `Bearer ${auth.accessToken}`,
1459
+ "ChatGPT-Account-Id": auth.chatgptAccountId,
1460
+ },
1461
+ signal: AbortSignal.timeout(8000),
1181
1462
  },
1182
- signal: AbortSignal.timeout(8000),
1183
- },
1184
- );
1185
- if (!resp.ok) {
1186
- await resp.body?.cancel().catch(() => {});
1187
- return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
1188
- }
1189
- return jsonResponse(safeResetCreditsDto(await resp.json()));
1463
+ );
1464
+ if (!resp.ok) {
1465
+ await resp.body?.cancel().catch(() => {});
1466
+ return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
1467
+ }
1468
+ return jsonResponse(safeResetCreditsDto(await resp.json()));
1469
+ });
1470
+ return result.ok ? result.value : result.response;
1190
1471
  } catch (e) {
1191
1472
  return jsonResponse({ error: e instanceof Error ? e.message : "Reset credit lookup failed" }, 500);
1192
1473
  }
@@ -1195,49 +1476,55 @@ export async function handleCodexAuthAPI(
1195
1476
  if (url.pathname === "/api/codex-auth/reset-credits/consume" && req.method === "POST") {
1196
1477
  const body = (await req.json().catch(() => ({}))) as { accountId?: string };
1197
1478
  if (!body.accountId) return jsonResponse({ error: "accountId required" }, 400);
1479
+ const accountId = body.accountId;
1198
1480
 
1199
1481
  try {
1200
- const auth = await resolveResetCreditAuth(getRuntimeConfig(config), body.accountId);
1201
- if (!auth.ok) return auth.response;
1202
-
1203
- const idempotencyKey = crypto.randomUUID();
1204
- const resp = await fetch(
1205
- "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume",
1206
- {
1207
- method: "POST",
1208
- headers: {
1209
- Authorization: `Bearer ${auth.accessToken}`,
1210
- "ChatGPT-Account-Id": auth.chatgptAccountId,
1211
- "Content-Type": "application/json",
1482
+ const operation = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => {
1483
+ const idempotencyKey = crypto.randomUUID();
1484
+ const resp = await fetch(
1485
+ "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume",
1486
+ {
1487
+ method: "POST",
1488
+ headers: {
1489
+ Authorization: `Bearer ${auth.accessToken}`,
1490
+ "ChatGPT-Account-Id": auth.chatgptAccountId,
1491
+ "Content-Type": "application/json",
1492
+ },
1493
+ body: JSON.stringify({ redeem_request_id: idempotencyKey }),
1494
+ signal: AbortSignal.timeout(10_000),
1212
1495
  },
1213
- body: JSON.stringify({ redeem_request_id: idempotencyKey }),
1214
- signal: AbortSignal.timeout(10_000),
1215
- },
1216
- );
1217
- if (!resp.ok) {
1218
- await resp.body?.cancel().catch(() => {});
1219
- return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
1220
- }
1221
- const result = safeResetCreditConsumeDto(await resp.json());
1222
- // After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage
1223
- // and return remaining only when that refresh freshly parsed available_count.
1224
- // Do not fall back to a preserved cached resetCredits (failed/omitted refresh).
1225
- if (result.code === "reset" || result.code === "already_redeemed") {
1226
- let freshResetCredits: number | undefined;
1227
- if (auth.isMain) {
1228
- ({ freshResetCredits } = await fetchMainAccountInfoAttempt(true, 1));
1229
- } else {
1230
- const account = configuredPoolAccount(getRuntimeConfig(config), body.accountId);
1231
- ({ freshResetCredits } = await fetchPoolAccountQuota(body.accountId, true, account?.plan));
1496
+ );
1497
+ if (!resp.ok) {
1498
+ await resp.body?.cancel().catch(() => {});
1499
+ return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status);
1232
1500
  }
1233
- return jsonResponse({
1234
- code: result.code,
1235
- ...(typeof freshResetCredits === "number" && Number.isFinite(freshResetCredits)
1236
- ? { remaining: freshResetCredits }
1237
- : {}),
1238
- });
1239
- }
1240
- return jsonResponse(result);
1501
+ const result = safeResetCreditConsumeDto(await resp.json());
1502
+ // After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage
1503
+ // and return remaining only when that refresh freshly parsed available_count.
1504
+ // Do not fall back to a preserved cached resetCredits (failed/omitted refresh).
1505
+ if (result.code === "reset" || result.code === "already_redeemed") {
1506
+ let freshResetCredits: number | undefined;
1507
+ if (auth.isMain) {
1508
+ ({ freshResetCredits } = await fetchMainAccountInfoAttempt(
1509
+ true,
1510
+ 1,
1511
+ auth.nativeMainLease,
1512
+ auth.nativeMainSharedClaimHeld === true,
1513
+ ));
1514
+ } else {
1515
+ const account = configuredPoolAccount(getRuntimeConfig(config), accountId);
1516
+ ({ freshResetCredits } = await fetchPoolAccountQuota(accountId, true, account?.plan));
1517
+ }
1518
+ return jsonResponse({
1519
+ code: result.code,
1520
+ ...(typeof freshResetCredits === "number" && Number.isFinite(freshResetCredits)
1521
+ ? { remaining: freshResetCredits }
1522
+ : {}),
1523
+ });
1524
+ }
1525
+ return jsonResponse(result);
1526
+ });
1527
+ return operation.ok ? operation.value : operation.response;
1241
1528
  } catch (e) {
1242
1529
  if (e instanceof PoolQuotaProbeBusyError) {
1243
1530
  const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503);