SLOPSHOPPER

usage-meter

Sleek colored 5-hour and weekly usage bars in the footer; warns at 75%/90% and before agent waves when the window is low.

newspinnerguardtoaststatustimer
v0.1.0no licenseupdated 2026-10-09eshin087/claude-code-mods/usage-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-meter
› fix the failing auth test and add an audit log call ⏺ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ usage-meter: 5h 31% auto-accept edits · 5h 31% ⟳NaNd
README

5-Hour Usage Meter

What it does: keeps your subscription's usage windows visible, so you know whether there's room for a big job or a wave of agents.

Where you see it:

  • Footer, right of the prompt: 5h 45% · wk 24%. The labels are grey and each % is green under 60%, amber at 60-85% and red above. It's text, as short as possible so both windows fit the desktop's narrow slot (the desktop app 2.26454 shows no picture there).
  • Reset time of the 5-hour window: on the terminal footer (5h 45% ⟳2h59m · wk 24%) and in the 75% and 90% toasts.
  • If the app has no footer slot for mods, the same short line shows as plain text in the status line instead.
  • Toasts at 75% and 90% of the 5-hour window (once per window).
  • Before an agent wave or workflow at 80% or more: a toast, and a note telling Claude to use fewer parallel agents.

How it works: every API response reports your rate-limit windows. The app passes them to mods whenever they move by a whole point. "Resets in" refreshes every minute.

Cost: none.

