SLOPSHOPPER

claude-stats

Usage limits (5h / 7d) and ccusage spend, drawn as a status band above the prompt.

newbandprocesstimer
★ 2v0.2.1no licenseupdated 2026-10-02estruyf/claude-stats-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claude-stats
› fix the failing auth test and add an audit log call ● claude-stats: claude-stats: ccusage failed: JSON Parse error: Unexpected EOF ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ◔ 31% 5h · resets now $0.42 ccusage unavailable ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◔ 31% 5h · resets now $0.42 ccusage unavailable
README

claude-stats

A Claude Code mod that draws your usage limits and spend in the band above the prompt.

Claude Code desktop

claude-stats in the Claude Code desktop app: rings for the 5h and 7d limits above the prompt

Terminal

claude-stats in the Claude Code CLI: the 5h and 7d limits on one line above the prompt

  • 5h / 7d: the subscription rate-limit windows from $.session.usage(), with a ring (desktop) or pie glyph (terminal) coloured green → amber (60%) → red (85%), and a countdown to the reset.
  • Spend: this session's cost, plus today and this month from ccusage (ccusage daily --json --offline --since <first of month>).

Rate limits only show on a Claude subscription (they're absent for API-key, Bedrock and Vertex sessions).

Install

In Claude Code:

/plugin marketplace add estruyf/claude-stats-mod
/plugin install claude-stats@claude-stats-mod
/reload-plugins

Or from your shell:

claude plugin marketplace add estruyf/claude-stats-mod
claude plugin install claude-stats@claude-stats-mod --scope user

Update

Refresh the marketplace, then update the plugin:

claude plugin marketplace update claude-stats-mod
claude plugin update claude-stats@claude-stats-mod

Run /reload-plugins in an open session (or restart the desktop app) to load the new version. Inside Claude Code you can also do this from /plugin, under the claude-stats-mod marketplace.

Try it from a clone

git clone https://github.com/estruyf/claude-stats-mod.git
claude --plugin-dir ./claude-stats-mod

Requires Claude Code 2.1.287+ (mods on by default). On 2.1.286, set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in the env block of ~/.claude/settings.json.

Options

Set them with /config or under pluginConfigs["claude-stats"].options in settings:

OptionDefault
showSpendtrueShow the spend pill. Turn it off to see only the usage limits; ccusage then never runs.
ccusageCommandnpx -y ccusage@latestTried directly, then through your login shell so nvm/volta PATHs resolve in the desktop app.
refreshSeconds60Minimum time between ccusage runs after a turn. While idle it refreshes every 10 minutes.

Develop

claude plugin validate .claude-plugin/plugin.json
claude plugin test .

Running with --plugin-dir hot-reloads the mod on every save, so there's no update step while developing.

To release, bump version in .claude-plugin/plugin.json and push to main. claude plugin update compares that version, so a push without a bump isn't picked up as an update.

Source 2 files
hooks/claude-stats.mjs 279 lines
1// Claude Stats: usage limits and ccusage spend, drawn in the band above the prompt.
2//
3//   ◔ 14% 5h · resets 1h7m   ◕ 83% 7d · resets 3h47m   $ $0.10 · $0.10 today · $0.10 mo
4
5// Held by the host, so the readings survive a hot reload of this file.
6const USAGE = { plugin: "claude-stats", key: "usage" };
7const COSTS = { plugin: "claude-stats", key: "costs" };
8const TICK = { plugin: "claude-stats", key: "tick" };
9
10const MINUTE = 60_000;
11const IDLE_REFRESH_MS = 10 * MINUTE; // pick up spend from other sessions while this one is idle
12
13const LIMIT_LABELS = { five_hour: "5h", seven_day: "7d", spend_limit: "spend" };
14
15const GREEN = "#22c55e";
16const AMBER = "#f59e0b";
17const RED = "#ef4444";
18
19let refreshing = false;
20let ticker;
21
22export function register(on, options = {}) {
23  const refreshMs = Math.max(10, Number(options.refreshSeconds) || 60) * 1000;
24  const showSpend = options.showSpend !== false;
25
26  on("session.start", async ($, e, next) => {
27    const result = await next(e);
28    await takeUsage($);
29    if (showSpend) refreshCosts($, options, 0).catch(() => {});
30
31    // Redraw the countdowns every minute, and catch spend from other sessions.
32    ticker?.cancel();
33    ticker = $.clock.every(MINUTE, () => {
34      takeUsage($)
35        .then(() => $.clock.now())
36        .then((now) => $.state.set(TICK, now))
37        .catch(() => {});
38      if (showSpend) refreshCosts($, options, IDLE_REFRESH_MS).catch(() => {});
39    });
40    return result;
41  });
42
43  // Pushed by the engine whenever a rate-limit window or the session cost moves.
44  on("session.measure", async ($, e, next) => {
45    await saveUsage($, e);
46    return next(e);
47  });
48
49  on("turn.complete", async ($, e, next) => {
50    const result = await next(e);
51    if (!e.agentId) {
52      await takeUsage($); // main-loop turns only, not subagents
53      if (showSpend) refreshCosts($, options, refreshMs).catch(() => {});
54    }
55    return result;
56  });
57
58  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
59    if (e.props?.hasSurvey) return next(e);
60
61    const { value: usage } = await $.state.get(USAGE);
62    const { value: costs } = await $.state.get(COSTS);
63    await $.state.get(TICK); // subscribe, so the reset countdowns redraw each minute
64
65    const limits = usage?.rateLimits ?? [];
66    const spend = showSpend ? costParts(usage, costs) : null;
67    if (limits.length === 0 && !spend) return next(e);
68
69    const now = await $.clock.now();
70    const els = $.ui.resolve(e);
71    return e.surface === "terminal"
72      ? terminalBand(els, limits, spend, now, e.props?.bodyColumns ?? 120)
73      : desktopBand(els, limits, spend, now);
74  });
75}
76
77// ── Readings ────────────────────────────────────────────────────────────────
78
79async function takeUsage($) {
80  await saveUsage($, await $.session.usage());
81}
82
83async function saveUsage($, { rateLimits = [], cost } = {}) {
84  await $.state.set(USAGE, {
85    rateLimits: rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })),
86    sessionUsd: cost?.usd,
87  });
88}
89
90// Runs ccusage once for the month so far: today's row plus the month's total.
91async function refreshCosts($, options, maxAgeMs) {
92  if (refreshing) return;
93  const now = await $.clock.now();
94  const { value: prev } = await $.state.get(COSTS);
95  if (prev?.at && now - prev.at < maxAgeMs) return;
96
97  refreshing = true;
98  try {
99    const d = new Date(now);
100    const y = d.getFullYear();
101    const m = String(d.getMonth() + 1).padStart(2, "0");
102    const day = String(d.getDate()).padStart(2, "0");
103    const stdout = await runCcusage($, options, ["daily", "--json", "--offline", "--since", `${y}${m}01`]);
104    const data = JSON.parse(stdout.slice(stdout.indexOf("{")));
105    const todayRow = (data.daily ?? []).find((r) => (r.period ?? r.date) === `${y}-${m}-${day}`);
106    await $.state.set(COSTS, {
107      today: todayRow?.totalCost ?? 0,
108      month: data.totals?.totalCost ?? 0,
109      at: now,
110    });
111  } catch (err) {
112    const error = String(err?.message ?? err);
113    $.ui.log(`claude-stats: ccusage failed: ${error}`, { to: "debug" });
114    await $.state.set(COSTS, { ...prev, at: now, error });
115  } finally {
116    refreshing = false;
117  }
118}
119
120// Tries the command as-is, then through a login shell: the desktop app starts
121// without the PATH that nvm, volta or fnm set up in your shell profile.
122async function runCcusage($, options, args) {
123  const command = String(options.ccusageCommand || "npx -y ccusage@latest").trim();
124  const argv = [...command.split(/\s+/), ...args];
125  const init = { timeoutMs: 90_000 };
126
127  try {
128    const r = await $.process.run(argv, init);
129    if (r.exitCode === 0) return r.stdout;
130    if (r.exitCode !== 127) throw new Error(r.stderr.trim() || `exit ${r.exitCode}`);
131  } catch (err) {
132    if (!/ENOENT|not found|cannot start|spawn/i.test(String(err?.message ?? err))) throw err;
133  }
134
135  const shell = (await $.env.get("SHELL")) || "/bin/zsh";
136  const r = await $.process.run([shell, "-lic", argv.map(quote).join(" ")], init);
137  if (r.exitCode !== 0) throw new Error(r.stderr.trim() || `exit ${r.exitCode}`);
138  return r.stdout;
139}
140
141function quote(arg) {
142  return /^[\w@./:=-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`;
143}
144
145// ── Drawing: desktop ────────────────────────────────────────────────────────
146
147function desktopBand({ Box, Text, Svg }, limits, spend, now) {
148  const pills = limits.map((l) => {
149    const pct = Math.round(l.percentUsed);
150    const reset = resetIn(l.resetsAt, now);
151    return pill(Box, [
152      Svg({ source: ring(l.percentUsed, colorFor(l.percentUsed)), alt: `${pct}% used`, width: 16, height: 16 }),
153      Text({ bold: true, children: `${pct}%` }),
154      Text({ dimColor: true, children: `${labelFor(l.kind)}${reset ? ` · resets ${reset}` : ""}` }),
155    ]);
156  });
157
158  if (spend) {
159    pills.push(
160      pill(Box, [
161        Svg({ source: dollarBadge(), alt: "cost", width: 16, height: 16 }),
162        Text({ bold: true, color: GREEN, children: spend.session }),
163        Text({ dimColor: true, children: spend.rest }),
164      ]),
165    );
166  }
167
168  return Box({ flexDirection: "row", flexWrap: "wrap", gap: 1, paddingX: 1, children: pills });
169}
170
171function pill(Box, children) {
172  return Box({
173    flexDirection: "row",
174    alignItems: "center",
175    gap: 1,
176    paddingX: 1,
177    borderStyle: "round",
178    borderDimColor: true,
179    children,
180  });
181}
182
183function ring(percent, color) {
184  const r = 6;
185  const c = 2 * Math.PI * r;
186  const offset = c * (1 - Math.min(Math.max(percent, 0), 100) / 100);
187  return (
188    `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">` +
189    `<circle cx="8" cy="8" r="${r}" fill="none" stroke="${color}" stroke-opacity="0.25" stroke-width="2.5"/>` +
190    `<circle cx="8" cy="8" r="${r}" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" ` +
191    `stroke-dasharray="${c.toFixed(2)}" stroke-dashoffset="${offset.toFixed(2)}" transform="rotate(-90 8 8)"/>` +
192    `</svg>`
193  );
194}
195
196function dollarBadge() {
197  return (
198    `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">` +
199    `<circle cx="8" cy="8" r="7" fill="none" stroke="${GREEN}" stroke-width="1.5"/>` +
200    `<text x="8" y="11.5" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" font-weight="700" fill="${GREEN}">$</text>` +
201    `</svg>`
202  );
203}
204
205// ── Drawing: terminal ───────────────────────────────────────────────────────
206
207function terminalBand({ Box, Text }, limits, spend, now, columns) {
208  const wide = columns >= 80;
209  const parts = [];
210  const gap = () => parts.length && parts.push(Text({ children: "   " }));
211
212  for (const l of limits) {
213    gap();
214    const reset = wide ? resetIn(l.resetsAt, now) : "";
215    parts.push(Text({ color: colorFor(l.percentUsed), children: `${pie(l.percentUsed)} ` }));
216    parts.push(Text({ bold: true, children: `${Math.round(l.percentUsed)}%` }));
217    parts.push(Text({ dimColor: true, children: ` ${labelFor(l.kind)}${reset ? ` · resets ${reset}` : ""}` }));
218  }
219
220  if (spend) {
221    gap();
222    parts.push(Text({ color: GREEN, bold: true, children: spend.session }));
223    if (wide) parts.push(Text({ dimColor: true, children: ` ${spend.rest}` }));
224  }
225
226  // A blank row above, so the band doesn't run into the transcript.
227  return Box({ flexDirection: "row", marginTop: 1, paddingX: 1, children: parts });
228}
229
230// Single-width glyphs, so the line stays aligned in every terminal font.
231function pie(percent) {
232  return "○◔◑◕●"[Math.min(4, Math.round(Math.max(percent, 0) / 25))];
233}
234
235// ── Formatting ──────────────────────────────────────────────────────────────
236
237function costParts(usage, costs) {
238  const hasSession = usage?.sessionUsd !== undefined;
239  const hasLedger = costs?.today !== undefined;
240  if (!hasSession && !hasLedger) return null;
241
242  const session = usd(usage?.sessionUsd ?? 0);
243  const rest = hasLedger
244    ? `${usd(costs.today)} today · ${usd(costs.month)} mo`
245    : costs?.error
246      ? "ccusage unavailable"
247      : "loading ccusage…";
248  return { session, rest };
249}
250
251function usd(n) {
252  if (n >= 1000) return `$${(n / 1000).toFixed(1)}k`;
253  if (n >= 100) return `$${Math.round(n)}`;
254  return `$${n.toFixed(2)}`;
255}
256
257function colorFor(percent) {
258  if (percent >= 85) return RED;
259  if (percent >= 60) return AMBER;
260  return GREEN;
261}
262
263function labelFor(kind) {
264  return LIMIT_LABELS[kind] ?? kind.replace(/_/g, " ");
265}
266
267function resetIn(resetsAt, now) {
268  if (!resetsAt) return "";
269  const ms = Date.parse(resetsAt) - now;
270  if (!(ms > 0)) return "now";
271  const mins = Math.ceil(ms / MINUTE);
272  const d = Math.floor(mins / 1440);
273  const h = Math.floor((mins % 1440) / 60);
274  const m = mins % 60;
275  if (d > 0) return `${d}d${h}h`;
276  if (h > 0) return `${h}h${m}m`;
277  return `${m}m`;
278}
279
types/index.d.ts 16 lines
1export type ClaudeStatsLimit = { kind: string; percentUsed: number; resetsAt?: string };
2
3export type ClaudeStatsUsage = { rateLimits: ClaudeStatsLimit[]; sessionUsd?: number };
4
5export type ClaudeStatsCosts = { today?: number; month?: number; at: number; error?: string };
6
7declare module "claude-code" {
8  interface PluginState {
9    "claude-stats": {
10      usage: ClaudeStatsUsage;
11      costs: ClaudeStatsCosts;
12      tick: number;
13    };
14  }
15}
16