oc-chatgpt-multi-auth 5.3.2 → 5.3.4

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 (40) hide show
  1. package/README.md +198 -85
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1623 -50
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/accounts.d.ts +16 -0
  6. package/dist/lib/accounts.d.ts.map +1 -1
  7. package/dist/lib/accounts.js +60 -0
  8. package/dist/lib/accounts.js.map +1 -1
  9. package/dist/lib/config.d.ts +4 -0
  10. package/dist/lib/config.d.ts.map +1 -1
  11. package/dist/lib/config.js +36 -0
  12. package/dist/lib/config.js.map +1 -1
  13. package/dist/lib/refresh-queue.d.ts +16 -0
  14. package/dist/lib/refresh-queue.d.ts.map +1 -1
  15. package/dist/lib/refresh-queue.js +46 -0
  16. package/dist/lib/refresh-queue.js.map +1 -1
  17. package/dist/lib/request/retry-budget.d.ts +19 -0
  18. package/dist/lib/request/retry-budget.d.ts.map +1 -0
  19. package/dist/lib/request/retry-budget.js +99 -0
  20. package/dist/lib/request/retry-budget.js.map +1 -0
  21. package/dist/lib/schemas.d.ts +26 -0
  22. package/dist/lib/schemas.d.ts.map +1 -1
  23. package/dist/lib/schemas.js +28 -0
  24. package/dist/lib/schemas.js.map +1 -1
  25. package/dist/lib/storage/migrations.d.ts +4 -0
  26. package/dist/lib/storage/migrations.d.ts.map +1 -1
  27. package/dist/lib/storage/migrations.js +2 -0
  28. package/dist/lib/storage/migrations.js.map +1 -1
  29. package/dist/lib/storage.d.ts +31 -5
  30. package/dist/lib/storage.d.ts.map +1 -1
  31. package/dist/lib/storage.js +354 -71
  32. package/dist/lib/storage.js.map +1 -1
  33. package/dist/lib/ui/auth-menu.d.ts.map +1 -1
  34. package/dist/lib/ui/auth-menu.js +23 -5
  35. package/dist/lib/ui/auth-menu.js.map +1 -1
  36. package/dist/lib/ui/beginner.d.ts +57 -0
  37. package/dist/lib/ui/beginner.d.ts.map +1 -0
  38. package/dist/lib/ui/beginner.js +230 -0
  39. package/dist/lib/ui/beginner.js.map +1 -0
  40. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -24,25 +24,27 @@
24
24
  */
25
25
  import { tool } from "@opencode-ai/plugin/tool";
26
26
  import { createAuthorizationFlow, exchangeAuthorizationCode, parseAuthorizationInput, REDIRECT_URI, } from "./lib/auth/auth.js";
27
- import { queuedRefresh } from "./lib/refresh-queue.js";
27
+ import { queuedRefresh, getRefreshQueueMetrics } from "./lib/refresh-queue.js";
28
28
  import { openBrowserUrl } from "./lib/auth/browser.js";
29
29
  import { startLocalOAuthServer } from "./lib/auth/server.js";
30
30
  import { promptAddAnotherAccount, promptLoginMode } from "./lib/cli.js";
31
- import { getCodexMode, getRequestTransformMode, getFastSession, getFastSessionStrategy, getFastSessionMaxInputItems, getRateLimitToastDebounceMs, getRetryAllAccountsMaxRetries, getRetryAllAccountsMaxWaitMs, getRetryAllAccountsRateLimited, getFallbackToGpt52OnUnsupportedGpt53, getUnsupportedCodexPolicy, getUnsupportedCodexFallbackChain, getTokenRefreshSkewMs, getSessionRecovery, getAutoResume, getToastDurationMs, getPerProjectAccounts, getEmptyResponseMaxRetries, getEmptyResponseRetryDelayMs, getPidOffsetEnabled, getFetchTimeoutMs, getStreamStallTimeoutMs, getCodexTuiV2, getCodexTuiColorProfile, getCodexTuiGlyphMode, loadPluginConfig, } from "./lib/config.js";
31
+ import { getCodexMode, getRequestTransformMode, getFastSession, getFastSessionStrategy, getFastSessionMaxInputItems, getRetryProfile, getRetryBudgetOverrides, getRateLimitToastDebounceMs, getRetryAllAccountsMaxRetries, getRetryAllAccountsMaxWaitMs, getRetryAllAccountsRateLimited, getFallbackToGpt52OnUnsupportedGpt53, getUnsupportedCodexPolicy, getUnsupportedCodexFallbackChain, getTokenRefreshSkewMs, getSessionRecovery, getAutoResume, getToastDurationMs, getPerProjectAccounts, getEmptyResponseMaxRetries, getEmptyResponseRetryDelayMs, getPidOffsetEnabled, getFetchTimeoutMs, getStreamStallTimeoutMs, getCodexTuiV2, getCodexTuiColorProfile, getCodexTuiGlyphMode, getBeginnerSafeMode, loadPluginConfig, } from "./lib/config.js";
32
32
  import { AUTH_LABELS, CODEX_BASE_URL, DUMMY_API_KEY, LOG_STAGES, PLUGIN_NAME, PROVIDER_ID, ACCOUNT_LIMITS, } from "./lib/constants.js";
33
33
  import { initLogger, logRequest, logDebug, logInfo, logWarn, logError, setCorrelationId, clearCorrelationId, } from "./lib/logger.js";
34
34
  import { checkAndNotify } from "./lib/auto-update-checker.js";
35
35
  import { handleContextOverflow } from "./lib/context-overflow.js";
36
36
  import { AccountManager, getAccountIdCandidates, extractAccountEmail, extractAccountId, formatAccountLabel, formatCooldown, formatWaitTime, sanitizeEmail, selectBestAccountCandidate, shouldUpdateAccountIdFromToken, resolveRequestAccountId, parseRateLimitReason, lookupCodexCliTokensByEmail, } from "./lib/accounts.js";
37
- import { getStoragePath, loadAccounts, saveAccounts, withAccountStorageTransaction, clearAccounts, setStoragePath, exportAccounts, importAccounts, loadFlaggedAccounts, saveFlaggedAccounts, clearFlaggedAccounts, StorageError, formatStorageErrorHint, } from "./lib/storage.js";
37
+ import { getStoragePath, loadAccounts, saveAccounts, withAccountStorageTransaction, clearAccounts, setStoragePath, exportAccounts, importAccounts, previewImportAccounts, createTimestampedBackupPath, loadFlaggedAccounts, saveFlaggedAccounts, clearFlaggedAccounts, StorageError, formatStorageErrorHint, } from "./lib/storage.js";
38
38
  import { createCodexHeaders, extractRequestUrl, handleErrorResponse, handleSuccessResponse, getUnsupportedCodexModelInfo, resolveUnsupportedCodexFallbackModel, refreshAndUpdateToken, rewriteUrlForCodex, shouldRefreshToken, transformRequestForCodex, } from "./lib/request/fetch-helpers.js";
39
39
  import { applyFastSessionDefaults } from "./lib/request/request-transformer.js";
40
40
  import { getRateLimitBackoff, RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS, resetRateLimitBackoff, } from "./lib/request/rate-limit-backoff.js";
41
41
  import { isEmptyResponse } from "./lib/request/response-handler.js";
42
+ import { RetryBudgetTracker, resolveRetryBudgetLimits, } from "./lib/request/retry-budget.js";
42
43
  import { addJitter } from "./lib/rotation.js";
43
44
  import { buildTableHeader, buildTableRow } from "./lib/table-formatter.js";
44
45
  import { setUiRuntimeOptions } from "./lib/ui/runtime.js";
45
46
  import { paintUiText, formatUiBadge, formatUiHeader, formatUiItem, formatUiKeyValue, formatUiSection } from "./lib/ui/format.js";
47
+ import { buildBeginnerChecklist, buildBeginnerDoctorFindings, recommendBeginnerNextAction, summarizeBeginnerAccounts, } from "./lib/ui/beginner.js";
46
48
  import { getModelFamily, getCodexInstructions, MODEL_FAMILIES, prewarmCodexInstructions, } from "./lib/prompts/codex.js";
47
49
  import { prewarmOpenCodeCodexPrompt } from "./lib/prompts/opencode-codex.js";
48
50
  import { createSessionRecoveryHook, isRecoverableError, detectErrorType, getRecoveryToastContent, } from "./lib/recovery.js";
@@ -69,7 +71,17 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
69
71
  let accountManagerPromise = null;
70
72
  let loaderMutex = null;
71
73
  let startupPrewarmTriggered = false;
74
+ let startupPreflightShown = false;
75
+ let beginnerSafeModeEnabled = false;
72
76
  const MIN_BACKOFF_MS = 100;
77
+ const createRetryBudgetUsage = () => ({
78
+ authRefresh: 0,
79
+ network: 0,
80
+ server: 0,
81
+ rateLimitShort: 0,
82
+ rateLimitGlobal: 0,
83
+ emptyResponse: 0,
84
+ });
73
85
  const runtimeMetrics = {
74
86
  startedAt: Date.now(),
75
87
  totalRequests: 0,
@@ -82,8 +94,18 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
82
94
  emptyResponseRetries: 0,
83
95
  accountRotations: 0,
84
96
  cumulativeLatencyMs: 0,
97
+ retryBudgetExhaustions: 0,
98
+ retryBudgetUsage: createRetryBudgetUsage(),
99
+ retryBudgetLimits: resolveRetryBudgetLimits("balanced"),
100
+ retryProfile: "balanced",
101
+ lastRetryBudgetExhaustedClass: null,
102
+ lastRetryBudgetReason: null,
85
103
  lastRequestAt: null,
86
104
  lastError: null,
105
+ lastErrorCategory: null,
106
+ lastSelectedAccountIndex: null,
107
+ lastQuotaKey: null,
108
+ lastSelectionSnapshot: null,
87
109
  };