Data saved: the last reading and which warnings were already shown (in the mod's own store).

Notes: a new session shows the last reading straight away (any window that has reset since is left out) and updates after its first reply. Nothing shows on API-key or non-subscription accounts.

Source 2 files
hooks/register.tsx 147 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { MeterLimit } from '../types'
5
6// The meter draws itself in the footer's mode slot (right of the prompt
7// footer), as short as it can be so both windows fit: `5h 45% · wk 24%`, the
8// labels grey and each % green, amber or red by how full it is. Text, not an
9// SVG image: the desktop app (2.26454, engine 2.1.293) asks for the slot but
10// shows no image there. The terminal has room for the 5-hour reset time too.
11// The last reading is kept in the store, so a new session shows the meter
12// before its first reply. A surface without the footer slot never asks for it;
13// until it does, the same line goes to the plain-text status line.
14
15const WARN_AT = [75, 90]
16const AGENT_WARN_AT = 80
17
18const limitsRef = atom({ plugin: 'usage-meter', key: 'limits' } as const, [])
19
20let lastToastAt = 0
21let footerSeen = false
22
23const fmtIn = (ms: number) => {
24  const m = Math.max(0, Math.round(ms / 60000))
25  if (m < 60) return `${m}m`
26  const h = Math.floor(m / 60)
27  return h < 48 ? `${h}h${String(m % 60).padStart(2, '0')}m` : `${Math.round(h / 24)}d`
28}
29
30const level = (pct: number) => (pct >= 85 ? 'error' : pct >= 60 ? 'warning' : 'success')
31
32const five = (list: readonly MeterLimit[]) => list.find(l => l.kind === 'five_hour')
33const week = (list: readonly MeterLimit[]) => list.find(l => l.kind === 'seven_day')
34
35const resetsIn = (l: MeterLimit | undefined, now: number) => (l?.resetsAt ? fmtIn(Date.parse(l.resetsAt) - now) : null)
36
37// The plain-text line, for a surface with no footer slot.
38const showPlain = async ($: EngineInterface) => {
39  const list = await read($, limitsRef)
40  const f = five(list)
41  const w = week(list)
42  if (footerSeen || (!f && !w)) return $.ui.status(undefined)
43  $.ui.status([f ? `5h ${Math.round(f.percentUsed)}%` : '', w ? `wk ${Math.round(w.percentUsed)}%` : ''].filter(Boolean).join(' · '))
44}
45
46// One toast per threshold per window (keyed by when the window resets).
47const warn = async ($: EngineInterface) => {
48  const f = five(await read($, limitsRef))
49  if (!f) return
50  const crossed = [...WARN_AT].reverse().find(n => f.percentUsed >= n)
51  if (crossed === undefined) return
52  const key = `warned:${f.resetsAt ?? 'window'}:${crossed}`
53  if (await $.store.get(key)) return
54  await $.store.set(key, true)
55  const r = resetsIn(f, await $.clock.now())
56  $.ui.toast(`5-hour window at ${Math.round(f.percentUsed)}%${r ? ` · resets in ${r}` : ''}`, { timeoutMs: 8000 })
57}
58
59// Before a session's first reply there is no reading: show the last one, minus
60// any window that has reset since (its old % would be wrong).
61const restore = async ($: EngineInterface) => {
62  const saved = (await $.store.get('last')) as MeterLimit[] | undefined
63  if (!Array.isArray(saved)) return
64  const now = await $.clock.now()
65  const live = saved.filter(l => !l.resetsAt || Date.parse(l.resetsAt) > now)
66  if (live.length > 0) {
67    await update($, limitsRef, () => live)
68    await showPlain($)
69  }
70}
71
72const take = async ($: EngineInterface, fresh: readonly SessionRateLimit[]) => {
73  if (fresh.length === 0) return
74  const list: MeterLimit[] = fresh.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, ...(l.resetsAt ? { resetsAt: l.resetsAt } : {}) }))
75  await update($, limitsRef, () => list)
76  // The windows are the account's, not the session's: the next session starts from this.
77  await $.store.set('last', list)
78  await showPlain($)
79  await warn($)
80}
81
82export const register: Register = on => {
83  on('session.start', async ($, e, next) => {
84    const started = await next(e)
85    const fresh = (await $.session.usage()).rateLimits
86    if (fresh.length > 0) await take($, fresh)
87    else await restore($)
88    // Keep "resets in" current, and drop the plain line once the footer draws the bars.
89    $.clock.every(60_000, () => $.ui.invalidate('ui.render'))
90    $.clock.every(5_000, () => void showPlain($))
91    // Old warning keys pile up otherwise.
92    const keys = (await $.store.keys()).filter(k => k.startsWith('warned:'))
93    for (const k of keys.slice(0, Math.max(0, keys.length - 20))) await $.store.delete(k)
94    return started
95  })
96
97  on('session.measure', async ($, e, next) => {
98    if (e.changed.includes('rateLimits')) await take($, e.rateLimits)
99    return next(e)
100  })
101
102  // The footer's mode slot: the engine's own labels (if any), then the meter.
103  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
104    const list = await read($, limitsRef)
105    const f = five(list)
106    const w = week(list)
107    if (!f && !w) return next(e)
108    footerSeen = true
109    const now = await $.clock.now()
110    const { Box, Text } = $.ui.resolve(e)
111    const r = e.surface === 'terminal' ? resetsIn(f, now) : null
112    // One window: its grey label, its % in the color of how full it is.
113    const part = (key: string, label: string, l: MeterLimit) => (
114      <Box key={key} flexDirection="row" gap={1}>
115        <Text dimColor>{label}</Text>
116        <Text color={level(l.percentUsed)}>{`${Math.round(l.percentUsed)}%`}</Text>
117      </Box>
118    )
119    return (
120      <Box flexDirection="row" gap={1}>
121        {e.props.modes.length > 0 && <Text dimColor>{`${e.props.modes.join(' & ')} ·`}</Text>}
122        {f && part('meter:5h', '5h', f)}
123        {r && <Text dimColor>{`⟳${r}`}</Text>}
124        {f && w && <Text dimColor>·</Text>}
125        {w && part('meter:wk', 'wk', w)}
126      </Box>
127    )
128  })
129
130  // Before a wave of subagents or a workflow, say so when the window is low:
131  // a toast for the person, and a note Claude reads with the result.
132  // A wave starts many agents at once: one toast per wave, not one per agent.
133  on('tool.call', { tool: ['Agent', 'Workflow'] }, async ($, e, next) => {
134    const ran = await next(e)
135    const f = five(await read($, limitsRef))
136    if (e.agentId !== undefined || !f || f.percentUsed < AGENT_WARN_AT || ran.deny !== undefined) return ran
137    const r = resetsIn(f, await $.clock.now())
138    const line = `5-hour usage window is at ${Math.round(f.percentUsed)}%${r ? `, resets in ${r}` : ''}`
139    const now = await $.clock.now()
140    if (now - lastToastAt > 120_000) {
141      lastToastAt = now
142      $.ui.toast(`${line}. Parallel agents may hit the limit.`, { timeoutMs: 8000 })
143    }
144    return { ...ran, context: [...(ran.context ?? []), `[usage-meter] The person's ${line}. Prefer fewer parallel agents until it resets.`] }
145  })
146}
147
types/index.d.ts 9 lines
1/** One rate-limit window as the meter draws it (five_hour, seven_day, ...). */
2export type MeterLimit = { kind: string; percentUsed: number; resetsAt?: string }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'usage-meter': { limits: MeterLimit[] }
7  }
8}
9