Shows the subscription plan limits - the 5-hour window, the 7-day window and a gateway spend limit - as a pinned status line under the prompt, with a reset…

Your Claude subscription has usage windows: a 5-hour one, a 7-day one, and on a Claude gateway a spend limit. Claude Code reports how full each one is, but only inside a notice that appears when you are already close. quota-meter keeps that reading on screen the whole session, adds a countdown to the reset, and works out how long the current pace can keep going.
A pinned line under the prompt, from the moment the session starts:
5h 15% (resets 1h23m) · 7d 6% · nothing hits 100% before reset
The tail is the useful part. It is one of three things:
5h full in ~2h05m - at the recent pace that window fills before it resets.nothing hits 100% before reset - every window resets before the pace could fill it.collecting burn rate - no window has been watched long enough yet to have a rate worth quoting. A window that is still collecting is simply left out of the tail; it never hides a window that does have a rate.When the account reports no windows at all the line reads no plan limits reported yet. That is the normal state for an API-key session and for the first few seconds of any session, before Claude has answered once.
A pane, opened and closed with /quota, one block per window:
5-hour limit · 15% used
██████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
resets 15:00, in 1h23m
burn 24.4%/h over the last 2m
projection: resets first
7-day limit · 6% used
████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
resets Sun 02:00, in 5d 12h
burn 0.0%/h over the last 2m
projection: steady, not rising
The bar is drawn to the pane's own width, so it fits whether the pane is docked beside the transcript (110 columns and up, in the fullscreen layout) or sitting inline above the prompt on a narrow terminal. The reset is given as a clock time and as a countdown, because one of the two is always the one you wanted.
claude plugin marketplace add Arunjay4213/claude-mods
claude plugin install quota-meter@claude-mods
Mods are early access, so the hooks module only loads when function hooks are switched on. Add this to ~/.claude/settings.json:
{
"env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" }
}
or set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 on the command line for one run.
To try it straight from a checkout, without installing:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir plugins/quota-meter

