From 498c8a33c2e090a22bfcb48c8f211b9fa4074d92 Mon Sep 17 00:00:00 2001 From: Aron Peyroteo Date: Fri, 10 Jul 2026 17:23:35 -0300 Subject: [PATCH] =?UTF-8?q?fix(usage):=20Claude=20lazy=20=E2=80=94=20s?= =?UTF-8?q?=C3=B3=20bate=20na=20API=20ao=20olhar=20o=20uso=20(evita=20429?= =?UTF-8?q?=20agregado)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A /api/oauth/usage divide um limite AGREGADO com o /status do próprio Claude Code. O loop de fundo de 60s consultando esse endpoint alimentava o 429 e a barra de % do Claude sumia ("parou de contar créditos"). Agora a coleta do Claude é sob demanda (lazy): o loop de fundo não bate mais na API dele — a chamada só acontece nos gatilhos de UI (abrir/expandir o overlay, revelar a janela pelo tray/atalho, botão ⟳) e no boot. Entre gatilhos mostra o último % conhecido ou o plano-só. O cache de 5min evita repetir a chamada a cada abrir/fechar. GLM/Codex/Antigravity seguem no loop normal. - usage.js: readClaudeUsage ganha allowFetch (default true); guard devolve cache/plano-só sem tocar a rede quando false. collectUsage propaga via claudeAllowFetch. Ordem dos guards (cooldown antes de lazy) mantém o boot-fetch seguro durante um 429 já ativo. - main.js: collectAndSendUsage({claudeFetch}); loop=false, boot/expand/reveal/ force=true. - +2 testes (lazy não bate / segura o último valor bom). Suíte 188 -> 190. --- CHANGELOG.md | 9 +++++++++ main.js | 27 +++++++++++++++++++++------ src/usage.js | 14 +++++++++++++- test/usage.test.js | 28 ++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94948ab..bd4dc5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added ### Changed +- **Consumo do Claude agora é _lazy_ (coleta sob demanda).** O loop de fundo (60s) + deixou de bater na API `/api/oauth/usage` do Claude — a chamada só acontece quando + você **vai olhar o uso**: ao **abrir/expandir** o overlay, ao **revelar** a janela + (tray/atalho), no botão **⟳** e no **boot** (1 chamada). Essa API divide um limite + **agregado** com o `/status` do próprio Claude Code, então consultá-la a cada 60 s + alimentava o **429** e a barra de % do Claude sumia. Agora o app fica fora desse + balde na maior parte do tempo; entre gatilhos, mostra o último % conhecido (ou o + plano-só, sem inventar número). O cache de 5 min evita repetir a chamada a cada + abrir/fechar. GLM/Codex/Antigravity seguem no loop normal (não sofrem esse limite). ### Fixed ## [0.6.8] - 2026-07-10 diff --git a/main.js b/main.js index 68e29cb..20da976 100644 --- a/main.js +++ b/main.js @@ -596,7 +596,12 @@ function createWindow() { function toggleWin() { if (!win || win.isDestroyed()) return; if (win.isVisible()) win.hide(); - else { win.show(); try { win.setSkipTaskbar(true); } catch {} try { win.moveTop(); } catch {} } + else { + win.show(); try { win.setSkipTaskbar(true); } catch {} try { win.moveTop(); } catch {} + // Revelou o overlay (tray/atalho) → o usuário vai olhar; busca o % do Claude + // agora (lazy). Cache de 5 min evita repetir a cada mostra/esconde. + collectAndSendUsage({ claudeFetch: true }); + } } // Traz o overlay de volta à tela se ele estiver OCULTO (hide). Não rouba o foco @@ -791,6 +796,10 @@ ipcMain.on('set-expanded', (_e, { expanded, h } = {}) => { // que o autosize deixou no estado expandido (janela não reduzia ao recolher). win.setMinimumSize(MIN_W, height); win.setSize(w, height, false); + } else { + // Expandiu: o usuário quer VER o uso → busca o % do Claude agora (lazy). O + // cache de 5 min evita spam de abrir/fechar; fora daqui o loop não bate. + collectAndSendUsage({ claudeFetch: true }); } }); @@ -944,10 +953,12 @@ app.whenReady().then(() => { .on('all', () => sendSessions()); reapDead(); setInterval(() => { _discAt = 0; reapDead(); sendSessions(); saveBounds(); }, 5000); // descobre novos + limpa mortos + captura posição (drag externo p/ ex.) - // Consumo/reset dos agentes: GLM (rede, cache 30s) + Claude (arquivo local). + // Consumo/reset dos agentes: GLM (rede, cache 30s) + Codex/Antigravity (disco). // Cadência própria (60s) — desacoplada das sessões (que refrescam a cada 5s). - collectAndSendUsage(); - setInterval(collectAndSendUsage, 60 * 1000); + // O Claude é LAZY: o loop de fundo NÃO bate na API dele (limite agregado do + // 429); só o boot e os gatilhos de UI (abrir/revelar overlay, ⟳) buscam o %. + collectAndSendUsage({ claudeFetch: true }); // boot: 1 chamada p/ já ter o % + setInterval(collectAndSendUsage, 60 * 1000); // fundo: claudeFetch=false (não bate) setupAutoUpdater(); // update checker (boot + 1h) — AppImage auto-update }); @@ -1122,7 +1133,7 @@ function codexCwdsFromSessions() { return [...cwds]; } -async function collectAndSendUsage() { +async function collectAndSendUsage({ claudeFetch = false } = {}) { try { let glmCreds = glmCredsFromSessions(); // Fallback 1: o próprio app foi lançado de um terminal GLM (vars já no env). @@ -1140,6 +1151,10 @@ async function collectAndSendUsage() { const codexCwds = codexCwdsFromSessions(); const entries = await usage.collectUsage({ glmCreds, codexCwds, home: app.getPath('home'), + // LAZY: o loop de fundo (claudeFetch=false) NÃO bate na API do Claude — só + // os gatilhos de UI (abrir/revelar overlay, ⟳) e o boot passam true. Tira o + // app do limite agregado do 429 (compartilhado com o /status do Claude Code). + claudeAllowFetch: claudeFetch, // 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, @@ -1197,7 +1212,7 @@ ipcMain.on('force-usage', () => { usage._clearGlmCache(); usage._clearCodexCache(); } catch { /* ignore */ } - collectAndSendUsage(); + collectAndSendUsage({ claudeFetch: true }); // ⟳: gatilho de UI → busca o % agora }); // ---- update checker (versão + release mais nova do GitHub) ---- diff --git a/src/usage.js b/src/usage.js index 2ecd832..e3266d7 100644 --- a/src/usage.js +++ b/src/usage.js @@ -286,7 +286,7 @@ function claudePlanFromCreds({ subscriptionType, rateLimitTier } = {}) { // 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 } = {}) { +async function readClaudeUsage({ home, now, fetcher, cooldownUntil, cooldownFails, setCooldown, allowFetch = true } = {}) { const pc = readClaudeConfig({ home, now }); const creds = readClaudeCreds({ home }); // Plano: credenciais primeiro (tier/subscription REAIS — ex.: 'default_claude_max_5x'), @@ -317,6 +317,14 @@ async function readClaudeUsage({ home, now, fetcher, cooldownUntil, cooldownFail const cd = Math.max(cached && cached.cooldownUntil || 0, cooldownUntil || 0); if (cd && nowMs < cd) return (cached && cached.entries) || planOnly; + // LAZY (coleta sob demanda): o loop de fundo passa allowFetch=false e NÃO bate + // na API — a chamada só acontece quando o usuário VAI OLHAR o uso (abrir/revelar + // o overlay, botão ⟳) ou no boot. A /api/oauth/usage divide um limite AGREGADO + // com o /status do próprio Claude Code; consultá-la em loop de 60s alimentava o + // 429. Sem gatilho, devolvemos o último valor bom conhecido (ou o plano-só) — + // mesmo comportamento do cooldown, porém sem tocar na rede. + if (!allowFetch) return (cached && cached.entries) || planOnly; + const headers = { Authorization: 'Bearer ' + token, 'anthropic-beta': 'oauth-2025-04-20', @@ -754,6 +762,10 @@ async function collectUsage(opts = {}) { home: opts.home, now: opts.now, fetcher: opts.claudeFetcher, cooldownUntil: opts.claudeCooldownUntil, cooldownFails: opts.claudeCooldownFails, setCooldown: opts.claudeSetCooldown, + // Lazy: só bate na API do Claude quando o caller pede (gatilho de UI). O + // main.js passa true ao abrir/revelar o overlay e no ⟳; o loop de fundo + // omite → false. Default true preserva o contrato dos testes/uso direto. + allowFetch: opts.claudeAllowFetch !== false, }).catch(() => null), Promise.resolve().then(() => readAntigravityUsage({ home: opts.home })).catch(() => null), ...creds.map((c) => readGlmUsage({ diff --git a/test/usage.test.js b/test/usage.test.js index 932a926..199c318 100644 --- a/test/usage.test.js +++ b/test/usage.test.js @@ -428,6 +428,34 @@ test('readClaudeUsage: cooldownUntil injetado (persistido) bloqueia a chamada me fs.rmSync(tmp, { recursive: true, force: true }); }); +test('readClaudeUsage: allowFetch=false (lazy) NÃO bate na API — cai no plano-só sem chamar o fetcher', async () => { + _clearClaudeCache(); // sem valor bom prévio + const tmp = claudeHome({ token: 'tok' }); + const f = mockFetcher({ five_hour: { utilization: 50, resets_at: '2026-07-07T17:00:00Z' } }); + // loop de fundo (lazy): sem cache e sem gatilho de UI → não toca a rede. + const r = await readClaudeUsage({ home: tmp, now: NOW, fetcher: f, allowFetch: false }); + assert.equal(f.calls.length, 0, 'loop de fundo não bate na API do Claude'); + assert.equal(r.length, 1); + assert.equal(r[0].id, 'claude-plan'); // plano-só honesto (sem %) + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test('readClaudeUsage: allowFetch=false devolve o ÚLTIMO valor bom do cache (não rebate nem regride)', async () => { + _clearClaudeCache(); + const tmp = claudeHome({ token: 'tok' }); + const f = mockFetcher({ five_hour: { utilization: 50, resets_at: '2026-07-07T17:00:00Z' } }); + // gatilho de UI (abrir overlay) → busca 1x e popula o cache com o % real. + const r1 = await readClaudeUsage({ home: tmp, now: NOW, fetcher: f, allowFetch: true }); + assert.equal(r1[0].id, 'claude-5h'); + assert.equal(f.calls.length, 1); + // +6min (cache de 5min já expirou) MAS loop de fundo (lazy) → não rebate; mantém o %. + const r2 = await readClaudeUsage({ home: tmp, now: NOW + 6 * 60_000, fetcher: f, allowFetch: false }); + assert.equal(f.calls.length, 1, 'lazy não gasta chamada no loop de fundo'); + assert.equal(r2[0].id, 'claude-5h'); + assert.equal(r2[0].usedPct, 50, 'segura o último valor bom em vez de regredir pro plano-só'); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + test('readClaudeUsage: 429 chama setCooldown com {until, fails} p/ persistir', async () => { _clearClaudeCache(); const tmp = claudeHome({ token: 'tok' });