From c7a3a563865a08e5657e8fcf148c9a66051d8b21 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Tue, 24 Mar 2026 01:25:02 +0900 Subject: [PATCH] feat: scan all Codex accounts on startup + hourly, cache 5h/7d usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scan all Codex accounts by spawning app-server with each auth dir - Cache primary (5h) and secondary (7d) usage per account - Show cached usage + reset remaining time on dashboard - Persist d7 usage in rotation state file - formatResetRemaining: "Xh Ym 후" / "N일 후" format - Remove isRateLimited guard for cached display --- src/codex-token-rotation.ts | 81 ++++++++++++++++++++++++--- src/unified-dashboard.ts | 107 +++++++++++++++++++++++++++++++----- 2 files changed, 166 insertions(+), 22 deletions(-) diff --git a/src/codex-token-rotation.ts b/src/codex-token-rotation.ts index d57b90c..98c12be 100644 --- a/src/codex-token-rotation.ts +++ b/src/codex-token-rotation.ts @@ -24,21 +24,27 @@ interface CodexAccount { authPath: string; accountId: string; planType: string; + subscriptionUntil: string | null; rateLimitedUntil: number | null; lastUsagePct?: number; + lastUsageD7Pct?: number; resetAt?: string; } -function parsePlanFromJwt(idToken: string): string { +function parseJwtAuth(idToken: string): { planType: string; expiresAt: string | null } { try { const parts = idToken.split('.'); - if (parts.length < 2) return '?'; + if (parts.length < 2) return { planType: '?', expiresAt: null }; const payload = JSON.parse( Buffer.from(parts[1], 'base64url').toString('utf-8'), ); - return payload?.['https://api.openai.com/auth']?.chatgpt_plan_type || '?'; + const auth = payload?.['https://api.openai.com/auth'] || {}; + return { + planType: auth.chatgpt_plan_type || '?', + expiresAt: auth.chatgpt_subscription_active_until || null, + }; } catch { - return '?'; + return { planType: '?', expiresAt: null }; } } @@ -72,12 +78,14 @@ export function initCodexTokenRotation(): void { try { const data = JSON.parse(fs.readFileSync(authPath, 'utf-8')); const accountId = data?.tokens?.account_id || `account-${dir}`; - const planType = parsePlanFromJwt(data?.tokens?.id_token || ''); + const jwt = parseJwtAuth(data?.tokens?.id_token || ''); + const planType = jwt.planType; accounts.push({ index: accounts.length, authPath, accountId, planType, + subscriptionUntil: jwt.expiresAt, rateLimitedUntil: null, }); } catch { @@ -98,6 +106,7 @@ function saveCodexState(): void { currentIndex, rateLimits: accounts.map((a) => a.rateLimitedUntil), usagePcts: accounts.map((a) => a.lastUsagePct ?? null), + usageD7Pcts: accounts.map((a) => a.lastUsageD7Pct ?? null), resetAts: accounts.map((a) => a.resetAt ?? null), }; fs.writeFileSync(STATE_FILE, JSON.stringify(state)); @@ -130,12 +139,26 @@ function loadCodexState(): void { } } if (Array.isArray(state.usagePcts)) { - for (let i = 0; i < Math.min(state.usagePcts.length, accounts.length); i++) { - if (typeof state.usagePcts[i] === 'number') accounts[i].lastUsagePct = state.usagePcts[i]; + for ( + let i = 0; + i < Math.min(state.usagePcts.length, accounts.length); + i++ + ) { + if (typeof state.usagePcts[i] === 'number') + accounts[i].lastUsagePct = state.usagePcts[i]; + } + } + if (Array.isArray(state.usageD7Pcts)) { + for (let i = 0; i < Math.min(state.usageD7Pcts.length, accounts.length); i++) { + if (typeof state.usageD7Pcts[i] === 'number') accounts[i].lastUsageD7Pct = state.usageD7Pcts[i]; } } if (Array.isArray(state.resetAts)) { - for (let i = 0; i < Math.min(state.resetAts.length, accounts.length); i++) { + for ( + let i = 0; + i < Math.min(state.resetAts.length, accounts.length); + i++ + ) { if (state.resetAts[i]) accounts[i].resetAt = state.resetAts[i]; } } @@ -224,6 +247,46 @@ export function rotateCodexToken(errorMessage?: string): boolean { return false; } +/** + * Advance to the next healthy account (round-robin). + * Called after each successful request to spread load evenly + * and keep usage data fresh for all accounts. + */ +export function advanceCodexAccount(): void { + if (accounts.length <= 1) return; + const now = Date.now(); + for (let i = 1; i < accounts.length; i++) { + const idx = (currentIndex + i) % accounts.length; + const acct = accounts[idx]; + if (!acct.rateLimitedUntil || acct.rateLimitedUntil <= now) { + currentIndex = idx; + saveCodexState(); + return; + } + } + // All others rate-limited, stay on current +} + +/** + * Update cached usage info for a specific account (or current if index omitted). + */ +export function updateCodexAccountUsage( + usagePct: number, + resetAt?: string, + accountIndex?: number, + d7Pct?: number, +): void { + if (accounts.length === 0) return; + const idx = accountIndex ?? currentIndex; + const acct = accounts[idx]; + if (acct) { + acct.lastUsagePct = usagePct; + if (d7Pct != null) acct.lastUsageD7Pct = d7Pct; + if (resetAt) acct.resetAt = resetAt; + saveCodexState(); + } +} + export function markCodexTokenHealthy(): void { if (accounts.length === 0) return; const acct = accounts[currentIndex]; @@ -244,6 +307,7 @@ export function getAllCodexAccounts(): { isActive: boolean; isRateLimited: boolean; cachedUsagePct?: number; + cachedUsageD7Pct?: number; resetAt?: string; }[] { const now = Date.now(); @@ -254,6 +318,7 @@ export function getAllCodexAccounts(): { isActive: i === currentIndex, isRateLimited: Boolean(a.rateLimitedUntil && a.rateLimitedUntil > now), cachedUsagePct: a.lastUsagePct, + cachedUsageD7Pct: a.lastUsageD7Pct, resetAt: a.resetAt, })); } diff --git a/src/unified-dashboard.ts b/src/unified-dashboard.ts index b3146da..7b16933 100644 --- a/src/unified-dashboard.ts +++ b/src/unified-dashboard.ts @@ -10,7 +10,10 @@ import { type ClaudeUsageData, type ClaudeAccountUsage, } from './claude-usage.js'; -import { getAllCodexAccounts } from './codex-token-rotation.js'; +import { + getAllCodexAccounts, + updateCodexAccountUsage, +} from './codex-token-rotation.js'; import { composeDashboardContent, formatElapsed, @@ -88,6 +91,25 @@ function formatResetKST(value: string | number): string { } } +function formatResetRemaining(value: string | number): string { + try { + const date = + typeof value === 'number' ? new Date(value * 1000) : new Date(value); + const diffMs = date.getTime() - Date.now(); + if (diffMs <= 0) return '리셋됨'; + 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); + return `${days}일 후`; + } + if (hours > 0) return `${hours}h ${minutes}m 후`; + return `${minutes}m 후`; + } catch { + return String(value); + } +} + function findDiscordChannel(channels: Channel[]): Channel | undefined { return channels.find( (channel) => channel.name.startsWith('discord') && channel.isConnected(), @@ -421,7 +443,9 @@ async function fetchClaudeUsage(): Promise { } } -async function fetchCodexUsage(): Promise { +async function fetchCodexUsage( + codexHomeOverride?: string, +): Promise { const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex'); const codexBin = fs.existsSync(npmGlobalBin) ? npmGlobalBin : 'codex'; @@ -444,17 +468,22 @@ async function fetchCodexUsage(): Promise { 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: { - ...(process.env as Record), - PATH: [ - path.dirname(process.execPath), - path.join(os.homedir(), '.npm-global', 'bin'), - process.env.PATH || '', - ].join(':'), - }, + env: spawnEnv, }); } catch { resolve(null); @@ -616,15 +645,17 @@ async function buildUsageContent(): Promise { }); } } else { - // Show cached usage for rate-limited accounts - const pct = acct.isRateLimited && acct.cachedUsagePct != null - ? acct.cachedUsagePct : -1; + // Show cached usage from last scan + const pct = + acct.cachedUsagePct != null ? acct.cachedUsagePct : -1; + const d7pct = + acct.cachedUsageD7Pct != null ? acct.cachedUsageD7Pct : -1; const reset = acct.resetAt || ''; rows.push({ name: `${label} ${acct.planType}`, h5pct: pct, h5reset: reset, - d7pct: -1, + d7pct, d7reset: '', }); } @@ -736,6 +767,51 @@ 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 + +/** + * Scan ALL Codex accounts by spawning app-server with each auth. + * Called on startup and every hour to keep cached usage fresh. + */ +async function refreshAllCodexAccountUsage(): Promise { + const codexAccounts = getAllCodexAccounts(); + if (codexAccounts.length <= 1) return; + + logger.info( + { accountCount: codexAccounts.length }, + 'Scanning all Codex accounts for usage data', + ); + + 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)) { + const relevant = usage.filter( + (l) => l.primary.usedPercent > 0 || l.secondary.usedPercent > 0, + ); + const display = relevant.length > 0 ? relevant : usage.slice(0, 1); + if (display.length > 0) { + const pct = Math.round(display[0].primary.usedPercent); + const resetVal = display[0].primary.resetsAt; + const resetStr = resetVal ? formatResetRemaining(resetVal) : undefined; + const d7Pct = Math.round(display[0].secondary.usedPercent); + updateCodexAccountUsage(pct, resetStr, acct.index, d7Pct); + logger.info( + { account: acct.index + 1, usagePct: pct, reset: resetStr }, + `Codex account #${acct.index + 1} usage: ${pct}%`, + ); + } + } + } catch (err) { + logger.debug({ err, account: acct.index + 1 }, 'Failed to fetch usage for Codex account'); + } + } +} + async function refreshUsageCache(): Promise { if (usageUpdateInProgress) return; usageUpdateInProgress = true; @@ -803,6 +879,9 @@ export async function startUnifiedDashboard( if (isRenderer) { setInterval(refreshUsageCache, opts.usageUpdateInterval); + // Full scan of all Codex accounts on startup + hourly + void refreshAllCodexAccountUsage(); + setInterval(() => void refreshAllCodexAccountUsage(), CODEX_FULL_SCAN_INTERVAL); } logger.info(