@bitkyc08/opencodex 2.8.0 → 2.9.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 (175) hide show
  1. package/README.md +24 -0
  2. package/bin/ocx.mjs +32 -4
  3. package/gui/dist/assets/index-CHwf3tTD.css +1 -0
  4. package/gui/dist/assets/index-u5eFOv2y.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-image-normalize.ts +114 -20
  8. package/src/adapters/anthropic.ts +126 -10
  9. package/src/adapters/azure.ts +3 -3
  10. package/src/adapters/base.ts +7 -3
  11. package/src/adapters/cursor/discovery.ts +14 -4
  12. package/src/adapters/cursor/effort-map.ts +18 -6
  13. package/src/adapters/cursor/framing.ts +102 -27
  14. package/src/adapters/cursor/kv-store.ts +30 -3
  15. package/src/adapters/cursor/live-models.ts +22 -2
  16. package/src/adapters/cursor/live-transport.ts +245 -49
  17. package/src/adapters/cursor/mcp-manager.ts +105 -8
  18. package/src/adapters/cursor/native-exec-mcp.ts +5 -3
  19. package/src/adapters/cursor/native-exec-shell.ts +296 -14
  20. package/src/adapters/cursor/native-exec.ts +381 -33
  21. package/src/adapters/cursor/protobuf-events.ts +28 -1
  22. package/src/adapters/cursor/protobuf-request.ts +71 -39
  23. package/src/adapters/cursor/request-builder.ts +2 -2
  24. package/src/adapters/cursor/transport.ts +2 -0
  25. package/src/adapters/cursor.ts +13 -2
  26. package/src/adapters/google-antigravity-replay.ts +184 -17
  27. package/src/adapters/google.ts +58 -8
  28. package/src/adapters/kiro-thinking.ts +23 -9
  29. package/src/adapters/kiro-tools.ts +49 -18
  30. package/src/adapters/kiro.ts +377 -133
  31. package/src/adapters/mimo-free.ts +36 -4
  32. package/src/adapters/openai-chat.ts +143 -17
  33. package/src/adapters/openai-responses.ts +130 -14
  34. package/src/adapters/run-turn-queue.ts +7 -1
  35. package/src/bridge.ts +466 -69
  36. package/src/chat/outbound.ts +144 -38
  37. package/src/claude/inbound-debug.ts +53 -8
  38. package/src/claude/outbound.ts +224 -38
  39. package/src/cli/agent-driven.ts +34 -1
  40. package/src/cli/catalog-prewarm.ts +5 -2
  41. package/src/cli/claude-desktop.ts +2 -2
  42. package/src/cli/doctor.ts +12 -0
  43. package/src/cli/export-command.ts +187 -0
  44. package/src/cli/help.ts +11 -0
  45. package/src/cli/index.ts +13 -3
  46. package/src/cli/init.ts +129 -102
  47. package/src/cli/opencode.ts +36 -151
  48. package/src/cli/star-prompt.ts +13 -4
  49. package/src/cli/status-oauth.ts +12 -2
  50. package/src/clients/config-export.ts +377 -0
  51. package/src/codex/account-runtime-state.ts +19 -1
  52. package/src/codex/account-store.ts +162 -82
  53. package/src/codex/auth-api.ts +467 -159
  54. package/src/codex/auth-context.ts +15 -2
  55. package/src/codex/catalog/aggregation.ts +15 -0
  56. package/src/codex/catalog/effort.ts +16 -6
  57. package/src/codex/catalog/metadata.ts +6 -0
  58. package/src/codex/catalog/parsing.ts +3 -1
  59. package/src/codex/catalog/provider-fetch.ts +29 -0
  60. package/src/codex/catalog/sync.ts +64 -7
  61. package/src/codex/catalog.ts +2 -2
  62. package/src/codex/inject.ts +5 -5
  63. package/src/codex/main-account-cache.ts +8 -1
  64. package/src/codex/model-cache.ts +81 -2
  65. package/src/codex/pool-rotation.ts +39 -0
  66. package/src/codex/project-config-warnings.ts +12 -1
  67. package/src/codex/quota.ts +35 -3
  68. package/src/codex/routing.ts +46 -1
  69. package/src/codex/shim.ts +10 -4
  70. package/src/codex/subagent-model-fallback.ts +12 -0
  71. package/src/codex/websocket-registry.ts +27 -0
  72. package/src/combos/failover.ts +31 -1
  73. package/src/combos/request.ts +9 -0
  74. package/src/combos/resolve.ts +60 -4
  75. package/src/combos/types.ts +12 -0
  76. package/src/config.ts +510 -55
  77. package/src/github/star-state.ts +13 -1
  78. package/src/images/fulfill.ts +39 -1
  79. package/src/images/loop.ts +52 -12
  80. package/src/lib/admission.ts +83 -0
  81. package/src/lib/app-owned-memory-stores.ts +173 -0
  82. package/src/lib/app-owned-memory.ts +265 -0
  83. package/src/lib/bun-stream-caps.ts +31 -7
  84. package/src/lib/config-ownership.ts +33 -0
  85. package/src/lib/crash-guard.ts +65 -5
  86. package/src/lib/debug-log-buffer.ts +47 -6
  87. package/src/lib/destination-policy.ts +12 -1
  88. package/src/lib/errors.ts +3 -0
  89. package/src/lib/gcp-adc.ts +40 -2
  90. package/src/lib/injection-debug-log.ts +26 -2
  91. package/src/lib/provider-outbound.ts +3 -0
  92. package/src/lib/sidecar-tracker.ts +5 -2
  93. package/src/lib/sse-decoder.ts +257 -37
  94. package/src/lib/state-store-registrations.ts +109 -0
  95. package/src/lib/state-store-sweeper.ts +184 -0
  96. package/src/lib/translator-budget.ts +356 -0
  97. package/src/lib/windows-secret-acl.ts +33 -12
  98. package/src/lib/winsw.ts +14 -1
  99. package/src/oauth/anthropic-routing.ts +31 -7
  100. package/src/oauth/google-antigravity.ts +2 -1
  101. package/src/oauth/health.ts +30 -12
  102. package/src/oauth/index.ts +127 -23
  103. package/src/oauth/kiro-credentials.ts +72 -1
  104. package/src/oauth/kiro.ts +23 -4
  105. package/src/oauth/store.ts +165 -18
  106. package/src/oauth/token-guardian.ts +43 -4
  107. package/src/oauth/types.ts +2 -1
  108. package/src/providers/base-url-choices.ts +10 -0
  109. package/src/providers/derive.ts +12 -0
  110. package/src/providers/free-directory.ts +4 -1
  111. package/src/providers/key-failover.ts +12 -0
  112. package/src/providers/openai-sidecar.ts +4 -1
  113. package/src/providers/quota.ts +68 -7
  114. package/src/providers/registry.ts +279 -3
  115. package/src/responses/parser.ts +5 -1
  116. package/src/responses/spill-store.ts +394 -0
  117. package/src/responses/state.ts +520 -102
  118. package/src/router.ts +18 -1
  119. package/src/server/adapter-resolve.ts +20 -3
  120. package/src/server/auth-cors.ts +121 -28
  121. package/src/server/chat-completions.ts +57 -12
  122. package/src/server/claude-messages.ts +85 -13
  123. package/src/server/index.ts +242 -100
  124. package/src/server/lifecycle.ts +155 -25
  125. package/src/server/management/agent-settings-routes.ts +79 -36
  126. package/src/server/management/api-key-usage.ts +167 -0
  127. package/src/server/management/body.ts +35 -0
  128. package/src/server/management/combo-routes.ts +5 -1
  129. package/src/server/management/config-routes.ts +42 -12
  130. package/src/server/management/logs-usage-routes.ts +41 -21
  131. package/src/server/management/model-routes.ts +188 -54
  132. package/src/server/management/oauth-account-routes.ts +115 -26
  133. package/src/server/management/provider-routes.ts +56 -6
  134. package/src/server/management/shared.ts +16 -3
  135. package/src/server/management/sidebar-routes.ts +50 -1
  136. package/src/server/management/system-restart.ts +13 -6
  137. package/src/server/management/system-routes.ts +15 -3
  138. package/src/server/management/usage-summary-cache.ts +86 -0
  139. package/src/server/management-api.ts +39 -5
  140. package/src/server/management-auth.ts +65 -14
  141. package/src/server/port-reclaim.ts +58 -12
  142. package/src/server/ports.ts +2 -0
  143. package/src/server/proxy-liveness.ts +60 -14
  144. package/src/server/relay-eager.ts +20 -4
  145. package/src/server/relay.ts +548 -154
  146. package/src/server/request-decompress.ts +51 -4
  147. package/src/server/request-log.ts +134 -15
  148. package/src/server/responses/collaboration.ts +15 -4
  149. package/src/server/responses/compact.ts +3 -0
  150. package/src/server/responses/core.ts +241 -66
  151. package/src/server/responses-image-gen-repair.ts +19 -5
  152. package/src/server/responses-item-id-repair.ts +23 -5
  153. package/src/server/sse-payload-rewrite.ts +71 -12
  154. package/src/server/startup-health-cache.ts +14 -1
  155. package/src/server/system-env.ts +8 -1
  156. package/src/server/windows-tcp-drop.ts +15 -5
  157. package/src/server/ws-bridge.ts +25 -0
  158. package/src/service.ts +179 -13
  159. package/src/storage/policy-job.ts +93 -23
  160. package/src/storage/policy-worker.ts +6 -0
  161. package/src/storage/restore-job.ts +62 -16
  162. package/src/storage/restore-worker.ts +6 -0
  163. package/src/storage/storage-mutation-coordinator.ts +36 -6
  164. package/src/storage/worker-lifecycle.ts +181 -47
  165. package/src/tray/windows.ts +97 -25
  166. package/src/types.ts +39 -6
  167. package/src/update/index.ts +24 -5
  168. package/src/update/job.ts +598 -73
  169. package/src/usage/log.ts +115 -17
  170. package/src/usage/summary.ts +67 -2
  171. package/src/vision/index.ts +112 -22
  172. package/src/web-search/loop.ts +38 -6
  173. package/src/web-search/progress-stream.ts +14 -3
  174. package/gui/dist/assets/index-BDjpkcRN.js +0 -67
  175. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
