oc-chatgpt-multi-auth 5.3.3 → 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 (37) hide show
  1. package/README.md +198 -85
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1385 -37
  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 +211 -45
  32. package/dist/lib/storage.js.map +1 -1
  33. package/dist/lib/ui/beginner.d.ts +57 -0
  34. package/dist/lib/ui/beginner.d.ts.map +1 -0
  35. package/dist/lib/ui/beginner.js +230 -0
  36. package/dist/lib/ui/beginner.js.map +1 -0
  37. 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,
@@ -767,6 +789,12 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
767
789
  const email = account?.email?.trim();
768
790
  const workspace = account?.accountLabel?.trim();
769
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
+ : [];
770
798
  const details = [];
771
799
  if (email)
772
800
  details.push(email);
@@ -774,11 +802,295 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
774
802
  details.push(`workspace:${workspace}`);
775
803
  if (accountId)
776
804
  details.push(`id:${accountId}`);
805
+ if (tags.length > 0)
806
+ details.push(`tags:${tags.join(",")}`);
777
807
  if (details.length === 0) {
778
808
  return `Account ${index + 1}`;
779
809
  }
780
810
  return `Account ${index + 1} (${details.join(", ")})`;
781
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
+ };
782
1094
  const invalidateAccountManagerCache = () => {
783
1095
  cachedAccountManager = null;
784
1096
  accountManagerPromise = null;
@@ -905,11 +1217,26 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
905
1217
  const fastSessionEnabled = getFastSession(pluginConfig);
906
1218
  const fastSessionStrategy = getFastSessionStrategy(pluginConfig);
907
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 };
908
1231
  const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig);
909
1232
  const rateLimitToastDebounceMs = getRateLimitToastDebounceMs(pluginConfig);
910
- const retryAllAccountsRateLimited = getRetryAllAccountsRateLimited(pluginConfig);
1233
+ const retryAllAccountsRateLimited = beginnerSafeMode
1234
+ ? false
1235
+ : getRetryAllAccountsRateLimited(pluginConfig);
911
1236
  const retryAllAccountsMaxWaitMs = getRetryAllAccountsMaxWaitMs(pluginConfig);
912
- const retryAllAccountsMaxRetries = getRetryAllAccountsMaxRetries(pluginConfig);
1237
+ const retryAllAccountsMaxRetries = beginnerSafeMode
1238
+ ? Math.min(1, getRetryAllAccountsMaxRetries(pluginConfig))
1239
+ : getRetryAllAccountsMaxRetries(pluginConfig);
913
1240
  const unsupportedCodexPolicy = getUnsupportedCodexPolicy(pluginConfig);
914
1241
  const fallbackOnUnsupportedCodexModel = unsupportedCodexPolicy === "fallback";
915
1242
  const fallbackToGpt52OnUnsupportedGpt53 = getFallbackToGpt52OnUnsupportedGpt53(pluginConfig);
@@ -934,6 +1261,13 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
934
1261
  fastSessionMaxInputItems,
935
1262
  });
936
1263
  }
1264
+ if (beginnerSafeMode) {
1265
+ logInfo("Beginner safe mode enabled", {
1266
+ retryProfile,
1267
+ retryAllAccountsRateLimited,
1268
+ retryAllAccountsMaxRetries,
1269
+ });
1270
+ }
937
1271
  const prewarmEnabled = process.env.CODEX_AUTH_PREWARM !== "0" &&
938
1272
  process.env.VITEST !== "true" &&
939
1273
  process.env.NODE_ENV !== "test";
@@ -953,6 +1287,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
953
1287
  }).catch((err) => {
954
1288
  logDebug(`Update check failed: ${err instanceof Error ? err.message : String(err)}`);
955
1289
  });
1290
+ await runStartupPreflight();
956
1291
  // Return SDK configuration
957
1292
  return {
958
1293
  apiKey: DUMMY_API_KEY,
@@ -1054,6 +1389,25 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1054
1389
  .trim() || undefined;
1055
1390
  const requestCorrelationId = setCorrelationId(threadIdCandidate ? `${threadIdCandidate}:${Date.now()}` : undefined);
1056
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
+ };
1057
1411
  const abortSignal = requestInit?.signal ?? init?.signal ?? null;
