Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <index>` |
| 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 <account> [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` |

---
Expand Down
37 changes: 37 additions & 0 deletions docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

---

## Daily Use

| Command | Description |
Expand Down
54 changes: 50 additions & 4 deletions lib/codex-manager/formatters/quota-formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { QuotaCacheEntry } from "../../quota-cache.js";
import {
type CodexQuotaSnapshot,
fetchCodexQuotaSnapshot,
formatQuotaResetAt,
formatQuotaSnapshotLine,
} from "../../quota-probe.js";
import {
Expand All @@ -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");
}
Expand All @@ -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<ReturnType<typeof fetchCodexQuotaSnapshot>>,
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(
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
11 changes: 8 additions & 3 deletions lib/quota-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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})`;
Expand Down
92 changes: 92 additions & 0 deletions test/codex-manager-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
Comment on lines +3287 to +3335

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

add a multi-account, fixed-clock regression

test/codex-manager-cli.test.ts:3288 captures the wall clock but provisions only one account. that cannot catch a check implementation using different reference times for different output lines, which is part of this pr’s contract. freeze time with vitest and add a second account near a day boundary; assert both lines use the same reference-time behavior.

as per path instructions, tests must stay deterministic and include regression coverage for changed behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/codex-manager-cli.test.ts` around lines 3287 - 3333, Update the test
case around runCodexMultiAuthCli to freeze Vitest’s clock using the captured now
value, provision a second account whose output crosses a day boundary, and
configure its quota data as needed. Assert the check output for both accounts,
verifying each line uses the same frozen reference time for reset-time
formatting; restore the clock after the test.

Source: Path instructions


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%)");
Comment on lines +3356 to +3371

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

exercise one missing reset independently

test/codex-manager-cli.test.ts:3357 omits the reset timestamp from primary as well as setting secondary.resetAtMs to 0. the test therefore never proves that one window keeps its reset clause while the other omits it. give primary a valid reset timestamp and assert the mixed output.

as per path instructions, tests must demand regression cases for the changed behavior.

proposed test adjustment
-			primary: { usedPercent: 70, windowMinutes: 300 },
+			primary: {
+				usedPercent: 70,
+				windowMinutes: 300,
+				resetAtMs: now + 60 * 60 * 1000,
+			},
 			secondary: { usedPercent: 10, windowMinutes: 10080, resetAtMs: 0 },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/codex-manager-cli.test.ts` around lines 3354 - 3369, Update the test
case around quotaProbeMocks.fetchCodexQuotaSnapshot and runCodexMultiAuthCli to
provide primary with a valid resetAtMs while keeping secondary.resetAtMs at 0.
Change the assertion to require mixed output: primary’s reset clause must be
present, while secondary’s reset clause remains omitted.

Source: Path instructions

});

it("does not label Codex-unavailable live checks as working now", async () => {
const now = Date.now();
storageMocks.loadAccounts.mockResolvedValueOnce({
Expand Down
Loading