@@ -1,13 +1,16 @@
1
- import { loadConfig, saveConfigPreservingClaudeCode } from "../config";
1
+ import { ConfigMutationLockError, loadConfig, mutatePersistedConfig, saveConfigPreservingClaudeCode } from "../config";
2
2
  import { withCodexAccountLogLabel } from "./account-label";
3
3
  import {
4
4
  getCodexAccountCredential,
5
5
  getValidCodexToken,
6
6
  isCodexAccountGenerationLive,
7
7
  markCodexAccountValidated,
8
+ readCodexAccountRecord,
8
9
  saveCodexAccountCredential,
9
10
  CodexCredentialGenerationConflictError,
10
11
  CodexCredentialRefreshLockTimeoutError,
12
+ CodexCredentialRefreshBusyError,
13
+ CodexCredentialRefreshStaleError,
11
14
  TokenRefreshError,
12
15
  } from "./account-store";
13
16
  import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
@@ -50,6 +53,8 @@ export {
50
53
  } from "./quota";
51
54
  import { extractAccountId, decodeJwtPayload } from "../oauth/chatgpt";
52
55
  import { getMainAccountPlan, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
56
+ import { captureConfigGeneration } from "../lib/state-store-sweeper";
57
+ import { reconcileLiveStateStores } from "../lib/state-store-registrations";
53
58
  import {
54
59
  clearMainAccountInfoCache,
55
60
  getMainAccountInfoCache,
@@ -77,6 +82,7 @@ import {
77
82
  isValidCodexAccountId,
78
83
  } from "./account-id";
79
84
  import { codexAccountIdNamespaceCollisionError } from "./account-namespace-match";
85
+ import { ResourceAdmissionError } from "../lib/admission";
80
86
 
81
87
  function jsonResponse(data: unknown, status = 200): Response {
82
88
  return new Response(JSON.stringify(data), {
@@ -87,7 +93,30 @@ function jsonResponse(data: unknown, status = 200): Response {
87
93
 
88
94
  const MANUAL_IMPORT_ENV = "OPENCODEX_ENABLE_UNVERIFIED_CODEX_IMPORT";
89
95
 
90
- const codexAuthLoginState = new Map<string, { status: string; accountId?: string; email?: string; error?: string; doneAt?: number }>();
96
+ const MAX_CODEX_LOGIN_STATE_ROWS = 32;
97
+ const CODEX_LOGIN_TERMINAL_TTL_MS = 300_000;
98
+ interface CodexLoginStateRow { status: string; startedAt: number; accountId?: string; email?: string; error?: string; doneAt?: number }
99
+ const codexAuthLoginState = new Map<string, CodexLoginStateRow>();
100
+ export class CodexLoginStateBusyError extends ResourceAdmissionError {
101
+ constructor() { super("codex_login_state_rows", MAX_CODEX_LOGIN_STATE_ROWS); this.name = "CodexLoginStateBusyError"; }
102
+ }
103
+
104
+ function setCodexLoginState(flowId: string, patch: Partial<CodexLoginStateRow>): void {
105
+ const row = codexAuthLoginState.get(flowId);
106
+ if (row) Object.assign(row, patch);
107
+ }
108
+
109
+ function pruneCodexLoginState(now = Date.now()): void {
110
+ for (const [id, row] of codexAuthLoginState) {
111
+ if (row.doneAt !== undefined && now - row.doneAt >= CODEX_LOGIN_TERMINAL_TTL_MS) codexAuthLoginState.delete(id);
112
+ }
113
+ while (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) {
114
+ const terminal = [...codexAuthLoginState].filter(([, row]) => row.doneAt !== undefined)
115
+ .sort((a, b) => (a[1].doneAt ?? 0) - (b[1].doneAt ?? 0))[0];
116
+ if (!terminal) break;
117
+ codexAuthLoginState.delete(terminal[0]);
118
+ }
119
+ }
91
120
 
92
121
  function configuredPoolAccount(config: OcxConfig, accountId: string): CodexAccount | null {
93
122
  if (!isValidCodexAccountId(accountId)) return null;
@@ -151,6 +180,7 @@ function poolAccountDto(
151
180
  quota: quota ? { ...quota } : null,
152
181
  needsReauth,
153
182
  hasCredential,
183
+ ...(quotaResult.quotaProbeSkipped ? { quotaProbeSkipped: true as const } : {}),
154
184
  ...oauthAccountHealthFields("codex", account.id, health),
155
185
  };
156
186
  }
@@ -244,8 +274,15 @@ function expireCodexAuthFlow(flowId: string | null, error = "Login cancelled"):
244
274
  ? [flowId]
245
275
  : [...codexAuthLoginState].filter(([, state]) => state.status === "pending").map(([id]) => id);
246
276
  for (const id of ids) {
247
- codexAuthLoginState.set(id, { status: "error", error, doneAt: Date.now() });
248
- setTimeout(() => codexAuthLoginState.delete(id), 30_000);
277
+ let owner = codexAuthLoginState.get(id);
278
+ if (!owner) {
279
+ pruneCodexLoginState();
280
+ if (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) continue;
281
+ owner = { status: "error", startedAt: Date.now() };
282
+ codexAuthLoginState.set(id, owner);
283
+ }
284
+ Object.assign(owner, { status: "error", error, doneAt: Date.now() });
285
+ setTimeout(() => { if (codexAuthLoginState.get(id) === owner) codexAuthLoginState.delete(id); }, 30_000);
249
286
  }
250
287
  }
251
288
 
@@ -346,6 +383,7 @@ async function retryMainAccountInfoIfIdentityChanged(
346
383
  }
347
384
 
348
385
  async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaining: number): Promise<MainAccountInfoFetchResult> {
386
+ const writerGeneration = captureConfigGeneration();
349
387
  reconcileMainCodexAccountRuntimeState();
350
388
  const tokenRead = readCodexTokensResult();
351
389
  if (tokenRead.status !== "ok") {
@@ -375,7 +413,7 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
375
413
  if (retried) return retried;
376
414
  if (terminalAuthFailure) {
377
415
  clearMainAccountInfoCache();
378
- markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
416
+ markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration);
379
417
  }
380
418
  return { info: EMPTY_MAIN_ACCOUNT_INFO };
381
419
  }
@@ -397,7 +435,7 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
397
435
  // score and auto-switch the main account exactly like a pool account (Option A).
398
436
  setMainAccountPlan(result.plan);
399
437
  if (result.quota) {
400
- setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota);
438
+ setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration);
401
439
  }
402
440
  return {
403
441
  info: result,
@@ -413,6 +451,8 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
413
451
  interface PoolQuotaResult {
414
452
  quota: StoredAccountQuota | null;
415
453
  needsReauth: boolean;
454
+ /** Credential generation whose cache or network result this DTO state belongs to. */
455
+ credentialGeneration?: number;
416
456
  /** Present only when this call freshly parsed a WHAM usage response. */
417
457
  freshQuota?: Omit<StoredAccountQuota, "updatedAt">;
418
458
  /** Present only when this call's WHAM response included a non-empty `plan_type`. */
@@ -421,6 +461,49 @@ interface PoolQuotaResult {
421
461
  freshCredentialGeneration?: number;
422
462
  /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
423
463
  freshResetCredits?: number;
464
+ quotaProbeSkipped?: true;
465
+ }
466
+
467
+ interface PoolQuotaRefreshFlight {
468
+ state: {
469
+ startCredentialGeneration?: number;
470
+ resolvedCredentialGeneration?: number;
471
+ };
472
+ promise: Promise<PoolQuotaResult>;
473
+ }
474
+
475
+ const poolQuotaRefreshInFlight = new Map<string, Set<PoolQuotaRefreshFlight>>();
476
+ const MAX_POOL_QUOTA_FLIGHTS = 16;
477
+
478
+ export class PoolQuotaProbeBusyError extends ResourceAdmissionError {
479
+ constructor() {
480
+ super("pool_quota_flights", MAX_POOL_QUOTA_FLIGHTS);
481
+ this.name = "PoolQuotaProbeBusyError";
482
+ }
483
+ }
484
+
485
+ function poolQuotaFlightCount(): number {
486
+ let count = 0;
487
+ for (const flights of poolQuotaRefreshInFlight.values()) count += flights.size;
488
+ return count;
489
+ }
490
+
491
+ /** Focused admission tests only; returns cleanup for the synthetic owners it inserts. */
492
+ export function seedCodexAuthAdmissionForTests(options: { loginFlows?: number; quotaFlights?: number }): () => void {
493
+ const prefix = `admission-test-${crypto.randomUUID()}`;
494
+ for (let index = 0; index < (options.loginFlows ?? 0); index++) {
495
+ codexAuthLoginState.set(`${prefix}-login-${index}`, { status: "starting", startedAt: Date.now() });
496
+ }
497
+ for (let index = 0; index < (options.quotaFlights ?? 0); index++) {
498
+ poolQuotaRefreshInFlight.set(`${prefix}-quota-${index}`, new Set([{
499
+ state: {},
500
+ promise: new Promise<PoolQuotaResult>(() => {}),
501
+ }]));
502
+ }
503
+ return () => {
504
+ for (const key of [...codexAuthLoginState.keys()]) if (key.startsWith(prefix)) codexAuthLoginState.delete(key);
505
+ for (const key of [...poolQuotaRefreshInFlight.keys()]) if (key.startsWith(prefix)) poolQuotaRefreshInFlight.delete(key);
506
+ };
424
507
  }
425
508
 
426
509
  export interface CodexAuthAccountDto {
@@ -438,41 +521,170 @@ export interface CodexAuthAccountDto {
438
521
  healthLabel: OAuthHealthLabel;
439
522
  healthSummary: string;
440
523
  healthAction?: string;
524
+ quotaProbeSkipped?: true;
441
525
  }
442
526
 
443
- async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, configuredPlan?: string): Promise<PoolQuotaResult> {
444
- const existing = getAccountQuota(accountId);
445
- if (!forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) {
446
- return { quota: existing, needsReauth: false };
527
+ interface FreshPoolPlanUpdate {
528
+ accountId: string;
529
+ plan: string;
530
+ credentialGeneration: number;
531
+ }
532
+
533
+ /**
534
+ * Persist only validated plan leaves against the latest disk snapshot. A quota GET must not save
535
+ * the long-lived runtime object wholesale: unrelated manual/provider writes may have landed while
536
+ * WHAM requests were in flight. Missing or malformed files fail closed: a read path must not
537
+ * recreate a deleted config from the server's older in-memory snapshot.
538
+ */
539
+ function reconcileFreshPoolAccountPlans(runtimeConfig: OcxConfig, updates: FreshPoolPlanUpdate[]): void {
540
+ if (updates.length === 0) return;
541
+ let outcome: ReturnType<typeof mutatePersistedConfig<FreshPoolPlanUpdate[]>>;
542
+ try {
543
+ outcome = mutatePersistedConfig(persistedConfig => {
544
+ const accepted: FreshPoolPlanUpdate[] = [];
545
+ let changed = false;
546
+ for (const update of updates) {
547
+ if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue;
548
+ const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId);
549
+ const persistedAccount = configuredPoolAccount(persistedConfig, update.accountId);
550
+ if (!liveAccount || !persistedAccount) continue;
551
+ accepted.push(update);
552
+ if (persistedAccount.plan !== update.plan) {
553
+ persistedAccount.plan = update.plan;
554
+ changed = true;
555
+ }
556
+ }
557
+ return { changed, value: accepted };
558
+ });
559
+ } catch (error) {
560
+ // Plan persistence is derived metadata on a read route. Contention must fail closed without
561
+ // turning account listing into a 500; a later refresh can retry against the latest files.
562
+ if (error instanceof ConfigMutationLockError) return;
563
+ throw error;
564
+ }
565
+ if (outcome.status === "unavailable") return;
566
+ for (const update of outcome.value) {
567
+ // A replacement immediately after the durable commit is allowed to supersede the result, but
568
+ // the long-lived object must never be updated from that stale generation.
569
+ if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue;
570
+ const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId);
571
+ if (liveAccount) {
572
+ liveAccount.plan = update.plan;
573
+ }
447
574
  }
575
+ }
576
+
577
+ async function fetchFreshPoolAccountQuota(
578
+ accountId: string,
579
+ existing: StoredAccountQuota | null,
580
+ configuredPlan?: string,
581
+ onCredentialGeneration?: (generation: number) => void,
582
+ ): Promise<PoolQuotaResult> {
583
+ const writerGeneration = captureConfigGeneration();
584
+ let requestCredentialGeneration = readCodexAccountRecord(accountId)?.generation;
448
585
  try {
449
586
  const { accessToken, chatgptAccountId, generation } = await getValidCodexToken(accountId);
587
+ requestCredentialGeneration = generation;
588
+ onCredentialGeneration?.(generation);
450
589
  const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
451
590
  headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId },
452
591
  signal: AbortSignal.timeout(8000),
453
592
  });
454
- if (!resp.ok) return { quota: existing ?? null, needsReauth: resp.status === 401 };
593
+ if (!resp.ok) {
594
+ return {
595
+ quota: existing ?? null,
596
+ needsReauth: resp.status === 401,
597
+ credentialGeneration: generation,
598
+ };
599
+ }
455
600
  const data = (await resp.json()) as WhamUsageResponse;
456
601
  const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined;
457
602
  const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan });
458
603
  const freshResetCredits = quota?.resetCredits;
459
- if (!quota) return { quota: existing ?? null, needsReauth: false };
604
+ if (!quota) {
605
+ return {
606
+ quota: isCodexAccountGenerationLive(accountId, generation) ? existing ?? null : getAccountQuota(accountId),
607
+ needsReauth: false,
608
+ credentialGeneration: generation,
609
+ ...(freshPlan !== undefined
610
+ ? { freshPlan, freshCredentialGeneration: generation }
611
+ : {}),
612
+ };
613
+ }
460
614
  if (!isCodexAccountGenerationLive(accountId, generation)) {
461
- return { quota: getAccountQuota(accountId), needsReauth: false };
615
+ return { quota: null, needsReauth: false, credentialGeneration: generation };
462
616
  }
463
- setAccountQuotaFromParsed(accountId, quota);
617
+ setAccountQuotaFromParsed(accountId, quota, writerGeneration);
464
618
  return {
465
619
  quota: getAccountQuota(accountId),
466
620
  needsReauth: false,
621
+ credentialGeneration: generation,
467
622
  freshQuota: quota,
468
623
  freshCredentialGeneration: generation,
469
624
  ...(freshPlan !== undefined ? { freshPlan } : {}),
470
625
  ...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
471
626
  };
472
627
  } catch (e) {
473
- if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError) return { quota: existing ?? null, needsReauth: false };
474
- if (e instanceof TokenRefreshError) return { quota: existing ?? null, needsReauth: true };
475
- return { quota: existing ?? null, needsReauth: false };
628
+ if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError
629
+ || e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) {
630
+ return {
631
+ quota: existing ?? null,
632
+ needsReauth: false,
633
+ credentialGeneration: requestCredentialGeneration,
634
+ ...(e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError
635
+ ? { quotaProbeSkipped: true as const }
636
+ : {}),
637
+ };
638
+ }
639
+ if (e instanceof TokenRefreshError) {
640
+ return { quota: existing ?? null, needsReauth: true, credentialGeneration: requestCredentialGeneration };
641
+ }
642
+ return { quota: existing ?? null, needsReauth: false, credentialGeneration: requestCredentialGeneration };
643
+ }
644
+ }
645
+
646
+ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, configuredPlan?: string): Promise<PoolQuotaResult> {
647
+ const existing = getAccountQuota(accountId);
648
+ if (!forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) {
649
+ return {
650
+ quota: existing,
651
+ needsReauth: false,
652
+ credentialGeneration: readCodexAccountRecord(accountId)?.generation,
653
+ };
654
+ }
655
+ // A token refresh may increment the generation (and rotate the refresh token) before WHAM
656
+ // completes. Join a flight whose starting or resolved generation is still current, but let a
657
+ // replacement credential with the same pool id start its own request.
658
+ const record = readCodexAccountRecord(accountId);
659
+ const flights = poolQuotaRefreshInFlight.get(accountId);
660
+ const current = flights && [...flights].find(flight => {
661
+ const generation = flight.state.resolvedCredentialGeneration
662
+ ?? flight.state.startCredentialGeneration;
663
+ return generation !== undefined && isCodexAccountGenerationLive(accountId, generation);
664
+ });
665
+ if (current) return current.promise;
666
+ if (poolQuotaFlightCount() >= MAX_POOL_QUOTA_FLIGHTS) throw new PoolQuotaProbeBusyError();
667
+
668
+ const state: PoolQuotaRefreshFlight["state"] = {
669
+ startCredentialGeneration: record?.generation,
670
+ };
671
+ const refresh = fetchFreshPoolAccountQuota(
672
+ accountId,
673
+ existing,
674
+ configuredPlan,
675
+ generation => { state.resolvedCredentialGeneration = generation; },
676
+ );
677
+ const flight: PoolQuotaRefreshFlight = { state, promise: refresh };
678
+ const activeFlights = flights ?? new Set<PoolQuotaRefreshFlight>();
679
+ activeFlights.add(flight);
680
+ if (!flights) poolQuotaRefreshInFlight.set(accountId, activeFlights);
681
+ try {
682
+ return await refresh;
683
+ } finally {
684
+ activeFlights.delete(flight);
685
+ if (activeFlights.size === 0 && poolQuotaRefreshInFlight.get(accountId) === activeFlights) {
686
+ poolQuotaRefreshInFlight.delete(accountId);
687
+ }
476
688
  }
477
689
  }
