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

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:
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).5h 45% ⟳2h59m · wk 24%) and in the 75% and 90% toasts.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.
hooks/register.tsx 147 lines1import { 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}
147types/index.d.ts 9 lines1/** 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