Hit your usage limit? Your prompt waits and goes out when the limit resets.

<img src="../../brand/pixel-icons/svg/rain-check.svg" width="64" height="64" alt="">
Hit your usage limit? Your prompt waits and goes out when the limit resets.
Sends 2:01pm · in 3h 12m [Send now] [Cancel]
"run the tests, then update the changelog"
/plugin install rain-check --marketplace modmyclaude/claude-code-mods
Nothing to set up. It stays out of the way until Claude Code refuses a turn because a usage limit (your 5-hour or weekly window) has run out. Then:
Usage limit reached · resets 2pm · in 5h. Continue then picks the cut-off turn up at the reset. Dismiss stops waiting, so what you send goes straight out again.Slash commands and ! shell lines are never held: they do not use your model limit.
| Command | What it does |
|---|---|
/rain-check | What waits, and when it goes out |
/rain-check now | Send what waits at once |
/rain-check cancel | Drop what waits; your words go back in the prompt box |
/rain-check says it is off. /config or the modmyclaude hub flips it, live.rate_limit), and each prompt you send while a limit has refused you (to hold it). Before any refusal it reads your prompts only to let them pass. When it gives your words back, it reads what is already in the prompt box, so they go after your draft./clear drops a waiting "Continue then" (the old conversation is gone) but keeps a prompt you wrote.This mod was built from the Rain Check prompt.
More mods: the full catalogue.
hooks/register.tsx 389 lines1// Rain Check: once Claude Code refuses a turn on a usage limit (the 5-hour or
2// weekly window), a prompt the person sends waits instead of failing, and goes
3// out by itself just after the window resets. The turn the limit cut off can be
4// picked up the same way, but only when the person asks: nothing is sent unasked.
5//
6// Signals: `classic.StopFailure` with `error: 'rate_limit'` (the refusal) and
7// `session.measure`'s `rateLimits` (which window, and its `resetsAt`). A reading
8// at 100% alone never holds a prompt: extra usage can still let requests through.
9// While something waits, the countdown ticks once a minute; otherwise no timer.
10
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, PromptOrigin, Register, RenderNode, Timer } from 'claude-code'
13
14import type { RainCheckQueued } from '../types'
15import {
16 CONTINUE_TEXT, SECOND_REFUSAL_MS, TICK_MS, clockWords, cutOffWindow, holdText, joined, limitLine, oneLine, ownWords, preview,
17 sendTime, waitingLine,
18} from './logic'
19import type { Window } from './logic'
20
21const LIMIT = { plugin: 'rain-check', key: 'limit' } as const
22const QUEUED = { plugin: 'rain-check', key: 'queued' } as const
23const NOW = { plugin: 'rain-check', key: 'now' } as const
24const LAST = { plugin: 'rain-check', key: 'lastSent' } as const
25
26const limit = atom(LIMIT, null)
27const queued = atom(QUEUED, null)
28const clock = atom(NOW, 0)
29const lastSent = atom(LAST, null)
30
31// What lives only as long as this copy of the module: the last windows Claude Code
32// reported, and the countdown's timer (a reload starts both over; heal re-arms it).
33let windows: readonly Window[] = []
34let timer: Timer | null = null
35/** When the last refusal near (not at) a cap came: a second one soon after makes it count. */
36let nearRefusalAt = -Infinity
37/** Set before send's first await, so two presses (or a press and the tick) cannot both send. */
38let isSending = false
39
40// The switch (`enabled`, which /config and the modmyclaude hub flip): off, Rain Check holds
41// nothing, draws nothing, keeps no timer and sends nothing. The engine reloads the mod with
42// the new value; the config.set hook below is the same switch at once.
43let isOn = true
44const OFF_TEXT = 'Rain Check is off. /mods or /config turns it on.'
45/** What the toast leads with when switching off gives a waiting prompt back. */
46const OFF_LEAD = 'Rain Check is off, so nothing waits.'
47
48/** A prompt the person sent themselves: typed, from the phone bridge, or the desktop app's box. */
49async function isPerson($: EngineInterface, origin: PromptOrigin | undefined): Promise<boolean> {
50 if (origin === undefined || origin.kind === 'composer' || origin.kind === 'bridge') return true
51 return origin.kind === 'sdk' && (await $.session.surfaces()).includes('desktop')
52}
53
54async function safely($: EngineInterface, work: () => Promise<unknown>): Promise<void> {
55 try {
56 await work()
57 } catch (error) {
58 $.ui.log(`rain-check: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
59 }
60}
61
62/** Starts the minute tick while a limit or a waiting prompt is shown, and stops it when neither is. */
63async function sync($: EngineInterface): Promise<void> {
64 const isActive = (await read($, limit)) !== null || (await read($, queued)) !== null
65 if (isActive && timer === null) timer = $.clock.every(TICK_MS, () => void safely($, () => tick($)))
66 if (!isActive && timer !== null) {
67 timer.cancel()
68 timer = null
69 }
70 const now = await $.clock.now()
71 await update($, clock, () => now)
72}
73
74/** Once a minute: moves the countdown, and at the reset sends what waits or clears the limit. */
75async function tick($: EngineInterface): Promise<void> {
76 const now = await $.clock.now()
77 await update($, clock, () => now)
78 const waiting = await read($, queued)
79 if (waiting !== null && now >= waiting.sendAt) return send($)
80 const cur = await read($, limit)
81 if (waiting === null && cur !== null && now >= sendTime(cur)) {
82 await update($, limit, () => null)
83 $.ui.toast('Usage limit reset.')
84 await sync($)
85 }
86}
87
88/**
89 * Sends what waits as a turn of its own. It reads the queue afresh, so the tick, Send now and
90 * /rain-check now never send it twice. A prompt another hook drops comes back to the person;
91 * one the limit refuses again waits again (classic.StopFailure, from lastSent).
92 */
93async function send($: EngineInterface): Promise<void> {
94 if (!isOn || isSending) return
95 isSending = true
96 try {
97 const waiting = await read($, queued)
98 if (waiting === null) return
99 await update($, queued, () => null)
100 await update($, limit, () => null)
101 await update($, lastSent, () => waiting)
102 await sync($)
103 // Switched off while this send got ready: the words go back to the person, never out.
104 if (!isOn) {
105 await update($, lastSent, () => null)
106 await giveBack($, waiting, OFF_LEAD)
107 return
108 }
109 const result = await $.prompt.submit({ text: waiting.text })
110 if (result.drop !== undefined) {
111 await update($, lastSent, () => null)
112 await giveBack($, waiting, `Not sent: ${oneLine(result.drop, 80)}.`)
113 return
114 }
115 $.ui.toast(waiting.source === 'continue' ? 'Usage limit reset. Picking up where Claude stopped.' : 'Usage limit reset. Your waiting prompt is sent.')
116 } finally {
117 isSending = false
118 }
119}
120
121/** Puts the person's own words back in the prompt box (after any draft there), or in a toast where no box takes them. */
122async function giveBack($: EngineInterface, waiting: RainCheckQueued, lead: string): Promise<void> {
123 const words = ownWords(waiting)
124 if (words === '') return $.ui.toast(lead)
125 let isFilled = false
126 try {
127 const box = await $.prompt.read()
128 const text = box.text.trim() === '' ? words : `${box.text}\n\n${words}`
129 isFilled = (await $.prompt.fill({ text, mode: 'replace' })).isFilled
130 } catch {
131 isFilled = false
132 }
133 if (!isFilled) $.ui.log(`rain-check: your words, in full: ${words}`) // the toast shows 60 characters
134 $.ui.toast(isFilled ? `${lead} Your words are back in the prompt box.` : `${lead} "${oneLine(words, 60)}"`)
135}
136
137/** Drops what waits, and never the person's words: they go back in the box. */
138async function cancel($: EngineInterface): Promise<void> {
139 if (!isOn) return
140 const waiting = await read($, queued)
141 if (waiting === null) return $.ui.toast('Nothing is waiting.')
142 await update($, queued, () => null)
143 await sync($)
144 await giveBack($, waiting, 'Cancelled.')
145}
146
147/**
148 * Switched off: nothing waits and no limit is kept, so on again starts fresh. A waiting
149 * prompt goes back to the person, never out. The words go back before the queue clears:
150 * a reload in between can give them back twice, never lose them.
151 */
152async function letGo($: EngineInterface): Promise<void> {
153 nearRefusalAt = -Infinity
154 timer?.cancel()
155 timer = null
156 // A send under way has the words in hand: it sees the switch before it sends, and gives them back.
157 const waiting = isSending ? null : await read($, queued)
158 if (waiting !== null) {
159 await giveBack($, waiting, OFF_LEAD)
160 await update($, queued, () => null)
161 }
162 if ((await read($, limit)) !== null) await update($, limit, () => null)
163 if ((await read($, lastSent)) !== null) await update($, lastSent, () => null)
164}
165
166/** The window a refused turn ran into, from a reading taken now (the last one can trail), else the last one. */
167async function refusedWindow($: EngineInterface, now: number): Promise<ReturnType<typeof cutOffWindow>> {
168 try {
169 const reading = (await $.session.usage()).rateLimits
170 if (reading.length > 0) return cutOffWindow(reading, now)
171 } catch {
172 // No fresh reading: the last one stands.
173 }
174 return cutOffWindow(windows, now)
175}
176
177/**
178 * Switched on: what an off it could not fully clear (a state write that failed) left behind is
179 * dropped first, so words already handed back are never sent at the reset.
180 */
181async function fresh($: EngineInterface): Promise<void> {
182 if ((await read($, queued)) !== null) await update($, queued, () => null)
183 if ((await read($, limit)) !== null) await update($, limit, () => null)
184 if ((await read($, lastSent)) !== null) await update($, lastSent, () => null)
185 await sync($)
186}
187
188export const register: Register = (on, options) => {
189 isOn = options.enabled !== false
190
191 on('config.set', { key: 'rain-check.enabled' }, async ($, e, next) => {
192 const set = await next(e)
193 if (set.deny === undefined && typeof set.value === 'boolean') {
194 const wasOn = isOn
195 isOn = set.value
196 // Only a change: off again would give the words back again.
197 if (isOn !== wasOn) await safely($, () => (isOn ? fresh($) : letGo($)))
198 $.ui.invalidate('ui.render')
199 }
200 return set
201 })
202
203 on('session.start', async ($, e, next) => {
204 await $.command.register({
205 name: 'rain-check',
206 description: 'See the prompt waiting for your usage limit; /rain-check now sends it, /rain-check cancel drops it',
207 argumentHint: '[now|cancel]',
208 immediate: true,
209 })
210 const started = await next(e)
211 // After a reload the limit and the waiting prompt are still in $.state, but not their tick.
212 // Reloaded off, what the copy before held is let go: the words go back, never out.
213 await safely($, () => (isOn ? sync($) : letGo($)))
214 return started
215 })
216
217 // Keeps the latest windows: a refusal reads its reset time from them.
218 on('session.measure', ($, e, next) => {
219 if (!isOn) return next(e)
220 windows = e.rateLimits
221 return next(e)
222 })
223
224 // A turn Claude Code ended on the usage limit: hold what comes next until the reset.
225 on('classic.StopFailure', async ($, e, next) => {
226 if (isOn && e.error === 'rate_limit') {
227 await safely($, async () => {
228 const now = await $.clock.now()
229 const out = await refusedWindow($, now)
230 // No window near full: a passing 429 (or an API key, which has none). Claude Code retries those itself.
231 if (out === null) return
232 const again = await read($, lastSent)
233 // Near a cap but not at it, one refusal may be a passing one: hold only on a second soon after
234 // (or on a refusal of what Rain Check just sent, which follows a limit already seen).
235 const isFirstNear = !out.isSure && again === null && now - nearRefusalAt > SECOND_REFUSAL_MS
236 nearRefusalAt = out.isSure ? -Infinity : now
237 if (isFirstNear) return
238 await update($, limit, () => ({ kind: out.kind, resetsAt: out.resetsAt }))
239 // What Rain Check sent was refused again: it waits for this reset instead.
240 if (again !== null) {
241 await update($, lastSent, () => null)
242 await update($, queued, cur => joined(cur, again.text, sendTime(out)))
243 $.ui.toast(`Still at the usage limit. It waits for ${clockWords(sendTime(out), now)}.`)
244 }
245 await sync($)
246 })
247 }
248 return next(e)
249 })
250
251 // At the limit, a prompt the person sends waits for the reset instead of failing. A hook that
252 // throws is skipped and the prompt goes through, so a bug here never eats one (a test pins it).
253 on('prompt.submit', async ($, e, next) => {
254 if (!isOn || e.turnId !== undefined || !(await isPerson($, e.origin))) return next(e)
255 // Slash commands and shell lines never reach the model's limit: they always run.
256 if (/^\s*[/!]/.test(e.text)) return next(e)
257 const cur = await read($, limit)
258 const now = await $.clock.now()
259 if (cur === null || now >= sendTime(cur)) return next(e)
260 const waiting = await read($, queued)
261 const held = joined(waiting, e.text, sendTime(cur))
262 await update($, queued, () => held)
263 await sync($)
264 return { drop: holdText(held, now, waiting !== null, (e.attachments?.length ?? 0) > 0) }
265 })
266
267 // A main turn that answered means the limit is over: what waits can go now.
268 on('turn.complete', async ($, e, next) => {
269 if (isOn && e.agentId === undefined && e.reason === 'answer') {
270 await safely($, async () => {
271 await update($, lastSent, () => null)
272 if ((await read($, limit)) === null) return
273 await update($, limit, () => null)
274 // A hook cannot submit inside the turn it is ending: the send goes one clock turn later.
275 if ((await read($, queued)) !== null) $.clock.after(0, () => void safely($, () => send($)))
276 else await sync($)
277 })
278 }
279 return next(e)
280 })
281
282 // A /clear or a resumed conversation: picking up the old turn no longer makes sense.
283 // The limit is the account's and stays, and so does a prompt the person wrote.
284 on('session.end', async ($, e, next) => {
285 if (isOn && (e.reason === 'clear' || e.reason === 'resume')) {
286 await safely($, async () => {
287 if ((await read($, queued))?.source === 'continue') {
288 await update($, queued, () => null)
289 await sync($)
290 }
291 })
292 }
293 return next(e)
294 })
295
296 on('command.run', { command: 'rain-check' }, async ($, e) => {
297 if (!isOn) {
298 $.ui.toast(OFF_TEXT)
299 return {}
300 }
301 const word = e.args.trim().toLowerCase()
302 const waiting = await read($, queued)
303 const cur = await read($, limit)
304 const now = await $.clock.now()
305 if (word === 'now') {
306 // A command's hook holds the turn it runs in: the send waits one clock turn, past it.
307 if (waiting === null) $.ui.toast('Nothing is waiting.')
308 else $.clock.after(0, () => void safely($, () => send($)))
309 return {}
310 }
311 if (word === 'cancel') {
312 await cancel($)
313 return {}
314 }
315 if (word !== '') {
316 $.ui.toast('Try /rain-check, /rain-check now or /rain-check cancel.')
317 return {}
318 }
319 if (waiting !== null) $.ui.toast(`${waitingLine(waiting, now)}. /rain-check now sends it, /rain-check cancel drops it.`)
320 else if (cur !== null) $.ui.toast(`${limitLine(cur, now)}. What you send now waits for it.`)
321 else $.ui.toast('Nothing is waiting. At a usage limit, what you send waits here.')
322 return {}
323 }).catch($ => {
324 $.ui.toast('/rain-check could not finish.')
325 return {}
326 })
327
328 // The band: beneath whatever other mods draw, one line (two while a prompt waits).
329 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
330 if (!isOn || e.props.hasSurvey) return next(e)
331 const waiting = await read($, queued)
332 const cur = await read($, limit)
333 if (waiting === null && cur === null) return next(e)
334 if (timer === null) void safely($, () => sync($))
335 const now = (await read($, clock)) || (await $.clock.now())
336 const rest = await next(e)
337 const { Box, Button, Text } = $.ui.resolve(e)
338 const rows: RenderNode[] = []
339 if (waiting !== null) {
340 rows.push(
341 <Box key="rain-waiting" flexDirection="row" gap={1}>
342 <Text color="warning" wrap="truncate-end">{waitingLine(waiting, now)}</Text>
343 <Button key="rain-now" label="Send now" onPress={() => safely($, () => send($))} />
344 <Button key="rain-cancel" label="Cancel" onPress={() => safely($, () => cancel($))} />
345 </Box>,
346 <Box key="rain-preview">
347 <Text dimColor wrap="truncate-end">{preview(waiting, e.props.bodyColumns)}</Text>
348 </Box>,
349 )
350 } else if (cur !== null) {
351 rows.push(
352 <Box key="rain-limit" flexDirection="row" gap={1}>
353 <Text color="warning" wrap="truncate-end">{limitLine(cur, now)}</Text>
354 <Button
355 key="rain-continue"
356 label="Continue then"
357 variant="primary"
358 onPress={() =>
359 safely($, async () => {
360 if (!isOn) return
361 await update($, queued, () => ({ text: CONTINUE_TEXT, sendAt: sendTime(cur), source: 'continue' as const }))
362 await sync($)
363 })
364 }
365 />
366 <Button
367 key="rain-dismiss"
368 label="Dismiss"
369 onPress={() =>
370 safely($, async () => {
371 if (!isOn) return
372 await update($, limit, () => null)
373 await sync($)
374 })
375 }
376 />
377 </Box>,
378 )
379 }
380 // What other mods draw here comes first.
381 return (
382 <Box key="rain-check" flexDirection="column">
383 {rest}
384 {rows}
385 </Box>
386 )
387 })
388}
389hooks/logic.ts 129 lines1// Rain Check's pure parts: which usage window ran out, when a waiting prompt
2// goes out, and the words the band, the hold and the toasts say. Nothing here
3// touches the engine, so the tests can pin every case directly.
4
5import type { RainCheckLimit, RainCheckQueued } from '../types'
6
7/** One usage window as Claude Code reports it (`SessionRateLimit`). */
8export type Window = { kind: string; percentUsed: number; resetsAt?: string }
9
10/** A waiting prompt goes out this long after the reset, so it lands in the new window. */
11export const AFTER_RESET_MS = 60_000
12/** The countdown moves by the minute. */
13export const TICK_MS = 60_000
14/** What a turn the limit cut off is picked up with. */
15export const CONTINUE_TEXT = 'Continue where you left off. Your usage limit has reset.'
16/** A turn that hit `rate_limit` names its window only when one is this full; below it the 429 was a passing one. */
17const NEAR_FULL = 95
18/** How much of the waiting prompt the band shows. */
19const PREVIEW_CHARS = 72
20
21const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
22
23/** When a window resets, or NaN when it does not say. */
24function resetOf(window: Window): number {
25 return Date.parse(window.resetsAt ?? '')
26}
27
28/** The window that is out now (used up, resetting later), the latest reset if several are; null when none is. */
29export function outWindow(windows: readonly Window[], now: number): RainCheckLimit | null {
30 let out: RainCheckLimit | null = null
31 for (const window of windows) {
32 const resetsAt = resetOf(window)
33 if (window.percentUsed < 100 || Number.isNaN(resetsAt) || resetsAt <= now) continue
34 if (out === null || resetsAt > out.resetsAt) out = { kind: window.kind, resetsAt }
35 }
36 return out
37}
38
39/** How close together two refusals must come for a window under 100% to count as out. */
40export const SECOND_REFUSAL_MS = 10 * 60_000
41
42/**
43 * The window a turn that ended on `rate_limit` ran into: one that reads used
44 * up (`isSure`), else the fullest at 95% or more (the reading can trail the
45 * refusal), which counts only on a second refusal soon after: one 429 near a
46 * weekly cap may be a passing one, and holding on it would hold for days.
47 * Null when none is near full: that 429 passes on its own, and an API key
48 * reports no windows at all.
49 */
50export function cutOffWindow(windows: readonly Window[], now: number): (RainCheckLimit & { isSure: boolean }) | null {
51 const out = outWindow(windows, now)
52 if (out !== null) return { ...out, isSure: true }
53 let fullest: Window | null = null
54 for (const window of windows) {
55 const resetsAt = resetOf(window)
56 if (Number.isNaN(resetsAt) || resetsAt <= now || window.percentUsed < NEAR_FULL) continue
57 if (fullest === null || window.percentUsed > fullest.percentUsed) fullest = window
58 }
59 return fullest === null ? null : { kind: fullest.kind, resetsAt: resetOf(fullest), isSure: false }
60}
61
62/** The person's own words in what waits: the pick-up line is not theirs, so it never goes back in the box. */
63export function ownWords(queued: RainCheckQueued): string {
64 if (queued.source === 'continue') return ''
65 return queued.text.startsWith(`${CONTINUE_TEXT}\n\n`) ? queued.text.slice(CONTINUE_TEXT.length + 2) : queued.text
66}
67
68/** `8am`, `8:01am`, or `Tue 8am` when it is not today: the way Claude Code names a reset. */
69export function clockWords(at: number, now: number): string {
70 const date = new Date(at)
71 const hours = date.getHours()
72 const minutes = date.getMinutes()
73 const time = `${hours % 12 === 0 ? 12 : hours % 12}${minutes === 0 ? '' : `:${String(minutes).padStart(2, '0')}`}${hours < 12 ? 'am' : 'pm'}`
74 return date.toDateString() === new Date(now).toDateString() ? time : `${DAYS[date.getDay()]} ${time}`
75}
76
77/** `45s`, `14m`, `2h 14m`, `3d 4h`: rounded up, never "0m". */
78export function untilWords(ms: number): string {
79 if (ms <= 0) return 'now'
80 if (ms < 60_000) return `${Math.ceil(ms / 1000)}s`
81 const minutes = Math.ceil(ms / 60_000)
82 if (minutes < 60) return `${minutes}m`
83 const hours = Math.floor(minutes / 60)
84 if (hours < 24) return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`
85 const days = Math.floor(hours / 24)
86 return hours % 24 === 0 ? `${days}d` : `${days}d ${hours % 24}h`
87}
88
89/** When a prompt queued now goes out: just after the reset. */
90export function sendTime(limit: Pick<RainCheckLimit, 'resetsAt'>): number {
91 return limit.resetsAt + AFTER_RESET_MS
92}
93
94/** The band's line while a prompt waits. */
95export function waitingLine(queued: RainCheckQueued, now: number): string {
96 const what = queued.source === 'continue' ? 'Continues' : 'Sends'
97 return `${what} ${clockWords(queued.sendAt, now)} · in ${untilWords(queued.sendAt - now)}`
98}
99
100/** The band's line while a window is out and nothing waits. */
101export function limitLine(limit: RainCheckLimit, now: number): string {
102 return `Usage limit reached · resets ${clockWords(limit.resetsAt, now)} · in ${untilWords(limit.resetsAt - now)}`
103}
104
105/** Text made safe to draw: no control characters, one line, cut to `max` with an ellipsis. */
106export function oneLine(text: string, max: number): string {
107 // eslint-disable-next-line no-control-regex
108 const flat = text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').replace(/\s+/g, ' ').trim()
109 const chars = [...flat]
110 return chars.length <= max ? flat : `${chars.slice(0, Math.max(1, max - 1)).join('')}…`
111}
112
113/** The waiting prompt as the band shows it, in quotes. */
114export function preview(queued: RainCheckQueued, columns: number): string {
115 return `"${oneLine(queued.text, Math.max(8, Math.min(PREVIEW_CHARS, columns - 4)))}"`
116}
117
118/** What a held send says (at most 20 words: the desktop shows it as the reason a hook held the prompt). */
119export function holdText(queued: RainCheckQueued, now: number, isAdded: boolean, hasFiles: boolean): string {
120 const when = `${clockWords(queued.sendAt, now)} (in ${untilWords(queued.sendAt - now)})`
121 const lead = isAdded ? `Added to the waiting prompt: sends ${when}.` : `Usage limit reached. This prompt waits and sends ${when}.`
122 return `${lead}${hasFiles ? ' Only the text waits: attach files again then.' : ' Cancel above the prompt.'}`
123}
124
125/** A held prompt and whatever already waits (another held prompt, or the pick-up), sent together as one. */
126export function joined(waiting: RainCheckQueued | null, text: string, sendAt: number): RainCheckQueued {
127 return { text: waiting === null ? text : `${waiting.text}\n\n${text}`, sendAt, source: 'held' }
128}
129types/index.d.ts 36 lines1// What Rain Check keeps in $.state for the session: the usage window that ran
2// out, the prompt waiting for it, the clock its countdown reads, and the
3// prompt it last sent (so one the limit refused again waits again).
4
5/**
6 * A usage window Claude Code refused a turn on: which one and when it resets.
7 * Set only by a real refusal, never by a reading alone (extra usage can let
8 * requests through at 100%).
9 */
10export type RainCheckLimit = {
11 /** `five_hour`, `seven_day`, or another window Claude Code reports. */
12 kind: string
13 /** When the window resets, ms since the epoch. */
14 resetsAt: number
15}
16
17/** A prompt waiting for the reset. */
18export type RainCheckQueued = {
19 text: string
20 /** When it goes out, ms since the epoch: just after the reset. */
21 sendAt: number
22 /** `held`: a prompt the person sent; `continue`: the turn the limit cut off, picked up. */
23 source: 'held' | 'continue'
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'rain-check': {
29 limit: RainCheckLimit | null
30 queued: RainCheckQueued | null
31 now: number
32 lastSent: RainCheckQueued | null
33 }
34 }
35}
36