478
690
 
@@ -542,12 +754,67 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa
542
754
  const runtimeConfig = getRuntimeConfig(config);
543
755
  const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
544
756
  const mainInfo = await fetchMainAccountInfo(forceRefresh);
545
- const withQuota = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
546
- const cred = getCodexAccountCredential(a.id);
547
- const quotaResult = cred
548
- ? await fetchPoolAccountQuota(a.id, forceRefresh, a.plan)
549
- : { quota: null, needsReauth: true };
550
- return poolAccountDto(a, quotaResult, !!cred, isCodexAccountPaused(runtimeConfig, a.id));
757
+ const refreshedPool = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
758
+ const cred = getCodexAccountCredential(account.id);
759
+ let quotaResult: PoolQuotaResult;
760
+ if (!cred) {
761
+ quotaResult = { quota: null, needsReauth: true };
762
+ } else {
763
+ try {
764
+ quotaResult = await fetchPoolAccountQuota(account.id, forceRefresh, account.plan);
765
+ } catch (error) {
766
+ if (!(error instanceof PoolQuotaProbeBusyError)) throw error;
767
+ quotaResult = {
768
+ quota: getAccountQuota(account.id),
769
+ needsReauth: false,
770
+ credentialGeneration: readCodexAccountRecord(account.id)?.generation,
771
+ quotaProbeSkipped: true,
772
+ };
773
+ }
774
+ }
775
+ return { accountId: account.id, quotaResult };
776
+ });
777
+
778
+ // WHAM plan_type is authoritative only for the credential generation that fetched it. Collect
779
+ // changes after every parallel read settles, then apply one narrow disk patch for the batch.
780
+ const planUpdates = refreshedPool.flatMap(({ accountId, quotaResult }): FreshPoolPlanUpdate[] => {
781
+ const plan = quotaResult.freshPlan;
782
+ const credentialGeneration = quotaResult.freshCredentialGeneration;
783
+ return plan && credentialGeneration !== undefined
784
+ ? [{ accountId, plan, credentialGeneration }]
785
+ : [];
786
+ });
787
+ reconcileFreshPoolAccountPlans(runtimeConfig, planUpdates);
788
+
789
+ const withQuota = refreshedPool.flatMap(({ accountId, quotaResult }) => {
790
+ const currentAccount = configuredPoolAccount(runtimeConfig, accountId);
791
+ if (!currentAccount) return [];
792
+ const currentCredential = getCodexAccountCredential(accountId);
793
+ if (!currentCredential) {
794
+ return [poolAccountDto(
795
+ currentAccount,
796
+ { quota: null, needsReauth: true },
797
+ false,
798
+ isCodexAccountPaused(runtimeConfig, accountId),
799
+ )];
800
+ }
801
+ const resultGeneration = quotaResult.credentialGeneration ?? quotaResult.freshCredentialGeneration;
802
+ const generationLive = resultGeneration === undefined
803
+ || isCodexAccountGenerationLive(accountId, resultGeneration);
804
+ const effectiveQuotaResult = !generationLive
805
+ ? { quota: null, needsReauth: false }
806
+ : quotaResult;
807
+ // Response DTO can show the WHAM plan even when disk persistence fails closed (lock busy /
808
+ // missing config). Persistence still remains generation-gated via reconcileFreshPoolAccountPlans.
809
+ const dtoAccount = generationLive && quotaResult.freshPlan
810
+ ? { ...currentAccount, plan: quotaResult.freshPlan }
811
+ : currentAccount;
812
+ return [poolAccountDto(
813
+ dtoAccount,
814
+ effectiveQuotaResult,
815
+ true,
816
+ isCodexAccountPaused(runtimeConfig, accountId),
817
+ )];
551
818
  });
