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
This commit is contained in:
Eyejoker
2026-03-23 23:16:54 +09:00
parent 30be180f4b
commit 7401626356
3 changed files with 154 additions and 14 deletions

View File

@@ -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),
}));

View File

@@ -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();
}
}

View File

@@ -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<string> {
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<string> {
});
}
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<string> {
}
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<string> {
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;