SLOPSHOPPER

limits

Your 5-hour and weekly limits, with when each resets, in the footer next to the model picker.

newspinnernetworktimer
v0.1.0no licenseupdated 2026-10-25eshin087/claude-code-mods/limits
A shopper browsing a rack in a slop shop
README

Limits

What it does: keeps your 5-hour and weekly limits in the footer next to the model picker, with the time left until each resets, so you never need the usage button.

Where you see it: the footer, right of the prompt:

5h 22%  3h27  ·  w 76%
  • 5h is the 5-hour window and w the weekly one (all models). The words and the dot between the windows are dim grey, the numbers bright (the % bold). Red marks trouble: a % from 85% up, and the 5-hour countdown under 1 hour. The 5-hour countdown shades with the time left: green past 3 hours, orange from 1 to 3 hours, red under 1 hour.
  • 3h27 after the 5h % is how long until that window resets (hours and minutes, no trailing m past an hour; 45m under an hour). The weekly window shows no countdown here, to keep the line short; the pace panel has its reset.
  • It's the same line on the desktop and the terminal; the desktop's footer slot is narrow, so the countdown is kept short and only the 5-hour window has one. The timings mod's 🔸 button follows it on both, and the pace mod's 🔹P in the terminal. Footer text can't be clicked on the desktop, only buttons, and a button's text can't take color, so open the pace panel with /pace there.
  • The desktop app didn't show a picture or an interactive meter in that slot. For the details, type /pace (the pace mod).

Where the numbers come from:

  • The account endpoint the usage button reads, with your login. The engine attaches the login; the mod never sees it.
  • Each reply's own rate-limit headers, in between, which cost nothing.
  • Every open session shares one reading in ~/.claude/mods-data/limits/readings.json, so only one session asks the endpoint at a time, at most every 3 minutes. If the endpoint fails, the file says why (error) and the mod waits 10 minutes before asking again.
  • The same file keeps 8 days of readings, which the pace mod works from.

