From 74016263568ddf25ffb665929f3234cf7cff5b75 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 23 Mar 2026 23:16:54 +0900 Subject: [PATCH] feat: persist rotation state across restarts, fix dashboard alignment - Save currentIndex + rateLimits to JSON file in DATA_DIR - Restore on startup so rotation survives service restarts - Replace emoji indicators with text (*/!) for monospace alignment - Dynamic column width based on longest name - Show Codex accounts with plan type on dashboard --- src/codex-token-rotation.ts | 70 ++++++++++++++++++++++++++++++++++--- src/token-rotation.ts | 47 ++++++++++++++++++++++--- src/unified-dashboard.ts | 51 ++++++++++++++++++++++++--- 3 files changed, 154 insertions(+), 14 deletions(-) diff --git a/src/codex-token-rotation.ts b/src/codex-token-rotation.ts index 092d642..12de6e4 100644 --- a/src/codex-token-rotation.ts +++ b/src/codex-token-rotation.ts @@ -14,15 +14,32 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import { DATA_DIR } from './config.js'; import { logger } from './logger.js'; +const STATE_FILE = path.join(DATA_DIR, 'codex-rotation-state.json'); + interface CodexAccount { index: number; authPath: string; accountId: string; + planType: string; rateLimitedUntil: number | null; } +function parsePlanFromJwt(idToken: string): string { + try { + const parts = idToken.split('.'); + if (parts.length < 2) return '?'; + const payload = JSON.parse( + Buffer.from(parts[1], 'base64url').toString('utf-8'), + ); + return payload?.['https://api.openai.com/auth']?.chatgpt_plan_type || '?'; + } catch { + return '?'; + } +} + const accounts: CodexAccount[] = []; let currentIndex = 0; let initialized = false; @@ -34,11 +51,15 @@ export function initCodexTokenRotation(): void { initialized = true; if (!fs.existsSync(ACCOUNTS_DIR)) { - logger.info({ dir: ACCOUNTS_DIR }, 'Codex accounts dir not found, skipping'); + logger.info( + { dir: ACCOUNTS_DIR }, + 'Codex accounts dir not found, skipping', + ); return; } - const dirs = fs.readdirSync(ACCOUNTS_DIR) + const dirs = fs + .readdirSync(ACCOUNTS_DIR) .filter((d) => /^\d+$/.test(d)) .sort((a, b) => parseInt(a) - parseInt(b)); @@ -49,10 +70,12 @@ 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 || ''); accounts.push({ index: accounts.length, authPath, accountId, + planType, rateLimitedUntil: null, }); } catch { @@ -60,12 +83,43 @@ export function initCodexTokenRotation(): void { } } + if (accounts.length > 1) loadCodexState(); logger.info( - { count: accounts.length, dir: ACCOUNTS_DIR }, + { count: accounts.length, dir: ACCOUNTS_DIR, activeIndex: currentIndex }, `Codex token rotation: ${accounts.length} account(s) found`, ); } +function saveCodexState(): void { + try { + const state = { + currentIndex, + rateLimits: accounts.map((a) => a.rateLimitedUntil), + }; + fs.writeFileSync(STATE_FILE, JSON.stringify(state)); + } catch { /* best effort */ } +} + +function loadCodexState(): void { + try { + if (!fs.existsSync(STATE_FILE)) return; + const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')); + const now = Date.now(); + if (typeof state.currentIndex === 'number' && state.currentIndex < accounts.length) { + currentIndex = state.currentIndex; + } + if (Array.isArray(state.rateLimits)) { + for (let i = 0; i < Math.min(state.rateLimits.length, accounts.length); i++) { + const until = state.rateLimits[i]; + if (typeof until === 'number' && until > now) { + accounts[i].rateLimitedUntil = until; + } + } + } + logger.info({ currentIndex, accountCount: accounts.length }, 'Codex rotation state restored'); + } catch { /* start fresh */ } +} + /** Get the auth.json path for the current active account. */ export function getActiveCodexAuthPath(): string | null { if (accounts.length === 0) return null; @@ -89,9 +143,14 @@ export function rotateCodexToken(): boolean { acct.rateLimitedUntil = null; currentIndex = idx; logger.info( - { accountIndex: currentIndex, totalAccounts: accounts.length, accountId: acct.accountId }, + { + accountIndex: currentIndex, + totalAccounts: accounts.length, + accountId: acct.accountId, + }, `Codex rotated to account #${currentIndex + 1}/${accounts.length}`, ); + saveCodexState(); return true; } } @@ -105,6 +164,7 @@ export function markCodexTokenHealthy(): void { const acct = accounts[currentIndex]; if (acct?.rateLimitedUntil) { acct.rateLimitedUntil = null; + saveCodexState(); } } @@ -115,6 +175,7 @@ export function getCodexAccountCount(): number { export function getAllCodexAccounts(): { index: number; accountId: string; + planType: string; isActive: boolean; isRateLimited: boolean; }[] { @@ -122,6 +183,7 @@ export function getAllCodexAccounts(): { return accounts.map((a, i) => ({ index: i, accountId: a.accountId, + planType: a.planType, isActive: i === currentIndex, isRateLimited: Boolean(a.rateLimitedUntil && a.rateLimitedUntil > now), })); diff --git a/src/token-rotation.ts b/src/token-rotation.ts index 9874732..da9f289 100644 --- a/src/token-rotation.ts +++ b/src/token-rotation.ts @@ -10,9 +10,15 @@ * All exhausted: fall through to provider fallback (Kimi etc.) */ +import fs from 'fs'; +import path from 'path'; + +import { DATA_DIR } from './config.js'; import { readEnvFile } from './env.js'; import { logger } from './logger.js'; +const STATE_FILE = path.join(DATA_DIR, 'token-rotation-state.json'); + interface TokenState { token: string; rateLimitedUntil: number | null; @@ -31,11 +37,9 @@ export function initTokenRotation(): void { 'CLAUDE_CODE_OAUTH_TOKEN', ]); const multi = - process.env.CLAUDE_CODE_OAUTH_TOKENS || - envFile.CLAUDE_CODE_OAUTH_TOKENS; + process.env.CLAUDE_CODE_OAUTH_TOKENS || envFile.CLAUDE_CODE_OAUTH_TOKENS; const single = - process.env.CLAUDE_CODE_OAUTH_TOKEN || - envFile.CLAUDE_CODE_OAUTH_TOKEN; + process.env.CLAUDE_CODE_OAUTH_TOKEN || envFile.CLAUDE_CODE_OAUTH_TOKEN; const raw = multi ? multi @@ -51,13 +55,44 @@ export function initTokenRotation(): void { } if (tokens.length > 1) { + loadState(); logger.info( - { count: tokens.length }, + { count: tokens.length, activeIndex: currentIndex }, `Token rotation initialized with ${tokens.length} tokens`, ); } } +function saveState(): void { + try { + const state = { + currentIndex, + rateLimits: tokens.map((t) => t.rateLimitedUntil), + }; + fs.writeFileSync(STATE_FILE, JSON.stringify(state)); + } catch { /* best effort */ } +} + +function loadState(): void { + try { + if (!fs.existsSync(STATE_FILE)) return; + const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')); + const now = Date.now(); + if (typeof state.currentIndex === 'number' && state.currentIndex < tokens.length) { + currentIndex = state.currentIndex; + } + if (Array.isArray(state.rateLimits)) { + for (let i = 0; i < Math.min(state.rateLimits.length, tokens.length); i++) { + const until = state.rateLimits[i]; + if (typeof until === 'number' && until > now) { + tokens[i].rateLimitedUntil = until; + } + } + } + logger.info({ currentIndex, tokenCount: tokens.length }, 'Token rotation state restored'); + } catch { /* start fresh */ } +} + /** Get the current active token. */ export function getCurrentToken(): string | undefined { if (tokens.length === 0) return process.env.CLAUDE_CODE_OAUTH_TOKEN; @@ -86,6 +121,7 @@ export function rotateToken(): boolean { { tokenIndex: currentIndex, totalTokens: tokens.length }, `Rotated to token #${currentIndex + 1}/${tokens.length}`, ); + saveState(); return true; } } @@ -103,6 +139,7 @@ export function markTokenHealthy(): void { const state = tokens[currentIndex]; if (state?.rateLimitedUntil) { state.rateLimitedUntil = null; + saveState(); } } diff --git a/src/unified-dashboard.ts b/src/unified-dashboard.ts index 3d83766..94f40b5 100644 --- a/src/unified-dashboard.ts +++ b/src/unified-dashboard.ts @@ -10,6 +10,7 @@ import { type ClaudeUsageData, type ClaudeAccountUsage, } from './claude-usage.js'; +import { getAllCodexAccounts } from './codex-token-rotation.js'; import { composeDashboardContent, formatElapsed, @@ -572,7 +573,7 @@ async function buildUsageContent(): Promise { const d7 = u.seven_day; const label = claudeAccounts.length > 1 - ? `Claude${account.index + 1}${account.isActive ? '⚡' : ''}${account.isRateLimited ? '🚫' : ''}` + ? `Claude${account.index + 1}${account.isActive ? '*' : ''}${account.isRateLimited ? '!' : ''}` : claudeUsageIsCached ? 'Claude*' : 'Claude'; @@ -593,7 +594,39 @@ async function buildUsageContent(): Promise { }); } - if (codexUsage && Array.isArray(codexUsage)) { + const codexAccounts = getAllCodexAccounts(); + if (codexAccounts.length > 1) { + // Multi-account: show each account with plan + status + for (const acct of codexAccounts) { + const icon = acct.isActive ? '*' : acct.isRateLimited ? '!' : ' '; + const label = `Codex${acct.index + 1}${icon}`; + if (acct.isActive && codexUsage && Array.isArray(codexUsage)) { + const relevant = codexUsage.filter( + (limit) => + limit.primary.usedPercent > 0 || limit.secondary.usedPercent > 0, + ); + const display = relevant.length > 0 ? relevant : codexUsage.slice(0, 1); + for (const limit of display) { + rows.push({ + name: `${label} ${acct.planType}`, + h5pct: Math.round(limit.primary.usedPercent), + h5reset: formatResetKST(limit.primary.resetsAt), + d7pct: Math.round(limit.secondary.usedPercent), + d7reset: formatResetKST(limit.secondary.resetsAt), + }); + } + } else { + rows.push({ + name: `${label} ${acct.planType}`, + h5pct: -1, + h5reset: '', + d7pct: -1, + d7reset: '', + }); + } + } + } else if (codexUsage && Array.isArray(codexUsage)) { + // Single account: existing behavior const relevant = codexUsage.filter( (limit) => limit.primary.usedPercent > 0 || limit.secondary.usedPercent > 0, @@ -611,8 +644,13 @@ async function buildUsageContent(): Promise { } if (rows.length > 0) { + // Emoji characters take 2 columns in monospace — count visual width + const visualWidth = (s: string) => + [...s].reduce((w, c) => w + (c.codePointAt(0)! > 0x7f ? 2 : 1), 0); + const maxNameWidth = Math.max(8, ...rows.map((r) => visualWidth(r.name))) + 1; + const padName = (s: string) => s + ' '.repeat(maxNameWidth - visualWidth(s)); lines.push('```'); - lines.push(' 5-Hour 7-Day'); + lines.push(`${' '.repeat(maxNameWidth)}5-Hour 7-Day`); for (const row of rows) { const h5 = row.h5pct >= 0 @@ -622,7 +660,7 @@ async function buildUsageContent(): Promise { row.d7pct >= 0 ? `${bar(row.d7pct)} ${String(row.d7pct).padStart(3)}%` : ' — '; - lines.push(`${row.name.padEnd(8)}${h5} ${d7}`); + lines.push(`${padName(row.name)}${h5} ${d7}`); } lines.push('```'); } else { @@ -732,7 +770,10 @@ export async function startUnifiedDashboard( const content = buildUnifiedDashboardContent(); if (!content) { logger.warn( - { cachedUsageLength: cachedUsageContent.length, statusShowRooms: STATUS_SHOW_ROOMS }, + { + cachedUsageLength: cachedUsageContent.length, + statusShowRooms: STATUS_SHOW_ROOMS, + }, 'Dashboard content empty, skipping render', ); statusMessageId = null;