SLOPSHOPPER

usage-limits

Keeps the 5-hour and weekly limit windows, their burn rate, a run-out projection, context fill, session cost and prompt-cache drops in a dim line above the…

newbandtoaststatustimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-limits
› 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 ⟨Claude Code's own drawing⟩ 5h 31% (resets NaNm) · ctx 49% · $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ 5h 31% (resets NaNm) · ctx 49% · $0.42
README

usage-limits

Shows your 5-hour and weekly usage windows in a line above the Claude Code prompt: how much you've used, how fast it's climbing, and when you'll run out if that comes before the reset. It also counts prompt-cache drops.

⚠ 5h 41% +38%/h out in 1h33m · 7d 18% +6%/d (resets 4d2h) · ctx 22% · $14.10 · cache drops 3 (1.9M)

The line is dim. Only a window marked ⚠, one that runs out before it resets at the current pace, is drawn in the warning color. The line sits in the band above the prompt, alongside any other plugin's line there; collapse the band with its [-] or ctrl+x ctrl+a. In the VS Code extension and on a phone, which have no band, the line falls back to the plugin status line. That status shows everywhere, in its usual yellow, while such a surface is attached.

cache drops 3 (1.9M) appears after the first drop: 3 requests this session had to rebuild their prompt cache, 1.9M tokens rewritten in all. A request counts when it isn't that agent's first, its context is at least 100K, at least half of the context was written to the cache afresh, and more than 4 minutes passed since the same agent's previous request. That pattern means the cache expired, usually over a long wait; subagents get a 5-minute cache by default. The main thread, subagents and in-process teammates all count. The count starts over on /clear and when the plugin reloads.

SettingDefaultWhat it does
warnAtPercent90Toasts once when a window passes this percentage.
rateWindowMinutes30Sets how far back the 5-hour burn rate looks (10 to 300).

The main README covers installing, reading the line, and what it can't see.