552
819
  const hasMainCredential = readCodexTokens() !== null;
553
820
  const mainNeedsReauth = !hasMainCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
@@ -686,6 +953,7 @@ export async function handleCodexAuthAPI(
686
953
  accounts.push(withCodexAccountLogLabel({ id: body.id, email: body.email, plan: body.plan, isMain: false }, accounts));
687
954
  latestConfig.codexAccounts = accounts;
688
955
  saveRuntimeConfig(config, latestConfig);
956
+ reconcileLiveStateStores();
689
957
  return jsonResponse({ ok: true });
690
958
  }
691
959
 
@@ -700,6 +968,7 @@ export async function handleCodexAuthAPI(
700
968
  }
701
969
  deleteCodexAccount(runtimeConfig, id);
702
970
  saveRuntimeConfig(config, runtimeConfig);
971
+ reconcileLiveStateStores();
703
972
  return jsonResponse({ ok: true });
704
973
  }
705
974
 
@@ -970,6 +1239,11 @@ export async function handleCodexAuthAPI(
970
1239
  }
971
1240
  return jsonResponse(result);
972
1241
  } catch (e) {
1242
+ if (e instanceof PoolQuotaProbeBusyError) {
1243
+ const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503);
1244
+ response.headers.set("Retry-After", "1");
1245
+ return response;
1246
+ }
973
1247
  return jsonResponse({ error: e instanceof Error ? e.message : "Reset credit consume failed" }, 500);