Not shown: the Fable weekly limit (the usage panel's per-model line).

Cost: no model calls. One small request to the usage endpoint every few minutes, shared by all your sessions.

Source 2 files
hooks/register.tsx 227 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { FooterPart, LimitWindow, LimitsFile, LimitsReading } from '../types'
5
6// Your 5-hour and weekly limits, with when the 5-hour one resets, in the footer
7// next to the model picker, so the usage button is never needed:
8//   5h 22% 3h12  ·  w 76%
9// The words 5h and w are dim grey, the numbers bright, the % bold, and a dim
10// dot sets the two windows apart. A % from 85% up and a countdown under 30
11// minutes turn red. The 5-hour window says how long until it resets; the weekly
12// one shows no countdown (the pace panel has it).
13// Plain text, on one line, alike on the desktop and the terminal: the desktop
14// app draws neither an SVG nor a surface module in that slot.
15// The figures come from the account endpoint the usage button reads (your
16// login rides along, attached by the engine; the mod never sees it), and from
17// each reply's own rate-limit headers in between. Every open session shares
18// one reading in ~/.claude/mods-data/limits/readings.json, so only one asks the
19// endpoint at a time; its short history is what the pace mod works from.
20
21// How often each session looks at the shared reading.
22const TICK_MS = 60_000
23// A reading or a check younger than this stands: no session asks again.
24const FRESH_MS = 3 * 60_000
25// After the endpoint fails, wait this long before asking again.
26const BACKOFF_MS = 10 * 60_000
27// History for the pace mod: 8 days, a reading at most every 2 minutes unless it moved.
28const HISTORY_MS = 8 * 24 * 3_600_000
29const HISTORY_MAX = 4000
30const HISTORY_EVERY_MS = 2 * 60_000
31
32// The words are grey and the numbers keep the bright text color; a % from 85%
33// up and a countdown under 30 minutes turn red.
34const GREY = '#9ca3af'
35const RED = '#f87171'
36// The desktop folds any run of whitespace (even no-break spaces) into one; a braille
37// blank is not whitespace to it, so it keeps the room between a % and its countdown
38// and around the dot between the windows.
39const PAD = '\u2800'
40const HOT_PCT = 85
41const SOON_MS = 30 * 60_000
42// The 5-hour countdown shades with the time left: green past 3 hours (a window
43// just begun), orange from 1 to 3 hours, red under 1 hour (nearly reset).
44const GREEN = '#4ade80'
45const ORANGE = '#fb923c'
46const fiveColor = (msLeft: number | null) => (msLeft === null ? undefined : msLeft > 3 * 3_600_000 ? GREEN : msLeft > 3_600_000 ? ORANGE : RED)
47// The weekly countdown only turns red, under SOON_MS.
48const resetColor = (label: string, msLeft: number | null) => (label === '5h' ? fiveColor(msLeft) : msLeft !== null && msLeft < SOON_MS ? RED : undefined)
49
50const reading = atom({ plugin: 'limits', key: 'reading' } as const, null)
51
52const EMPTY: LimitsFile = { latest: null, history: [], checkedAt: 0, error: null, errorAt: null }
53
54const dataFile = async ($: EngineInterface) => {
55  // CLAUDE_CONFIG_DIR (set when Claude's folder lives on another drive) wins over ~/.claude
56  const dir = ((await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'}/.claude`).replace(/\\/g, '/')
57  return `${dir}/mods-data/limits/readings.json`
58}
59
60const load = async ($: EngineInterface): Promise<LimitsFile> => {
61  try {
62    return { ...EMPTY, ...(JSON.parse(await $.fs.read(await dataFile($))) as Partial<LimitsFile>) }
63  } catch {
64    return { ...EMPTY }
65  }
66}
67
68const save = async ($: EngineInterface, file: LimitsFile) => $.fs.write(await dataFile($), JSON.stringify(file))
69
70// The countdown to a reset: 3h27, 45m.
71// No "in" and no trailing m past an hour: the footer slot is narrow. Resets
72// land a fraction of a second before the minute (03:59:59.7), so it is rounded
73// to the nearest minute.
74const countdownOf = (iso: string | null, now: number) => {
75  const t = iso === null ? NaN : Date.parse(iso)
76  if (!Number.isFinite(t) || t <= now) return null
77  const m = Math.round((t - now) / 60_000)
78  return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
79}
80
81// A window whose reset time has passed has started over.
82const current = (w: LimitWindow | null, now: number): LimitWindow | null => {
83  if (!w) return null
84  const t = w.resetsAt === null ? NaN : Date.parse(w.resetsAt)
85  return Number.isFinite(t) && t <= now ? { pct: 0, resetsAt: null } : w
86}
87
88const partOf = (label: string, w: LimitWindow, reset: string | null, now: number): FooterPart => {
89  const t = w.resetsAt === null ? NaN : Date.parse(w.resetsAt)
90  return { label, pct: Math.round(w.pct), reset, msLeft: Number.isFinite(t) ? t - now : null }
91}
92
93const footerParts = (r: LimitsReading, now: number) => {
94  const five = current(r.five, now)
95  const week = current(r.week, now)
96  return [five && partOf('5h', five, countdownOf(five.resetsAt, now), now), week && partOf('w', week, null, now)].filter(
97    (p): p is FooterPart => p !== null,
98  )
99}
100
101// The endpoint's window: { utilization: 0-100, resets_at: ISO }.
102const windowOf = (raw: unknown): LimitWindow | null => {
103  const w = raw as { utilization?: unknown; resets_at?: unknown } | null | undefined
104  if (!w || typeof w.utilization !== 'number' || !Number.isFinite(w.utilization)) return null
105  return { pct: Math.round(w.utilization * 10) / 10, resetsAt: typeof w.resets_at === 'string' ? w.resets_at : null }
106}
107
108// A reply's rate-limit headers, as the engine reads them.
109const fromHeaders = (list: readonly SessionRateLimit[], at: number, before: LimitsReading | null): LimitsReading | null => {
110  const pick = (kind: string, prev: LimitWindow | null) => {
111    const l = list.find(x => x.kind === kind)
112    return l ? { pct: l.percentUsed, resetsAt: l.resetsAt ?? prev?.resetsAt ?? null } : prev
113  }
114  const five = pick('five_hour', before?.five ?? null)
115  const week = pick('seven_day', before?.week ?? null)
116  return five || week ? { at, five, week } : null
117}
118
119const isSame = (a: LimitWindow | null, b: LimitWindow | null) => a?.pct === b?.pct && a?.resetsAt === b?.resetsAt
120
121const show = async ($: EngineInterface, r: LimitsReading | null) => {
122  const cur = await read($, reading)
123  if (r && (!cur || cur.at !== r.at || !isSame(cur.five, r.five) || !isSame(cur.week, r.week))) await update($, reading, () => r)
124}
125
126// Saves a new reading for every session (and the pace mod's history), then shows it.
127const record = async ($: EngineInterface, r: LimitsReading, file: LimitsFile, error: string | null) => {
128  const last = file.history.at(-1)
129  const hasMoved = !last || !isSame(last.five, r.five) || !isSame(last.week, r.week)
130  const history = hasMoved || r.at - last.at >= HISTORY_EVERY_MS ? [...file.history, r] : file.history
131  await save($, {
132    latest: r,
133    history: history.filter(h => h.at >= r.at - HISTORY_MS).slice(-HISTORY_MAX),
134    checkedAt: file.checkedAt,
135    error,
136    errorAt: error === null ? null : file.errorAt,
137  })
138  await show($, r)
139}
140
141// Asks the account endpoint, unless a session did lately or it failed lately.
142const refresh = async ($: EngineInterface) => {
143  const file = await load($)
144  const now = await $.clock.now()
145  const isFresh = file.latest !== null && now - file.latest.at < FRESH_MS
146  const isRecent = now - file.checkedAt < FRESH_MS
147  const isBackingOff = file.errorAt !== null && now - file.errorAt < BACKOFF_MS
148  if (isFresh || isRecent || isBackingOff) return show($, file.latest)
149  // Claimed first, so the other sessions leave this check to this one.
150  await save($, { ...file, checkedAt: now })
151  const fail = async (error: string) => {
152    await save($, { ...file, checkedAt: now, error, errorAt: now })
153    await show($, file.latest)
154  }
155  const auth = await $.session.authorize().catch(() => null)
156  if (!auth) return fail('no Anthropic login to read the plan limits with')
157  try {
158    const base = ((await $.env.get('ANTHROPIC_BASE_URL')) ?? 'https://api.anthropic.com').replace(/\/+$/, '')
159    const res = await $.http.fetch(`${base}/api/oauth/usage`, { auth: auth.handle, headers: { 'anthropic-beta': 'oauth-2025-04-20' } })
160    if (!res.ok) return fail(`the usage endpoint answered ${res.status}`)
161    const body = JSON.parse(res.text) as Record<string, unknown>
162    const fresh = { at: now, five: windowOf(body.five_hour), week: windowOf(body.seven_day) }
163    if (!fresh.five && !fresh.week) return fail('the usage endpoint named no 5-hour or weekly window')
164    await record($, fresh, { ...file, checkedAt: now }, null)
165  } catch (err) {
166    await fail(`the usage endpoint could not be read: ${err instanceof Error ? err.message : String(err)}`)
167  }
168}
169
170export const register: Register = on => {
171  on('session.start', async ($, e, next) => {
172    const started = await next(e)
173    const file = await load($)
174    await show($, file.latest)
175    // This session's own reading, when it has had a reply already (a resumed one).
176    const own = fromHeaders((await $.session.usage()).rateLimits, await $.clock.now(), file.latest)
177    if (own) await record($, own, file, file.error)
178    void refresh($).catch(() => undefined)
179    // Another session's reading, a due check, and a reset time passing all show within a minute.
180    $.clock.every(TICK_MS, () => {
181      void refresh($).catch(() => undefined)
182      $.ui.invalidate('ui.render')
183    })
184    return started
185  })
186
187  // Each reply's headers: free, and fresher than the last check.
188  on('session.measure', async ($, e, next) => {
189    if (e.changed.includes('rateLimits')) {
190      const file = await load($)
191      const r = fromHeaders(e.rateLimits, await $.clock.now(), file.latest)
192      if (r) await record($, r, file, file.error)
193    }
194    return next(e)
195  })
196
197  // The footer's mode slot, right of the prompt by the model picker.
198  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
199    const r = await read($, reading)
200    const parts = r ? footerParts(r, await $.clock.now()) : []
201    if (parts.length === 0) return next(e)
202    const below = await next(e)
203    const { Box, Text } = $.ui.resolve(e)
204    // The same on the terminal and the desktop. The desktop folds any run of
205    // spaces (even no-break ones) into one and ignores a Box's gap, so the
206    // windows are set apart by a dim dot, not by spaces. Then whatever the mods
207    // beneath add (the timings button, the pace P).
208    return (
209      <Box flexDirection="row" gap={1}>
210        <Box key="limits:line" flexShrink={0}>
211          <Text wrap="truncate-end">
212            {parts.flatMap((p, i) => [
213              ...(i > 0 ? [<Text key={`limits:${p.label}:dot`} color={GREY}>{` ${PAD}·${PAD} `}</Text>] : []),
214              <Text key={`limits:${p.label}:label`} color={GREY}>{`${p.label} `}</Text>,
215              <Text key={`limits:${p.label}:pct`} bold {...(p.pct >= HOT_PCT ? { color: RED } : {})}>{`${p.pct}%`}</Text>,
216              ...(p.reset !== null
217                ? [<Text key={`limits:${p.label}:reset`} {...(resetColor(p.label, p.msLeft) ? { color: resetColor(p.label, p.msLeft) } : {})}>{`${PAD}${p.reset}`}</Text>]
218                : []),
219            ])}
220          </Text>
221        </Box>
222        {below}
223      </Box>
224    )
225  })
226}
227
types/index.d.ts 30 lines
1/** One usage window: how full it is (0-100) and when it resets (ISO 8601), as the account reports it. */
2export type LimitWindow = { pct: number; resetsAt: string | null }
3
4/** The account's 5-hour and weekly windows at one moment (`at`, ms). */
5export type LimitsReading = { at: number; five: LimitWindow | null; week: LimitWindow | null }
6
7/**
8 * What every open session shares in ~/.claude/mods-data/limits/readings.json:
9 * the latest reading, a short history (the pace mod's input), and when the
10 * account endpoint was last asked and what went wrong, if anything.
11 */
12export type LimitsFile = {
13  latest: LimitsReading | null
14  history: LimitsReading[]
15  checkedAt: number
16  error: string | null
17  errorAt: number | null
18}
19
20/** One window as the footer draws it; `reset` the countdown to its reset ("1h17"), null when it isn't shown; `msLeft` the time to the reset in ms, shown or not. */
21export type FooterPart = { label: string; pct: number; reset: string | null; msLeft: number | null }
22
23declare module 'claude-code' {
24  interface PluginState {
25    limits: {
26      reading: LimitsReading | null
27    }
28  }
29}
30