Source 1 files
hooks/register.tsx 299 lines
1import type { EngineInterface, ModelUsage, Register, SessionRateLimit, SessionUsage } from 'claude-code'
2
3const HOUR = 3_600_000
4const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
5// a rolling rate needs at least this much history before it is shown
6const MIN_SPAN_MS = 5 * 60_000
7// the weekly pace is the window's own average, once it has run this long
8const MIN_WEEK_ELAPSED_MS = 6 * HOUR
9const WEEK_MS = 7 * 24 * HOUR
10// a resetsAt that moves less than this is the same window, reported again
11const SAME_WINDOW_MS = 10 * 60_000
12// a request is a cache drop when it rewrites at least this share of a context at least
13// this big, longer than this after the same agent's previous request
14const DROP_SHARE = 0.5
15const DROP_MIN_CONTEXT = 100_000
16const DROP_MIN_GAP_MS = 4 * 60_000
17// the surfaces that raise the AbovePrompt band; any other gets the line as a status
18const BAND_SURFACES: readonly string[] = ['terminal', 'desktop']
19
20export type Sample = { t: number; p: number }
21export type History = { resetsAt?: string; samples: Sample[] }
22
23// "2h14m" / "3d4h" / "12m"
24export function duration(ms: number): string {
25  const mins = Math.max(0, Math.round(ms / 60000))
26  const d = Math.floor(mins / 1440)
27  const h = Math.floor((mins % 1440) / 60)
28  const m = mins % 60
29  return d > 0 ? `${d}d${h}h` : h > 0 ? `${h}h${m}m` : `${m}m`
30}
31
32function sameWindow(a: string | undefined, b: string | undefined): boolean {
33  if (a === undefined || b === undefined) return a === b
34  return Math.abs(Date.parse(a) - Date.parse(b)) < SAME_WINDOW_MS
35}
36
37// adds a reading to a window's history; a new window (its reset time moved) starts afresh
38export function record(prev: History | undefined, limit: SessionRateLimit, now: number, keepMs: number): History {
39  const kept = prev !== undefined && sameWindow(prev.resetsAt, limit.resetsAt) ? prev.samples : []
40  const samples = [...kept, { t: now, p: limit.percentUsed }].filter(s => now - s.t <= keepMs)
41  return { resetsAt: limit.resetsAt, samples }
42}
43
44// percentage points per hour across the samples; undefined while the history is too short
45export function burnRate(samples: readonly Sample[]): number | undefined {
46  const first = samples[0]
47  const last = samples[samples.length - 1]
48  if (first === undefined || last === undefined || last.t - first.t < MIN_SPAN_MS) return undefined
49  return ((last.p - first.p) / (last.t - first.t)) * HOUR
50}
51
52// the pace a window is judged by, in points per hour: the weekly window by its own average
53// so far (nights and weekends included), the others by the recent rolling rate
54export function pace(limit: SessionRateLimit, samples: readonly Sample[], now: number): number | undefined {
55  if (limit.kind === 'seven_day') {
56    if (limit.resetsAt === undefined) return undefined
57    const elapsed = now - (Date.parse(limit.resetsAt) - WEEK_MS)
58    return elapsed >= MIN_WEEK_ELAPSED_MS ? (limit.percentUsed / elapsed) * HOUR : undefined
59  }
60  return burnRate(samples)
61}
62
63// "+38%/h", or per day for the weekly window; undefined when too small to matter
64export function rateText(kind: string, rate: number | undefined): string | undefined {
65  if (rate === undefined) return undefined
66  if (kind === 'seven_day') return rate * 24 >= 1 ? `+${Math.round(rate * 24)}%/d` : undefined
67  return rate >= 1 ? `+${Math.round(rate)}%/h` : undefined
68}
69
70// how long until the window hits 100% at this pace, when that comes before it resets
71export function runsOutIn(limit: SessionRateLimit, rate: number | undefined, now: number): number | undefined {
72  if (rateText(limit.kind, rate) === undefined || limit.percentUsed >= 100) return undefined
73  const outIn = ((100 - limit.percentUsed) / rate!) * HOUR
74  const resetIn = limit.resetsAt === undefined ? Infinity : Date.parse(limit.resetsAt) - now
75  return outIn < resetIn ? outIn : undefined
76}
77
78export function hasReset(limit: SessionRateLimit, now: number): boolean {
79  return limit.resetsAt !== undefined && Date.parse(limit.resetsAt) <= now
80}
81
82export function formatLimit(limit: SessionRateLimit, now: number, rate?: number): string {
83  const label = LABELS[limit.kind] ?? limit.kind
84  // readings only arrive with a response, so an idle session's last one can outlive its window
85  if (hasReset(limit, now)) return `${label} reset`
86  let text = `${label} ${Math.round(limit.percentUsed)}%`
87  const shown = rateText(limit.kind, rate)
88  if (shown !== undefined) text += ` ${shown}`
89  const out = runsOutIn(limit, rate, now)
90  if (out !== undefined) return `⚠ ${text} out in ${duration(out)}`
91  return limit.resetsAt === undefined ? text : `${text} (resets ${duration(Date.parse(limit.resetsAt) - now)})`
92}
93
94export type Drops = { count: number; tokens: number }
95
96// 955492 -> 955K, 1900000 -> 1.9M
97export function tokens(n: number): string {
98  return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}K`
99}
100
101// the line's parts, before the cache drops
102export function usageParts(
103  usage: Omit<SessionUsage, 'startedAt'>,
104  now: number,
105  rates: ReadonlyMap<string, number | undefined> = new Map(),
106): string[] {
107  const parts = usage.rateLimits.map(l => formatLimit(l, now, rates.get(l.kind)))
108  if (parts.length === 0) parts.push('limits: no reading yet')
109  if (usage.context.percent !== undefined) parts.push(`ctx ${Math.round(usage.context.percent)}%`)
110  if (usage.cost !== undefined) parts.push(`$${usage.cost.usd.toFixed(2)}`)
111  return parts
112}
113
114// "cache drops 3 (1.9M)" appended, or nothing while there are none
115export function withDrops(parts: readonly string[], drops: Drops): string[] {
116  return drops.count > 0 ? [...parts, `cache drops ${drops.count} (${tokens(drops.tokens)})`] : [...parts]
117}
118
119export function formatUsage(
120  usage: Omit<SessionUsage, 'startedAt'>,
121  now: number,
122  rates: ReadonlyMap<string, number | undefined> = new Map(),
123  drops: Drops = { count: 0, tokens: 0 },
124): string {
125  return withDrops(usageParts(usage, now, rates), drops).join(' · ')
126}
127
128// the tokens a request rebuilt when it is a cache drop, else undefined: not the agent's
129// first request (`prevAt` undefined), more than four minutes since its previous request
130// started, a context of 100K or more, and at least half of it written to the cache afresh
131export function cacheDrop(prevAt: number | undefined, now: number, usage: ModelUsage): number | undefined {
132  if (prevAt === undefined || now - prevAt <= DROP_MIN_GAP_MS) return undefined
133  const context = usage.input_tokens + usage.cache_creation_input_tokens + usage.cache_read_input_tokens
134  if (context < DROP_MIN_CONTEXT || usage.cache_creation_input_tokens < context * DROP_SHARE) return undefined
135  return usage.cache_creation_input_tokens
136}
137
138// the line as runs of text: each part marked ⚠ a warning run of its own, the other
139// parts and the separators merged into dim runs
140export type Run = { text: string; isWarning: boolean }
141export function runs(parts: readonly string[]): Run[] {
142  const out: Run[] = []
143  parts.forEach((part, i) => {
144    const pieces: Run[] = i > 0 ? [{ text: ' · ', isWarning: false }] : []
145    pieces.push({ text: part, isWarning: part.startsWith('⚠') })
146    for (const piece of pieces) {
147      const last = out[out.length - 1]
148      if (last !== undefined && !last.isWarning && !piece.isWarning) last.text += piece.text
149      else out.push({ ...piece })
150    }
151  })
152  return out
153}
154
155// whether no surface the session draws on raises a band (or nothing draws at all, as
156// under -p or an SDK host), so the line goes out as a status instead; a phone or IDE
157// attached beside a terminal goes without, so the terminal keeps its quiet band alone
158export function needsStatus(surfaces: readonly string[]): boolean {
159  return !surfaces.some(s => BAND_SURFACES.includes(s))
160}
161
162// a configured number, or the default when unset or not a number, held within bounds
163export function setting(value: unknown, fallback: number, min: number, max: number): number {
164  const n = Number(value ?? fallback)
165  return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback
166}
167
168// module state, which a reload starts over: the cache drops so far, when each agent's
169// last request started, the line's parts before the drops, and the line the band draws
170let drops: Drops = { count: 0, tokens: 0 }
171const lastStepAt = new Map<string, number>()
172let base: string[] | undefined
173let line: string[] | undefined
174
175// the band draws the line where it is raised; the status carries it where it is not
176async function show($: EngineInterface, parts: string[]) {
177  base = parts
178  const shown = withDrops(parts, drops)
179  if (line === undefined || line.join(' · ') !== shown.join(' · ')) {
180    line = shown
181    $.ui.invalidate('ui.render')
182  }
183  $.ui.status(needsStatus(await $.session.surfaces()) ? shown.join(' · ') : undefined)
184}
185
186async function reshow($: EngineInterface) {
187  if (base !== undefined) await show($, base)
188}
189
190export const register: Register = (on, options) => {
191  const warnAt = setting(options.warnAtPercent, 90, 1, 100)
192  const rateWindowMs = setting(options.rateWindowMinutes, 30, 10, 300) * 60_000
193  const history = new Map<string, History>()
194  // one toast per window per reset per reason
195  const toasted = new Set<string>()
196  const rates = (limits: readonly SessionRateLimit[], now: number) => {
197    const out = new Map<string, number | undefined>()
198    for (const limit of limits) {
199      if (hasReset(limit, now)) {
200        history.delete(limit.kind)
201        continue
202      }
203      const h = record(history.get(limit.kind), limit, now, rateWindowMs)
204      history.set(limit.kind, h)
205      out.set(limit.kind, pace(limit, h.samples, now))
206    }
207    return out
208  }
209
210  on('session.start', async ($, e, next) => {
211    const result = await next(e)
212    const tick = async () => {
213      try {
214        const now = await $.clock.now()
215        const usage = await $.session.usage()
216        await show($, usageParts(usage, now, rates(usage.rateLimits, now)))
217      } catch {
218        // the next tick or measurement draws it again
219      }
220    }
221    await tick()
222    // keep the countdowns and rates moving between turns
223    $.clock.every(60_000, tick)
224    return result
225  })
226
227  on('session.end', { reason: 'clear' }, async ($, e, next) => {
228    drops = { count: 0, tokens: 0 }
229    lastStepAt.clear()
230    await reshow($)
231    return next(e)
232  })
233
234  // every model request of the main loop, a subagent or an in-process teammate
235  on('turn.step', async function* ($, e, next) {
236    const at = await $.clock.now()
237    const result = yield* next(e)
238    // a request that got no response read and wrote no cache
239    if (result.usage === null) return result
240    const id = e.agentId ?? 'main'
241    const rebuilt = cacheDrop(lastStepAt.get(id), at, result.usage)
242    lastStepAt.set(id, at)
243    if (rebuilt !== undefined) {
244      drops = { count: drops.count + 1, tokens: drops.tokens + rebuilt }
245      await reshow($)
246    }
247    return result
248  })
249
250  on('session.measure', async ($, e, next) => {
251    const now = await $.clock.now()
252    const r = rates(e.rateLimits, now)
253    await show($, usageParts(e, now, r))
254    for (const limit of e.rateLimits) {
255      if (hasReset(limit, now)) continue
256      const window = `${limit.kind}:${limit.resetsAt ?? ''}`
257      const label = LABELS[limit.kind] ?? limit.kind
258      const out = runsOutIn(limit, r.get(limit.kind), now)
259      if (out !== undefined && !toasted.has(`${window}:pace`)) {
260        toasted.add(`${window}:pace`)
261        $.ui.toast(`At this pace the ${label} limit runs out in ${duration(out)}, before it resets`)
262      }
263      if (limit.percentUsed >= warnAt && !toasted.has(`${window}:warn`)) {
264        toasted.add(`${window}:warn`)
265        $.ui.toast(`Usage: ${formatLimit(limit, now, r.get(limit.kind))}`)
266      }
267    }
268    return next(e)
269  })
270
271  // a phone or an IDE joining or leaving changes whether the status is needed
272  on('session.attach', async ($, e, next) => {
273    const result = await next(e)
274    await reshow($)
275    return result
276  })
277  on('session.detach', async ($, e, next) => {
278    const result = await next(e)
279    await reshow($)
280    return result
281  })
282
283  // one dim line in the band above the prompt, under what the plugins beneath drew;
284  // only the parts marked ⚠ take the warning color
285  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
286    const below = await next(e)
287    if (e.props.hasSurvey || line === undefined) return below
288    const { Box, Text } = $.ui.resolve(e)
289    return (
290      <Box flexDirection="column">
291        {below}
292        <Text wrap="truncate-end">
293          {runs(line).map(r => (r.isWarning ? <Text color="warning">{r.text}</Text> : <Text dimColor>{r.text}</Text>))}
294        </Text>
295      </Box>
296    )
297  })
298}
299