oc-codex-multi-auth 6.20.0 → 6.22.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 (215) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +729 -568
  3. package/assets/icon.svg +7 -7
  4. package/assets/opencode-logo-ornate-dark.svg +18 -18
  5. package/assets/readme-hero.svg +31 -31
  6. package/config/README.md +177 -177
  7. package/config/minimal-opencode.json +14 -14
  8. package/config/opencode-legacy.json +1496 -1496
  9. package/config/opencode-modern.json +555 -555
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +567 -102
  12. package/dist/index.js.map +1 -1
  13. package/dist/lib/account-display.d.ts +46 -0
  14. package/dist/lib/account-display.d.ts.map +1 -1
  15. package/dist/lib/account-display.js +263 -1
  16. package/dist/lib/account-display.js.map +1 -1
  17. package/dist/lib/accounts/persistence.d.ts +3 -1
  18. package/dist/lib/accounts/persistence.d.ts.map +1 -1
  19. package/dist/lib/accounts/persistence.js +27 -3
  20. package/dist/lib/accounts/persistence.js.map +1 -1
  21. package/dist/lib/accounts/stale-state.d.ts +1 -0
  22. package/dist/lib/accounts/stale-state.d.ts.map +1 -1
  23. package/dist/lib/accounts/stale-state.js +30 -0
  24. package/dist/lib/accounts/stale-state.js.map +1 -1
  25. package/dist/lib/accounts/warm-recovery.d.ts +8 -0
  26. package/dist/lib/accounts/warm-recovery.d.ts.map +1 -0
  27. package/dist/lib/accounts/warm-recovery.js +70 -0
  28. package/dist/lib/accounts/warm-recovery.js.map +1 -0
  29. package/dist/lib/accounts/warm-request.d.ts +2 -0
  30. package/dist/lib/accounts/warm-request.d.ts.map +1 -1
  31. package/dist/lib/accounts/warm-request.js +4 -2
  32. package/dist/lib/accounts/warm-request.js.map +1 -1
  33. package/dist/lib/accounts.d.ts +5 -1
  34. package/dist/lib/accounts.d.ts.map +1 -1
  35. package/dist/lib/accounts.js +22 -17
  36. package/dist/lib/accounts.js.map +1 -1
  37. package/dist/lib/auth/login-runner.d.ts.map +1 -1
  38. package/dist/lib/auth/login-runner.js +79 -2
  39. package/dist/lib/auth/login-runner.js.map +1 -1
  40. package/dist/lib/auto-update-checker.d.ts +17 -1
  41. package/dist/lib/auto-update-checker.d.ts.map +1 -1
  42. package/dist/lib/auto-update-checker.js +49 -3
  43. package/dist/lib/auto-update-checker.js.map +1 -1
  44. package/dist/lib/circuit-breaker.d.ts +5 -0
  45. package/dist/lib/circuit-breaker.d.ts.map +1 -1
  46. package/dist/lib/circuit-breaker.js +23 -6
  47. package/dist/lib/circuit-breaker.js.map +1 -1
  48. package/dist/lib/cli.d.ts +1 -0
  49. package/dist/lib/cli.d.ts.map +1 -1
  50. package/dist/lib/cli.js +9 -3
  51. package/dist/lib/cli.js.map +1 -1
  52. package/dist/lib/codex-reset.d.ts +7 -1
  53. package/dist/lib/codex-reset.d.ts.map +1 -1
  54. package/dist/lib/codex-reset.js +9 -2
  55. package/dist/lib/codex-reset.js.map +1 -1
  56. package/dist/lib/codex-usage.d.ts +7 -4
  57. package/dist/lib/codex-usage.d.ts.map +1 -1
  58. package/dist/lib/codex-usage.js +48 -13
  59. package/dist/lib/codex-usage.js.map +1 -1
  60. package/dist/lib/config.d.ts +77 -1
  61. package/dist/lib/config.d.ts.map +1 -1
  62. package/dist/lib/config.js +190 -15
  63. package/dist/lib/config.js.map +1 -1
  64. package/dist/lib/context-overflow.js +10 -10
  65. package/dist/lib/desktop-notifications.js +6 -6
  66. package/dist/lib/oauth-success.js +202 -202
  67. package/dist/lib/parallel-probe.d.ts.map +1 -1
  68. package/dist/lib/parallel-probe.js +2 -1
  69. package/dist/lib/parallel-probe.js.map +1 -1
  70. package/dist/lib/plan-allotment.d.ts +72 -0
  71. package/dist/lib/plan-allotment.d.ts.map +1 -0
  72. package/dist/lib/plan-allotment.js +142 -0
  73. package/dist/lib/plan-allotment.js.map +1 -0
  74. package/dist/lib/plugin-origin.d.ts +77 -0
  75. package/dist/lib/plugin-origin.d.ts.map +1 -0
  76. package/dist/lib/plugin-origin.js +301 -0
  77. package/dist/lib/plugin-origin.js.map +1 -0
  78. package/dist/lib/prompts/codex-opencode-bridge.js +67 -67
  79. package/dist/lib/prompts/codex.js +75 -75
  80. package/dist/lib/quota-display.d.ts +40 -0
  81. package/dist/lib/quota-display.d.ts.map +1 -0
  82. package/dist/lib/quota-display.js +45 -0
  83. package/dist/lib/quota-display.js.map +1 -0
  84. package/dist/lib/quota-notifications.d.ts +2 -1
  85. package/dist/lib/quota-notifications.d.ts.map +1 -1
  86. package/dist/lib/quota-notifications.js +43 -14
  87. package/dist/lib/quota-notifications.js.map +1 -1
  88. package/dist/lib/quota-overview.d.ts +253 -0
  89. package/dist/lib/quota-overview.d.ts.map +1 -0
  90. package/dist/lib/quota-overview.js +707 -0
  91. package/dist/lib/quota-overview.js.map +1 -0
  92. package/dist/lib/request/fetch-helpers.js +22 -18
  93. package/dist/lib/request/fetch-helpers.js.map +1 -1
  94. package/dist/lib/request/response-handler.d.ts.map +1 -1
  95. package/dist/lib/request/response-handler.js +85 -43
  96. package/dist/lib/request/response-handler.js.map +1 -1
  97. package/dist/lib/request/retry-budget.d.ts +26 -0
  98. package/dist/lib/request/retry-budget.d.ts.map +1 -1
  99. package/dist/lib/request/retry-budget.js +43 -0
  100. package/dist/lib/request/retry-budget.js.map +1 -1
  101. package/dist/lib/rotation.d.ts +1 -1
  102. package/dist/lib/schemas.d.ts +67 -0
  103. package/dist/lib/schemas.d.ts.map +1 -1
  104. package/dist/lib/schemas.js +53 -0
  105. package/dist/lib/schemas.js.map +1 -1
  106. package/dist/lib/storage/backup.d.ts +33 -0
  107. package/dist/lib/storage/backup.d.ts.map +1 -1
  108. package/dist/lib/storage/backup.js +43 -7
  109. package/dist/lib/storage/backup.js.map +1 -1
  110. package/dist/lib/storage/credential-snapshots.d.ts +78 -0
  111. package/dist/lib/storage/credential-snapshots.d.ts.map +1 -0
  112. package/dist/lib/storage/credential-snapshots.js +302 -0
  113. package/dist/lib/storage/credential-snapshots.js.map +1 -0
  114. package/dist/lib/storage/export-import.d.ts.map +1 -1
  115. package/dist/lib/storage/export-import.js +7 -0
  116. package/dist/lib/storage/export-import.js.map +1 -1
  117. package/dist/lib/storage/flagged.d.ts.map +1 -1
  118. package/dist/lib/storage/flagged.js +23 -1
  119. package/dist/lib/storage/flagged.js.map +1 -1
  120. package/dist/lib/storage/load-save.d.ts +5 -0
  121. package/dist/lib/storage/load-save.d.ts.map +1 -1
  122. package/dist/lib/storage/load-save.js +61 -0
  123. package/dist/lib/storage/load-save.js.map +1 -1
  124. package/dist/lib/storage/normalize.d.ts.map +1 -1
  125. package/dist/lib/storage/normalize.js +38 -2
  126. package/dist/lib/storage/normalize.js.map +1 -1
  127. package/dist/lib/storage/paths.d.ts +1 -0
  128. package/dist/lib/storage/paths.d.ts.map +1 -1
  129. package/dist/lib/storage/paths.js +1 -1
  130. package/dist/lib/storage/paths.js.map +1 -1
  131. package/dist/lib/storage/state.d.ts +1 -0
  132. package/dist/lib/storage/state.d.ts.map +1 -1
  133. package/dist/lib/storage/state.js +12 -0
  134. package/dist/lib/storage/state.js.map +1 -1
  135. package/dist/lib/storage/test-home-guard.d.ts +35 -0
  136. package/dist/lib/storage/test-home-guard.d.ts.map +1 -0
  137. package/dist/lib/storage/test-home-guard.js +59 -0
  138. package/dist/lib/storage/test-home-guard.js.map +1 -0
  139. package/dist/lib/tools/codex-dashboard.d.ts.map +1 -1
  140. package/dist/lib/tools/codex-dashboard.js +3 -2
  141. package/dist/lib/tools/codex-dashboard.js.map +1 -1
  142. package/dist/lib/tools/codex-doctor.d.ts.map +1 -1
  143. package/dist/lib/tools/codex-doctor.js +16 -0
  144. package/dist/lib/tools/codex-doctor.js.map +1 -1
  145. package/dist/lib/tools/codex-health.d.ts.map +1 -1
  146. package/dist/lib/tools/codex-health.js +10 -2
  147. package/dist/lib/tools/codex-health.js.map +1 -1
  148. package/dist/lib/tools/codex-label.d.ts.map +1 -1
  149. package/dist/lib/tools/codex-label.js +1 -0
  150. package/dist/lib/tools/codex-label.js.map +1 -1
  151. package/dist/lib/tools/codex-limits.d.ts.map +1 -1
  152. package/dist/lib/tools/codex-limits.js +26 -10
  153. package/dist/lib/tools/codex-limits.js.map +1 -1
  154. package/dist/lib/tools/codex-list.d.ts.map +1 -1
  155. package/dist/lib/tools/codex-list.js +28 -3
  156. package/dist/lib/tools/codex-list.js.map +1 -1
  157. package/dist/lib/tools/codex-note.d.ts.map +1 -1
  158. package/dist/lib/tools/codex-note.js +4 -1
  159. package/dist/lib/tools/codex-note.js.map +1 -1
  160. package/dist/lib/tools/codex-pool.d.ts.map +1 -1
  161. package/dist/lib/tools/codex-pool.js +6 -2
  162. package/dist/lib/tools/codex-pool.js.map +1 -1
  163. package/dist/lib/tools/codex-refresh.d.ts.map +1 -1
  164. package/dist/lib/tools/codex-refresh.js +4 -1
  165. package/dist/lib/tools/codex-refresh.js.map +1 -1
  166. package/dist/lib/tools/codex-remove.d.ts.map +1 -1
  167. package/dist/lib/tools/codex-remove.js +1 -0
  168. package/dist/lib/tools/codex-remove.js.map +1 -1
  169. package/dist/lib/tools/codex-reset.d.ts.map +1 -1
  170. package/dist/lib/tools/codex-reset.js +53 -8
  171. package/dist/lib/tools/codex-reset.js.map +1 -1
  172. package/dist/lib/tools/codex-status.d.ts.map +1 -1
  173. package/dist/lib/tools/codex-status.js +25 -2
  174. package/dist/lib/tools/codex-status.js.map +1 -1
  175. package/dist/lib/tools/codex-switch.d.ts.map +1 -1
  176. package/dist/lib/tools/codex-switch.js +1 -0
  177. package/dist/lib/tools/codex-switch.js.map +1 -1
  178. package/dist/lib/tools/codex-tag.d.ts.map +1 -1
  179. package/dist/lib/tools/codex-tag.js +4 -1
  180. package/dist/lib/tools/codex-tag.js.map +1 -1
  181. package/dist/lib/tools/codex-warm.d.ts +2 -1
  182. package/dist/lib/tools/codex-warm.d.ts.map +1 -1
  183. package/dist/lib/tools/codex-warm.js +49 -5
  184. package/dist/lib/tools/codex-warm.js.map +1 -1
  185. package/dist/lib/tools/index.d.ts +10 -1
  186. package/dist/lib/tools/index.d.ts.map +1 -1
  187. package/dist/lib/tools/index.js.map +1 -1
  188. package/dist/lib/tui-quota-cache.d.ts +35 -0
  189. package/dist/lib/tui-quota-cache.d.ts.map +1 -1
  190. package/dist/lib/tui-quota-cache.js +79 -12
  191. package/dist/lib/tui-quota-cache.js.map +1 -1
  192. package/dist/lib/tui-quota-overview.d.ts +55 -0
  193. package/dist/lib/tui-quota-overview.d.ts.map +1 -0
  194. package/dist/lib/tui-quota-overview.js +209 -0
  195. package/dist/lib/tui-quota-overview.js.map +1 -0
  196. package/dist/lib/tui-status.d.ts +55 -0
  197. package/dist/lib/tui-status.d.ts.map +1 -1
  198. package/dist/lib/tui-status.js +155 -14
  199. package/dist/lib/tui-status.js.map +1 -1
  200. package/dist/lib/ui/auth-menu.d.ts +2 -0
  201. package/dist/lib/ui/auth-menu.d.ts.map +1 -1
  202. package/dist/lib/ui/auth-menu.js +10 -6
  203. package/dist/lib/ui/auth-menu.js.map +1 -1
  204. package/dist/tui.d.ts +59 -1
  205. package/dist/tui.d.ts.map +1 -1
  206. package/dist/tui.js +535 -43
  207. package/dist/tui.js.map +1 -1
  208. package/package.json +155 -155
  209. package/scripts/audit-dev-allowlist.js +114 -114
  210. package/scripts/clean-dist.js +27 -27
  211. package/scripts/copy-oauth-success.js +47 -47
  212. package/scripts/install-oc-codex-multi-auth-core.js +2003 -1469
  213. package/scripts/install-oc-codex-multi-auth.js +37 -37
  214. package/scripts/test-all-models.sh +260 -260
  215. package/scripts/validate-model-map.sh +97 -97
