Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down
41 changes: 35 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,24 @@ 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 }[];
byProject: { project: string; tokens: TokenCounts; cost: number | null }[];
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";
Expand Down Expand Up @@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[ch] as string));
}
Expand All @@ -85,13 +102,22 @@ function renderLive(c: ThemeColors, live: LiveData | null, error: string | null)
const badge = live?.plan
? `<span style="font-size:0.62rem;border:1px solid ${c.border};border-radius:3px;padding:2px 8px;color:${c.muted}">${esc(live.plan)}${live.tier ? ` · ${esc(live.tier.replace(/^default_claude_/, ''))}` : ''}</span>`
: '';
const updated = live
? `<span style="font-size:0.62rem;color:${c.muted};margin-left:8px">updated ${ago(Date.now() - live.fetchedAt)}</span>`
: '';
let body: string;
if (error) {
body = errorNote(c, error);
} else if (!live || live.limits.length === 0) {
body = `<div style="font-size:0.75rem;color:${c.muted}">No limit data available.</div>`;
} 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)}.`)
+ `<div style="height:10px"></div>`;
} 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 `
Expand All @@ -106,7 +132,7 @@ function renderLive(c: ThemeColors, live: LiveData | null, error: string | null)
</div>`;
}).join('');
}
return card(c, `plan limits ${badge}`, body);
return card(c, `plan limits ${badge}${updated}`, body);
}

function renderChart(c: ThemeColors, daily: HistoryData['daily']): string {
Expand Down Expand Up @@ -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();
};
}
Expand Down
148 changes: 148 additions & 0 deletions src/live-cache.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading