From 2013dbef013cc9ab9250f190ebbadf52608be82a Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 4 Mar 2026 05:47:49 +0800 Subject: [PATCH 1/7] refactor: extract shared account view helpers Introduce a shared account-view helper module and reuse it across the plugin entrypoint and CLI manager to remove duplicated formatting logic while preserving behavior. Add unit/regression tests to lock formatting and active-index fallback behavior. Co-authored-by: Codex --- index.ts | 54 ++------------------ lib/accounts/account-view.ts | 57 +++++++++++++++++++++ lib/codex-manager.ts | 49 ++---------------- test/account-view.test.ts | 93 ++++++++++++++++++++++++++++++++++ test/codex-manager-cli.test.ts | 26 ++++++++++ test/index.test.ts | 9 +++- 6 files changed, 193 insertions(+), 95 deletions(-) create mode 100644 lib/accounts/account-view.ts create mode 100644 test/account-view.test.ts diff --git a/index.ts b/index.ts index 7db8808..b755f40 100644 --- a/index.ts +++ b/index.ts @@ -117,6 +117,11 @@ import { lookupCodexCliTokensByEmail, isCodexCliSyncEnabled, } from "./lib/accounts.js"; +import { + resolveActiveIndex, + getRateLimitResetTimeForFamily, + formatRateLimitEntry, +} from "./lib/accounts/account-view.js"; import { getStoragePath, loadAccounts, @@ -676,21 +681,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { } }; - const resolveActiveIndex = ( - storage: { - activeIndex: number; - activeIndexByFamily?: Partial>; - accounts: unknown[]; - }, - family: ModelFamily = "codex", - ): number => { - const total = storage.accounts.length; - if (total === 0) return 0; - const rawCandidate = storage.activeIndexByFamily?.[family] ?? storage.activeIndex; - const raw = Number.isFinite(rawCandidate) ? rawCandidate : 0; - return Math.max(0, Math.min(raw, total - 1)); - }; - const hydrateEmails = async ( storage: AccountStorageV3 | null, ): Promise => { @@ -755,40 +745,6 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return storage; }; - const getRateLimitResetTimeForFamily = ( - account: { rateLimitResetTimes?: Record }, - now: number, - family: ModelFamily, - ): number | null => { - const times = account.rateLimitResetTimes; - if (!times) return null; - - let minReset: number | null = null; - const prefix = `${family}:`; - for (const [key, value] of Object.entries(times)) { - if (typeof value !== "number") continue; - if (value <= now) continue; - if (key !== family && !key.startsWith(prefix)) continue; - if (minReset === null || value < minReset) { - minReset = value; - } - } - - return minReset; - }; - - const formatRateLimitEntry = ( - account: { rateLimitResetTimes?: Record }, - now: number, - family: ModelFamily = "codex", - ): string | null => { - const resetAt = getRateLimitResetTimeForFamily(account, now, family); - if (typeof resetAt !== "number") return null; - const remaining = resetAt - now; - if (remaining <= 0) return null; - return `resets in ${formatWaitTime(remaining)}`; - }; - const applyUiRuntimeFromConfig = ( pluginConfig: ReturnType, ): UiRuntimeOptions => { diff --git a/lib/accounts/account-view.ts b/lib/accounts/account-view.ts new file mode 100644 index 0000000..576711d --- /dev/null +++ b/lib/accounts/account-view.ts @@ -0,0 +1,57 @@ +import type { ModelFamily } from "../prompts/codex.js"; +import { formatWaitTime } from "./rate-limits.js"; + +export interface ActiveAccountStorageView { + activeIndex: number; + activeIndexByFamily?: Partial>; + accounts: unknown[]; +} + +export interface AccountRateLimitView { + rateLimitResetTimes?: Record; +} + +export function resolveActiveIndex( + storage: ActiveAccountStorageView, + family: ModelFamily = "codex", +): number { + const total = storage.accounts.length; + if (total === 0) return 0; + const rawCandidate = storage.activeIndexByFamily?.[family] ?? storage.activeIndex; + const raw = Number.isFinite(rawCandidate) ? rawCandidate : 0; + return Math.max(0, Math.min(raw, total - 1)); +} + +export function getRateLimitResetTimeForFamily( + account: AccountRateLimitView, + now: number, + family: ModelFamily, +): number | null { + const times = account.rateLimitResetTimes; + if (!times) return null; + + let minReset: number | null = null; + const prefix = `${family}:`; + for (const [key, value] of Object.entries(times)) { + if (typeof value !== "number") continue; + if (value <= now) continue; + if (key !== family && !key.startsWith(prefix)) continue; + if (minReset === null || value < minReset) { + minReset = value; + } + } + + return minReset; +} + +export function formatRateLimitEntry( + account: AccountRateLimitView, + now: number, + family: ModelFamily = "codex", +): string | null { + const resetAt = getRateLimitResetTimeForFamily(account, now, family); + if (typeof resetAt !== "number") return null; + const remaining = resetAt - now; + if (remaining <= 0) return null; + return `resets in ${formatWaitTime(remaining)}`; +} diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 794eb7c..cbf0210 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -22,6 +22,10 @@ import { sanitizeEmail, selectBestAccountCandidate, } from "./accounts.js"; +import { + resolveActiveIndex, + formatRateLimitEntry, +} from "./accounts/account-view.js"; import { ACCOUNT_LIMITS } from "./constants.js"; import { loadDashboardDisplaySettings, @@ -362,51 +366,6 @@ function runFeaturesReport(): number { return 0; } -function resolveActiveIndex( - storage: AccountStorageV3, - family: ModelFamily = "codex", -): number { - const total = storage.accounts.length; - if (total === 0) return 0; - const rawCandidate = storage.activeIndexByFamily?.[family] ?? storage.activeIndex; - const raw = Number.isFinite(rawCandidate) ? rawCandidate : 0; - return Math.max(0, Math.min(raw, total - 1)); -} - -function getRateLimitResetTimeForFamily( - account: { rateLimitResetTimes?: Record }, - now: number, - family: ModelFamily, -): number | null { - const times = account.rateLimitResetTimes; - if (!times) return null; - - let minReset: number | null = null; - const prefix = `${family}:`; - for (const [key, value] of Object.entries(times)) { - if (typeof value !== "number") continue; - if (value <= now) continue; - if (key !== family && !key.startsWith(prefix)) continue; - if (minReset === null || value < minReset) { - minReset = value; - } - } - - return minReset; -} - -function formatRateLimitEntry( - account: { rateLimitResetTimes?: Record }, - now: number, - family: ModelFamily = "codex", -): string | null { - const resetAt = getRateLimitResetTimeForFamily(account, now, family); - if (typeof resetAt !== "number") return null; - const remaining = resetAt - now; - if (remaining <= 0) return null; - return `resets in ${formatWaitTime(remaining)}`; -} - function normalizeQuotaEmail(email: string | undefined): string | null { const normalized = sanitizeEmail(email); return normalized && normalized.length > 0 ? normalized : null; diff --git a/test/account-view.test.ts b/test/account-view.test.ts new file mode 100644 index 0000000..ce554f5 --- /dev/null +++ b/test/account-view.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { + resolveActiveIndex, + getRateLimitResetTimeForFamily, + formatRateLimitEntry, +} from "../lib/accounts/account-view.js"; + +describe("account-view helpers", () => { + it("resolves active index from family mapping and clamps to bounds", () => { + expect( + resolveActiveIndex({ + activeIndex: 9, + activeIndexByFamily: { codex: 4 }, + accounts: [{}, {}], + }), + ).toBe(1); + }); + + it("falls back to storage active index and normalizes non-finite candidates", () => { + expect( + resolveActiveIndex({ + activeIndex: Number.NaN, + activeIndexByFamily: { codex: Number.NaN }, + accounts: [{}, {}, {}], + }), + ).toBe(0); + expect( + resolveActiveIndex({ + activeIndex: 2, + accounts: [{}, {}, {}], + }), + ).toBe(2); + }); + + it("returns earliest future reset for matching family keys", () => { + const now = 1_000_000; + expect( + getRateLimitResetTimeForFamily( + { + rateLimitResetTimes: { + codex: now + 90_000, + "codex:gpt-5.2": now + 30_000, + "gpt-5.1": now + 10_000, + "codex:bad": undefined, + }, + }, + now, + "codex", + ), + ).toBe(now + 30_000); + }); + + it("returns null for missing or expired family reset state", () => { + const now = 5_000; + expect(getRateLimitResetTimeForFamily({}, now, "codex")).toBeNull(); + expect( + getRateLimitResetTimeForFamily( + { + rateLimitResetTimes: { + codex: now, + "codex:gpt-5.2": now - 1, + }, + }, + now, + "codex", + ), + ).toBeNull(); + }); + + it("formats wait labels only when a positive reset window exists", () => { + const now = 42_000; + expect( + formatRateLimitEntry( + { + rateLimitResetTimes: { + codex: now + 90_000, + }, + }, + now, + ), + ).toBe("resets in 1m 30s"); + expect( + formatRateLimitEntry( + { + rateLimitResetTimes: { + codex: now, + }, + }, + now, + ), + ).toBeNull(); + }); +}); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 27261cd..46af34d 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -499,6 +499,32 @@ describe("codex manager cli commands", () => { expect(logSpy.mock.calls.some((call) => String(call[0]).includes("live session OK"))).toBe(true); }); + it("shows rate-limit reset details in auth status output", async () => { + const now = Date.now(); + loadAccountsMock.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + email: "rate-limited@example.com", + refreshToken: "refresh-rate-limited", + addedAt: now - 1_000, + lastUsed: now - 1_000, + rateLimitResetTimes: { + codex: now + 120_000, + }, + }, + ], + }); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "status"]); + expect(exitCode).toBe(0); + expect(logSpy.mock.calls.some((call) => String(call[0]).includes("rate-limited"))).toBe(true); + }); + it("runs fix apply mode and returns a switch recommendation", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValueOnce({ diff --git a/test/index.test.ts b/test/index.test.ts index d6d9549..5593107 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -731,13 +731,20 @@ describe("OpenAIOAuthPlugin", () => { }); it("shows detailed status for accounts", async () => { + const now = Date.now(); mockStorage.accounts = [ - { refreshToken: "r1", email: "user@example.com", lastUsed: Date.now() - 60000 }, + { + refreshToken: "r1", + email: "user@example.com", + lastUsed: now - 60000, + rateLimitResetTimes: { codex: now + 120000 }, + }, ]; mockStorage.activeIndexByFamily = { codex: 0 }; const result = await plugin.tool["codex-status"].execute(); expect(result).toContain("Account Status"); expect(result).toContain("Active index by model family"); + expect(result).toContain("resets in"); }); }); From 00dba6efcc7e8976a2903eade39ee87825cb18d5 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 4 Mar 2026 06:00:36 +0800 Subject: [PATCH 2/7] refactor: centralize active-index family mutations Add shared helpers for per-family active-index map creation and bulk updates, then reuse them across plugin, CLI manager, and codex-cli sync flows to reduce duplicated state-mutation logic. Extend tests to lock switch and selection behavior for all model-family indexes. Co-authored-by: Codex --- index.ts | 13 ++------- lib/accounts/active-index.ts | 29 +++++++++++++++++++ lib/codex-cli/sync.ts | 7 ++--- lib/codex-manager.ts | 28 ++++++------------- test/active-index.test.ts | 54 ++++++++++++++++++++++++++++++++++++ test/index.test.ts | 10 +++++++ 6 files changed, 107 insertions(+), 34 deletions(-) create mode 100644 lib/accounts/active-index.ts create mode 100644 test/active-index.test.ts diff --git a/index.ts b/index.ts index b755f40..5ac326f 100644 --- a/index.ts +++ b/index.ts @@ -122,6 +122,7 @@ import { getRateLimitResetTimeForFamily, formatRateLimitEntry, } from "./lib/accounts/account-view.js"; +import { setActiveIndexForAllFamilies } from "./lib/accounts/active-index.js"; import { getStoragePath, loadAccounts, @@ -955,11 +956,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { account.lastUsed = now; account.lastSwitchReason = "rotation"; } - storage.activeIndex = index; - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = index; - } + setActiveIndexForAllFamilies(storage, index); await saveAccounts(storage); if (cachedAccountManager) { @@ -3522,11 +3519,7 @@ while (attempted.size < Math.max(1, accountCount)) { account.lastSwitchReason = "rotation"; } - storage.activeIndex = targetIndex; - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = targetIndex; - } + setActiveIndexForAllFamilies(storage, targetIndex); try { await saveAccounts(storage); } catch (saveError) { diff --git a/lib/accounts/active-index.ts b/lib/accounts/active-index.ts new file mode 100644 index 0000000..f95f19e --- /dev/null +++ b/lib/accounts/active-index.ts @@ -0,0 +1,29 @@ +import { MODEL_FAMILIES, type ModelFamily } from "../prompts/codex.js"; + +export interface ActiveIndexFamilyStorage { + activeIndex: number; + activeIndexByFamily?: Partial>; +} + +export function createActiveIndexByFamily( + index: number, + families: readonly ModelFamily[] = MODEL_FAMILIES, +): Partial> { + const byFamily: Partial> = {}; + for (const family of families) { + byFamily[family] = index; + } + return byFamily; +} + +export function setActiveIndexForAllFamilies( + storage: ActiveIndexFamilyStorage, + index: number, + families: readonly ModelFamily[] = MODEL_FAMILIES, +): void { + storage.activeIndex = index; + storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; + for (const family of families) { + storage.activeIndexByFamily[family] = index; + } +} diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index 38e6605..83d7f54 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -4,6 +4,7 @@ import { type AccountStorageV3, } from "../storage.js"; import { MODEL_FAMILIES, type ModelFamily } from "../prompts/codex.js"; +import { setActiveIndexForAllFamilies } from "../accounts/active-index.js"; import { createLogger } from "../logger.js"; import { loadCodexCliState, type CodexCliAccountSnapshot } from "./state.js"; import { @@ -162,11 +163,7 @@ function writeFamilyIndexes( storage: AccountStorageV3, index: number, ): void { - storage.activeIndex = index; - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = index; - } + setActiveIndexForAllFamilies(storage, index); } /** diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index cbf0210..d289ce0 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -26,6 +26,10 @@ import { resolveActiveIndex, formatRateLimitEntry, } from "./accounts/account-view.js"; +import { + createActiveIndexByFamily, + setActiveIndexForAllFamilies, +} from "./accounts/active-index.js"; import { ACCOUNT_LIMITS } from "./constants.js"; import { loadDashboardDisplaySettings, @@ -40,7 +44,7 @@ import { summarizeForecast, type ForecastAccountResult, } from "./forecast.js"; -import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; +import { MODEL_FAMILIES } from "./prompts/codex.js"; import { fetchCodexQuotaSnapshot, formatQuotaSnapshotLine, @@ -1331,10 +1335,7 @@ async function persistAccountPool( : selectedAccountIndex === null ? fallbackActiveIndex : Math.max(0, Math.min(selectedAccountIndex, accounts.length - 1)); - const activeIndexByFamily: Partial> = {}; - for (const family of MODEL_FAMILIES) { - activeIndexByFamily[family] = nextActiveIndex; - } + const activeIndexByFamily = createActiveIndexByFamily(nextActiveIndex); await saveAccounts({ version: 3, @@ -2345,10 +2346,7 @@ interface VerifyFlaggedReport { } function createEmptyAccountStorage(): AccountStorageV3 { - const activeIndexByFamily: Partial> = {}; - for (const family of MODEL_FAMILIES) { - activeIndexByFamily[family] = 0; - } + const activeIndexByFamily = createActiveIndexByFamily(0); return { version: 3, accounts: [], @@ -3631,11 +3629,7 @@ async function handleManageAction( const idx = menuResult.deleteAccountIndex; if (idx >= 0 && idx < storage.accounts.length) { storage.accounts.splice(idx, 1); - storage.activeIndex = 0; - storage.activeIndexByFamily = {}; - for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = 0; - } + setActiveIndexForAllFamilies(storage, 0); await saveAccounts(storage); console.log(`Deleted account ${idx + 1}.`); } @@ -3856,11 +3850,7 @@ async function runSwitch(args: string[]): Promise { return 1; } - storage.activeIndex = targetIndex; - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - storage.activeIndexByFamily[family] = targetIndex; - } + setActiveIndexForAllFamilies(storage, targetIndex); const wasDisabled = account.enabled === false; if (wasDisabled) { account.enabled = true; diff --git a/test/active-index.test.ts b/test/active-index.test.ts new file mode 100644 index 0000000..bc7768d --- /dev/null +++ b/test/active-index.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { MODEL_FAMILIES } from "../lib/prompts/codex.js"; +import { + createActiveIndexByFamily, + setActiveIndexForAllFamilies, +} from "../lib/accounts/active-index.js"; + +describe("active-index helpers", () => { + it("creates a per-family active index map", () => { + expect(createActiveIndexByFamily(2)).toEqual({ + "gpt-5-codex": 2, + "codex-max": 2, + codex: 2, + "gpt-5.2": 2, + "gpt-5.1": 2, + }); + }); + + it("sets active index across all model families", () => { + const storage: { + activeIndex: number; + activeIndexByFamily?: Partial>; + } = { + activeIndex: 0, + }; + + setActiveIndexForAllFamilies(storage, 3); + + expect(storage.activeIndex).toBe(3); + for (const family of MODEL_FAMILIES) { + expect(storage.activeIndexByFamily?.[family]).toBe(3); + } + }); + + it("preserves unrelated keys when reusing existing family map objects", () => { + const storage: { + activeIndex: number; + activeIndexByFamily?: Record; + } = { + activeIndex: 1, + activeIndexByFamily: { + legacy: 99, + codex: 1, + }, + }; + + setActiveIndexForAllFamilies(storage, 0); + + expect(storage.activeIndexByFamily?.legacy).toBe(99); + for (const family of MODEL_FAMILIES) { + expect(storage.activeIndexByFamily?.[family]).toBe(0); + } + }); +}); diff --git a/test/index.test.ts b/test/index.test.ts index 5593107..76f35bf 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -696,6 +696,11 @@ describe("OpenAIOAuthPlugin", () => { { refreshToken: "r2", email: "user2@example.com" }, ]; const result = await plugin.tool["codex-switch"].execute({ index: 2 }); + const { MODEL_FAMILIES } = await import("../lib/prompts/codex.js"); + expect(mockStorage.activeIndex).toBe(1); + for (const family of MODEL_FAMILIES) { + expect(mockStorage.activeIndexByFamily[family]).toBe(1); + } expect(result).toContain("Switched to account"); }); @@ -1906,6 +1911,11 @@ describe("OpenAIOAuthPlugin event handler edge cases", () => { await plugin.event({ event: { type: "account.select", properties: { accountIndex: 1 } }, }); + const { MODEL_FAMILIES } = await import("../lib/prompts/codex.js"); + expect(mockStorage.activeIndex).toBe(1); + for (const family of MODEL_FAMILIES) { + expect(mockStorage.activeIndexByFamily[family]).toBe(1); + } }); it("reloads account manager from disk when handling account.select", async () => { From 0d96ab3a2535870d18cfd4c3afeace1b027b02f1 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 4 Mar 2026 06:26:50 +0800 Subject: [PATCH 3/7] refactor: unify active-index normalization flow Extract active-index normalization into a shared helper and reuse it in plugin account-flow clamping, codex-cli sync, and doctor normalization paths. Add focused tests covering bounded normalization and empty-account map handling to preserve behavior across both clear and fill modes. Co-authored-by: Codex --- index.ts | 22 ++++--------- lib/accounts/active-index.ts | 53 +++++++++++++++++++++++++++++ lib/codex-cli/sync.ts | 21 ++++-------- lib/codex-manager.ts | 22 ++----------- test/active-index.test.ts | 64 ++++++++++++++++++++++++++++++++++++ test/index.test.ts | 4 +++ 6 files changed, 136 insertions(+), 50 deletions(-) diff --git a/index.ts b/index.ts index 5ac326f..e0c9aaf 100644 --- a/index.ts +++ b/index.ts @@ -122,7 +122,10 @@ import { getRateLimitResetTimeForFamily, formatRateLimitEntry, } from "./lib/accounts/account-view.js"; -import { setActiveIndexForAllFamilies } from "./lib/accounts/active-index.js"; +import { + setActiveIndexForAllFamilies, + normalizeActiveIndexByFamily, +} from "./lib/accounts/active-index.js"; import { getStoragePath, loadAccounts, @@ -2385,20 +2388,9 @@ while (attempted.size < Math.max(1, accountCount)) { let refreshAccountIndex: number | undefined; const clampActiveIndices = (storage: AccountStorageV3): void => { - const count = storage.accounts.length; - if (count === 0) { - storage.activeIndex = 0; - storage.activeIndexByFamily = {}; - return; - } - storage.activeIndex = Math.max(0, Math.min(storage.activeIndex, count - 1)); - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - const raw = storage.activeIndexByFamily[family]; - const candidate = - typeof raw === "number" && Number.isFinite(raw) ? raw : storage.activeIndex; - storage.activeIndexByFamily[family] = Math.max(0, Math.min(candidate, count - 1)); - } + normalizeActiveIndexByFamily(storage, storage.accounts.length, { + clearFamilyMapWhenEmpty: true, + }); }; const isFlaggableFailure = (failure: Extract): boolean => { diff --git a/lib/accounts/active-index.ts b/lib/accounts/active-index.ts index f95f19e..40171f1 100644 --- a/lib/accounts/active-index.ts +++ b/lib/accounts/active-index.ts @@ -5,6 +5,16 @@ export interface ActiveIndexFamilyStorage { activeIndexByFamily?: Partial>; } +interface NormalizeActiveIndexOptions { + clearFamilyMapWhenEmpty?: boolean; + families?: readonly ModelFamily[]; +} + +function clampIndex(index: number, count: number): number { + if (count <= 0) return 0; + return Math.max(0, Math.min(index, count - 1)); +} + export function createActiveIndexByFamily( index: number, families: readonly ModelFamily[] = MODEL_FAMILIES, @@ -27,3 +37,46 @@ export function setActiveIndexForAllFamilies( storage.activeIndexByFamily[family] = index; } } + +export function normalizeActiveIndexByFamily( + storage: ActiveIndexFamilyStorage, + accountCount: number, + options: NormalizeActiveIndexOptions = {}, +): boolean { + const families = options.families ?? MODEL_FAMILIES; + const clearFamilyMapWhenEmpty = options.clearFamilyMapWhenEmpty === true; + let changed = false; + + const nextActiveIndex = clampIndex(storage.activeIndex, accountCount); + if (storage.activeIndex !== nextActiveIndex) { + storage.activeIndex = nextActiveIndex; + changed = true; + } + + if (accountCount === 0 && clearFamilyMapWhenEmpty) { + const hadKeys = !!storage.activeIndexByFamily && Object.keys(storage.activeIndexByFamily).length > 0; + if (storage.activeIndexByFamily === undefined || hadKeys) { + storage.activeIndexByFamily = {}; + changed = true; + } + return changed; + } + + if (!storage.activeIndexByFamily) { + storage.activeIndexByFamily = {}; + changed = true; + } + + for (const family of families) { + const raw = storage.activeIndexByFamily[family]; + const fallback = storage.activeIndex; + const candidate = typeof raw === "number" && Number.isFinite(raw) ? raw : fallback; + const nextValue = accountCount === 0 ? 0 : clampIndex(candidate, accountCount); + if (storage.activeIndexByFamily[family] !== nextValue) { + storage.activeIndexByFamily[family] = nextValue; + changed = true; + } + } + + return changed; +} diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index 83d7f54..787fd55 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -3,8 +3,11 @@ import { type AccountMetadataV3, type AccountStorageV3, } from "../storage.js"; -import { MODEL_FAMILIES, type ModelFamily } from "../prompts/codex.js"; -import { setActiveIndexForAllFamilies } from "../accounts/active-index.js"; +import { type ModelFamily } from "../prompts/codex.js"; +import { + setActiveIndexForAllFamilies, + normalizeActiveIndexByFamily, +} from "../accounts/active-index.js"; import { createLogger } from "../logger.js"; import { loadCodexCliState, type CodexCliAccountSnapshot } from "./state.js"; import { @@ -182,19 +185,7 @@ function writeFamilyIndexes( * @param storage - The account storage object whose indexes will be normalized and clamped */ function normalizeStoredFamilyIndexes(storage: AccountStorageV3): void { - const count = storage.accounts.length; - const clamped = count === 0 ? 0 : Math.max(0, Math.min(storage.activeIndex, count - 1)); - if (storage.activeIndex !== clamped) { - storage.activeIndex = clamped; - } - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - const raw = storage.activeIndexByFamily[family]; - const resolved = - typeof raw === "number" && Number.isFinite(raw) ? raw : storage.activeIndex; - storage.activeIndexByFamily[family] = - count === 0 ? 0 : Math.max(0, Math.min(resolved, count - 1)); - } + normalizeActiveIndexByFamily(storage, storage.accounts.length); } /** diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index d289ce0..b9b2ece 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -29,6 +29,7 @@ import { import { createActiveIndexByFamily, setActiveIndexForAllFamilies, + normalizeActiveIndexByFamily, } from "./accounts/active-index.js"; import { ACCOUNT_LIMITS } from "./constants.js"; import { @@ -44,7 +45,6 @@ import { summarizeForecast, type ForecastAccountResult, } from "./forecast.js"; -import { MODEL_FAMILIES } from "./prompts/codex.js"; import { fetchCodexQuotaSnapshot, formatQuotaSnapshotLine, @@ -3051,25 +3051,7 @@ function hasPlaceholderEmail(value: string | undefined): boolean { } function normalizeDoctorIndexes(storage: AccountStorageV3): boolean { - const total = storage.accounts.length; - const nextActive = total === 0 ? 0 : Math.max(0, Math.min(storage.activeIndex, total - 1)); - let changed = false; - if (storage.activeIndex !== nextActive) { - storage.activeIndex = nextActive; - changed = true; - } - storage.activeIndexByFamily = storage.activeIndexByFamily ?? {}; - for (const family of MODEL_FAMILIES) { - const raw = storage.activeIndexByFamily[family]; - const fallback = storage.activeIndex; - const candidate = typeof raw === "number" && Number.isFinite(raw) ? raw : fallback; - const clamped = total === 0 ? 0 : Math.max(0, Math.min(candidate, total - 1)); - if (storage.activeIndexByFamily[family] !== clamped) { - storage.activeIndexByFamily[family] = clamped; - changed = true; - } - } - return changed; + return normalizeActiveIndexByFamily(storage, storage.accounts.length); } function applyDoctorFixes(storage: AccountStorageV3): { changed: boolean; actions: DoctorFixAction[] } { diff --git a/test/active-index.test.ts b/test/active-index.test.ts index bc7768d..15389e4 100644 --- a/test/active-index.test.ts +++ b/test/active-index.test.ts @@ -3,6 +3,7 @@ import { MODEL_FAMILIES } from "../lib/prompts/codex.js"; import { createActiveIndexByFamily, setActiveIndexForAllFamilies, + normalizeActiveIndexByFamily, } from "../lib/accounts/active-index.js"; describe("active-index helpers", () => { @@ -51,4 +52,67 @@ describe("active-index helpers", () => { expect(storage.activeIndexByFamily?.[family]).toBe(0); } }); + + it("normalizes active and per-family indexes within account bounds", () => { + const storage: { + activeIndex: number; + activeIndexByFamily?: Partial>; + } = { + activeIndex: 99, + activeIndexByFamily: { + codex: Number.NaN, + "gpt-5.1": -3, + }, + }; + + const changed = normalizeActiveIndexByFamily(storage, 2); + + expect(changed).toBe(true); + expect(storage.activeIndex).toBe(1); + for (const family of MODEL_FAMILIES) { + expect(storage.activeIndexByFamily?.[family]).toBeGreaterThanOrEqual(0); + expect(storage.activeIndexByFamily?.[family]).toBeLessThanOrEqual(1); + } + expect(storage.activeIndexByFamily?.codex).toBe(1); + expect(storage.activeIndexByFamily?.["gpt-5.1"]).toBe(0); + }); + + it("clears family map when empty accounts are requested to clear", () => { + const storage: { + activeIndex: number; + activeIndexByFamily?: Record; + } = { + activeIndex: 5, + activeIndexByFamily: { + codex: 2, + legacy: 9, + }, + }; + + const changed = normalizeActiveIndexByFamily(storage, 0, { + clearFamilyMapWhenEmpty: true, + }); + + expect(changed).toBe(true); + expect(storage.activeIndex).toBe(0); + expect(storage.activeIndexByFamily).toEqual({}); + }); + + it("fills model-family indexes with zero for empty account sets by default", () => { + const storage: { + activeIndex: number; + activeIndexByFamily?: Partial>; + } = { + activeIndex: 3, + activeIndexByFamily: {}, + }; + + const changed = normalizeActiveIndexByFamily(storage, 0); + + expect(changed).toBe(true); + expect(storage.activeIndex).toBe(0); + for (const family of MODEL_FAMILIES) { + expect(storage.activeIndexByFamily?.[family]).toBe(0); + } + }); }); diff --git a/test/index.test.ts b/test/index.test.ts index 76f35bf..f4e3188 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -803,9 +803,13 @@ describe("OpenAIOAuthPlugin", () => { it("handles removal of last account", async () => { mockStorage.accounts = [{ refreshToken: "r1", email: "user@example.com" }]; + mockStorage.activeIndex = 3; + mockStorage.activeIndexByFamily = { codex: 3, "gpt-5.1": 3 }; const result = await plugin.tool["codex-remove"].execute({ index: 1 }); expect(result).toContain("Removed"); expect(result).toContain("No accounts remaining"); + expect(mockStorage.activeIndex).toBe(0); + expect(mockStorage.activeIndexByFamily).toEqual({}); }); }); From a0b9d72f099f0c571a791c4825a1d3f114260193 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 4 Mar 2026 06:31:29 +0800 Subject: [PATCH 4/7] refactor: share family rate-limit status formatting Extract model-family rate-limit status rendering into account-view helpers and replace duplicate status mapping logic in the plugin status output paths. Add unit coverage for mixed limited/ok family labels to protect output behavior. Co-authored-by: Codex --- index.ts | 14 +++----------- lib/accounts/account-view.ts | 14 +++++++++++++- test/account-view.test.ts | 17 +++++++++++++++++ 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/index.ts b/index.ts index e0c9aaf..6f2e09b 100644 --- a/index.ts +++ b/index.ts @@ -119,8 +119,8 @@ import { } from "./lib/accounts.js"; import { resolveActiveIndex, - getRateLimitResetTimeForFamily, formatRateLimitEntry, + formatRateLimitStatusByFamily, } from "./lib/accounts/account-view.js"; import { setActiveIndexForAllFamilies, @@ -3598,11 +3598,7 @@ while (attempted.size < Math.max(1, accountCount)) { lines.push(""); lines.push(...formatUiSection(ui, "Rate limits by model family (per account)")); storage.accounts.forEach((account, index) => { - const statuses = MODEL_FAMILIES.map((family) => { - const resetAt = getRateLimitResetTimeForFamily(account, now, family); - if (typeof resetAt !== "number") return `${family}=ok`; - return `${family}=${formatWaitTime(resetAt - now)}`; - }); + const statuses = formatRateLimitStatusByFamily(account, now); lines.push(formatUiItem(ui, `Account ${index + 1}: ${statuses.join(" | ")}`)); }); @@ -3651,11 +3647,7 @@ while (attempted.size < Math.max(1, accountCount)) { lines.push(""); lines.push("Rate limits by model family (per account):"); storage.accounts.forEach((account, index) => { - const statuses = MODEL_FAMILIES.map((family) => { - const resetAt = getRateLimitResetTimeForFamily(account, now, family); - if (typeof resetAt !== "number") return `${family}=ok`; - return `${family}=${formatWaitTime(resetAt - now)}`; - }); + const statuses = formatRateLimitStatusByFamily(account, now); lines.push(` Account ${index + 1}: ${statuses.join(" | ")}`); }); diff --git a/lib/accounts/account-view.ts b/lib/accounts/account-view.ts index 576711d..025ab1d 100644 --- a/lib/accounts/account-view.ts +++ b/lib/accounts/account-view.ts @@ -1,4 +1,4 @@ -import type { ModelFamily } from "../prompts/codex.js"; +import { MODEL_FAMILIES, type ModelFamily } from "../prompts/codex.js"; import { formatWaitTime } from "./rate-limits.js"; export interface ActiveAccountStorageView { @@ -55,3 +55,15 @@ export function formatRateLimitEntry( if (remaining <= 0) return null; return `resets in ${formatWaitTime(remaining)}`; } + +export function formatRateLimitStatusByFamily( + account: AccountRateLimitView, + now: number, + families: readonly ModelFamily[] = MODEL_FAMILIES, +): string[] { + return families.map((family) => { + const resetAt = getRateLimitResetTimeForFamily(account, now, family); + if (typeof resetAt !== "number") return `${family}=ok`; + return `${family}=${formatWaitTime(resetAt - now)}`; + }); +} diff --git a/test/account-view.test.ts b/test/account-view.test.ts index ce554f5..f1c3b01 100644 --- a/test/account-view.test.ts +++ b/test/account-view.test.ts @@ -3,6 +3,7 @@ import { resolveActiveIndex, getRateLimitResetTimeForFamily, formatRateLimitEntry, + formatRateLimitStatusByFamily, } from "../lib/accounts/account-view.js"; describe("account-view helpers", () => { @@ -90,4 +91,20 @@ describe("account-view helpers", () => { ), ).toBeNull(); }); + + it("formats model-family status labels with ok and remaining times", () => { + const now = 1_000_000; + expect( + formatRateLimitStatusByFamily( + { + rateLimitResetTimes: { + codex: now + 30_000, + "gpt-5.1": now + 70_000, + }, + }, + now, + ["codex", "gpt-5.1", "codex-max"], + ), + ).toEqual(["codex=30s", "gpt-5.1=1m 10s", "codex-max=ok"]); + }); }); From f8bf733fddcbd9e3e458f55ebdca67b82e49f996 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 4 Mar 2026 06:33:33 +0800 Subject: [PATCH 5/7] refactor: share family index label formatting Move active-index-by-family label rendering into shared account-view helpers and reuse it in both v2 and legacy codex status output paths. Add helper tests to lock 1-based labels and placeholder output for missing families. Co-authored-by: Codex --- index.ts | 15 +++++---------- lib/accounts/account-view.ts | 13 +++++++++++++ test/account-view.test.ts | 16 ++++++++++++++++ 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/index.ts b/index.ts index 6f2e09b..7116a50 100644 --- a/index.ts +++ b/index.ts @@ -120,6 +120,7 @@ import { import { resolveActiveIndex, formatRateLimitEntry, + formatActiveIndexByFamilyLabels, formatRateLimitStatusByFamily, } from "./lib/accounts/account-view.js"; import { @@ -3588,11 +3589,8 @@ while (attempted.size < Math.max(1, accountCount)) { lines.push(""); lines.push(...formatUiSection(ui, "Active index by model family")); - for (const family of MODEL_FAMILIES) { - const idx = storage.activeIndexByFamily?.[family]; - const familyIndexLabel = - typeof idx === "number" && Number.isFinite(idx) ? String(idx + 1) : "-"; - lines.push(formatUiItem(ui, `${family}: ${familyIndexLabel}`)); + for (const line of formatActiveIndexByFamilyLabels(storage.activeIndexByFamily)) { + lines.push(formatUiItem(ui, line)); } lines.push(""); @@ -3637,11 +3635,8 @@ while (attempted.size < Math.max(1, accountCount)) { lines.push(""); lines.push("Active index by model family:"); - for (const family of MODEL_FAMILIES) { - const idx = storage.activeIndexByFamily?.[family]; - const familyIndexLabel = - typeof idx === "number" && Number.isFinite(idx) ? String(idx + 1) : "-"; - lines.push(` ${family}: ${familyIndexLabel}`); + for (const line of formatActiveIndexByFamilyLabels(storage.activeIndexByFamily)) { + lines.push(` ${line}`); } lines.push(""); diff --git a/lib/accounts/account-view.ts b/lib/accounts/account-view.ts index 025ab1d..4fec315 100644 --- a/lib/accounts/account-view.ts +++ b/lib/accounts/account-view.ts @@ -11,6 +11,8 @@ export interface AccountRateLimitView { rateLimitResetTimes?: Record; } +export type ActiveIndexByFamilyView = Partial> | undefined; + export function resolveActiveIndex( storage: ActiveAccountStorageView, family: ModelFamily = "codex", @@ -67,3 +69,14 @@ export function formatRateLimitStatusByFamily( return `${family}=${formatWaitTime(resetAt - now)}`; }); } + +export function formatActiveIndexByFamilyLabels( + activeIndexByFamily: ActiveIndexByFamilyView, + families: readonly ModelFamily[] = MODEL_FAMILIES, +): string[] { + return families.map((family) => { + const idx = activeIndexByFamily?.[family]; + const label = typeof idx === "number" && Number.isFinite(idx) ? String(idx + 1) : "-"; + return `${family}: ${label}`; + }); +} diff --git a/test/account-view.test.ts b/test/account-view.test.ts index f1c3b01..b33166d 100644 --- a/test/account-view.test.ts +++ b/test/account-view.test.ts @@ -3,6 +3,7 @@ import { resolveActiveIndex, getRateLimitResetTimeForFamily, formatRateLimitEntry, + formatActiveIndexByFamilyLabels, formatRateLimitStatusByFamily, } from "../lib/accounts/account-view.js"; @@ -107,4 +108,19 @@ describe("account-view helpers", () => { ), ).toEqual(["codex=30s", "gpt-5.1=1m 10s", "codex-max=ok"]); }); + + it("formats active-index labels with 1-based values and fallback placeholders", () => { + expect( + formatActiveIndexByFamilyLabels({ + codex: 0, + "gpt-5.1": 2, + }), + ).toEqual([ + "gpt-5-codex: -", + "codex-max: -", + "codex: 1", + "gpt-5.2: -", + "gpt-5.1: 3", + ]); + }); }); From 0e0edf87d17952b4a9e79493e7fd3c9bf81e5921 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 4 Mar 2026 06:36:11 +0800 Subject: [PATCH 6/7] refactor: extract account-removal index reconciliation Move codex-remove index/family reindexing into shared active-index helpers to centralize the mutation policy and reduce inline branching in the plugin entrypoint. Add helper tests for in-range and out-of-range removal behavior. Co-authored-by: Codex --- index.ts | 27 ++--------------------- lib/accounts/active-index.ts | 42 ++++++++++++++++++++++++++++++++++++ test/active-index.test.ts | 41 +++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 25 deletions(-) diff --git a/index.ts b/index.ts index 7116a50..aa88f6e 100644 --- a/index.ts +++ b/index.ts @@ -126,6 +126,7 @@ import { import { setActiveIndexForAllFamilies, normalizeActiveIndexByFamily, + removeAccountAndReconcileActiveIndexes, } from "./lib/accounts/active-index.js"; import { getStoragePath, @@ -3861,31 +3862,7 @@ while (attempted.size < Math.max(1, accountCount)) { const label = formatAccountLabel(account, targetIndex); - storage.accounts.splice(targetIndex, 1); - - if (storage.accounts.length === 0) { - storage.activeIndex = 0; - storage.activeIndexByFamily = {}; - } else { - if (storage.activeIndex >= storage.accounts.length) { - storage.activeIndex = 0; - } else if (storage.activeIndex > targetIndex) { - storage.activeIndex -= 1; - } - - if (storage.activeIndexByFamily) { - for (const family of MODEL_FAMILIES) { - const idx = storage.activeIndexByFamily[family]; - if (typeof idx === "number") { - if (idx >= storage.accounts.length) { - storage.activeIndexByFamily[family] = 0; - } else if (idx > targetIndex) { - storage.activeIndexByFamily[family] = idx - 1; - } - } - } - } - } + removeAccountAndReconcileActiveIndexes(storage, targetIndex); try { await saveAccounts(storage); diff --git a/lib/accounts/active-index.ts b/lib/accounts/active-index.ts index 40171f1..e0bbb8a 100644 --- a/lib/accounts/active-index.ts +++ b/lib/accounts/active-index.ts @@ -5,6 +5,10 @@ export interface ActiveIndexFamilyStorage { activeIndexByFamily?: Partial>; } +export interface AccountListActiveIndexStorage extends ActiveIndexFamilyStorage { + accounts: unknown[]; +} + interface NormalizeActiveIndexOptions { clearFamilyMapWhenEmpty?: boolean; families?: readonly ModelFamily[]; @@ -80,3 +84,41 @@ export function normalizeActiveIndexByFamily( return changed; } + +export function removeAccountAndReconcileActiveIndexes( + storage: AccountListActiveIndexStorage, + targetIndex: number, + families: readonly ModelFamily[] = MODEL_FAMILIES, +): boolean { + if (targetIndex < 0 || targetIndex >= storage.accounts.length) { + return false; + } + + storage.accounts.splice(targetIndex, 1); + + if (storage.accounts.length === 0) { + storage.activeIndex = 0; + storage.activeIndexByFamily = {}; + return true; + } + + if (storage.activeIndex >= storage.accounts.length) { + storage.activeIndex = 0; + } else if (storage.activeIndex > targetIndex) { + storage.activeIndex -= 1; + } + + if (storage.activeIndexByFamily) { + for (const family of families) { + const idx = storage.activeIndexByFamily[family]; + if (typeof idx !== "number") continue; + if (idx >= storage.accounts.length) { + storage.activeIndexByFamily[family] = 0; + } else if (idx > targetIndex) { + storage.activeIndexByFamily[family] = idx - 1; + } + } + } + + return true; +} diff --git a/test/active-index.test.ts b/test/active-index.test.ts index 15389e4..5ab421e 100644 --- a/test/active-index.test.ts +++ b/test/active-index.test.ts @@ -4,6 +4,7 @@ import { createActiveIndexByFamily, setActiveIndexForAllFamilies, normalizeActiveIndexByFamily, + removeAccountAndReconcileActiveIndexes, } from "../lib/accounts/active-index.js"; describe("active-index helpers", () => { @@ -115,4 +116,44 @@ describe("active-index helpers", () => { expect(storage.activeIndexByFamily?.[family]).toBe(0); } }); + + it("removes accounts and reconciles active indexes for remaining entries", () => { + const storage: { + accounts: unknown[]; + activeIndex: number; + activeIndexByFamily?: Partial>; + } = { + accounts: [{ id: 1 }, { id: 2 }, { id: 3 }], + activeIndex: 2, + activeIndexByFamily: { + codex: 2, + "gpt-5.1": 2, + }, + }; + + const changed = removeAccountAndReconcileActiveIndexes(storage, 0); + + expect(changed).toBe(true); + expect(storage.accounts).toHaveLength(2); + expect(storage.activeIndex).toBe(0); + expect(storage.activeIndexByFamily?.codex).toBe(0); + expect(storage.activeIndexByFamily?.["gpt-5.1"]).toBe(0); + }); + + it("returns false and keeps storage unchanged for out-of-range removals", () => { + const storage: { + accounts: unknown[]; + activeIndex: number; + activeIndexByFamily?: Record; + } = { + accounts: [{ id: 1 }], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + expect(removeAccountAndReconcileActiveIndexes(storage, 2)).toBe(false); + expect(storage.accounts).toHaveLength(1); + expect(storage.activeIndex).toBe(0); + expect(storage.activeIndexByFamily).toEqual({ codex: 0 }); + }); }); From 6b7b2e8926504eba67fc4cba0ac5af4abd63a998 Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 4 Mar 2026 07:27:38 +0800 Subject: [PATCH 7/7] refactor: centralize account storage clone/empty factories Introduce shared account-storage view helpers for creating empty v3 storage and cloning mutable working copies, then reuse them across plugin login/check flow, codex-cli sync, and manager reset paths. Add unit tests to lock clone isolation and family-index initialization behavior. Co-authored-by: Codex --- index.ts | 24 ++++++------------ lib/accounts/storage-view.ts | 29 +++++++++++++++++++++ lib/codex-cli/sync.ts | 26 ++++--------------- lib/codex-manager.ts | 18 ++++--------- test/account-storage-view.test.ts | 42 +++++++++++++++++++++++++++++++ 5 files changed, 89 insertions(+), 50 deletions(-) create mode 100644 lib/accounts/storage-view.ts create mode 100644 test/account-storage-view.test.ts diff --git a/index.ts b/index.ts index aa88f6e..54a55a3 100644 --- a/index.ts +++ b/index.ts @@ -123,6 +123,10 @@ import { formatActiveIndexByFamilyLabels, formatRateLimitStatusByFamily, } from "./lib/accounts/account-view.js"; +import { + cloneAccountStorage, + createEmptyAccountStorage, +} from "./lib/accounts/storage-view.js"; import { setActiveIndexForAllFamilies, normalizeActiveIndexByFamily, @@ -2653,14 +2657,8 @@ while (attempted.size < Math.max(1, accountCount)) { const runAccountCheck = async (deepProbe: boolean): Promise => { const loadedStorage = await hydrateEmails(await loadAccounts()); const workingStorage = loadedStorage - ? { - ...loadedStorage, - accounts: loadedStorage.accounts.map((account) => ({ ...account })), - activeIndexByFamily: loadedStorage.activeIndexByFamily - ? { ...loadedStorage.activeIndexByFamily } - : {}, - } - : { version: 3 as const, accounts: [], activeIndex: 0, activeIndexByFamily: {} }; + ? cloneAccountStorage(loadedStorage) + : createEmptyAccountStorage(); if (workingStorage.accounts.length === 0) { console.log("\nNo accounts to check.\n"); @@ -3006,14 +3004,8 @@ while (attempted.size < Math.max(1, accountCount)) { while (true) { const loadedStorage = await hydrateEmails(await loadAccounts()); const workingStorage = loadedStorage - ? { - ...loadedStorage, - accounts: loadedStorage.accounts.map((account) => ({ ...account })), - activeIndexByFamily: loadedStorage.activeIndexByFamily - ? { ...loadedStorage.activeIndexByFamily } - : {}, - } - : { version: 3 as const, accounts: [], activeIndex: 0, activeIndexByFamily: {} }; + ? cloneAccountStorage(loadedStorage) + : createEmptyAccountStorage(); const flaggedStorage = await loadFlaggedAccounts(); if (workingStorage.accounts.length === 0 && flaggedStorage.accounts.length === 0) { diff --git a/lib/accounts/storage-view.ts b/lib/accounts/storage-view.ts new file mode 100644 index 0000000..ef5feb4 --- /dev/null +++ b/lib/accounts/storage-view.ts @@ -0,0 +1,29 @@ +import type { AccountStorageV3 } from "../storage.js"; +import { createActiveIndexByFamily } from "./active-index.js"; + +interface CreateEmptyAccountStorageOptions { + initializeFamilyIndexes?: boolean; +} + +export function createEmptyAccountStorage( + options: CreateEmptyAccountStorageOptions = {}, +): AccountStorageV3 { + const initializeFamilyIndexes = options.initializeFamilyIndexes === true; + return { + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: initializeFamilyIndexes ? createActiveIndexByFamily(0) : {}, + }; +} + +export function cloneAccountStorage(storage: AccountStorageV3): AccountStorageV3 { + return { + version: 3, + accounts: storage.accounts.map((account) => ({ ...account })), + activeIndex: storage.activeIndex, + activeIndexByFamily: storage.activeIndexByFamily + ? { ...storage.activeIndexByFamily } + : {}, + }; +} diff --git a/lib/codex-cli/sync.ts b/lib/codex-cli/sync.ts index 787fd55..af880fa 100644 --- a/lib/codex-cli/sync.ts +++ b/lib/codex-cli/sync.ts @@ -8,6 +8,10 @@ import { setActiveIndexForAllFamilies, normalizeActiveIndexByFamily, } from "../accounts/active-index.js"; +import { + cloneAccountStorage, + createEmptyAccountStorage, +} from "../accounts/storage-view.js"; import { createLogger } from "../logger.js"; import { loadCodexCliState, type CodexCliAccountSnapshot } from "./state.js"; import { @@ -24,26 +28,6 @@ function normalizeEmail(value: string | undefined): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -function createEmptyStorage(): AccountStorageV3 { - return { - version: 3, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - }; -} - -function cloneStorage(storage: AccountStorageV3): AccountStorageV3 { - return { - version: 3, - accounts: storage.accounts.map((account) => ({ ...account })), - activeIndex: storage.activeIndex, - activeIndexByFamily: storage.activeIndexByFamily - ? { ...storage.activeIndexByFamily } - : {}, - }; -} - function buildIndexByAccountId(accounts: AccountMetadataV3[]): Map { const map = new Map(); for (let i = 0; i < accounts.length; i += 1) { @@ -268,7 +252,7 @@ export async function syncAccountStorageFromCodexCli( return { storage: current, changed: false }; } - const next = current ? cloneStorage(current) : createEmptyStorage(); + const next = current ? cloneAccountStorage(current) : createEmptyAccountStorage(); let changed = false; for (const snapshot of state.accounts) { diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index b9b2ece..a8f5c56 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -31,6 +31,9 @@ import { setActiveIndexForAllFamilies, normalizeActiveIndexByFamily, } from "./accounts/active-index.js"; +import { + createEmptyAccountStorage as createEmptyAccountStorageBase, +} from "./accounts/storage-view.js"; import { ACCOUNT_LIMITS } from "./constants.js"; import { loadDashboardDisplaySettings, @@ -2346,13 +2349,7 @@ interface VerifyFlaggedReport { } function createEmptyAccountStorage(): AccountStorageV3 { - const activeIndexByFamily = createActiveIndexByFamily(0); - return { - version: 3, - accounts: [], - activeIndex: 0, - activeIndexByFamily, - }; + return createEmptyAccountStorageBase({ initializeFamilyIndexes: true }); } function findExistingAccountIndexForFlagged( @@ -3589,12 +3586,7 @@ async function runDoctor(args: string[]): Promise { } async function clearAccountsAndReset(): Promise { - await saveAccounts({ - version: 3, - accounts: [], - activeIndex: 0, - activeIndexByFamily: {}, - }); + await saveAccounts(createEmptyAccountStorageBase()); } async function handleManageAction( diff --git a/test/account-storage-view.test.ts b/test/account-storage-view.test.ts new file mode 100644 index 0000000..4c1ac5b --- /dev/null +++ b/test/account-storage-view.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { MODEL_FAMILIES } from "../lib/prompts/codex.js"; +import { + cloneAccountStorage, + createEmptyAccountStorage, +} from "../lib/accounts/storage-view.js"; + +describe("account storage view helpers", () => { + it("creates default empty storage without prefilled family indexes", () => { + expect(createEmptyAccountStorage()).toEqual({ + version: 3, + accounts: [], + activeIndex: 0, + activeIndexByFamily: {}, + }); + }); + + it("can create empty storage with per-family indexes initialized", () => { + const storage = createEmptyAccountStorage({ initializeFamilyIndexes: true }); + for (const family of MODEL_FAMILIES) { + expect(storage.activeIndexByFamily?.[family]).toBe(0); + } + }); + + it("clones account storage to isolated mutable copies", () => { + const original = { + version: 3 as const, + accounts: [{ refreshToken: "r1", email: "a@example.com", enabled: true }], + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + }; + + const clone = cloneAccountStorage(original); + expect(clone).toEqual(original); + + clone.accounts[0]!.email = "b@example.com"; + clone.activeIndexByFamily.codex = 1; + + expect(original.accounts[0]!.email).toBe("a@example.com"); + expect(original.activeIndexByFamily.codex).toBe(0); + }); +});