Skip to content
Merged
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,50 @@ 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
desktop mandava o overlay para o fundo, sem passar por `toggleWin`/`revealIfHidden`
(ú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

Expand Down
51 changes: 50 additions & 1 deletion main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:<ms>} — 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');

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

// ---- update checker (versão + release mais nova do GitHub) ----
Expand Down
2 changes: 2 additions & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
4 changes: 4 additions & 0 deletions src/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
<button class="btn" id="toggleListBtn" data-tip="lista normal/compacta" data-i18n-tip="tooltip_toggle_list" aria-label="alternar lista">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
</button>
<button class="btn" id="forceUsageBtn" data-tip="atualizar uso agora" data-i18n-tip="tooltip_force_usage" aria-label="atualizar uso agora">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
</button>
<button class="btn" id="toggleFooterBtn" data-tip="alternar uso/launcher" data-i18n-tip="tooltip_toggle_footer" aria-label="alternar rodapé">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>
</button>
Expand Down
27 changes: 27 additions & 0 deletions src/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Botão de versão: ramifica por estado. available → baixar (AppImage) ou abrir
// release (demais); ready → reiniciar e instalar; idle/error → "verificar agora".
Expand Down Expand Up @@ -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(); });
Expand Down
5 changes: 5 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
Loading
Loading