Shows your 5h and 7d limits, and stops deep research from running on expensive models unless your 5h window is about to reset and your weekly usage is low.

Keeps an eye on your Claude Code usage limits, and stops deep research from burning Fable when you can't afford it.
🕤 5h 6% 21:20 | 🗓️ 7d 74% Oct 8 18:00
Deep research on Fable asks first right now. You're on Opus.
✗ 5h resets in 3h 12m (needs under 30 min)
✓ Weekly 74% used (needs under 75%)
Run this in your terminal:
curl -fsSL https://raw.githubusercontent.com/westpoint-io/claude-limits-checker/main/install.sh | bash
Or paste this to Claude Code:
Install the Claude Code plugin from https://github.com/westpoint-io/claude-limits-checker by running
curl -fsSL https://raw.githubusercontent.com/westpoint-io/claude-limits-checker/main/install.sh | bash
Then restart Claude Code and run /limits.
The script installs the limits plugin. If you don't have a status line yet, it also sets up the Catppuccin one. If you already have one, it leaves it alone.
claude plugin marketplace add westpoint-io/claude-limits-checker
claude plugin install limits@claude-limits-checker
For the status line, save statusline.sh somewhere and point statusLine in ~/.claude/settings.json at it:
{ "statusLine": { "type": "command", "command": "bash ~/.claude/limits-statusline.sh" } }
Under the prompt you always see both windows and when they reset. The clock face matches the 5h reset time.
Before a deep research prompt runs on Fable, it checks two things:
If both are true, the prompt runs on Fable as normal. If not, it stops and asks:
This looks like deep research on Fable.
✗ 5h resets in 3h 12m (needs under 30 min)
✓ Weekly 74% used (needs under 75%)
Which model should run it?
1. Sonnet
2. Opus
3. Keep Fable
4. Cancel
Sonnet or Opus switches the model and sends your prompt again. Keep Fable runs it anyway. Cancel drops it.
The same check runs on research commands like the built-in /deep-research. Pick Sonnet or Opus there and it switches the model and runs the command again.
Haiku reads each prompt once to tell deep research apart from everything else. Quick questions, edits and fixes go straight through, and so does anything on a model you don't watch.
| Command | What it does |
|---|---|
/limits | Prints your limits and today's verdict, and opens the settings pane (it also opens by itself when a session starts) |
/limits:settings | Opens just the settings pane |
Open the pane with /limits and change things there. It saves straight away.
| Setting | Default | Meaning |
|---|---|---|
| Models to watch | Fable | Fable, Opus or both |
| 5h window resets within | 30 min | Deep research is allowed only this close to the 5h reset |
| Weekly usage under | 75% | Deep research is allowed only while the week is below this |
| Open on start | On | Opens the settings pane with your limits whenever a session starts |
You can also use /config under limits, or claude plugin configure limits@claude-limits-checker.
jq, git, and a truecolor terminal. gh is optional, for the PR number.claude plugin validate .
claude plugin test .
hooks/rules.ts has the rule and the status text, hooks/register.tsx the prompt check, the dialog, the commands and the settings pane.
MIT, see LICENSE. Free to use and change, at home or at work.
Made by Westpoint. The status line comes from our claude-status-line. Found a bug or want something added? Open an issue, PRs are welcome too.
hooks/register.tsx 331 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptSubmitInput, Register, SessionRateLimit } from 'claude-code'
3
4import { NAMES, WATCHABLE, allows, choicesFor, explain, familyOf, limitsLine, parseModels, readLimits } from './rules'
5import type { Family, Limits, Rule } from './rules'
6
7type $ = EngineInterface
8
9const ALIAS: Record<string, string> = { Sonnet: 'sonnet', Opus: 'opus' }
10const FAMILIES = WATCHABLE
11const PANE = 'limits-settings'
12const MINUTE_STEPS = [10, 15, 20, 30, 45, 60, 90, 120]
13const PERCENT_STEPS = [50, 60, 70, 75, 80, 85, 90, 95]
14
15/** The last thing the settings pane saved, or why it couldn't. */
16const notice = atom({ plugin: 'limits', key: 'notice' } as const, null)
17
18// Set from the user's config each time the module loads.
19let rule: Rule = { resetWithinMinutes: 30, weeklyMaxPercent: 75 }
20let watched: Set<Family> = new Set(['fable'])
21let openOnStart = true
22// The config values this load started from; pane edits saved on top of them stay valid while they match.
23let base = ''
24
25type Saved = { models: string; resetWithinMinutes: number; weeklyMaxPercent: number; openOnStart: boolean; base: string }
26
27const snapshot = () => ({
28 models: FAMILIES.filter(f => watched.has(f)).join(', '),
29 resetWithinMinutes: rule.resetWithinMinutes,
30 weeklyMaxPercent: rule.weeklyMaxPercent,
31 openOnStart,
32})
33
34const currentLimits = async ($: $): Promise<Limits> =>
35 readLimits((await $.session.usage()).rateLimits, await $.clock.now())
36
37const isDeepResearch = async ($: $, text: string): Promise<boolean> => {
38 const trimmed = text.trim()
39 if (trimmed.length < 15 || trimmed.startsWith('/')) return false
40 const label = await $.model.classify(
41 'Is this request to a coding assistant a deep research task? Deep research means open-ended ' +
42 'investigation that reads many sources (web pages, docs, papers or a large part of a codebase) ' +
43 'and writes up findings. Small questions, edits, fixes and builds are "other".\n\nRequest:\n' +
44 trimmed.slice(0, 4000),
45 ['deep-research', 'other'],
46 )
47
48 return label === 'deep-research'
49}
50
51/** Commands that run a research harness, like the built-in /deep-research. */
52const isResearchCommand = (name: string) => /research/i.test(name) && !name.startsWith('autoresearch')
53
54/** The watched model you're on, or undefined when the check doesn't apply. */
55const watchedModel = async ($: $): Promise<Family | undefined> => {
56 const current = familyOf(await $.session.model())
57 return current && watched.has(current) ? current : undefined
58}
59
60/** Applies the rule, asking when it fails: 'run', 'cancel', or the model alias to switch to. */
61const decide = async ($: $, current: Family, what: string): Promise<string> => {
62 const limits = await currentLimits($)
63 if (allows(limits, rule)) return 'run'
64 const answer = await $.ui.ask(`${what} on ${NAMES[current]}.\n${explain(limits, rule).join('\n')}\nWhich model should run it?`, {
65 options: choicesFor(current),
66 header: 'Limits',
67 })
68 if (answer === `Keep ${NAMES[current]}`) return 'run'
69
70 return ALIAS[answer] ?? 'cancel'
71}
72
73/** Switches the model, then runs the same slash command again. */
74const rerun = async ($: $, model: string, command: string, args: string) => {
75 await $.command.run({ command: 'model', args: model })
76 await $.command.run({ command, args })
77}
78
79/** Switches the model, then sends the same prompt again. Runs after the original was dropped. */
80const resend = async ($: $, model: string, e: PromptSubmitInput) => {
81 await $.command.run({ command: 'model', args: model })
82 await $.prompt.submit({ text: e.text, attachments: e.attachments, asUser: true })
83}
84
85/** A dropdown's options, with the current value kept even when it's not a preset. */
86const steps = (presets: number[], current: number, unit: string) =>
87 [...new Set([...presets, current])].sort((a, b) => a - b).map(n => ({ value: String(n), label: `${n}${unit}` }))
88
89/** Applies pane edits saved in the store, unless the config changed since they were made. */
90const loadSaved = async ($: $) => {
91 const saved = (await $.store.get('settings')) as Saved | undefined
92 if (!saved) return
93 if (saved.base !== base) return $.store.delete('settings')
94 watched = parseModels(saved.models)
95 rule = { resetWithinMinutes: saved.resetWithinMinutes, weeklyMaxPercent: saved.weeklyMaxPercent }
96 openOnStart = saved.openOnStart ?? openOnStart
97}
98
99/**
100 * Writes one setting the way /config does, and keeps it in the store too in case this
101 * install has no config row for it. A refusal (an org lock) undoes the change.
102 */
103const save = async ($: $, field: string, value: string | number | boolean, done: string, undo: () => void) => {
104 let deny: string | undefined
105 try {
106 deny = (await $.config.set({ key: `limits.${field}`, value })).deny
107 } catch {
108 // No config row for this field here: the store copy below carries it.
109 }
110 if (deny) {
111 undo()
112 return update($, notice, () => `Couldn't save: ${deny}`)
113 }
114 await $.store.set('settings', { ...snapshot(), base } satisfies Saved)
115 await update($, notice, () => done)
116}
117
118const toggleModel = async ($: $, family: Family) => {
119 const before = watched
120 const next = new Set(watched)
121 if (next.has(family)) next.delete(family)
122 else next.add(family)
123 watched = next
124 const list = FAMILIES.filter(f => next.has(f))
125 const names = list.map(f => NAMES[f]).join(', ') || 'no models'
126 await save($, 'models', list.join(', '), `Saved. Watching ${names}.`, () => (watched = before))
127}
128
129const setMinutes = async ($: $, value: string) => {
130 const before = rule
131 rule = { ...rule, resetWithinMinutes: Number(value) }
132 await save($, 'resetWithinMinutes', Number(value), `Saved. Allowed within ${value} min of the 5h reset.`, () => (rule = before))
133}
134
135const setPercent = async ($: $, value: string) => {
136 const before = rule
137 rule = { ...rule, weeklyMaxPercent: Number(value) }
138 await save($, 'weeklyMaxPercent', Number(value), `Saved. Allowed while weekly usage is under ${value}%.`, () => (rule = before))
139}
140
141const toggleOpenOnStart = async ($: $) => {
142 const before = openOnStart
143 openOnStart = !openOnStart
144 await save(
145 $,
146 'openOnStart',
147 openOnStart,
148 openOnStart ? 'Saved. This panel opens when a session starts.' : 'Saved. This panel stays closed until you run /limits.',
149 () => (openOnStart = before),
150 )
151}
152
153const openSettings = ($: $) => $.ui.open({ id: PANE, title: 'Limits settings', focus: true })
154
155const showLimits = ($: $, rateLimits: readonly SessionRateLimit[]) => $.ui.status(limitsLine(rateLimits))
156
157const report = async ($: $): Promise<string> => {
158 const current = familyOf(await $.session.model())
159 const usage = await $.session.usage()
160 const limits = readLimits(usage.rateLimits, await $.clock.now())
161 const list = FAMILIES.filter(f => watched.has(f))
162 const names = list.map(f => NAMES[f]).join(' and ')
163 const verdict =
164 list.length === 0
165 ? 'No models are watched, so deep research always runs.'
166 : allows(limits, rule)
167 ? `Deep research on ${names} is allowed right now.`
168 : `Deep research on ${names} asks first right now.`
169 const on = current ? ` You're on ${NAMES[current]}.` : ''
170
171 return [limitsLine(usage.rateLimits) ?? 'No usage reading yet.', verdict + on, ...explain(limits, rule)].join('\n')
172}
173
174export const register: Register = (on, options) => {
175 rule = {
176 resetWithinMinutes: Number(options.resetWithinMinutes ?? 30),
177 weeklyMaxPercent: Number(options.weeklyMaxPercent ?? 75),
178 }
179 watched = parseModels(String(options.models ?? 'fable'))
180 openOnStart = options.openOnStart !== false
181 base = JSON.stringify(snapshot())
182
183 on('session.start', async ($, e, next) => {
184 await $.command.register({
185 name: 'limits',
186 description: 'Show your 5h and 7d limits and whether deep research is allowed right now',
187 })
188 await loadSaved($)
189 const ran = await next(e)
190 showLimits($, (await $.session.usage()).rateLimits)
191 if (openOnStart) void openSettings($)
192
193 return ran
194 })
195
196 on('session.measure', async ($, e, next) => {
197 if (e.changed.includes('rateLimits')) showLimits($, e.rateLimits)
198
199 return next(e)
200 })
201
202 on('command.run', { command: 'limits' }, async ($, e) => {
203 if ((e.args ?? '').trim() === 'settings') {
204 await update($, notice, () => null)
205 await openSettings($)
206 return {}
207 }
208
209 await update($, notice, () => null)
210 await openSettings($)
211 return { text: await report($) }
212 })
213
214 // commands/settings.md lists it as /limits:settings; this answers it, so nothing reaches the model.
215 on('command.run', { command: 'limits:settings' }, async $ => {
216 await update($, notice, () => null)
217 await openSettings($)
218 return {}
219 })
220
221 // Draw /limits' answer as its own block, without the plugin-name prefix.
222 on('ui.render', { component: 'CommandOutput', props: { command: 'limits' } }, ($, e, next) => {
223 if (e.props.isErrored || !e.props.text) return next(e)
224 const { Box, Text } = $.ui.resolve(e)
225 // The row's text arrives as `limits: <what we answered>`; drop that prefix.
226 const [head, verdict, ...rest] = e.props.text.replace(/^limits:\s*/, '').split('\n')
227
228 return (
229 <Box flexDirection="column">
230 <Text bold>{head}</Text>
231 <Text>{verdict}</Text>
232 {rest.map((line, i) => (
233 <Text key={`line-${i}`}>{` ${line}`}</Text>
234 ))}
235 </Box>
236 )
237 })
238
239 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
240 const table = $.ui.resolve(e)
241 const { Box, Button, Text } = table
242 const Select = 'Select' in table ? table.Select : undefined
243 const message = await read($, notice)
244 const limits = await currentLimits($)
245 const line = limitsLine((await $.session.usage()).rateLimits)
246 const verdict = allows(limits, rule) ? 'Deep research is allowed right now.' : 'Deep research will ask first right now.'
247
248 return (
249 <Box flexDirection="column" gap={1}>
250 <Box flexDirection="column">
251 <Text bold>Models to watch</Text>
252 <Text dimColor>Deep research on these asks first, unless both rules below pass.</Text>
253 <Box flexDirection="row" gap={1}>
254 {FAMILIES.map(f => (
255 <Button
256 key={`model-${f}`}
257 variant={watched.has(f) ? 'primary' : 'secondary'}
258 onPress={() => toggleModel($, f)}
259 >
260 {`${watched.has(f) ? '✓' : '○'} ${NAMES[f]}`}
261 </Button>
262 ))}
263 </Box>
264 </Box>
265 <Box flexDirection="column">
266 <Text bold>5h window resets within</Text>
267 {Select ? (
268 <Select
269 key="minutes"
270 value={String(rule.resetWithinMinutes)}
271 options={steps(MINUTE_STEPS, rule.resetWithinMinutes, ' min')}
272 onSelect={value => setMinutes($, value)}
273 />
274 ) : (
275 <Text>{rule.resetWithinMinutes} min</Text>
276 )}
277 </Box>
278 <Box flexDirection="column">
279 <Text bold>Weekly usage under</Text>
280 {Select ? (
281 <Select
282 key="percent"
283 value={String(rule.weeklyMaxPercent)}
284 options={steps(PERCENT_STEPS, rule.weeklyMaxPercent, '%')}
285 onSelect={value => setPercent($, value)}
286 />
287 ) : (
288 <Text>{rule.weeklyMaxPercent}%</Text>
289 )}
290 </Box>
291 <Box flexDirection="column">
292 <Text dimColor>{line ?? 'No usage reading yet.'}</Text>
293 <Text dimColor>{verdict}</Text>
294 </Box>
295 <Button key="open-on-start" dimColor onPress={() => toggleOpenOnStart($)}>
296 {`${openOnStart ? '✓' : '○'} Open this panel when a session starts`}
297 </Button>
298 {message && <Text color={message.startsWith("Couldn't") ? 'red' : 'green'}>{message}</Text>}
299 </Box>
300 )
301 })
302
303 on('prompt.submit', async ($, e, next) => {
304 // Our own re-send, or anything a plugin sent, goes straight through.
305 if (e.origin?.kind === 'plugin') return next(e)
306 const current = await watchedModel($)
307 if (!current || !(await isDeepResearch($, e.text))) return next(e)
308
309 const choice = await decide($, current, 'This looks like deep research')
310 if (choice === 'run') return next(e)
311 if (choice === 'cancel') return { drop: 'Deep research cancelled.' }
312 $.clock.after(0, () => void resend($, choice, e))
313
314 return { drop: `Switching to ${NAMES[familyOf(choice)!]} and sending your prompt again.` }
315 }).catch(($, e, next) => next(e))
316
317 // Slash commands like /deep-research never reach prompt.submit, so they get the same check here.
318 on('command.run', async ($, e, next) => {
319 if (e.origin?.kind === 'plugin' || !isResearchCommand(e.command)) return next(e)
320 const current = await watchedModel($)
321 if (!current) return next(e)
322
323 const choice = await decide($, current, `/${e.command} is about to run`)
324 if (choice === 'run') return next(e)
325 if (choice === 'cancel') return { text: 'Deep research cancelled.' }
326 $.clock.after(0, () => void rerun($, choice, e.command, e.args ?? ''))
327
328 return { text: `Switching to ${NAMES[familyOf(choice)!]} and running /${e.command} again.` }
329 }).catch(($, e, next) => next(e))
330}
331hooks/rules.ts 120 lines1import type { SessionRateLimit } from 'claude-code'
2
3const MINUTE = 60_000
4
5/** When a watched model is fine for deep research: both must hold. */
6export type Rule = { resetWithinMinutes: number; weeklyMaxPercent: number }
7
8/** What we know about the two windows right now; undefined when Claude Code has no reading. */
9export type Limits = { fiveHourPct?: number; resetInMinutes?: number; weeklyPct?: number }
10
11export type Family = 'fable' | 'opus' | 'sonnet' | 'haiku'
12
13export const NAMES: Record<Family, string> = { fable: 'Fable', opus: 'Opus', sonnet: 'Sonnet', haiku: 'Haiku' }
14
15export const familyOf = (model: string): Family | undefined => {
16 const name = model.toLowerCase()
17 if (name.includes('fable') || name.includes('mythos')) return 'fable'
18 if (name.includes('opus')) return 'opus'
19 if (name.includes('sonnet')) return 'sonnet'
20 if (name.includes('haiku')) return 'haiku'
21
22 return undefined
23}
24
25/** The models the check can watch: the expensive ones. */
26export const WATCHABLE: Family[] = ['fable', 'opus']
27
28/** The `models` setting, `fable, opus`, as the families it names. Anything else is ignored. */
29export const parseModels = (setting: string): Set<Family> =>
30 new Set(
31 setting
32 .split(/[\s,]+/)
33 .map(word => familyOf(word))
34 .filter((f): f is Family => f !== undefined && WATCHABLE.includes(f)),
35 )
36
37/** What the dialog offers on `current`: the other of Sonnet and Opus, keep, cancel. */
38export const choicesFor = (current: Family): string[] => [
39 ...(['sonnet', 'opus'] as const).filter(f => f !== current).map(f => NAMES[f]),
40 `Keep ${NAMES[current]}`,
41 'Cancel',
42]
43
44export const readLimits = (rateLimits: readonly SessionRateLimit[], now: number): Limits => {
45 const five = rateLimits.find(w => w.kind === 'five_hour')
46 const week = rateLimits.find(w => w.kind === 'seven_day')
47 const resetsAt = five?.resetsAt ? Date.parse(five.resetsAt) : undefined
48
49 return {
50 fiveHourPct: five?.percentUsed,
51 resetInMinutes: resetsAt === undefined ? undefined : Math.max(0, (resetsAt - now) / MINUTE),
52 weeklyPct: week?.percentUsed,
53 }
54}
55
56/** Fable is allowed only when the 5h window resets soon AND the week isn't too used. */
57export const allows = (limits: Limits, rule: Rule): boolean =>
58 limits.resetInMinutes !== undefined &&
59 limits.resetInMinutes < rule.resetWithinMinutes &&
60 limits.weeklyPct !== undefined &&
61 limits.weeklyPct < rule.weeklyMaxPercent
62
63/** Same format as the status line's reset countdown: 2d 4h, 3h 12m, 12m. */
64export const countdown = (minutes: number): string => {
65 const m = Math.max(0, Math.floor(minutes))
66 const d = Math.floor(m / 1440)
67 const h = Math.floor((m % 1440) / 60)
68 if (d > 0) return `${d}d ${h}h`
69 if (h > 0) return `${h}h ${m % 60}m`
70
71 return `${m}m`
72}
73
74/** The two conditions, each with whether it passes. */
75export const explain = (limits: Limits, rule: Rule): string[] => {
76 const mark = (ok: boolean) => (ok ? '✓' : '✗')
77 const reset =
78 limits.resetInMinutes === undefined
79 ? `? 5h reset not known yet (needs under ${rule.resetWithinMinutes} min)`
80 : `${mark(limits.resetInMinutes < rule.resetWithinMinutes)} 5h resets in ${countdown(limits.resetInMinutes)} (needs under ${rule.resetWithinMinutes} min)`
81 const week =
82 limits.weeklyPct === undefined
83 ? `? Weekly usage not known yet (needs under ${rule.weeklyMaxPercent}%)`
84 : `${mark(limits.weeklyPct < rule.weeklyMaxPercent)} Weekly ${Math.round(limits.weeklyPct)}% used (needs under ${rule.weeklyMaxPercent}%)`
85
86 return [reset, week]
87}
88
89const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
90
91const hhmm = (d: Date) => `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
92
93/** The clock face showing `d` to the nearest half hour: 21:20 is 🕤, 09:05 is 🕘. */
94export const clockFace = (d: Date): string => {
95 const halfHours = Math.round((d.getHours() * 60 + d.getMinutes()) / 30)
96 const hour = (Math.floor(halfHours / 2) + 11) % 12
97 const base = halfHours % 2 === 0 ? 0x1f550 : 0x1f55c
98
99 return String.fromCodePoint(base + hour)
100}
101
102/** `🕤 5h 1% 21:20 | 🗓️ 7d 50% Oct 6 21:20`, or undefined when there's no reading. */
103export const limitsLine = (rateLimits: readonly SessionRateLimit[]): string | undefined => {
104 const parts: string[] = []
105 const five = rateLimits.find(w => w.kind === 'five_hour')
106 const week = rateLimits.find(w => w.kind === 'seven_day')
107 if (five) {
108 const d = five.resetsAt ? new Date(five.resetsAt) : undefined
109 const at = d ? ` ${hhmm(d)}` : ''
110 parts.push(`${d ? clockFace(d) : '🕐'} 5h ${Math.round(five.percentUsed)}%${at}`)
111 }
112 if (week) {
113 const d = week.resetsAt ? new Date(week.resetsAt) : undefined
114 const at = d ? ` ${MONTHS[d.getMonth()]} ${d.getDate()} ${hhmm(d)}` : ''
115 parts.push(`🗓️ 7d ${Math.round(week.percentUsed)}%${at}`)
116 }
117
118 return parts.length > 0 ? parts.join(' | ') : undefined
119}
120types/index.d.ts 9 lines1/** The last thing the settings pane saved, or why it couldn't. */
2export type Notice = string | null
3
4declare module 'claude-code' {
5 interface PluginState {
6 limits: { notice: Notice }
7 }
8}
9