@bitkyc08/opencodex 2.7.42 → 2.7.43

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 (143) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +13 -14
  3. package/gui/dist/assets/index-Czw-jpTU.css +1 -0
  4. package/gui/dist/assets/index-cmds12BG.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  10. package/src/adapters/cursor/protobuf-events.ts +24 -2
  11. package/src/adapters/cursor/protobuf-request.ts +1 -2
  12. package/src/adapters/cursor/tool-definitions.ts +68 -29
  13. package/src/adapters/google-wire-compiler.ts +4 -0
  14. package/src/adapters/google.ts +128 -2
  15. package/src/adapters/identity.ts +12 -2
  16. package/src/adapters/kiro.ts +49 -6
  17. package/src/adapters/mimo-free.ts +2 -0
  18. package/src/adapters/openai-responses.ts +246 -59
  19. package/src/claude/agents-inject.ts +5 -0
  20. package/src/claude/inbound.ts +26 -9
  21. package/src/cli/account-auth.ts +1 -1
  22. package/src/cli/agent-driven.ts +37 -0
  23. package/src/cli/claude.ts +3 -3
  24. package/src/cli/doctor.ts +23 -18
  25. package/src/cli/help.ts +42 -6
  26. package/src/cli/index.ts +88 -19
  27. package/src/cli/interactive-confirm.ts +129 -0
  28. package/src/cli/opencode.ts +701 -0
  29. package/src/cli/provider-runtime.ts +3 -0
  30. package/src/cli/provider.ts +31 -10
  31. package/src/cli/star-prompt.ts +55 -16
  32. package/src/cli/status.ts +47 -13
  33. package/src/codex/account-id.ts +34 -0
  34. package/src/codex/account-lifecycle.ts +4 -1
  35. package/src/codex/account-namespace-match.ts +63 -0
  36. package/src/codex/account-namespaces.ts +149 -0
  37. package/src/codex/account-pause.ts +20 -0
  38. package/src/codex/account-usability.ts +6 -1
  39. package/src/codex/app-server-processes.ts +511 -0
  40. package/src/codex/auth-api.ts +293 -34
  41. package/src/codex/auth-collision.ts +2 -1
  42. package/src/codex/auth-context.ts +60 -17
  43. package/src/codex/catalog/parsing.ts +16 -1
  44. package/src/codex/catalog/provider-fetch.ts +48 -12
  45. package/src/codex/catalog/sync.ts +18 -3
  46. package/src/codex/catalog.ts +1 -1
  47. package/src/codex/history-provider.ts +145 -1
  48. package/src/codex/inject.ts +114 -14
  49. package/src/codex/main-account.ts +2 -8
  50. package/src/codex/pool-rotation.ts +186 -0
  51. package/src/codex/quota.ts +15 -0
  52. package/src/codex/routing.ts +695 -106
  53. package/src/codex/shim.ts +4 -1
  54. package/src/codex/subagent-defaults.ts +550 -0
  55. package/src/codex/subagent-model-fallback.ts +2 -0
  56. package/src/codex/sync.ts +3 -0
  57. package/src/config.ts +566 -25
  58. package/src/images/artifacts.ts +516 -0
  59. package/src/images/fulfill-video.ts +163 -0
  60. package/src/images/fulfill.ts +111 -0
  61. package/src/images/index.ts +4 -0
  62. package/src/images/loop.ts +789 -0
  63. package/src/images/plan.ts +133 -0
  64. package/src/images/synthetic-tool.ts +133 -0
  65. package/src/images/types.ts +41 -0
  66. package/src/images/xai-client.ts +141 -0
  67. package/src/images/xai-video-client.ts +163 -0
  68. package/src/lib/admin-secrets.ts +25 -0
  69. package/src/lib/config-ownership.ts +327 -0
  70. package/src/lib/crash-guard.ts +2 -0
  71. package/src/lib/destination-policy.ts +122 -4
  72. package/src/lib/pinned-http.ts +151 -0
  73. package/src/lib/process-control.ts +2 -2
  74. package/src/lib/provider-outbound.ts +164 -0
  75. package/src/lib/provider-url.ts +14 -0
  76. package/src/lib/proxy-env.ts +18 -0
  77. package/src/lib/windows-elevation.ts +81 -3
  78. package/src/lib/windows-secret-acl.ts +189 -12
  79. package/src/lib/winsw.ts +2 -0
  80. package/src/oauth/anthropic-routing.ts +570 -0
  81. package/src/oauth/health.ts +6 -0
  82. package/src/oauth/index.ts +283 -72
  83. package/src/oauth/key-providers.ts +17 -6
  84. package/src/oauth/kimi.ts +2 -0
  85. package/src/oauth/kiro-credentials.ts +322 -10
  86. package/src/oauth/kiro.ts +423 -43
  87. package/src/oauth/login-cli.ts +32 -5
  88. package/src/oauth/store.ts +54 -4
  89. package/src/oauth/types.ts +11 -0
  90. package/src/providers/alibaba-region-migration.ts +16 -3
  91. package/src/providers/antigravity-models.ts +3 -0
  92. package/src/providers/api-keys.ts +13 -6
  93. package/src/providers/derive.ts +6 -0
  94. package/src/providers/key-failover.ts +24 -4
  95. package/src/providers/quota.ts +233 -29
  96. package/src/providers/registry.ts +11 -3
  97. package/src/responses/parser.ts +11 -0
  98. package/src/responses/state.ts +22 -8
  99. package/src/responses/tool-groups.ts +19 -0
  100. package/src/router.ts +14 -4
  101. package/src/server/auth-cors.ts +110 -22
  102. package/src/server/claude-messages.ts +8 -1
  103. package/src/server/gui-static.ts +30 -6
  104. package/src/server/images.ts +303 -9
  105. package/src/server/index.ts +77 -9
  106. package/src/server/lifecycle.ts +25 -1
  107. package/src/server/management/agent-settings-routes.ts +28 -4
  108. package/src/server/management/combo-routes.ts +7 -0
  109. package/src/server/management/config-routes.ts +5 -2
  110. package/src/server/management/logs-usage-routes.ts +167 -3
  111. package/src/server/management/oauth-account-routes.ts +163 -17
  112. package/src/server/management/provider-routes.ts +35 -7
  113. package/src/server/management/system-restart.ts +172 -0
  114. package/src/server/management/system-routes.ts +33 -10
  115. package/src/server/management-api.ts +2 -2
  116. package/src/server/management-auth.ts +216 -0
  117. package/src/server/proxy-liveness.ts +14 -3
  118. package/src/server/responses/compact.ts +21 -13
  119. package/src/server/responses/core.ts +583 -152
  120. package/src/server/responses-image-gen-repair.ts +118 -0
  121. package/src/server/responses-item-id-repair.ts +10 -85
  122. package/src/server/sse-payload-rewrite.ts +116 -0
  123. package/src/server/system-env.ts +28 -10
  124. package/src/service.ts +49 -2
  125. package/src/storage/cleanup-job.ts +57 -0
  126. package/src/storage/cleanup.ts +1504 -28
  127. package/src/storage/policy-job.ts +366 -0
  128. package/src/storage/policy-scheduler.ts +40 -0
  129. package/src/storage/policy-worker.ts +53 -0
  130. package/src/storage/policy.ts +522 -0
  131. package/src/storage/restore-job.ts +242 -0
  132. package/src/storage/restore-worker.ts +52 -0
  133. package/src/storage/storage-mutation-coordinator.ts +109 -0
  134. package/src/tray/windows.ts +2 -0
  135. package/src/types.ts +96 -1
  136. package/src/update/index.ts +36 -18
  137. package/src/update/job.ts +103 -12
  138. package/src/update/npm-invocation.d.mts +23 -0
  139. package/src/update/npm-invocation.mjs +94 -0
  140. package/src/usage/debug.ts +2 -0
  141. package/src/usage/log.ts +4 -0
  142. package/gui/dist/assets/index-Bl_VBGoI.js +0 -65
  143. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -3,6 +3,7 @@ import { withCodexAccountLogLabel } from "./account-label";
