From 6ab38ca461b68a680228d77c312e9f72064912df Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 24 Jul 2026 23:15:12 +0900 Subject: [PATCH] fix(usage): render codex usage when rate-limit response has null secondary window Some Codex plans (e.g. Plus) return only a weekly window in `primary` with `secondary: null`. applyCodexUsageToAccount dereferenced `secondary.usedPercent` unconditionally, throwing a TypeError that refreshActiveCodexUsage swallowed at debug level, so usage never applied and the dashboard row stayed blank (-1). Guard the null secondary, and when a lone window is weekly (windowDurationMins > 1440) route it into the 7d slot leaving 5h unknown. Accounts that report both windows are unchanged. Adds a regression test for the secondary:null case. --- src/codex-usage-collector.test.ts | 42 +++++++++++++++++++++++++++++++ src/codex-usage-collector.ts | 34 +++++++++++++++++++------ 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/src/codex-usage-collector.test.ts b/src/codex-usage-collector.test.ts index 90106dd..0db4661 100644 --- a/src/codex-usage-collector.test.ts +++ b/src/codex-usage-collector.test.ts @@ -157,6 +157,48 @@ describe('codex-usage-collector fallback account usage', () => { ]); }); + it('handles a lone weekly window (secondary: null) without throwing and routes it to 7d', async () => { + createDefaultCodexAuth(tempHome); + const childProcess = await import('child_process'); + + vi.mocked(childProcess.spawn).mockImplementation((( + _cmd: string, + _args: readonly string[] | undefined, + _opts?: { env?: Record }, + ) => { + // Some plans (e.g. Plus) report only a weekly window in `primary` with + // `secondary: null`. This previously threw on `secondary.usedPercent`. + return createFakeChildProcess({ + codex: { + limitName: 'Codex', + primary: { + usedPercent: 10, + windowDurationMins: 10080, // 7 days + resetsAt: new Date(Date.now() + 4 * 86_400_000).toISOString(), + }, + secondary: null, + }, + }) as never; + }) as unknown as typeof childProcess.spawn); + + const rotation = await import('./codex-token-rotation.js'); + const usage = await import('./codex-usage-collector.js'); + + rotation.initCodexTokenRotation(); + const result = await usage.refreshActiveCodexUsage(); + + // Usage must be applied (not swallowed by a TypeError), and the lone weekly + // window is shown as 7d while the missing 5h window renders as unknown. + expect(result.fetchedAt).toEqual(expect.any(String)); + expect(result.rows).toEqual([ + expect.objectContaining({ + name: 'Codex', + h5pct: -1, + d7pct: 10, + }), + ]); + }); + it('finds codex via ~/.hermes/node/bin when running under bun', async () => { createDefaultCodexAuth(tempHome); const childProcess = await import('child_process'); diff --git a/src/codex-usage-collector.ts b/src/codex-usage-collector.ts index e9baaa7..f5fc43a 100644 --- a/src/codex-usage-collector.ts +++ b/src/codex-usage-collector.ts @@ -14,8 +14,8 @@ import { logger } from './logger.js'; export interface CodexRateLimit { limitId?: string; limitName: string | null; - primary: { usedPercent: number; resetsAt: string | number }; - secondary: { usedPercent: number; resetsAt: string | number }; + primary: { usedPercent: number; resetsAt: string | number; windowDurationMins?: number }; + secondary: { usedPercent: number; resetsAt: string | number; windowDurationMins?: number } | null; } /** @@ -291,8 +291,8 @@ export function applyCodexUsageToAccount( account: accountIndex + 1, buckets: usage.map((l) => ({ id: l.limitId, - h5: l.primary.usedPercent, - d7: l.secondary.usedPercent, + h5: l.primary?.usedPercent ?? null, + d7: l.secondary?.usedPercent ?? null, })), }, `Codex account #${accountIndex + 1}: ${usage.length} rate-limit bucket(s)`, @@ -312,13 +312,31 @@ export function applyCodexUsageToAccount( return; } - const pct = Math.round(effective.primary.usedPercent); - const d7Pct = Math.round(effective.secondary.usedPercent); + // Historical shape: primary = 5h window, secondary = 7d window. Some plans + // (e.g. Plus) return only ONE window in `primary` (a weekly/7d window) with + // `secondary: null`. Guard the null so we don't throw, and route a lone + // weekly window into the 7d slot so usage still renders. // Store raw ISO timestamps (not pre-formatted strings) so the exhaustion // gate can compute "minutes until reset" later. The dashboard render path // formats these at display time via `formatResetRemaining`. - const resetIso = toIsoMaybe(effective.primary.resetsAt); - const resetD7Iso = toIsoMaybe(effective.secondary.resetsAt); + let pct = Math.round(effective.primary.usedPercent); + let resetIso = toIsoMaybe(effective.primary.resetsAt); + let d7Pct = effective.secondary + ? Math.round(effective.secondary.usedPercent) + : -1; + let resetD7Iso = effective.secondary + ? toIsoMaybe(effective.secondary.resetsAt) + : undefined; + if ( + !effective.secondary && + (effective.primary.windowDurationMins ?? 0) > 1440 + ) { + // Lone window is weekly (> 1 day) — show it as 7d, leave 5h unknown. + d7Pct = pct; + resetD7Iso = resetIso; + pct = -1; + resetIso = undefined; + } updateCodexAccountUsage(pct, resetIso, accountIndex, d7Pct, resetD7Iso); logger.info( {