package/dist/index.js CHANGED
@@ -23,6 +23,12 @@
23
23
 
24
24
  */
25
25
  import { mkdir, readFile, writeFile } from "node:fs/promises";
26
+ import { watchFile, unwatchFile } from "node:fs";
27
+ import { consumeLastWrittenAccountsDigest } from "./lib/storage/load-save.js";
28
+ import { subscribeToStoragePathChanges } from "./lib/storage/state.js";
29
+ import { isKeychainOptInEnabled } from "./lib/storage/keychain.js";
30
+ import { AnyAccountStorageSchema } from "./lib/schemas.js";
31
+ import { registerCleanup, unregisterCleanup } from "./lib/shutdown.js";
26
32
  import { createHash } from "node:crypto";
27
33
  import { homedir } from "node:os";
28
34
  import { join } from "node:path";
@@ -33,15 +39,17 @@ import { buildDeviceCodeInstructions, completeDeviceCodeSession, createDeviceCod
33
39
  import { applyAccountSelectionFallbacks, persistResolvedAccountSelection, persistAccountPool, resolveAndPersistAccountSelection, resolveAccountSelection, } from "./lib/auth/login-runner.js";
34
40
  import { coordinateFlaggedPersistedRefresh, coordinatePersistedRefresh, } from "./lib/storage/coordinated-refresh.js";
35
41
  import { promptAddAnotherAccount, promptLoginMode } from "./lib/cli.js";
36
- import { getCodexMode, getRequestTransformMode, getFastSession, getFastSessionStrategy, getFastSessionMaxInputItems, getRetryProfile, getRetryBudgetOverrides, getRateLimitToastDebounceMs, getRetryAllAccountsMaxRetries, getRetryAllAccountsMaxWaitMs, getRetryAllAccountsRateLimited, getFallbackToGpt52OnUnsupportedGpt53, getUnsupportedCodexPolicy, getUnsupportedCodexFallbackChain, getTokenRefreshSkewMs, getSessionRecovery, getAutoResume, getAutoUpdate, getToastDurationMs, getAccountToastsEnabled, getPerProjectAccounts, getEmptyResponseMaxRetries, getEmptyResponseRetryDelayMs, getPidOffsetEnabled, getRotationStrategy, getModelAccountPool, getModelAccountPoolMode, getFetchTimeoutMs, getStreamStallTimeoutMs, getCodexTuiV2, getCodexTuiColorProfile, getCodexTuiGlyphMode, getBeginnerSafeMode, getCodexTuiMaskEmail, loadPluginConfig, } from "./lib/config.js";
42
+ import { getCodexMode, getRequestTransformMode, getFastSession, getFastSessionStrategy, getFastSessionMaxInputItems, getRetryProfile, getRetryBudgetOverrides, getRateLimitToastDebounceMs, getRetryAllAccountsMaxRetries, getRetryAllAccountsMaxWaitMs, getRetryAllAccountsRateLimited, getFallbackToGpt52OnUnsupportedGpt53, getUnsupportedCodexPolicy, getUnsupportedCodexFallbackChain, getTokenRefreshSkewMs, getSessionRecovery, getAutoResume, getAutoUpdate, getToastDurationMs, getAccountToastsEnabled, getPerProjectAccounts, getEmptyResponseMaxRetries, getEmptyResponseRetryDelayMs, getPidOffsetEnabled, getRotationStrategy, getModelAccountPool, getModelAccountPoolMode, getFetchTimeoutMs, getStreamStallTimeoutMs, getCodexTuiV2, getCodexTuiColorProfile, getCodexTuiGlyphMode, getBeginnerSafeMode, getCodexTuiMaskEmail, getQuotaDisplay, loadPluginConfig, } from "./lib/config.js";
37
43
  import { AUTH_LABELS, CODEX_BASE_URL, DUMMY_API_KEY, LOG_STAGES, PLUGIN_NAME, PROVIDER_ID, ACCOUNT_LIMITS, } from "./lib/constants.js";
38
44
  import { initLogger, logRequest, logDebug, logInfo, logWarn, logError, setCorrelationId, clearCorrelationId, } from "./lib/logger.js";
39
45
  import { createQuotaMonitor } from "./lib/quota-notifications.js";
40
46
  import { checkAndNotify } from "./lib/auto-update-checker.js";
47
+ import { describePluginOrigin, getPluginOrigin, recordPluginOrigin } from "./lib/plugin-origin.js";
41
48
  import { handleContextOverflow } from "./lib/context-overflow.js";
42
49
  import { AccountManager, extractAccountEmail, extractAccountId, formatAccountLabel, formatWaitTime, sanitizeEmail, shouldUpdateAccountIdFromToken, resolveRequestAccountId, parseRateLimitReason, lookupCodexCliTokensByEmail, } from "./lib/accounts.js";
43
50
  import { getModelPoolAccountKey, matchesModelPoolAccountKey, } from "./lib/accounts/pool-identity.js";
44
- import { resolveDisplayEmail } from "./lib/account-display.js";
51
+ import { formatSeatSuffix, maskIdentityValue, resolveDisplayEmail, seatIsDisclosable, } from "./lib/account-display.js";
52
+ import { extractAccountUserId } from "./lib/auth/token-utils.js";
45
53
  import { CodexAuthError } from "./lib/errors.js";
46
54
  import { getStoragePath, loadAccounts, withAccountStorageTransaction, clearAccounts, setStoragePath, loadFlaggedAccounts, withFlaggedAccountStorageTransaction, clearFlaggedAccounts, StorageError, formatStorageErrorHint, } from "./lib/storage.js";
47
55
  import { getWorkspaceIdentityKey } from "./lib/storage/identity.js";
@@ -179,9 +187,19 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
179
187
  let accountManagerPromise = null;
180
188
  let loaderMutex = null;
181
189
  let startupPrewarmTriggered = false;
190
+ let startupOriginRecorded = false;
182
191
  let startupPreflightShown = false;
183
192
  let beginnerSafeModeEnabled = false;
184
193
  const MIN_BACKOFF_MS = 100;
194
+ // An all-accounts rate-limit wait can run for days, and the local accounts
195
+ // file is its only wake-up. A quota reset granted server-side leaves that file
196
+ // untouched, so such a wait would be slept straight through. Long waits
197
+ // therefore re-probe upstream: first after a minute, doubling to a quarter
198
+ // hour, so a multi-day sleep costs a handful of usage requests rather than one
199
+ // per countdown tick.
200
+ const UPSTREAM_REPROBE_MIN_WAIT_MS = 60_000;
201
+ const UPSTREAM_REPROBE_FIRST_DELAY_MS = 60_000;
202
+ const UPSTREAM_REPROBE_MAX_DELAY_MS = 15 * 60_000;
185
203
  const runtimeMetrics = {
186
204
  startedAt: Date.now(),
187
205
  totalRequests: 0,
@@ -234,17 +252,33 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
234
252
  selectionExplainability: serializeSelectionExplainability(options.selectionExplainability ?? snapshot?.explainability ?? []),
235
253
  };
236
254
  };
