SLOPSHOPPER

rain-check

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

newbandcommandtoastprompttimer
★ 1v0.1.0MITupdated 2026-10-14modmyclaude/claude-code-mods/mods/rain-check
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rain-check
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ rain-check │ ⏺ Read(src/auth.ts) │ Nothing is waiting. At a usage limit, what │ ⎿ Read 6 lines │ you send waits here. │ ⏺ 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 › /rain-check ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<img src="../../brand/pixel-icons/svg/rain-check.svg" width="64" height="64" alt="">

Rain Check

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"

Install

/plugin install rain-check --marketplace modmyclaude/claude-code-mods

Use it

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:

  • A line above your prompt says when the limit resets: 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.
  • Anything you send now waits. Instead of failing, your prompt is held, and the line shows when it goes out and the first words of it. It goes out by itself one minute after the reset, so it lands in the new window. Send a second prompt and it joins the first; they go out together.
  • Send now sends it at once (if the limit lifted early, or you have extra usage). If the limit still refuses it, it simply waits again.
  • Cancel drops what waits and puts your words back in the prompt box, after anything you have typed since.

Slash commands and ! shell lines are never held: they do not use your model limit.

CommandWhat it does
/rain-checkWhat waits, and when it goes out
/rain-check nowSend what waits at once
/rain-check cancelDrop what waits; your words go back in the prompt box

Settings

  • On: on by default. Off, Rain Check holds nothing, draws nothing and sends nothing, and forgets the limit it saw; a prompt already waiting goes back to the prompt box (or the transcript, where no box takes it), never out. /rain-check says it is off. /config or the modmyclaude hub flips it, live.

What it can touch

  • Reads: the usage windows Claude Code reports after each response (kind, percent used, reset time), the kind of error that ends a turn (only to see a 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.
  • Writes: its own session state (the limit, the waiting prompt, the countdown's clock, and the last prompt it sent), the line above the prompt, and toasts. Nothing is kept across sessions.
  • On disk: nothing, with one exception. When your words come back (you cancel, the send is blocked, or you switch Rain Check off) and the prompt box cannot take them, they are shown in full in one dim line in the transcript, so they are not lost. Claude is not sent that line, but Claude Code's debug log (a file, when debug logging is on) keeps a copy.
  • Sends: at the reset, the prompt you wrote, or "Continue where you left off. Your usage limit has reset." if you pressed Continue then. It sends nothing you did not write or ask for.
  • Processes and network: none. Model calls: none of its own.

Limits

  • It starts holding only after a real refusal in this session, and checks your usage again at that moment. Near a cap but not at it (95% to 99%), one refusal may be a passing one, so it holds only after a second within 10 minutes. A window that reads 100% is not enough on its own, because extra usage can still let requests through. So the first prompt at the limit fails as usual, and what follows waits.
  • It waits only while the session is open. Quit Claude Code and the waiting prompt is gone; it is not saved to disk.
  • The waiting prompt is text only: files you attached are not kept, and the hold says so.
  • If another hook or a settings rule blocks the send at the reset, your words come back to the prompt box with the reason; nothing is sent later behind your back.
  • If the computer sleeps through the reset, the prompt goes out within a minute of waking.
  • A turn refused for a passing reason (a short-lived rate limit, or an API key with no usage windows) holds nothing: Claude Code retries those itself.
  • /clear drops a waiting "Continue then" (the old conversation is gone) but keeps a prompt you wrote.
  • The line is checked through the engine's test kit on the terminal and desktop surfaces.

Built from a prompt

This mod was built from the Rain Check prompt.

More mods: the full catalogue.

Source 3 files
hooks/register.tsx 389 lines
1// 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}
389
hooks/logic.ts 129 lines
1// 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}
129
types/index.d.ts 36 lines
1// 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