88
110
  const createSelectionVariant = (tokens, candidate) => ({
89
111
  ...tokens,
@@ -226,7 +248,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
226
248
  await withAccountStorageTransaction(async (loadedStorage, persist) => {
227
249
  const now = Date.now();
228
250
  const stored = replaceAll ? null : loadedStorage;
229
- const accounts = stored?.accounts ? [...stored.accounts] : [];
251
+ let accounts = stored?.accounts ? [...stored.accounts] : [];
230
252
  const pushIndex = (map, key, index) => {
231
253
  const existing = map.get(key);
232
254
  if (existing) {
@@ -241,6 +263,93 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
241
263
  const [onlyIndex] = indices;
242
264
  return typeof onlyIndex === "number" ? onlyIndex : undefined;
243
265
  };
266
+ const pickNewestAccountIndex = (existingIndex, candidateIndex) => {
267
+ const existing = accounts[existingIndex];
268
+ const candidate = accounts[candidateIndex];
269
+ if (!existing)
270
+ return candidateIndex;
271
+ if (!candidate)
272
+ return existingIndex;
273
+ const existingLastUsed = existing.lastUsed ?? 0;
274
+ const candidateLastUsed = candidate.lastUsed ?? 0;
275
+ if (candidateLastUsed > existingLastUsed)
276
+ return candidateIndex;
277
+ if (candidateLastUsed < existingLastUsed)
278
+ return existingIndex;
279
+ const existingAddedAt = existing.addedAt ?? 0;
280
+ const candidateAddedAt = candidate.addedAt ?? 0;
281
+ return candidateAddedAt >= existingAddedAt ? candidateIndex : existingIndex;
282
+ };
283
+ const mergeAccountRecords = (targetIndex, sourceIndex) => {
284
+ const target = accounts[targetIndex];
285
+ const source = accounts[sourceIndex];
286
+ if (!target || !source)
287
+ return;
288
+ const targetLastUsed = target.lastUsed ?? 0;
289
+ const sourceLastUsed = source.lastUsed ?? 0;
290
+ const targetAddedAt = target.addedAt ?? 0;
291
+ const sourceAddedAt = source.addedAt ?? 0;
292
+ const sourceIsNewer = sourceLastUsed > targetLastUsed ||
293
+ (sourceLastUsed === targetLastUsed && sourceAddedAt > targetAddedAt);
294
+ const newer = sourceIsNewer ? source : target;
295
+ const older = sourceIsNewer ? target : source;
296
+ const mergedRateLimitResetTimes = {};
297
+ const rateLimitResetKeys = new Set([
298
+ ...Object.keys(older.rateLimitResetTimes ?? {}),
299
+ ...Object.keys(newer.rateLimitResetTimes ?? {}),
300
+ ]);
301
+ for (const key of rateLimitResetKeys) {
302
+ const olderRaw = older.rateLimitResetTimes?.[key];
303
+ const newerRaw = newer.rateLimitResetTimes?.[key];
304
+ const olderValue = typeof olderRaw === "number" && Number.isFinite(olderRaw) ? olderRaw : 0;
305
+ const newerValue = typeof newerRaw === "number" && Number.isFinite(newerRaw) ? newerRaw : 0;
306
+ const resolved = Math.max(olderValue, newerValue);
307
+ if (resolved > 0) {
308
+ mergedRateLimitResetTimes[key] = resolved;
309
+ }
310
+ }
311
+ const mergedEnabled = target.enabled === false || source.enabled === false
312
+ ? false
313
+ : target.enabled ?? source.enabled;
314
+ const targetCoolingDownUntil = typeof target.coolingDownUntil === "number" && Number.isFinite(target.coolingDownUntil)
315
+ ? target.coolingDownUntil
316
+ : 0;
317
+ const sourceCoolingDownUntil = typeof source.coolingDownUntil === "number" && Number.isFinite(source.coolingDownUntil)
318
+ ? source.coolingDownUntil
319
+ : 0;
320
+ const mergedCoolingDownUntilValue = Math.max(targetCoolingDownUntil, sourceCoolingDownUntil);
321
+ const mergedCoolingDownUntil = mergedCoolingDownUntilValue > 0 ? mergedCoolingDownUntilValue : undefined;
322
+ const mergedCooldownReason = (() => {
323
+ if (mergedCoolingDownUntilValue <= 0) {
324
+ return target.cooldownReason ?? source.cooldownReason;
325
+ }
326
+ if (sourceCoolingDownUntil > targetCoolingDownUntil) {
327
+ return source.cooldownReason ?? target.cooldownReason;
328
+ }
329
+ if (targetCoolingDownUntil > sourceCoolingDownUntil) {
330
+ return target.cooldownReason ?? source.cooldownReason;
331
+ }
332
+ return source.cooldownReason ?? target.cooldownReason;
333
+ })();
334
+ accounts[targetIndex] = {
335
+ ...target,
336
+ accountId: target.accountId ?? source.accountId,
337
+ organizationId: target.organizationId ?? source.organizationId,
338
+ accountIdSource: target.accountIdSource ?? source.accountIdSource,
339
+ accountLabel: target.accountLabel ?? source.accountLabel,
340
+ email: target.email ?? source.email,
341
+ refreshToken: newer.refreshToken || older.refreshToken,
342
+ accessToken: newer.accessToken || older.accessToken,
343
+ expiresAt: newer.expiresAt ?? older.expiresAt,
344
+ enabled: mergedEnabled,
345
+ addedAt: Math.max(target.addedAt ?? 0, source.addedAt ?? 0),
346
+ lastUsed: Math.max(target.lastUsed ?? 0, source.lastUsed ?? 0),
347
+ lastSwitchReason: target.lastSwitchReason ?? source.lastSwitchReason,
348
+ rateLimitResetTimes: mergedRateLimitResetTimes,
349
+ coolingDownUntil: mergedCoolingDownUntil,
350
+ cooldownReason: mergedCooldownReason,
351
+ };
352
+ };
244
353
  const resolveUniqueOrgScopedMatch = (indexes, accountId, refreshToken) => {
245
354
  const byAccountId = accountId
246
355
  ? asUniqueIndex(indexes.byAccountIdOrgScoped.get(accountId))
@@ -260,6 +369,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
260
369
  const byEmailNoOrg = new Map();
261
370
  const byAccountIdOrgScoped = new Map();
262
371
  const byRefreshTokenOrgScoped = new Map();
372
+ const byRefreshTokenGlobal = new Map();
263
373
  for (let i = 0; i < accounts.length; i += 1) {
264
374
  const account = accounts[i];
265
375
  if (!account)
@@ -268,6 +378,10 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
268
378
  const accountId = account.accountId?.trim();
269
379
  const refreshToken = account.refreshToken?.trim();
270
380
  const email = account.email?.trim();
381
+ // Global refresh token index: first entry wins (keeps earliest/primary).
382
+ if (refreshToken && !byRefreshTokenGlobal.has(refreshToken)) {
383
+ byRefreshTokenGlobal.set(refreshToken, i);
384
+ }
271
385
  if (organizationId) {
272
386
  byOrganizationId.set(organizationId, i);
273
387
  if (accountId) {
@@ -295,6 +409,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
295
409
  byEmailNoOrg,
296
410
  byAccountIdOrgScoped,
297
411
  byRefreshTokenOrgScoped,
412
+ byRefreshTokenGlobal,
298
413
  };
299
414
  };
300
415
  let identityIndexes = buildIdentityIndexes();
@@ -328,7 +443,12 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
328
443
  return byEmail;
329
444
  }
330
445
  }
331
- return resolveUniqueOrgScopedMatch(identityIndexes, normalizedAccountId, result.refresh);
446
+ const orgScoped = resolveUniqueOrgScopedMatch(identityIndexes, normalizedAccountId, result.refresh);
447
+ if (orgScoped !== undefined)
448
+ return orgScoped;
449
+ // Absolute last resort: same refresh token = same human account.
450
+ // Catches org-scoped entries invisible to no-org lookups.
451
+ return identityIndexes.byRefreshTokenGlobal.get(result.refresh);
332
452
  })();
333
453
  if (existingIndex === undefined) {
334
454
  accounts.push({
@@ -351,11 +471,20 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
351
471
  continue;
352
472
  const nextEmail = accountEmail ?? existing.email;
353
473
  const nextOrganizationId = organizationId ?? existing.organizationId;
354
- const nextAccountId = normalizedAccountId ?? existing.accountId;
355
- const nextAccountIdSource = normalizedAccountId
356
- ? accountIdSource ?? existing.accountIdSource
357
- : existing.accountIdSource;
358
- const nextAccountLabel = accountLabel ?? existing.accountLabel;
474
+ const preserveOrgIdentity = typeof existing.organizationId === "string" &&
475
+ existing.organizationId.trim().length > 0 &&
476
+ !organizationId;
477
+ const nextAccountId = preserveOrgIdentity
478
+ ? existing.accountId ?? normalizedAccountId
479
+ : normalizedAccountId ?? existing.accountId;
480
+ const nextAccountIdSource = preserveOrgIdentity
481
+ ? existing.accountIdSource ?? accountIdSource
482
+ : normalizedAccountId
483
+ ? accountIdSource ?? existing.accountIdSource
484
+ : existing.accountIdSource;
485
+ const nextAccountLabel = preserveOrgIdentity
486
+ ? existing.accountLabel ?? accountLabel
487
+ : accountLabel ?? existing.accountLabel;
359
488
  accounts[existingIndex] = {
360
489
  ...existing,
361
490
  accountId: nextAccountId,
@@ -370,22 +499,140 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
370
499
  };
371
500
  identityIndexes = buildIdentityIndexes();
372
501
  }
502
+ const pruneRefreshTokenCollisions = () => {
503
+ const indicesToRemove = new Set();
504
+ const refreshMap = new Map();
505
+ for (let i = 0; i < accounts.length; i += 1) {
506
+ const account = accounts[i];
507
+ if (!account)
508
+ continue;
509
+ const refreshToken = account.refreshToken?.trim();
510
+ if (!refreshToken)
511
+ continue;
512
+ const orgKey = account.organizationId?.trim() ?? "";
513
+ let entry = refreshMap.get(refreshToken);
514
+ if (!entry) {
515
+ entry = { byOrg: new Map(), fallbackIndex: undefined };
516
+ refreshMap.set(refreshToken, entry);
517
+ }
518
+ if (orgKey) {
519
+ const existingIndex = entry.byOrg.get(orgKey);
520
+ if (existingIndex !== undefined) {
521
+ const newestIndex = pickNewestAccountIndex(existingIndex, i);
522
+ const obsoleteIndex = newestIndex === existingIndex ? i : existingIndex;
523
+ mergeAccountRecords(newestIndex, obsoleteIndex);
524
+ indicesToRemove.add(obsoleteIndex);
525
+ entry.byOrg.set(orgKey, newestIndex);
526
+ continue;
527
+ }
528
+ entry.byOrg.set(orgKey, i);
529
+ continue;
530
+ }
531
+ const existingFallback = entry.fallbackIndex;
532
+ if (typeof existingFallback === "number") {
533
+ const newestIndex = pickNewestAccountIndex(existingFallback, i);
534
+ const obsoleteIndex = newestIndex === existingFallback ? i : existingFallback;
535
+ mergeAccountRecords(newestIndex, obsoleteIndex);
536
+ indicesToRemove.add(obsoleteIndex);
537
+ entry.fallbackIndex = newestIndex;
538
+ continue;
539
+ }
540
+ entry.fallbackIndex = i;
541
+ }
542
+ for (const entry of refreshMap.values()) {
543
+ const fallbackIndex = entry.fallbackIndex;
544
+ if (typeof fallbackIndex !== "number")
545
+ continue;
546
+ const orgIndices = Array.from(entry.byOrg.values());
547
+ if (orgIndices.length === 0)
548
+ continue;
549
+ const [firstOrgIndex, ...otherOrgIndices] = orgIndices;
550
+ if (typeof firstOrgIndex !== "number")
551
+ continue;
552
+ let preferredOrgIndex = firstOrgIndex;
553
+ for (const orgIndex of otherOrgIndices) {
554
+ preferredOrgIndex = pickNewestAccountIndex(preferredOrgIndex, orgIndex);
555
+ }
556
+ mergeAccountRecords(preferredOrgIndex, fallbackIndex);
557
+ indicesToRemove.add(fallbackIndex);
558
+ }
559
+ if (indicesToRemove.size > 0) {
560
+ accounts = accounts.filter((_, index) => !indicesToRemove.has(index));
561
+ }
562
+ };
563
+ const collectIdentityKeys = (account) => {
564
+ const keys = [];
565
+ const organizationId = account?.organizationId?.trim();
566
+ if (organizationId)
567
+ keys.push(`org:${organizationId}`);
568
+ const accountId = account?.accountId?.trim();
569
+ if (accountId)
570
+ keys.push(`account:${accountId}`);
571
+ const refreshToken = account?.refreshToken?.trim();
572
+ if (refreshToken)
573
+ keys.push(`refresh:${refreshToken}`);
574
+ return keys;
575
+ };
576
+ const getStoredAccountAtIndex = (rawIndex) => {
577
+ const storedAccounts = stored?.accounts;
578
+ if (!storedAccounts)
579
+ return undefined;
580
+ if (typeof rawIndex !== "number" || !Number.isFinite(rawIndex))
581
+ return undefined;
582
+ const candidate = Math.floor(rawIndex);
583
+ if (candidate < 0 || candidate >= storedAccounts.length)
584
+ return undefined;
585
+ return storedAccounts[candidate];
586
+ };
587
+ const storedActiveKeys = replaceAll
588
+ ? []
589
+ : collectIdentityKeys(getStoredAccountAtIndex(stored?.activeIndex));
590
+ const storedActiveKeysByFamily = {};
591
+ if (!replaceAll) {
592
+ for (const family of MODEL_FAMILIES) {
593
+ const familyKeys = collectIdentityKeys(getStoredAccountAtIndex(stored?.activeIndexByFamily?.[family]));
594
+ if (familyKeys.length > 0) {
595
+ storedActiveKeysByFamily[family] = familyKeys;
596
+ }
597
+ }
598
+ }
599
+ pruneRefreshTokenCollisions();
373
600
  if (accounts.length === 0)
374
601
  return;
375
- const activeIndex = replaceAll
602
+ const resolveIndexByIdentityKeys = (identityKeys) => {
603
+ if (!identityKeys || identityKeys.length === 0)
604
+ return undefined;
605
+ for (const identityKey of identityKeys) {
606
+ const index = accounts.findIndex((account) => collectIdentityKeys(account).includes(identityKey));
607
+ if (index >= 0) {
608
+ return index;
609
+ }
610
+ }
611
+ return undefined;
612
+ };
613
+ const fallbackActiveIndex = replaceAll
376
614
  ? 0
377
615
  : typeof stored?.activeIndex === "number" && Number.isFinite(stored.activeIndex)
378
616
  ? stored.activeIndex
379
617
  : 0;
380
- const clampedActiveIndex = Math.max(0, Math.min(activeIndex, accounts.length - 1));
618
+ const remappedActiveIndex = replaceAll
619
+ ? undefined
620
+ : resolveIndexByIdentityKeys(storedActiveKeys);
621
+ const activeIndex = remappedActiveIndex ?? fallbackActiveIndex;
622
+ const clampedActiveIndex = Math.max(0, Math.min(Math.floor(activeIndex), accounts.length - 1));
381
623
  const activeIndexByFamily = {};
382
624
  for (const family of MODEL_FAMILIES) {
383
625
  const storedFamilyIndex = stored?.activeIndexByFamily?.[family];
626
+ const remappedFamilyIndex = replaceAll
627
+ ? undefined
628
+ : resolveIndexByIdentityKeys(storedActiveKeysByFamily[family]);
384
629
  const rawFamilyIndex = replaceAll
385
630
  ? 0
386
- : typeof storedFamilyIndex === "number" && Number.isFinite(storedFamilyIndex)
387
- ? storedFamilyIndex
388
- : clampedActiveIndex;
631
+ : typeof remappedFamilyIndex === "number"
632
+ ? remappedFamilyIndex
633
+ : typeof storedFamilyIndex === "number" && Number.isFinite(storedFamilyIndex)
634
+ ? storedFamilyIndex
635
+ : clampedActiveIndex;
389
636
  activeIndexByFamily[family] = Math.max(0, Math.min(Math.floor(rawFamilyIndex), accounts.length - 1));
390
637
  }
391
638
  await persist({
@@ -542,6 +789,12 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
542
789
  const email = account?.email?.trim();
543
790
  const workspace = account?.accountLabel?.trim();
544
791
  const accountId = formatAccountIdForDisplay(account?.accountId);
792
+ const tags = Array.isArray(account?.accountTags)
793
+ ? account.accountTags
794
+ .filter((tag) => typeof tag === "string")
795
+ .map((tag) => tag.trim().toLowerCase())
796
+ .filter((tag) => tag.length > 0)
797
+ : [];
545
798
  const details = [];
546
799
  if (email)
547
800
  details.push(email);
@@ -549,11 +802,295 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
549
802
  details.push(`workspace:${workspace}`);
550
803
  if (accountId)
551
804
  details.push(`id:${accountId}`);
805
+ if (tags.length > 0)
806
+ details.push(`tags:${tags.join(",")}`);
552
807
  if (details.length === 0) {
553
808
  return `Account ${index + 1}`;
554
809
  }
555
810
  return `Account ${index + 1} (${details.join(", ")})`;
556
811
  };
812
+ const normalizeAccountTags = (raw) => {
813
+ return Array.from(new Set(raw
814
+ .split(",")
815
+ .map((entry) => entry.trim().toLowerCase())
816
+ .filter((entry) => entry.length > 0)));
817
+ };
818
+ const supportsInteractiveMenus = () => {
819
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
820
+ return false;
821
+ if (process.env.OPENCODE_TUI === "1")
822
+ return false;
823
+ if (process.env.OPENCODE_DESKTOP === "1")
824
+ return false;
825
+ if (process.env.TERM_PROGRAM === "opencode")
826
+ return false;
827
+ return true;
828
+ };
829
+ const promptAccountIndexSelection = async (ui, storage, title) => {
830
+ if (!supportsInteractiveMenus())
831
+ return null;
832
+ try {
833
+ const { select } = await import("./lib/ui/select.js");
834
+ const selected = await select(storage.accounts.map((account, index) => ({
835
+ label: formatCommandAccountLabel(account, index),
836
+ value: index,
837
+ })), {
838
+ message: title,
839
+ subtitle: "Select account index",
840
+ help: "Up/Down select | Enter confirm | Esc cancel",
841
+ clearScreen: true,
842
+ variant: ui.v2Enabled ? "codex" : "legacy",
843
+ theme: ui.theme,
844
+ });
845
+ return typeof selected === "number" ? selected : null;
846
+ }
847
+ catch {
848
+ return null;
849
+ }
850
+ };
851
+ const toBeginnerAccountSnapshots = (storage, activeIndex, now) => {
852
+ return storage.accounts.map((account, index) => ({
853
+ index,
854
+ label: formatCommandAccountLabel(account, index),
855
+ accountLabel: account.accountLabel,
856
+ enabled: account.enabled !== false,
857
+ isActive: index === activeIndex,
858
+ rateLimitedUntil: getRateLimitResetTimeForFamily(account, now, "codex"),
859
+ coolingDownUntil: typeof account.coolingDownUntil === "number"
860
+ ? account.coolingDownUntil
861
+ : null,
862
+ }));
863
+ };
864
+ const getBeginnerRuntimeSnapshot = () => ({
865
+ totalRequests: runtimeMetrics.totalRequests,
866
+ failedRequests: runtimeMetrics.failedRequests,
867
+ rateLimitedResponses: runtimeMetrics.rateLimitedResponses,
868
+ authRefreshFailures: runtimeMetrics.authRefreshFailures,
869
+ serverErrors: runtimeMetrics.serverErrors,
870
+ networkErrors: runtimeMetrics.networkErrors,
871
+ lastErrorCategory: runtimeMetrics.lastErrorCategory,
872
+ });
873
+ const formatDoctorSeverity = (ui, severity) => {
874
+ if (severity === "ok")
875
+ return formatUiBadge(ui, "ok", "success");
876
+ if (severity === "warning")
877
+ return formatUiBadge(ui, "warning", "warning");
878
+ return formatUiBadge(ui, "error", "danger");
879
+ };
880
+ const formatDoctorSeverityText = (severity) => {
881
+ if (severity === "ok")
882
+ return "[ok]";
883
+ if (severity === "warning")
884
+ return "[warning]";
885
+ return "[error]";
886
+ };
887
+ const buildSetupChecklistState = async () => {
888
+ const storage = await loadAccounts();
889
+ const now = Date.now();
890
+ const activeIndex = storage && storage.accounts.length > 0
891
+ ? resolveActiveIndex(storage, "codex")
892
+ : 0;
893
+ const snapshots = storage
894
+ ? toBeginnerAccountSnapshots(storage, activeIndex, now)
895
+ : [];
896
+ const runtime = getBeginnerRuntimeSnapshot();
897
+ const checklist = buildBeginnerChecklist(snapshots, now);
898
+ const summary = summarizeBeginnerAccounts(snapshots, now);
899
+ const nextAction = recommendBeginnerNextAction({
900
+ accounts: snapshots,
901
+ now,
902
+ runtime,
903
+ });
904
+ return {
905
+ now,
906
+ storage,
907
+ activeIndex,
908
+ snapshots,
909
+ runtime,
910
+ checklist,
911
+ summary,
912
+ nextAction,
913
+ };
914
+ };
915
+ const renderSetupChecklistOutput = (ui, state) => {
916
+ if (ui.v2Enabled) {
917
+ const lines = [
918
+ ...formatUiHeader(ui, "Setup checklist"),
919
+ formatUiKeyValue(ui, "Accounts", String(state.summary.total)),
920
+ formatUiKeyValue(ui, "Healthy", String(state.summary.healthy), state.summary.healthy > 0 ? "success" : "warning"),
921
+ formatUiKeyValue(ui, "Blocked", String(state.summary.blocked), state.summary.blocked > 0 ? "warning" : "muted"),
922
+ "",
923
+ ];
924
+ for (const item of state.checklist) {
925
+ const marker = item.done
926
+ ? getStatusMarker(ui, "ok")
927
+ : getStatusMarker(ui, "warning");
928
+ lines.push(formatUiItem(ui, `${marker} ${item.label} - ${item.detail}`, item.done ? "success" : "warning"));
929
+ if (item.command) {
930
+ lines.push(` ${formatUiKeyValue(ui, "command", item.command, "muted")}`);
931
+ }
932
+ }
933
+ lines.push("");
934
+ lines.push(...formatUiSection(ui, "Recommended next step"));
935
+ lines.push(formatUiItem(ui, state.nextAction, "accent"));
936
+ lines.push(formatUiItem(ui, "Guided wizard: codex-setup --wizard", "muted"));
937
+ return lines.join("\n");
938
+ }
939
+ const lines = [
940
+ "Setup Checklist:",
941
+ `Accounts: ${state.summary.total}`,
942
+ `Healthy accounts: ${state.summary.healthy}`,
943
+ `Blocked accounts: ${state.summary.blocked}`,
944
+ "",
945
+ ];
946
+ for (const item of state.checklist) {
947
+ const marker = item.done ? "[x]" : "[ ]";
948
+ lines.push(`${marker} ${item.label} - ${item.detail}`);
949
+ if (item.command)
950
+ lines.push(` command: ${item.command}`);
951
+ }
952
+ lines.push("");
953
+ lines.push(`Recommended next step: ${state.nextAction}`);
954
+ lines.push("Guided wizard: codex-setup --wizard");
955
+ return lines.join("\n");
956
+ };
957
+ const runSetupWizard = async (ui, state) => {
958
+ if (!supportsInteractiveMenus()) {
959
+ return [
960
+ ui.v2Enabled
961
+ ? formatUiItem(ui, "Interactive wizard mode is unavailable in this session.", "warning")
962
+ : "Interactive wizard mode is unavailable in this session.",
963
+ ui.v2Enabled
964
+ ? formatUiItem(ui, "Showing checklist view instead.", "muted")
965
+ : "Showing checklist view instead.",
966
+ "",
967
+ renderSetupChecklistOutput(ui, state),
968
+ ].join("\n");
969
+ }
970
+ try {
971
+ const { select } = await import("./lib/ui/select.js");
972
+ const labels = {
973
+ checklist: "Show setup checklist",
974
+ next: "Show best next action",
975
+ "add-account": "Add account now",
976
+ health: "Run health check",
977
+ switch: "Switch active account",
978
+ label: "Set account label",
979
+ doctor: "Run doctor diagnostics",
980
+ dashboard: "Open live dashboard",
981
+ metrics: "Open runtime metrics",
982
+ backup: "Backup accounts",
983
+ "safe-mode": "Enable beginner safe mode",
984
+ help: "Open command help",
985
+ };
986
+ const commandMap = {
987
+ "add-account": "opencode auth login",
988
+ health: "codex-health",
989
+ switch: "codex-switch index=2",
990
+ label: "codex-label index=2 label=\"Work\"",
991
+ doctor: "codex-doctor",
992
+ dashboard: "codex-dashboard",
993
+ metrics: "codex-metrics",
994
+ backup: "codex-export <path>",
995
+ "safe-mode": "set CODEX_AUTH_BEGINNER_SAFE_MODE=1",
996
+ help: "codex-help",
997
+ };
998
+ const choice = await select([
999
+ { label: "Setup wizard", value: "exit", kind: "heading" },
1000
+ { label: labels.checklist, value: "checklist", color: "cyan" },
1001
+ { label: labels.next, value: "next", color: "green" },
1002
+ { label: labels["add-account"], value: "add-account", color: "cyan" },
1003
+ { label: labels.health, value: "health", color: "cyan" },
1004
+ { label: labels.switch, value: "switch", color: "cyan" },
1005
+ { label: labels.label, value: "label", color: "cyan" },
1006
+ { label: labels.doctor, value: "doctor", color: "yellow" },
1007
+ { label: labels.dashboard, value: "dashboard", color: "cyan" },
1008
+ { label: labels.metrics, value: "metrics", color: "cyan" },
1009
+ { label: labels.backup, value: "backup", color: "yellow" },
1010
+ { label: labels["safe-mode"], value: "safe-mode", color: "yellow" },
1011
+ { label: labels.help, value: "help", color: "cyan" },
1012
+ { label: "", value: "exit", separator: true },
1013
+ { label: "Exit wizard", value: "exit", color: "red" },
1014
+ ], {
1015
+ message: "Beginner setup wizard",
1016
+ subtitle: `Accounts: ${state.summary.total} | Healthy: ${state.summary.healthy} | Blocked: ${state.summary.blocked}`,
1017
+ help: "Up/Down select | Enter confirm | Esc exit",
1018
+ clearScreen: true,
1019
+ variant: ui.v2Enabled ? "codex" : "legacy",
1020
+ theme: ui.theme,
1021
+ });
1022
+ if (!choice || choice === "exit") {
1023
+ return ui.v2Enabled
1024
+ ? [
1025
+ ...formatUiHeader(ui, "Setup wizard"),
1026
+ "",
1027
+ formatUiItem(ui, "Wizard closed.", "muted"),
1028
+ formatUiItem(ui, `Next: ${state.nextAction}`, "accent"),
1029
+ ].join("\n")
1030
+ : `Setup wizard closed.\n\nNext: ${state.nextAction}`;
1031
+ }
1032
+ if (choice === "checklist") {
1033
+ return renderSetupChecklistOutput(ui, state);
1034
+ }
1035
+ if (choice === "next") {
1036
+ return ui.v2Enabled
1037
+ ? [
1038
+ ...formatUiHeader(ui, "Setup wizard"),
1039
+ "",
1040
+ formatUiItem(ui, "Best next action", "accent"),
1041
+ formatUiItem(ui, state.nextAction, "success"),
1042
+ ].join("\n")
1043
+ : `Best next action:\n${state.nextAction}`;
1044
+ }
1045
+ const command = commandMap[choice];
1046
+ const selectedLabel = labels[choice];
1047
+ if (ui.v2Enabled) {
1048
+ return [
1049
+ ...formatUiHeader(ui, "Setup wizard"),
1050
+ "",
1051
+ formatUiItem(ui, `Selected: ${selectedLabel}`, "accent"),
1052
+ formatUiItem(ui, `Run: ${command}`, "success"),
1053
+ formatUiItem(ui, "Run codex-setup --wizard again to choose another step.", "muted"),
1054
+ ].join("\n");
1055
+ }
1056
+ return [
1057
+ "Setup wizard:",
1058
+ `Selected: ${selectedLabel}`,
1059
+ `Run: ${command}`,
1060
+ "",
1061
+ "Run codex-setup --wizard again to choose another step.",
1062
+ ].join("\n");
1063
+ }
1064
+ catch (error) {
1065
+ const reason = error instanceof Error ? error.message : String(error);
1066
+ return [
1067
+ ui.v2Enabled
1068
+ ? formatUiItem(ui, `Wizard failed to open: ${reason}`, "warning")
1069
+ : `Wizard failed to open: ${reason}`,
1070
+ ui.v2Enabled
1071
+ ? formatUiItem(ui, "Showing checklist view instead.", "muted")
1072
+ : "Showing checklist view instead.",
1073
+ "",
1074
+ renderSetupChecklistOutput(ui, state),
1075
+ ].join("\n");
1076
+ }
1077
+ };
1078
+ const runStartupPreflight = async () => {
1079
+ if (startupPreflightShown)
1080
+ return;
1081
+ startupPreflightShown = true;
1082
+ try {
1083
+ const state = await buildSetupChecklistState();
1084
+ const message = `Codex preflight: healthy ${state.summary.healthy}/${state.summary.total}, ` +
1085
+ `blocked ${state.summary.blocked}, rate-limited ${state.summary.rateLimited}. ` +
1086
+ `Next: ${state.nextAction}`;
1087
+ await showToast(message, state.summary.healthy > 0 ? "info" : "warning");
1088
+ logInfo(message);
1089
+ }
1090
+ catch (error) {
1091
+ logDebug(`[${PLUGIN_NAME}] Startup preflight skipped: ${error instanceof Error ? error.message : String(error)}`);
1092
+ }
1093
+ };
557
1094
  const invalidateAccountManagerCache = () => {
558
1095
  cachedAccountManager = null;
559
1096
  accountManagerPromise = null;
@@ -680,11 +1217,26 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
680
1217
  const fastSessionEnabled = getFastSession(pluginConfig);
681
1218
  const fastSessionStrategy = getFastSessionStrategy(pluginConfig);
682
1219
  const fastSessionMaxInputItems = getFastSessionMaxInputItems(pluginConfig);
1220
+ const beginnerSafeMode = getBeginnerSafeMode(pluginConfig);
1221
+ beginnerSafeModeEnabled = beginnerSafeMode;
1222
+ const retryProfile = beginnerSafeMode
1223
+ ? "conservative"
1224
+ : getRetryProfile(pluginConfig);
1225
+ const retryBudgetOverrides = beginnerSafeMode
1226
+ ? {}
1227
+ : getRetryBudgetOverrides(pluginConfig);
1228
+ const retryBudgetLimits = resolveRetryBudgetLimits(retryProfile, retryBudgetOverrides);
1229
+ runtimeMetrics.retryProfile = retryProfile;
1230
+ runtimeMetrics.retryBudgetLimits = { ...retryBudgetLimits };
683
1231
  const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig);
684
1232
  const rateLimitToastDebounceMs = getRateLimitToastDebounceMs(pluginConfig);
685
- const retryAllAccountsRateLimited = getRetryAllAccountsRateLimited(pluginConfig);
1233
+ const retryAllAccountsRateLimited = beginnerSafeMode
1234
+ ? false
1235
+ : getRetryAllAccountsRateLimited(pluginConfig);
686
1236
  const retryAllAccountsMaxWaitMs = getRetryAllAccountsMaxWaitMs(pluginConfig);
687
- const retryAllAccountsMaxRetries = getRetryAllAccountsMaxRetries(pluginConfig);
1237
+ const retryAllAccountsMaxRetries = beginnerSafeMode
1238
+ ? Math.min(1, getRetryAllAccountsMaxRetries(pluginConfig))
1239
+ : getRetryAllAccountsMaxRetries(pluginConfig);
688
1240
  const unsupportedCodexPolicy = getUnsupportedCodexPolicy(pluginConfig);
689
1241
  const fallbackOnUnsupportedCodexModel = unsupportedCodexPolicy === "fallback";
690
1242
  const fallbackToGpt52OnUnsupportedGpt53 = getFallbackToGpt52OnUnsupportedGpt53(pluginConfig);
@@ -709,6 +1261,13 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
709
1261
  fastSessionMaxInputItems,
710
1262
  });
711
1263
  }
1264
+ if (beginnerSafeMode) {
1265
+ logInfo("Beginner safe mode enabled", {
1266
+ retryProfile,
1267
+ retryAllAccountsRateLimited,
1268
+ retryAllAccountsMaxRetries,
1269
+ });
1270
+ }
712
1271
  const prewarmEnabled = process.env.CODEX_AUTH_PREWARM !== "0" &&
713
1272
  process.env.VITEST !== "true" &&
714
1273
  process.env.NODE_ENV !== "test";
@@ -728,6 +1287,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
728
1287
  }).catch((err) => {
729
1288
  logDebug(`Update check failed: ${err instanceof Error ? err.message : String(err)}`);
730
1289
  });
1290
+ await runStartupPreflight();
731
1291
  // Return SDK configuration
732
1292
  return {
733
1293
  apiKey: DUMMY_API_KEY,
@@ -829,6 +1389,25 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
829
1389
  .trim() || undefined;
830
1390
  const requestCorrelationId = setCorrelationId(threadIdCandidate ? `${threadIdCandidate}:${Date.now()}` : undefined);
831
1391
  runtimeMetrics.lastRequestAt = Date.now();
1392
+ const retryBudget = new RetryBudgetTracker(retryBudgetLimits);
1393
+ const consumeRetryBudget = (bucket, reason) => {
1394
+ if (retryBudget.consume(bucket)) {
1395
+ runtimeMetrics.retryBudgetUsage[bucket] += 1;
1396
+ return true;
1397
+ }
1398
+ runtimeMetrics.retryBudgetExhaustions += 1;
1399
+ runtimeMetrics.lastRetryBudgetExhaustedClass = bucket;
1400
+ runtimeMetrics.lastRetryBudgetReason = reason;
1401
+ runtimeMetrics.lastErrorCategory = "retry-budget";
1402
+ runtimeMetrics.lastError = `Retry budget exhausted (${bucket}): ${reason}`;
1403
+ logWarn(`Retry budget exhausted for ${bucket}`, {
1404
+ reason,
1405
+ profile: retryProfile,
1406
+ limits: retryBudget.getLimits(),
1407
+ usage: retryBudget.getUsage(),
1408
+ });
1409
+ return false;
1410
+ };
832
1411
  const abortSignal = requestInit?.signal ?? init?.signal ?? null;
833
1412
  const sleep = (ms) => new Promise((resolve, reject) => {
834
1413
  if (abortSignal?.aborted) {
@@ -879,11 +1458,28 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
879
1458
  const attempted = new Set();
880
1459
  let restartAccountTraversalWithFallback = false;
881
1460
  while (attempted.size < Math.max(1, accountCount)) {
1461
+ const selectionExplainability = accountManager.getSelectionExplainability(modelFamily, model, Date.now());
1462
+ runtimeMetrics.lastSelectionSnapshot = {
1463
+ timestamp: Date.now(),
1464
+ family: modelFamily,
1465
+ model: model ?? null,
1466
+ selectedAccountIndex: null,
1467
+ quotaKey,
1468
+ explainability: selectionExplainability,
1469
+ };
882
1470
  const account = accountManager.getCurrentOrNextForFamilyHybrid(modelFamily, model, { pidOffsetEnabled });
883
1471
  if (!account || attempted.has(account.index)) {
884
1472
  break;
885
1473
  }
886
1474
  attempted.add(account.index);
1475
+ runtimeMetrics.lastSelectedAccountIndex = account.index;
1476
+ runtimeMetrics.lastQuotaKey = quotaKey;
1477
+ if (runtimeMetrics.lastSelectionSnapshot) {
1478
+ runtimeMetrics.lastSelectionSnapshot = {
1479
+ ...runtimeMetrics.lastSelectionSnapshot,
1480
+ selectedAccountIndex: account.index,
1481
+ };
1482
+ }
887
1483
  // Log account selection for debugging rotation
888
1484
  logDebug(`Using account ${account.index + 1}/${accountCount}: ${account.email ?? "unknown"} for ${modelFamily}`);
889
1485
  let accountAuth = accountManager.toAuthDetails(account);
@@ -897,10 +1493,23 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
897
1493
  }
898
1494
  catch (err) {
899
1495
  logDebug(`[${PLUGIN_NAME}] Auth refresh failed for account: ${err?.message ?? String(err)}`);
1496
+ if (!consumeRetryBudget("authRefresh", `Auth refresh failed for account ${account.index + 1}`)) {
1497
+ return new Response(JSON.stringify({
1498
+ error: {
1499
+ message: "Auth refresh retry budget exhausted for this request. Try again or switch accounts.",
1500
+ },
1501
+ }), {
1502
+ status: 503,
1503
+ headers: {
1504
+ "content-type": "application/json; charset=utf-8",
1505
+ },
1506
+ });
1507
+ }
900
1508
  runtimeMetrics.authRefreshFailures++;
901
1509
  runtimeMetrics.failedRequests++;
902
1510
  runtimeMetrics.accountRotations++;
903
1511
  runtimeMetrics.lastError = err?.message ?? String(err);
1512
+ runtimeMetrics.lastErrorCategory = "auth-refresh";
904
1513
  const failures = accountManager.incrementAuthFailures(account);
905
1514
  const accountLabel = formatAccountLabel(account, account.index);
906
1515
  if (failures >= ACCOUNT_LIMITS.MAX_AUTH_FAILURES_BEFORE_REMOVAL) {
@@ -944,6 +1553,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
944
1553
  runtimeMetrics.accountRotations++;
945
1554
  runtimeMetrics.lastError =
946
1555
  `Local token bucket depleted for account ${account.index + 1} (${modelFamily}${model ? `:${model}` : ""})`;
1556
+ runtimeMetrics.lastErrorCategory = "rate-limit-local";
947
1557
  logWarn(`Skipping account ${account.index + 1}: local token bucket depleted for ${modelFamily}${model ? `:${model}` : ""}`);
948
1558
  break;
949
1559
  }
@@ -975,10 +1585,24 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
975
1585
  catch (networkError) {
976
1586
  const errorMsg = networkError instanceof Error ? networkError.message : String(networkError);
977
1587
  logWarn(`Network error for account ${account.index + 1}: ${errorMsg}`);
1588
+ if (!consumeRetryBudget("network", `Network error on account ${account.index + 1}: ${errorMsg}`)) {
1589
+ accountManager.refundToken(account, modelFamily, model);
1590
+ return new Response(JSON.stringify({
1591
+ error: {
1592
+ message: "Network retry budget exhausted for this request. Try again in a moment.",
1593
+ },
1594
+ }), {
1595
+ status: 503,
1596
+ headers: {
1597
+ "content-type": "application/json; charset=utf-8",
1598
+ },
1599
+ });
1600
+ }
978
1601
  runtimeMetrics.failedRequests++;
979
1602
  runtimeMetrics.networkErrors++;
980
1603
  runtimeMetrics.accountRotations++;
981
1604
  runtimeMetrics.lastError = errorMsg;
1605
+ runtimeMetrics.lastErrorCategory = "network";
982
1606
  accountManager.refundToken(account, modelFamily, model);
983
1607
  accountManager.recordFailure(account, modelFamily, model);
984
1608
  break;
@@ -1016,6 +1640,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1016
1640
  accountManager.recordFailure(account, modelFamily, model);
1017
1641
  account.lastSwitchReason = "rotation";
1018
1642
  runtimeMetrics.lastError = `Unsupported model on account ${account.index + 1}: ${blockedModel}`;
1643
+ runtimeMetrics.lastErrorCategory = "unsupported-model";
1019
1644
  logWarn(`Model ${blockedModel} is unsupported for account ${account.index + 1}. Trying next account/workspace before fallback.`, {
1020
1645
  unsupportedCodexPolicy,
1021
1646
  requestedModel: blockedModel,
@@ -1063,6 +1688,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1063
1688
  body: JSON.stringify(transformedBody),
1064
1689
  };
1065
1690
  runtimeMetrics.lastError = `Model fallback: ${previousModel} -> ${model}`;
1691
+ runtimeMetrics.lastErrorCategory = "model-fallback";
1066
1692
  logWarn(`Model ${previousModel} is unsupported for this ChatGPT account. Falling back to ${model}.`, {
1067
1693
  unsupportedCodexPolicy,
1068
1694
  requestedModel: previousModel,
@@ -1077,6 +1703,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1077
1703
  if (unsupportedModelInfo.isUnsupported && !fallbackOnUnsupportedCodexModel) {
1078
1704
  const blockedModel = unsupportedModelInfo.unsupportedModel ?? model ?? "requested model";
1079
1705
  runtimeMetrics.lastError = `Unsupported model (strict): ${blockedModel}`;
1706
+ runtimeMetrics.lastErrorCategory = "unsupported-model";
1080
1707
  logWarn(`Model ${blockedModel} is unsupported for this ChatGPT account. Strict policy blocks automatic fallback.`, {
1081
1708
  unsupportedCodexPolicy,
1082
1709
  requestedModel: blockedModel,
@@ -1099,15 +1726,20 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1099
1726
  runtimeMetrics.serverErrors++;
1100
1727
  runtimeMetrics.accountRotations++;
1101
1728
  runtimeMetrics.lastError = `HTTP ${response.status}`;
1729
+ runtimeMetrics.lastErrorCategory = "server";
1102
1730
  accountManager.refundToken(account, modelFamily, model);
1103
1731
  accountManager.recordFailure(account, modelFamily, model);
1732
+ if (!consumeRetryBudget("server", `Server error ${response.status} on account ${account.index + 1}`)) {
1733
+ return errorResponse;
1734
+ }
1104
1735
  break;
1105
1736
  }
1106
1737
  if (rateLimit) {
1107
1738
  runtimeMetrics.rateLimitedResponses++;
1108
1739
  const { attempt, delayMs } = getRateLimitBackoff(account.index, quotaKey, rateLimit.retryAfterMs);
1109
1740
  const waitLabel = formatWaitTime(delayMs);
1110
- if (delayMs <= RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS) {
1741
+ if (delayMs <= RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS &&
1742
+ consumeRetryBudget("rateLimitShort", `Short 429 retry for account ${account.index + 1} after ${delayMs}ms`)) {
1111
1743
  if (accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
1112
1744
  await showToast(`Rate limited. Retrying in ${waitLabel} (attempt ${attempt})...`, "warning", { duration: toastDurationMs });
1113
1745
  accountManager.markToastShown(account.index);
@@ -1119,6 +1751,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1119
1751
  accountManager.recordRateLimit(account, modelFamily, model);
1120
1752
  account.lastSwitchReason = "rate-limit";
1121
1753
  runtimeMetrics.accountRotations++;
1754
+ runtimeMetrics.lastErrorCategory = "rate-limit";
1122
1755
  accountManager.saveToDiskDebounced();
1123
1756
  logWarn(`Rate limited. Rotating account ${account.index + 1} (${account.email ?? "unknown"}).`);
1124
1757
  if (accountManager.getAccountCount() > 1 &&
@@ -1130,6 +1763,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1130
1763
  }
1131
1764
  runtimeMetrics.failedRequests++;
1132
1765
  runtimeMetrics.lastError = `HTTP ${response.status}`;
1766
+ runtimeMetrics.lastErrorCategory = "http";
1133
1767
  return errorResponse;
1134
1768
  }
1135
1769
  resetRateLimitBackoff(account.index, quotaKey);
@@ -1140,6 +1774,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1140
1774
  if (!successResponse.ok) {
1141
1775
  runtimeMetrics.failedRequests++;
1142
1776
  runtimeMetrics.lastError = `HTTP ${successResponse.status}`;
1777
+ runtimeMetrics.lastErrorCategory = "http";
1143
1778
  return successResponse;
1144
1779
  }
1145
1780
  if (!isStreaming && emptyResponseMaxRetries > 0) {
@@ -1148,7 +1783,8 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1148
1783
  const bodyText = await clonedResponse.text();
1149
1784
  const parsedBody = bodyText ? JSON.parse(bodyText) : null;
1150
1785
  if (isEmptyResponse(parsedBody)) {
1151
- if (emptyResponseRetries < emptyResponseMaxRetries) {
1786
+ if (emptyResponseRetries < emptyResponseMaxRetries &&
1787
+ consumeRetryBudget("emptyResponse", `Empty response retry ${emptyResponseRetries + 1}/${emptyResponseMaxRetries}`)) {
1152
1788
  emptyResponseRetries++;
1153
1789
  runtimeMetrics.emptyResponseRetries++;
1154
1790
  logWarn(`Empty response received (attempt ${emptyResponseRetries}/${emptyResponseMaxRetries}). Retrying...`);
@@ -1168,6 +1804,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1168
1804
  accountManager.recordSuccess(account, modelFamily, model);
1169
1805
  runtimeMetrics.successfulRequests++;
1170
1806
  runtimeMetrics.lastError = null;
1807
+ runtimeMetrics.lastErrorCategory = null;
1171
1808
  return successResponse;
1172
1809
  }
1173
1810
  if (restartAccountTraversalWithFallback) {
@@ -1184,7 +1821,8 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1184
1821
  waitMs > 0 &&
1185
1822
  (retryAllAccountsMaxWaitMs === 0 ||
1186
1823
  waitMs <= retryAllAccountsMaxWaitMs) &&
1187
- allRateLimitedRetries < retryAllAccountsMaxRetries) {
1824
+ allRateLimitedRetries < retryAllAccountsMaxRetries &&
1825
+ consumeRetryBudget("rateLimitGlobal", `All accounts rate-limited wait ${waitMs}ms`)) {
1188
1826
  const countdownMessage = `All ${count} account(s) rate-limited. Waiting`;
1189
1827
  await sleepWithCountdown(addJitter(waitMs, 0.2), countdownMessage);
1190
1828
  allRateLimitedRetries++;
@@ -1198,6 +1836,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1198
1836
  : `All ${count} account(s) failed (server errors or auth issues). Check account health with \`codex-health\`.`;
1199
1837
  runtimeMetrics.failedRequests++;
1200
1838
  runtimeMetrics.lastError = message;
1839
+ runtimeMetrics.lastErrorCategory = waitMs > 0 ? "rate-limit" : "account-failure";
1201
1840
  return new Response(JSON.stringify({ error: { message } }), {
1202
1841
  status: waitMs > 0 ? 429 : 503,
1203
1842
  headers: {
@@ -1414,7 +2053,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1414
2053
  const headers = createCodexHeaders(undefined, params.accountId, params.accessToken, {
1415
2054
  model,
1416
2055
  });
1417
- headers.set("content-type", "application/json; charset=utf-8");
2056
+ headers.set("content-type", "application/json");
1418
2057
  const controller = new AbortController();
1419
2058
  const timeout = setTimeout(() => controller.abort(), 15_000);
1420
2059
  let response;
@@ -2057,11 +2696,17 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2057
2696
  tool: {
2058
2697
  "codex-list": tool({
2059
2698
  description: "List all Codex OAuth accounts and the current active index.",
2060
- args: {},
2061
- async execute() {
2699
+ args: {
2700
+ tag: tool.schema
2701
+ .string()
2702
+ .optional()
2703
+ .describe("Optional tag filter (e.g., work, personal, team-a)."),
2704
+ },
2705
+ async execute({ tag } = {}) {
2062
2706
  const ui = resolveUiRuntime();
2063
2707
  const storage = await loadAccounts();
2064
2708
  const storePath = getStoragePath();
2709
+ const normalizedTag = tag?.trim().toLowerCase() ?? "";
2065
2710
  if (!storage || storage.accounts.length === 0) {
2066
2711
  if (ui.v2Enabled) {
2067
2712
  return [
@@ -2069,6 +2714,8 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2069
2714
  "",
2070
2715
  formatUiItem(ui, "No accounts configured.", "warning"),
2071
2716
  formatUiItem(ui, "Run: opencode auth login", "accent"),
2717
+ formatUiItem(ui, "Setup checklist: codex-setup"),
2718
+ formatUiItem(ui, "Command guide: codex-help"),
2072
2719
  formatUiKeyValue(ui, "Storage", storePath, "muted"),
2073
2720
  ].join("\n");
2074
2721
  }
@@ -2077,21 +2724,47 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2077
2724
  "",
2078
2725
  "Add accounts:",
2079
2726
  " opencode auth login",
2727
+ " codex-setup",
2728
+ " codex-help",
2080
2729
  "",
2081
2730
  `Storage: ${storePath}`,
2082
2731
  ].join("\n");
2083
2732
  }
2084
2733
  const now = Date.now();
2085
2734
  const activeIndex = resolveActiveIndex(storage, "codex");
2735
+ const filteredEntries = storage.accounts
2736
+ .map((account, index) => ({ account, index }))
2737
+ .filter(({ account }) => {
2738
+ if (!normalizedTag)
2739
+ return true;
2740
+ const tags = Array.isArray(account.accountTags)
2741
+ ? account.accountTags.map((entry) => entry.trim().toLowerCase())
2742
+ : [];
2743
+ return tags.includes(normalizedTag);
2744
+ });
2745
+ if (normalizedTag && filteredEntries.length === 0) {
2746
+ if (ui.v2Enabled) {
2747
+ return [
2748
+ ...formatUiHeader(ui, "Codex accounts"),
2749
+ "",
2750
+ formatUiItem(ui, `No accounts found for tag: ${normalizedTag}`, "warning"),
2751
+ formatUiItem(ui, "Use codex-tag index=2 tags=\"work,team-a\" to add tags.", "accent"),
2752
+ ].join("\n");
2753
+ }
2754
+ return `No accounts found for tag: ${normalizedTag}\n\nUse codex-tag index=2 tags="work,team-a" to add tags.`;
2755
+ }
2086
2756
  if (ui.v2Enabled) {
2087
2757
  const lines = [
2088
2758
  ...formatUiHeader(ui, "Codex accounts"),
2089
- formatUiKeyValue(ui, "Total", String(storage.accounts.length)),
2759
+ formatUiKeyValue(ui, "Total", String(filteredEntries.length)),
2760
+ normalizedTag
2761
+ ? formatUiKeyValue(ui, "Filter tag", normalizedTag, "accent")
2762
+ : formatUiKeyValue(ui, "Filter tag", "none", "muted"),
2090
2763
  formatUiKeyValue(ui, "Storage", storePath, "muted"),
2091
2764
  "",
2092
2765
  ...formatUiSection(ui, "Accounts"),
2093
2766
  ];
2094
- storage.accounts.forEach((account, index) => {
2767
+ filteredEntries.forEach(({ account, index }) => {
2095
2768
  const label = formatCommandAccountLabel(account, index);
2096
2769
  const badges = [];
2097
2770
  if (index === activeIndex)
@@ -2116,9 +2789,18 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2116
2789
  lines.push("");
2117
2790
  lines.push(...formatUiSection(ui, "Commands"));
2118
2791
  lines.push(formatUiItem(ui, "Add account: opencode auth login", "accent"));
2119
- lines.push(formatUiItem(ui, "Switch account: codex-switch <index>"));
2792
+ lines.push(formatUiItem(ui, "Switch account: codex-switch index=2"));
2120
2793
  lines.push(formatUiItem(ui, "Detailed status: codex-status"));
2794
+ lines.push(formatUiItem(ui, "Live dashboard: codex-dashboard"));
2121
2795
  lines.push(formatUiItem(ui, "Runtime metrics: codex-metrics"));
2796
+ lines.push(formatUiItem(ui, "Set account tags: codex-tag index=2 tags=\"work,team-a\""));
2797
+ lines.push(formatUiItem(ui, "Set account note: codex-note index=2 note=\"weekday primary\""));
2798
+ lines.push(formatUiItem(ui, "Doctor checks: codex-doctor"));
2799
+ lines.push(formatUiItem(ui, "Onboarding checklist: codex-setup"));
2800
+ lines.push(formatUiItem(ui, "Guided setup wizard: codex-setup --wizard"));
2801
+ lines.push(formatUiItem(ui, "Best next action: codex-next"));
2802
+ lines.push(formatUiItem(ui, "Rename account label: codex-label index=2 label=\"Work\""));
2803
+ lines.push(formatUiItem(ui, "Command guide: codex-help"));
2122
2804
  return lines.join("\n");
2123
2805
  }
2124
2806
  const listTableOptions = {
@@ -2129,11 +2811,11 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2129
2811
  ],
2130
2812
  };
2131
2813
  const lines = [
2132
- `Codex Accounts (${storage.accounts.length}):`,
2814
+ `Codex Accounts (${filteredEntries.length}):`,
2133
2815
  "",
2134
2816
  ...buildTableHeader(listTableOptions),
2135
2817
  ];
2136
- storage.accounts.forEach((account, index) => {
2818
+ filteredEntries.forEach(({ account, index }) => {
2137
2819
  const label = formatCommandAccountLabel(account, index);
2138
2820
  const statuses = [];
2139
2821
  const rateLimit = formatRateLimitEntry(account, now);
@@ -2151,21 +2833,33 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2151
2833
  });
2152
2834
  lines.push("");
2153
2835
  lines.push(`Storage: ${storePath}`);
2836
+ if (normalizedTag) {
2837
+ lines.push(`Filter tag: ${normalizedTag}`);
2838
+ }
2154
2839
  lines.push("");
2155
2840
  lines.push("Commands:");
2156
2841
  lines.push(" - Add account: opencode auth login");
2157
2842
  lines.push(" - Switch account: codex-switch");
2158
2843
  lines.push(" - Status details: codex-status");
2844
+ lines.push(" - Live dashboard: codex-dashboard");
2159
2845
  lines.push(" - Runtime metrics: codex-metrics");
2846
+ lines.push(" - Set account tags: codex-tag");
2847
+ lines.push(" - Set account note: codex-note");
2848
+ lines.push(" - Doctor checks: codex-doctor");
2849
+ lines.push(" - Setup checklist: codex-setup");
2850
+ lines.push(" - Guided setup wizard: codex-setup --wizard");
2851
+ lines.push(" - Best next action: codex-next");
2852
+ lines.push(" - Rename account label: codex-label");
2853
+ lines.push(" - Command guide: codex-help");
2160
2854
  return lines.join("\n");
2161
2855
  },
2162
2856
  }),
2163
2857
  "codex-switch": tool({
2164
- description: "Switch active Codex account by index (1-based).",
2858
+ description: "Switch active Codex account by index (1-based) or interactive picker when index is omitted.",
2165
2859
  args: {
2166
- index: tool.schema.number().describe("Account number to switch to (1-based, e.g., 1 for first account)"),
2860
+ index: tool.schema.number().optional().describe("Account number to switch to (1-based, e.g., 1 for first account)"),
2167
2861
  },
2168
- async execute({ index }) {
2862
+ async execute({ index } = {}) {
2169
2863
  const ui = resolveUiRuntime();
2170
2864
  const storage = await loadAccounts();
2171
2865
  if (!storage || storage.accounts.length === 0) {
@@ -2179,7 +2873,34 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2179
2873
  }
2180
2874
  return "No Codex accounts configured. Run: opencode auth login";
2181
2875
  }
2182
- const targetIndex = Math.floor((index ?? 0) - 1);
2876
+ let resolvedIndex = index;
2877
+ if (resolvedIndex === undefined) {
2878
+ const selectedIndex = await promptAccountIndexSelection(ui, storage, "Switch account");
2879
+ if (selectedIndex === null) {
2880
+ if (supportsInteractiveMenus()) {
2881
+ if (ui.v2Enabled) {
2882
+ return [
2883
+ ...formatUiHeader(ui, "Switch account"),
2884
+ "",
2885
+ formatUiItem(ui, "No account selected.", "warning"),
2886
+ formatUiItem(ui, "Run again and pick an account, or pass codex-switch index=2.", "muted"),
2887
+ ].join("\n");
2888
+ }
2889
+ return "No account selected.";
2890
+ }
2891
+ if (ui.v2Enabled) {
2892
+ return [
2893
+ ...formatUiHeader(ui, "Switch account"),
2894
+ "",
2895
+ formatUiItem(ui, "Missing account number.", "warning"),
2896
+ formatUiItem(ui, "Use: codex-switch index=2", "accent"),
2897
+ ].join("\n");
2898
+ }
2899
+ return "Missing account number. Use: codex-switch index=2";
2900
+ }
2901
+ resolvedIndex = selectedIndex + 1;
2902
+ }
2903
+ const targetIndex = Math.floor((resolvedIndex ?? 0) - 1);
2183
2904
  if (!Number.isFinite(targetIndex) ||
2184
2905
  targetIndex < 0 ||
2185
2906
  targetIndex >= storage.accounts.length) {
@@ -2187,11 +2908,11 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2187
2908
  return [
2188
2909
  ...formatUiHeader(ui, "Switch account"),
2189
2910
  "",
2190
- formatUiItem(ui, `Invalid account number: ${index}`, "danger"),
2911
+ formatUiItem(ui, `Invalid account number: ${resolvedIndex}`, "danger"),
2191
2912
  formatUiKeyValue(ui, "Valid range", `1-${storage.accounts.length}`, "muted"),
2192
2913
  ].join("\n");
2193
2914
  }
2194
- return `Invalid account number: ${index}\n\nValid range: 1-${storage.accounts.length}`;
2915
+ return `Invalid account number: ${resolvedIndex}\n\nValid range: 1-${storage.accounts.length}`;
2195
2916
  }
2196
2917
  const now = Date.now();
2197
2918
  const account = storage.accounts[targetIndex];
@@ -2255,10 +2976,18 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2255
2976
  }
2256
2977
  const now = Date.now();
2257
2978
  const activeIndex = resolveActiveIndex(storage, "codex");
2979
+ const explainabilityFamily = runtimeMetrics.lastSelectionSnapshot?.family ?? "codex";
2980
+ const explainabilityModel = runtimeMetrics.lastSelectionSnapshot?.model ?? undefined;
2981
+ const managerForExplainability = cachedAccountManager ?? (await AccountManager.loadFromDisk());
2982
+ const explainability = managerForExplainability.getSelectionExplainability(explainabilityFamily, explainabilityModel, now);
2983
+ const explainabilityByIndex = new Map(explainability.map((entry) => [entry.index, entry]));
2258
2984
  if (ui.v2Enabled) {
2259
2985
  const lines = [
2260
2986
  ...formatUiHeader(ui, "Account status"),
2261
2987
  formatUiKeyValue(ui, "Total", String(storage.accounts.length)),
2988
+ formatUiKeyValue(ui, "Selection view", explainabilityModel
2989
+ ? `${explainabilityFamily}:${explainabilityModel}`
2990
+ : explainabilityFamily, "muted"),
2262
2991
  "",
2263
2992
  ...formatUiSection(ui, "Accounts"),
2264
2993
  ];
@@ -2299,6 +3028,21 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2299
3028
  });
2300
3029
  lines.push(formatUiItem(ui, `Account ${index + 1}: ${statuses.join(" | ")}`));
2301
3030
  });
3031
+ lines.push("");
3032
+ lines.push(...formatUiSection(ui, "Selection explainability"));
3033
+ for (const entry of explainability) {
3034
+ const state = entry.eligible ? "eligible" : "blocked";
3035
+ const reasons = entry.reasons.join(", ");
3036
+ lines.push(formatUiItem(ui, `Account ${entry.index + 1}: ${state} | health=${Math.round(entry.healthScore)} | tokens=${entry.tokensAvailable.toFixed(1)} | ${reasons}`));
3037
+ }
3038
+ const nextAction = recommendBeginnerNextAction({
3039
+ accounts: toBeginnerAccountSnapshots(storage, activeIndex, now),
3040
+ now,
3041
+ runtime: getBeginnerRuntimeSnapshot(),
3042
+ });
3043
+ lines.push("");
3044
+ lines.push(...formatUiSection(ui, "Recommended next step"));
3045
+ lines.push(formatUiItem(ui, nextAction, "accent"));
2302
3046
  return lines.join("\n");
2303
3047
  }
2304
3048
  const statusTableOptions = {
@@ -2344,6 +3088,21 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2344
3088
  });
2345
3089
  lines.push(` Account ${index + 1}: ${statuses.join(" | ")}`);
2346
3090
  });
3091
+ lines.push("");
3092
+ lines.push(`Selection explainability (${explainabilityModel ? `${explainabilityFamily}:${explainabilityModel}` : explainabilityFamily}):`);
3093
+ for (const [index] of storage.accounts.entries()) {
3094
+ const details = explainabilityByIndex.get(index);
3095
+ if (!details)
3096
+ continue;
3097
+ const state = details.eligible ? "eligible" : "blocked";
3098
+ lines.push(` Account ${index + 1}: ${state} | health=${Math.round(details.healthScore)} | tokens=${details.tokensAvailable.toFixed(1)} | ${details.reasons.join(", ")}`);
3099
+ }
3100
+ lines.push("");
3101
+ lines.push(`Recommended next step: ${recommendBeginnerNextAction({
3102
+ accounts: toBeginnerAccountSnapshots(storage, activeIndex, now),
3103
+ now,
3104
+ runtime: getBeginnerRuntimeSnapshot(),
3105
+ })}`);
2347
3106
  return lines.join("\n");
2348
3107
  },
2349
3108
  }),
@@ -2356,6 +3115,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2356
3115
  const uptimeMs = Math.max(0, now - runtimeMetrics.startedAt);
2357
3116
  const total = runtimeMetrics.totalRequests;
2358
3117
  const successful = runtimeMetrics.successfulRequests;
3118
+ const refreshMetrics = getRefreshQueueMetrics();
2359
3119
  const successRate = total > 0 ? ((successful / total) * 100).toFixed(1) : "0.0";
2360
3120
  const avgLatencyMs = successful > 0
2361
3121
  ? Math.round(runtimeMetrics.cumulativeLatencyMs / successful)
@@ -2378,11 +3138,41 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2378
3138
  `Auth refresh failures: ${runtimeMetrics.authRefreshFailures}`,
2379
3139
  `Account rotations: ${runtimeMetrics.accountRotations}`,
2380
3140
  `Empty-response retries: ${runtimeMetrics.emptyResponseRetries}`,
3141
+ `Retry profile: ${runtimeMetrics.retryProfile}`,
3142
+ `Beginner safe mode: ${beginnerSafeModeEnabled ? "on" : "off"}`,
3143
+ `Retry budget exhaustions: ${runtimeMetrics.retryBudgetExhaustions}`,
3144
+ `Retry budget usage (auth/network/server/short/global/empty): ` +
3145
+ `${runtimeMetrics.retryBudgetUsage.authRefresh}/` +
3146
+ `${runtimeMetrics.retryBudgetUsage.network}/` +
3147
+ `${runtimeMetrics.retryBudgetUsage.server}/` +
3148
+ `${runtimeMetrics.retryBudgetUsage.rateLimitShort}/` +
3149
+ `${runtimeMetrics.retryBudgetUsage.rateLimitGlobal}/` +
3150
+ `${runtimeMetrics.retryBudgetUsage.emptyResponse}`,
3151
+ `Refresh queue (started/success/failed/pending): ` +
3152
+ `${refreshMetrics.started}/` +
3153
+ `${refreshMetrics.succeeded}/` +
3154
+ `${refreshMetrics.failed}/` +
3155
+ `${refreshMetrics.pending}`,
2381
3156
  `Last upstream request: ${lastRequest}`,
2382
3157
  ];
2383
3158
  if (runtimeMetrics.lastError) {
2384
3159
  lines.push(`Last error: ${runtimeMetrics.lastError}`);
2385
3160
  }
3161
+ if (runtimeMetrics.lastErrorCategory) {
3162
+ lines.push(`Last error category: ${runtimeMetrics.lastErrorCategory}`);
3163
+ }
3164
+ if (runtimeMetrics.lastSelectedAccountIndex !== null) {
3165
+ lines.push(`Last selected account: ${runtimeMetrics.lastSelectedAccountIndex + 1}`);
3166
+ }
3167
+ if (runtimeMetrics.lastQuotaKey) {
3168
+ lines.push(`Last quota key: ${runtimeMetrics.lastQuotaKey}`);
3169
+ }
3170
+ if (runtimeMetrics.lastRetryBudgetExhaustedClass) {
3171
+ lines.push(`Last budget exhaustion: ${runtimeMetrics.lastRetryBudgetExhaustedClass}` +
3172
+ (runtimeMetrics.lastRetryBudgetReason
3173
+ ? ` (${runtimeMetrics.lastRetryBudgetReason})`
3174
+ : ""));
3175
+ }
2386
3176
  if (ui.v2Enabled) {
2387
3177
  const styled = [
2388
3178
  ...formatUiHeader(ui, "Codex plugin metrics"),
@@ -2398,16 +3188,730 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2398
3188
  formatUiKeyValue(ui, "Auth refresh failures", String(runtimeMetrics.authRefreshFailures), "warning"),
2399
3189
  formatUiKeyValue(ui, "Account rotations", String(runtimeMetrics.accountRotations), "accent"),
2400
3190
  formatUiKeyValue(ui, "Empty-response retries", String(runtimeMetrics.emptyResponseRetries), "warning"),
3191
+ formatUiKeyValue(ui, "Retry profile", runtimeMetrics.retryProfile, "muted"),
3192
+ formatUiKeyValue(ui, "Beginner safe mode", beginnerSafeModeEnabled ? "on" : "off", beginnerSafeModeEnabled ? "accent" : "muted"),
3193
+ formatUiKeyValue(ui, "Retry budget exhaustions", String(runtimeMetrics.retryBudgetExhaustions), "warning"),
3194
+ formatUiKeyValue(ui, "Retry budget usage", `A${runtimeMetrics.retryBudgetUsage.authRefresh} N${runtimeMetrics.retryBudgetUsage.network} S${runtimeMetrics.retryBudgetUsage.server} RS${runtimeMetrics.retryBudgetUsage.rateLimitShort} RG${runtimeMetrics.retryBudgetUsage.rateLimitGlobal} E${runtimeMetrics.retryBudgetUsage.emptyResponse}`, "muted"),
3195
+ formatUiKeyValue(ui, "Retry budget limits", `A${runtimeMetrics.retryBudgetLimits.authRefresh} N${runtimeMetrics.retryBudgetLimits.network} S${runtimeMetrics.retryBudgetLimits.server} RS${runtimeMetrics.retryBudgetLimits.rateLimitShort} RG${runtimeMetrics.retryBudgetLimits.rateLimitGlobal} E${runtimeMetrics.retryBudgetLimits.emptyResponse}`, "muted"),
3196
+ formatUiKeyValue(ui, "Refresh queue", `started=${refreshMetrics.started} dedup=${refreshMetrics.deduplicated} reuse=${refreshMetrics.rotationReused} success=${refreshMetrics.succeeded} failed=${refreshMetrics.failed} pending=${refreshMetrics.pending}`, "muted"),
2401
3197
  formatUiKeyValue(ui, "Last upstream request", lastRequest, "muted"),
2402
3198
  ];
2403
3199
  if (runtimeMetrics.lastError) {
2404
3200
  styled.push(formatUiKeyValue(ui, "Last error", runtimeMetrics.lastError, "danger"));
2405
3201
  }
3202
+ if (runtimeMetrics.lastErrorCategory) {
3203
+ styled.push(formatUiKeyValue(ui, "Last error category", runtimeMetrics.lastErrorCategory, "warning"));
3204
+ }
3205
+ if (runtimeMetrics.lastSelectedAccountIndex !== null) {
3206
+ styled.push(formatUiKeyValue(ui, "Last selected account", String(runtimeMetrics.lastSelectedAccountIndex + 1), "accent"));
3207
+ }
3208
+ if (runtimeMetrics.lastQuotaKey) {
3209
+ styled.push(formatUiKeyValue(ui, "Last quota key", runtimeMetrics.lastQuotaKey, "muted"));
3210
+ }
3211
+ if (runtimeMetrics.lastRetryBudgetExhaustedClass) {
3212
+ styled.push(formatUiKeyValue(ui, "Last budget exhaustion", runtimeMetrics.lastRetryBudgetReason
3213
+ ? `${runtimeMetrics.lastRetryBudgetExhaustedClass} (${runtimeMetrics.lastRetryBudgetReason})`
3214
+ : runtimeMetrics.lastRetryBudgetExhaustedClass, "warning"));
3215
+ }
2406
3216
  return Promise.resolve(styled.join("\n"));
2407
3217
  }
2408
3218
  return Promise.resolve(lines.join("\n"));
2409
3219
  },
2410
3220
  }),
3221
+ "codex-help": tool({
3222
+ description: "Beginner-friendly command guide with quickstart and troubleshooting flows.",
3223
+ args: {
3224
+ topic: tool.schema
3225
+ .string()
3226
+ .optional()
3227
+ .describe("Optional topic: setup, switch, health, backup, dashboard, metrics."),
3228
+ },
3229
+ async execute({ topic }) {
3230
+ const ui = resolveUiRuntime();
3231
+ await Promise.resolve();
3232
+ const normalizedTopic = (topic ?? "").trim().toLowerCase();
3233
+ const sections = [
3234
+ {
3235
+ key: "setup",
3236
+ title: "Quickstart",
3237
+ lines: [
3238
+ "1) Add account: opencode auth login",
3239
+ "2) Verify account health: codex-health",
3240
+ "3) View account list: codex-list",
3241
+ "4) Run checklist: codex-setup",
3242
+ "5) Use guided wizard: codex-setup --wizard",
3243
+ "6) Start requests and monitor: codex-dashboard",
3244
+ ],
3245
+ },
3246
+ {
3247
+ key: "switch",
3248
+ title: "Daily account operations",
3249
+ lines: [
3250
+ "List accounts: codex-list",
3251
+ "Switch active account: codex-switch index=2",
3252
+ "Show detailed status: codex-status",
3253
+ "Set account label: codex-label index=2 label=\"Work\"",
3254
+ "Set account tags: codex-tag index=2 tags=\"work,team-a\"",
3255
+ "Set account note: codex-note index=2 note=\"weekday primary\"",
3256
+ "Filter by tag: codex-list tag=\"work\"",
3257
+ "Remove account: codex-remove index=2",
3258
+ ],
3259
+ },
3260
+ {
3261
+ key: "health",
3262
+ title: "Health and recovery",
3263
+ lines: [
3264
+ "Verify token health: codex-health",
3265
+ "Refresh all tokens: codex-refresh",
3266
+ "Run diagnostics: codex-doctor",
3267
+ "Run diagnostics with fixes: codex-doctor --fix",
3268
+ "Show best next action: codex-next",
3269
+ "Run guided wizard: codex-setup --wizard",
3270
+ ],
3271
+ },
3272
+ {
3273
+ key: "dashboard",
3274
+ title: "Monitoring",
3275
+ lines: [
3276
+ "Live dashboard: codex-dashboard",
3277
+ "Runtime metrics: codex-metrics",
3278
+ "Per-account status detail: codex-status",
3279
+ ],
3280
+ },
3281
+ {
3282
+ key: "backup",
3283
+ title: "Backup and migration",
3284
+ lines: [
3285
+ "Export accounts: codex-export <path>",
3286
+ "Auto backup export: codex-export",
3287
+ "Import preview: codex-import <path> --dryRun",
3288
+ "Import apply: codex-import <path>",
3289
+ "Setup checklist: codex-setup",
3290
+ ],
3291
+ },
3292
+ ];
3293
+ const visibleSections = normalizedTopic.length === 0
3294
+ ? sections
3295
+ : sections.filter((section) => section.key.includes(normalizedTopic));
3296
+ if (visibleSections.length === 0) {
3297
+ const available = sections.map((section) => section.key).join(", ");
3298
+ if (ui.v2Enabled) {
3299
+ return [
3300
+ ...formatUiHeader(ui, "Codex help"),
3301
+ "",
3302
+ formatUiItem(ui, `Unknown topic: ${normalizedTopic}`, "warning"),
3303
+ formatUiItem(ui, `Available topics: ${available}`, "muted"),
3304
+ ].join("\n");
3305
+ }
3306
+ return `Unknown topic: ${normalizedTopic}\n\nAvailable topics: ${available}`;
3307
+ }
3308
+ if (ui.v2Enabled) {
3309
+ const lines = [...formatUiHeader(ui, "Codex help"), ""];
3310
+ for (const section of visibleSections) {
3311
+ lines.push(...formatUiSection(ui, section.title));
3312
+ for (const line of section.lines) {
3313
+ lines.push(formatUiItem(ui, line));
3314
+ }
3315
+ lines.push("");
3316
+ }
3317
+ lines.push(...formatUiSection(ui, "Tips"));
3318
+ lines.push(formatUiItem(ui, "Run codex-setup after adding accounts."));
3319
+ lines.push(formatUiItem(ui, "Use codex-setup --wizard for menu-driven onboarding."));
3320
+ lines.push(formatUiItem(ui, "Use codex-doctor when request failures increase."));
3321
+ return lines.join("\n").trimEnd();
3322
+ }
3323
+ const lines = ["Codex Help:", ""];
3324
+ for (const section of visibleSections) {
3325
+ lines.push(`${section.title}:`);
3326
+ for (const line of section.lines) {
3327
+ lines.push(` - ${line}`);
3328
+ }
3329
+ lines.push("");
3330
+ }
3331
+ lines.push("Tips:");
3332
+ lines.push(" - Run codex-setup after adding accounts.");
3333
+ lines.push(" - Use codex-setup --wizard for menu-driven onboarding.");
3334
+ lines.push(" - Use codex-doctor when request failures increase.");
3335
+ return lines.join("\n");
3336
+ },
3337
+ }),
3338
+ "codex-setup": tool({
3339
+ description: "Beginner checklist for first-time setup and account readiness.",
3340
+ args: {
3341
+ wizard: tool.schema
3342
+ .boolean()
3343
+ .optional()
3344
+ .describe("Launch menu-driven setup wizard when terminal supports it."),
3345
+ },
3346
+ async execute({ wizard } = {}) {
3347
+ const ui = resolveUiRuntime();
3348
+ const state = await buildSetupChecklistState();
3349
+ if (wizard) {
3350
+ return runSetupWizard(ui, state);
3351
+ }
3352
+ return renderSetupChecklistOutput(ui, state);
3353
+ },
3354
+ }),
3355
+ "codex-doctor": tool({
3356
+ description: "Run beginner-friendly diagnostics with clear fixes.",
3357
+ args: {
3358
+ deep: tool.schema
3359
+ .boolean()
3360
+ .optional()
3361
+ .describe("Include technical snapshot details (default: false)."),
3362
+ fix: tool.schema
3363
+ .boolean()
3364
+ .optional()
3365
+ .describe("Apply safe automated fixes (refresh tokens and switch to healthiest eligible account)."),
3366
+ },
3367
+ async execute({ deep, fix } = {}) {
3368
+ const ui = resolveUiRuntime();
3369
+ const storage = await loadAccounts();
3370
+ const now = Date.now();
3371
+ const activeIndex = storage && storage.accounts.length > 0
3372
+ ? resolveActiveIndex(storage, "codex")
3373
+ : 0;
3374
+ const snapshots = storage
3375
+ ? toBeginnerAccountSnapshots(storage, activeIndex, now)
3376
+ : [];
3377
+ const runtime = getBeginnerRuntimeSnapshot();
3378
+ const summary = summarizeBeginnerAccounts(snapshots, now);
3379
+ const findings = buildBeginnerDoctorFindings({
3380
+ accounts: snapshots,
3381
+ now,
3382
+ runtime,
3383
+ });
3384
+ const nextAction = recommendBeginnerNextAction({ accounts: snapshots, now, runtime });
3385
+ const appliedFixes = [];
3386
+ const fixErrors = [];
3387
+ if (fix && storage && storage.accounts.length > 0) {
3388
+ let changedByRefresh = false;
3389
+ let refreshedCount = 0;
3390
+ for (const account of storage.accounts) {
3391
+ try {
3392
+ const refreshResult = await queuedRefresh(account.refreshToken);
3393
+ if (refreshResult.type === "success") {
3394
+ account.refreshToken = refreshResult.refresh;
3395
+ account.accessToken = refreshResult.access;
3396
+ account.expiresAt = refreshResult.expires;
3397
+ changedByRefresh = true;
3398
+ refreshedCount += 1;
3399
+ }
3400
+ }
3401
+ catch (error) {
3402
+ fixErrors.push(error instanceof Error ? error.message : String(error));
3403
+ }
3404
+ }
3405
+ if (changedByRefresh) {
3406
+ try {
3407
+ await saveAccounts(storage);
3408
+ appliedFixes.push(`Refreshed ${refreshedCount} account token(s).`);
3409
+ }
3410
+ catch (error) {
3411
+ fixErrors.push(`Failed to persist refresh updates: ${error instanceof Error ? error.message : String(error)}`);
3412
+ }
3413
+ }
3414
+ try {
3415
+ const managerForFix = await AccountManager.loadFromDisk();
3416
+ const explainability = managerForFix.getSelectionExplainability("codex", undefined, Date.now());
3417
+ const eligible = explainability
3418
+ .filter((entry) => entry.eligible)
3419
+ .sort((a, b) => {
3420
+ if (b.healthScore !== a.healthScore)
3421
+ return b.healthScore - a.healthScore;
3422
+ return b.tokensAvailable - a.tokensAvailable;
3423
+ });
3424
+ const best = eligible[0];
3425
+ if (best) {
3426
+ const currentActive = resolveActiveIndex(storage, "codex");
3427
+ if (best.index !== currentActive) {
3428
+ storage.activeIndex = best.index;
3429
+ storage.activeIndexByFamily = storage.activeIndexByFamily ?? {};
3430
+ for (const family of MODEL_FAMILIES) {
3431
+ storage.activeIndexByFamily[family] = best.index;
3432
+ }
3433
+ await saveAccounts(storage);
3434
+ appliedFixes.push(`Switched active account to ${best.index + 1} (best eligible).`);
3435
+ }
3436
+ }
3437
+ else {
3438
+ appliedFixes.push("No eligible account available for auto-switch.");
3439
+ }
3440
+ }
3441
+ catch (error) {
3442
+ fixErrors.push(`Auto-switch evaluation failed: ${error instanceof Error ? error.message : String(error)}`);
3443
+ }
3444
+ if (cachedAccountManager) {
3445
+ const reloadedManager = await AccountManager.loadFromDisk();
3446
+ cachedAccountManager = reloadedManager;
3447
+ accountManagerPromise = Promise.resolve(reloadedManager);
3448
+ }
3449
+ }
3450
+ if (ui.v2Enabled) {
3451
+ const lines = [
3452
+ ...formatUiHeader(ui, "Codex doctor"),
3453
+ formatUiKeyValue(ui, "Accounts", String(summary.total)),
3454
+ formatUiKeyValue(ui, "Healthy", String(summary.healthy), summary.healthy > 0 ? "success" : "warning"),
3455
+ formatUiKeyValue(ui, "Blocked", String(summary.blocked), summary.blocked > 0 ? "warning" : "muted"),
3456
+ formatUiKeyValue(ui, "Failure rate", runtime.totalRequests > 0 ? `${Math.round((runtime.failedRequests / runtime.totalRequests) * 100)}%` : "0%"),
3457
+ "",
3458
+ ...formatUiSection(ui, "Findings"),
3459
+ ];
3460
+ for (const finding of findings) {
3461
+ const tone = finding.severity === "ok"
3462
+ ? "success"
3463
+ : finding.severity === "warning"
3464
+ ? "warning"
3465
+ : "danger";
3466
+ lines.push(formatUiItem(ui, `${formatDoctorSeverity(ui, finding.severity)} ${finding.summary}`, tone));
3467
+ lines.push(` ${formatUiKeyValue(ui, "fix", finding.action, "muted")}`);
3468
+ }
3469
+ lines.push("");
3470
+ lines.push(...formatUiSection(ui, "Recommended next step"));
3471
+ lines.push(formatUiItem(ui, nextAction, "accent"));
3472
+ if (fix) {
3473
+ lines.push("");
3474
+ lines.push(...formatUiSection(ui, "Auto-fix"));
3475
+ if (appliedFixes.length === 0) {
3476
+ lines.push(formatUiItem(ui, "No safe fixes were applied.", "muted"));
3477
+ }
3478
+ else {
3479
+ for (const entry of appliedFixes) {
3480
+ lines.push(formatUiItem(ui, entry, "success"));
3481
+ }
3482
+ }
3483
+ for (const error of fixErrors) {
3484
+ lines.push(formatUiItem(ui, error, "warning"));
3485
+ }
3486
+ }
3487
+ if (deep) {
3488
+ lines.push("");
3489
+ lines.push(...formatUiSection(ui, "Technical snapshot"));
3490
+ lines.push(formatUiKeyValue(ui, "Storage", getStoragePath(), "muted"));
3491
+ lines.push(formatUiKeyValue(ui, "Runtime failures", `failed=${runtime.failedRequests}, rateLimited=${runtime.rateLimitedResponses}, authRefreshFailed=${runtime.authRefreshFailures}, server=${runtime.serverErrors}, network=${runtime.networkErrors}`, "muted"));
3492
+ }
3493
+ return lines.join("\n");
3494
+ }
3495
+ const lines = [
3496
+ "Codex Doctor:",
3497
+ `Accounts: ${summary.total} (healthy=${summary.healthy}, blocked=${summary.blocked})`,
3498
+ `Failure rate: ${runtime.totalRequests > 0 ? Math.round((runtime.failedRequests / runtime.totalRequests) * 100) : 0}%`,
3499
+ "",
3500
+ "Findings:",
3501
+ ];
3502
+ for (const finding of findings) {
3503
+ lines.push(` ${formatDoctorSeverityText(finding.severity)} ${finding.summary}`);
3504
+ lines.push(` fix: ${finding.action}`);
3505
+ }
3506
+ lines.push("");
3507
+ lines.push(`Recommended next step: ${nextAction}`);
3508
+ if (fix) {
3509
+ lines.push("");
3510
+ lines.push("Auto-fix:");
3511
+ if (appliedFixes.length === 0) {
3512
+ lines.push(" - No safe fixes were applied.");
3513
+ }
3514
+ else {
3515
+ for (const entry of appliedFixes) {
3516
+ lines.push(` - ${entry}`);
3517
+ }
3518
+ }
3519
+ for (const error of fixErrors) {
3520
+ lines.push(` - warning: ${error}`);
3521
+ }
3522
+ }
3523
+ if (deep) {
3524
+ lines.push("");
3525
+ lines.push("Technical snapshot:");
3526
+ lines.push(` Storage: ${getStoragePath()}`);
3527
+ lines.push(` Runtime failures: failed=${runtime.failedRequests}, rateLimited=${runtime.rateLimitedResponses}, authRefreshFailed=${runtime.authRefreshFailures}, server=${runtime.serverErrors}, network=${runtime.networkErrors}`);
3528
+ }
3529
+ return lines.join("\n");
3530
+ },
3531
+ }),
3532
+ "codex-next": tool({
3533
+ description: "Show the single most recommended next action for beginners.",
3534
+ args: {},
3535
+ async execute() {
3536
+ const ui = resolveUiRuntime();
3537
+ const storage = await loadAccounts();
3538
+ const now = Date.now();
3539
+ const activeIndex = storage && storage.accounts.length > 0
3540
+ ? resolveActiveIndex(storage, "codex")
3541
+ : 0;
3542
+ const snapshots = storage
3543
+ ? toBeginnerAccountSnapshots(storage, activeIndex, now)
3544
+ : [];
3545
+ const action = recommendBeginnerNextAction({
3546
+ accounts: snapshots,
3547
+ now,
3548
+ runtime: getBeginnerRuntimeSnapshot(),
3549
+ });
3550
+ if (ui.v2Enabled) {
3551
+ return [
3552
+ ...formatUiHeader(ui, "Recommended next action"),
3553
+ "",
3554
+ formatUiItem(ui, action, "accent"),
3555
+ ].join("\n");
3556
+ }
3557
+ return `Recommended next action:\n${action}`;
3558
+ },
3559
+ }),
3560
+ "codex-label": tool({
3561
+ description: "Set or clear a beginner-friendly display label for an account (interactive picker when index is omitted).",
3562
+ args: {
3563
+ index: tool.schema.number().optional().describe("Account number to update (1-based, e.g., 1 for first account)"),
3564
+ label: tool.schema.string().describe("Display label. Use an empty string to clear (e.g., Work, Personal, Team A)"),
3565
+ },
3566
+ async execute({ index, label }) {
3567
+ const ui = resolveUiRuntime();
3568
+ const storage = await loadAccounts();
3569
+ if (!storage || storage.accounts.length === 0) {
3570
+ if (ui.v2Enabled) {
3571
+ return [
3572
+ ...formatUiHeader(ui, "Set account label"),
3573
+ "",
3574
+ formatUiItem(ui, "No accounts configured.", "warning"),
3575
+ formatUiItem(ui, "Run: opencode auth login", "accent"),
3576
+ ].join("\n");
3577
+ }
3578
+ return "No Codex accounts configured. Run: opencode auth login";
3579
+ }
3580
+ let resolvedIndex = index;
3581
+ if (resolvedIndex === undefined) {
3582
+ const selectedIndex = await promptAccountIndexSelection(ui, storage, "Set account label");
3583
+ if (selectedIndex === null) {
3584
+ if (supportsInteractiveMenus()) {
3585
+ if (ui.v2Enabled) {
3586
+ return [
3587
+ ...formatUiHeader(ui, "Set account label"),
3588
+ "",
3589
+ formatUiItem(ui, "No account selected.", "warning"),
3590
+ formatUiItem(ui, "Run again and pick an account, or pass codex-label index=2 label=\"Work\".", "muted"),
3591
+ ].join("\n");
3592
+ }
3593
+ return "No account selected.";
3594
+ }
3595
+ if (ui.v2Enabled) {
3596
+ return [
3597
+ ...formatUiHeader(ui, "Set account label"),
3598
+ "",
3599
+ formatUiItem(ui, "Missing account number.", "warning"),
3600
+ formatUiItem(ui, "Use: codex-label index=2 label=\"Work\"", "accent"),
3601
+ ].join("\n");
3602
+ }
3603
+ return "Missing account number. Use: codex-label index=2 label=\"Work\"";
3604
+ }
3605
+ resolvedIndex = selectedIndex + 1;
3606
+ }
3607
+ const targetIndex = Math.floor((resolvedIndex ?? 0) - 1);
3608
+ if (!Number.isFinite(targetIndex) ||
3609
+ targetIndex < 0 ||
3610
+ targetIndex >= storage.accounts.length) {
3611
+ if (ui.v2Enabled) {
3612
+ return [
3613
+ ...formatUiHeader(ui, "Set account label"),
3614
+ "",
3615
+ formatUiItem(ui, `Invalid account number: ${resolvedIndex}`, "danger"),
3616
+ formatUiKeyValue(ui, "Valid range", `1-${storage.accounts.length}`, "muted"),
3617
+ ].join("\n");
3618
+ }
3619
+ return `Invalid account number: ${resolvedIndex}\n\nValid range: 1-${storage.accounts.length}`;
3620
+ }
3621
+ const normalizedLabel = (label ?? "").trim().replace(/\s+/g, " ");
3622
+ if (normalizedLabel.length > 60) {
3623
+ if (ui.v2Enabled) {
3624
+ return [
3625
+ ...formatUiHeader(ui, "Set account label"),
3626
+ "",
3627
+ formatUiItem(ui, "Label is too long (max 60 characters).", "danger"),
3628
+ ].join("\n");
3629
+ }
3630
+ return "Label is too long (max 60 characters).";
3631
+ }
3632
+ const account = storage.accounts[targetIndex];
3633
+ if (!account) {
3634
+ return `Account ${resolvedIndex} not found.`;
3635
+ }
3636
+ const previousLabel = account.accountLabel?.trim() ?? "";
3637
+ if (normalizedLabel.length === 0) {
3638
+ delete account.accountLabel;
3639
+ }
3640
+ else {
3641
+ account.accountLabel = normalizedLabel;
3642
+ }
3643
+ try {
3644
+ await saveAccounts(storage);
3645
+ }
3646
+ catch (saveError) {
3647
+ logWarn("Failed to save account label update", { error: String(saveError) });
3648
+ if (ui.v2Enabled) {
3649
+ return [
3650
+ ...formatUiHeader(ui, "Set account label"),
3651
+ "",
3652
+ formatUiItem(ui, "Label updated in memory but failed to persist.", "danger"),
3653
+ ].join("\n");
3654
+ }
3655
+ return "Label updated in memory but failed to persist. Changes may be lost on restart.";
3656
+ }
3657
+ if (cachedAccountManager) {
3658
+ const reloadedManager = await AccountManager.loadFromDisk();
3659
+ cachedAccountManager = reloadedManager;
3660
+ accountManagerPromise = Promise.resolve(reloadedManager);
3661
+ }
3662
+ const accountLabel = formatCommandAccountLabel(account, targetIndex);
3663
+ if (ui.v2Enabled) {
3664
+ const statusText = normalizedLabel.length === 0
3665
+ ? `Cleared label for ${accountLabel}`
3666
+ : `Set label for ${accountLabel} to "${normalizedLabel}"`;
3667
+ const previousText = previousLabel.length > 0
3668
+ ? formatUiKeyValue(ui, "Previous label", previousLabel, "muted")
3669
+ : formatUiKeyValue(ui, "Previous label", "none", "muted");
3670
+ return [
3671
+ ...formatUiHeader(ui, "Set account label"),
3672
+ "",
3673
+ formatUiItem(ui, `${getStatusMarker(ui, "ok")} ${statusText}`, "success"),
3674
+ previousText,
3675
+ ].join("\n");
3676
+ }
3677
+ if (normalizedLabel.length === 0) {
3678
+ return `Cleared label for ${accountLabel}`;
3679
+ }
3680
+ return `Set label for ${accountLabel} to "${normalizedLabel}"`;
3681
+ },
3682
+ }),
3683
+ "codex-tag": tool({
3684
+ description: "Set or clear account tags for filtering and grouping.",
3685
+ args: {
3686
+ index: tool.schema.number().optional().describe("Account number to update (1-based, e.g., 1 for first account)"),
3687
+ tags: tool.schema.string().describe("Comma-separated tags (e.g., work,team-a). Empty string clears tags."),
3688
+ },
3689
+ async execute({ index, tags }) {
3690
+ const ui = resolveUiRuntime();
3691
+ const storage = await loadAccounts();
3692
+ if (!storage || storage.accounts.length === 0) {
3693
+ if (ui.v2Enabled) {
3694
+ return [
3695
+ ...formatUiHeader(ui, "Set account tags"),
3696
+ "",
3697
+ formatUiItem(ui, "No accounts configured.", "warning"),
3698
+ formatUiItem(ui, "Run: opencode auth login", "accent"),
3699
+ ].join("\n");
3700
+ }
3701
+ return "No Codex accounts configured. Run: opencode auth login";
3702
+ }
3703
+ let resolvedIndex = index;
3704
+ if (resolvedIndex === undefined) {
3705
+ const selectedIndex = await promptAccountIndexSelection(ui, storage, "Set account tags");
3706
+ if (selectedIndex === null) {
3707
+ if (supportsInteractiveMenus()) {
3708
+ return ui.v2Enabled
3709
+ ? [
3710
+ ...formatUiHeader(ui, "Set account tags"),
3711
+ "",
3712
+ formatUiItem(ui, "No account selected.", "warning"),
3713
+ ].join("\n")
3714
+ : "No account selected.";
3715
+ }
3716
+ return "Missing account number. Use: codex-tag index=2 tags=\"work,team-a\"";
3717
+ }
3718
+ resolvedIndex = selectedIndex + 1;
3719
+ }
3720
+ const targetIndex = Math.floor((resolvedIndex ?? 0) - 1);
3721
+ if (!Number.isFinite(targetIndex) ||
3722
+ targetIndex < 0 ||
3723
+ targetIndex >= storage.accounts.length) {
3724
+ return `Invalid account number: ${resolvedIndex}\n\nValid range: 1-${storage.accounts.length}`;
3725
+ }
3726
+ const account = storage.accounts[targetIndex];
3727
+ if (!account)
3728
+ return `Account ${resolvedIndex} not found.`;
3729
+ const normalizedTags = normalizeAccountTags(tags ?? "");
3730
+ const previousTags = Array.isArray(account.accountTags)
3731
+ ? [...account.accountTags]
3732
+ : [];
3733
+ if (normalizedTags.length === 0) {
3734
+ delete account.accountTags;
3735
+ }
3736
+ else {
3737
+ account.accountTags = normalizedTags;
3738
+ }
3739
+ try {
3740
+ await saveAccounts(storage);
3741
+ }
3742
+ catch (error) {
3743
+ logWarn("Failed to save account tag update", { error: String(error) });
3744
+ return "Tag update failed to persist. Changes may be lost on restart.";
3745
+ }
3746
+ if (cachedAccountManager) {
3747
+ const reloadedManager = await AccountManager.loadFromDisk();
3748
+ cachedAccountManager = reloadedManager;
3749
+ accountManagerPromise = Promise.resolve(reloadedManager);
3750
+ }
3751
+ const accountLabel = formatCommandAccountLabel(account, targetIndex);
3752
+ const previousText = previousTags.length > 0 ? previousTags.join(", ") : "none";
3753
+ const nextText = normalizedTags.length > 0 ? normalizedTags.join(", ") : "none";
3754
+ if (ui.v2Enabled) {
3755
+ return [
3756
+ ...formatUiHeader(ui, "Set account tags"),
3757
+ "",
3758
+ formatUiItem(ui, `${getStatusMarker(ui, "ok")} Updated tags for ${accountLabel}`, "success"),
3759
+ formatUiKeyValue(ui, "Previous tags", previousText, "muted"),
3760
+ formatUiKeyValue(ui, "Current tags", nextText, normalizedTags.length > 0 ? "accent" : "muted"),
3761
+ ].join("\n");
3762
+ }
3763
+ return `Updated tags for ${accountLabel}\nPrevious tags: ${previousText}\nCurrent tags: ${nextText}`;
3764
+ },
3765
+ }),
3766
+ "codex-note": tool({
3767
+ description: "Set or clear an account note for reminders.",
3768
+ args: {
3769
+ index: tool.schema.number().optional().describe("Account number to update (1-based, e.g., 1 for first account)"),
3770
+ note: tool.schema.string().describe("Short note. Empty string clears the note."),
3771
+ },
3772
+ async execute({ index, note }) {
3773
+ const ui = resolveUiRuntime();
3774
+ const storage = await loadAccounts();
3775
+ if (!storage || storage.accounts.length === 0) {
3776
+ return "No Codex accounts configured. Run: opencode auth login";
3777
+ }
3778
+ let resolvedIndex = index;
3779
+ if (resolvedIndex === undefined) {
3780
+ const selectedIndex = await promptAccountIndexSelection(ui, storage, "Set account note");
3781
+ if (selectedIndex === null) {
3782
+ if (supportsInteractiveMenus())
3783
+ return "No account selected.";
3784
+ return "Missing account number. Use: codex-note index=2 note=\"weekday primary\"";
3785
+ }
3786
+ resolvedIndex = selectedIndex + 1;
3787
+ }
3788
+ const targetIndex = Math.floor((resolvedIndex ?? 0) - 1);
3789
+ if (!Number.isFinite(targetIndex) ||
3790
+ targetIndex < 0 ||
3791
+ targetIndex >= storage.accounts.length) {
3792
+ return `Invalid account number: ${resolvedIndex}\n\nValid range: 1-${storage.accounts.length}`;
3793
+ }
3794
+ const account = storage.accounts[targetIndex];
3795
+ if (!account)
3796
+ return `Account ${resolvedIndex} not found.`;
3797
+ const normalizedNote = (note ?? "").trim();
3798
+ if (normalizedNote.length > 240) {
3799
+ return "Note is too long (max 240 characters).";
3800
+ }
3801
+ if (normalizedNote.length === 0) {
3802
+ delete account.accountNote;
3803
+ }
3804
+ else {
3805
+ account.accountNote = normalizedNote;
3806
+ }
3807
+ try {
3808
+ await saveAccounts(storage);
3809
+ }
3810
+ catch (error) {
3811
+ logWarn("Failed to save account note update", { error: String(error) });
3812
+ return "Note update failed to persist. Changes may be lost on restart.";
3813
+ }
3814
+ if (cachedAccountManager) {
3815
+ const reloadedManager = await AccountManager.loadFromDisk();
3816
+ cachedAccountManager = reloadedManager;
3817
+ accountManagerPromise = Promise.resolve(reloadedManager);
3818
+ }
3819
+ const accountLabel = formatCommandAccountLabel(account, targetIndex);
3820
+ if (normalizedNote.length === 0) {
3821
+ return `Cleared note for ${accountLabel}`;
3822
+ }
3823
+ return `Saved note for ${accountLabel}: ${normalizedNote}`;
3824
+ },
3825
+ }),
3826
+ "codex-dashboard": tool({
3827
+ description: "Show a live Codex dashboard: account eligibility, retry budgets, and refresh queue health.",
3828
+ args: {},
3829
+ async execute() {
3830
+ const ui = resolveUiRuntime();
3831
+ const storage = await loadAccounts();
3832
+ if (!storage || storage.accounts.length === 0) {
3833
+ if (ui.v2Enabled) {
3834
+ return [
3835
+ ...formatUiHeader(ui, "Codex dashboard"),
3836
+ "",
3837
+ formatUiItem(ui, "No accounts configured.", "warning"),
3838
+ formatUiItem(ui, "Run: opencode auth login", "accent"),
3839
+ ].join("\n");
3840
+ }
3841
+ return "No Codex accounts configured. Run: opencode auth login";
3842
+ }
3843
+ const now = Date.now();
3844
+ const refreshMetrics = getRefreshQueueMetrics();
3845
+ const family = runtimeMetrics.lastSelectionSnapshot?.family ?? "codex";
3846
+ const model = runtimeMetrics.lastSelectionSnapshot?.model ?? undefined;
3847
+ const manager = cachedAccountManager ?? (await AccountManager.loadFromDisk());
3848
+ const explainability = manager.getSelectionExplainability(family, model, now);
3849
+ const selectionLabel = model ? `${family}:${model}` : family;
3850
+ if (ui.v2Enabled) {
3851
+ const lines = [
3852
+ ...formatUiHeader(ui, "Codex dashboard"),
3853
+ formatUiKeyValue(ui, "Accounts", String(storage.accounts.length)),
3854
+ formatUiKeyValue(ui, "Selection lens", selectionLabel, "muted"),
3855
+ formatUiKeyValue(ui, "Retry profile", runtimeMetrics.retryProfile, "muted"),
3856
+ formatUiKeyValue(ui, "Beginner safe mode", beginnerSafeModeEnabled ? "on" : "off", beginnerSafeModeEnabled ? "accent" : "muted"),
3857
+ formatUiKeyValue(ui, "Retry usage", `A${runtimeMetrics.retryBudgetUsage.authRefresh} N${runtimeMetrics.retryBudgetUsage.network} S${runtimeMetrics.retryBudgetUsage.server} RS${runtimeMetrics.retryBudgetUsage.rateLimitShort} RG${runtimeMetrics.retryBudgetUsage.rateLimitGlobal} E${runtimeMetrics.retryBudgetUsage.emptyResponse}`, "muted"),
3858
+ formatUiKeyValue(ui, "Refresh queue", `pending=${refreshMetrics.pending}, success=${refreshMetrics.succeeded}, failed=${refreshMetrics.failed}`, "muted"),
3859
+ "",
3860
+ ...formatUiSection(ui, "Account eligibility"),
3861
+ ];
3862
+ for (const entry of explainability) {
3863
+ const label = formatCommandAccountLabel(storage.accounts[entry.index], entry.index);
3864
+ const state = entry.eligible ? formatUiBadge(ui, "eligible", "success") : formatUiBadge(ui, "blocked", "warning");
3865
+ lines.push(formatUiItem(ui, `${label} ${state} health=${Math.round(entry.healthScore)} tokens=${entry.tokensAvailable.toFixed(1)} reasons=${entry.reasons.join(", ")}`));
3866
+ }
3867
+ lines.push("");
3868
+ lines.push(...formatUiSection(ui, "Recommended next step"));
3869
+ lines.push(formatUiItem(ui, recommendBeginnerNextAction({
3870
+ accounts: toBeginnerAccountSnapshots(storage, resolveActiveIndex(storage, "codex"), now),
3871
+ now,
3872
+ runtime: getBeginnerRuntimeSnapshot(),
3873
+ }), "accent"));
3874
+ if (runtimeMetrics.lastError) {
3875
+ lines.push("");
3876
+ lines.push(...formatUiSection(ui, "Last error"));
3877
+ lines.push(formatUiItem(ui, runtimeMetrics.lastError, "danger"));
3878
+ if (runtimeMetrics.lastErrorCategory) {
3879
+ lines.push(formatUiKeyValue(ui, "Category", runtimeMetrics.lastErrorCategory, "warning"));
3880
+ }
3881
+ }
3882
+ return lines.join("\n");
3883
+ }
3884
+ const lines = [
3885
+ "Codex Dashboard:",
3886
+ `Accounts: ${storage.accounts.length}`,
3887
+ `Selection lens: ${selectionLabel}`,
3888
+ `Retry profile: ${runtimeMetrics.retryProfile}`,
3889
+ `Beginner safe mode: ${beginnerSafeModeEnabled ? "on" : "off"}`,
3890
+ `Retry usage: auth=${runtimeMetrics.retryBudgetUsage.authRefresh}, network=${runtimeMetrics.retryBudgetUsage.network}, server=${runtimeMetrics.retryBudgetUsage.server}, short429=${runtimeMetrics.retryBudgetUsage.rateLimitShort}, global429=${runtimeMetrics.retryBudgetUsage.rateLimitGlobal}, empty=${runtimeMetrics.retryBudgetUsage.emptyResponse}`,
3891
+ `Refresh queue: pending=${refreshMetrics.pending}, success=${refreshMetrics.succeeded}, failed=${refreshMetrics.failed}`,
3892
+ "",
3893
+ "Account eligibility:",
3894
+ ];
3895
+ for (const entry of explainability) {
3896
+ const label = formatCommandAccountLabel(storage.accounts[entry.index], entry.index);
3897
+ lines.push(` - ${label}: ${entry.eligible ? "eligible" : "blocked"} | health=${Math.round(entry.healthScore)} | tokens=${entry.tokensAvailable.toFixed(1)} | reasons=${entry.reasons.join(", ")}`);
3898
+ }
3899
+ lines.push("");
3900
+ lines.push(`Recommended next step: ${recommendBeginnerNextAction({
3901
+ accounts: toBeginnerAccountSnapshots(storage, resolveActiveIndex(storage, "codex"), now),
3902
+ now,
3903
+ runtime: getBeginnerRuntimeSnapshot(),
3904
+ })}`);
3905
+ if (runtimeMetrics.lastError) {
3906
+ lines.push("");
3907
+ lines.push(`Last error: ${runtimeMetrics.lastError}`);
3908
+ if (runtimeMetrics.lastErrorCategory) {
3909
+ lines.push(`Category: ${runtimeMetrics.lastErrorCategory}`);
3910
+ }
3911
+ }
3912
+ return lines.join("\n");
3913
+ },
3914
+ }),
2411
3915
  "codex-health": tool({
2412
3916
  description: "Check health of all Codex accounts by validating refresh tokens.",
2413
3917
  args: {},
@@ -2465,11 +3969,11 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2465
3969
  },
2466
3970
  }),
2467
3971
  "codex-remove": tool({
2468
- description: "Remove one Codex account entry by index (1-based). Use codex-list to list accounts first.",
3972
+ description: "Remove one Codex account entry by index (1-based) or interactive picker when index is omitted.",
2469
3973
  args: {
2470
- index: tool.schema.number().describe("Account number to remove (1-based, e.g., 1 for first account)"),
3974
+ index: tool.schema.number().optional().describe("Account number to remove (1-based, e.g., 1 for first account)"),
2471
3975
  },
2472
- async execute({ index }) {
3976
+ async execute({ index } = {}) {
2473
3977
  const ui = resolveUiRuntime();
2474
3978
  const storage = await loadAccounts();
2475
3979
  if (!storage || storage.accounts.length === 0) {
@@ -2482,7 +3986,34 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2482
3986
  }
2483
3987
  return "No Codex accounts configured. Nothing to remove.";
2484
3988
  }
2485
- const targetIndex = Math.floor((index ?? 0) - 1);
3989
+ let resolvedIndex = index;
3990
+ if (resolvedIndex === undefined) {
3991
+ const selectedIndex = await promptAccountIndexSelection(ui, storage, "Remove account");
3992
+ if (selectedIndex === null) {
3993
+ if (supportsInteractiveMenus()) {
3994
+ if (ui.v2Enabled) {
3995
+ return [
3996
+ ...formatUiHeader(ui, "Remove account"),
3997
+ "",
3998
+ formatUiItem(ui, "No account selected.", "warning"),
3999
+ formatUiItem(ui, "Run again and pick an account, or pass codex-remove index=2.", "muted"),
4000
+ ].join("\n");
4001
+ }
4002
+ return "No account selected.";
4003
+ }
4004
+ if (ui.v2Enabled) {
4005
+ return [
4006
+ ...formatUiHeader(ui, "Remove account"),
4007
+ "",
4008
+ formatUiItem(ui, "Missing account number.", "warning"),
4009
+ formatUiItem(ui, "Use: codex-remove index=2", "accent"),
4010
+ ].join("\n");
4011
+ }
4012
+ return "Missing account number. Use: codex-remove index=2";
4013
+ }
4014
+ resolvedIndex = selectedIndex + 1;
4015
+ }
4016
+ const targetIndex = Math.floor((resolvedIndex ?? 0) - 1);
2486
4017
  if (!Number.isFinite(targetIndex) ||
2487
4018
  targetIndex < 0 ||
2488
4019
  targetIndex >= storage.accounts.length) {
@@ -2490,16 +4021,16 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2490
4021
  return [
2491
4022
  ...formatUiHeader(ui, "Remove account"),
2492
4023
  "",
2493
- formatUiItem(ui, `Invalid account number: ${index}`, "danger"),
4024
+ formatUiItem(ui, `Invalid account number: ${resolvedIndex}`, "danger"),
2494
4025
  formatUiKeyValue(ui, "Valid range", `1-${storage.accounts.length}`, "muted"),
2495
4026
  formatUiItem(ui, "Use codex-list to list all accounts.", "accent"),
2496
4027
  ].join("\n");
2497
4028
  }
2498
- return `Invalid account number: ${index}\n\nValid range: 1-${storage.accounts.length}\n\nUse codex-list to list all accounts.`;
4029
+ return `Invalid account number: ${resolvedIndex}\n\nValid range: 1-${storage.accounts.length}\n\nUse codex-list to list all accounts.`;
2499
4030
  }
2500
4031
  const account = storage.accounts[targetIndex];
2501
4032
  if (!account) {
2502
- return `Account ${index} not found.`;
4033
+ return `Account ${resolvedIndex} not found.`;
2503
4034
  }
2504
4035
  const label = formatCommandAccountLabel(account, targetIndex);
2505
4036
  storage.accounts.splice(targetIndex, 1);
@@ -2646,15 +4177,22 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2646
4177
  },
2647
4178
  }),
2648
4179
  "codex-export": tool({
2649
- description: "Export accounts to a JSON file for backup or migration to another machine.",
4180
+ description: "Export accounts to a JSON file for backup or migration. Can auto-generate timestamped backup paths.",
2650
4181
  args: {
2651
- path: tool.schema.string().describe("File path to export to (e.g., ~/codex-backup.json)"),
4182
+ path: tool.schema.string().optional().describe("File path to export to (e.g., ~/codex-backup.json). If omitted, a timestamped backup path is used."),
2652
4183
  force: tool.schema.boolean().optional().describe("Overwrite existing file (default: true)"),
4184
+ timestamped: tool.schema.boolean().optional().describe("When true (default), omitted paths use a timestamped backup filename."),
2653
4185
  },
2654
- async execute({ path: filePath, force }) {
4186
+ async execute({ path: filePath, force, timestamped, }) {
2655
4187
  const ui = resolveUiRuntime();
4188
+ const shouldTimestamp = timestamped ?? true;
4189
+ const resolvedExportPath = filePath && filePath.trim().length > 0
4190
+ ? filePath
4191
+ : shouldTimestamp
4192
+ ? createTimestampedBackupPath()
4193
+ : "codex-backup.json";
2656
4194
  try {
2657
- await exportAccounts(filePath, force ?? true);
4195
+ await exportAccounts(resolvedExportPath, force ?? true);
2658
4196
  const storage = await loadAccounts();
2659
4197
  const count = storage?.accounts.length ?? 0;
2660
4198
  if (ui.v2Enabled) {
@@ -2662,10 +4200,10 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2662
4200
  ...formatUiHeader(ui, "Export accounts"),
2663
4201
  "",
2664
4202
  formatUiItem(ui, `${getStatusMarker(ui, "ok")} Exported ${count} account(s)`, "success"),
2665
- formatUiKeyValue(ui, "Path", filePath, "muted"),
4203
+ formatUiKeyValue(ui, "Path", resolvedExportPath, "muted"),
2666
4204
  ].join("\n");
2667
4205
  }
2668
- return `Exported ${count} account(s) to: ${filePath}`;
4206
+ return `Exported ${count} account(s) to: ${resolvedExportPath}`;
2669
4207
  }
2670
4208
  catch (error) {
2671
4209
  const msg = error instanceof Error ? error.message : String(error);
@@ -2682,16 +4220,49 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2682
4220
  },
2683
4221
  }),
2684
4222
  "codex-import": tool({
2685
- description: "Import accounts from a JSON file, merging with existing accounts.",
4223
+ description: "Import accounts from a JSON file, with dry-run preview and automatic timestamped backup before apply.",
2686
4224
  args: {
2687
4225
  path: tool.schema.string().describe("File path to import from (e.g., ~/codex-backup.json)"),
4226
+ dryRun: tool.schema.boolean().optional().describe("Preview import impact without applying changes."),
2688
4227
  },
2689
- async execute({ path: filePath }) {
4228
+ async execute({ path: filePath, dryRun }) {
2690
4229
  const ui = resolveUiRuntime();
2691
4230
  try {
2692
- const result = await importAccounts(filePath);
4231
+ const preview = await previewImportAccounts(filePath);
4232
+ if (dryRun) {
4233
+ if (ui.v2Enabled) {
4234
+ return [
4235
+ ...formatUiHeader(ui, "Import preview"),
4236
+ "",
4237
+ formatUiItem(ui, "No changes applied (dry run).", "warning"),
4238
+ formatUiKeyValue(ui, "Path", filePath, "muted"),
4239
+ formatUiKeyValue(ui, "New accounts", String(preview.imported), preview.imported > 0 ? "success" : "muted"),
4240
+ formatUiKeyValue(ui, "Duplicates skipped", String(preview.skipped), preview.skipped > 0 ? "warning" : "muted"),
4241
+ formatUiKeyValue(ui, "Resulting total", String(preview.total), "accent"),
4242
+ ].join("\n");
4243
+ }
4244
+ return [
4245
+ "Import preview (dry run):",
4246
+ `Path: ${filePath}`,
4247
+ `New accounts: ${preview.imported}`,
4248
+ `Duplicates skipped: ${preview.skipped}`,
4249
+ `Resulting total: ${preview.total}`,
4250
+ ].join("\n");
4251
+ }
4252
+ const result = await importAccounts(filePath, {
4253
+ preImportBackupPrefix: "codex-pre-import-backup",
4254
+ backupMode: "required",
4255
+ });
4256
+ const backupSummary = result.backupStatus === "created"
4257
+ ? result.backupPath ?? "created"
4258
+ : result.backupStatus === "failed"
4259
+ ? `failed (${result.backupError ?? "unknown error"})`
4260
+ : "skipped (no existing accounts)";
4261
+ const backupStatus = result.backupStatus === "created" ? "ok" : "warning";
2693
4262
  invalidateAccountManagerCache();
2694
4263
  const lines = [`Import complete.`, ``];
4264
+ lines.push(`Preview: +${preview.imported} new, ${preview.skipped} skipped, ${preview.total} total`);
4265
+ lines.push(`Auto-backup: ${backupSummary}`);
2695
4266
  if (result.imported > 0) {
2696
4267
  lines.push(`New accounts: ${result.imported}`);
2697
4268
  }
@@ -2705,6 +4276,8 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2705
4276
  "",
2706
4277
  formatUiItem(ui, `${getStatusMarker(ui, "ok")} Import complete`, "success"),
2707
4278
  formatUiKeyValue(ui, "Path", filePath, "muted"),
4279
+ formatUiKeyValue(ui, "Auto-backup", backupSummary, backupStatus === "ok" ? "muted" : "warning"),
4280
+ formatUiKeyValue(ui, "Preview", `+${preview.imported}, skipped=${preview.skipped}, total=${preview.total}`, "muted"),
2708
4281
  formatUiKeyValue(ui, "New accounts", String(result.imported), result.imported > 0 ? "success" : "muted"),
2709
4282
  formatUiKeyValue(ui, "Duplicates skipped", String(result.skipped), result.skipped > 0 ? "warning" : "muted"),
2710
4283
  formatUiKeyValue(ui, "Total accounts", String(result.total), "accent"),