237
- const buildJsonAccountIdentity = (index, options = {}) => ({
238
- index: index + 1,
239
- zeroBasedIndex: index,
240
- ...(options.includeSensitive
241
- ? {
242
- label: options.label ?? formatCommandAccountLabel(options.account, index),
243
- email: options.account?.email ?? null,
244
- accountId: options.account?.accountId ?? null,
245
- }
246
- : {}),
247
- });
255
+ const buildJsonAccountIdentity = (index, options = {}) => {
256
+ const includeSensitive = options.includeSensitive ?? false;
257
+ const accountUserId = options.account?.accountUserId?.trim() || undefined;
258
+ return {
259
+ index: index + 1,
260
+ zeroBasedIndex: index,
261
+ ...(includeSensitive
262
+ ? {
263
+ label: options.label ??
264
+ formatCommandAccountLabel(options.account, index, {
265
+ peerAccounts: options.peerAccounts,
266
+ }),
267
+ email: options.account?.email ?? null,
268
+ accountId: options.account?.accountId ?? null,
269
+ }
270
+ : {}),
271
+ // Members of one Business workspace share `accountId`, so the seat is
272
+ // the field a JSON consumer can tell them apart by. It rides in both
273
+ // modes - the member id masked when sensitive output is off, and the
274
+ // suffix withheld when the id is too short to excerpt without
275
+ // disclosing it - under the same field names the standalone CLI emits.
276
+ accountUserId: maskIdentityValue(accountUserId, includeSensitive) ?? null,
277
+ seatSuffix: seatIsDisclosable(accountUserId, includeSensitive)
278
+ ? (formatSeatSuffix(accountUserId, options.peerAccounts?.map((peer) => peer?.accountUserId)) ?? null)
279
+ : null,
280
+ };
281
+ };
248
282
  const appendRoutingVisibilityText = (lines, routing, options = {}) => {
249
283
  lines.push("Routing visibility:");
250
284
  lines.push(` Requested model: ${formatRoutingValue(routing.requestedModel)}`);
@@ -443,14 +477,16 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
443
477
  logDebug(`[${PLUGIN_NAME}] Failed to clear TUI quota cache: ${error instanceof Error ? error.message : String(error)}`);
444
478
  }
445
479
  };
