diff --git a/CHANGELOG.md b/CHANGELOG.md index 89cce18..68a66b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Botão "atualizar uso agora" (⟳)** no header do overlay. Força a recoleta na + hora (fura o cache de conveniência de 5 min). É **seguro**: respeita o cooldown + do 429 — durante a janela de rate limit o botão fica apagado com tooltip + "aguarde Xmin" e **não** bate na API (evita re-escalar a penalidade). +- **Tile de uso "Extra" (overage)** para contas com crédito extra medido em + dinheiro (`extra_usage` da API — Team/Enterprise/Pro com limite mensal). Mostra + o % e o valor gasto/limite (ex.: `$50.4/$50.0`), respeitando a moeda e as casas + decimais (`decimal_places`) do payload. + ### Changed +- **Plano do Claude resolvido pelas credenciais** (`~/.claude/.credentials.json`: + `rateLimitTier`/`subscriptionType`) antes do `.claude.json`. As credenciais + trazem o tier REAL (ex.: `default_claude_max_5x`) enquanto o `.claude.json` pode + ter um tier interno opaco (`default_raven`) — assim o rótulo fica preciso + ("Claude Max 5×") em vez do genérico. + ### Fixed - **Overlay caía para trás ao clicar fora.** No Mutter/XWayland o estado `_NET_WM_STATE_ABOVE` oscilava ao perder o foco: clicar em outra janela ou no @@ -16,6 +31,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (únicos pontos que reafirmavam o `alwaysOnTop`). Agora `win.on('blur')` reafirma `setAlwaysOnTop(true, 'screen-saver')` + `moveTop()` — mesmo padrão do toggle/reveal. Complementa o raise explícito da v0.6.7. +- **Consumo do Claude sumia do overlay.** Duas causas combinadas: (1) contas que + não são Claude Max (ex.: **Team**, tier interno `default_raven`) não eram + reconhecidas — o rótulo do plano virava `null` e o tile desaparecia; agora + `parseClaudeConfig` reconhece `claude_team`/`claude_pro`/`claude_enterprise` e + cai num rótulo genérico "Claude" para qualquer conta presente mas não mapeada. + (2) Ao levar **HTTP 429** (rate limit) da API de uso, o loop de 60 s rebatia na + mesma janela e **renovava a penalidade** indefinidamente — o `%` nunca voltava. + Agora um 429 agenda um **cooldown** que respeita o header `Retry-After` (ou 15 + min de fallback): durante ele o coletor não bate na API e mantém o último valor + conhecido (ou o plano-só), sem sumir nem piscar ⚠. Além disso, o Claude passou a + ter **cache próprio de 5 min** (a API é fortemente rate-limited e as janelas são + de 5h/7d — não faz sentido consultar a cada 60 s). O cooldown é **persistido em + disco** (`claude-cooldown.json`, só o timestamp — nunca o token), então rodar em + dev (`bun start`/restarts) não re-bate no boot nem **re-escala** o rate limit. + **Reset do plano no tile plano-só**: o `planLimitsEndDate` (ex.: reset semanal do + Claude Team) agora aparece mesmo sem a API OAuth — antes o tile mostrava o campo + de reset vazio. **Backoff exponencial**: o endpoint `/api/oauth/usage` é compartilhado com o + próprio Claude Code (`/status`) — limite agregado apertado. A cada 429 seguido, o + app alonga a espera (`Retry-After × 1.5^fails`, teto 1 h) em vez de rebater logo + que o `Retry-After` expira, dando espaço ao limite recuperar (bater de volta só + piorava a punição). ## [0.6.7] - 2026-07-09 diff --git a/main.js b/main.js index d27e672..68e29cb 100644 --- a/main.js +++ b/main.js @@ -57,6 +57,7 @@ const BOUNDS_FILE = path.join(BASE_DIR, 'window.json'); // {x, y, width} const ALIASES_FILE = path.join(BASE_DIR, 'aliases.json'); // {cwd: apelido} const SETTINGS_FILE = path.join(BASE_DIR, 'settings.json'); // {idleThresholdSec, escalateIdle, shortcut} const USAGE_FILE = path.join(BASE_DIR, 'usage.json'); // último uso conhecido (sobrevive a reinício; mostrado stale até refrescar) +const CLAUDE_COOLDOWN_FILE = path.join(BASE_DIR, 'claude-cooldown.json'); // {until:} — cooldown do 429 da API de uso (SÓ o timestamp, nunca o token) const SETTINGS_BOUNDS_FILE = path.join(BASE_DIR, 'settings-window.json'); // {x, y, width, height} const AUTOSTART_FILE = path.join(process.env.HOME, '.config/autostart/ai-traffic-lights.desktop'); @@ -981,6 +982,27 @@ function saveUsage() { } let lastUsage = loadUsage(); +// Cooldown do 429 da API de uso do Claude, PERSISTIDO em disco (com o contador +// de falhas p/ o backoff exponencial). Sem isto, rodar em dev (`bun start`/ +// restarts) perde o estado a cada reinício, re-bate no boot e RE-ESCALA o rate +// limit. Grava só {until, fails} — NUNCA o token. Nunca lança. +function loadClaudeCooldown() { + try { + const o = JSON.parse(fs.readFileSync(CLAUDE_COOLDOWN_FILE, 'utf8')); + const until = (o && typeof o.until === 'number' && o.until > Date.now()) ? o.until : 0; + const fails = (o && typeof o.fails === 'number' && o.fails > 0) ? o.fails : 0; + return { until, fails }; + } catch { return { until: 0, fails: 0 }; } +} +function saveClaudeCooldown({ until, fails } = {}) { + claudeCooldownUntil = until || 0; + claudeCooldownFails = fails || 0; + try { fs.writeFileSync(CLAUDE_COOLDOWN_FILE, JSON.stringify({ until: claudeCooldownUntil, fails: claudeCooldownFails })); } catch { /* ignore */ } +} +const _cd0 = loadClaudeCooldown(); +let claudeCooldownUntil = _cd0.until; +let claudeCooldownFails = _cd0.fails; + // Credenciais do GLM vivem no AMBIENTE DE CADA TERMINAL (o usuário tem terminais // Claude/Anthropic e terminais Claude/GLM — z.ai), possivelmente com CONTAS // z.ai DIFERENTES em terminais diferentes. Não estão em dotfile nem globais. @@ -1116,13 +1138,22 @@ async function collectAndSendUsage() { // credencial dele consulta a MESMA API de quota — mescla (dedup por token). glmCreds = mergeGlmCreds(glmCreds, opencodeGlmCreds()); const codexCwds = codexCwdsFromSessions(); - const entries = await usage.collectUsage({ glmCreds, codexCwds, home: app.getPath('home') }); + const entries = await usage.collectUsage({ + glmCreds, codexCwds, home: app.getPath('home'), + // cooldown do 429 persistido: não rebate na API enquanto vigente; o coletor + // chama de volta setCooldown quando leva um 429 novo (grava {until, fails}). + claudeCooldownUntil: claudeCooldownUntil, + claudeCooldownFails: claudeCooldownFails, + claudeSetCooldown: saveClaudeCooldown, + }); // Funde com o último estado: mantém o valor bom de cada linha se a coleta // atual falhou pra ela (evita zerar/sumir); esmaece pra cinza (stale) após // alguns min sem atualização em vez de piscar. Ver usage.mergeUsage. if (Array.isArray(entries)) { lastUsage = usage.mergeUsage(lastUsage, entries); saveUsage(); maybeNotifyReset(); } } catch { /* collectUsage já engole erros internamente; defeção dupla */ } sendToRenderer('usage', lastUsage); + // meta p/ a UI: o cooldown do 429 (se vigente) alimenta o tooltip do botão ⟳. + sendToRenderer('usage-meta', { claudeCooldownUntil: claudeCooldownUntil > Date.now() ? claudeCooldownUntil : 0, claudeCooldownFails: claudeCooldownUntil > Date.now() ? claudeCooldownFails : 0 }); } // Estado (por id) que detectReset usa entre coletas p/ achar a transição @@ -1149,6 +1180,24 @@ function maybeNotifyReset() { } ipcMain.on('request-usage', () => { sendToRenderer('usage', lastUsage); + sendToRenderer('usage-meta', { claudeCooldownUntil: claudeCooldownUntil > Date.now() ? claudeCooldownUntil : 0, claudeCooldownFails: claudeCooldownUntil > Date.now() ? claudeCooldownFails : 0 }); +}); + +// Force (botão ⟳): fura o cache de CONVENIÊNCIA (5min Claude / 30s GLM) e +// recoleta na hora. NÃO fura o cooldown do 429 — esse vive no disco e é injetado +// em collectUsage, então mesmo com o cache limpo o coletor não re-bate durante a +// janela de rate limit (evita re-escalar). É "atualizar já", não "ignorar limite". +ipcMain.on('force-usage', () => { + try { + // Durante cooldown ativo NÃO limpa o cache do Claude: ele guarda o último + // valor bom que readClaudeUsage usa como fallback. Limpá-lo faria o tile + // regredir p/ plano-só (perder o %) só porque o usuário clicou ⟳ no rate + // limit. Fora do cooldown, limpa normalmente p/ forçar recoleta real. + if (!(claudeCooldownUntil > Date.now())) usage._clearClaudeCache(); + usage._clearGlmCache(); + usage._clearCodexCache(); + } catch { /* ignore */ } + collectAndSendUsage(); }); // ---- update checker (versão + release mais nova do GitHub) ---- diff --git a/preload.js b/preload.js index e1bf060..c7d8baf 100644 --- a/preload.js +++ b/preload.js @@ -8,6 +8,8 @@ contextBridge.exposeInMainWorld('trafficLight', { // main a cada 60s + carga sob demanda. entries: [{agent,title,usedPct,resetAt,...}] onUsage: (cb) => ipcRenderer.on('usage', (_e, entries) => cb(entries)), requestUsage: () => ipcRenderer.send('request-usage'), + forceUsage: () => ipcRenderer.send('force-usage'), // fura o cache de conveniência e recoleta já (respeita o cooldown do 429) + onUsageMeta: (cb) => ipcRenderer.on('usage-meta', (_e, meta) => cb(meta)), // {claudeCooldownUntil} — p/ o tooltip do force setExpanded: (expanded, h) => ipcRenderer.send('set-expanded', { expanded, h }), autoHeight: (h) => ipcRenderer.send('auto-height', h), resizeStart: () => ipcRenderer.send('resize-start'), diff --git a/src/i18n.js b/src/i18n.js index 499de00..8b7e169 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -19,6 +19,8 @@ const STRINGS = { snooze_off: 'Restore alerts', tooltip_settings: 'preferences', tooltip_toggle_footer: 'toggle usage / launcher', + tooltip_force_usage: 'refresh usage now', + tooltip_force_cooldown: 'rate-limited — wait {min} min', tooltip_toggle_list: 'normal / compact list', update_available: 'Update available: v{v} ({method}) — click to open the release', update_download: 'Download v{v} ({method}) — click to install', @@ -130,6 +132,8 @@ const STRINGS = { snooze_off: 'Restaurar alertas', tooltip_settings: 'preferências', tooltip_toggle_footer: 'alternar uso / launcher', + tooltip_force_usage: 'atualizar uso agora', + tooltip_force_cooldown: 'rate-limited — aguarde {min} min', tooltip_toggle_list: 'lista normal / compacta', update_available: 'Atualização disponível: v{v} ({method}) — clique para abrir a release', update_download: 'Baixar v{v} ({method}) — clique para instalar', diff --git a/src/index.html b/src/index.html index af50169..417032c 100644 --- a/src/index.html +++ b/src/index.html @@ -18,6 +18,9 @@ + diff --git a/src/renderer.js b/src/renderer.js index 4506b3d..5c567e6 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -36,6 +36,7 @@ const $usage = document.getElementById('usage'); const $ver = document.getElementById('verBtn'); const $toggleList = document.getElementById('toggleListBtn'); const $toggleFooter = document.getElementById('toggleFooterBtn'); +const $forceUsage = document.getElementById('forceUsageBtn'); const $summaryLed = document.getElementById('summaryLed'); const $expand = document.getElementById('expandBtn'); const $quit = document.getElementById('quitBtn'); @@ -639,6 +640,31 @@ if ($toggleFooter) $toggleFooter.addEventListener('click', () => { persistUI({ showUsage: !footerShowsUsage() }); applyFooterMode(); }); +// Force (⟳): recoleta o uso na hora (fura o cache de conveniência; o cooldown do +// 429 continua respeitado no main). Gira o ícone ~600ms como feedback — o push +// de 'usage' chega logo depois e re-renderiza os tiles. +let claudeCooldownUntil = 0; +function applyUsageMeta(meta) { + claudeCooldownUntil = (meta && typeof meta.claudeCooldownUntil === 'number') ? meta.claudeCooldownUntil : 0; + if (!$forceUsage) return; + if (claudeCooldownUntil > Date.now()) { + const min = Math.ceil((claudeCooldownUntil - Date.now()) / 60000); + $forceUsage.setAttribute('data-tip', T('tooltip_force_cooldown', { min })); + $forceUsage.classList.add('is-cooldown'); + } else { + $forceUsage.setAttribute('data-tip', T('tooltip_force_usage')); + $forceUsage.classList.remove('is-cooldown'); + } +} +if ($forceUsage) $forceUsage.addEventListener('click', () => { + // Bloqueado durante o cooldown do 429: recoletar não traria % novo (o coletor + // respeita o cooldown) e o spinner daria falsa impressão de atualização. O + // tooltip já explica "aguarde Xmin"; aqui só não dispara nada. + if (claudeCooldownUntil > Date.now()) return; + window.trafficLight.forceUsage(); + $forceUsage.classList.add('is-spinning'); + setTimeout(() => $forceUsage.classList.remove('is-spinning'), 600); +}); // Botão de versão: ramifica por estado. available → baixar (AppImage) ou abrir // release (demais); ready → reiniciar e instalar; idle/error → "verificar agora". @@ -673,6 +699,7 @@ window.trafficLight.getLang().then((l) => { T = makeT(l || 'en'); applyStaticI18 window.trafficLight.onSessions((s) => { sessions = s || []; render(); }); window.trafficLight.requestSessions(); window.trafficLight.onUsage((u) => { usageEntries = Array.isArray(u) ? u : []; applyFooterMode(); }); +window.trafficLight.onUsageMeta((m) => applyUsageMeta(m)); window.trafficLight.requestUsage(); window.trafficLight.getVersion().then((v) => { appVersion = v || ''; renderVersion(); }); window.trafficLight.getUpdate().then((i) => { updateInfo = i || null; renderVersion(); }); diff --git a/src/styles.css b/src/styles.css index 7bd8a2b..74ba45a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -105,6 +105,11 @@ html, body { } .btn:hover { background: rgba(255,255,255,0.10); color: var(--ink); } .btn-close:hover { background: rgba(224,86,63,0.22); color: var(--red); } +/* Force (⟳): gira o ícone enquanto recoleta (feedback de "atualizando"). */ +.btn.is-spinning svg { animation: spin 0.6s linear; transform-origin: center; } +@keyframes spin { to { transform: rotate(360deg); } } +/* Force em cooldown do 429: meio apagado sinaliza que está rate-limited. */ +.btn.is-cooldown { opacity: 0.45; cursor: not-allowed; } /* Ícones SVG (substituem glyphs de texto/emoji — alinhamento perfeito pois o desenho é centrado no viewBox, sem depender de métrica de fonte). */ .btn svg { width: 16px; height: 16px; display: block; } diff --git a/src/usage.js b/src/usage.js index 58c411c..2ecd832 100644 --- a/src/usage.js +++ b/src/usage.js @@ -33,6 +33,16 @@ const CLAUDE_TIER_LABEL = { default_claude_max_20x: 'Max 20×', }; +// ---- tradução de organizationType → label humano (fallback quando o tier não é +// um dos Max conhecidos). Cobre contas Team/Pro/Enterprise cujo tier tem código +// interno opaco (ex.: default_raven) que não mapeamos individualmente. ---- +const CLAUDE_ORG_LABEL = { + claude_max: 'Claude Max', + claude_team: 'Claude Team', + claude_pro: 'Claude Pro', + claude_enterprise: 'Claude Enterprise', +}; + // =========================== LÓGICA PURA (parse) =========================== // Extrai reset/plano/passes de um objeto .claude.json já parseado. @@ -48,11 +58,18 @@ function parseClaudeConfig(cfg, now) { if (saffron.planLimitsEndDate) out.resetAt = saffron.planLimitsEndDate; // plano: oauthAccount.organizationType / organizationRateLimitTier + // Ordem: tier Max conhecido (mais específico) → tipo de org conhecido → org + // presente mas desconhecida (rótulo genérico "Claude"). Só fica null quando + // NÃO há conta OAuth alguma — aí o coletor omite o Claude do overlay. const acc = cfg.oauthAccount || {}; if (acc.organizationRateLimitTier && CLAUDE_TIER_LABEL[acc.organizationRateLimitTier]) { out.plan = 'Claude ' + CLAUDE_TIER_LABEL[acc.organizationRateLimitTier]; - } else if (acc.organizationType === 'claude_max') { - out.plan = 'Claude Max'; + } else if (acc.organizationType && CLAUDE_ORG_LABEL[acc.organizationType]) { + out.plan = CLAUDE_ORG_LABEL[acc.organizationType]; + } else if (acc.organizationType || acc.organizationUuid || acc.emailAddress) { + // conta existe (algum campo de identidade), mas tipo/tier não mapeados → + // não some do overlay; mostra o rótulo genérico. + out.plan = 'Claude'; } // passes restantes (free passes do plano) @@ -150,6 +167,26 @@ function parseAnthropicUsage(payload, now) { ? Math.max(0, Math.round((Date.parse(resetAt) - nowMs) / 60000)) : null; out.push({ title: w.title, usedPct: clampPct(win.utilization), resetAt, resetInMin }); } + // extra_usage: uso "extra"/overage medido em dinheiro (Team/Enterprise com + // limite mensal de crédito, ou Pro com overage). used_credits/monthly_limit + // vêm em UNIDADES MENORES da moeda (decimal_places: USD=2 → centavos). Vira um + // tile "Extra" com % e o valor gasto/limite (ex.: "$50.4/$50"). Só entra quando + // habilitado e com limite positivo — senão fica fora (não polui a barra). + const ex = payload.extra_usage; + if (ex && typeof ex === 'object' && ex.is_enabled && typeof ex.monthly_limit === 'number' && ex.monthly_limit > 0) { + const dec = (typeof ex.decimal_places === 'number' && ex.decimal_places >= 0 && ex.decimal_places <= 4) ? ex.decimal_places : 2; + const div = Math.pow(10, dec); + const spent = typeof ex.used_credits === 'number' ? ex.used_credits / div : null; + const limit = ex.monthly_limit / div; + const pct = typeof ex.utilization === 'number' ? clampPct(ex.utilization) + : (spent != null ? clampPct((spent / limit) * 100) : null); + const resetAt = typeof ex.resets_at === 'string' ? ex.resets_at : null; + const resetInMin = resetAt && !Number.isNaN(Date.parse(resetAt)) + ? Math.max(0, Math.round((Date.parse(resetAt) - nowMs) / 60000)) : null; + const sym = ex.currency === 'USD' || !ex.currency ? '$' : (ex.currency + ' '); + const extra = spent != null ? `${sym}${spent.toFixed(dec === 0 ? 0 : 1)}/${sym}${limit.toFixed(dec === 0 ? 0 : 1)}` : null; + out.push({ title: 'Extra', usedPct: pct, resetAt, resetInMin, extra }); + } return out; } @@ -189,14 +226,18 @@ function windowTitle(min) { // =========================== I/O =========================== -// Resolve o label do plano Claude (tier) a partir do .claude.json. Barato, -// síncrono. Devolve 'Claude Max 5×' / 'Claude Max' / 'Claude'. -function claudePlanLabel({ home } = {}) { +// Lê o .claude.json e devolve o objeto COMPLETO do parseClaudeConfig — plano e +// passes (o tile plano-só usa isso quando a API OAuth não responde). Barato, +// síncrono. Devolve null quando NÃO há conta OAuth (sem .claude.json ou sem +// campos de identidade) — distingue "sem Claude" de "Claude sem plano mapeado". +// Obs.: parsed.resetAt aqui é o planLimitsEndDate (fim do ciclo do PLANO), NÃO o +// reset da janela de uso — por isso o caller o ignora no tile plano-só. +function readClaudeConfig({ home, now } = {}) { try { const cfg = JSON.parse(fs.readFileSync(path.join(home || os.homedir(), '.claude.json'), 'utf8')); - const p = parseClaudeConfig(cfg, 0); - return p.plan || 'Claude'; - } catch { return 'Claude'; } + const parsed = parseClaudeConfig(cfg, now || Date.now()); + return parsed.plan ? parsed : null; // sem conta (plan null) → null + } catch { return null; } } // Lê o OAuth access token do Claude Code de ~/.claude/.credentials.json @@ -204,29 +245,77 @@ function claudePlanLabel({ home } = {}) { // não gravamos nem renovamos — se estiver expirado, a API rejeita e caímos no // fallback plano-só (o Claude Code renova sozinho no uso normal). Nunca lança. function readClaudeOAuthToken({ home } = {}) { + return readClaudeCreds({ home }).accessToken; +} + +// Lê as credenciais OAuth: accessToken + subscriptionType + rateLimitTier. Estes +// dois últimos são a fonte MAIS confiável do plano (o .claude.json pode trazer um +// tier interno opaco como 'default_raven', enquanto as credenciais trazem o tier +// real 'default_claude_max_5x'). Nunca lança — campos ausentes viram null. +function readClaudeCreds({ home } = {}) { try { const creds = JSON.parse(fs.readFileSync(path.join(home || os.homedir(), '.claude/.credentials.json'), 'utf8')); - const t = creds && creds.claudeAiOauth && creds.claudeAiOauth.accessToken; - return typeof t === 'string' && t ? t : null; - } catch { return null; } + const o = (creds && creds.claudeAiOauth) || {}; + return { + accessToken: typeof o.accessToken === 'string' && o.accessToken ? o.accessToken : null, + subscriptionType: typeof o.subscriptionType === 'string' ? o.subscriptionType : null, + rateLimitTier: typeof o.rateLimitTier === 'string' ? o.rateLimitTier : null, + }; + } catch { return { accessToken: null, subscriptionType: null, rateLimitTier: null }; } +} + +// Resolve o rótulo do plano a partir das credenciais (fonte confiável). O tier +// Max conhecido vence; senão o subscriptionType (team/pro/enterprise) vira label. +// Devolve null se as credenciais não bastam (o caller cai no .claude.json). +function claudePlanFromCreds({ subscriptionType, rateLimitTier } = {}) { + if (rateLimitTier && CLAUDE_TIER_LABEL[rateLimitTier]) return 'Claude ' + CLAUDE_TIER_LABEL[rateLimitTier]; + const sub = (subscriptionType || '').toLowerCase(); + const SUB_LABEL = { max: 'Claude Max', team: 'Claude Team', pro: 'Claude Pro', enterprise: 'Claude Enterprise' }; + if (SUB_LABEL[sub]) return SUB_LABEL[sub]; + return null; } // Coletor do Claude. Tenta a API OAuth de uso (% E reset REAIS das janelas 5h e // 7 dias — o mesmo dado do painel/`/status`); se não houver token ou a chamada // falhar, cai no fallback: uma linha só com o plano (sem número, honesto). // Cache por token, 30s. Nunca lança. -const _claudeCacheByToken = new Map(); // token → { at, entries } -async function readClaudeUsage({ home, now, fetcher } = {}) { - const plan = claudePlanLabel({ home }); - const token = readClaudeOAuthToken({ home }); - const planOnly = plan !== 'Claude' - ? [{ id: 'claude-plan', agent: 'claude', plan, title: null, usedPct: null, resetAt: null, resetInMin: null, extra: null, source: 'claude.json', error: null }] +// +// 429 (rate limit): a API manda Retry-After (ex.: ~1000s). Rebater a cada 60s +// RENOVA a penalidade e o % nunca volta — foi o bug do "Claude sumiu". Ao levar +// 429 agendamos um cooldown (respeitando Retry-After) durante o qual NÃO batemos +// na API: devolvemos o último valor bom conhecido, ou o plano-só. Assim o tile +// não some nem pisca ⚠ e a janela de rate limit expira sozinha. +const _claudeCacheByToken = new Map(); // token → { at, entries, cooldownUntil } +async function readClaudeUsage({ home, now, fetcher, cooldownUntil, cooldownFails, setCooldown } = {}) { + const pc = readClaudeConfig({ home, now }); + const creds = readClaudeCreds({ home }); + // Plano: credenciais primeiro (tier/subscription REAIS — ex.: 'default_claude_max_5x'), + // depois o .claude.json (que pode ter só um tier interno opaco). Se nenhum + // resolve mas há conta, cai no genérico do .claude.json (ou null = sem conta). + const plan = claudePlanFromCreds(creds) || (pc ? pc.plan : null); + const token = creds.accessToken; + // Tile plano-só (sem a API OAuth): mostra só o plano + passes, SEM reset. O + // planLimitsEndDate do .claude.json é o fim do ciclo do PLANO (ex.: Jul 13), + // NÃO o reset da janela de uso (5h/7d, que reseta várias vezes até lá) — pô-lo + // aqui enganava ("3d" logo após a janela ter resetado). O reset REAL das + // janelas só existe no runtime da API (resets_at) → sem API, honestamente sem + // reset. `passes` (free passes do plano) é info local legítima, fica. + const planOnly = plan + ? [{ id: 'claude-plan', agent: 'claude', plan, title: null, usedPct: null, + resetAt: null, resetInMin: null, + extra: (pc.passes != null ? pc.passes + ' passes' : null), + source: 'claude.json', error: null }] : null; if (!token) return planOnly; const nowMs = now || Date.now(); const cached = _claudeCacheByToken.get(token); - if (cached && (nowMs - cached.at) < CACHE_MS) return cached.entries; + if (cached && (nowMs - cached.at) < CLAUDE_CACHE_MS) return cached.entries; + // Cooldown PERSISTIDO (injetado pelo main.js, sobrevive a restart): sem isto, + // `bun start`/dev perde o cooldown em memória a cada reinício, re-bate no boot + // e RE-ESCALA o 429 (o servidor sobe o Retry-After a cada toque). Não rebate. + const cd = Math.max(cached && cached.cooldownUntil || 0, cooldownUntil || 0); + if (cd && nowMs < cd) return (cached && cached.entries) || planOnly; const headers = { Authorization: 'Bearer ' + token, @@ -237,24 +326,45 @@ async function readClaudeUsage({ home, now, fetcher } = {}) { let payload; try { payload = await _httpsGetJson('https://api.anthropic.com/api/oauth/usage', headers, fetcher); - } catch { + } catch (e) { + // 429 → backoff exponencial: cada 429 seguido alonga a espera (Retry-After × + // 1.5^fails, teto 1h) p/ dar espaço ao limite agregado recuperar. Mantém o + // último valor bom (ou plano-só). Outras falhas (401/offline) → plano-só. + if (e && e.statusCode === 429) { + const baseMs = (typeof e.retryAfterMs === 'number' && e.retryAfterMs > 0) + ? e.retryAfterMs : CLAUDE_429_COOLDOWN_MS; + const fails = (cached && cached.fails) || (cooldownFails || 0); + const backoff = Math.min(CLAUDE_429_MAX_BACKOFF_MS, + Math.round(baseMs * Math.pow(CLAUDE_429_BACKOFF_FACTOR, fails))); + const until = nowMs + backoff; + const keep = (cached && cached.entries) ? cached.entries : planOnly; + _claudeCacheByToken.set(token, { at: nowMs, entries: keep, cooldownUntil: until, fails: fails + 1 }); + // Persiste {until, fails} (só timestamps/contador, nunca o token) p/ restart. + if (typeof setCooldown === 'function') { try { setCooldown({ until, fails: fails + 1 }); } catch { /* nunca quebra a coleta */ } } + return keep; + } return planOnly; // token expirado/offline → plano-só (não polui com ⚠) } const windows = parseAnthropicUsage(payload, nowMs); if (!windows.length) return planOnly; + // id estável por janela (5h/7d/extra) — o 'Extra' (overage) NÃO pode colidir + // com o 7d; um mapa explícito evita o ternário que jogava tudo em '7d'. + const idByTitle = { '5 h': 'claude-5h', '7 dias': 'claude-7d', 'Extra': 'claude-extra' }; const entries = windows.map((w) => ({ - id: 'claude-' + (w.title === '5 h' ? '5h' : '7d'), + id: idByTitle[w.title] || 'claude-' + String(w.title).replace(/\s+/g, ''), agent: 'claude', title: w.title, plan, usedPct: w.usedPct, resetAt: w.resetAt, resetInMin: w.resetInMin, - extra: null, + extra: w.extra || null, // 'Extra' traz "$50.4/$50"; janelas não têm source: 'anthropic.oauth', error: null, })); - _claudeCacheByToken.set(token, { at: nowMs, entries }); + _claudeCacheByToken.set(token, { at: nowMs, entries, fails: 0 }); + // Sucesso → zera o backoff persistido (libera p/ futuras coletas normais). + if (typeof setCooldown === 'function') { try { setCooldown({ until: 0, fails: 0 }); } catch { /* nunca quebra a coleta */ } } return entries; } function _clearClaudeCache() { _claudeCacheByToken.clear(); } @@ -498,6 +608,18 @@ function defaultListAntigravityDbs(base) { catch { return []; } } +// Converte o header Retry-After em ms. Aceita os dois formatos do HTTP: um +// número de SEGUNDOS ("1007") ou uma data HTTP ("Wed, 21 Oct 2026 07:28:00 GMT"). +// `now` em ms (injetável p/ teste). Devolve ms >= 0, ou null se ilegível. +function parseRetryAfter(header, now) { + if (header == null) return null; + const s = String(header).trim(); + if (/^\d+$/.test(s)) return parseInt(s, 10) * 1000; // delta-seconds + const when = Date.parse(s); // HTTP-date + if (Number.isNaN(when)) return null; + return Math.max(0, when - (now || Date.now())); +} + // Faz um GET HTTPS injetando um `fetcher` (testável). Em produção usa https.get. // Devolve o JSON parseado ou lança (quem chama captura). function _httpsGetJson(url, headers, fetcher, timeoutMs = 4000) { @@ -508,8 +630,15 @@ function _httpsGetJson(url, headers, fetcher, timeoutMs = 4000) { (res) => { let data = ''; res.on('data', (c) => { data += c; }); - res.on('end', () => res.statusCode === 200 - ? resolve(data) : reject(new Error(`HTTP ${res.statusCode}`))); + res.on('end', () => { + if (res.statusCode === 200) { resolve(data); return; } + // Anexa metadados ao erro p/ o caller decidir backoff (429 → cooldown). + const err = new Error(`HTTP ${res.statusCode}`); + err.statusCode = res.statusCode; + const ra = parseRetryAfter(res.headers && res.headers['retry-after']); + if (ra != null) err.retryAfterMs = ra; + reject(err); + }); }, ); req.on('error', reject); @@ -525,6 +654,18 @@ function _httpsGetJson(url, headers, fetcher, timeoutMs = 4000) { // não se sobrescrevem no cache. `label`/`suffix` distinguem contas na UI quando // há mais de uma (multi-conta); com 1 conta ficam vazios e o id fica canônico. const CACHE_MS = 30 * 1000; +// O Claude tem cache PRÓPRIO e mais longo: a API /api/oauth/usage é fortemente +// rate-limited (Retry-After ~1000s) e as janelas são de 5h/7d — o % mal muda em +// minutos. 5 min = no máx. 12 req/h, tira a pressão do endpoint. (GLM segue 30s.) +const CLAUDE_CACHE_MS = 5 * 60 * 1000; // 5 min +// Cooldown padrão quando o 429 não traz Retry-After legível (fallback conservador). +const CLAUDE_429_COOLDOWN_MS = 15 * 60 * 1000; // 15 min +// Backoff exponencial: a cada 429 SEGUIDO, o app espera cada vez mais antes de +// tentar (Retry-After × 1.5^fails), até o teto. Evita o ciclo "cooldown expira → +// rebater → 429 de novo → re-armar" que mantinha o limite agregado estourado (o +// mesmo endpoint é consultado pelo próprio Claude Code no /status). Teto de 1h. +const CLAUDE_429_BACKOFF_FACTOR = 1.5; +const CLAUDE_429_MAX_BACKOFF_MS = 60 * 60 * 1000; const _glmCacheByToken = new Map(); // token → { at, entries } async function readGlmUsage({ env, now, fetcher, label, suffix } = {}) { const E = env || process.env; @@ -609,7 +750,11 @@ async function collectUsage(opts = {}) { // Claude usa opts.claudeFetcher (separado do de GLM: cada API tem schema/mock // próprio; em teste sem claudeFetcher e sem token, o Claude cai no plano-só). const [claude, antigravity, ...glm] = await Promise.all([ - readClaudeUsage({ home: opts.home, now: opts.now, fetcher: opts.claudeFetcher }).catch(() => null), + readClaudeUsage({ + home: opts.home, now: opts.now, fetcher: opts.claudeFetcher, + cooldownUntil: opts.claudeCooldownUntil, cooldownFails: opts.claudeCooldownFails, + setCooldown: opts.claudeSetCooldown, + }).catch(() => null), Promise.resolve().then(() => readAntigravityUsage({ home: opts.home })).catch(() => null), ...creds.map((c) => readGlmUsage({ env: c.env, now: opts.now, fetcher: opts.fetcher, @@ -734,7 +879,14 @@ function mergeUsage(prev, fresh, now) { // como resquício legado no usage.json. Sem isto as duas versões têm chaves // diferentes e não colapsam → o GLM aparece duplicado ("z.ai 2×"). const planNorm = String(e.plan || '').replace(/\s*\((z\.ai|bigmodel\.cn)\)\s*$/, '').trim(); - const key = [e.agent, e.title || '', planNorm, e.resetAt || ''].join('|'); + // Normaliza o resetAt p/ SEGUNDOS na chave: a mesma conta chegando por 2 + // credenciais (tokens distintos) recebe resetAt da API com diferença de ~1ms + // ("...09.995Z" vs "...09.996Z") — sem isto, a chave difere e o tile mensal + // aparece duplicado ("z.ai Pro mês 2×"). Trunca sub-segundo; contas realmente + // distintas têm resets separados por muito mais que 1s. + const resetMs = e.resetAt ? Date.parse(e.resetAt) : NaN; + const resetKey = Number.isNaN(resetMs) ? '' : Math.floor(resetMs / 1000); + const key = [e.agent, e.title || '', planNorm, resetKey].join('|'); const prev = byContent.get(key); if (!prev) { byContent.set(key, e); continue; } // escolhe a melhor: valor bom > stale menor > fetchedAt maior. @@ -817,7 +969,9 @@ function detectReset(prevState, entries, now, threshold) { if (typeof module !== 'undefined') module.exports = { parseClaudeConfig, parseAnthropicUsage, parseGlmQuota, parseCodexRateLimits, parseAntigravityTier, parseAntigravityQuota, readClaudeUsage, readGlmUsage, readCodexUsage, readAntigravityUsage, collectUsage, parseEnviron, - findCodexRollout, lastCodexRateLimits, mergeUsage, isSummaryEntry, detectReset, - USAGE_STALE_MS, USAGE_DROP_MS, - _clearGlmCache, _clearClaudeCache, _clearCodexCache, _httpsGetJson, CLAUDE_TIER_LABEL, + findCodexRollout, lastCodexRateLimits, mergeUsage, isSummaryEntry, detectReset, parseRetryAfter, + USAGE_STALE_MS, USAGE_DROP_MS, CLAUDE_429_COOLDOWN_MS, CLAUDE_CACHE_MS, + CLAUDE_429_BACKOFF_FACTOR, CLAUDE_429_MAX_BACKOFF_MS, + _clearGlmCache, _clearClaudeCache, _clearCodexCache, _httpsGetJson, CLAUDE_TIER_LABEL, CLAUDE_ORG_LABEL, + readClaudeCreds, claudePlanFromCreds, }; diff --git a/test/rename.test.js b/test/rename.test.js index c557ac1..9be66a3 100644 --- a/test/rename.test.js +++ b/test/rename.test.js @@ -42,7 +42,7 @@ async function setup() { trafficLight: { onSessions: (cb) => { sessionsCb = cb; }, requestSessions() {}, setExpanded() {}, autoHeight() {}, - onUsage() {}, requestUsage() {}, + onUsage() {}, requestUsage() {}, onUsageMeta() {}, forceUsage() {}, resizeStart() {}, resizeMove() {}, focus() {}, getAliases: () => Promise.resolve({}), setAlias: (cwd, v) => calls.setAlias.push([cwd, v]), notify() {}, toggleVisibility() {}, setTrayLevel() {}, diff --git a/test/usage.test.js b/test/usage.test.js index 6ac6144..932a926 100644 --- a/test/usage.test.js +++ b/test/usage.test.js @@ -9,7 +9,8 @@ const { parseClaudeConfig, parseAnthropicUsage, parseGlmQuota, parseCodexRateLimits, parseAntigravityTier, parseAntigravityQuota, lastCodexRateLimits, readClaudeUsage, readGlmUsage, readCodexUsage, readAntigravityUsage, - collectUsage, parseEnviron, mergeUsage, detectReset, _clearGlmCache, _clearClaudeCache, _clearCodexCache, + collectUsage, parseEnviron, mergeUsage, detectReset, parseRetryAfter, + _clearGlmCache, _clearClaudeCache, _clearCodexCache, } = require('../src/usage'); // now fixo = 2026-07-07T12:00:00Z → testes determinísticos (mês é 0-indexed em JS: 6=Jul). @@ -68,6 +69,23 @@ test('parseClaudeConfig: só organizationType (sem tier) → "Claude Max"', () = assert.equal(parseClaudeConfig(cfg, NOW).plan, 'Claude Max'); }); +test('parseClaudeConfig: organizationType claude_team → "Claude Team"', () => { + const cfg = { oauthAccount: { organizationType: 'claude_team' } }; + assert.equal(parseClaudeConfig(cfg, NOW).plan, 'Claude Team'); +}); + +test('parseClaudeConfig: tier desconhecido (default_raven) mas org presente → "Claude" genérico (não some)', () => { + // Regressão do "créditos do Claude sumiram": conta virou team/tier opaco que + // não mapeamos → antes plan ficava null e o tile desaparecia. Agora: rótulo + // genérico enquanto houver QUALQUER campo de identidade da conta. + const cfg = { oauthAccount: { organizationType: 'unknown_kind', organizationRateLimitTier: 'default_raven', emailAddress: 'x@y.com' } }; + assert.equal(parseClaudeConfig(cfg, NOW).plan, 'Claude'); +}); + +test('parseClaudeConfig: sem oauthAccount → plan null (sem conta, some do overlay)', () => { + assert.equal(parseClaudeConfig({ cachedGrowthBookFeatures: {} }, NOW).plan, null); +}); + test('parseClaudeConfig: payload vazio/malformado → tudo null', () => { const r = parseClaudeConfig({}, NOW); assert.deepEqual(r, { usedPct: null, resetAt: null, resetInMin: null, plan: null, passes: null }); @@ -157,6 +175,33 @@ test('parseAnthropicUsage: extrai janelas 5h e 7d com utilization + resets_at', assert.equal(out[1].usedPct, 78); }); +test('parseAnthropicUsage: extra_usage (overage) vira tile "Extra" com % e valor ($ minor units)', () => { + // payload REAL da conta Team (capturado 2026-07-10): used_credits/monthly_limit + // em centavos (decimal_places:2). 5042/5000 = $50.42/$50.00, 100%. + const payload = { + five_hour: { utilization: 7, resets_at: '2026-07-07T17:00:00Z' }, + extra_usage: { is_enabled: true, monthly_limit: 5000, used_credits: 5042, utilization: 100, currency: 'USD', decimal_places: 2 }, + }; + const out = parseAnthropicUsage(payload, NOW); + assert.equal(out.length, 2); + const extra = out.find((w) => w.title === 'Extra'); + assert.ok(extra, 'tile Extra presente'); + assert.equal(extra.usedPct, 100); + assert.equal(extra.extra, '$50.4/$50.0'); +}); + +test('parseAnthropicUsage: extra_usage desabilitado ou sem limite → não vira tile', () => { + assert.equal(parseAnthropicUsage({ extra_usage: { is_enabled: false, monthly_limit: 5000, used_credits: 10 } }, NOW).length, 0); + assert.equal(parseAnthropicUsage({ extra_usage: { is_enabled: true, monthly_limit: 0 } }, NOW).length, 0); +}); + +test('parseAnthropicUsage: extra_usage sem utilization → % derivado de used/limit', () => { + const out = parseAnthropicUsage({ extra_usage: { is_enabled: true, monthly_limit: 5000, used_credits: 2500, currency: 'USD', decimal_places: 2 } }, NOW); + assert.equal(out[0].title, 'Extra'); + assert.equal(out[0].usedPct, 50); // 2500/5000 + assert.equal(out[0].extra, '$25.0/$50.0'); +}); + test('parseAnthropicUsage: janela ausente/sem utilization é pulada; clampa >100', () => { const out = parseAnthropicUsage({ five_hour: { utilization: 150 }, seven_day: null }, NOW); assert.equal(out.length, 1); @@ -166,6 +211,26 @@ test('parseAnthropicUsage: janela ausente/sem utilization é pulada; clampa >100 assert.deepEqual(parseAnthropicUsage({}, NOW), []); }); +// =========================== parseRetryAfter (backoff do 429) =========================== + +test('parseRetryAfter: delta-seconds ("1007") → ms', () => { + assert.equal(parseRetryAfter('1007', NOW), 1007 * 1000); + assert.equal(parseRetryAfter('0', NOW), 0); +}); + +test('parseRetryAfter: HTTP-date → ms até a data (nunca negativo)', () => { + const future = new Date(NOW + 5 * 60_000).toUTCString(); + assert.equal(parseRetryAfter(future, NOW), 5 * 60_000); + const past = new Date(NOW - 60_000).toUTCString(); + assert.equal(parseRetryAfter(past, NOW), 0); // passado → 0, não negativo +}); + +test('parseRetryAfter: ausente/ilegível → null', () => { + assert.equal(parseRetryAfter(null, NOW), null); + assert.equal(parseRetryAfter(undefined, NOW), null); + assert.equal(parseRetryAfter('lixo', NOW), null); +}); + // =========================== readClaudeUsage (I/O + OAuth) =========================== // Monta um home tmp com .claude.json (plano) e opcionalmente .credentials.json (OAuth). @@ -201,6 +266,31 @@ test('readClaudeUsage: OAuth ok → 2 linhas (5h + 7d) com % e reset reais', asy fs.rmSync(tmp, { recursive: true, force: true }); }); +test('claudePlanFromCreds: tier Max conhecido vence; senão subscriptionType', () => { + const { claudePlanFromCreds } = require('../src/usage'); + assert.equal(claudePlanFromCreds({ rateLimitTier: 'default_claude_max_5x' }), 'Claude Max 5×'); + assert.equal(claudePlanFromCreds({ subscriptionType: 'team', rateLimitTier: 'default_raven' }), 'Claude Team'); + assert.equal(claudePlanFromCreds({ subscriptionType: 'enterprise' }), 'Claude Enterprise'); + assert.equal(claudePlanFromCreds({}), null); // sem info → null (caller usa .claude.json) +}); + +test('readClaudeUsage: extra_usage vira tile id=claude-extra (não colide com 7d)', async () => { + _clearClaudeCache(); + const tmp = claudeHome({ token: 'tok' }); + const f = mockFetcher({ + five_hour: { utilization: 7, resets_at: '2026-07-07T17:00:00Z' }, + seven_day: { utilization: 24, resets_at: '2026-07-09T12:00:00Z' }, + extra_usage: { is_enabled: true, monthly_limit: 5000, used_credits: 5042, utilization: 100, currency: 'USD', decimal_places: 2 }, + }); + const r = await readClaudeUsage({ home: tmp, now: NOW, fetcher: f }); + const ids = r.map((e) => e.id).sort(); + assert.deepEqual(ids, ['claude-5h', 'claude-7d', 'claude-extra'], 'três ids distintos (Extra não colide)'); + const extra = r.find((e) => e.id === 'claude-extra'); + assert.equal(extra.usedPct, 100); + assert.equal(extra.extra, '$50.4/$50.0'); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + test('readClaudeUsage: sem token OAuth → fallback plano-só (1 linha, sem %)', async () => { _clearClaudeCache(); const tmp = claudeHome({ token: null }); @@ -213,6 +303,27 @@ test('readClaudeUsage: sem token OAuth → fallback plano-só (1 linha, sem %)', fs.rmSync(tmp, { recursive: true, force: true }); }); +test('readClaudeUsage: plano-só NÃO mostra planLimitsEndDate como reset (é fim do ciclo do plano, não da janela)', async () => { + // planLimitsEndDate (ex.: Jul 13) é o fim do ciclo do PLANO, não o reset da + // janela de uso 5h/7d (que reseta várias vezes até lá). Pô-lo no tile enganava + // ("3d" logo após a janela resetar). Sem a API OAuth → honestamente sem reset. + _clearClaudeCache(); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'atl-')); + fs.writeFileSync(path.join(tmp, '.claude.json'), JSON.stringify({ + oauthAccount: { organizationType: 'claude_team', organizationRateLimitTier: 'default_raven' }, + cachedGrowthBookFeatures: { tengu_saffron_lattice: { planLimitsEndDate: '2026-07-13T07:00:00Z' } }, + passesLastSeenRemaining: 2, + })); + const r = await readClaudeUsage({ home: tmp, now: NOW }); // sem token → plano-só + assert.equal(r[0].id, 'claude-plan'); + assert.equal(r[0].plan, 'Claude Team'); + assert.equal(r[0].resetAt, null, 'NÃO expõe planLimitsEndDate como reset da janela'); + assert.equal(r[0].resetInMin, null); + assert.equal(r[0].usedPct, null); // % só vem da API (honesto) + assert.equal(r[0].extra, '2 passes'); // passes é info local legítima + fs.rmSync(tmp, { recursive: true, force: true }); +}); + test('readClaudeUsage: OAuth falha (rede) → fallback plano-só, não lança', async () => { _clearClaudeCache(); const tmp = claudeHome({ token: 'tok' }); @@ -231,6 +342,171 @@ test('readClaudeUsage: sem plano nem token → null', async () => { fs.rmSync(tmp, { recursive: true, force: true }); }); +// fetcher que sempre lança 429 (com Retry-After em ms), contando chamadas. +function fetcher429(retryAfterMs) { + const calls = []; + const fn = async (url, headers) => { + calls.push({ url, headers }); + const e = new Error('HTTP 429'); + e.statusCode = 429; + if (retryAfterMs != null) e.retryAfterMs = retryAfterMs; + throw e; + }; + fn.calls = calls; + return fn; +} + +test('readClaudeUsage: 429 → cooldown NÃO rebate na API durante o Retry-After (bug do "Claude sumiu")', async () => { + _clearClaudeCache(); + const tmp = claudeHome({ token: 'tok' }); + const f = fetcher429(20 * 60_000); // Retry-After 20 min + // 1ª coleta leva 429 → cai no plano-só e agenda o cooldown. + const r1 = await readClaudeUsage({ home: tmp, now: NOW, fetcher: f }); + assert.equal(r1.length, 1); + assert.equal(r1[0].id, 'claude-plan'); // tile NÃO some + assert.equal(f.calls.length, 1); + // +60s: passou do cache (30s) MAS ainda no cooldown → não bate na API de novo. + const r2 = await readClaudeUsage({ home: tmp, now: NOW + 60_000, fetcher: f }); + assert.equal(r2[0].id, 'claude-plan'); + assert.equal(f.calls.length, 1, 'não deve rebater na API durante o cooldown do 429'); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test('readClaudeUsage: 429 sem Retry-After usa cooldown padrão (15 min)', async () => { + _clearClaudeCache(); + const tmp = claudeHome({ token: 'tok' }); + const f = fetcher429(null); // sem header legível + await readClaudeUsage({ home: tmp, now: NOW, fetcher: f }); + // +10min < 15min padrão → ainda em cooldown, sem nova chamada. + await readClaudeUsage({ home: tmp, now: NOW + 10 * 60_000, fetcher: f }); + assert.equal(f.calls.length, 1); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test('readClaudeUsage: 429 mantém o ÚLTIMO valor bom (não regride pro plano-só)', async () => { + _clearClaudeCache(); + const tmp = claudeHome({ token: 'tok' }); + // 1ª: OK → 2 janelas reais. 2ª: 429 → deve segurar as janelas boas, não sumir %. + let n = 0; + const f = async () => { + n += 1; + if (n === 1) return JSON.stringify({ five_hour: { utilization: 42, resets_at: '2026-07-07T17:00:00Z' }, seven_day: { utilization: 80, resets_at: '2026-07-09T12:00:00Z' } }); + const e = new Error('HTTP 429'); e.statusCode = 429; e.retryAfterMs = 20 * 60_000; throw e; + }; + const r1 = await readClaudeUsage({ home: tmp, now: NOW, fetcher: f }); + assert.equal(r1.length, 2); + assert.equal(r1[0].usedPct, 42); + // +60s: cache expirou → tenta, leva 429 → mantém as 2 janelas boas anteriores. + const r2 = await readClaudeUsage({ home: tmp, now: NOW + 60_000, fetcher: f }); + assert.equal(r2.length, 2, 'segura as janelas boas em vez de cair pro plano-só'); + assert.equal(r2[0].usedPct, 42); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test('readClaudeUsage: cache próprio de 5min (não rebate em +60s, ao contrário do GLM 30s)', async () => { + _clearClaudeCache(); + const tmp = claudeHome({ token: 'tok' }); + const f = mockFetcher({ five_hour: { utilization: 30, resets_at: '2026-07-07T17:00:00Z' } }); + await readClaudeUsage({ home: tmp, now: NOW, fetcher: f }); + await readClaudeUsage({ home: tmp, now: NOW + 60_000, fetcher: f }); // +60s < 5min + await readClaudeUsage({ home: tmp, now: NOW + 4 * 60_000, fetcher: f }); // +4min < 5min + assert.equal(f.calls.length, 1, 'cache de 5min evita rebater a cada tick de 60s'); + // +6min > 5min → cache expira, rebate. + await readClaudeUsage({ home: tmp, now: NOW + 6 * 60_000, fetcher: f }); + assert.equal(f.calls.length, 2); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test('readClaudeUsage: cooldownUntil injetado (persistido) bloqueia a chamada mesmo com cache vazio', async () => { + _clearClaudeCache(); // simula processo recém-iniciado (bun start) + const tmp = claudeHome({ token: 'tok' }); + const f = fetcher429(20 * 60_000); + // cooldown veio do disco (futuro) → NÃO deve bater na API no boot (anti-reescalada). + const r = await readClaudeUsage({ home: tmp, now: NOW, fetcher: f, cooldownUntil: NOW + 10 * 60_000 }); + assert.equal(f.calls.length, 0, 'cooldown persistido evita re-bater no boot e re-escalar o 429'); + assert.equal(r[0].id, 'claude-plan'); // cai no plano-só (cache vazio) + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test('readClaudeUsage: 429 chama setCooldown com {until, fails} p/ persistir', async () => { + _clearClaudeCache(); + const tmp = claudeHome({ token: 'tok' }); + const f = fetcher429(20 * 60_000); + let saved = null; + await readClaudeUsage({ home: tmp, now: NOW, fetcher: f, setCooldown: (o) => { saved = o; } }); + assert.deepEqual(saved, { until: NOW + 20 * 60_000, fails: 1 }, 'persistiu {until=Retry-After, fails=1}'); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test('readClaudeUsage: 429 seguidos aplicam backoff exponencial (Retry-After × 1.5^fails)', async () => { + _clearClaudeCache(); + const tmp = claudeHome({ token: 'tok' }); + const f = fetcher429(10 * 60_000); // Retry-After fixo 10min + let saved = null; + // 1ª falha: fails 0→1, backoff = 10min × 1.5^0 = 10min + await readClaudeUsage({ home: tmp, now: NOW, fetcher: f, setCooldown: (o) => { saved = o; } }); + assert.equal(saved.until, NOW + 10 * 60_000, '1ª falha: sem multiplicar ainda'); + assert.equal(saved.fails, 1); + // 2ª falha (fails=1 injetado do disco): backoff = 10min × 1.5^1 = 15min + await readClaudeUsage({ home: tmp, now: NOW + 11 * 60_000, fetcher: f, cooldownFails: 1, setCooldown: (o) => { saved = o; } }); + assert.equal(saved.until, NOW + 11 * 60_000 + 15 * 60_000, '2ª falha: ×1.5 = 15min'); + assert.equal(saved.fails, 2); + // 3ª falha (fails=2): backoff = 10min × 1.5^2 = 22.5min + await readClaudeUsage({ home: tmp, now: NOW + 30 * 60_000, fetcher: f, cooldownFails: 2, setCooldown: (o) => { saved = o; } }); + assert.equal(saved.until, NOW + 30 * 60_000 + Math.round(10 * 60_000 * 1.5 * 1.5), '3ª falha: ×2.25'); + assert.equal(saved.fails, 3); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test('readClaudeUsage: 429 respeita o teto de backoff (1h)', async () => { + _clearClaudeCache(); + const tmp = claudeHome({ token: 'tok' }); + const f = fetcher429(30 * 60_000); // Retry-After 30min + let saved = null; + // fails=10 injetado: 30min × 1.5^10 = imenso → clampado no teto de 1h + await readClaudeUsage({ home: tmp, now: NOW, fetcher: f, cooldownFails: 10, setCooldown: (o) => { saved = o; } }); + assert.equal(saved.until, NOW + 60 * 60_000, 'clampado no teto de 1h'); + assert.equal(saved.fails, 11); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test('readClaudeUsage: sucesso zera o contador de backoff (fails=0)', async () => { + _clearClaudeCache(); + const tmp = claudeHome({ token: 'tok' }); + let n = 0; + const f = async () => { + n += 1; + if (n === 1) { const e = new Error('HTTP 429'); e.statusCode = 429; e.retryAfterMs = 5 * 60_000; throw e; } + return JSON.stringify({ five_hour: { utilization: 9, resets_at: '2026-07-07T17:00:00Z' } }); + }; + let saved = null; + await readClaudeUsage({ home: tmp, now: NOW, fetcher: f, cooldownFails: 0, setCooldown: (o) => { saved = o; } }); + assert.equal(saved.fails, 1, 'falhou → armou fails=1'); + // depois de expirar, sucesso → zera + await readClaudeUsage({ home: tmp, now: NOW + 6 * 60_000, fetcher: f, cooldownFails: 1, setCooldown: (o) => { saved = o; } }); + assert.deepEqual(saved, { until: 0, fails: 0 }, 'sucesso zera o backoff persistido'); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test('readClaudeUsage: após o cooldown expirar, rebate na API e recupera o %', async () => { + _clearClaudeCache(); + const tmp = claudeHome({ token: 'tok' }); + let n = 0; + const f = async () => { + n += 1; + if (n === 1) { const e = new Error('HTTP 429'); e.statusCode = 429; e.retryAfterMs = 5 * 60_000; throw e; } + return JSON.stringify({ five_hour: { utilization: 12, resets_at: '2026-07-07T17:00:00Z' } }); + }; + const r1 = await readClaudeUsage({ home: tmp, now: NOW, fetcher: f }); + assert.equal(r1[0].id, 'claude-plan'); // 429 → plano-só + // +6min: cooldown (5min) já expirou → bate de novo e pega o % real. + const r2 = await readClaudeUsage({ home: tmp, now: NOW + 6 * 60_000, fetcher: f }); + assert.equal(r2[0].id, 'claude-5h'); + assert.equal(r2[0].usedPct, 12); + assert.equal(n, 2, 'rebateu na API após o cooldown'); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + // =========================== readGlmUsage (I/O + fetcher mock) =========================== // fetcher mock: devolve o body dado; captura url/headers para asserção. @@ -672,6 +948,16 @@ test('mergeUsage: token inválido (summary sem %) some quando há concreto do me assert.equal(out[0].id, 'glm-tokens'); // o summary fantasma sumiu }); +test('mergeUsage: mesma conta por 2 credenciais com resetAt differing 1ms → colapsa (bug "mês 2×")', () => { + // Regressão real: a mesma conta z.ai Pro chega por 2 tokens (sufixos 6baca3 e + // c3c374); a API devolve resetAt com ~1ms de diferença ("...09.995Z" vs + // "...09.996Z"). A chave de dedup via resetAt exato não cola → "MCP (mês) 2×". + const a = { id: 'glm-month:6baca3', agent: 'glm', title: 'MCP (mês)', plan: 'GLM Pro (z.ai)', usedPct: 0, resetAt: '2026-08-10T11:47:09.995Z', error: null }; + const b = { id: 'glm-month:c3c374', agent: 'glm', title: 'MCP (mês)', plan: 'GLM Pro (z.ai)', usedPct: 0, resetAt: '2026-08-10T11:47:09.996Z', error: null }; + const out = mergeUsage([], [a, b], NOW); + assert.equal(out.length, 1, 'o mensal duplicado (1ms de diferença) colapsa em 1 linha'); +}); + test('mergeUsage: antigravity-plan limpa antigravity-quota do cache anterior', () => { const prev = [{ id: 'antigravity-quota', agent: 'antigravity', plan: 'Antigravity (m)', usedPct: 100, resetAt: '2026-07-14T19:56:12Z', fetchedAt: NOW }]; const fresh = [{ id: 'antigravity-plan', agent: 'antigravity', plan: 'Antigravity (m)', usedPct: null }];