974
1248
  }
975
1249
  }
@@ -993,7 +1267,16 @@ export async function handleCodexAuthAPI(
993
1267
  return jsonResponse({ error: "Unknown pool account for reauth" }, 404);
994
1268
  }
995
1269
  }
1270
+ pruneCodexLoginState();
1271
+ if (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) {
1272
+ const busy = new CodexLoginStateBusyError();
1273
+ const response = jsonResponse({ error: busy.message, code: busy.code }, 503);
1274
+ response.headers.set("Retry-After", "1");
1275
+ return response;
1276
+ }
996
1277
  const flowId = `flow-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1278
+ const loginOwner: CodexLoginStateRow = { status: "starting", startedAt: Date.now() };
1279
+ codexAuthLoginState.set(flowId, loginOwner);
997
1280
  try {
998
1281
  const { startLoginFlow, getLoginStatus } = await import("../oauth");
999
1282
  const result = await startLoginFlow("chatgpt", { forceLogin: true });
@@ -1006,178 +1289,203 @@ export async function handleCodexAuthAPI(
1006
1289
  }
1007
1290
 
1008
1291
  (async () => {
1009
- let completed = false;
1010
- for (let i = 0; i < 150; i++) {
1011
- await new Promise(r => setTimeout(r, 2000));
1012
- const st = getLoginStatus("chatgpt");
1013
- if (st.done && st.loggedIn) {
1014
- const { getCredential } = await import("../oauth/store");
1015
- const cred = getCredential("chatgpt");
1016
- if (cred) {
1017
- const oauthAccountId = cred.accountId;
1018
- if (!oauthAccountId) {
1019
- codexAuthLoginState.set(flowId, {
1020
- status: "error",
1021
- error: "Could not determine account identity from OAuth tokens. Please retry OAuth login.",
1022
- doneAt: Date.now(),
1023
- });
1024
- completed = true;
1025
- break;
1026
- }
1027
-
1028
- let email = cred.email || accountId;
1029
- let plan: string | undefined;
1030
- let quota: Omit<StoredAccountQuota, "updatedAt"> | null = null;
1031
- try {
1032
- const tokens = { access_token: cred.access, account_id: oauthAccountId };
1033
- const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
1034
- headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
1035
- signal: AbortSignal.timeout(8000),
1036
- });
1037
- if (resp.ok) {
1038
- const data = (await resp.json()) as WhamUsageResponse;
1039
- email = data.email ?? email;
1040
- plan = data.plan_type ?? undefined;
1041
- quota = parseUsageQuota(data);
1292
+ try {
1293
+ let completed = false;
1294
+ for (let i = 0; i < 150; i++) {
1295
+ await new Promise(r => setTimeout(r, 2000));
1296
+ const st = getLoginStatus("chatgpt");
1297
+ if (st.done && st.loggedIn) {
1298
+ const { getCredential } = await import("../oauth/store");
1299
+ const cred = getCredential("chatgpt");
1300
+ if (cred) {
1301
+ const oauthAccountId = cred.accountId;
1302
+ if (!oauthAccountId) {
1303
+ setCodexLoginState(flowId, {
1304
+ status: "error",
1305
+ error: "Could not determine account identity from OAuth tokens. Please retry OAuth login.",
1306
+ doneAt: Date.now(),
1307
+ });
1308
+ completed = true;
1309
+ break;
1042
1310
  }
1043
- } catch { /* wham fetch is non-blocking */ }
1044
- // Reauth must refresh the same ChatGPT identity already bound to this pool slot.
1045
- // Otherwise a different login would silently overwrite credentials under a trusted id.
1046
- if (reauth) {
1047
- const existingCred = getCodexAccountCredential(accountId);
1048
- const poolAccount = configuredPoolAccount(getRuntimeConfig(config), accountId);
1049
- const expectedChatgptId = existingCred?.chatgptAccountId?.trim();
1050
- const expectedEmail = poolAccount?.email?.trim().toLowerCase();
1051
- const gotEmail = email.trim().toLowerCase();
1052
- if (expectedChatgptId) {
1053
- if (expectedChatgptId !== oauthAccountId) {
1054
- codexAuthLoginState.set(flowId, {
1055
- status: "error",
1056
- error: "Signed-in ChatGPT account does not match this pool account. Sign in with the same account, or remove it and add a new one.",
1057
- doneAt: Date.now(),
1058
- });
1059
- completed = true;
1060
- break;
1311
+
1312
+ let email = cred.email || accountId;
1313
+ let plan: string | undefined;
1314
+ let quota: Omit<StoredAccountQuota, "updatedAt"> | null = null;
1315
+ try {
1316
+ const tokens = { access_token: cred.access, account_id: oauthAccountId };
1317
+ const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
1318
+ headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
1319
+ signal: AbortSignal.timeout(8000),
1320
+ });
1321
+ if (resp.ok) {
1322
+ const data = (await resp.json()) as WhamUsageResponse;
1323
+ email = data.email ?? email;
1324
+ plan = nonEmptyPlan(data.plan_type) ?? undefined;
1325
+ quota = parseUsageQuota(data);
1061
1326
  }
1062
- } else if (expectedEmail) {
1063
- if (!gotEmail || gotEmail !== expectedEmail) {
1064
- codexAuthLoginState.set(flowId, {
1327
+ } catch { /* wham fetch is non-blocking */ }
1328
+ // Reauth must refresh the same ChatGPT identity already bound to this pool slot.
1329
+ // Otherwise a different login would silently overwrite credentials under a trusted id.
1330
+ if (reauth) {
1331
+ const existingCred = getCodexAccountCredential(accountId);
1332
+ const poolAccount = configuredPoolAccount(getRuntimeConfig(config), accountId);
1333
+ const expectedChatgptId = existingCred?.chatgptAccountId?.trim();
1334
+ const expectedEmail = poolAccount?.email?.trim().toLowerCase();
1335
+ const gotEmail = email.trim().toLowerCase();
1336
+ if (expectedChatgptId) {
1337
+ if (expectedChatgptId !== oauthAccountId) {
1338
+ setCodexLoginState(flowId, {
1339
+ status: "error",
1340
+ error: "Signed-in ChatGPT account does not match this pool account. Sign in with the same account, or remove it and add a new one.",
1341
+ doneAt: Date.now(),
1342
+ });
1343
+ completed = true;
1344
+ break;
1345
+ }
1346
+ } else if (expectedEmail) {
1347
+ if (!gotEmail || gotEmail !== expectedEmail) {
1348
+ setCodexLoginState(flowId, {
1349
+ status: "error",
1350
+ error: "Signed-in ChatGPT account does not match this pool account. Sign in with the same account, or remove it and add a new one.",
1351
+ doneAt: Date.now(),
1352
+ });
1353
+ completed = true;
1354
+ break;
1355
+ }
1356
+ } else {
1357
+ // No chatgptAccountId and no pool email — refuse silent identity replacement
1358
+ // (including empty credential slots that still have a pool row).
1359
+ setCodexLoginState(flowId, {
1065
1360
  status: "error",
1066
- error: "Signed-in ChatGPT account does not match this pool account. Sign in with the same account, or remove it and add a new one.",
1361
+ error: "Cannot verify account identity for reauth. Remove this account and add it again.",
1067
1362
  doneAt: Date.now(),
1068
1363
  });
1069
1364
  completed = true;
1070
1365
  break;
1071
1366
  }
1072
- } else {
1073
- // No chatgptAccountId and no pool email — refuse silent identity replacement
1074
- // (including empty credential slots that still have a pool row).
1075
- codexAuthLoginState.set(flowId, {
1367
+ }
1368
+
1369
+ // 1.2: Duplicate check is scoped by personal vs workspace plan bucket.
1370
+ const collision = checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined);
1371
+ if (collision.collision) {
1372
+ setCodexLoginState(flowId, {
1373
+ status: "error", error: collision.reason, doneAt: Date.now(),
1374
+ });
1375
+ completed = true;
1376
+ break;
1377
+ }
1378
+
1379
+ const warmup = await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId);
1380
+ if (!warmup.ok) {
1381
+ const body = await warmup.response.json().catch(() => ({})) as { error?: string; reason?: string };
1382
+ setCodexLoginState(flowId, {
1076
1383
  status: "error",
1077
- error: "Cannot verify account identity for reauth. Remove this account and add it again.",
1384
+ error: body.reason ? `${body.error ?? "Codex account warmup failed"} (${body.reason})` : body.error ?? "Codex account warmup failed",
1078
1385
  doneAt: Date.now(),
1079
1386
  });
1080
1387
  completed = true;
1081
1388
  break;
1082
1389
  }
1083
- }
1084
1390
 
1085
- // 1.2: Duplicate check is scoped by personal vs workspace plan bucket.
1086
- const collision = checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined);
1087
- if (collision.collision) {
1088
- codexAuthLoginState.set(flowId, {
1089
- status: "error", error: collision.reason, doneAt: Date.now(),
1090
- });
1091
- completed = true;
1092
- break;
1093
- }
1391
+ const latestConfig = getRuntimeConfig(config);
1392
+ const accounts = latestConfig.codexAccounts ?? [];
1393
+ const existingIdx = accounts.findIndex(account => account.id === accountId);
1394
+ const commitConflict = codexAccountPersistenceConflict(
1395
+ latestConfig,
1396
+ accountId,
1397
+ reauth ? "reauth" : "create",
1398
+ );
1399
+ if (commitConflict) {
1400
+ setCodexLoginState(flowId, {
1401
+ status: "error",
1402
+ error: commitConflict,
1403
+ doneAt: Date.now(),
1404
+ });
1405
+ completed = true;
1406
+ break;
1407
+ }
1094
1408
 
1095
- const warmup = await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId);
1096
- if (!warmup.ok) {
1097
- const body = await warmup.response.json().catch(() => ({})) as { error?: string; reason?: string };
1098
- codexAuthLoginState.set(flowId, {
1099
- status: "error",
1100
- error: body.reason ? `${body.error ?? "Codex account warmup failed"} (${body.reason})` : body.error ?? "Codex account warmup failed",
1101
- doneAt: Date.now(),
1409
+ saveCodexAccountCredential(accountId, {
1410
+ accessToken: cred.access,
1411
+ refreshToken: cred.refresh,
1412
+ expiresAt: cred.expires,
1413
+ chatgptAccountId: oauthAccountId,
1102
1414
  });
1103
- completed = true;
1104
- break;
1105
- }
1415
+ // A successful reauthentication replaces the credential generation. Do not let a
1416
+ // failed optional WHAM probe make the replacement inherit quota from the old record.
1417
+ if (reauth) clearAccountQuota(accountId);
1418
+ markCodexAccountValidated(accountId, warmup.validatedAt);
1419
+ clearAccountNeedsReauth(accountId);
1420
+ if (quota) {
1421
+ setAccountQuotaFromParsed(accountId, quota);
1422
+ }
1106
1423
 
1107
- const latestConfig = getRuntimeConfig(config);
1108
- const accounts = latestConfig.codexAccounts ?? [];
1109
- const existingIdx = accounts.findIndex(account => account.id === accountId);
1110
- const commitConflict = codexAccountPersistenceConflict(
1111
- latestConfig,
1112
- accountId,
1113
- reauth ? "reauth" : "create",
1114
- );
1115
- if (commitConflict) {
1116
- codexAuthLoginState.set(flowId, {
1117
- status: "error",
1118
- error: commitConflict,
1119
- doneAt: Date.now(),
1120
- });
1424
+ if (existingIdx >= 0) {
1425
+ // Keep the pool id stable; refresh display metadata after a successful login/reauth.
1426
+ accounts[existingIdx] = withCodexAccountLogLabel({
1427
+ ...accounts[existingIdx],
1428
+ email,
1429
+ plan: plan ?? accounts[existingIdx].plan,
1430
+ isMain: false,
1431
+ }, accounts);
1432
+ latestConfig.codexAccounts = accounts;
1433
+ saveRuntimeConfig(config, latestConfig);
1434
+ } else {
1435
+ accounts.push(withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts));
1436
+ latestConfig.codexAccounts = accounts;
1437
+ saveRuntimeConfig(config, latestConfig);
1438
+ }
1439
+ reconcileLiveStateStores();
1440
+ setCodexLoginState(flowId, { status: "done", accountId, email, doneAt: Date.now() });
1121
1441
  completed = true;
1122
- break;
1123
1442
  }
1124
-
1125
- saveCodexAccountCredential(accountId, {
1126
- accessToken: cred.access,
1127
- refreshToken: cred.refresh,
1128
- expiresAt: cred.expires,
1129
- chatgptAccountId: oauthAccountId,
1130
- });
1131
- markCodexAccountValidated(accountId, warmup.validatedAt);
1132
- clearAccountNeedsReauth(accountId);
1133
- if (quota) {
1134
- setAccountQuotaFromParsed(accountId, quota);
1135
- }
1136
-
1137
- if (existingIdx >= 0) {
1138
- // Keep the pool id stable; refresh display metadata after a successful login/reauth.
1139
- accounts[existingIdx] = withCodexAccountLogLabel({
1140
- ...accounts[existingIdx],
1141
- email,
1142
- plan: plan ?? accounts[existingIdx].plan,
1143
- isMain: false,
1144
- }, accounts);
1145
- latestConfig.codexAccounts = accounts;
1146
- saveRuntimeConfig(config, latestConfig);
1147
- } else {
1148
- accounts.push(withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts));
1149
- latestConfig.codexAccounts = accounts;
1150
- saveRuntimeConfig(config, latestConfig);
1151
- }
1152
- codexAuthLoginState.set(flowId, { status: "done", accountId, email, doneAt: Date.now() });
1443
+ break;
1444
+ }
1445
+ if (st.done && st.error) {
1446
+ setCodexLoginState(flowId, { status: "error", error: st.error, doneAt: Date.now() });
1153
1447
  completed = true;
1448
+ break;
1154
1449
  }
1155
- break;
1156
1450
  }
1157
- if (st.done && st.error) {
1158
- codexAuthLoginState.set(flowId, { status: "error", error: st.error, doneAt: Date.now() });
1159
- completed = true;
1160
- break;
1451
+ if (!completed) {
1452
+ setCodexLoginState(flowId, {
1453
+ status: "error",
1454
+ error: "Login timed out before OAuth completed.",
1455
+ doneAt: Date.now(),
1456
+ });
1161
1457
  }
1162
- }
1163
- if (!completed) {
1164
- codexAuthLoginState.set(flowId, {
1458
+ } catch (error) {
1459
+ const message = error instanceof ConfigMutationLockError
1460
+ || error instanceof CodexCredentialRefreshLockTimeoutError
1461
+ ? "Configuration is busy; retry login shortly."
1462
+ : error instanceof CodexCredentialRefreshBusyError || error instanceof CodexCredentialRefreshStaleError
1463
+ ? "Credential refresh is busy; retry login shortly."
1464
+ : error instanceof Error ? error.message : String(error);
1465
+ setCodexLoginState(flowId, {
1165
1466
  status: "error",
1166
- error: "Login timed out before OAuth completed.",
1467
+ error: message,
1167
1468
  doneAt: Date.now(),
1168
1469
  });
1470
+ } finally {
1471
+ // TTL: keep completed flow state available for clients that miss a short polling window.
1472
+ setTimeout(() => { if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId); }, CODEX_LOGIN_TERMINAL_TTL_MS);
1169
1473
  }
1170
- // TTL: keep completed flow state available for clients that miss a short polling window.
1171
- setTimeout(() => codexAuthLoginState.delete(flowId), 300_000);
1172
1474
  })();
1173
1475
 
1174
- codexAuthLoginState.set(flowId, { status: "pending" });
1476
+ setCodexLoginState(flowId, { status: "pending" });
1175
1477
  return jsonResponse({ ok: true, flowId, url: result.url, instructions: result.instructions });
1176
1478
  } catch (e) {
1479
+ if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId);
1177
1480
  const msg = e instanceof Error ? e.message : String(e);
1178
1481
  if (msg.includes("already in progress")) {
1179
1482
  return jsonResponse({ error: msg, status: "pending" }, 409);
1180
1483
  }
1484
+ if (e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) {
1485
+ const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503);
1486
+ response.headers.set("Retry-After", "1");
1487
+ return response;
1488
+ }
1181
1489
  return jsonResponse({ error: msg }, 500);
1182
1490
  }
1183
1491
  }