446
- const recordPromptQuotaHeaders = async (response, account, accountCount) => {
480
+ const recordPromptQuotaHeaders = async (response, account, accountCount, peerAccounts) => {
447
481
  try {
448
482
  const snapshot = parseTuiQuotaSnapshotFromHeaders(response.headers, {
449
483
  fingerprint: createUsageAccountFingerprint(account),
450
484
  accountIndex: account.index + 1,
451
485
  accountCount,
452
486
  accountEmail: account.email?.trim() || undefined,
453
- accountLabel: formatAccountLabel(account, account.index),
487
+ accountLabel: formatAccountLabel(account, account.index, {
488
+ peerAccounts,
489
+ }),
454
490
  });
455
491
  if (!snapshot)
456
492
  return;
@@ -806,6 +842,12 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
806
842
  const email = resolveDisplayEmail(account?.email, options.maskEmail ?? false);
807
843
  const workspace = account?.accountLabel?.trim();
808
844
  const accountId = formatAccountIdForDisplay(account?.accountId);
845
+ // `omitSeat` is for a caller that renders the seat itself in a place
846
+ // a long email cannot push it out of - a table column of its own.
847
+ // Leaving it in the label too would print the seat twice.
848
+ const seat = options.omitSeat
849
+ ? undefined
850
+ : formatSeatSuffix(account?.accountUserId, options.peerAccounts?.map((peer) => peer?.accountUserId));
809
851
  const tags = Array.isArray(account?.accountTags)
810
852
  ? account.accountTags
811
853
  .filter((tag) => typeof tag === "string")
@@ -819,6 +861,8 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
819
861
  details.push(`workspace:${workspace}`);
820
862
  if (accountId)
821
863
  details.push(`id:${accountId}`);
864
+ if (seat)
865
+ details.push(`seat:${seat}`);
822
866
  if (tags.length > 0)
823
867
  details.push(`tags:${tags.join(",")}`);
824
868
  if (details.length === 0) {
@@ -833,6 +877,8 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
833
877
  .filter((entry) => entry.length > 0)));
834
878
  };
835
879
  const supportsInteractiveMenus = () => {
880
+ if (process.env.FORCE_INTERACTIVE_MODE === "1")
881
+ return true;
836
882
  if (!process.stdin.isTTY || !process.stdout.isTTY)
837
883
  return false;
838
884
  if (process.env.OPENCODE_TUI === "1")
@@ -841,6 +887,8 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
841
887
  return false;
842
888
  if (process.env.TERM_PROGRAM === "opencode")
843
889
  return false;
890
+ if (process.env.ELECTRON_RUN_AS_NODE === "1")
891
+ return false;
844
892
  return true;
845
893
  };
846
894
  const promptAccountIndexSelection = async (ui, storage, title) => {
@@ -850,7 +898,10 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
850
898
  const { select } = await import("./lib/ui/select.js");
851
899
  const maskEmail = resolveMaskEmail();
852
900
  const selected = await select(storage.accounts.map((account, index) => ({
853
- label: formatCommandAccountLabel(account, index, { maskEmail }),
901
+ label: formatCommandAccountLabel(account, index, {
902
+ maskEmail,
903
+ peerAccounts: storage.accounts,
904
+ }),
854
905
  value: index,
855
906
  })), {
856
907
  message: title,
@@ -869,7 +920,9 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
869
920
  const toBeginnerAccountSnapshots = (storage, activeIndex, now) => {
870
921
  return storage.accounts.map((account, index) => ({
871
922
  index,
872
- label: formatCommandAccountLabel(account, index),
923
+ label: formatCommandAccountLabel(account, index, {
924
+ peerAccounts: storage.accounts,
925
+ }),
873
926
  accountLabel: account.accountLabel,
874
927
  enabled: account.enabled !== false,
875
928
  isActive: index === activeIndex,
@@ -1112,25 +1165,59 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1112
1165
  logDebug(`[${PLUGIN_NAME}] Startup preflight skipped: ${error instanceof Error ? error.message : String(error)}`);
1113
1166
  }
1114
1167
  };
1115
- const invalidateAccountManagerCache = () => {
1116
- // Dispose the outgoing manager so we don't leak its shutdown handler
1117
- // into the global cleanup queue or leave a pending debounce timer
1118
- // pointing at a stale instance. Flush first (best-effort, in the
1119
- // background) so any queued debounced save is not silently dropped.
1168
+ const invalidateAccountManagerCache = (clearedSnapshots) => {
1169
+ // Retire before flushing: keep disk membership authoritative while
1170
+ // publishing queued rate-limit evidence through the volatile merge.
1120
1171
  const previous = cachedAccountManager;
1121
1172
  cachedAccountManager = null;
1122
1173
  accountManagerPromise = null;
1123
1174
  if (previous) {
1175
+ previous.disposeShutdownHandler(false, clearedSnapshots);
1124
1176
  void previous
1125
1177
  .flushPendingSave()
1126
1178
  .catch((error) => {
1127
1179
  logWarn(`Failed to flush pending save while invalidating account manager: ${error instanceof Error ? error.message : String(error)}`);
1128
- })
1129
- .finally(() => {
1130
- previous.disposeShutdownHandler();
1131
1180
  });
1132
1181
  }
1133
1182
  };
1183
+ /**
1184
+ * `loadAccounts()` reports a read or parse failure the same way it reports
1185
+ * an absent file - by returning null - and a null load builds a manager
1186
+ * holding zero accounts. Installing that over a working pool makes this
1187
+ * process answer "No Codex accounts configured" while the accounts file on
1188
+ * disk is intact, which cross-process lock contention makes reachable.
1189
+ *
1190
+ * Emptying the pool for real always goes through an explicit action
1191
+ * (`codex-remove`, logout, a storage-mode switch); each installs its own
1192
+ * manager rather than arriving here, so refusing the shrink costs a genuine
1193
+ * deletion nothing.
1194
+ */
1195
+ const isUntrustworthyEmptyReload = (incumbent, reloaded) => incumbent !== null &&
1196
+ incumbent !== reloaded &&
1197
+ reloaded.getAccountCount() === 0 &&
1198
+ incumbent.getAccountCount() > 0;
1199
+ const EMPTY_RELOAD_RETRY_DELAY_MS = 2000;
1200
+ const EMPTY_RELOAD_MAX_RETRIES = 3;
1201
+ let emptyReloadRetries = 0;
1202
+ let emptyReloadRetryTimer;
1203
+ const cancelEmptyReloadRetry = () => {
1204
+ clearTimeout(emptyReloadRetryTimer);
1205
+ emptyReloadRetryTimer = undefined;
1206
+ emptyReloadRetries = 0;
1207
+ };
1208
+ const scheduleEmptyReloadRetry = (retry) => {
1209
+ if (emptyReloadRetries >= EMPTY_RELOAD_MAX_RETRIES) {
1210
+ emptyReloadRetries = 0;
1211
+ return;
1212
+ }
1213
+ emptyReloadRetries += 1;
1214
+ clearTimeout(emptyReloadRetryTimer);
1215
+ emptyReloadRetryTimer = setTimeout(() => {
1216
+ emptyReloadRetryTimer = undefined;
1217
+ void retry();
1218
+ }, EMPTY_RELOAD_RETRY_DELAY_MS);
1219
+ emptyReloadRetryTimer.unref();
1220
+ };
1134
1221
  const reloadCachedAccountManager = async () => {
1135
1222
  if (!cachedAccountManager)
1136
1223
  return;
@@ -1149,6 +1236,13 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1149
1236
  }
1150
1237
  try {
1151
1238
  const reloadedManager = await AccountManager.loadFromDisk();
1239
+ if (isUntrustworthyEmptyReload(previous, reloadedManager)) {
1240
+ reloadedManager.disposeShutdownHandler();
1241
+ logWarn(`[${PLUGIN_NAME}] Account reload returned no accounts while ${previous.getAccountCount()} are held; keeping the loaded pool and retrying`);
1242
+ scheduleEmptyReloadRetry(reloadCachedAccountManager);
1243
+ return;
1244
+ }
1245
+ cancelEmptyReloadRetry();
1152
1246
  cachedAccountManager = reloadedManager;
1153
1247
  accountManagerPromise = Promise.resolve(reloadedManager);
1154
1248
  // Dispose only after the replacement is installed so we never leak
@@ -1160,6 +1254,197 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1160
1254
  logWarn(`Failed to reload account manager: ${error instanceof Error ? error.message : String(error)}`);
1161
1255
  }
1162
1256
  };
1257
+ /**
1258
+ * Refills the cache after the fetch path refused an empty reload.
1259
+ * `reloadCachedAccountManager` cannot serve here - it compares against
1260
+ * the cached incumbent, which is exactly what is missing - so the
1261
+ * incumbent the refusing request kept serving is passed in instead.
1262
+ */
1263
+ const repopulateAccountManagerCache = async (incumbent) => {
1264
+ try {
1265
+ const reloaded = await AccountManager.loadFromDisk();
1266
+ if (cachedAccountManager) {
1267
+ // Another actor repopulated first; the late load is stale and
1268
+ // retires for the same reason the fetch path retires it.
1269
+ if (cachedAccountManager !== reloaded)
1270
+ reloaded.disposeShutdownHandler();
1271
+ return;
1272
+ }
1273
+ if (isUntrustworthyEmptyReload(incumbent, reloaded)) {
1274
+ reloaded.disposeShutdownHandler();
1275
+ scheduleEmptyReloadRetry(() => repopulateAccountManagerCache(incumbent));
1276
+ return;
1277
+ }
1278
+ cancelEmptyReloadRetry();
1279
+ cachedAccountManager = reloaded;
1280
+ accountManagerPromise = Promise.resolve(reloaded);
1281
+ }
1282
+ catch (error) {
1283
+ logWarn(`Failed to reload account manager: ${error instanceof Error ? error.message : String(error)}`);
1284
+ }
1285
+ };
1286
+ let watchedAccountsPath;
1287
+ let observedAccountsDigest;
1288
+ let accountsReloadTimer;
1289
+ let accountsWatcherDisposed = false;
1290
+ let accountsWatchGeneration = 0;
1291
+ let unsubscribeAccountsPath;
1292
+ const stopAccountsWatcher = () => {
1293
+ accountsWatchGeneration += 1;
1294
+ if (watchedAccountsPath)
1295
+ unwatchFile(watchedAccountsPath, onAccountsStatChanged);
1296
+ watchedAccountsPath = undefined;
1297
+ observedAccountsDigest = undefined;
1298
+ clearTimeout(accountsReloadTimer);
1299
+ accountsReloadTimer = undefined;
1300
+ };
1301
+ const disposeAccountsWatcher = () => {
1302
+ accountsWatcherDisposed = true;
1303
+ unsubscribeAccountsPath?.();
1304
+ stopAccountsWatcher();
1305
+ cancelEmptyReloadRetry();
1306
+ unregisterCleanup(disposeAccountsWatcher);
1307
+ };
1308
+ const readAccountsFileState = async (path) => {
1309
+ try {
1310
+ const content = await readFile(path, "utf8");
1311
+ const data = JSON.parse(content);
1312
+ if (!AnyAccountStorageSchema.safeParse(data).success)
1313
+ return;
1314
+ // Counted off the raw document rather than the parsed union so the
1315
+ // count is the same for every storage version.
1316
+ const accounts = data.accounts;
1317
+ return {
1318
+ digest: createHash("sha256").update(content).digest("hex"),
1319
+ accountCount: Array.isArray(accounts) ? accounts.length : 0,
1320
+ };
1321
+ }
1322
+ catch {
1323
+ return;
1324
+ }
1325
+ };
1326
+ const reloadForExternalAccountsChange = async (path, generation, attempt = 0, retired) => {
1327
+ const observed = await readAccountsFileState(path);
1328
+ if (generation !== accountsWatchGeneration || !observed || path !== getStoragePath())
1329
+ return;
1330
+ const digest = observed.digest;
1331
+ if (digest === consumeLastWrittenAccountsDigest(path))
1332
+ return;
1333
+ const retryLater = () => {
1334
+ if (attempt < 2 && generation === accountsWatchGeneration && !accountsWatcherDisposed) {
1335
+ accountsReloadTimer = setTimeout(() => {
1336
+ accountsReloadTimer = undefined;
1337
+ void reloadForExternalAccountsChange(path, generation, attempt + 1, retired);
1338
+ }, 1500);
1339
+ accountsReloadTimer.unref();
1340
+ }
1341
+ };
1342
+ const previous = cachedAccountManager;
1343
+ try {
1344
+ // A null cache means an invalidation retired the incumbent; the
1345
+ // reload still must adopt the external change. Bailing here would
1346
+ // strand it: onAccountsFileChanged already marked this digest
1347
+ // observed, and an in-flight load started before the write can
1348
+ // still resolve and install a pre-change snapshot whose
1349
+ // full-membership save then deletes the imported accounts.
1350
+ if (previous) {
1351
+ if (previous !== retired) {
1352
+ previous.disposeShutdownHandler(true);
1353
+ retired = previous;
1354
+ }
1355
+ await previous.flushPendingSave();
1356
+ }
1357
+ const reloaded = await AccountManager.loadFromDisk();
1358
+ if (generation !== accountsWatchGeneration || accountsWatcherDisposed || path !== getStoragePath()) {
1359
+ reloaded.disposeShutdownHandler();
1360
+ return;
1361
+ }
1362
+ // The file this reload observed carried accounts but the load
1363
+ // produced none, so `loadAccounts()` failed to read it rather than
1364
+ // the accounts having gone away - a failure it reports as an empty
1365
+ // result, never as a throw, so the catch below cannot see it.
1366
+ // Adopting it would answer "No Codex accounts configured" against an
1367
+ // intact file; the retired incumbent still serves its accounts until
1368
+ // a retry lands a real one.
1369
+ if (observed.accountCount > 0 && reloaded.getAccountCount() === 0) {
1370
+ reloaded.disposeShutdownHandler();
1371
+ logWarn(`[${PLUGIN_NAME}] Externally changed accounts file holds ${observed.accountCount} account(s) but loaded as empty; keeping the current pool and retrying`);
1372
+ retryLater();
1373
+ return;
1374
+ }
1375
+ const outgoing = cachedAccountManager;
1376
+ if (outgoing && outgoing !== retired) {
1377
+ // Another actor replaced the cached manager while this reload
1378
+ // was in flight (concurrent fetch reload or tool mutation).
1379
+ // Retire the incumbent with the same external-reload
1380
+ // semantics, or its queued membership save can clobber the
1381
+ // external change this reload is about to adopt.
1382
+ outgoing.disposeShutdownHandler(true);
1383
+ retired = outgoing;
1384
+ }
1385
+ cachedAccountManager = reloaded;
1386
+ accountManagerPromise = Promise.resolve(reloaded);
1387
+ observedAccountsDigest = digest;
1388
+ }
1389
+ catch {
1390
+ logWarn("Could not reload externally updated account storage");
1391
+ retryLater();
1392
+ return;
1393
+ }
1394
+ logDebug("Reloaded cached account manager after external accounts file change");
1395
+ };
1396
+ const onAccountsFileChanged = async () => {
1397
+ if (accountsWatcherDisposed)
1398
+ return;
1399
+ if (watchedAccountsPath !== getStoragePath()) {
1400
+ await ensureAccountsWatcher();
1401
+ return;
1402
+ }
1403
+ const path = watchedAccountsPath;
1404
+ if (!path)
1405
+ return;
1406
+ const generation = accountsWatchGeneration;
1407
+ const observed = await readAccountsFileState(path);
1408
+ if (generation !== accountsWatchGeneration || !observed || observed.digest === observedAccountsDigest)
1409
+ return;
1410
+ const digest = observed.digest;
1411
+ observedAccountsDigest = digest;
1412
+ clearTimeout(accountsReloadTimer);
1413
+ accountsReloadTimer = undefined;
1414
+ if (digest === consumeLastWrittenAccountsDigest(path))
1415
+ return;
1416
+ accountsReloadTimer = setTimeout(() => {
1417
+ accountsReloadTimer = undefined;
1418
+ void reloadForExternalAccountsChange(path, generation);
1419
+ }, 500);
1420
+ accountsReloadTimer.unref();
1421
+ };
1422
+ const onAccountsStatChanged = () => {
1423
+ void onAccountsFileChanged();
1424
+ };
1425
+ const ensureAccountsWatcher = async () => {
1426
+ if (accountsWatcherDisposed)
1427
+ return;
1428
+ const path = getStoragePath();
1429
+ if (path === watchedAccountsPath)
1430
+ return;
1431
+ stopAccountsWatcher();
1432
+ if (isKeychainOptInEnabled())
1433
+ return;
1434
+ unsubscribeAccountsPath ??= subscribeToStoragePathChanges(() => {
1435
+ void ensureAccountsWatcher();
1436
+ });
1437
+ watchedAccountsPath = path;
1438
+ const generation = accountsWatchGeneration;
1439
+ const initial = await readAccountsFileState(path);
1440
+ if (generation !== accountsWatchGeneration)
1441
+ return;
1442
+ observedAccountsDigest = initial?.digest;
1443
+ // Stat polling follows the path across the storage writer's temp-file rename.
1444
+ watchFile(path, { interval: 1500, persistent: false }, onAccountsStatChanged);
1445
+ unregisterCleanup(disposeAccountsWatcher);
1446
+ registerCleanup(disposeAccountsWatcher);
1447
+ };
1163
1448
  const persistAuthenticatedSelections = async (results, replaceAll) => {
1164
1449
  try {
1165
1450
  await persistAccountPool(results, replaceAll);
@@ -1192,6 +1477,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1192
1477
  const { event } = input;
1193
1478
  if (event.type === "server.instance.disposed") {
1194
1479
  quotaMonitor.dispose();
1480
+ disposeAccountsWatcher();
1195
1481
  return;
1196
1482
  }
1197
1483
  // Handle TUI account selection events
@@ -1346,7 +1632,9 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1346
1632
  });
1347
1633
  }
1348
1634
  applyUiRuntimeFromConfig(pluginConfig);
1349
- const perProjectAccounts = getPerProjectAccounts(pluginConfig);
1635
+ let perProjectAccounts = getPerProjectAccounts(pluginConfig);
1636
+ let storageTransition;
1637
+ const activeFetches = new Set();
1350
1638
  setStoragePath(perProjectAccounts ? process.cwd() : null);
1351
1639
  const authFallback = auth.type === "oauth" ? auth : undefined;
1352
1640
  // Prefer multi-account auth metadata when available, but still handle
@@ -1386,8 +1674,19 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1386
1674
  }
1387
1675
  });
1388
1676
  }
1389
- let accountManager = await accountManagerPromise;
1390
- cachedAccountManager = accountManager;
1677
+ const loadedManager = await accountManagerPromise;
1678
+ if (cachedAccountManager && cachedAccountManager !== loadedManager) {
1679
+ // An external reload replaced the cache while this load was
1680
+ // in flight. The loaded snapshot is stale; retiring it keeps
1681
+ // its debounced save from writing full membership over the
1682
+ // successor's state.
1683
+ loadedManager.disposeShutdownHandler();
1684
+ }
1685
+ else {
1686
+ cachedAccountManager = loadedManager;
1687
+ }
1688
+ const accountManager = cachedAccountManager ?? loadedManager;
1689
+ await ensureAccountsWatcher();
1391
1690
  const refreshToken = authFallback?.refresh ?? "";
1392
1691
  const needsPersist = refreshToken &&
1393
1692
  !accountManager.hasRefreshToken(refreshToken);
@@ -1412,86 +1711,60 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1412
1711
  global: providerConfig?.options || {},
1413
1712
  models: providerConfig?.models || {},
1414
1713
  };