1058
1412
  const sleep = (ms) => new Promise((resolve, reject) => {
1059
1413
  if (abortSignal?.aborted) {
@@ -1104,11 +1458,28 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1104
1458
  const attempted = new Set();
1105
1459
  let restartAccountTraversalWithFallback = false;
1106
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
+ };
1107
1470
  const account = accountManager.getCurrentOrNextForFamilyHybrid(modelFamily, model, { pidOffsetEnabled });
1108
1471
  if (!account || attempted.has(account.index)) {
1109
1472
  break;
1110
1473
  }
1111
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
+ }
1112
1483
  // Log account selection for debugging rotation
1113
1484
  logDebug(`Using account ${account.index + 1}/${accountCount}: ${account.email ?? "unknown"} for ${modelFamily}`);
1114
1485
  let accountAuth = accountManager.toAuthDetails(account);
@@ -1122,10 +1493,23 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1122
1493
  }
1123
1494
  catch (err) {
1124
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
+ }
1125
1508
  runtimeMetrics.authRefreshFailures++;
1126
1509
  runtimeMetrics.failedRequests++;
1127
1510
  runtimeMetrics.accountRotations++;
1128
1511
  runtimeMetrics.lastError = err?.message ?? String(err);
1512
+ runtimeMetrics.lastErrorCategory = "auth-refresh";
1129
1513
  const failures = accountManager.incrementAuthFailures(account);
1130
1514
  const accountLabel = formatAccountLabel(account, account.index);
1131
1515
  if (failures >= ACCOUNT_LIMITS.MAX_AUTH_FAILURES_BEFORE_REMOVAL) {
@@ -1169,6 +1553,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1169
1553
  runtimeMetrics.accountRotations++;
1170
1554
  runtimeMetrics.lastError =
1171
1555
  `Local token bucket depleted for account ${account.index + 1} (${modelFamily}${model ? `:${model}` : ""})`;
1556
+ runtimeMetrics.lastErrorCategory = "rate-limit-local";
1172
1557
  logWarn(`Skipping account ${account.index + 1}: local token bucket depleted for ${modelFamily}${model ? `:${model}` : ""}`);
1173
1558
  break;
1174
1559
  }
@@ -1200,10 +1585,24 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1200
1585
  catch (networkError) {
1201
1586
  const errorMsg = networkError instanceof Error ? networkError.message : String(networkError);
1202
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
+ }
1203
1601
  runtimeMetrics.failedRequests++;
1204
1602
  runtimeMetrics.networkErrors++;
1205
1603
  runtimeMetrics.accountRotations++;
1206
1604
  runtimeMetrics.lastError = errorMsg;
1605
+ runtimeMetrics.lastErrorCategory = "network";
1207
1606
  accountManager.refundToken(account, modelFamily, model);
1208
1607
  accountManager.recordFailure(account, modelFamily, model);
1209
1608
  break;
@@ -1241,6 +1640,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1241
1640
  accountManager.recordFailure(account, modelFamily, model);
1242
1641
  account.lastSwitchReason = "rotation";
1243
1642
  runtimeMetrics.lastError = `Unsupported model on account ${account.index + 1}: ${blockedModel}`;