3
3
  import {
4
4
  getCodexAccountCredential,
5
5
  getValidCodexToken,
6
+ isCodexAccountGenerationLive,
6
7
  markCodexAccountValidated,
7
8
  saveCodexAccountCredential,
8
9
  CodexCredentialGenerationConflictError,
@@ -10,7 +11,20 @@ import {
10
11
  TokenRefreshError,
11
12
  } from "./account-store";
12
13
  import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
13
- import { clearCodexAccountCooldown, resetCodexRoutingForManualSelection } from "./routing";
14
+ import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause";
15
+ import {
16
+ clearCodexAccountCooldown,
17
+ clearThreadAccountMapForAccount,
18
+ getEffectiveActiveCodexAccountId,
19
+ reconcileCodexActiveAfterExclusion,
20
+ resetCodexRoutingForManualSelection,
21
+ } from "./routing";
22
+ import {
23
+ normalizeAccountPoolStickyLimit,
24
+ normalizeAccountPoolStrategy,
25
+ parseAccountPoolStickyLimit,
26
+ parseAccountPoolStrategy,
27
+ } from "./pool-rotation";
14
28
  import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision";
15
29
  export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision";
16
30
  export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
@@ -18,6 +32,7 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth }
18
32
  import {
19
33
  clearAccountQuota,
20
34
  getAccountQuota,
35
+ isCodexQuotaExhausted,
21
36
  listAccountQuotas,
22
37
  parseUsageQuota,
23
38
  setAccountQuotaFromParsed,
@@ -34,7 +49,7 @@ export {
34
49
  updateAccountQuota,
35
50
  } from "./quota";
36
51
  import { extractAccountId, decodeJwtPayload } from "../oauth/chatgpt";
37
- import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
52
+ import { getMainAccountPlan, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
38
53
  import {
39
54
  clearMainAccountInfoCache,
40
55
  getMainAccountInfoCache,
@@ -55,6 +70,13 @@ import {
55
70
  type OAuthAccountHealth,
56
71
  type OAuthHealthLabel,
57
72
  } from "../oauth/health";
73
+ import {
74
+ CODEX_ACCOUNT_ID_RE,
75
+ hasLegacyMainCodexPoolAccount,
76
+ isSelectableCodexPoolAccount,
77
+ isValidCodexAccountId,
78
+ } from "./account-id";
79
+ import { codexAccountIdNamespaceCollisionError } from "./account-namespace-match";
58
80
 
59
81
  function jsonResponse(data: unknown, status = 200): Response {
60
82
  return new Response(JSON.stringify(data), {
@@ -63,14 +85,32 @@ function jsonResponse(data: unknown, status = 200): Response {
63
85
  });
64
86
  }
65
87
 
66
- const ACCOUNT_ID_RE = /^[a-zA-Z0-9._-]{1,64}$/;
67
88
  const MANUAL_IMPORT_ENV = "OPENCODEX_ENABLE_UNVERIFIED_CODEX_IMPORT";
68
89
 
69
90
  const codexAuthLoginState = new Map<string, { status: string; accountId?: string; email?: string; error?: string; doneAt?: number }>();
70
91
 
71
92
  function configuredPoolAccount(config: OcxConfig, accountId: string): CodexAccount | null {
72
- if (!ACCOUNT_ID_RE.test(accountId)) return null;
73
- return (config.codexAccounts ?? []).find(account => account.id === accountId && !account.isMain) ?? null;
93
+ if (!isValidCodexAccountId(accountId)) return null;
94
+ return (config.codexAccounts ?? [])
95
+ .find(account => account.id === accountId && isSelectableCodexPoolAccount(account)) ?? null;
96
+ }
97
+
98
+ function codexAccountPersistenceConflict(
99
+ config: OcxConfig,
100
+ accountId: string,
101
+ mode: "create" | "reauth",
102
+ ): string | undefined {
103
+ if (mode === "reauth") {
104
+ return configuredPoolAccount(config, accountId)
105
+ ? undefined
106
+ : "Pool account was removed while login was in progress. Add it again as a new account.";
107
+ }
108
+ const namespaceCollision = codexAccountIdNamespaceCollisionError(config.codexAccountNamespaces, accountId);
109
+ if (namespaceCollision) return namespaceCollision;
110
+ return (config.codexAccounts ?? []).some(account => account.id === accountId)
111
+ || Boolean(getCodexAccountCredential(accountId))
112
+ ? `Account id already exists: ${accountId}`
113
+ : undefined;
74
114
  }
75
115
 
76
116
  function isThirtyDayOnlyPlan(plan: string | null | undefined): boolean {
@@ -95,6 +135,7 @@ function poolAccountDto(
95
135
  account: CodexAccount,
96
136
  quotaResult: PoolQuotaResult,
97
137
  hasCredential: boolean,
138
+ paused: boolean,
98
139
  ): CodexAuthAccountDto {
99
140
  const quota = quotaForPlan(quotaResult.quota, account.plan);
100
141
  const needsReauth = !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id);
@@ -106,6 +147,7 @@ function poolAccountDto(
106
147
  ...(account.plan !== undefined ? { plan: account.plan } : {}),
107
148
  ...(account.logLabel !== undefined ? { logLabel: account.logLabel } : {}),
108
149
  isMain: false,
150
+ paused,
109
151
  quota: quota ? { ...quota } : null,
110
152
  needsReauth,
111
153
  hasCredential,
@@ -121,11 +163,14 @@ async function resolveResetCreditAuth(
121
163
  | { ok: false; response: Response }
122
164
  > {
123
165
  if (accountId === MAIN_CODEX_ACCOUNT_ID) {
166
+ if (hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) {
167
+ return { ok: false, response: jsonResponse({ error: "Remove the legacy __main__ pool row before using the Desktop account" }, 409) };
168
+ }
124
169
  const tokens = readCodexTokens();
125
170
  if (!tokens) return { ok: false, response: jsonResponse({ error: "Main Codex account not logged in" }, 401) };
126
171
  return { ok: true, isMain: true, accessToken: tokens.access_token, chatgptAccountId: tokens.account_id };
127
172
  }
128
- if (!ACCOUNT_ID_RE.test(accountId)) {
173
+ if (!isValidCodexAccountId(accountId)) {
129
174
  return { ok: false, response: jsonResponse({ error: "Invalid account id format" }, 400) };
130
175
  }
131
176
  if (!configuredPoolAccount(runtimeConfig, accountId)) {
@@ -208,6 +253,10 @@ const MAIN_CACHE_TTL = 5 * 60_000;
208
253
  const POOL_CACHE_TTL = 5 * 60_000;
209
254
  const POOL_QUOTA_REFRESH_CONCURRENCY = 4;
210
255
 
256
+ function nonEmptyPlan(value: unknown): string | null {
257
+ return typeof value === "string" && value.trim() !== "" ? value : null;
258
+ }
259
+
211
260
  function isRuntimeConfig(config: OcxConfig): boolean {
212
261
  return !!config && typeof config === "object" && !!config.providers;
213
262
  }
@@ -271,6 +320,8 @@ async function isTerminalMainAuthResponse(resp: Response): Promise<boolean> {
271
320
 
272
321
  interface MainAccountInfoFetchResult {
273
322
  info: MainAccountInfo;
323
+ /** Present only when this call freshly parsed a WHAM usage response. */
324
+ freshQuota?: Omit<StoredAccountQuota, "updatedAt">;
274
325
  /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
275
326
  freshResetCredits?: number;
276
327
  }
@@ -331,11 +382,12 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
331
382
  const data = (await resp.json()) as WhamUsageResponse;
332
383
  const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
333
384
  if (retried) return retried;
334
- const quota = parseUsageQuota(data);
385
+ const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan());
386
+ const quota = parseUsageQuota({ ...data, ...(plan ? { plan_type: plan } : {}) });
335
387
  const freshResetCredits = quota?.resetCredits;
336
388
  const result = {
337
389
  email: data.email ?? null,
338
- plan: data.plan_type ?? null,
390
+ plan,
339
391
  quota,
340
392
  ts: Date.now(),
341
393
  };
@@ -349,6 +401,7 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
349
401
  }
350
402
  return {
351
403
  info: result,
404
+ ...(quota ? { freshQuota: quota } : {}),
352
405
  ...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
353
406
  };
354
407
  } catch {
@@ -360,6 +413,12 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
360
413
  interface PoolQuotaResult {
361
414
  quota: StoredAccountQuota | null;
362
415
  needsReauth: boolean;
416
+ /** Present only when this call freshly parsed a WHAM usage response. */
417
+ freshQuota?: Omit<StoredAccountQuota, "updatedAt">;
418
+ /** Present only when this call's WHAM response included a non-empty `plan_type`. */
419
+ freshPlan?: string;
420
+ /** Credential generation used by this fresh quota request. */
421
+ freshCredentialGeneration?: number;
363
422
  /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */
364
423
  freshResetCredits?: number;
365
424
  }
@@ -371,6 +430,7 @@ export interface CodexAuthAccountDto {
371
430
  plan?: string | null;
372
431
  logLabel?: string;
373
432
  isMain: boolean;
433
+ paused: boolean;
374
434
  quota: (StoredAccountQuota | (Omit<StoredAccountQuota, "updatedAt"> & { updatedAt: number })) | null;
375
435
  needsReauth?: boolean;
376
436
  hasCredential: boolean;
@@ -386,20 +446,27 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co
386
446
  return { quota: existing, needsReauth: false };
387
447
  }
388
448
  try {
389
- const { accessToken, chatgptAccountId } = await getValidCodexToken(accountId);
449
+ const { accessToken, chatgptAccountId, generation } = await getValidCodexToken(accountId);
390
450
  const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
391
451
  headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId },
392
452
  signal: AbortSignal.timeout(8000),
393
453
  });
394
454
  if (!resp.ok) return { quota: existing ?? null, needsReauth: resp.status === 401 };
395
455
  const data = (await resp.json()) as WhamUsageResponse;
396
- const quota = parseUsageQuota({ ...data, plan_type: data.plan_type ?? configuredPlan });
456
+ const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined;
457
+ const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan });
397
458
  const freshResetCredits = quota?.resetCredits;
398
459
  if (!quota) return { quota: existing ?? null, needsReauth: false };
460
+ if (!isCodexAccountGenerationLive(accountId, generation)) {
461
+ return { quota: getAccountQuota(accountId), needsReauth: false };
462
+ }
399
463
  setAccountQuotaFromParsed(accountId, quota);
400
464
  return {
401
465
  quota: getAccountQuota(accountId),
402
466
  needsReauth: false,
467
+ freshQuota: quota,
468
+ freshCredentialGeneration: generation,
469
+ ...(freshPlan !== undefined ? { freshPlan } : {}),
403
470
  ...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
404
471
  };
405
472
  } catch (e) {
@@ -441,7 +508,7 @@ export async function primeCodexPoolQuotas(config: OcxConfig, reason: string): P
441
508
  reconcileMainCodexAccountRuntimeState();
442
509
  primeInFlight = (async () => {
443
510
  const runtimeConfig = getRuntimeConfig(config);
444
- const pool = (runtimeConfig.codexAccounts ?? []).filter(a => !a.isMain);
511
+ const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
445
512
  const stale = pool.filter(a => {
446
513
  const q = getAccountQuota(a.id);
447
514
  return !q || Date.now() - q.updatedAt >= POOL_CACHE_TTL;
@@ -473,14 +540,14 @@ export function clearCodexQuotaPrimeState(): void {
473
540
 
474
541
  export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = false): Promise<CodexAuthAccountDto[]> {
475
542
  const runtimeConfig = getRuntimeConfig(config);
476
- const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(a => !a.isMain);
543
+ const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
477
544
  const mainInfo = await fetchMainAccountInfo(forceRefresh);
478
545
  const withQuota = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
479
546
  const cred = getCodexAccountCredential(a.id);
480
547
  const quotaResult = cred
481
548
  ? await fetchPoolAccountQuota(a.id, forceRefresh, a.plan)
482
549
  : { quota: null, needsReauth: true };
483
- return poolAccountDto(a, quotaResult, !!cred);
550
+ return poolAccountDto(a, quotaResult, !!cred, isCodexAccountPaused(runtimeConfig, a.id));
484
551
  });
485
552
  const hasMainCredential = readCodexTokens() !== null;
486
553
  const mainNeedsReauth = !hasMainCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
@@ -493,6 +560,7 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa
493
560
  email: maskEmail(mainInfo.email) ?? "Codex App login",
494
561
  plan: mainInfo.plan,
495
562
  isMain: true,
563
+ paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
496
564
  hasCredential: hasMainCredential,
497
565
  needsReauth: mainNeedsReauth,
498
566
  quota: mainInfo.quota ? { ...quotaForPlan({ ...mainInfo.quota, updatedAt: Date.now() }, mainInfo.plan) } : null,
@@ -501,6 +569,69 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa
501
569
  return [main, ...withQuota];
502
570
  }
503
571
 
572
+ interface PauseExhaustedResult {
573
+ pausedAccountIds: string[];
574
+ checkedAccountCount: number;
575
+ failedAccountCount: number;
576
+ }
577
+
578
+ function selectFallbackAfterPause(config: OcxConfig, pausedActiveId: string): void {
579
+ reconcileCodexActiveAfterExclusion(config, pausedActiveId);
580
+ }
581
+
582
+ async function pauseExhaustedCodexAccounts(config: OcxConfig): Promise<PauseExhaustedResult> {
583
+ const poolAccounts = (config.codexAccounts ?? []).filter(account => !account.isMain);
584
+ const mainAttempted = readCodexTokens() !== null;
585
+ const [mainResult, poolResults] = await Promise.all([
586
+ fetchMainAccountInfoAttempt(true, 1),
587
+ mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
588
+ if (!getCodexAccountCredential(account.id)) return { account, quotaResult: null };
589
+ return {
590
+ account,
591
+ quotaResult: await fetchPoolAccountQuota(account.id, true, account.plan),
592
+ };
593
+ }),
594
+ ]);
595
+
596
+ let checkedAccountCount = 0;
597
+ let failedAccountCount = 0;
598
+ const exhaustedIds: string[] = [];
599
+ if (mainAttempted) {
600
+ if (mainResult.freshQuota && mainResult.info.plan) {
601
+ checkedAccountCount += 1;
602
+ if (
603
+ !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID)
604
+ && isCodexQuotaExhausted(mainResult.freshQuota, mainResult.info.plan)
605
+ ) {
606
+ exhaustedIds.push(MAIN_CODEX_ACCOUNT_ID);
607
+ }
608
+ } else {
609
+ failedAccountCount += 1;
610
+ }
611
+ }
612
+ for (const { account, quotaResult } of poolResults) {
613
+ const currentAccount = (config.codexAccounts ?? []).find(candidate => candidate.id === account.id && !candidate.isMain);
614
+ if (!currentAccount) continue;
615
+ const generation = quotaResult?.freshCredentialGeneration;
616
+ const plan = quotaResult?.freshPlan ?? currentAccount.plan;
617
+ if (!quotaResult?.freshQuota || generation === undefined || !isCodexAccountGenerationLive(account.id, generation) || !plan) {
618
+ failedAccountCount += 1;
619
+ continue;
620
+ }
621
+ checkedAccountCount += 1;
622
+ if (!isCodexAccountPaused(config, account.id) && isCodexQuotaExhausted(quotaResult.freshQuota, plan)) {
623
+ exhaustedIds.push(account.id);
624
+ }
625
+ }
626
+
627
+ for (const id of exhaustedIds) {
628
+ setCodexAccountPaused(config, id, true);
629
+ clearThreadAccountMapForAccount(id);
630
+ }
631
+ for (const id of exhaustedIds) selectFallbackAfterPause(config, id);
632
+ return { pausedAccountIds: exhaustedIds, checkedAccountCount, failedAccountCount };
633
+ }
634
+
504
635
  export async function handleCodexAuthAPI(
505
636
  req: Request,
506
637
  url: URL,
@@ -520,17 +651,15 @@ export async function handleCodexAuthAPI(
520
651
  if (!body.id || !body.email || !body.accessToken || !body.refreshToken || !body.chatgptAccountId) {
521
652
  return jsonResponse({ error: "Missing required fields" }, 400);
522
653
  }
523
- if (!ACCOUNT_ID_RE.test(body.id)) {
654
+ if (!isValidCodexAccountId(body.id)) {
524
655
  return jsonResponse({ error: "Invalid account id format" }, 400);
525
656
  }
526
657
  if (body.accessToken.length > 10_000 || body.refreshToken.length > 10_000) {
527
658
  return jsonResponse({ error: "Input too large" }, 400);
528
659
  }
529
660
  const runtimeConfig = getRuntimeConfig(config);
530
- const accounts = runtimeConfig.codexAccounts ?? [];
531
- if (accounts.some(a => a.id === body.id) || getCodexAccountCredential(body.id)) {
532
- return jsonResponse({ error: `Account id already exists: ${body.id}` }, 400);
533
- }
661
+ const preflightConflict = codexAccountPersistenceConflict(runtimeConfig, body.id, "create");
662
+ if (preflightConflict) return jsonResponse({ error: preflightConflict }, 400);
534
663
  // 1.1: Duplicate check is scoped by personal vs workspace plan bucket.
535
664
  const derivedAccountId = extractAccountId(undefined, body.accessToken) ?? body.chatgptAccountId;
536
665
  const collision = checkAccountIdCollision(derivedAccountId, body.email, body.plan);
@@ -542,6 +671,9 @@ export async function handleCodexAuthAPI(
542
671
  const exp = typeof payload?.exp === "number" ? payload.exp * 1000 : Date.now() + 3600_000;
543
672
  const warmup = await verifyCodexAccountWarmup(body.id, body.accessToken, derivedAccountId);
544
673
  if (!warmup.ok) return warmup.response;
674
+ const latestConfig = getRuntimeConfig(config);
675
+ const commitConflict = codexAccountPersistenceConflict(latestConfig, body.id, "create");
676
+ if (commitConflict) return jsonResponse({ error: commitConflict }, 400);
545
677
  saveCodexAccountCredential(body.id, {
546
678
  accessToken: body.accessToken,
547
679
  refreshToken: body.refreshToken,
@@ -550,9 +682,10 @@ export async function handleCodexAuthAPI(
550
682
  });
551
683
  markCodexAccountValidated(body.id, warmup.validatedAt);
552
684
  clearAccountNeedsReauth(body.id);
685
+ const accounts = latestConfig.codexAccounts ?? [];
553
686
  accounts.push(withCodexAccountLogLabel({ id: body.id, email: body.email, plan: body.plan, isMain: false }, accounts));
554
- runtimeConfig.codexAccounts = accounts;
555
- saveRuntimeConfig(config, runtimeConfig);
687
+ latestConfig.codexAccounts = accounts;
688
+ saveRuntimeConfig(config, latestConfig);
556
689
  return jsonResponse({ ok: true });
557
690
  }
558
691
 
@@ -560,6 +693,11 @@ export async function handleCodexAuthAPI(
560
693
  const id = url.searchParams.get("id");
561
694
  if (!id) return jsonResponse({ error: "Missing id" }, 400);
562
695
  const runtimeConfig = getRuntimeConfig(config);
696
+ const isLegacyPoolAccount = CODEX_ACCOUNT_ID_RE.test(id)
697
+ && (runtimeConfig.codexAccounts ?? []).some(account => !account.isMain && account.id === id);
698
+ if (!isValidCodexAccountId(id) && !isLegacyPoolAccount) {
699
+ return jsonResponse({ error: "Invalid account id format" }, 400);
700
+ }
563
701
  deleteCodexAccount(runtimeConfig, id);
564
702
  saveRuntimeConfig(config, runtimeConfig);
565
703
  return jsonResponse({ ok: true });
@@ -569,8 +707,8 @@ export async function handleCodexAuthAPI(
569
707
  const body = await req.json().catch(() => ({})) as { id?: unknown; alias?: unknown };
570
708
  const id = typeof body.id === "string" ? body.id.trim() : "";
571
709
  const alias = typeof body.alias === "string" ? body.alias.trim() : "";
572
- if (!id || !ACCOUNT_ID_RE.test(id)) return jsonResponse({ error: "Invalid account id format" }, 400);
573
710
  if (id === MAIN_CODEX_ACCOUNT_ID) return jsonResponse({ error: "Main Codex account alias is not configurable" }, 400);
711
+ if (!isValidCodexAccountId(id)) return jsonResponse({ error: "Invalid account id format" }, 400);
574
712
  if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) {
575
713
  return jsonResponse({ error: "Alias must be a string of at most 80 printable characters" }, 400);
576
714
  }
@@ -583,6 +721,59 @@ export async function handleCodexAuthAPI(
583
721
  return jsonResponse({ ok: true, id, alias: alias || null });
584
722
  }
585
723
 
724
+ if (url.pathname === "/api/codex-auth/accounts/pause" && req.method === "PUT") {
725
+ const body = await req.json().catch(() => ({})) as { id?: unknown; paused?: unknown };
726
+ const id = typeof body.id === "string" ? body.id.trim() : "";
727
+ if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) {
728
+ return jsonResponse({ error: "Invalid account id format" }, 400);
729
+ }
730
+ if (typeof body.paused !== "boolean") return jsonResponse({ error: "paused must be a boolean" }, 400);
731
+
732
+ const runtimeConfig = getRuntimeConfig(config);
733
+ const exists = id === MAIN_CODEX_ACCOUNT_ID
734
+ || (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id);
735
+ if (!exists) return jsonResponse({ error: "Account not found" }, 404);
736
+
737
+ setCodexAccountPaused(runtimeConfig, id, body.paused);
738
+ if (body.paused) {
739
+ clearThreadAccountMapForAccount(id);
740
+ selectFallbackAfterPause(runtimeConfig, id);
741
+ }
742
+ saveRuntimeConfig(config, runtimeConfig);
743
+ return jsonResponse({
744
+ ok: true,
745
+ id,
746
+ paused: body.paused,
747
+ activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
748
+ appliesImmediately: true,
749
+ });
750
+ }
751
+
752
+ if (url.pathname === "/api/codex-auth/accounts/pause-exhausted" && req.method === "PUT") {
753
+ const runtimeConfig = getRuntimeConfig(config);
754
+ const result = await pauseExhaustedCodexAccounts(runtimeConfig);
755
+ const { pausedAccountIds, checkedAccountCount, failedAccountCount } = result;
756
+ if (checkedAccountCount === 0 && failedAccountCount > 0) {
757
+ return jsonResponse({
758
+ ok: false,
759
+ error: "Failed to refresh any Codex account quota",
760
+ checkedAccountCount,
761
+ failedAccountCount,
762
+ }, 502);
763
+ }
764
+ if (pausedAccountIds.length > 0) saveRuntimeConfig(config, runtimeConfig);
765
+ return jsonResponse({
766
+ ok: true,
767
+ pausedAccountIds,
768
+ pausedCount: pausedAccountIds.length,
769
+ checkedAccountCount,
770
+ failedAccountCount,
771
+ complete: failedAccountCount === 0,
772
+ activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
773
+ appliesImmediately: true,
774
+ });
775
+ }
776
+
586
777
  // Manual escape from a quota cooldown. Injected Codex routing makes this proxy the only
587
778
  // model path for Codex Desktop, so a cooldown that outlives the real upstream limit
588
779
  // otherwise leaves editing config.toml as the user's only recovery.
@@ -594,7 +785,9 @@ export async function handleCodexAuthAPI(
594
785
  if (url.pathname === "/api/codex-auth/accounts/clear-cooldown" && req.method === "POST") {
595
786
  const body = await req.json().catch(() => ({})) as { id?: unknown };
596
787
  const id = typeof body.id === "string" ? body.id.trim() : "";
597
- if (!id || !ACCOUNT_ID_RE.test(id)) return jsonResponse({ error: "Invalid account id format" }, 400);
788
+ if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) {
789
+ return jsonResponse({ error: "Invalid account id format" }, 400);
790
+ }
598
791
  return jsonResponse({ ok: true, id, cleared: clearCodexAccountCooldown(id) });
599
792
  }
600
793
 
@@ -602,12 +795,21 @@ export async function handleCodexAuthAPI(
602
795
  let body: { accountId: string | null };
603
796
  try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
604
797
  const runtimeConfig = getRuntimeConfig(config);
798
+ const targetAccountId = body.accountId ?? MAIN_CODEX_ACCOUNT_ID;
799
+ if (body.accountId === MAIN_CODEX_ACCOUNT_ID && hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) {
800
+ return jsonResponse({ error: "Remove the legacy __main__ pool row before selecting the Desktop account" }, 409);
801
+ }
802
+ if (isCodexAccountPaused(runtimeConfig, targetAccountId)) {
803
+ return jsonResponse({ error: "Account is paused" }, 409);
804
+ }
605
805
  if (body.accountId != null && body.accountId !== MAIN_CODEX_ACCOUNT_ID) {
606
- const exists = (runtimeConfig.codexAccounts ?? []).some(a => a.id === body.accountId);
806
+ if (!isValidCodexAccountId(body.accountId)) return jsonResponse({ error: "Invalid account id format" }, 400);
807
+ const exists = (runtimeConfig.codexAccounts ?? [])
808
+ .some(account => isSelectableCodexPoolAccount(account) && account.id === body.accountId);
607
809
  if (!exists) return jsonResponse({ error: "Account not found" }, 400);
608
810
  }
609
811
  runtimeConfig.activeCodexAccountId = body.accountId ?? undefined;
610
- resetCodexRoutingForManualSelection(body.accountId ?? MAIN_CODEX_ACCOUNT_ID);
812
+ resetCodexRoutingForManualSelection(targetAccountId);
611
813
  saveRuntimeConfig(config, runtimeConfig);
612
814
  return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true });
613
815
  }
@@ -615,9 +817,11 @@ export async function handleCodexAuthAPI(
615
817
  if (url.pathname === "/api/codex-auth/active" && req.method === "GET") {
616
818
  const runtimeConfig = getRuntimeConfig(config);
617
819
  return jsonResponse({
618
- activeCodexAccountId: runtimeConfig.activeCodexAccountId ?? null,
820
+ activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
619
821
  autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80,
620
822
  upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3,
823
+ accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
824
+ accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit),
621
825
  });
622
826
  }
623
827
 
@@ -633,6 +837,46 @@ export async function handleCodexAuthAPI(
633
837
  return jsonResponse({ ok: true });
634
838
  }
635
839
 
840
+ if (
841
+ url.pathname === "/api/codex-auth/pool-strategy"
842
+ && (req.method === "PUT" || req.method === "PATCH")
843
+ ) {
844
+ let parsedBody: unknown;
845
+ try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
846
+ if (typeof parsedBody !== "object" || parsedBody === null || Array.isArray(parsedBody)) {
847
+ return jsonResponse({ error: "body must be an object" }, 400);
848
+ }
849
+ const body = parsedBody as { strategy?: unknown; stickyLimit?: unknown };
850
+ if (body.strategy === undefined && body.stickyLimit === undefined) {
851
+ return jsonResponse({ error: "strategy or stickyLimit required" }, 400);
852
+ }
853
+ const runtimeConfig = getRuntimeConfig(config);
854
+ let nextStrategy: NonNullable<ReturnType<typeof parseAccountPoolStrategy>> | undefined;
855
+ let nextSticky: NonNullable<ReturnType<typeof parseAccountPoolStickyLimit>> | undefined;
856
+ if (body.strategy !== undefined) {
857
+ const parsed = parseAccountPoolStrategy(body.strategy);
858
+ if (parsed === null) {
859
+ return jsonResponse({ error: 'strategy must be one of: quota, round-robin, fill-first' }, 400);
860
+ }
861
+ nextStrategy = parsed;
862
+ }
863
+ if (body.stickyLimit !== undefined) {
864
+ const parsed = parseAccountPoolStickyLimit(body.stickyLimit);
865
+ if (parsed === null) {
866
+ return jsonResponse({ error: "stickyLimit must be an integer 1-100" }, 400);
867
+ }
868
+ nextSticky = parsed;
869
+ }
870
+ if (nextStrategy !== undefined) runtimeConfig.accountPoolStrategy = nextStrategy;
871
+ if (nextSticky !== undefined) runtimeConfig.accountPoolStickyLimit = nextSticky;
872
+ saveRuntimeConfig(config, runtimeConfig);
873
+ return jsonResponse({
874
+ ok: true,
875
+ accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
876
+ accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit),
877
+ });
878
+ }
879
+
636
880
  if (url.pathname === "/api/codex-auth/failover" && req.method === "PUT") {
637
881
  let body: { threshold: number };
638
882
  try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
@@ -734,15 +978,15 @@ export async function handleCodexAuthAPI(
734
978
  const body = (await req.json().catch(() => ({}))) as { id?: string; reauth?: boolean };
735
979
  const requestedAccountId = body.id?.trim();
736
980
  const reauth = body.reauth === true;
737
- if (requestedAccountId && !ACCOUNT_ID_RE.test(requestedAccountId)) {
981
+ if (requestedAccountId && !isValidCodexAccountId(requestedAccountId)) {
738
982
  return jsonResponse({ error: "Invalid account id format" }, 400);
739
983
  }
740
984
  const accountId = requestedAccountId || `chatgpt-${Date.now()}`;
741
985
  const runtimeConfig = getRuntimeConfig(config);
742
- const exists = (runtimeConfig.codexAccounts ?? []).some(a => a.id === accountId) || Boolean(getCodexAccountCredential(accountId));
743
- if (exists && !reauth) {
744
- return jsonResponse({ error: `Account id already exists: ${accountId}` }, 400);
745
- }
986
+ const preflightConflict = !reauth
987
+ ? codexAccountPersistenceConflict(runtimeConfig, accountId, "create")
988
+ : undefined;
989
+ if (preflightConflict) return jsonResponse({ error: preflightConflict }, 400);
746
990
  if (reauth) {
747
991
  if (!requestedAccountId) return jsonResponse({ error: "id required for reauth" }, 400);
748
992
  if (!configuredPoolAccount(runtimeConfig, accountId)) {
@@ -860,6 +1104,24 @@ export async function handleCodexAuthAPI(
860
1104
  break;
861
1105
  }
862
1106
 
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
+ });
1121
+ completed = true;
1122
+ break;
1123
+ }
1124
+
863
1125
  saveCodexAccountCredential(accountId, {
864
1126
  accessToken: cred.access,
865
1127
  refreshToken: cred.refresh,
@@ -872,15 +1134,12 @@ export async function handleCodexAuthAPI(
872
1134
  setAccountQuotaFromParsed(accountId, quota);
873
1135
  }
874
1136
 
875
- const latestConfig = getRuntimeConfig(config);
876
- const accounts = latestConfig.codexAccounts ?? [];
877
- const existingIdx = accounts.findIndex(a => a.id === accountId);
878
1137
  if (existingIdx >= 0) {
879
1138
  // Keep the pool id stable; refresh display metadata after a successful login/reauth.
880
1139
  accounts[existingIdx] = withCodexAccountLogLabel({
881
1140
  ...accounts[existingIdx],
882
1141
  email,
883
- plan,
1142
+ plan: plan ?? accounts[existingIdx].plan,
884
1143
  isMain: false,
885
1144
  }, accounts);
886
1145
  latestConfig.codexAccounts = accounts;
@@ -4,6 +4,7 @@ import { getCodexAccountCredential } from "./account-store";
4
4
  import { loadConfig } from "../config";
5
5
  import { resolveCodexHomeDir } from "./home";
6
6
  import { extractAccountId } from "../oauth/chatgpt";
7
+ import { isSelectableCodexPoolAccount } from "./account-id";
7
8
 
8
9
  export interface CodexTokens {
9
10
  access_token: string;
@@ -94,7 +95,7 @@ export function checkAccountIdCollision(
94
95
  const candidateWorkspace = isWorkspacePlan(plan);
95
96
  for (const account of loadConfig().codexAccounts ?? []) {
96
97
  if (excludeAccountId && account.id === excludeAccountId) continue;
97
- if (account.isMain) continue;
98
+ if (!isSelectableCodexPoolAccount(account)) continue;
98
99
  if (isWorkspacePlan(account.plan) !== candidateWorkspace) continue;
99
100
  const cred = getCodexAccountCredential(account.id);
100
101
  const poolEmail = normalizedEmail(account.email);