1415
- // Load plugin configuration and determine CODEX_MODE
1416
- // Priority: CODEX_MODE env var > config file > default (true)
1417
- const codexMode = getCodexMode(pluginConfig);
1418
- const requestTransformMode = getRequestTransformMode(pluginConfig);
1419
- const useLegacyRequestTransform = requestTransformMode === "legacy";
1420
- const fastSessionEnabled = getFastSession(pluginConfig);
1421
- const fastSessionStrategy = getFastSessionStrategy(pluginConfig);
1422
- const fastSessionMaxInputItems = getFastSessionMaxInputItems(pluginConfig);
1423
- const beginnerSafeMode = getBeginnerSafeMode(pluginConfig);
1424
- beginnerSafeModeEnabled = beginnerSafeMode;
1425
- const maskEmailEnabled = getCodexTuiMaskEmail(pluginConfig);
1426
- const retryProfile = beginnerSafeMode
1427
- ? "conservative"
1428
- : getRetryProfile(pluginConfig);
1429
- const retryBudgetOverrides = beginnerSafeMode
1430
- ? {}
1431
- : getRetryBudgetOverrides(pluginConfig);
1432
- const retryBudgetLimits = resolveRetryBudgetLimits(retryProfile, retryBudgetOverrides);
1433
- runtimeMetrics.retryProfile = retryProfile;
1434
- runtimeMetrics.retryBudgetLimits = { ...retryBudgetLimits };
1435
- const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig);
1436
- const rateLimitToastDebounceMs = getRateLimitToastDebounceMs(pluginConfig);
1437
- const retryAllAccountsRateLimited = beginnerSafeMode
1438
- ? false
1439
- : getRetryAllAccountsRateLimited(pluginConfig);
1440
- const retryAllAccountsMaxWaitMs = getRetryAllAccountsMaxWaitMs(pluginConfig);
1441
- const retryAllAccountsMaxRetries = beginnerSafeMode
1442
- ? Math.min(1, getRetryAllAccountsMaxRetries(pluginConfig))
1443
- : getRetryAllAccountsMaxRetries(pluginConfig);
1444
- const unsupportedCodexPolicy = getUnsupportedCodexPolicy(pluginConfig);
1445
- const fallbackOnUnsupportedCodexModel = unsupportedCodexPolicy === "fallback";
1446
- const fallbackToGpt52OnUnsupportedGpt53 = getFallbackToGpt52OnUnsupportedGpt53(pluginConfig);
1447
- const unsupportedCodexFallbackChain = getUnsupportedCodexFallbackChain(pluginConfig);
1448
- const toastDurationMs = getToastDurationMs(pluginConfig);
1449
- const accountToastsEnabled = getAccountToastsEnabled(pluginConfig);
1450
- const fetchTimeoutMs = getFetchTimeoutMs(pluginConfig);
1451
- const streamStallTimeoutMs = getStreamStallTimeoutMs(pluginConfig);
1452
1714
  const sessionRecoveryEnabled = getSessionRecovery(pluginConfig);
