SLOPSHOPPER

quota-meter

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…

newpanecommandstatustimer
★ 4v0.1.0MITupdated 2026-09-14Arunjay4213/claude-mods/plugins/quota-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · quota-meter
│ ┃ Quota ✕ › fix the failing auth test and add an audit log call │ ┃ 5-hour limit · 31% used │ ┃ █████████████████░░░░░░░░░░░░░░░░░░░░░░░░… ⏺ Read(src/auth.ts) │ ┃ no reset time reported ⎿ Read 6 lines │ ┃ burn rate: collecting (needs samples over… ⏺ Update(src/auth.ts) │ ┃ projection: collecting ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Sampled after each turn and every 60s. /q… ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /quota │ ⎿ quota-meter: quota pane open. /quota closes it. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ quota-meter: 5h 31% · collecting burn rate

Draws

Pane · Quota
5-hour limit · 31% used █████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ no reset time reported burn rate: collecting (needs samples over 15m) projection: collecting Sampled after each turn and every 60s. /quota closes this…
README

quota-meter

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.

What it shows

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.

Install

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

Screenshot

The /quota pane and the pinned quota line under the prompt

How the projection is computed

$.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.

  • The burn rate is a first-to-last slope, not a least-squares fit: (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.
  • A rate is only quoted once the readings span 5% of the window: 15 minutes for the 5-hour window, about 8.4 hours for the 7-day one (and for a spend limit, whose readings are kept on the same 7-day clock). Below that the mod says 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.
  • The projection is the remaining percent divided by that rate: (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.
  • Readings are dropped when they stop being about this window. Anything older than the window length goes, and a percent that fell by more than half a point means the window reset, so every reading before it is discarded. Readings persist in $.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.

Limitations

  • API-key sessions report no limits. 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.
  • The first reading needs a turn. At session start the last response is the previous session's, or there is none, so the line may say no plan limits reported yet until Claude answers once.
  • The 7-day window takes 8.4 hours to say anything. That is the point at which its whole-percent readings stop being noise, so a fresh session shows 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.
  • One pane at a time. /quota toggles: the second run closes it, as /diff does.

Layout

.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 them
Source 2 files
hooks/register.tsx 203 lines
1/* @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}
203
hooks/quota.ts 253 lines
1import 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