$.session.usage() reports the windows the last API response carried, each as { kind, percentUsed, resetsAt }. The mod reads it after every finished turn and on a 60 second tick, and keeps each reading as { atMs, percentUsed } in $.store.
(last percent - first percent) / (last time - first time), expressed as percent per hour. A straight slope is the right shape here because the readings inside one window are a step function that only rises, so a fit through the middle of it would understate the current position.collecting in both surfaces rather than showing a number it does not have. The reason for the wait is that the API reports usage as a whole percent. A 7-day window that ticks from 6% to 7% during a 16 minute span reads as 3.8% per hour, which projects a full window in a day; the same single tick spread over 8.4 hours reads as the small movement it actually was. Percent values are rounded to whole numbers before anything is computed from them, because the API sometimes reports 7.000000000000001.(100 - percentUsed) / burn. If the window resets before that time arrives, the mod says resets first instead of a time you will never reach. If the rate is zero or falling, it says steady, not rising.$.store, so restarting Claude Code inside the same window keeps the rate you had built up.Nothing here is estimated or filled in. If a figure is missing from the API it is reported as missing.
rateLimits is empty off a subscription, so the meter shows no plan limits reported yet and the pane says the same. There is nothing to read.no plan limits reported yet until Claude answers once.collecting for it and projects only from the 5-hour window. The pane always prints the span a rate was measured over, so you can see how much to trust it.spend_limit has no fixed length, so its readings are kept for seven days, and it can read above 100% once the limit is passed. The bar stops at full; the percent does not./quota toggles: the second run closes it, as /diff does..claude-plugin/plugin.json name, version, description
hooks/hooks.json names the module
hooks/register.tsx the hooks: sampling, the status line, the command, the pane
hooks/quota.ts the numbers and the wording, with no engine calls in themhooks/register.tsx 203 lines1/* @jsx h */
2import type { EngineInterface, Register, SessionRateLimit, Timer } from 'claude-code'
3
4import {
5 addSample,
6 barOf,
7 colorOf,
8 detailOf,
9 NO_LIMITS_STATUS,
10 paneRowsOf,
11 rowsOf,
12 samplesOf,
13 statusOf,
14 windowMsOf,
15 type Samples,
16} from './quota'
17
18// quota-meter: the plan's rate-limit windows as a pinned status line, and
19// `/quota` for a pane with a bar, the reset clock and the burn rate per window.
20//
21// `$.session.usage()` reports the windows the last API response carried, so the
22// readings are sampled after every turn and on a 60 second tick (the tick also
23// keeps the countdown moving while the session sits idle). The readings live in
24// `$.store`, so the burn rate survives a restart inside the same window.
25
26const PANE_ID = 'quota'
27const PANE_TITLE = 'Quota'
28const COMMAND = 'quota'
29const STORE_KEY = 'samples'
30const TICK_MS = 60_000
31
32let limits: SessionRateLimit[] = []
33let samples: Samples = {}
34let isPaneOpen = false
35let tick: Timer | null = null
36
37/** Runs `work`, and on a failure keeps whatever the meter already had. */
38const quietly = async <T,>(work: () => Promise<T>): Promise<T | null> => {
39 try {
40 return await work()
41 } catch {
42 return null
43 }
44}
45
46/** Reads the windows, keeps the reading, and repaints both surfaces. */
47async function sample($: EngineInterface): Promise<void> {
48 const usage = await quietly(() => $.session.usage())
49 if (usage) limits = usage.rateLimits ?? []
50
51 const nowMs = await quietly(() => $.clock.now())
52 if (nowMs !== null && limits.length > 0) {
53 for (const limit of limits) {
54 if (!Number.isFinite(limit.percentUsed)) continue
55 samples[limit.kind] = addSample(
56 samples[limit.kind] ?? [],
57 // whole percent is all the API means, and all the store should hold
58 { atMs: nowMs, percentUsed: Math.round(limit.percentUsed), resetsAt: limit.resetsAt },
59 windowMsOf(limit.kind),
60 )
61 }
62 await quietly(() => $.store.set(STORE_KEY, samples))
63 }
64
65 paint($, nowMs ?? Date.now())
66}
67
68/** Pins the status line, and asks the pane for a redraw while it is open. */
69function paint($: EngineInterface, nowMs: number): void {
70 try {
71 $.ui.status(limits.length === 0 ? NO_LIMITS_STATUS : statusOf(rowsOf(limits, samples, nowMs)))
72 } catch {
73 // the status line is a courtesy, never a reason to fail
74 }
75 if (!isPaneOpen) return
76 try {
77 $.ui.invalidate('ui.render')
78 } catch {
79 // the next tick redraws
80 }
81}
82
83export const register: Register = on => {
84 on('session.start', async ($, e, next) => {
85 const result = await next(e)
86
87 samples = samplesOf(await quietly(() => $.store.get(STORE_KEY)))
88
89 await quietly(() =>
90 $.command.register({
91 name: COMMAND,
92 description: 'Plan limit windows: percent used, reset countdown and burn rate (quota-meter)',
93 }),
94 )
95
96 await sample($)
97
98 try {
99 tick?.cancel()
100 tick = $.clock.every(TICK_MS, () => {
101 void sample($)
102 })
103 } catch {
104 // without the tick the meter still updates after every turn
105 }
106
107 return result
108 })
109
110 on('turn.complete', async ($, e, next) => {
111 const result = await next(e)
112 void sample($)
113 return result
114 })
115
116 on('command.run', { command: COMMAND }, async ($, e, next) => {
117 if (isPaneOpen) {
118 await quietly(() => $.ui.close({ id: PANE_ID }))
119 isPaneOpen = false
120 return { text: 'quota pane closed' }
121 }
122
123 const opened = await quietly(() =>
124 $.ui.open({
125 id: PANE_ID,
126 title: PANE_TITLE,
127 rows: paneRowsOf(Math.max(1, limits.length)),
128 }).then(() => true),
129 )
130
131 if (opened === null) return { text: 'quota-meter: the pane did not open' }
132
133 isPaneOpen = true
134 void sample($)
135 return { text: 'quota pane open. /quota closes it.' }
136 })
137
138 on('ui.close', { id: PANE_ID }, async ($, e, next) => {
139 const result = await next(e)
140 if (result.deny === undefined) isPaneOpen = false
141 return result
142 })
143
144 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
145 if (e.requestId !== PANE_ID) return next(e)
146
147 // a reload of the module loses the flag while the pane stays open; the pane
148 // drawing itself is the proof that it is, so `/quota` still toggles
149 isPaneOpen = true
150
151 const { Box, Text } = await $.ui.resolve(e)
152 const nowMs = (await quietly(() => $.clock.now())) ?? Date.now()
153 const rows = rowsOf(limits, samples, nowMs)
154
155 // the bar fills the pane's body, less the one column of padding each side
156 const cells = Math.max(8, e.props.bodyColumns - 2)
157
158 if (rows.length === 0) {
159 return (
160 <Box flexDirection="column" paddingX={1}>
161 <Text bold>Quota</Text>
162 <Text dimColor wrap="truncate-end">
163 No plan limits reported yet.
164 </Text>
165 <Text dimColor wrap="truncate-end">
166 Subscription sessions report them once Claude has answered; API-key sessions never do.
167 </Text>
168 </Box>
169 )
170 }
171
172 return (
173 <Box flexDirection="column" paddingX={1}>
174 {rows.map(row => {
175 const [reset, burn, projection] = detailOf(row)
176 return (
177 <Box flexDirection="column" marginBottom={1}>
178 <Text bold wrap="truncate-end">
179 {`${row.title} · ${row.percentUsed}% used`}
180 </Text>
181 <Text color={colorOf(row.percentUsed)} wrap="truncate-end">
182 {barOf(row.percentUsed, cells)}
183 </Text>
184 <Text dimColor wrap="truncate-end">
185 {reset}
186 </Text>
187 <Text dimColor wrap="truncate-end">
188 {burn}
189 </Text>
190 <Text dimColor wrap="truncate-end">
191 {projection}
192 </Text>
193 </Box>
194 )
195 })}
196 <Text dimColor wrap="truncate-end">
197 Sampled after each turn and every 60s. /quota closes this pane.
198 </Text>
199 </Box>
200 )
201 })
202}
203hooks/quota.ts 253 lines1import type { SessionRateLimit } from 'claude-code'
2
3// The numbers behind the meter: keeping samples, the burn rate over them, and
4// the text both surfaces draw. Nothing here touches `$`, so a failure in the
5// engine can never reach it.
6
7/** One reading of one window: when it was taken and what it read. */
8export type Sample = { atMs: number; percentUsed: number; resetsAt?: string }
9
10/** Every window's kept readings, by `kind` (`five_hour`, `seven_day`, ...). */
11export type Samples = Record<string, Sample[]>
12
13const HOUR_MS = 60 * 60 * 1000
14const DAY_MS = 24 * HOUR_MS
15
16/** How long a window lasts, so samples from before it can be dropped. */
17export const windowMsOf = (kind: string): number =>
18 kind === 'five_hour' ? 5 * HOUR_MS : 7 * DAY_MS
19
20const LONG_LABEL: Record<string, string> = {
21 five_hour: '5-hour limit',
22 seven_day: '7-day limit',
23 spend_limit: 'spend limit',
24}
25
26const SHORT_LABEL: Record<string, string> = {
27 five_hour: '5h',
28 seven_day: '7d',
29 spend_limit: 'spend',
30}
31
32const ORDER = ['five_hour', 'seven_day', 'spend_limit']
33
34/** Readings older than this many per window are dropped, oldest first. */
35const MAX_SAMPLES = 240
36
37/**
38 * A slope needs the samples to span this much of the window before it means
39 * anything. The percent is reported as a whole number, so a single point of
40 * movement over a few minutes reads as a huge rate per hour; a fifth of an
41 * hour into the 5-hour window, or 8.4 hours into the 7-day one, is enough for
42 * one tick to be a small part of the span instead of all of it.
43 */
44const PROJECTION_SPAN_FRACTION = 0.05
45
46/** A percent that fell by more than this says the window reset. */
47const RESET_DROP = 0.5
48
49/** Reads back what the store holds, dropping anything that is not a sample. */
50export function samplesOf(stored: unknown): Samples {
51 if (typeof stored !== 'object' || stored === null) return {}
52 const out: Samples = {}
53 for (const [kind, rows] of Object.entries(stored as Record<string, unknown>)) {
54 if (!Array.isArray(rows)) continue
55 const kept = rows.filter(
56 (row): row is Sample =>
57 typeof row === 'object' &&
58 row !== null &&
59 Number.isFinite((row as Sample).atMs) &&
60 Number.isFinite((row as Sample).percentUsed) &&
61 ((row as Sample).resetsAt === undefined || typeof (row as Sample).resetsAt === 'string'),
62 )
63 if (kept.length > 0) out[kind] = kept
64 }
65 return out
66}
67
68/**
69 * Adds a reading to a window's kept samples. A percent that dropped means the
70 * window reset, so the older samples belong to a window that is gone; samples
71 * from before the current window started are dropped too.
72 */
73export function addSample(
74 kept: readonly Sample[],
75 next: Sample,
76 windowMs: number,
77): Sample[] {
78 const last = kept[kept.length - 1]
79 // Two signs of a new window: the reset time moved, or the percent fell. The
80 // first catches a reset that happened while Claude Code was closed and the
81 // new window already reads higher than the old one did.
82 const isReset =
83 last !== undefined &&
84 ((last.resetsAt !== undefined && next.resetsAt !== undefined && last.resetsAt !== next.resetsAt) ||
85 next.percentUsed < last.percentUsed - RESET_DROP)
86 const floor = next.atMs - windowMs
87 const fresh = isReset ? [] : kept.filter(s => s.atMs >= floor && s.atMs < next.atMs)
88 const rows = [...fresh, next]
89 return rows.slice(Math.max(0, rows.length - MAX_SAMPLES))
90}
91
92/** The burn rate: the straight first-to-last slope over the kept samples. */
93export type Burn = { percentPerHour: number; spanMs: number }
94
95export function burnOf(samples: readonly Sample[], windowMs: number): Burn | null {
96 const first = samples[0]
97 const last = samples[samples.length - 1]
98 if (first === undefined || last === undefined || samples.length < 2) return null
99 const spanMs = last.atMs - first.atMs
100 if (spanMs < windowMs * PROJECTION_SPAN_FRACTION) return null
101 return {
102 percentPerHour: ((last.percentUsed - first.percentUsed) / spanMs) * HOUR_MS,
103 spanMs,
104 }
105}
106
107/** One window, ready to draw. */
108export type Row = {
109 kind: string
110 title: string
111 short: string
112 percentUsed: number
113 resetMs: number | null
114 untilResetMs: number | null
115 burn: Burn | null
116 fullInMs: number | null
117}
118
119const parsedMs = (iso: string | undefined): number | null => {
120 if (typeof iso !== 'string') return null
121 const ms = Date.parse(iso)
122 return Number.isFinite(ms) ? ms : null
123}
124
125export function rowsOf(
126 limits: readonly SessionRateLimit[],
127 samples: Samples,
128 nowMs: number,
129): Row[] {
130 const rows = limits.map((limit): Row => {
131 // the API reports whole percent, but not always exactly (7.000000000000001)
132 const percentUsed = Math.round(limit.percentUsed)
133 const burn = burnOf(samples[limit.kind] ?? [], windowMsOf(limit.kind))
134 const resetMs = parsedMs(limit.resetsAt)
135 const left = 100 - percentUsed
136 return {
137 kind: limit.kind,
138 title: LONG_LABEL[limit.kind] ?? limit.kind,
139 short: SHORT_LABEL[limit.kind] ?? limit.kind,
140 percentUsed,
141 resetMs,
142 untilResetMs: resetMs === null ? null : Math.max(0, resetMs - nowMs),
143 burn,
144 fullInMs:
145 burn === null || burn.percentPerHour <= 0
146 ? null
147 : Math.max(0, (left / burn.percentPerHour) * HOUR_MS),
148 }
149 })
150 const rank = (kind: string) => {
151 const at = ORDER.indexOf(kind)
152 return at === -1 ? ORDER.length : at
153 }
154 return rows.sort((a, b) => rank(a.kind) - rank(b.kind))
155}
156
157/** `1h12m`, `42m`, `2d 3h`: a length of time, never negative. */
158export function durationOf(ms: number): string {
159 const minutes = Math.max(0, Math.round(ms / 60_000))
160 const hours = Math.floor(minutes / 60)
161 if (hours >= 24) return `${Math.floor(hours / 24)}d ${hours % 24}h`
162 if (hours > 0) return `${hours}h${String(minutes % 60).padStart(2, '0')}m`
163 return `${minutes}m`
164}
165
166const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
167
168/** `15:30`, or `Sun 02:00` when the moment is far enough off to need the day. */
169export function clockOf(ms: number, withDay = false): string {
170 const at = new Date(ms)
171 const time = `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
172 return withDay ? `${DAYS[at.getDay()] ?? ''} ${time}`.trim() : time
173}
174
175/** The filled bar for a percent, exactly `cells` characters wide. */
176export function barOf(percent: number, cells: number): string {
177 const width = Math.max(4, Math.floor(cells))
178 const clamped = Math.max(0, Math.min(100, percent))
179 const filled = Math.max(0, Math.min(width, Math.round((clamped / 100) * width)))
180 return '█'.repeat(filled) + '░'.repeat(width - filled)
181}
182
183/** Green while there is room, yellow past half, red near the limit. */
184export const colorOf = (percent: number): string =>
185 percent >= 80 ? 'red' : percent >= 50 ? 'yellow' : 'green'
186
187/** The window that fills first at this pace, if any fills before it resets. */
188function soonestFull(rows: readonly Row[]): Row | null {
189 let best: Row | null = null
190 for (const row of rows) {
191 if (row.fullInMs === null) continue
192 if (row.untilResetMs !== null && row.fullInMs >= row.untilResetMs) continue
193 if (best === null || row.fullInMs < (best.fullInMs ?? Infinity)) best = row
194 }
195 return best
196}
197
198export const NO_LIMITS_STATUS = 'no plan limits reported yet'
199
200const STATUS_MAX = 80
201
202/** The one line pinned under the prompt. */
203export function statusOf(rows: readonly Row[]): string {
204 if (rows.length === 0) return NO_LIMITS_STATUS
205 const parts = rows.map((row, at) => {
206 const percent = `${row.short} ${row.percentUsed}%`
207 const resets =
208 at === 0 && row.untilResetMs !== null
209 ? ` (resets ${durationOf(row.untilResetMs)})`
210 : ''
211 return `${percent}${resets}`
212 })
213 const full = soonestFull(rows)
214 // a window still collecting is left out of the tail rather than hiding the
215 // windows that do have a rate: the 7-day one collects for hours, and the
216 // 5-hour projection is the line's whole point
217 const isCollecting = rows.every(row => row.burn === null)
218 const tail = full
219 ? `${full.short} full in ~${durationOf(full.fullInMs ?? 0)}`
220 : isCollecting
221 ? 'collecting burn rate'
222 : 'nothing hits 100% before reset'
223 const line = [...parts, tail].join(' · ')
224 if (line.length <= STATUS_MAX) return line
225 const short = parts.join(' · ')
226 return short.length <= STATUS_MAX ? short : `${short.slice(0, STATUS_MAX - 1)}…`
227}
228
229/** The three detail lines a pane block draws under its bar. */
230export function detailOf(row: Row): [string, string, string] {
231 const reset =
232 row.resetMs === null
233 ? 'no reset time reported'
234 : `resets ${clockOf(row.resetMs, (row.untilResetMs ?? 0) > DAY_MS - 4 * HOUR_MS)}, in ${durationOf(row.untilResetMs ?? 0)}`
235 const burn =
236 row.burn === null
237 ? `burn rate: collecting (needs samples over ${durationOf(windowMsOf(row.kind) * PROJECTION_SPAN_FRACTION)})`
238 : `burn ${row.burn.percentPerHour.toFixed(1)}%/h over the last ${durationOf(row.burn.spanMs)}`
239 const projection =
240 row.burn === null
241 ? 'projection: collecting'
242 : row.fullInMs === null
243 ? 'projection: steady, not rising'
244 : row.untilResetMs !== null && row.fullInMs >= row.untilResetMs
245 ? 'projection: resets first'
246 : `projection: 100% in ~${durationOf(row.fullInMs)}`
247 return [reset, burn, projection]
248}
249
250/** Rows the pane asks for while it sits inline above the prompt. */
251export const paneRowsOf = (count: number): number =>
252 Math.max(7, Math.min(26, count * 6 + 2))
253