diff --git a/src/codex-usage-collector.ts b/src/codex-usage-collector.ts new file mode 100644 index 0000000..7cd2c45 --- /dev/null +++ b/src/codex-usage-collector.ts @@ -0,0 +1,301 @@ +import { ChildProcess, spawn } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + getAllCodexAccounts, + updateCodexAccountUsage, +} from './codex-token-rotation.js'; +import { formatResetRemaining, type UsageRow } from './dashboard-usage-rows.js'; +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 }; +} + +/** + * Result returned by the refresh functions. + * Caller is responsible for persisting into module-level cache. + */ +export interface CodexUsageRefreshResult { + rows: UsageRow[]; + /** Non-null only when at least one account was successfully fetched. */ + fetchedAt: string | null; +} + +const CODEX_ACCOUNTS_DIR = path.join(os.homedir(), '.codex-accounts'); + +/** Full scan interval — exported so the orchestrator can schedule it. */ +export const CODEX_FULL_SCAN_INTERVAL = 3_600_000; // 1 hour + +export async function fetchCodexUsage( + codexHomeOverride?: string, +): Promise { + const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex'); + const codexBin = fs.existsSync(npmGlobalBin) ? npmGlobalBin : 'codex'; + + return new Promise((resolve) => { + let done = false; + let proc: ChildProcess | null = null; + const finish = (value: CodexRateLimit[] | null) => { + if (done) return; + done = true; + clearTimeout(timer); + if (proc) { + try { + proc.kill(); + } catch { + /* ignore */ + } + } + resolve(value); + }; + + const timer = setTimeout(() => finish(null), 20_000); + + const spawnEnv: Record = { + ...(process.env as Record), + PATH: [ + path.dirname(process.execPath), + path.join(os.homedir(), '.npm-global', 'bin'), + process.env.PATH || '', + ].join(':'), + }; + if (codexHomeOverride) { + spawnEnv.CODEX_HOME = codexHomeOverride; + } + + try { + proc = spawn(codexBin, ['app-server'], { + stdio: ['pipe', 'pipe', 'pipe'], + env: spawnEnv, + }); + } catch { + resolve(null); + return; + } + + if (!proc.stdout || !proc.stdin) { + finish(null); + return; + } + + proc.on('error', () => finish(null)); + proc.on('close', () => finish(null)); + + let buffer = ''; + proc.stdout.on('data', (chunk: Buffer) => { + buffer += chunk.toString(); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + if (!line.trim()) continue; + try { + const message = JSON.parse(line); + if (message.id === 1) { + proc!.stdin!.write( + JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'account/rateLimits/read', + params: {}, + }) + '\n', + ); + } else if (message.id === 2 && message.result) { + const byId = message.result.rateLimitsByLimitId; + finish( + byId && typeof byId === 'object' + ? Object.entries(byId).map(([id, val]) => ({ + ...(val as CodexRateLimit), + limitId: id, + })) + : null, + ); + } + } catch { + /* ignore */ + } + } + }); + + proc.stdin.write( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { clientInfo: { name: 'usage-monitor', version: '1.0' } }, + }) + '\n', + ); + }); +} + +/** + * Extract usage percentages from the primary 'codex' rate-limit bucket + * and update the rotation state for a given account. + * + * Bucket selection: + * 1. limitId === 'codex' → use it + * 2. No 'codex' bucket + single bucket → use it + * 3. No 'codex' bucket + multiple buckets → unknown (show —) + * + * All buckets are logged at info level for observability. + */ +export function applyCodexUsageToAccount( + usage: CodexRateLimit[], + accountIndex: number, +): void { + if (usage.length === 0) return; + + // Log all buckets for observability + logger.info( + { + account: accountIndex + 1, + buckets: usage.map((l) => ({ + id: l.limitId, + h5: l.primary.usedPercent, + d7: l.secondary.usedPercent, + })), + }, + `Codex account #${accountIndex + 1}: ${usage.length} rate-limit bucket(s)`, + ); + + // Select the effective bucket + const primaryBucket = usage.find((l) => l.limitId === 'codex'); + let effective: CodexRateLimit | null = null; + + if (primaryBucket) { + effective = primaryBucket; + } else if (usage.length === 1) { + effective = usage[0]; + } else { + // Multiple unknown buckets — cannot determine which is authoritative + logger.warn( + { account: accountIndex + 1 }, + `Codex account #${accountIndex + 1}: no 'codex' bucket found among ${usage.length} buckets, showing unknown`, + ); + updateCodexAccountUsage(-1, undefined, accountIndex, -1, undefined); + return; + } + + const pct = Math.round(effective.primary.usedPercent); + const d7Pct = Math.round(effective.secondary.usedPercent); + const resetStr = effective.primary.resetsAt + ? formatResetRemaining(effective.primary.resetsAt) + : undefined; + const resetD7Str = effective.secondary.resetsAt + ? formatResetRemaining(effective.secondary.resetsAt) + : undefined; + updateCodexAccountUsage(pct, resetStr, accountIndex, d7Pct, resetD7Str); + logger.info( + { + account: accountIndex + 1, + bucket: effective.limitId, + h5: pct, + d7: d7Pct, + reset: resetStr, + }, + `Codex account #${accountIndex + 1} usage: 5h=${pct}% 7d=${d7Pct}%`, + ); +} + +/** + * Build display-ready usage rows from Codex rotation state. + * Called after refreshing usage data. + */ +export function buildCodexUsageRowsFromState(): UsageRow[] { + const codexAccounts = getAllCodexAccounts(); + if (codexAccounts.length === 0) return []; + + const isMulti = codexAccounts.length > 1; + return codexAccounts.map((acct) => { + const icon = acct.isActive ? '*' : acct.isRateLimited ? '!' : ' '; + const label = isMulti + ? `Codex${acct.index + 1}${icon} ${acct.planType}` + : 'Codex'; + return { + name: label, + h5pct: acct.cachedUsagePct != null ? acct.cachedUsagePct : -1, + h5reset: acct.resetAt || '', + d7pct: acct.cachedUsageD7Pct != null ? acct.cachedUsageD7Pct : -1, + d7reset: acct.resetD7At || '', + }; + }); +} + +/** + * Scan ALL Codex accounts by spawning app-server with each auth. + * Returns refresh result — caller owns cache state. + */ +export async function refreshAllCodexAccountUsage(): Promise { + const codexAccounts = getAllCodexAccounts(); + if (codexAccounts.length <= 1) { + return { rows: buildCodexUsageRowsFromState(), fetchedAt: null }; + } + + logger.info( + { accountCount: codexAccounts.length }, + 'Scanning all Codex accounts for usage data', + ); + + let anySuccess = false; + for (const acct of codexAccounts) { + const accountDir = path.join(CODEX_ACCOUNTS_DIR, String(acct.index + 1)); + if (!fs.existsSync(accountDir)) continue; + + try { + const usage = await fetchCodexUsage(accountDir); + if (usage && Array.isArray(usage) && usage.length > 0) { + applyCodexUsageToAccount(usage, acct.index); + anySuccess = true; + } + } catch (err) { + logger.debug( + { err, account: acct.index + 1 }, + 'Failed to fetch usage for Codex account', + ); + } + } + + return { + rows: buildCodexUsageRowsFromState(), + fetchedAt: anySuccess ? new Date().toISOString() : null, + }; +} + +/** + * Quick-refresh the active Codex account's usage. + * Returns refresh result — caller owns cache state. + */ +export async function refreshActiveCodexUsage(): Promise { + const codexAccounts = getAllCodexAccounts(); + if (codexAccounts.length === 0) { + return { rows: [], fetchedAt: null }; + } + + const active = codexAccounts.find((a) => a.isActive); + if (!active) { + return { rows: buildCodexUsageRowsFromState(), fetchedAt: null }; + } + + const accountDir = path.join(CODEX_ACCOUNTS_DIR, String(active.index + 1)); + if (!fs.existsSync(accountDir)) { + return { rows: buildCodexUsageRowsFromState(), fetchedAt: null }; + } + + let fetchedAt: string | null = null; + try { + const usage = await fetchCodexUsage(accountDir); + if (usage && Array.isArray(usage) && usage.length > 0) { + applyCodexUsageToAccount(usage, active.index); + fetchedAt = new Date().toISOString(); + } + } catch (err) { + logger.debug({ err }, 'Failed to fetch active Codex account usage'); + } + + return { rows: buildCodexUsageRowsFromState(), fetchedAt }; +} diff --git a/src/unified-dashboard.test.ts b/src/dashboard-usage-rows.test.ts similarity index 95% rename from src/unified-dashboard.test.ts rename to src/dashboard-usage-rows.test.ts index ddde4a4..f15a507 100644 --- a/src/unified-dashboard.test.ts +++ b/src/dashboard-usage-rows.test.ts @@ -3,24 +3,17 @@ import { describe, expect, it, vi } from 'vitest'; import type { ClaudeAccountUsage } from './claude-usage.js'; vi.mock('./claude-usage.js', () => ({ - fetchAllClaudeUsage: vi.fn(), - fetchAllClaudeProfiles: vi.fn(), getClaudeProfile: (index: number) => index === 0 ? { email: 'a@example.com', planType: 'max' } : { email: 'b@example.com', planType: 'pro' }, })); -vi.mock('./codex-token-rotation.js', () => ({ - getAllCodexAccounts: () => [], - updateCodexAccountUsage: vi.fn(), -})); - import { buildClaudeUsageRows, extractCodexUsageRows, mergeClaudeDashboardAccounts, -} from './unified-dashboard.js'; +} from './dashboard-usage-rows.js'; import type { StatusSnapshot } from './status-dashboard.js'; describe('extractCodexUsageRows staleness', () => { @@ -95,7 +88,7 @@ describe('extractCodexUsageRows staleness', () => { }); }); -describe('unified dashboard Claude usage rows', () => { +describe('dashboard Claude usage rows', () => { it('keeps both Claude accounts visible when one account usage is unavailable', () => { const rows = buildClaudeUsageRows([ { diff --git a/src/dashboard-usage-rows.ts b/src/dashboard-usage-rows.ts new file mode 100644 index 0000000..31c8dff --- /dev/null +++ b/src/dashboard-usage-rows.ts @@ -0,0 +1,103 @@ +import { getClaudeProfile, type ClaudeAccountUsage } from './claude-usage.js'; +import type { StatusSnapshot } from './status-dashboard.js'; + +export type UsageRow = { + name: string; + h5pct: number; + h5reset: string; + d7pct: number; + d7reset: string; +}; + +export function formatResetRemaining(value: string | number): string { + if (value === '' || value == null) return ''; + try { + const date = + typeof value === 'number' ? new Date(value * 1000) : new Date(value); + if (Number.isNaN(date.getTime())) return ''; + const diffMs = date.getTime() - Date.now(); + if (diffMs <= 0) return ' reset'; + const hours = Math.floor(diffMs / 3_600_000); + const minutes = Math.floor((diffMs % 3_600_000) / 60_000); + if (hours >= 24) { + const days = Math.floor(hours / 24); + const remH = hours % 24; + return `${String(days).padStart(2)}d ${String(remH).padStart(2)}h`; + } + return `${String(hours).padStart(2)}h ${String(minutes).padStart(2)}m`; + } catch { + return String(value).padStart(6); + } +} + +export function mergeClaudeDashboardAccounts( + liveAccounts: ClaudeAccountUsage[] | null | undefined, + cachedAccounts: ClaudeAccountUsage[], +): ClaudeAccountUsage[] { + if (!liveAccounts) return cachedAccounts; + + const cachedByIndex = new Map( + cachedAccounts.map((account) => [account.index, account]), + ); + + return liveAccounts.map((account) => ({ + ...account, + usage: account.usage || cachedByIndex.get(account.index)?.usage || null, + })); +} + +export function buildClaudeUsageRows( + claudeAccounts: ClaudeAccountUsage[], +): UsageRow[] { + const isMultiAccount = claudeAccounts.length > 1; + + return claudeAccounts.map((account) => { + const usage = account.usage; + const h5 = usage?.five_hour; + const d7 = usage?.seven_day; + const profile = getClaudeProfile(account.index); + const planSuffix = profile ? ` ${profile.planType}` : ''; + const label = isMultiAccount + ? `Claude${account.index + 1}${account.isActive ? '*' : ''}${account.isRateLimited ? '!' : ''}${planSuffix}` + : `Claude${account.isActive ? '*' : ''}${account.isRateLimited ? '!' : ''}${planSuffix}`; + + return { + name: label, + h5pct: h5 + ? h5.utilization > 1 + ? Math.round(h5.utilization) + : Math.round(h5.utilization * 100) + : -1, + h5reset: h5 ? formatResetRemaining(h5.resets_at) : '', + d7pct: d7 + ? d7.utilization > 1 + ? Math.round(d7.utilization) + : Math.round(d7.utilization * 100) + : -1, + d7reset: d7 ? formatResetRemaining(d7.resets_at) : '', + }; + }); +} + +/** + * Extract Codex usage rows from a snapshot, applying staleness check. + * Returns real rows if usageRowsFetchedAt is within maxAgeMs, otherwise a + * single degraded row. Returns empty array if no usage data present. + */ +export function extractCodexUsageRows( + snapshot: StatusSnapshot | undefined, + maxAgeMs: number, + now: number = Date.now(), +): UsageRow[] { + if (!snapshot?.usageRows || snapshot.usageRows.length === 0) return []; + + const fetchedAt = snapshot.usageRowsFetchedAt + ? new Date(snapshot.usageRowsFetchedAt).getTime() + : 0; + const usageAge = now - fetchedAt; + if (usageAge <= maxAgeMs) { + return [...snapshot.usageRows]; + } + // Usage data is stale — return degraded indicator + return [{ name: 'Codex', h5pct: -1, h5reset: '', d7pct: -1, d7reset: '' }]; +} diff --git a/src/unified-dashboard.ts b/src/unified-dashboard.ts index 8c6fa6d..7bce7ae 100644 --- a/src/unified-dashboard.ts +++ b/src/unified-dashboard.ts @@ -1,19 +1,17 @@ -import { ChildProcess, execSync, spawn } from 'child_process'; -import fs from 'fs'; +import { execSync } from 'child_process'; import os from 'os'; -import path from 'path'; import { STATUS_SHOW_ROOMS, USAGE_DASHBOARD_ENABLED } from './config.js'; import { fetchAllClaudeUsage, fetchAllClaudeProfiles, - getClaudeProfile, type ClaudeAccountUsage, } from './claude-usage.js'; import { - getAllCodexAccounts, - updateCodexAccountUsage, -} from './codex-token-rotation.js'; + CODEX_FULL_SCAN_INTERVAL, + refreshActiveCodexUsage, + refreshAllCodexAccountUsage, +} from './codex-usage-collector.js'; import { composeDashboardContent, formatElapsed, @@ -21,13 +19,18 @@ import { type DashboardRoomLine, renderCategorizedRoomSections, } from './dashboard-render.js'; +import { + buildClaudeUsageRows, + extractCodexUsageRows, + mergeClaudeDashboardAccounts, + type UsageRow, +} from './dashboard-usage-rows.js'; import { getAllChats, updateRegisteredGroupName } from './db.js'; import type { GroupQueue } from './group-queue.js'; import { logger } from './logger.js'; import { readStatusSnapshots, writeStatusSnapshot, - type StatusSnapshot, } from './status-dashboard.js'; import type { AgentType, @@ -49,13 +52,6 @@ export interface UnifiedDashboardOptions { purgeOnStart?: boolean; } -interface CodexRateLimit { - limitId?: string; - limitName: string | null; - primary: { usedPercent: number; resetsAt: string | number }; - secondary: { usedPercent: number; resetsAt: string | number }; -} - const STATUS_ICONS: Record = { processing: '🟡', waiting: '🔵', @@ -85,27 +81,6 @@ let cachedCodexUsageRows: UsageRow[] = []; /** Codex service only: ISO timestamp of last successful usage fetch. */ let codexUsageFetchedAt: string | null = null; -function formatResetRemaining(value: string | number): string { - if (value === '' || value == null) return ''; - try { - const date = - typeof value === 'number' ? new Date(value * 1000) : new Date(value); - if (Number.isNaN(date.getTime())) return ''; - const diffMs = date.getTime() - Date.now(); - if (diffMs <= 0) return ' reset'; - const hours = Math.floor(diffMs / 3_600_000); - const minutes = Math.floor((diffMs % 3_600_000) / 60_000); - if (hours >= 24) { - const days = Math.floor(hours / 24); - const remH = hours % 24; - return `${String(days).padStart(2)}d ${String(remH).padStart(2)}h`; - } - return `${String(hours).padStart(2)}h ${String(minutes).padStart(2)}m`; - } catch { - return String(value).padStart(6); - } -} - function findDiscordChannel(channels: Channel[]): Channel | undefined { return channels.find( (channel) => channel.name.startsWith('discord') && channel.isConnected(), @@ -346,188 +321,6 @@ function buildStatusContent(): string { return `${header}\n\n${sections}`; } -async function fetchCodexUsage( - codexHomeOverride?: string, -): Promise { - const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex'); - const codexBin = fs.existsSync(npmGlobalBin) ? npmGlobalBin : 'codex'; - - return new Promise((resolve) => { - let done = false; - let proc: ChildProcess | null = null; - const finish = (value: CodexRateLimit[] | null) => { - if (done) return; - done = true; - clearTimeout(timer); - if (proc) { - try { - proc.kill(); - } catch { - /* ignore */ - } - } - resolve(value); - }; - - const timer = setTimeout(() => finish(null), 20_000); - - const spawnEnv: Record = { - ...(process.env as Record), - PATH: [ - path.dirname(process.execPath), - path.join(os.homedir(), '.npm-global', 'bin'), - process.env.PATH || '', - ].join(':'), - }; - if (codexHomeOverride) { - spawnEnv.CODEX_HOME = codexHomeOverride; - } - - try { - proc = spawn(codexBin, ['app-server'], { - stdio: ['pipe', 'pipe', 'pipe'], - env: spawnEnv, - }); - } catch { - resolve(null); - return; - } - - if (!proc.stdout || !proc.stdin) { - finish(null); - return; - } - - proc.on('error', () => finish(null)); - proc.on('close', () => finish(null)); - - let buffer = ''; - proc.stdout.on('data', (chunk: Buffer) => { - buffer += chunk.toString(); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - for (const line of lines) { - if (!line.trim()) continue; - try { - const message = JSON.parse(line); - if (message.id === 1) { - proc!.stdin!.write( - JSON.stringify({ - jsonrpc: '2.0', - id: 2, - method: 'account/rateLimits/read', - params: {}, - }) + '\n', - ); - } else if (message.id === 2 && message.result) { - const byId = message.result.rateLimitsByLimitId; - finish( - byId && typeof byId === 'object' - ? Object.entries(byId).map(([id, val]) => ({ - ...(val as CodexRateLimit), - limitId: id, - })) - : null, - ); - } - } catch { - /* ignore */ - } - } - }); - - proc.stdin.write( - JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { clientInfo: { name: 'usage-monitor', version: '1.0' } }, - }) + '\n', - ); - }); -} - -type UsageRow = { - name: string; - h5pct: number; - h5reset: string; - d7pct: number; - d7reset: string; -}; - -export function mergeClaudeDashboardAccounts( - liveAccounts: ClaudeAccountUsage[] | null | undefined, - cachedAccounts: ClaudeAccountUsage[], -): ClaudeAccountUsage[] { - if (!liveAccounts) return cachedAccounts; - - const cachedByIndex = new Map( - cachedAccounts.map((account) => [account.index, account]), - ); - - return liveAccounts.map((account) => ({ - ...account, - usage: account.usage || cachedByIndex.get(account.index)?.usage || null, - })); -} - -export function buildClaudeUsageRows( - claudeAccounts: ClaudeAccountUsage[], -): UsageRow[] { - const isMultiAccount = claudeAccounts.length > 1; - - return claudeAccounts.map((account) => { - const usage = account.usage; - const h5 = usage?.five_hour; - const d7 = usage?.seven_day; - const profile = getClaudeProfile(account.index); - const planSuffix = profile ? ` ${profile.planType}` : ''; - const label = isMultiAccount - ? `Claude${account.index + 1}${account.isActive ? '*' : ''}${account.isRateLimited ? '!' : ''}${planSuffix}` - : `Claude${account.isActive ? '*' : ''}${account.isRateLimited ? '!' : ''}${planSuffix}`; - - return { - name: label, - h5pct: h5 - ? h5.utilization > 1 - ? Math.round(h5.utilization) - : Math.round(h5.utilization * 100) - : -1, - h5reset: h5 ? formatResetRemaining(h5.resets_at) : '', - d7pct: d7 - ? d7.utilization > 1 - ? Math.round(d7.utilization) - : Math.round(d7.utilization * 100) - : -1, - d7reset: d7 ? formatResetRemaining(d7.resets_at) : '', - }; - }); -} - -/** - * Extract Codex usage rows from a snapshot, applying staleness check. - * Returns real rows if usageRowsFetchedAt is within maxAgeMs, otherwise a - * single degraded row. Returns empty array if no usage data present. - * Exported for testing. - */ -export function extractCodexUsageRows( - snapshot: StatusSnapshot | undefined, - maxAgeMs: number, - now: number = Date.now(), -): UsageRow[] { - if (!snapshot?.usageRows || snapshot.usageRows.length === 0) return []; - - const fetchedAt = snapshot.usageRowsFetchedAt - ? new Date(snapshot.usageRowsFetchedAt).getTime() - : 0; - const usageAge = now - fetchedAt; - if (usageAge <= maxAgeMs) { - return [...snapshot.usageRows]; - } - // Usage data is stale — return degraded indicator - return [{ name: 'Codex', h5pct: -1, h5reset: '', d7pct: -1, d7reset: '' }]; -} - async function buildUsageContent(): Promise { const shouldFetchClaudeUsage = USAGE_DASHBOARD_ENABLED; let liveClaudeAccounts: ClaudeAccountUsage[] | null = null; @@ -660,167 +453,6 @@ function buildUnifiedDashboardContent(): string { return composeDashboardContent(sections); } -const CODEX_ACCOUNTS_DIR = path.join(os.homedir(), '.codex-accounts'); -const CODEX_FULL_SCAN_INTERVAL = 3_600_000; // 1 hour - -/** - * Extract usage percentages from the primary 'codex' rate-limit bucket - * and update the rotation state for a given account. - * - * Bucket selection: - * 1. limitId === 'codex' → use it (user confirmed bengalfox = Codex Spark, not needed) - * 2. No 'codex' bucket + single bucket → use it - * 3. No 'codex' bucket + multiple buckets → unknown (show —) - * - * All buckets are logged at info level for observability. - */ -function applyCodexUsageToAccount( - usage: CodexRateLimit[], - accountIndex: number, -): void { - if (usage.length === 0) return; - - // Log all buckets for observability - logger.info( - { - account: accountIndex + 1, - buckets: usage.map((l) => ({ - id: l.limitId, - h5: l.primary.usedPercent, - d7: l.secondary.usedPercent, - })), - }, - `Codex account #${accountIndex + 1}: ${usage.length} rate-limit bucket(s)`, - ); - - // Select the effective bucket - const primaryBucket = usage.find((l) => l.limitId === 'codex'); - let effective: CodexRateLimit | null = null; - - if (primaryBucket) { - effective = primaryBucket; - } else if (usage.length === 1) { - effective = usage[0]; - } else { - // Multiple unknown buckets — cannot determine which is authoritative - logger.warn( - { account: accountIndex + 1 }, - `Codex account #${accountIndex + 1}: no 'codex' bucket found among ${usage.length} buckets, showing unknown`, - ); - updateCodexAccountUsage(-1, undefined, accountIndex, -1, undefined); - return; - } - - const pct = Math.round(effective.primary.usedPercent); - const d7Pct = Math.round(effective.secondary.usedPercent); - const resetStr = effective.primary.resetsAt - ? formatResetRemaining(effective.primary.resetsAt) - : undefined; - const resetD7Str = effective.secondary.resetsAt - ? formatResetRemaining(effective.secondary.resetsAt) - : undefined; - updateCodexAccountUsage(pct, resetStr, accountIndex, d7Pct, resetD7Str); - logger.info( - { - account: accountIndex + 1, - bucket: effective.limitId, - h5: pct, - d7: d7Pct, - reset: resetStr, - }, - `Codex account #${accountIndex + 1} usage: 5h=${pct}% 7d=${d7Pct}%`, - ); -} - -/** - * Build display-ready usage rows from Codex rotation state. - * Called by the Codex service after refreshing usage data. - */ -function buildCodexUsageRowsFromState(): UsageRow[] { - const codexAccounts = getAllCodexAccounts(); - if (codexAccounts.length === 0) return []; - - const isMulti = codexAccounts.length > 1; - return codexAccounts.map((acct) => { - const icon = acct.isActive ? '*' : acct.isRateLimited ? '!' : ' '; - const label = isMulti - ? `Codex${acct.index + 1}${icon} ${acct.planType}` - : 'Codex'; - return { - name: label, - h5pct: acct.cachedUsagePct != null ? acct.cachedUsagePct : -1, - h5reset: acct.resetAt || '', - d7pct: acct.cachedUsageD7Pct != null ? acct.cachedUsageD7Pct : -1, - d7reset: acct.resetD7At || '', - }; - }); -} - -/** - * Scan ALL Codex accounts by spawning app-server with each auth. - * Codex service only — called on startup and every hour. - */ -async function refreshAllCodexAccountUsage(): Promise { - const codexAccounts = getAllCodexAccounts(); - if (codexAccounts.length <= 1) return; - - logger.info( - { accountCount: codexAccounts.length }, - 'Scanning all Codex accounts for usage data', - ); - - let anySuccess = false; - for (const acct of codexAccounts) { - const accountDir = path.join(CODEX_ACCOUNTS_DIR, String(acct.index + 1)); - if (!fs.existsSync(accountDir)) continue; - - try { - const usage = await fetchCodexUsage(accountDir); - if (usage && Array.isArray(usage) && usage.length > 0) { - applyCodexUsageToAccount(usage, acct.index); - anySuccess = true; - } - } catch (err) { - logger.debug( - { err, account: acct.index + 1 }, - 'Failed to fetch usage for Codex account', - ); - } - } - - if (anySuccess) { - codexUsageFetchedAt = new Date().toISOString(); - } - cachedCodexUsageRows = buildCodexUsageRowsFromState(); -} - -/** - * Quick-refresh the active Codex account's usage (5-min interval). - * Codex service only — fetches the active account and rebuilds cached rows. - */ -async function refreshActiveCodexUsage(): Promise { - const codexAccounts = getAllCodexAccounts(); - if (codexAccounts.length === 0) return; - - const active = codexAccounts.find((a) => a.isActive); - if (!active) return; - - const accountDir = path.join(CODEX_ACCOUNTS_DIR, String(active.index + 1)); - if (!fs.existsSync(accountDir)) return; - - try { - const usage = await fetchCodexUsage(accountDir); - if (usage && Array.isArray(usage) && usage.length > 0) { - applyCodexUsageToAccount(usage, active.index); - codexUsageFetchedAt = new Date().toISOString(); - } - } catch (err) { - logger.debug({ err }, 'Failed to fetch active Codex account usage'); - } - - cachedCodexUsageRows = buildCodexUsageRowsFromState(); -} - async function refreshUsageCache(): Promise { if (usageUpdateInProgress) return; usageUpdateInProgress = true; @@ -902,12 +534,18 @@ export async function startUnifiedDashboard( } else { // Codex service: fetch own usage and expose via status snapshot. // Active account every usageUpdateInterval; full scan on startup + hourly. - void refreshAllCodexAccountUsage().then( - () => void refreshActiveCodexUsage(), - ); - setInterval(() => void refreshActiveCodexUsage(), opts.usageUpdateInterval); + // Collector returns data; we own the cache state. + const applyRefresh = (result: { rows: UsageRow[]; fetchedAt: string | null }) => { + cachedCodexUsageRows = result.rows; + if (result.fetchedAt) codexUsageFetchedAt = result.fetchedAt; + }; + void refreshAllCodexAccountUsage().then((r) => { + applyRefresh(r); + return refreshActiveCodexUsage().then(applyRefresh); + }); + setInterval(() => void refreshActiveCodexUsage().then(applyRefresh), opts.usageUpdateInterval); setInterval( - () => void refreshAllCodexAccountUsage(), + () => void refreshAllCodexAccountUsage().then(applyRefresh), CODEX_FULL_SCAN_INTERVAL, ); }