1715
+ beginnerSafeModeEnabled = getBeginnerSafeMode(pluginConfig);
1716
+ const initialRetryProfile = beginnerSafeModeEnabled ? "conservative" : getRetryProfile(pluginConfig);
1717
+ runtimeMetrics.retryProfile = initialRetryProfile;
1718
+ runtimeMetrics.retryBudgetLimits = resolveRetryBudgetLimits(initialRetryProfile, beginnerSafeModeEnabled ? {} : getRetryBudgetOverrides(pluginConfig));
1453
1719
  const autoResumeEnabled = getAutoResume(pluginConfig);
1454
1720
  const autoUpdateEnabled = getAutoUpdate(pluginConfig);
1455
- const emptyResponseMaxRetries = getEmptyResponseMaxRetries(pluginConfig);
1456
- const emptyResponseRetryDelayMs = getEmptyResponseRetryDelayMs(pluginConfig);
1457
- const pidOffsetEnabled = getPidOffsetEnabled(pluginConfig);
1458
- const rotationStrategy = getRotationStrategy(pluginConfig);
1459
- const effectiveUserConfig = fastSessionEnabled
1460
- ? applyFastSessionDefaults(userConfig)
1461
- : userConfig;
1462
- if (fastSessionEnabled) {
1721
+ if (getFastSession(pluginConfig)) {
1463
1722
  logDebug("Fast session mode enabled", {
1464
1723
  reasoningEffort: "none/low",
1465
1724
  reasoningSummary: "auto",
1466
1725
  textVerbosity: "low",
1467
- fastSessionStrategy,
1468
- fastSessionMaxInputItems,
1726
+ fastSessionStrategy: getFastSessionStrategy(pluginConfig),
1727
+ fastSessionMaxInputItems: getFastSessionMaxInputItems(pluginConfig),
1469
1728
  });
1470
1729
  }
1471
- if (beginnerSafeMode) {
1730
+ if (getBeginnerSafeMode(pluginConfig)) {
1472
1731
  logInfo("Beginner safe mode enabled", {
1473
- retryProfile,
1474
- retryAllAccountsRateLimited,
1475
- retryAllAccountsMaxRetries,
1732
+ retryProfile: "conservative",
1733
+ retryAllAccountsRateLimited: false,
1734
+ retryAllAccountsMaxRetries: Math.min(1, getRetryAllAccountsMaxRetries(pluginConfig)),
1476
1735
  });
1477
1736
  }
1478
- const prewarmEnabled = process.env.CODEX_AUTH_PREWARM !== "0" &&
1479
- process.env.VITEST !== "true" &&
1480
- process.env.NODE_ENV !== "test";
1481
- if (!startupPrewarmTriggered && prewarmEnabled && useLegacyRequestTransform) {
1737
+ const underTestRunner = process.env.VITEST === "true" || process.env.NODE_ENV === "test";
1738
+ const prewarmEnabled = process.env.CODEX_AUTH_PREWARM !== "0" && !underTestRunner;
1739
+ if (!startupPrewarmTriggered && prewarmEnabled && getRequestTransformMode(pluginConfig) === "legacy") {
1482
1740
  startupPrewarmTriggered = true;
1483
1741
  const configuredModels = Object.keys(userConfig.models ?? {});
1484
1742
  prewarmCodexInstructions(configuredModels);
1485
- if (codexMode) {
1743
+ if (getCodexMode(pluginConfig)) {
1486
1744
  prewarmOpenCodeCodexPrompt();
1487
1745
  }
1488
1746
  }
1489
1747
  const recoveryHook = sessionRecoveryEnabled
1490
1748
  ? createSessionRecoveryHook({ client, directory: process.cwd() }, { sessionRecovery: true, autoResume: autoResumeEnabled })
1491
1749
  : null;
1750
+ const pluginOrigin = getPluginOrigin();
1751
+ if (pluginOrigin && !startupOriginRecorded) {
1752
+ startupOriginRecorded = true;
1753
+ if (pluginOrigin.isLocalCheckout) {
1754
+ logInfo(`Running from ${describePluginOrigin(pluginOrigin)}`);
1755
+ }
1756
+ if (!underTestRunner) {
1757
+ recordPluginOrigin(pluginOrigin).catch((err) => {
1758
+ logDebug(`Failed to record plugin origin: ${err instanceof Error ? err.message : String(err)}`);
1759
+ });
1760
+ }
1761
+ }
1492
1762
  checkAndNotify(async (message, variant) => {
1493
1763
  await showToast(message, variant);
1494
- }, { autoUpdate: autoUpdateEnabled }).catch((err) => {
1764
+ }, {
1765
+ autoUpdate: autoUpdateEnabled,
1766
+ localCheckout: pluginOrigin?.isLocalCheckout ?? false,
1767
+ }).catch((err) => {
1495
1768
  logDebug(`Update check failed: ${err instanceof Error ? err.message : String(err)}`);
1496
1769
  });
1497
1770
  await runStartupPreflight();
@@ -1515,10 +1788,79 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1515
1788
  * @returns Response from Codex API
1516
1789
  */
1517
1790
  async fetch(input, init) {
1791
+ let finishFetch;
1792
+ let pendingFetch;
1518
1793
  try {
1519
- if (cachedAccountManager && cachedAccountManager !== accountManager) {
1520
- accountManager = cachedAccountManager;
1794
+ const pluginConfig = loadPluginConfig();
1795
+ const currentPerProjectAccounts = getPerProjectAccounts(pluginConfig);
1796
+ while (storageTransition)
1797
+ await storageTransition;
1798
+ if (currentPerProjectAccounts !== perProjectAccounts) {
1799
+ storageTransition = (async () => {
1800
+ await Promise.all(activeFetches);
1801
+ // Drain the old pool's pending write before changing the global path.
1802
+ await cachedAccountManager?.flushPendingSave();
1803
+ setStoragePath(currentPerProjectAccounts ? process.cwd() : null);
1804
+ invalidateAccountManagerCache();
1805
+ perProjectAccounts = currentPerProjectAccounts;
1806
+ })().finally(() => { storageTransition = undefined; });
1807
+ await storageTransition;
1808
+ }
1809
+ pendingFetch = new Promise((resolve) => { finishFetch = resolve; });
1810
+ activeFetches.add(pendingFetch);
1811
+ if (!accountManagerPromise) {
1812
+ const pending = AccountManager.loadFromDisk();
1813
+ accountManagerPromise = pending;
1814
+ void pending.catch(() => {
1815
+ if (accountManagerPromise === pending)
1816
+ accountManagerPromise = null;
1817
+ });
1521
1818
  }
1819
+ if (!cachedAccountManager) {
1820
+ const loadedManager = await accountManagerPromise;
1821
+ if (cachedAccountManager && cachedAccountManager !== loadedManager) {
1822
+ // External reload won the race while this load was in
1823
+ // flight: adopt the cache and retire the stale load.
1824
+ loadedManager.disposeShutdownHandler();
1825
+ }
1826
+ else if (!cachedAccountManager) {
1827
+ cachedAccountManager = loadedManager;
1828
+ }
1829
+ }
1830
+ const codexMode = getCodexMode(pluginConfig);
1831
+ const requestTransformMode = getRequestTransformMode(pluginConfig);
1832
+ const fastSessionEnabled = getFastSession(pluginConfig);
1833
+ const fastSessionStrategy = getFastSessionStrategy(pluginConfig);
1834
+ const fastSessionMaxInputItems = getFastSessionMaxInputItems(pluginConfig);
1835
+ const beginnerSafeMode = getBeginnerSafeMode(pluginConfig);
1836
+ beginnerSafeModeEnabled = beginnerSafeMode;
1837
+ const maskEmailEnabled = getCodexTuiMaskEmail(pluginConfig);
1838
+ const retryProfile = beginnerSafeMode ? "conservative" : getRetryProfile(pluginConfig);
1839
+ const retryBudgetOverrides = beginnerSafeMode ? {} : getRetryBudgetOverrides(pluginConfig);
1840
+ const retryBudgetLimits = resolveRetryBudgetLimits(retryProfile, retryBudgetOverrides);
1841
+ runtimeMetrics.retryProfile = retryProfile;
1842
+ runtimeMetrics.retryBudgetLimits = { ...retryBudgetLimits };
1843
+ const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig);
1844
+ const rateLimitToastDebounceMs = getRateLimitToastDebounceMs(pluginConfig);
1845
+ const retryAllAccountsRateLimited = beginnerSafeMode ? false : getRetryAllAccountsRateLimited(pluginConfig);
1846
+ const retryAllAccountsMaxWaitMs = getRetryAllAccountsMaxWaitMs(pluginConfig);
1847
+ const retryAllAccountsMaxRetries = beginnerSafeMode
1848
+ ? Math.min(1, getRetryAllAccountsMaxRetries(pluginConfig))
1849
+ : getRetryAllAccountsMaxRetries(pluginConfig);
1850
+ const unsupportedCodexPolicy = getUnsupportedCodexPolicy(pluginConfig);
1851
+ const fallbackOnUnsupportedCodexModel = unsupportedCodexPolicy === "fallback";
1852
+ const fallbackToGpt52OnUnsupportedGpt53 = getFallbackToGpt52OnUnsupportedGpt53(pluginConfig);
1853
+ const unsupportedCodexFallbackChain = getUnsupportedCodexFallbackChain(pluginConfig);
1854
+ const toastDurationMs = getToastDurationMs(pluginConfig);
1855
+ const accountToastsEnabled = getAccountToastsEnabled(pluginConfig);
1856
+ const fetchTimeoutMs = getFetchTimeoutMs(pluginConfig);
1857
+ const streamStallTimeoutMs = getStreamStallTimeoutMs(pluginConfig);
1858
+ const emptyResponseMaxRetries = getEmptyResponseMaxRetries(pluginConfig);
1859
+ const emptyResponseRetryDelayMs = getEmptyResponseRetryDelayMs(pluginConfig);
1860
+ const pidOffsetEnabled = getPidOffsetEnabled(pluginConfig);
1861
+ const rotationStrategy = getRotationStrategy(pluginConfig);
1862
+ const effectiveUserConfig = fastSessionEnabled ? applyFastSessionDefaults(userConfig) : userConfig;
1863
+ let accountManager = cachedAccountManager;
1522
1864
  // Step 1: Extract and rewrite URL for Codex backend
1523
1865
  const originalUrl = extractRequestUrl(input);
1524
1866
  const url = openAIBaseURL ? originalUrl : rewriteUrlForCodex(originalUrl);
@@ -1609,9 +1951,18 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1609
1951
  runtimeMetrics.promptCacheMissingRequests++;
1610
1952
  }
1611
1953
  const retryBudget = new RetryBudgetTracker(retryBudgetLimits);
1612
- const consumeRetryBudget = (bucket, reason) => {
1613
- if (retryBudget.consume(bucket)) {
1614
- runtimeMetrics.retryBudgetUsage[bucket] += 1;
1954
+ const consumeRetryBudget = (bucket, reason, waitMs) => {
1955
+ // Pass the wait so the charge scales with how long the retry
1956
+ // blocks. Metrics follow the tracker's own counter rather than
1957
+ // assuming one unit, or a free sub-second wait would report
1958
+ // budget it never spent.
1959
+ const usedBefore = retryBudget.getUsage()[bucket];
1960
+ const granted = waitMs === undefined
1961
+ ? retryBudget.consume(bucket)
1962
+ : retryBudget.consumeWait(bucket, waitMs);
1963
+ if (granted) {
1964
+ runtimeMetrics.retryBudgetUsage[bucket] +=
1965
+ retryBudget.getUsage()[bucket] - usedBefore;
1615
1966
  return true;
1616
1967
  }
1617
1968
  runtimeMetrics.retryBudgetExhaustions += 1;
@@ -1652,25 +2003,69 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1652
2003
  };
1653
2004
  abortSignal?.addEventListener("abort", onAbort, { once: true });
1654
2005
  });
1655
- const sleepWithCountdown = async (totalMs, message, intervalMs = 5000) => {
2006
+ const sleepWithCountdown = async (totalMs, message, intervalMs = 5000, probeUpstream) => {
1656
2007
  const startTime = Date.now();
1657
2008
  const endTime = startTime + totalMs;
2009
+ let probeDelayMs = UPSTREAM_REPROBE_FIRST_DELAY_MS;
2010
+ let nextProbeAt = probeUpstream && totalMs >= UPSTREAM_REPROBE_MIN_WAIT_MS
2011
+ ? startTime + probeDelayMs
2012
+ : Number.POSITIVE_INFINITY;
1658
2013
  while (Date.now() < endTime) {
2014
+ if (cachedAccountManager !== accountManager)
2015
+ return;
1659
2016
  if (abortSignal?.aborted) {
1660
2017
  throw abortError();
1661
2018
  }
2019
+ if (probeUpstream && Date.now() >= nextProbeAt) {
2020
+ if (await probeUpstream())
2021
+ return;
2022
+ if (cachedAccountManager !== accountManager)
2023
+ return;
2024
+ if (abortSignal?.aborted) {
2025
+ throw abortError();
2026
+ }
2027
+ probeDelayMs = Math.min(probeDelayMs * 2, UPSTREAM_REPROBE_MAX_DELAY_MS);
2028
+ // Measured from the end of the probe, so a slow usage
2029
+ // request cannot schedule the next one in the past and
2030
+ // collapse the countdown sleep below to zero.
2031
+ nextProbeAt = Date.now() + probeDelayMs;
2032
+ }
1662
2033
  const remaining = Math.max(0, endTime - Date.now());
1663
2034
  const waitLabel = formatWaitTime(remaining);
1664
2035
  await showToast(`${message} (${waitLabel} remaining)`, "warning", { duration: Math.min(intervalMs + 1000, toastDurationMs) });
1665
- const sleepTime = Math.min(intervalMs, remaining);
2036
+ const sleepTime = Math.min(intervalMs, remaining, nextProbeAt - Date.now());
1666
2037
  if (sleepTime > 0) {
1667
2038
  await sleep(sleepTime);
1668
2039
  }
1669
2040
  else {
1670
- break;
2041
+ continue;
1671
2042
  }
1672
2043
  }
1673
2044
  };
2045
+ /**
2046
+ * True when an all-accounts wait can stop early.
2047
+ *
2048
+ * `runNow` refreshes `/wham/usage` for every account and persists
2049
+ * whatever it finds, so a reset that never touched local disk
2050
+ * becomes visible here. Persisting a recovery also drops the cached
2051
+ * manager, which is what makes the enclosing retry loop re-resolve
2052
+ * one that no longer reports a block.
2053
+ */
2054
+ const probeUpstreamBlockLifted = async () => {
2055
+ try {
2056
+ await quotaMonitor.runNow();
2057
+ }
2058
+ catch (error) {
2059
+ logDebug(`[${PLUGIN_NAME}] Upstream quota re-probe failed: ${error instanceof Error ? error.message : String(error)}`);
2060
+ return false;
2061
+ }
2062
+ if (cachedAccountManager !== accountManager)
2063
+ return true;
2064
+ const manager = accountManager;
2065
+ if (!manager)
2066
+ return false;
2067
+ return manager.getMinWaitTimeForFamily(modelFamily, model) === 0;
2068
+ };
1674
2069
  let allRateLimitedRetries = 0;
1675
2070
  let emptyResponseRetries = 0;
1676
2071
  const attemptedUnsupportedFallbackModels = new Set();
@@ -1758,6 +2153,58 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1758
2153
  const MAX_QUOTA_FALLBACK_SWITCHES = 3;
1759
2154
  let quotaFallbackSwitches = 0;
1760
2155
  while (true) {
2156
+ if (cachedAccountManager && cachedAccountManager !== accountManager) {
2157
+ accountManager = cachedAccountManager;
2158
+ }
2159
+ else if (!cachedAccountManager) {
2160
+ // Reload through the shared accountManagerPromise so
2161
+ // concurrent requests (and the startup path above)
2162
+ // share one load. Racing independent loadFromDisk()
2163
+ // calls here can orphan the slower manager while an
2164
+ // in-flight request still holds it, and an orphaned
2165
+ // manager is never retired, so its next save would
2166
+ // write full membership and drop externally imported
2167
+ // accounts.
2168
+ if (accountManagerPromise === null) {
2169
+ const pending = AccountManager.loadFromDisk();
2170
+ accountManagerPromise = pending;
2171
+ void pending.catch(() => {
2172
+ if (accountManagerPromise === pending)
2173
+ accountManagerPromise = null;
2174
+ });
2175
+ }
2176
+ const loading = accountManagerPromise;
2177
+ const reloaded = await loading;
2178
+ if (cachedAccountManager) {
2179
+ if (cachedAccountManager !== reloaded) {
2180
+ // The cache was repopulated while this load was in
2181
+ // flight (external reload after an invalidation).
2182
+ // The loaded manager is stale; retire it so its
2183
+ // debounced save cannot write full membership over
2184
+ // accounts imported after its snapshot was taken.
2185
+ reloaded.disposeShutdownHandler();
2186
+ }
2187
+ accountManager = cachedAccountManager;
2188
+ }
2189
+ else if (accountManager && isUntrustworthyEmptyReload(accountManager, reloaded)) {
2190
+ // The incumbent this request holds still has accounts, so
2191
+ // the empty result is a failed `loadAccounts()` read rather
2192
+ // than a real removal (same refusal as
2193
+ // reloadCachedAccountManager). Keep serving the incumbent
2194
+ // and drop the resolved promise so the next pass re-reads
2195
+ // disk instead of adopting this same empty manager.
2196
+ reloaded.disposeShutdownHandler();
2197
+ if (accountManagerPromise === loading)
2198
+ accountManagerPromise = null;
2199
+ logWarn(`[${PLUGIN_NAME}] Account reload returned no accounts while ${accountManager.getAccountCount()} are held; keeping the loaded pool and retrying`);
2200
+ const incumbent = accountManager;
2201
+ scheduleEmptyReloadRetry(() => repopulateAccountManagerCache(incumbent));
2202
+ }
2203
+ else {
2204
+ cachedAccountManager = reloaded;
2205
+ accountManager = reloaded;
2206
+ }
2207
+ }
1761
2208
  let accountCount = accountManager.getAccountCount();
1762
2209
  const attempted = new Set();
1763
2210
  // Diagnostics for the terminal error message below. The composite key keeps
@@ -1874,6 +2321,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1874
2321
  const failures = await accountManager.incrementAuthFailures(account);
1875
2322
  const accountLabel = formatAccountLabel(account, account.index, {
1876
2323
  maskEmail: maskEmailEnabled,
2324
+ peerAccounts: accountManager.getAccountsSnapshot(),
1877
2325
  });
1878
2326
  if (failures >= ACCOUNT_LIMITS.MAX_AUTH_FAILURES_BEFORE_REMOVAL) {
1879
2327
  const removedCount = accountManager.removeAccountsWithSameRefreshToken(account);
@@ -1923,6 +2371,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1923
2371
  accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
1924
2372
  const accountLabel = formatAccountLabel(account, account.index, {
1925
2373
  maskEmail: maskEmailEnabled,
2374
+ peerAccounts: accountManager.getAccountsSnapshot(),
1926
2375
  });
1927
2376
  await showToast(`Using ${accountLabel} (${account.index + 1}/${accountCount})`, "info");
1928
2377
  accountManager.markToastShown(account.index);
@@ -2110,7 +2559,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2110
2559
  // only the block would leave the status line reporting "0% left" for
2111
2560
  // an account the router considers healthy.
2112
2561
  const recordQuotaHeaders = () => {
2113
- void recordPromptQuotaHeaders(response, account, accountCount);
2562
+ void recordPromptQuotaHeaders(response, account, accountCount, accountManager.getAccountsSnapshot());
2114
2563
  return applyQuotaExhaustion(accountManager, response.headers, account, modelFamily, model);
2115
2564
  };
2116
2565
  if (response.ok) {
@@ -2130,6 +2579,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2130
2579
  if (workspaceDeactivated) {
2131
2580
  const accountLabel = formatAccountLabel(account, account.index, {
2132
2581
  maskEmail: maskEmailEnabled,
2582
+ peerAccounts: accountManager.getAccountsSnapshot(),
2133
2583
  });
2134
2584
  accountManager.refundToken(account, modelFamily, model);
2135
2585
  accountManager.recordFailure(account, modelFamily, model);
@@ -2343,6 +2793,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2343
2793
  if (isInvalidatedAuthTokenError(errorBody, response.status)) {
2344
2794
  const accountLabel = formatAccountLabel(account, account.index, {
2345
2795
  maskEmail: maskEmailEnabled,
2796
+ peerAccounts: accountManager.getAccountsSnapshot(),
2346
2797
  });
2347
2798
  accountManager.refundToken(account, modelFamily, model);
2348
2799
  accountManager.recordFailure(account, modelFamily, model);
@@ -2614,9 +3065,9 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2614
3065
  (retryAllAccountsMaxWaitMs === 0 ||
2615
3066
  waitMs <= retryAllAccountsMaxWaitMs) &&
2616
3067
  allRateLimitedRetries < retryAllAccountsMaxRetries &&
2617
- consumeRetryBudget("rateLimitGlobal", `All accounts rate-limited wait ${waitMs}ms`)) {
3068
+ consumeRetryBudget("rateLimitGlobal", `All accounts rate-limited wait ${waitMs}ms`, waitMs)) {
2618
3069
  const countdownMessage = `All ${count} account(s) rate-limited. Waiting`;
2619
- await sleepWithCountdown(addJitter(waitMs, 0.2), countdownMessage);
3070
+ await sleepWithCountdown(addJitter(waitMs, 0.2), countdownMessage, undefined, probeUpstreamBlockLifted);
2620
3071
  allRateLimitedRetries++;
2621
3072
  continue;
2622
3073
  }
@@ -2657,6 +3108,9 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2657
3108
  }
2658
3109
  }
2659
3110
  finally {
3111
+ if (pendingFetch)
3112
+ activeFetches.delete(pendingFetch);
3113
+ finishFetch?.();
2660
3114
  clearCorrelationId();
2661
3115
  }
2662
3116
  },
@@ -2716,19 +3170,20 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2716
3170
  message.includes("invalid refresh") ||
2717
3171
  message.includes("token has been revoked"));
2718
3172
  };
3173
+ const authQuotaDisplay = getQuotaDisplay(authPluginConfig);
2719
3174
  const formatCodexQuotaLine = (usage) => {
2720
3175
  const parts = [];
2721
3176
  for (const window of [usage.primary, usage.secondary]) {
2722
3177
  if (!hasUsageWindow(window))
2723
3178
  continue;
2724
- parts.push(`${formatUsageLimitTitle(window.windowMinutes)} ${formatUsageLimitSummary(window)}`);
3179
+ parts.push(`${formatUsageLimitTitle(window.windowMinutes)} ${formatUsageLimitSummary(window, authQuotaDisplay)}`);
2725
3180
  }
2726
3181
  if (hasUsageWindow(usage.codeReview)) {
2727
- parts.push(`Code review ${formatUsageLimitSummary(usage.codeReview)}`);
3182
+ parts.push(`Code review ${formatUsageLimitSummary(usage.codeReview, authQuotaDisplay)}`);
2728
3183
  }
2729
3184
  for (const limit of usage.additionalLimits) {
2730
3185
  if (hasUsageWindow(limit.window)) {
2731
- parts.push(`${limit.name} ${formatUsageLimitSummary(limit.window)}`);
3186
+ parts.push(`${limit.name} ${formatUsageLimitSummary(limit.window, authQuotaDisplay)}`);
2732
3187
  }
2733
3188
  }
2734
3189
  const planLabel = formatPlanType(usage.planType);
@@ -2900,8 +3355,17 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2900
3355
  }
2901
3356
  if (deepProbe) {
2902
3357
  ok += 1;
2903
- const detail = tokenAccountId
2904
- ? `${authDetail} (id:${tokenAccountId.slice(-6)})`
3358
+ // Both read from the probed token, so the pair is the seat
3359
+ // the credential actually belongs to. The workspace id
3360
+ // alone repeats across every member of a Business
3361
+ // workspace and cannot confirm which seat answered.
3362
+ const tokenSeat = formatSeatSuffix(extractAccountUserId(accessToken), workingStorage.accounts.map((peer) => peer?.accountUserId));
3363
+ const identity = [
3364
+ tokenAccountId ? `id:${tokenAccountId.slice(-6)}` : undefined,
3365
+ tokenSeat ? `seat:${tokenSeat}` : undefined,
3366
+ ].filter((part) => part !== undefined);
3367
+ const detail = identity.length > 0
3368
+ ? `${authDetail} (${identity.join(", ")})`
2905
3369
  : authDetail;
2906
3370
  console.log(`[${i + 1}/${total}] ${label}: ${detail}`);
2907
3371
  continue;
@@ -2919,7 +3383,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2919
3383
  organizationId: account.organizationId,
2920
3384
  normalizeAccountErrors: true,
2921
3385
  });
2922
- const usage = parseCodexUsagePayload(payload);
3386
+ const usage = parseCodexUsagePayload(payload, authQuotaDisplay);
2923
3387
  ok += 1;
2924
3388
  console.log(`[${i + 1}/${total}] ${label}: ${formatCodexQuotaLine(usage)}`);
2925
3389
  }
@@ -3195,6 +3659,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
3195
3659
  }
3196
3660
  return {
3197
3661
  accountId: account.accountId,
3662
+ accountUserId: account.accountUserId,
3198
3663
  accountLabel: account.accountLabel,
3199
3664
  email: account.email,
3200
3665
  index,