1643
+ runtimeMetrics.lastErrorCategory = "unsupported-model";
1244
1644
  logWarn(`Model ${blockedModel} is unsupported for account ${account.index + 1}. Trying next account/workspace before fallback.`, {
1245
1645
  unsupportedCodexPolicy,
1246
1646
  requestedModel: blockedModel,
@@ -1288,6 +1688,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1288
1688
  body: JSON.stringify(transformedBody),
1289
1689
  };
1290
1690
  runtimeMetrics.lastError = `Model fallback: ${previousModel} -> ${model}`;
1691
+ runtimeMetrics.lastErrorCategory = "model-fallback";
1291
1692
  logWarn(`Model ${previousModel} is unsupported for this ChatGPT account. Falling back to ${model}.`, {
1292
1693
  unsupportedCodexPolicy,
1293
1694
  requestedModel: previousModel,
@@ -1302,6 +1703,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1302
1703
  if (unsupportedModelInfo.isUnsupported && !fallbackOnUnsupportedCodexModel) {
1303
1704
  const blockedModel = unsupportedModelInfo.unsupportedModel ?? model ?? "requested model";
1304
1705
  runtimeMetrics.lastError = `Unsupported model (strict): ${blockedModel}`;
1706
+ runtimeMetrics.lastErrorCategory = "unsupported-model";
1305
1707
  logWarn(`Model ${blockedModel} is unsupported for this ChatGPT account. Strict policy blocks automatic fallback.`, {
1306
1708
  unsupportedCodexPolicy,
1307
1709
  requestedModel: blockedModel,
@@ -1324,15 +1726,20 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1324
1726
  runtimeMetrics.serverErrors++;
1325
1727
  runtimeMetrics.accountRotations++;
1326
1728
  runtimeMetrics.lastError = `HTTP ${response.status}`;
1729
+ runtimeMetrics.lastErrorCategory = "server";
1327
1730
  accountManager.refundToken(account, modelFamily, model);
1328
1731
  accountManager.recordFailure(account, modelFamily, model);
1732
+ if (!consumeRetryBudget("server", `Server error ${response.status} on account ${account.index + 1}`)) {
1733
+ return errorResponse;
1734
+ }
1329
1735
  break;
1330
1736
  }
1331
1737
  if (rateLimit) {
1332
1738
  runtimeMetrics.rateLimitedResponses++;
1333
1739
  const { attempt, delayMs } = getRateLimitBackoff(account.index, quotaKey, rateLimit.retryAfterMs);
1334
1740
  const waitLabel = formatWaitTime(delayMs);
1335
- 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`)) {
1336
1743
  if (accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
1337
1744
  await showToast(`Rate limited. Retrying in ${waitLabel} (attempt ${attempt})...`, "warning", { duration: toastDurationMs });
1338
1745
  accountManager.markToastShown(account.index);
@@ -1344,6 +1751,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1344
1751
  accountManager.recordRateLimit(account, modelFamily, model);
1345
1752
  account.lastSwitchReason = "rate-limit";
1346
1753
  runtimeMetrics.accountRotations++;
1754
+ runtimeMetrics.lastErrorCategory = "rate-limit";
1347
1755
  accountManager.saveToDiskDebounced();
1348
1756
  logWarn(`Rate limited. Rotating account ${account.index + 1} (${account.email ?? "unknown"}).`);
1349
1757
  if (accountManager.getAccountCount() > 1 &&
@@ -1355,6 +1763,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1355
1763
  }
1356
1764
  runtimeMetrics.failedRequests++;
1357
1765
  runtimeMetrics.lastError = `HTTP ${response.status}`;
1766
+ runtimeMetrics.lastErrorCategory = "http";
1358
1767
  return errorResponse;
1359
1768
  }
1360
1769
  resetRateLimitBackoff(account.index, quotaKey);
@@ -1365,6 +1774,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1365
1774
  if (!successResponse.ok) {
1366
1775
  runtimeMetrics.failedRequests++;
1367
1776
  runtimeMetrics.lastError = `HTTP ${successResponse.status}`;
1777
+ runtimeMetrics.lastErrorCategory = "http";
1368
1778
  return successResponse;
1369
1779
  }
1370
1780
  if (!isStreaming && emptyResponseMaxRetries > 0) {
@@ -1373,7 +1783,8 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1373
1783
  const bodyText = await clonedResponse.text();
1374
1784
  const parsedBody = bodyText ? JSON.parse(bodyText) : null;
1375
1785
  if (isEmptyResponse(parsedBody)) {
1376
- if (emptyResponseRetries < emptyResponseMaxRetries) {
1786
+ if (emptyResponseRetries < emptyResponseMaxRetries &&
1787
+ consumeRetryBudget("emptyResponse", `Empty response retry ${emptyResponseRetries + 1}/${emptyResponseMaxRetries}`)) {
1377
1788
  emptyResponseRetries++;
1378
1789
  runtimeMetrics.emptyResponseRetries++;
1379
1790
  logWarn(`Empty response received (attempt ${emptyResponseRetries}/${emptyResponseMaxRetries}). Retrying...`);
@@ -1393,6 +1804,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1393
1804
  accountManager.recordSuccess(account, modelFamily, model);
1394
1805
  runtimeMetrics.successfulRequests++;
1395
1806
  runtimeMetrics.lastError = null;
1807
+ runtimeMetrics.lastErrorCategory = null;
1396
1808
  return successResponse;
1397
1809
  }
1398
1810
  if (restartAccountTraversalWithFallback) {
@@ -1409,7 +1821,8 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1409
1821
  waitMs > 0 &&
1410
1822
  (retryAllAccountsMaxWaitMs === 0 ||
1411
1823
  waitMs <= retryAllAccountsMaxWaitMs) &&
1412
- allRateLimitedRetries < retryAllAccountsMaxRetries) {
1824
+ allRateLimitedRetries < retryAllAccountsMaxRetries &&
1825
+ consumeRetryBudget("rateLimitGlobal", `All accounts rate-limited wait ${waitMs}ms`)) {
1413
1826
  const countdownMessage = `All ${count} account(s) rate-limited. Waiting`;
1414
1827
  await sleepWithCountdown(addJitter(waitMs, 0.2), countdownMessage);
1415
1828
  allRateLimitedRetries++;
@@ -1423,6 +1836,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1423
1836
  : `All ${count} account(s) failed (server errors or auth issues). Check account health with \`codex-health\`.`;
1424
1837
  runtimeMetrics.failedRequests++;
1425
1838
  runtimeMetrics.lastError = message;
1839
+ runtimeMetrics.lastErrorCategory = waitMs > 0 ? "rate-limit" : "account-failure";
1426
1840
  return new Response(JSON.stringify({ error: { message } }), {
1427
1841
  status: waitMs > 0 ? 429 : 503,
1428
1842
  headers: {
@@ -2282,11 +2696,17 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2282
2696
  tool: {
2283
2697
  "codex-list": tool({
2284
2698
  description: "List all Codex OAuth accounts and the current active index.",
2285
- args: {},
2286
- 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 } = {}) {
2287
2706
  const ui = resolveUiRuntime();
2288
2707
  const storage = await loadAccounts();
2289
2708
  const storePath = getStoragePath();
2709
+ const normalizedTag = tag?.trim().toLowerCase() ?? "";
2290
2710
  if (!storage || storage.accounts.length === 0) {
2291
2711
  if (ui.v2Enabled) {
2292
2712
  return [
@@ -2294,6 +2714,8 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2294
2714
  "",
2295
2715
  formatUiItem(ui, "No accounts configured.", "warning"),
2296
2716
  formatUiItem(ui, "Run: opencode auth login", "accent"),
2717
+ formatUiItem(ui, "Setup checklist: codex-setup"),
2718
+ formatUiItem(ui, "Command guide: codex-help"),
2297
2719
  formatUiKeyValue(ui, "Storage", storePath, "muted"),
2298
2720
  ].join("\n");
2299
2721
  }
@@ -2302,21 +2724,47 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2302
2724
  "",
2303
2725
  "Add accounts:",
2304
2726
  " opencode auth login",
2727
+ " codex-setup",
2728
+ " codex-help",
2305
2729
  "",
2306
2730
  `Storage: ${storePath}`,
2307
2731
  ].join("\n");
2308
2732
  }
2309
2733
  const now = Date.now();
2310
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
+ }
2311
2756
  if (ui.v2Enabled) {
2312
2757
  const lines = [
2313
2758
  ...formatUiHeader(ui, "Codex accounts"),
2314
- 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"),
2315
2763
  formatUiKeyValue(ui, "Storage", storePath, "muted"),
2316
2764
  "",
2317
2765
  ...formatUiSection(ui, "Accounts"),
2318
2766
  ];
2319
- storage.accounts.forEach((account, index) => {
2767
+ filteredEntries.forEach(({ account, index }) => {
2320
2768
  const label = formatCommandAccountLabel(account, index);
2321
2769
  const badges = [];
2322
2770
  if (index === activeIndex)
@@ -2341,9 +2789,18 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2341
2789
  lines.push("");
2342
2790
  lines.push(...formatUiSection(ui, "Commands"));
2343
2791
  lines.push(formatUiItem(ui, "Add account: opencode auth login", "accent"));
2344
- lines.push(formatUiItem(ui, "Switch account: codex-switch <index>"));
2792
+ lines.push(formatUiItem(ui, "Switch account: codex-switch index=2"));
2345
2793
  lines.push(formatUiItem(ui, "Detailed status: codex-status"));
2794
+ lines.push(formatUiItem(ui, "Live dashboard: codex-dashboard"));
2346
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"));
2347
2804
  return lines.join("\n");
2348
2805
  }
2349
2806
  const listTableOptions = {
@@ -2354,11 +2811,11 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2354
2811
  ],
2355
2812
  };
2356
2813
  const lines = [
2357
- `Codex Accounts (${storage.accounts.length}):`,
2814
+ `Codex Accounts (${filteredEntries.length}):`,
2358
2815
  "",
2359
2816
  ...buildTableHeader(listTableOptions),
2360
2817
  ];
2361
- storage.accounts.forEach((account, index) => {
2818
+ filteredEntries.forEach(({ account, index }) => {
2362
2819
  const label = formatCommandAccountLabel(account, index);
2363
2820
  const statuses = [];
2364
2821
  const rateLimit = formatRateLimitEntry(account, now);
@@ -2376,21 +2833,33 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2376
2833
  });
2377
2834
  lines.push("");
2378
2835
  lines.push(`Storage: ${storePath}`);
2836
+ if (normalizedTag) {
2837
+ lines.push(`Filter tag: ${normalizedTag}`);
2838
+ }
2379
2839
  lines.push("");
2380
2840
  lines.push("Commands:");
2381
2841
  lines.push(" - Add account: opencode auth login");
2382
2842
  lines.push(" - Switch account: codex-switch");
2383
2843
  lines.push(" - Status details: codex-status");
2844
+ lines.push(" - Live dashboard: codex-dashboard");
2384
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");
2385
2854
  return lines.join("\n");
2386
2855
  },
2387
2856
  }),
2388
2857
  "codex-switch": tool({
2389
- 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.",
2390
2859
  args: {
2391
- 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)"),
2392
2861
  },
2393
- async execute({ index }) {
2862
+ async execute({ index } = {}) {
2394
2863
  const ui = resolveUiRuntime();
2395
2864
  const storage = await loadAccounts();
2396
2865
  if (!storage || storage.accounts.length === 0) {
@@ -2404,7 +2873,34 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2404
2873
  }
2405
2874
  return "No Codex accounts configured. Run: opencode auth login";
2406
2875
  }
2407
- 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);
2408
2904
  if (!Number.isFinite(targetIndex) ||
2409
2905
  targetIndex < 0 ||
2410
2906
  targetIndex >= storage.accounts.length) {
@@ -2412,11 +2908,11 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2412
2908
  return [
2413
2909
  ...formatUiHeader(ui, "Switch account"),
2414
2910
  "",
2415
- formatUiItem(ui, `Invalid account number: ${index}`, "danger"),
2911
+ formatUiItem(ui, `Invalid account number: ${resolvedIndex}`, "danger"),
2416
2912
  formatUiKeyValue(ui, "Valid range", `1-${storage.accounts.length}`, "muted"),
2417
2913
  ].join("\n");
2418
2914
  }
2419
- 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}`;
2420
2916
  }
2421
2917
  const now = Date.now();
2422
2918
  const account = storage.accounts[targetIndex];
@@ -2480,10 +2976,18 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2480
2976
  }
2481
2977
  const now = Date.now();
2482
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]));
2483
2984
  if (ui.v2Enabled) {
2484
2985
  const lines = [
2485
2986
  ...formatUiHeader(ui, "Account status"),
2486
2987
  formatUiKeyValue(ui, "Total", String(storage.accounts.length)),
2988
+ formatUiKeyValue(ui, "Selection view", explainabilityModel
2989
+ ? `${explainabilityFamily}:${explainabilityModel}`
2990
+ : explainabilityFamily, "muted"),
2487
2991
  "",
2488
2992
  ...formatUiSection(ui, "Accounts"),
2489
2993
  ];
@@ -2524,6 +3028,21 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2524
3028
  });
2525
3029
  lines.push(formatUiItem(ui, `Account ${index + 1}: ${statuses.join(" | ")}`));
2526
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"));
2527
3046
  return lines.join("\n");
2528
3047
  }
2529
3048
  const statusTableOptions = {
@@ -2569,6 +3088,21 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2569
3088
  });
2570
3089
  lines.push(` Account ${index + 1}: ${statuses.join(" | ")}`);
2571
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
+ })}`);
2572
3106
  return lines.join("\n");
2573
3107
  },
2574
3108
  }),
@@ -2581,6 +3115,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2581
3115
  const uptimeMs = Math.max(0, now - runtimeMetrics.startedAt);
2582
3116
  const total = runtimeMetrics.totalRequests;
2583
3117
  const successful = runtimeMetrics.successfulRequests;
3118
+ const refreshMetrics = getRefreshQueueMetrics();
2584
3119
  const successRate = total > 0 ? ((successful / total) * 100).toFixed(1) : "0.0";
2585
3120
  const avgLatencyMs = successful > 0
2586
3121
  ? Math.round(runtimeMetrics.cumulativeLatencyMs / successful)
@@ -2603,11 +3138,41 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2603
3138
  `Auth refresh failures: ${runtimeMetrics.authRefreshFailures}`,
2604
3139
  `Account rotations: ${runtimeMetrics.accountRotations}`,
2605
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}`,
2606
3156
  `Last upstream request: ${lastRequest}`,
2607
3157
  ];
2608
3158
  if (runtimeMetrics.lastError) {
2609
3159
  lines.push(`Last error: ${runtimeMetrics.lastError}`);
2610
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
+ }
2611
3176
  if (ui.v2Enabled) {
2612
3177
  const styled = [
2613
3178
  ...formatUiHeader(ui, "Codex plugin metrics"),
@@ -2623,16 +3188,730 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2623
3188
  formatUiKeyValue(ui, "Auth refresh failures", String(runtimeMetrics.authRefreshFailures), "warning"),
2624
3189
  formatUiKeyValue(ui, "Account rotations", String(runtimeMetrics.accountRotations), "accent"),
2625
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"),
2626
3197
  formatUiKeyValue(ui, "Last upstream request", lastRequest, "muted"),
2627
3198
  ];
2628
3199
  if (runtimeMetrics.lastError) {
2629
3200
  styled.push(formatUiKeyValue(ui, "Last error", runtimeMetrics.lastError, "danger"));
2630
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
+ }
2631
3216
  return Promise.resolve(styled.join("\n"));
2632
3217
  }
2633
3218
  return Promise.resolve(lines.join("\n"));
2634
3219
  },
2635
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
+ }),
2636
3915
  "codex-health": tool({
2637
3916
  description: "Check health of all Codex accounts by validating refresh tokens.",
2638
3917
  args: {},
@@ -2690,11 +3969,11 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2690
3969
  },
2691
3970
  }),
2692
3971
  "codex-remove": tool({
2693
- 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.",
2694
3973
  args: {
2695
- 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)"),
2696
3975
  },
2697
- async execute({ index }) {
3976
+ async execute({ index } = {}) {
2698
3977
  const ui = resolveUiRuntime();
2699
3978
  const storage = await loadAccounts();
2700
3979
  if (!storage || storage.accounts.length === 0) {
@@ -2707,7 +3986,34 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2707
3986
  }
2708
3987
  return "No Codex accounts configured. Nothing to remove.";
2709
3988
  }
2710
- 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);
2711
4017
  if (!Number.isFinite(targetIndex) ||
2712
4018
  targetIndex < 0 ||
2713
4019
  targetIndex >= storage.accounts.length) {
@@ -2715,16 +4021,16 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2715
4021
  return [
2716
4022
  ...formatUiHeader(ui, "Remove account"),
2717
4023
  "",
2718
- formatUiItem(ui, `Invalid account number: ${index}`, "danger"),
4024
+ formatUiItem(ui, `Invalid account number: ${resolvedIndex}`, "danger"),
2719
4025
  formatUiKeyValue(ui, "Valid range", `1-${storage.accounts.length}`, "muted"),
2720
4026
  formatUiItem(ui, "Use codex-list to list all accounts.", "accent"),
2721
4027
  ].join("\n");
2722
4028
  }
2723
- 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.`;
2724
4030
  }
2725
4031
  const account = storage.accounts[targetIndex];
2726
4032
  if (!account) {
2727
- return `Account ${index} not found.`;
4033
+ return `Account ${resolvedIndex} not found.`;
2728
4034
  }
2729
4035
  const label = formatCommandAccountLabel(account, targetIndex);
2730
4036
  storage.accounts.splice(targetIndex, 1);
@@ -2871,15 +4177,22 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2871
4177
  },
2872
4178
  }),
2873
4179
  "codex-export": tool({
2874
- 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.",
2875
4181
  args: {
2876
- 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."),
2877
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."),
2878
4185
  },
2879
- async execute({ path: filePath, force }) {
4186
+ async execute({ path: filePath, force, timestamped, }) {
2880
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";
2881
4194
  try {
2882
- await exportAccounts(filePath, force ?? true);
4195
+ await exportAccounts(resolvedExportPath, force ?? true);
2883
4196
  const storage = await loadAccounts();
2884
4197
  const count = storage?.accounts.length ?? 0;
2885
4198
  if (ui.v2Enabled) {
@@ -2887,10 +4200,10 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2887
4200
  ...formatUiHeader(ui, "Export accounts"),
2888
4201
  "",
2889
4202
  formatUiItem(ui, `${getStatusMarker(ui, "ok")} Exported ${count} account(s)`, "success"),
2890
- formatUiKeyValue(ui, "Path", filePath, "muted"),
4203
+ formatUiKeyValue(ui, "Path", resolvedExportPath, "muted"),
2891
4204
  ].join("\n");
2892
4205
  }
2893
- return `Exported ${count} account(s) to: ${filePath}`;
4206
+ return `Exported ${count} account(s) to: ${resolvedExportPath}`;
2894
4207
  }
2895
4208
  catch (error) {
2896
4209
  const msg = error instanceof Error ? error.message : String(error);
@@ -2907,16 +4220,49 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2907
4220
  },
2908
4221
  }),
2909
4222
  "codex-import": tool({
2910
- 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.",
2911
4224
  args: {
2912
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."),
2913
4227
  },
2914
- async execute({ path: filePath }) {
4228
+ async execute({ path: filePath, dryRun }) {
2915
4229
  const ui = resolveUiRuntime();
2916
4230
  try {
2917
- 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";
2918
4262
  invalidateAccountManagerCache();
2919
4263
  const lines = [`Import complete.`, ``];
4264
+ lines.push(`Preview: +${preview.imported} new, ${preview.skipped} skipped, ${preview.total} total`);
4265
+ lines.push(`Auto-backup: ${backupSummary}`);
2920
4266
  if (result.imported > 0) {
2921
4267
  lines.push(`New accounts: ${result.imported}`);
2922
4268
  }
@@ -2930,6 +4276,8 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2930
4276
  "",
2931
4277
  formatUiItem(ui, `${getStatusMarker(ui, "ok")} Import complete`, "success"),
2932
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"),
2933
4281
  formatUiKeyValue(ui, "New accounts", String(result.imported), result.imported > 0 ? "success" : "muted"),
2934
4282
  formatUiKeyValue(ui, "Duplicates skipped", String(result.skipped), result.skipped > 0 ? "warning" : "muted"),
2935
4283
  formatUiKeyValue(ui, "Total accounts", String(result.total), "accent"),