diff --git a/README.md b/README.md
index 7390a4b..04d5a27 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,8 @@ Shows the current usage of Claude Code in a CloudCLI tab:
- **Live plan limits** — session (5h window), weekly, and per-model utilization
gauges with severity coloring and reset countdowns, straight from the same
- endpoint that powers Claude Code's `/usage` screen. Auto-refreshes every 60s.
+ endpoint that powers Claude Code's `/usage` screen. Auto-refreshes every
+ 5 minutes (the endpoint is strictly rate-limited, see below).
- **30-day history** — daily token bar chart, totals (tokens / estimated cost /
sessions), and per-model / per-project breakdowns, parsed locally from
`~/.claude/projects/**/*.jsonl` transcripts.
@@ -45,7 +46,7 @@ The backend subprocess (`dist/server.js`) exposes two endpoints via the host's
RPC proxy:
- `GET /live` — reads the OAuth token from `~/.claude/.credentials.json` and
- calls `https://api.anthropic.com/api/oauth/usage` (cached 30s). It never
+ calls `https://api.anthropic.com/api/oauth/usage` (cached 5 minutes). It never
writes to the credentials file and never uses the refresh token; if the token
is expired it asks you to run `claude` to refresh it.
- `GET /history?days=30` — parses local session transcripts (deduped by message
@@ -58,6 +59,20 @@ vice-versa.
> **Note:** the usage endpoint is unofficial and may change; the live section
> degrades gracefully if it does.
+### Rate limiting
+
+The usage endpoint allows only a handful of requests per window, and that
+budget is shared with Claude Code's own `/usage` screen and statusline (see
+[anthropics/claude-code#30930](https://github.com/anthropics/claude-code/issues/30930)).
+To stay well under it, the plugin:
+
+- calls the endpoint at most once every 5 minutes, no matter how many tabs are
+ open or how often you click refresh (concurrent requests share one call);
+- on HTTP 429 honors `Retry-After` when present, otherwise backs off
+ 5 → 10 → 20 → 40 → 60 minutes between attempts;
+- keeps showing the last good reading (marked as stale, with the reason) while
+ it waits, instead of blanking the gauges.
+
## Development
```bash
diff --git a/src/index.ts b/src/index.ts
index a215764..fe4226c 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -9,7 +9,11 @@ import type { PluginAPI, PluginContext } from './types.js';
interface TokenCounts { input: number; output: number; cacheCreate: number; cacheRead: number }
interface LiveLimit { kind: string; label: string; percent: number; severity: string; resetsAt: string | null; model: string | null }
-interface LiveData { plan: string | null; tier: string | null; limits: LiveLimit[] }
+interface LiveData {
+ plan: string | null; tier: string | null; limits: LiveLimit[];
+ fetchedAt: number; stale: boolean; error: string | null; retryAt: number | null;
+}
+
interface HistoryData {
daily: { date: string; tokens: TokenCounts; cost: number }[];
byModel: { model: string; tokens: TokenCounts; cost: number | null }[];
@@ -17,6 +21,12 @@ interface HistoryData {
totals: { tokens: TokenCounts; cost: number; sessions: number; messages: number };
}
+// The usage endpoint rate-limits after a handful of calls and the limit is shared
+// with Claude Code's own /usage screen, so poll it rarely. The server caches for
+// the same interval and backs off on 429, so the refresh button is safe too.
+const LIVE_POLL_MS = 5 * 60_000;
+const TICK_MS = 60_000;
+
// ── Theme (matches starter conventions) ─────────────────────────────────
const MONO = "'JetBrains Mono', 'Fira Code', ui-monospace, monospace";
@@ -63,6 +73,13 @@ function resetsIn(iso: string | null): string {
return h > 0 ? `resets in ${h}h ${m}m` : `resets in ${m}m`;
}
+function ago(ms: number): string {
+ const m = Math.floor(ms / 60_000);
+ if (m < 1) return 'just now';
+ if (m < 60) return `${m}m ago`;
+ return `${Math.floor(m / 60)}h ${m % 60}m ago`;
+}
+
function esc(s: string): string {
return s.replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[ch] as string));
}
@@ -85,13 +102,22 @@ function renderLive(c: ThemeColors, live: LiveData | null, error: string | null)
const badge = live?.plan
? `${esc(live.plan)}${live.tier ? ` · ${esc(live.tier.replace(/^default_claude_/, ''))}` : ''}`
: '';
+ const updated = live
+ ? `updated ${ago(Date.now() - live.fetchedAt)}`
+ : '';
let body: string;
if (error) {
body = errorNote(c, error);
} else if (!live || live.limits.length === 0) {
body = `
No limit data available.
`;
} else {
- body = live.limits.map((l) => {
+ if (live.stale && live.error) {
+ body = errorNote(c, `${live.error} Showing last reading from ${ago(Date.now() - live.fetchedAt)}.`)
+ + ``;
+ } else {
+ body = '';
+ }
+ body += live.limits.map((l) => {
const color = sevColor(c, l.severity, l.percent);
const pct = Math.max(0, Math.min(100, l.percent));
return `
@@ -106,7 +132,7 @@ function renderLive(c: ThemeColors, live: LiveData | null, error: string | null)
`;
}).join('');
}
- return card(c, `plan limits ${badge}`, body);
+ return card(c, `plan limits ${badge}${updated}`, body);
}
function renderChart(c: ThemeColors, daily: HistoryData['daily']): string {
@@ -227,12 +253,15 @@ export function mount(container: HTMLElement, api: PluginAPI): void {
render(api.context);
void load(true);
- // Live gauges poll every 60s while mounted; countdown text refreshes with them.
- const timer = window.setInterval(() => void load(false), 60_000);
+ // Live gauges re-fetch every 5 minutes; countdown / "updated" text re-renders
+ // every minute without touching the network.
+ const poll = window.setInterval(() => void load(false), LIVE_POLL_MS);
+ const tick = window.setInterval(() => render(api.context), TICK_MS);
const unsubscribe = api.onContextChange((ctx) => render(ctx));
(container as any)._cuCleanup = () => {
- window.clearInterval(timer);
+ window.clearInterval(poll);
+ window.clearInterval(tick);
unsubscribe();
};
}
diff --git a/src/live-cache.test.ts b/src/live-cache.test.ts
new file mode 100644
index 0000000..0faf086
--- /dev/null
+++ b/src/live-cache.test.ts
@@ -0,0 +1,148 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { LiveCache, LIVE_CACHE_MS, parseRetryAfter, backoffMs, UsageResponse } from './live.js';
+
+const BODY = { limits: [{ kind: 'session', percent: 10, severity: 'normal', resets_at: null }] };
+
+function harness(responses: (UsageResponse | Error)[]) {
+ let now = 1_000_000;
+ let calls = 0;
+ const cache = new LiveCache(
+ async () => {
+ const r = responses[Math.min(calls, responses.length - 1)];
+ calls++;
+ if (r instanceof Error) throw r;
+ return r;
+ },
+ () => now,
+ );
+ return { cache, calls: () => calls, advance: (ms: number) => { now += ms; }, now: () => now };
+}
+
+const ok: UsageResponse = { status: 200, retryAfter: null, body: BODY, plan: 'max', tier: null };
+const limited: UsageResponse = { status: 429, retryAfter: null, body: null, plan: 'max', tier: null };
+
+test('first call fetches and reports when it was fetched', async () => {
+ const h = harness([ok]);
+ const r = await h.cache.get();
+ assert.equal(h.calls(), 1);
+ assert.equal(r.limits[0].percent, 10);
+ assert.equal(r.fetchedAt, h.now());
+ assert.equal(r.stale, false);
+ assert.equal(r.error, null);
+});
+
+test('calls within the cache window do not hit the endpoint', async () => {
+ const h = harness([ok]);
+ await h.cache.get();
+ h.advance(LIVE_CACHE_MS - 1);
+ await h.cache.get();
+ assert.equal(h.calls(), 1);
+ h.advance(1);
+ await h.cache.get();
+ assert.equal(h.calls(), 2);
+});
+
+test('cache window is at least five minutes', () => {
+ assert.ok(LIVE_CACHE_MS >= 5 * 60_000);
+});
+
+test('concurrent calls share one in-flight fetch', async () => {
+ const h = harness([ok]);
+ await Promise.all([h.cache.get(), h.cache.get(), h.cache.get()]);
+ assert.equal(h.calls(), 1);
+});
+
+test('429 with cached data serves the stale reading and a retry time', async () => {
+ const h = harness([ok, limited]);
+ await h.cache.get();
+ h.advance(LIVE_CACHE_MS);
+ const r = await h.cache.get();
+ assert.equal(h.calls(), 2);
+ assert.equal(r.stale, true);
+ assert.equal(r.limits[0].percent, 10);
+ assert.match(r.error!, /429/);
+ assert.equal(r.retryAt, h.now() + backoffMs(1));
+});
+
+test('no fetch happens until the retry time has passed', async () => {
+ const h = harness([ok, limited, ok]);
+ await h.cache.get();
+ h.advance(LIVE_CACHE_MS);
+ const r = await h.cache.get();
+ h.advance(r.retryAt! - h.now() - 1);
+ await h.cache.get();
+ assert.equal(h.calls(), 2);
+ h.advance(1);
+ const fresh = await h.cache.get();
+ assert.equal(h.calls(), 3);
+ assert.equal(fresh.stale, false);
+ assert.equal(fresh.error, null);
+});
+
+test('429 honors the Retry-After header', async () => {
+ const h = harness([{ ...limited, retryAfter: '900' }]);
+ await assert.rejects(h.cache.get());
+ h.advance(899_000);
+ await assert.rejects(h.cache.get(), /429/);
+ assert.equal(h.calls(), 1);
+});
+
+test('repeated 429s back off exponentially up to a cap', async () => {
+ assert.equal(backoffMs(1), 5 * 60_000);
+ assert.equal(backoffMs(2), 10 * 60_000);
+ assert.equal(backoffMs(3), 20 * 60_000);
+ assert.equal(backoffMs(4), 40 * 60_000);
+ assert.equal(backoffMs(5), 60 * 60_000);
+ assert.equal(backoffMs(9), 60 * 60_000);
+});
+
+test('consecutive 429s lengthen the wait; a success resets it', async () => {
+ const h = harness([limited, limited, ok, limited]);
+ await assert.rejects(h.cache.get());
+ h.advance(backoffMs(1));
+ await assert.rejects(h.cache.get());
+ h.advance(backoffMs(2));
+ await h.cache.get();
+ h.advance(LIVE_CACHE_MS);
+ const r = await h.cache.get();
+ assert.equal(r.retryAt, h.now() + backoffMs(1));
+});
+
+test('429 without cached data throws a 429 with the wait in the message', async () => {
+ const h = harness([limited]);
+ await assert.rejects(h.cache.get(), (err: any) => err.status === 429 && /5 min/.test(err.message));
+});
+
+test('a thrown fetch error keeps stale data and retries at the normal interval', async () => {
+ const h = harness([ok, new Error('boom'), ok]);
+ await h.cache.get();
+ h.advance(LIVE_CACHE_MS);
+ const r = await h.cache.get();
+ assert.equal(r.stale, true);
+ assert.equal(r.error, 'boom');
+ h.advance(LIVE_CACHE_MS);
+ const fresh = await h.cache.get();
+ assert.equal(fresh.stale, false);
+ assert.equal(h.calls(), 3);
+});
+
+test('parseRetryAfter reads seconds and HTTP dates, ignores junk', () => {
+ assert.equal(parseRetryAfter('120', 0), 120_000);
+ assert.equal(parseRetryAfter('0', 0), null);
+ assert.equal(parseRetryAfter('garbage', 0), null);
+ assert.equal(parseRetryAfter(null, 0), null);
+ const now = Date.parse('2026-10-30T08:00:00Z');
+ assert.equal(parseRetryAfter('Fri, 30 Oct 2026 08:10:00 GMT', now), 600_000);
+ assert.equal(parseRetryAfter('Fri, 30 Oct 2026 07:00:00 GMT', now), null);
+});
+
+test('after a non-429 error, calls before the next interval do not re-fetch', async () => {
+ const h = harness([ok, new Error('boom')]);
+ await h.cache.get();
+ h.advance(LIVE_CACHE_MS);
+ await h.cache.get();
+ h.advance(LIVE_CACHE_MS - 1);
+ await h.cache.get();
+ assert.equal(h.calls(), 2);
+});
diff --git a/src/live.ts b/src/live.ts
index 5bf68d5..c89f16d 100644
--- a/src/live.ts
+++ b/src/live.ts
@@ -60,3 +60,123 @@ export function normalizeUsage(raw: unknown, plan: string | null, tier: string |
}
return { plan, tier, limits };
}
+
+// ── Caching + rate-limit backoff ────────────────────────────────────────
+//
+// The usage endpoint is rate-limited far more aggressively than normal API
+// calls (a handful of requests per window, shared with Claude Code's own
+// /usage screen). So: poll rarely, coalesce concurrent callers, and on 429
+// back off and keep showing the last good reading instead of hammering it.
+
+/** How long a successful reading is served without re-fetching. */
+export const LIVE_CACHE_MS = 5 * 60_000;
+const BACKOFF_BASE_MS = 5 * 60_000;
+const BACKOFF_CAP_MS = 60 * 60_000;
+
+/** What the server hands the cache after calling the endpoint. */
+export interface UsageResponse {
+ status: number;
+ retryAfter: string | null;
+ body: unknown;
+ plan: string | null;
+ tier: string | null;
+}
+
+export interface LiveResult extends LiveData {
+ /** When the limits were last fetched successfully (ms epoch). */
+ fetchedAt: number;
+ /** True when the reading could not be refreshed and is older than LIVE_CACHE_MS. */
+ stale: boolean;
+ /** Why the last refresh failed, if it did. */
+ error: string | null;
+ /** When the next fetch is allowed, if we are backing off. */
+ retryAt: number | null;
+}
+
+/** Delay before the n-th consecutive 429 is retried: 5m, 10m, 20m, 40m, 60m cap. */
+export function backoffMs(failures: number): number {
+ return Math.min(BACKOFF_BASE_MS * 2 ** Math.max(0, failures - 1), BACKOFF_CAP_MS);
+}
+
+/** Retry-After header → delay in ms, or null when absent, zero, past, or unparsable. */
+export function parseRetryAfter(header: string | null, nowMs: number): number | null {
+ if (!header) return null;
+ const secs = Number(header);
+ if (Number.isFinite(secs)) return secs > 0 ? secs * 1000 : null;
+ const at = Date.parse(header);
+ return Number.isFinite(at) && at > nowMs ? at - nowMs : null;
+}
+
+function fmtWait(ms: number): string {
+ const min = Math.ceil(ms / 60_000);
+ return min <= 1 ? '1 min' : `${min} min`;
+}
+
+export class LiveCache {
+ private last: { at: number; data: LiveData } | null = null;
+ private failure: { message: string; status: number; retryAt: number | null } | null = null;
+ private failures429 = 0;
+ private nextAllowedAt = 0;
+ private inflight: Promise | null = null;
+
+ constructor(
+ private readonly fetchUsage: () => Promise,
+ private readonly now: () => number = Date.now,
+ ) {}
+
+ async get(): Promise {
+ if (this.inflight) {
+ await this.inflight;
+ } else if (this.now() >= this.nextAllowedAt) {
+ this.inflight = this.refresh().finally(() => { this.inflight = null; });
+ await this.inflight;
+ }
+ return this.result();
+ }
+
+ private async refresh(): Promise {
+ const now = this.now();
+ // Whatever happens, don't call the endpoint again before the normal cadence;
+ // a 429 pushes this further out below.
+ this.nextAllowedAt = now + LIVE_CACHE_MS;
+ let res: UsageResponse;
+ try {
+ res = await this.fetchUsage();
+ } catch (err: any) {
+ this.failure = { message: err?.message ?? 'Usage fetch failed.', status: typeof err?.status === 'number' ? err.status : 502, retryAt: null };
+ return;
+ }
+ if (res.status === 429) {
+ this.failures429++;
+ const wait = parseRetryAfter(res.retryAfter, now) ?? backoffMs(this.failures429);
+ this.failure = {
+ message: `Usage endpoint returned HTTP 429 (rate limited) — retrying in ${fmtWait(wait)}.`,
+ status: 429,
+ retryAt: now + wait,
+ };
+ this.nextAllowedAt = now + wait;
+ return;
+ }
+ if (res.status < 200 || res.status >= 300) {
+ this.failure = { message: `Usage endpoint returned HTTP ${res.status}.`, status: 502, retryAt: null };
+ return;
+ }
+ this.failures429 = 0;
+ this.failure = null;
+ this.last = { at: now, data: normalizeUsage(res.body, res.plan, res.tier) };
+ }
+
+ private result(): LiveResult {
+ if (!this.last) {
+ const f = this.failure!;
+ throw Object.assign(new Error(f.message), { status: f.status });
+ }
+ return {
+ ...this.last.data,
+ fetchedAt: this.last.at,
+ stale: this.failure !== null,
+ error: this.failure?.message ?? null,
+ retryAt: this.failure?.retryAt ?? null,
+ };
+ }
+}
diff --git a/src/server.ts b/src/server.ts
index 02a323b..015eb89 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -2,20 +2,16 @@ import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
-import { parseCredentials, normalizeUsage, LiveData } from './live.js';
+import { parseCredentials, LiveCache, LiveResult, Credentials } from './live.js';
import { parseTranscriptLine, projectLabel, aggregate, SessionEntries, UsageEntry } from './history.js';
const CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
-const LIVE_CACHE_MS = 30_000;
// ── /live ──────────────────────────────────────────────────────────────
-let liveCache: { at: number; data: LiveData } | null = null;
-
-async function getLive(): Promise {
- if (liveCache && Date.now() - liveCache.at < LIVE_CACHE_MS) return liveCache.data;
-
+/** Local, no-network checks — never throttled, so a fresh login shows up immediately. */
+function readCredentials(): Credentials {
let credsJson: unknown;
try {
credsJson = JSON.parse(fs.readFileSync(path.join(CLAUDE_DIR, '.credentials.json'), 'utf-8'));
@@ -27,7 +23,14 @@ async function getLive(): Promise {
if (creds.expired) {
throw Object.assign(new Error('OAuth token expired — run claude to refresh it, then retry.'), { status: 401 });
}
+ return creds;
+}
+// The endpoint is shared with Claude Code's own /usage screen and rate-limits
+// after only a few calls, so LiveCache polls it at most every 5 minutes and
+// backs off on 429 (see live.ts).
+const liveCache = new LiveCache(async () => {
+ const creds = readCredentials();
const res = await fetch(USAGE_URL, {
headers: {
Authorization: `Bearer ${creds.accessToken}`,
@@ -36,12 +39,18 @@ async function getLive(): Promise {
},
signal: AbortSignal.timeout(10_000),
});
- if (!res.ok) {
- throw Object.assign(new Error(`Usage endpoint returned HTTP ${res.status}.`), { status: 502 });
- }
- const data = normalizeUsage(await res.json(), creds.plan, creds.tier);
- liveCache = { at: Date.now(), data };
- return data;
+ return {
+ status: res.status,
+ retryAfter: res.headers.get('retry-after'),
+ body: res.ok ? await res.json() : null,
+ plan: creds.plan,
+ tier: creds.tier,
+ };
+});
+
+async function getLive(): Promise {
+ readCredentials();
+ return liveCache.get();
}
// ── /history ───────────────────────────────────────────────────────────