import fs from 'node:fs'; import fsp from 'node:fs/promises'; import path from 'node:path'; import { execFile as execFileCb } from 'node:child_process'; import { promisify } from 'node:util'; const execFile = promisify(execFileCb); // Cache stores the in-flight/settled PROMISE, so concurrent requests share one // underlying ccusage run / file scan instead of piling up. const cache = new Map(); // key -> { ts, val: Promise } const TTL = 60_000; function cached(key, fn) { const c = cache.get(key); if (c && Date.now() - c.ts < TTL) return c.val; const val = Promise.resolve().then(fn).catch(() => null); cache.set(key, { ts: Date.now(), val }); return val; } // Normalize a reset timestamp to unix SECONDS. Providers disagree: some report // epoch seconds, some epoch millis, Claude's statusline payload uses an ISO // string. The frontend assumes seconds. function toEpochSec(v) { if (typeof v === 'number' && Number.isFinite(v)) return v > 1e12 ? Math.floor(v / 1000) : v; if (typeof v === 'string') { const ms = Date.parse(v); if (!Number.isNaN(ms)) return Math.floor(ms / 1000); } return undefined; } function dateOf(entry) { for (const k of ['date', 'period', 'day']) { if (typeof entry[k] === 'string' && /^\d{4}-\d{2}-\d{2}/.test(entry[k])) return entry[k].slice(0, 10); } for (const v of Object.values(entry)) { if (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v)) return v; } return null; } // Claude stores conversation transcripts under CLAUDE_CONFIG_DIR/projects, but // depending on version they can also live in ~/.claude or ~/.config/claude. Give // ccusage all candidates (it accepts a comma-separated CLAUDE_CONFIG_DIR and // ignores ones that don't exist) so token/cost aggregation isn't silently empty. function claudeEnv() { const home = process.env.HOME || ''; const dirs = [process.env.CLAUDE_CONFIG_DIR, path.join(home, '.claude'), path.join(home, '.config', 'claude')] .filter(Boolean) .filter((d, i, a) => a.indexOf(d) === i); return { ...process.env, CLAUDE_CONFIG_DIR: dirs.join(',') }; } // Tokens + (estimated) cost for a provider, via `ccusage daily --json`. function providerUsage(prov) { return cached(`u:${prov}`, async () => { const env = prov === 'claude' ? claudeEnv() : process.env; let out; try { ({ stdout: out } = await execFile('ccusage', [prov, 'daily', '--json'], { encoding: 'utf8', timeout: 20_000, maxBuffer: 16 * 1024 * 1024, env })); } catch { return null; } let data; try { data = JSON.parse(out); } catch { return null; } const arr = Array.isArray(data.daily) ? data.daily : []; const today = new Date().toISOString().slice(0, 10); const weekCut = new Date(Date.now() - 6 * 864e5).toISOString().slice(0, 10); let tT = 0, cT = 0, tW = 0, cW = 0; for (const e of arr) { const d = dateOf(e); if (!d) continue; const tok = e.totalTokens || 0; const cost = e.totalCost || 0; if (d === today) { tT += tok; cT += cost; } if (d >= weekCut) { tW += tok; cW += cost; } } return { tokensToday: tT, costToday: cT, tokensWeek: tW, costWeek: cW, totalCost: (data.totals && data.totals.totalCost) || 0, }; }); } function findRateLimits(obj) { let found = null; const walk = (o) => { if (!o || typeof o !== 'object') return; if (o.rate_limits && typeof o.rate_limits === 'object') found = o.rate_limits; for (const v of Object.values(o)) if (v && typeof v === 'object') walk(v); }; walk(obj); return found; } // Codex persists a rate-limit snapshot into its session rollout files. Scan // rollout files newest-first and return the latest snapshot we can find — NOT // just the newest file's, because a freshly-started session's rollout has no // rate_limits yet (it only appears after a request), which would otherwise make // the quota look "gone" right after a restart until you run something. async function codexRollouts() { const home = process.env.CODEX_HOME || path.join(process.env.HOME || '', '.codex'); const root = path.join(home, 'sessions'); const files = []; const walk = async (dir, depth) => { if (depth > 5) return; let ents = []; try { ents = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; } for (const e of ents) { const p = path.join(dir, e.name); if (e.isDirectory()) await walk(p, depth + 1); else if (e.name.startsWith('rollout-') && e.name.endsWith('.jsonl')) { try { files.push({ p, m: (await fsp.stat(p)).mtimeMs }); } catch {} } } }; await walk(root, 0); return files.sort((a, b) => b.m - a.m); } function codexQuota() { return cached('codexq', async () => { const win = (w) => (w ? { usedPercent: w.used_percent, windowMinutes: w.window_minutes, resetsAt: toEpochSec(w.resets_at) } : null); for (const f of await codexRollouts()) { let rl = null; let lines; try { lines = (await fsp.readFile(f.p, 'utf8')).split('\n'); } catch { continue; } for (const ln of lines) { if (!ln) continue; let j; try { j = JSON.parse(ln); } catch { continue; } const r = findRateLimits(j); if (r) rl = r; } if (rl) return { fiveHour: win(rl.primary), weekly: win(rl.secondary) }; } return null; }); } // Claude quota is written by our statusline hook (see claude-statusline.mjs): // it captures the rate_limits Claude reports, so it's as fresh as the last time // a Claude session made a model call on the Space. async function claudeQuota() { try { const cfg = process.env.CLAUDE_CONFIG_DIR; if (!cfg) return null; const p = path.join(cfg, 'usage.json'); const j = JSON.parse(await fsp.readFile(p, 'utf8')); const rl = j.rate_limits; if (!rl) return null; const w = (x) => (x ? { usedPercent: x.used_percentage ?? x.used_percent, resetsAt: toEpochSec(x.resets_at) } : null); return { fiveHour: w(rl.five_hour), weekly: w(rl.seven_day), opus: w(rl.seven_day_opus), updatedAt: j.ts || null }; } catch { return null; } } // Diagnostics: where might Claude transcripts live, and does ccusage see them? function countJsonl(dir, depth = 0) { if (depth > 5) return 0; let n = 0; let ents = []; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return 0; } for (const e of ents) { const p = path.join(dir, e.name); if (e.isDirectory()) n += countJsonl(p, depth + 1); else if (e.name.endsWith('.jsonl')) n++; } return n; } async function debugInfo() { const home = process.env.HOME || ''; let ccusage = null; try { ccusage = (await execFile('ccusage', ['--version'], { encoding: 'utf8', timeout: 10_000 })).stdout.trim(); } catch (e) { ccusage = `MISSING (${e.code || e.message})`; } const claudeDirs = [process.env.CLAUDE_CONFIG_DIR, path.join(home, '.claude'), path.join(home, '.config', 'claude')] .filter(Boolean) .map((d) => { const proj = path.join(d, 'projects'); return { dir: d, projectsExists: fs.existsSync(proj), jsonl: fs.existsSync(proj) ? countJsonl(proj) : 0 }; }); const raw = async (prov) => { const u = await providerUsage(prov); return u ? { tokensWeek: u.tokensWeek, costWeek: u.costWeek } : null; }; return { env: { HOME: home, CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR || null, CODEX_HOME: process.env.CODEX_HOME || null }, ccusage, claudeDirs, codexRolloutFiles: (await codexRollouts()).length, raw: { claude: await raw('claude'), codex: await raw('codex'), gemini: await raw('gemini') }, }; } export async function buildUsage(debug = false) { const [uClaude, uCodex, uGemini, qClaude, qCodex] = await Promise.all([ providerUsage('claude'), providerUsage('codex'), providerUsage('gemini'), claudeQuota(), codexQuota(), ]); const out = { providers: { claude: { ...(uClaude || {}), quota: qClaude }, codex: { ...(uCodex || {}), quota: qCodex }, gemini: { ...(uGemini || {}), quota: null }, }, generatedAt: new Date().toISOString(), }; if (debug) out._debug = await debugInfo(); return out; }