diff --git a/docs/features.md b/docs/features.md index ab321e0d9..489c47240 100644 --- a/docs/features.md +++ b/docs/features.md @@ -14,7 +14,7 @@ User-facing capability map for Codex CLI multi-account OAuth, account switching, | Explicit active-account switching | Persist a manual pin by index instead of relying on hidden state | `codex-multi-auth switch ` | | Clear manual pin | Drop the persisted pin so hybrid rotation resumes | `codex-multi-auth unpin` | | Workspace selection | List or set personal vs business/team workspaces under one account | `codex-multi-auth workspace [workspace]` | -| Fast and deep health checks | See whether the current pool is usable before a coding session | `codex-multi-auth check` | +| Fast and deep health checks | See whether the current pool is usable before a coding session, including when each quota window resets | `codex-multi-auth check` | | Flagged-account verification and restore | Recover accounts sidelined during prior failures | `codex-multi-auth verify-flagged` | --- diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 512f6e410..dccd91c33 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -44,6 +44,43 @@ Compatibility forms are supported for migrations and wrapper-routed environments --- +## `codex-multi-auth check` + +Live-probes every stored account and prints one line per account. Unlike +`fix`, `check` does not skip disabled accounts: an account whose token is +still usable is re-enabled as part of the run. When `showQuotaDetails` is on +(the default, see [settings.md](settings.md)) the healthy lines carry a +compact quota summary with the percentage **left** in each window and the +absolute time that window resets: + +```console +$ codex-multi-auth check +Checking 2 account(s) with quick check + live check... +Model probe: gpt-5.6-sol | prompt family gpt-5.2 | tool search yes | computer use yes + ✓ Account 1 (Personal, 1@example.com) | live session OK (5h 100%, resets 18:10 | 7d 93%, resets 13:50 on Jul 29) + ✓ Account 2 (Personal, 2@example.com) | live session OK (5h 100%, resets 18:35 | 7d 98%, resets 14:15 on Jul 29) +``` + +Reset-time details: + +- Times are rendered in the **local system timezone** on a 24-hour clock. +- A reset later today prints as `HH:MM`; anything past midnight appends the + date (`HH:MM on Jul 29` under an `en-US` locale). The date text and its + field order follow the host locale, so a non-English locale renders the + month differently — the shape is not a stable output contract. The year is + omitted because both windows are at most seven days long. +- Window labels come from the backend (`5h`, `7d`, …), and fall back to + `quota` when the backend omits the window length. +- When the backend does not return a reset timestamp for a window, that + window keeps its percentage and simply omits the reset clause. A missing or + malformed timestamp never fails the account check. +- Reset times are shown only by `check`. The dashboard, account menu, and + `forecast` keep their narrower percentage-only summaries. + +Turning `showQuotaDetails` off reduces the line to a bare `live session OK`. + +--- + ## Daily Use | Command | Description | diff --git a/lib/codex-manager/formatters/quota-formatters.ts b/lib/codex-manager/formatters/quota-formatters.ts index 5899dff29..1700815a8 100644 --- a/lib/codex-manager/formatters/quota-formatters.ts +++ b/lib/codex-manager/formatters/quota-formatters.ts @@ -3,6 +3,7 @@ import type { QuotaCacheEntry } from "../../quota-cache.js"; import { type CodexQuotaSnapshot, fetchCodexQuotaSnapshot, + formatQuotaResetAt, formatQuotaSnapshotLine, } from "../../quota-probe.js"; import { @@ -29,7 +30,9 @@ export function styleQuotaSummary(summary: string): string { if (/rate-limited/i.test(segment)) { return stylePromptText(segment, "danger"); } - const match = segment.match(/^([0-9a-zA-Z]+)\s+(\d{1,3})%$/); + const match = segment.match( + /^([0-9a-zA-Z]+)\s+(\d{1,3})%(,\s*resets\s+\S.*)?$/, + ); if (!match) { return stylePromptText(segment, "muted"); } @@ -42,18 +45,29 @@ export function styleQuotaSummary(summary: string): string { return stylePromptText(segment, "muted"); } const tone = quotaToneFromLeftPercent(leftPercent); - return `${stylePromptText(windowLabel, "muted")} ${stylePromptText(`${leftPercent}%`, tone)}`; + const resetSuffix = match[3] + ? stylePromptText(match[3], "muted") + : ""; + return `${stylePromptText(windowLabel, "muted")} ${stylePromptText(`${leftPercent}%`, tone)}${resetSuffix}`; }); return joinStyledSegments(rendered); } +/** + * Render the per-account health line printed by `codex-multi-auth check`. + * + * `check` is the only caller, and unlike the dashboard rows and account menu it + * prints one account per line with no width pressure, so it opts into the + * absolute reset clock for each quota window. + */ export function formatQuotaSnapshotForDashboard( snapshot: Awaited>, settings: DashboardDisplaySettings, + now = Date.now(), ): string { if (!settings.showQuotaDetails) return "live session OK"; - return `live session OK (${formatCompactQuotaSnapshot(snapshot)})`; + return `live session OK (${formatCompactQuotaSnapshot(snapshot, now, { showReset: true })})`; } export function quotaCacheEntryToSnapshot( @@ -87,30 +101,55 @@ function formatCompactQuotaWindowLabel( return `${windowMinutes}m`; } +/** + * Options shared by the compact quota formatters. + * + * `showReset` is opt-in so that space-constrained surfaces (dashboard rows, the + * account menu, forecast lines) keep their existing single-line width, while + * `codex-multi-auth check` can append the absolute reset time. + */ +export interface CompactQuotaFormatOptions { + showReset?: boolean; +} + function formatCompactQuotaPart( windowMinutes: number | undefined, usedPercent: number | undefined, + resetAtMs: number | undefined, + options: CompactQuotaFormatOptions, + now: number, ): string | null { const label = formatCompactQuotaWindowLabel(windowMinutes); if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) { return null; } const left = quotaLeftPercentFromUsed(usedPercent); - return `${label} ${left}%`; + const part = `${label} ${left}%`; + if (!options.showReset) return part; + // A missing or malformed reset timestamp must never drop the percentage. + const reset = formatQuotaResetAt(resetAtMs, now); + return reset ? `${part}, resets ${reset}` : part; } export function formatCompactQuotaSnapshot( snapshot: CodexQuotaSnapshot, now = Date.now(), + options: CompactQuotaFormatOptions = {}, ): string { const parts = [ formatCompactQuotaPart( snapshot.primary.windowMinutes, snapshot.primary.usedPercent, + snapshot.primary.resetAtMs, + options, + now, ), formatCompactQuotaPart( snapshot.secondary.windowMinutes, snapshot.secondary.usedPercent, + snapshot.secondary.resetAtMs, + options, + now, ), ].filter( (value): value is string => typeof value === "string" && value.length > 0, @@ -130,15 +169,22 @@ export function formatCompactQuotaSnapshot( export function formatAccountQuotaSummary( entry: QuotaCacheEntry, now = Date.now(), + options: CompactQuotaFormatOptions = {}, ): string { const parts = [ formatCompactQuotaPart( entry.primary.windowMinutes, entry.primary.usedPercent, + entry.primary.resetAtMs, + options, + now, ), formatCompactQuotaPart( entry.secondary.windowMinutes, entry.secondary.usedPercent, + entry.secondary.resetAtMs, + options, + now, ), ].filter( (value): value is string => typeof value === "string" && value.length > 0, diff --git a/lib/quota-probe.ts b/lib/quota-probe.ts index a8e942a6b..43fd3b6e9 100644 --- a/lib/quota-probe.ts +++ b/lib/quota-probe.ts @@ -266,15 +266,20 @@ function formatQuotaWindowLabel(windowMinutes: number | undefined): string { * and produces no sensitive token data. * * @param resetAtMs - Timestamp in milliseconds since epoch; if `undefined`, non-finite, or <= 0, the function returns `undefined`. + * @param nowMs - Reference timestamp used for the "is today" comparison; defaults to the current time. * @returns The formatted reset time string, or `undefined` if `resetAtMs` is invalid or not provided. */ -function formatResetAt(resetAtMs: number | undefined): string | undefined { +export function formatQuotaResetAt( + resetAtMs: number | undefined, + nowMs = Date.now(), +): string | undefined { if (!resetAtMs || !Number.isFinite(resetAtMs) || resetAtMs <= 0) return undefined; const date = new Date(resetAtMs); if (!Number.isFinite(date.getTime())) return undefined; - const now = new Date(); + const now = new Date(nowMs); const sameDay = + Number.isFinite(now.getTime()) && now.getFullYear() === date.getFullYear() && now.getMonth() === date.getMonth() && now.getDate() === date.getDate(); @@ -305,7 +310,7 @@ function formatWindowSummary(label: string, window: CodexQuotaWindow): string { typeof used === "number" && Number.isFinite(used) ? Math.max(0, Math.min(100, Math.round(100 - used))) : undefined; - const reset = formatResetAt(window.resetAtMs); + const reset = formatQuotaResetAt(window.resetAtMs); let summary = label; if (left !== undefined) summary = `${summary} ${left}% left`; if (reset) summary = `${summary} (resets ${reset})`; diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 45c793192..c0e6136ca 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -3279,6 +3279,98 @@ describe("codex manager cli commands", () => { ).toBe(true); }); + // Issue #633: `check` prints when each quota window resets. Asserted on the + // real printed line so the health-check -> formatter wiring is covered, not + // just the formatter in isolation. The reset clock is local-timezone, and a + // run started just before midnight pushes it to the next day, so the day + // suffix is optional here rather than pinned. + it("check prints the reset time for each quota window", async () => { + const now = Date.now(); + storageMocks.loadAccounts.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + accountId: "acc_reset", + email: "reset@example.com", + refreshToken: "refresh-reset", + accessToken: "access-reset", + expiresAt: now + 60 * 60 * 1000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + quotaProbeMocks.fetchCodexQuotaSnapshot.mockResolvedValueOnce({ + status: 200, + model: DEFAULT_MODEL, + primary: { + usedPercent: 70, + windowMinutes: 300, + resetAtMs: now + 3 * 60 * 60 * 1000, + }, + secondary: { + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: now + 5 * 24 * 60 * 60 * 1000, + }, + }); + const logSpy = silenceConsole("log"); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "check"]); + expect(exitCode).toBe(0); + + const line = logSpy.mock.calls + .map((call) => String(call[0])) + .find((text) => text.includes("live session OK")); + expect(line).toBeDefined(); + // The date half of the suffix is produced by toLocaleDateString with an + // undefined locale, so its month text and field order follow the host. + // Assert the structure, not an en-US shape, or this fails off en-US. + expect(line).toMatch(/5h 30%, resets \d{2}:\d{2}/); + expect(line).toMatch(/7d 90%, resets \d{2}:\d{2} on \S/); + }); + + it("check keeps the percentage when a window has no reset timestamp", async () => { + const now = Date.now(); + storageMocks.loadAccounts.mockResolvedValueOnce({ + version: 3, + activeIndex: 0, + activeIndexByFamily: { codex: 0 }, + accounts: [ + { + accountId: "acc_no_reset", + email: "no-reset@example.com", + refreshToken: "refresh-no-reset", + accessToken: "access-no-reset", + expiresAt: now + 60 * 60 * 1000, + addedAt: now - 1_000, + lastUsed: now - 1_000, + enabled: true, + }, + ], + }); + quotaProbeMocks.fetchCodexQuotaSnapshot.mockResolvedValueOnce({ + status: 200, + model: DEFAULT_MODEL, + primary: { usedPercent: 70, windowMinutes: 300 }, + secondary: { usedPercent: 10, windowMinutes: 10080, resetAtMs: 0 }, + }); + const logSpy = silenceConsole("log"); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + + const exitCode = await runCodexMultiAuthCli(["auth", "check"]); + expect(exitCode).toBe(0); + + const line = logSpy.mock.calls + .map((call) => String(call[0])) + .find((text) => text.includes("live session OK")); + expect(line).toContain("live session OK (5h 30% | 7d 90%)"); + }); + it("does not label Codex-unavailable live checks as working now", async () => { const now = Date.now(); storageMocks.loadAccounts.mockResolvedValueOnce({ diff --git a/test/codex-manager-formatters.test.ts b/test/codex-manager-formatters.test.ts index d6e28d800..55fd2eb2e 100644 --- a/test/codex-manager-formatters.test.ts +++ b/test/codex-manager-formatters.test.ts @@ -10,10 +10,16 @@ import { stringifyLogArgs, } from "../lib/codex-manager/formatters/text-style.js"; import { + formatAccountQuotaSummary, + formatCompactQuotaSnapshot, + formatQuotaSnapshotForDashboard, quotaCacheEntryToSnapshot, styleQuotaSummary, } from "../lib/codex-manager/formatters/quota-formatters.js"; +import type { DashboardDisplaySettings } from "../lib/dashboard-settings.js"; import type { QuotaCacheEntry } from "../lib/quota-cache.js"; +import { formatQuotaResetAt } from "../lib/quota-probe.js"; +import type { CodexQuotaSnapshot } from "../lib/quota-probe.js"; import { formatModelInspection, inspectRequestedModel, @@ -151,6 +157,139 @@ describe("quota formatters", () => { }); }); +// Reset-timestamp rendering for `codex-multi-auth check` (issue #633). +// The reset clock is local-timezone by design, so expectations are derived +// from the same Intl calls the formatter uses instead of hardcoding a zone. +describe("compact quota reset timestamps", () => { + // 2026-07-22T09:00 local; +2h stays on the same local day, +8d does not. + const NOW = new Date(2026, 6, 22, 9, 0, 0).getTime(); + const SAME_DAY = NOW + 2 * 60 * 60 * 1000; + const NEXT_WEEK = NOW + 8 * 24 * 60 * 60 * 1000; + + const localTime = (ms: number): string => + new Date(ms).toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + + const snapshot = ( + primaryReset: number | undefined, + secondaryReset: number | undefined, + ): CodexQuotaSnapshot => + ({ + status: "ok", + planType: "plus", + model: "gpt-5.3-codex", + primary: { + usedPercent: 0, + windowMinutes: 300, + resetAtMs: primaryReset, + }, + secondary: { + usedPercent: 7, + windowMinutes: 10080, + resetAtMs: secondaryReset, + }, + }) as unknown as CodexQuotaSnapshot; + + it("formatQuotaResetAt uses local 24h time, adds the day when not today", () => { + expect(formatQuotaResetAt(SAME_DAY, NOW)).toBe(localTime(SAME_DAY)); + expect(formatQuotaResetAt(NEXT_WEEK, NOW)).toBe( + `${localTime(NEXT_WEEK)} on ${new Date(NEXT_WEEK).toLocaleDateString( + undefined, + { month: "short", day: "2-digit" }, + )}`, + ); + }); + + it("formatQuotaResetAt rejects missing and malformed timestamps", () => { + expect(formatQuotaResetAt(undefined, NOW)).toBeUndefined(); + expect(formatQuotaResetAt(0, NOW)).toBeUndefined(); + expect(formatQuotaResetAt(-1, NOW)).toBeUndefined(); + expect(formatQuotaResetAt(Number.NaN, NOW)).toBeUndefined(); + expect(formatQuotaResetAt(Number.POSITIVE_INFINITY, NOW)).toBeUndefined(); + }); + + it("omits reset times unless the caller opts in", () => { + expect(formatCompactQuotaSnapshot(snapshot(SAME_DAY, NEXT_WEEK), NOW)).toBe( + "5h 100% | 7d 93%", + ); + }); + + it("appends a per-window reset time when showReset is set", () => { + expect( + formatCompactQuotaSnapshot(snapshot(SAME_DAY, NEXT_WEEK), NOW, { + showReset: true, + }), + ).toBe( + `5h 100%, resets ${formatQuotaResetAt(SAME_DAY, NOW)} | 7d 93%, resets ${formatQuotaResetAt(NEXT_WEEK, NOW)}`, + ); + }); + + it("keeps the percentage when a reset timestamp is missing or malformed", () => { + expect( + formatCompactQuotaSnapshot(snapshot(undefined, Number.NaN), NOW, { + showReset: true, + }), + ).toBe("5h 100% | 7d 93%"); + }); + + it("formatAccountQuotaSummary honors showReset the same way", () => { + const entry = { + status: "ok", + planType: "plus", + model: "gpt-5.3-codex", + fetchedAt: NOW, + primary: { usedPercent: 0, windowMinutes: 300, resetAtMs: SAME_DAY }, + secondary: { usedPercent: 7, windowMinutes: 10080, resetAtMs: undefined }, + } as unknown as QuotaCacheEntry; + expect(formatAccountQuotaSummary(entry, NOW)).toBe("5h 100% | 7d 93%"); + expect(formatAccountQuotaSummary(entry, NOW, { showReset: true })).toBe( + `5h 100%, resets ${formatQuotaResetAt(SAME_DAY, NOW)} | 7d 93%`, + ); + }); + + it("formatQuotaSnapshotForDashboard is the check line and shows resets", () => { + const display = { + showQuotaDetails: true, + } as unknown as DashboardDisplaySettings; + expect( + formatQuotaSnapshotForDashboard( + snapshot(SAME_DAY, NEXT_WEEK), + display, + NOW, + ), + ).toBe( + `live session OK (5h 100%, resets ${formatQuotaResetAt(SAME_DAY, NOW)} | 7d 93%, resets ${formatQuotaResetAt(NEXT_WEEK, NOW)})`, + ); + }); + + it("formatQuotaSnapshotForDashboard stays bare when quota details are off", () => { + const display = { + showQuotaDetails: false, + } as unknown as DashboardDisplaySettings; + expect( + formatQuotaSnapshotForDashboard( + snapshot(SAME_DAY, NEXT_WEEK), + display, + NOW, + ), + ).toBe("live session OK"); + }); + + it("styleQuotaSummary still tones segments that carry a reset suffix", () => { + expect(styleQuotaSummary("5h 80%, resets 14:05 | 7d 15%")).toBe( + "5h 80%, resets 14:05 | 7d 15%", + ); + expect(styleQuotaSummary("7d 150%, resets 14:05 on Jul 29")).toBe( + "7d 100%, resets 14:05 on Jul 29", + ); + // A trailing "resets" with no value is not a reset suffix. + expect(styleQuotaSummary("5h 80%, resets")).toBe("5h 80%, resets"); + }); +}); + describe("model formatters", () => { it("inspectRequestedModel reports remapping consistently", () => { const inspection = inspectRequestedModel("gpt-5.3-codex");