feat(bar): per-window subscription quota detail + codex multi-session scan

Expose per-window detail (5h / weekly / Opus / Sonnet) on native subscription
rows instead of collapsing to a single percentage, so the bar can show the
binding window, resets, and a burn-rate pace line. Fix the Codex collector to
scan recent session rollouts newest-first for the latest non-null rate_limits
(today's exec-mode session is null) and mark the result stale with its source
time, so Codex quota appears instead of silently vanishing.
This commit is contained in:
Tam Nhu Tran committed 2026-06-09 14:39:41 -04:00
1 parent e9df0757a2
commit e96967c224
5 files changed
+441 -46

No files matched your search

+54 -1
View File
@@ -28,6 +28,32 @@ import type { DailyUsage, HourlyUsage } from '../usage/types';
// Types
// ============================================================================
/**
* Per-window quota detail for native subscription rows (Claude/Codex).
*
* Carries BOTH used and remaining percent so the macOS bar never re-derives
* them. CLIProxy rows omit the parent `quotaWindows` field entirely, keeping
* the payload backward compatible.
*
* JSON shape (decode test pins these exact keys): the inner object keys stay
* camelCase (usedPercent/remainingPercent/resetAt/windowMinutes); only the
* parent field name serializes to snake_case ("quota_windows").
*/
export interface QuotaWindowDetail {
/** Stable key: "five_hour" | "seven_day" | "seven_day_opus" | "seven_day_sonnet". */
key: string;
/** Display label: "5h" | "week" | "Opus · week" | "Sonnet · week". */
label: string;
/** Used percentage (0-100). */
usedPercent: number;
/** Remaining percentage (0-100). Carried explicitly, never derived in Swift. */
remainingPercent: number;
/** ISO timestamp when this window resets, null if unknown. */
resetAt: string | null;
/** Window length in minutes (300 | 10080), null if unknown. */
windowMinutes: number | null;
}
/** Single account glance row returned by /api/bar/summary */
export interface BarSummaryRow {
/** Account identifier (email or custom name) */
@@ -67,6 +93,18 @@ export interface BarSummaryRow {
fetchedAt: string;
/** True if account token is expired and needs re-authentication */
needsReauth: boolean;
/**
* Native-only per-window quota breakdown (Claude: 5h/week/opus/sonnet,
* Codex: 5h/week). CLIProxy rows OMIT this field so existing decode/encode
* tests and the Swift legacy path stay unaffected. Serialized as
* "quota_windows".
*/
quotaWindows?: QuotaWindowDetail[];
/**
* Native-only ISO mtime of the source session that supplied a stale Codex
* reading. Present only when stale; serialized as "stale_as_of".
*/
staleAsOf?: string | null;
}
// ============================================================================
@@ -177,6 +215,21 @@ function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | null> {
});
}
/**
* Map a row to its wire shape. The native-only additions use snake_case parent
* keys ("quota_windows" / "stale_as_of") to match the existing payload's mixed
* casing; inner QuotaWindowDetail keys stay camelCase and pass through as-is.
* Rows without the native fields serialize byte-identically to before, so
* CLIProxy decode/encode tests are unaffected.
*/
function serializeBarRow(row: BarSummaryRow): Record<string, unknown> {
const { quotaWindows, staleAsOf, ...rest } = row;
const wire: Record<string, unknown> = { ...rest };
if (quotaWindows !== undefined) wire.quota_windows = quotaWindows;
if (staleAsOf !== undefined && staleAsOf !== null) wire.stale_as_of = staleAsOf;
return wire;
}
// ============================================================================
// Per-account health derivation
// ============================================================================
@@ -496,7 +549,7 @@ export function createBarRouter(deps: BarRouterDeps): Router {
const getNative = deps.getNativeAccountRows ?? (async () => [] as BarSummaryRow[]);
const nativeRows = (await withTimeout(getNative(), NATIVE_SIDELOAD_TIMEOUT_MS)) ?? [];
res.json([...rows, ...nativeRows]);
res.json([...rows, ...nativeRows].map(serializeBarRow));
} catch (err) {
console.error('[bar-routes] /summary error:', (err as Error).message);
res.status(500).json({ error: 'Internal server error' });
@@ -2,19 +2,41 @@
* Codex local quota collector (zero network).
*
* Codex writes a `rate_limits` object into its rollout session logs
* (~/.codex/sessions/<y>/<m>/<d>/rollout-*.jsonl). We read the most recent
* session's last non-null rate_limits to surface the user's Codex subscription
* quota WITHOUT any network call — pure local file read.
* (~/.codex/sessions/<y>/<m>/<d>/rollout-*.jsonl). We surface the user's Codex
* subscription quota WITHOUT any network call — pure local file read.
*
* Exec-mode sessions often never emit rate_limits (the field stays null); in
* that case we return null so the bar simply omits the Codex row rather than
* inventing a fake one.
* Exec-mode sessions often never emit rate_limits (the field stays null). The
* NEWEST session is frequently exec-mode, so reading only it would emit no row
* even though an older interactive session still carries real quota. We instead
* scan recent sessions newest-first and use the first one that yields a usable
* rate_limits, marking the result stale when that source file is old. We only
* return null when no scanned session has any rate_limits — then the bar omits
* the row rather than inventing a fake one.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { resolveCodexConfigPaths } from '../services/codex-dashboard-service';
/**
* One Codex quota window normalized for the bar's per-window detail.
* `key`/`label` map primary->five_hour/"5h", secondary->seven_day/"week".
*/
export interface CodexLocalQuotaWindow {
/** Stable window key: "five_hour" | "seven_day". */
key: string;
/** Short display label: "5h" | "week". */
label: string;
/** Used percentage (0-100). */
usedPercent: number;
/** Remaining percentage (0-100) = 100 - usedPercent. */
remainingPercent: number;
/** ISO timestamp when this window resets, null if unknown. */
resetAt: string | null;
/** Window length in minutes (300 / 10080), null if absent in the raw log. */
windowMinutes: number | null;
}
/** Normalized Codex quota snapshot from a local session log. */
export interface CodexLocalQuota {
/** Remaining percentage (0-100): min across primary/secondary windows. */
@@ -25,6 +47,13 @@ export interface CodexLocalQuota {
tier: string | null;
/** True when the source file is older than the freshness window. */
stale: boolean;
/**
* ISO mtime of the session file that SUPPLIED this data, present only when
* stale. Lets the bar render an "as of HH:mm (older session)" footnote.
*/
staleAsOf: string | null;
/** Per-window detail (primary -> five_hour, secondary -> seven_day). */
windows: CodexLocalQuotaWindow[];
}
/** Injectable seams for deterministic tests (no real fs / no tail subprocess). */
@@ -45,9 +74,17 @@ const TAIL_LINES = 200;
/** A source older than this is reported stale (but still emitted). */
const STALE_AFTER_MS = 5 * 60 * 1000;
/**
* Cap on how many recent session files we scan newest-first before giving up.
* Bounds the walk when Codex genuinely never reported (avoids touching the whole
* history) while still reaching past several exec-mode sessions to a real one.
*/
const MAX_SESSIONS_SCANNED = 20;
interface CodexRateWindow {
usedPercent: number;
resetsAtSeconds: number | null;
windowMinutes: number | null;
}
interface CodexRateLimits {
@@ -73,6 +110,7 @@ function parseWindow(value: unknown): CodexRateWindow | null {
return {
usedPercent,
resetsAtSeconds: asFiniteNumber(obj['resets_at']),
windowMinutes: asFiniteNumber(obj['window_minutes']),
};
}
@@ -162,9 +200,52 @@ function computeNextReset(rate: CodexRateLimits): string | null {
return new Date(Math.min(...resets) * 1000).toISOString();
}
function windowDetail(window: CodexRateWindow, key: string, label: string): CodexLocalQuotaWindow {
const usedPercent = Math.max(0, Math.min(100, window.usedPercent));
return {
key,
label,
usedPercent,
remainingPercent: 100 - usedPercent,
resetAt:
window.resetsAtSeconds !== null
? new Date(window.resetsAtSeconds * 1000).toISOString()
: null,
windowMinutes: window.windowMinutes,
};
}
/** Build per-window detail (primary -> 5h, secondary -> week), omitting absent. */
function buildWindows(rate: CodexRateLimits): CodexLocalQuotaWindow[] {
const windows: CodexLocalQuotaWindow[] = [];
if (rate.primary) windows.push(windowDetail(rate.primary, 'five_hour', '5h'));
if (rate.secondary) windows.push(windowDetail(rate.secondary, 'seven_day', 'week'));
return windows;
}
/** Scan one session file's tail backward for the latest non-null rate_limits. */
async function readRateLimitsFromFile(
file: string,
tailLinesImpl: (file: string, lines: number) => Promise<string[]>
): Promise<CodexRateLimits | null> {
let lines: string[];
try {
lines = await tailLinesImpl(file, TAIL_LINES);
} catch {
return null;
}
for (let i = lines.length - 1; i >= 0; i--) {
const found = extractRateLimits(lines[i]);
if (found) return found;
}
return null;
}
/**
* Read the latest Codex session's most recent rate_limits and normalize it.
* Returns null when no session carries a usable rate_limits object.
* Scan recent Codex sessions newest-first and normalize the first usable
* rate_limits. Staleness is computed from the mtime of the file that SUPPLIED
* the data (not the newest file), so a real-but-old session reads correctly as
* stale. Returns null only when none of the scanned sessions carries quota.
*/
export async function getCodexLocalQuota(
deps: CodexLocalQuotaDeps = {}
@@ -182,31 +263,28 @@ export async function getCodexLocalQuota(
const rolloutFiles = collectRolloutFiles(sessionsDir, existsImpl, readdirImpl);
if (rolloutFiles.length === 0) return null;
// Filename carries an ISO timestamp, so the lexicographically-last file is
// the most recent session.
const latest = rolloutFiles[rolloutFiles.length - 1];
// Filenames carry an ISO timestamp, so the lexicographic order is chronological.
// Walk newest-first, bounded, until a session yields a usable rate_limits.
const newestFirst = rolloutFiles.slice().reverse().slice(0, MAX_SESSIONS_SCANNED);
let lines: string[];
try {
lines = await tailLinesImpl(latest, TAIL_LINES);
} catch {
return null;
}
// Iterate backwards for the most recent non-null rate_limits.
let rate: CodexRateLimits | null = null;
for (let i = lines.length - 1; i >= 0; i--) {
const found = extractRateLimits(lines[i]);
let sourceFile: string | null = null;
for (const file of newestFirst) {
const found = await readRateLimitsFromFile(file, tailLinesImpl);
if (found) {
rate = found;
sourceFile = file;
break;
}
}
if (!rate) return null;
if (!rate || !sourceFile) return null;
let stale = false;
let staleAsOf: string | null = null;
try {
stale = now - statMtimeMsImpl(latest) > STALE_AFTER_MS;
const mtimeMs = statMtimeMsImpl(sourceFile);
stale = now - mtimeMs > STALE_AFTER_MS;
if (stale) staleAsOf = new Date(mtimeMs).toISOString();
} catch {
// Unknown mtime -> treat as fresh; the data itself is still valid.
stale = false;
@@ -217,5 +295,7 @@ export async function getCodexLocalQuota(
nextReset: computeNextReset(rate),
tier: rate.planType,
stale,
staleAsOf,
windows: buildWindows(rate),
};
}
+82 -1
View File
@@ -27,7 +27,7 @@ import {
import { fetchClaudeQuotaWithToken } from '../../cliproxy/quota/quota-fetcher-claude';
import { getCodexLocalQuota, type CodexLocalQuota } from './codex-local-quota-collector';
import type { ClaudeQuotaResult } from '../../cliproxy/quota/quota-types';
import type { BarSummaryRow } from '../routes/bar-routes';
import type { BarSummaryRow, QuotaWindowDetail } from '../routes/bar-routes';
// ============================================================================
// Safety constants (concrete, named, module-level)
@@ -174,7 +174,69 @@ function deriveClaudeNextReset(quota: ClaudeQuotaResult): string | null {
return resets.length > 0 ? resets[0].iso : null;
}
/** Window length in minutes by Claude rate-limit family. */
const FIVE_HOUR_MINUTES = 300;
const SEVEN_DAY_MINUTES = 10080;
/**
* Build the per-window detail for a Claude subscription row.
*
* 5h + weekly come from coreUsage (the canonical core summary). Opus/Sonnet
* weekly splits come from quota.windows[] and only exist on Max plans, so they
* are omitted entirely when absent. Each window carries BOTH used and remaining
* percent so the bar never re-derives them.
*/
function buildClaudeQuotaWindows(quota: ClaudeQuotaResult): QuotaWindowDetail[] {
const windows: QuotaWindowDetail[] = [];
const fiveHour = quota.coreUsage?.fiveHour;
if (fiveHour) {
windows.push({
key: 'five_hour',
label: '5h',
usedPercent: 100 - fiveHour.remainingPercent,
remainingPercent: fiveHour.remainingPercent,
resetAt: fiveHour.resetAt,
windowMinutes: FIVE_HOUR_MINUTES,
});
}
const weekly = quota.coreUsage?.weekly;
if (weekly) {
windows.push({
key: 'seven_day',
label: 'week',
usedPercent: 100 - weekly.remainingPercent,
remainingPercent: weekly.remainingPercent,
resetAt: weekly.resetAt,
windowMinutes: SEVEN_DAY_MINUTES,
});
}
// Opus/Sonnet weekly splits are Max-only; surface them when the API carries
// them, otherwise omit so non-Max plans get exactly the two core windows.
const splitLabels: Record<string, string> = {
seven_day_opus: 'Opus · week',
seven_day_sonnet: 'Sonnet · week',
};
for (const w of quota.windows) {
const label = splitLabels[w.rateLimitType];
if (!label) continue;
windows.push({
key: w.rateLimitType,
label,
usedPercent: w.usedPercent,
remainingPercent: w.remainingPercent,
resetAt: w.resetAt,
windowMinutes: SEVEN_DAY_MINUTES,
});
}
return windows;
}
function buildClaudeRow(quota: ClaudeQuotaResult, tier: string | null, now: number): BarSummaryRow {
const quotaWindows = buildClaudeQuotaWindows(quota);
return {
account_id: CLAUDE_PROVIDER,
provider: CLAUDE_PROVIDER,
@@ -191,10 +253,26 @@ function buildClaudeRow(quota: ClaudeQuotaResult, tier: string | null, now: numb
cached: false,
fetchedAt: new Date(now).toISOString(),
needsReauth: false,
// Omit the field entirely (rather than an empty array) when no windows
// resolved, so the wire shape stays minimal.
...(quotaWindows.length > 0 ? { quotaWindows } : {}),
};
}
/** Map the Codex local windows into the row's per-window detail shape. */
function buildCodexQuotaWindows(quota: CodexLocalQuota): QuotaWindowDetail[] {
return quota.windows.map((w) => ({
key: w.key,
label: w.label,
usedPercent: w.usedPercent,
remainingPercent: w.remainingPercent,
resetAt: w.resetAt,
windowMinutes: w.windowMinutes,
}));
}
function buildCodexRow(quota: CodexLocalQuota, now: number): BarSummaryRow {
const quotaWindows = buildCodexQuotaWindows(quota);
return {
account_id: CODEX_PROVIDER,
provider: CODEX_PROVIDER,
@@ -213,6 +291,9 @@ function buildCodexRow(quota: CodexLocalQuota, now: number): BarSummaryRow {
cached: false,
fetchedAt: new Date(now).toISOString(),
needsReauth: false,
...(quotaWindows.length > 0 ? { quotaWindows } : {}),
// staleAsOf is only present (and serialized) when the source session is old.
...(quota.staleAsOf ? { staleAsOf: quota.staleAsOf } : {}),
};
}