SLOPSHOPPER

no-limit

Resumes your Claude Code session automatically once the usage limit that stopped it resets

newpanebandspinnercommandtoast
v0.3.0MITupdated 2026-10-05longmaba/no-limit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · no-limit
│ ┃ no-limit ✕ › fix the failing auth test and add an audit log call │ ┃ (o.o) WATCHING auto-resume on │ ┃ ⏺ Read(src/auth.ts) │ ┃ Usage ⎿ Read 6 lines │ ┃ 5-hour ██████░░░░░░░░░░░░░░ 31% resets in ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Resume prompt (default) ⏺ Bash(bun test) │ ┃ The usage limit has reset. Carry on with ⎿ 3 pass, 1 fail │ ┃ the task you were working on when the │ ┃ limit stopped you. If it was already ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ finished, say so in one line. │ ┃ Type a new resume prompt, Enter to save (em ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ History › /no-limit │ ┃ No resumes yet. ⎿ no-limit: Opened the no-limit dashboard. │ ┃ │ ┃ [ Turn off ] [ Test (1 min) ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts auto-accept edits · ●∙∙∙∙ no-limit · 5h 31%

Draws

Pane · no-limit
(o.o) WATCHING auto-resume on Usage 5-hour ██████░░░░░░░░░░░░░░ 31% resets in NaNm Resume prompt (default) The usage limit has reset. Carry on with the task you were working on when the limit stopped you. If it was already finished, say so in one line. Type a new resume prompt, Enter to save (empty: default) ⏎ History No resumes yet. [ Turn off ] [ Test (1 min) ]
README

no-limit

A Claude Code mod that picks your session back up once the usage limit that stopped it resets.

Hit the 5-hour limit in the middle of a task and walk away. no-limit reads when the limit resets, waits it out, and sends Claude a prompt to carry on where it left off. No tmux, no scripts, and it works the same on macOS, Linux and Windows.

Install

You need Claude Code v2.1.287 or later (claude --version).

claude plugin marketplace add longmaba/no-limit
claude plugin install no-limit@longmaba-mods

Or from inside a session: /plugin install no-limit --marketplace longmaba/no-limit. Run /reload-plugins if a session was already open.

To try it once without installing, clone the repository and run claude --plugin-dir ./no-limit.

What you see

  • The footer, at the right under the prompt:
  • ∙∙●∙∙ no-limit · 5h 15% while it's watching, with your 5-hour usage
  • (-.-) zZ resets in 1h 12m while it waits for a reset
  • \(^o^)/ back to work for a moment after it resumes
  • A band above the prompt while it waits, with a progress bar, the countdown, and Resume now, Cancel and Details buttons
  • A dashboard (/no-limit): usage bars for the 5-hour and weekly windows, the wait's progress, the resume prompt (editable), and the last few resumes

Commands

CommandWhat it does
/no-limitOpen the dashboard
/no-limit statusPrint what's pending
/no-limit nowResume right away
/no-limit cancelDrop the pending resume
/no-limit off / onStop or start resuming automatically
/no-limit anim off / onKeep the footer still, or animate it
/no-limit message <text>Set the prompt sent on resume (no text puts the default back)
/no-limit test [minutes]Pretend the limit was hit, to see it work (default 1 minute)

Typing your own prompt while it waits cancels the pending resume: you're back, so your prompt wins.

How it works

  • When a turn stops with a rate_limit error, it reads the rate-limit windows Claude Code tracks for your account. It waits for the latest reset among the windows that are full, or the 5-hour window's when none reads full, plus one minute.
  • It checks the clock every 30 seconds instead of setting one long timer, so a laptop that slept through the reset still resumes once it wakes.
  • If no reset time is reported it waits 15 minutes, then 30, then 60. A limit hit right after a resume counts as a retry, and it stops after 5 resumes in a row that hit the limit again.
  • Settings (on/off, animation, the resume prompt) and the resume history are kept across sessions.

Limits

  • Claude Code has to stay open. The mod runs inside Claude Code, so if you close the terminal, nothing resumes the session.
  • The footer, band and dashboard show in the terminal and the Code tab of the desktop app only. A claude -p run ends before any reset, so there's nothing to resume there.

Trust

A mod is code that runs with your permissions. To see what this one hooks and calls before installing, clone it and run claude plugin validate ./no-limit. It reads your rate-limit figures, keeps its settings in Claude Code's plugin store, and submits one prompt per resume. It makes no network requests and touches no files.

Development

claude plugin validate --strict .
claude plugin test .

Tested with Claude Code 2.1.289. Mods are early access, and the API can change between releases.

License

MIT

Source 2 files
hooks/register.tsx 530 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Limit, Pending, Resumed } from '../types'
5
6const pending = atom({ plugin: 'no-limit', key: 'pending' } as const, null)
7const lastResume = atom({ plugin: 'no-limit', key: 'lastResume' } as const, null)
8const history = atom({ plugin: 'no-limit', key: 'history' } as const, [])
9const limits = atom({ plugin: 'no-limit', key: 'limits' } as const, [])
10const enabled = atom({ plugin: 'no-limit', key: 'enabled' } as const, true)
11const message = atom({ plugin: 'no-limit', key: 'message' } as const, null)
12const animate = atom({ plugin: 'no-limit', key: 'animate' } as const, true)
13const frame = atom({ plugin: 'no-limit', key: 'frame' } as const, 0)
14const cheerUntil = atom({ plugin: 'no-limit', key: 'cheerUntil' } as const, 0)
15const hasGivenUp = atom({ plugin: 'no-limit', key: 'hasGivenUp' } as const, false)
16
17const PANE = 'no-limit'
18const MINUTE = 60_000
19// How often the countdown checks the clock: polling, not one long timer,
20// so a laptop that slept through the reset still resumes once it wakes
21const TICK_MS = 30_000
22const FRAME_MS = 400
23// Wait this long past the reported reset so the first request isn't refused
24const BUFFER_MS = 1 * MINUTE
25// A limit hit this soon after a resume counts as that resume failing
26const RETRY_WINDOW_MS = 10 * MINUTE
27const MAX_ATTEMPTS = 5
28// When the API reports no reset time: 15m, 30m, 60m, 60m, ...
29const FALLBACK_MS = 15 * MINUTE
30const FALLBACK_MAX_MS = 60 * MINUTE
31const HISTORY_SIZE = 10
32// Frames the footer cheers for after a resume, about five seconds
33const CHEER_FRAMES = 12
34
35const DEFAULT_MESSAGE =
36  'The usage limit has reset. Carry on with the task you were working on when the limit stopped you. If it was already finished, say so in one line.'
37
38const HELP = [
39  '/no-limit            open the dashboard',
40  '/no-limit status     print what is pending',
41  '/no-limit now        resume right away',
42  '/no-limit cancel     drop the pending resume',
43  '/no-limit off | on   stop or start resuming automatically',
44  '/no-limit anim off | on   still or animated footer indicator',
45  '/no-limit message …  set the resume prompt (no text: back to the default)',
46  '/no-limit test [m]   pretend the limit was hit, resuming in m minutes (default 1)',
47].join('\n')
48
49// The footer indicator's frames: a soft sweep while watching, a snooze
50// through the wait, a cheer on waking
51const SWEEP = ['●∙∙∙∙', '∙●∙∙∙', '∙∙●∙∙', '∙∙∙●∙', '∙∙∙∙●', '∙∙∙●∙', '∙∙●∙∙', '∙●∙∙∙']
52const SNOOZE = ['(-.-)    ', '(-.-) z  ', '(-.-) zZ ', '(-_-) zZz', '(-_-)  Zz', '(-.-)   z']
53const CHEER = ['\\(^o^)/', '\\(^_^)/', '\\(^o^)/', '(^_^)b ']
54
55let frames: Timer | undefined
56
57export const register: Register = on => {
58  on('session.start', async ($, e, next) => {
59    await $.command.register({
60      name: 'no-limit',
61      description: 'Auto-resume after a usage limit: open the dashboard, or status, now, cancel, off, on, anim, message, test',
62      argumentHint: '[status|now|cancel|off|on|anim on|anim off|message <text>|test [minutes]]',
63    })
64    // The engine draws a status line as a warning; this mod draws in the footer instead
65    $.ui.status(undefined)
66    await load($)
67    // A reload drops the old timers; the pending resume itself lives in $.state
68    $.clock.every(TICK_MS, () => void tick($))
69    await tick($)
70
71    return next(e)
72  })
73
74  on('session.measure', async ($, e, next) => {
75    if (e.changed.includes('rateLimits')) {
76      await update($, limits, () => e.rateLimits.map(w => ({ ...w })))
77      await refresh($)
78    }
79
80    return next(e)
81  })
82
83  on('classic.StopFailure', async ($, e, next) => {
84    if (e.error === 'rate_limit' && (await read($, enabled))) {
85      await arm($)
86    }
87
88    return next(e)
89  })
90
91  // The person typing at the prompt means they're back: their prompt wins
92  on('prompt.submit', async ($, e, next) => {
93    if (e.origin.kind === 'composer' && (await read($, pending)) !== null) {
94      await clear($)
95    }
96
97    return next(e)
98  })
99
100  on('command.run', { command: 'no-limit' }, async ($, e) => {
101    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
102    const text = e.args.trim().slice(verb.length).trim()
103
104    switch (verb.toLowerCase()) {
105      case '':
106      case 'dashboard': {
107        const opened = await $.ui.open({ id: PANE, title: 'no-limit' })
108        return { text: opened.isPlaced ? 'Opened the no-limit dashboard.' : await describeStatus($) }
109      }
110      case 'status':
111        return { text: await describeStatus($) }
112      case 'now': {
113        const p = await read($, pending)
114        if (p === null) return { text: 'Nothing is pending.' }
115        // A prompt submitted from this hook would wait on the turn the hook holds
116        $.clock.after(0, () => void resumeNow($))
117        return { text: 'Resuming now.' }
118      }
119      case 'cancel':
120        await clear($)
121        return { text: 'Pending resume cancelled.' }
122      case 'off':
123        await setEnabled($, false)
124        return { text: 'Auto-resume is off. /no-limit on turns it back on.' }
125      case 'on':
126        await setEnabled($, true)
127        return { text: 'Auto-resume is on.' }
128      case 'anim':
129      case 'animation': {
130        const isOn = rest[0]?.toLowerCase() !== 'off'
131        await setAnimated($, isOn)
132        return { text: isOn ? 'Status line animation on.' : 'Status line animation off.' }
133      }
134      case 'message':
135        await setMessage($, text)
136        return { text: text === '' ? `Resume prompt reset to the default:\n${DEFAULT_MESSAGE}` : `Resume prompt set to:\n${text}` }
137      case 'test': {
138        const minutes = Number(rest[0] ?? 1)
139        if (!Number.isFinite(minutes) || minutes <= 0) return { text: 'Usage: /no-limit test [minutes]' }
140        await pretend($, minutes)
141        return { text: `Pretending the limit was hit. Resuming in ${until(minutes * MINUTE)}.` }
142      }
143      default:
144        return { text: HELP }
145    }
146  })
147
148  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
149    const p = await read($, pending)
150    if (p === null || e.props.hasSurvey) {
151      return next(e)
152    }
153
154    const now = await $.clock.now()
155    const retry = p.attempts > 0 ? ` · retry ${p.attempts}/${MAX_ATTEMPTS - 1}` : ''
156    const [done, left] = bar(progress(p, now), clamp(e.props.bodyColumns - 75, 6, 16))
157    const { Box, Button, Text } = $.ui.resolve(e)
158
159    return (
160      <Box flexDirection="row" gap={1}>
161        <Text color="yellow">(-.-) zZ</Text>
162        <Text bold>{label(p.window)}</Text>
163        <Box flexDirection="row">
164          <Text color="yellow">{done}</Text>
165          <Text dimColor>{left}</Text>
166        </Box>
167        <Text>
168          resuming in <Text bold>{until(p.resumeAt - now)}</Text>
169          <Text dimColor>{retry}</Text>
170        </Text>
171        <Button key="now" label="Resume now" variant="primary" onPress={() => resumeNow($)} />
172        <Button key="cancel" label="Cancel" onPress={() => clear($)} />
173        <Button key="details" label="Details" onPress={() => $.ui.open({ id: PANE, title: 'no-limit' })} />
174      </Box>
175    )
176  })
177
178  // The dim labels at the right of the prompt footer: the engine's own modes,
179  // then this mod's indicator, calm colors only
180  on('ui.render', { component: 'SessionMode' }, async ($, e) => {
181    const look = await indicator($)
182    const { Box, Text } = $.ui.resolve(e)
183
184    return (
185      <Box flexDirection="row">
186        {e.props.modes.length > 0 && <Text dimColor>{e.props.modes.join(' & ')} · </Text>}
187        {look.color === null ? <Text dimColor>{look.glyph}</Text> : <Text color={look.color}>{look.glyph}</Text>}
188        <Text dimColor> {look.text}</Text>
189      </Box>
190    )
191  })
192
193  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
194    const p = await read($, pending)
195    const windows = await read($, limits)
196    const isOn = await read($, enabled)
197    const prompt = await read($, message)
198    const resumes = await read($, history)
199    const now = await $.clock.now()
200    const width = clamp(e.props.bodyColumns - 36, 10, 40)
201    const { Box, Button, Text } = $.ui.resolve(e)
202    const Input = e.surface === 'mobile' ? null : $.ui.resolve(e).Input
203
204    const badge =
205      p !== null
206        ? { text: '(-.-) zZ  WAITING FOR RESET', color: 'yellow' }
207        : isOn
208          ? { text: '(o.o)  WATCHING', color: 'green' }
209          : { text: '(-_-)  OFF', color: 'gray' }
210    const shownWindows = windows.filter(w => w.kind === 'five_hour' || w.kind === 'seven_day')
211
212    return (
213      <Box flexDirection="column" gap={1} paddingX={1}>
214        <Box flexDirection="row" justifyContent="space-between">
215          <Text bold color={badge.color}>
216            {badge.text}
217          </Text>
218          <Text dimColor>auto-resume {isOn ? 'on' : 'off'}</Text>
219        </Box>
220
221        <Box flexDirection="column">
222          <Text bold>Usage</Text>
223          {shownWindows.length === 0 && <Text dimColor>No readings yet: they arrive with the next reply.</Text>}
224          {shownWindows.map(w => {
225            const [used, free] = bar(w.percentUsed / 100, width)
226            const resetsIn = w.resetsAt === undefined ? '' : `resets in ${until(Date.parse(w.resetsAt) - now)}`
227            return (
228              <Box key={`limit-${w.kind}`} flexDirection="row" gap={1}>
229                <Text>{(w.kind === 'five_hour' ? '5-hour' : 'Weekly').padEnd(6)}</Text>
230                <Box flexDirection="row">
231                  <Text color={heat(w.percentUsed)}>{used}</Text>
232                  <Text dimColor>{free}</Text>
233                </Box>
234                <Text bold color={heat(w.percentUsed)}>
235                  {`${Math.round(w.percentUsed)}%`.padStart(4)}
236                </Text>
237                <Text dimColor>{resetsIn}</Text>
238              </Box>
239            )
240          })}
241        </Box>
242
243        {p !== null && (
244          <Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
245            <Text color="yellow" bold>
246              {label(p.window)} hit. Napping until it resets.
247            </Text>
248            <Box flexDirection="row" gap={1}>
249              <Box flexDirection="row">
250                <Text color="yellow">{bar(progress(p, now), width)[0]}</Text>
251                <Text dimColor>{bar(progress(p, now), width)[1]}</Text>
252              </Box>
253              <Text>
254                resuming in <Text bold>{until(p.resumeAt - now)}</Text>
255              </Text>
256            </Box>
257            {p.attempts > 0 && (
258              <Text dimColor>
259                Retry {p.attempts} of {MAX_ATTEMPTS - 1}: the last resume hit the limit again.
260              </Text>
261            )}
262          </Box>
263        )}
264
265        <Box flexDirection="column">
266          <Text bold>Resume prompt{prompt === null ? ' (default)' : ''}</Text>
267          <Text dimColor wrap="wrap">
268            {prompt ?? DEFAULT_MESSAGE}
269          </Text>
270          {Input !== null && (
271            <Input
272              key="message"
273              placeholder="Type a new resume prompt, Enter to save (empty: default)"
274              submitLabel="Save"
275              onSubmit={value => setMessage($, value.trim())}
276            />
277          )}
278        </Box>
279
280        <Box flexDirection="column">
281          <Text bold>History</Text>
282          {resumes.length === 0 && <Text dimColor>No resumes yet.</Text>}
283          {resumes
284            .slice()
285            .reverse()
286            .slice(0, 5)
287            .map(r => (
288              <Text>
289                <Text color="green">✓</Text> {ago(now - r.at).padEnd(8)} after the {label(r.window).toLowerCase()}
290                <Text dimColor>, waited {until(r.waitedMs)}</Text>
291              </Text>
292            ))}
293        </Box>
294
295        <Box flexDirection="row" gap={1} flexWrap="wrap">
296          {p !== null && <Button key="now" label="Resume now" hotkey="r" variant="primary" onPress={() => resumeNow($)} />}
297          {p !== null && <Button key="cancel" label="Cancel" hotkey="c" onPress={() => clear($)} />}
298          <Button key="toggle" label={isOn ? 'Turn off' : 'Turn on'} hotkey="o" onPress={() => setEnabled($, !isOn)} />
299          <Button key="test" label="Test (1 min)" hotkey="t" onPress={() => pretend($, 1)} />
300        </Box>
301      </Box>
302    )
303  })
304}
305
306// Seed the session's values from what earlier sessions kept
307async function load($: EngineInterface) {
308  const kept = {
309    enabled: await $.store.get('enabled'),
310    message: await $.store.get('message'),
311    animate: await $.store.get('animate'),
312    history: await $.store.get('history'),
313  }
314  await update($, enabled, () => kept.enabled !== false)
315  await update($, animate, () => kept.animate !== false)
316  await update($, message, () => (typeof kept.message === 'string' && kept.message !== '' ? kept.message : null))
317  await update($, history, list => (list.length === 0 && Array.isArray(kept.history) ? (kept.history as Resumed[]) : list))
318
319  const { rateLimits } = await $.session.usage()
320  if (rateLimits.length > 0) {
321    await update($, limits, () => rateLimits.map(w => ({ ...w })))
322  }
323}
324
325async function arm($: EngineInterface) {
326  const now = await $.clock.now()
327  const last = await read($, lastResume)
328  const attempts = last !== null && now - last.at < RETRY_WINDOW_MS ? last.attempts + 1 : 0
329
330  if (attempts >= MAX_ATTEMPTS) {
331    await clear($)
332    await update($, hasGivenUp, () => true)
333    await refresh($)
334    $.ui.toast(`no-limit: still limited after ${MAX_ATTEMPTS - 1} resumes, giving up. /no-limit test 1 to try again.`)
335    return
336  }
337
338  const reset = await findReset($, now)
339  const p: Pending =
340    reset !== null
341      ? { resumeAt: reset.at + BUFFER_MS, armedAt: now, window: reset.window, attempts }
342      : { resumeAt: now + Math.min(FALLBACK_MS * 2 ** attempts, FALLBACK_MAX_MS), armedAt: now, window: 'unknown', attempts }
343
344  await update($, hasGivenUp, () => false)
345  await update($, pending, () => p)
346  $.ui.toast(`${label(p.window)} hit. Resuming automatically in ${until(p.resumeAt - now)}.`)
347  await tick($)
348}
349
350async function pretend($: EngineInterface, minutes: number) {
351  const now = await $.clock.now()
352  await update($, hasGivenUp, () => false)
353  await refresh($)
354  await update($, pending, () => ({ resumeAt: now + minutes * MINUTE, armedAt: now, window: 'test', attempts: 0 }))
355  await tick($)
356}
357
358// The latest future reset among the windows that are full; failing that the
359// 5-hour window's, since that's the one a busy session usually runs out of
360async function findReset($: EngineInterface, now: number) {
361  const { rateLimits } = await $.session.usage()
362  const future = rateLimits
363    .map(w => ({ window: w.kind, at: w.resetsAt === undefined ? NaN : Date.parse(w.resetsAt), percentUsed: w.percentUsed }))
364    .filter(w => Number.isFinite(w.at) && w.at > now)
365  const full = future.filter(w => w.percentUsed >= 100)
366  const candidates = full.length > 0 ? full : future.filter(w => w.window === 'five_hour')
367
368  return candidates.reduce<{ window: string; at: number } | null>(
369    (latest, w) => (latest === null || w.at > latest.at ? { window: w.window, at: w.at } : latest),
370    null,
371  )
372}
373
374async function tick($: EngineInterface) {
375  const p = await read($, pending)
376  if (p !== null && (await $.clock.now()) >= p.resumeAt) {
377    await resume($, p)
378    return
379  }
380
381  await refresh($)
382  $.ui.invalidate('ui.render')
383}
384
385async function resume($: EngineInterface, p: Pending) {
386  const now = await $.clock.now()
387  const entry: Resumed = { at: now, window: p.window, waitedMs: now - p.armedAt }
388  await update($, pending, () => null)
389  await update($, lastResume, () => ({ at: now, attempts: p.attempts }))
390  await update($, history, list => [...list, entry].slice(-HISTORY_SIZE))
391  await $.store.set('history', await read($, history))
392  if (await read($, animate)) {
393    const at = await read($, frame)
394    await update($, cheerUntil, () => at + CHEER_FRAMES)
395  }
396  await refresh($)
397  $.ui.toast('Usage limit reset: resuming the session')
398
399  const text = (await read($, message)) ?? DEFAULT_MESSAGE
400  void $.prompt.submit({ text })
401}
402
403async function resumeNow($: EngineInterface) {
404  const p = await read($, pending)
405  if (p !== null) {
406    await resume($, p)
407  }
408}
409
410async function clear($: EngineInterface) {
411  await update($, pending, () => null)
412  await refresh($)
413}
414
415async function setEnabled($: EngineInterface, isOn: boolean) {
416  await $.store.set('enabled', isOn)
417  await update($, enabled, () => isOn)
418  if (!isOn) {
419    await update($, pending, () => null)
420  }
421  await refresh($)
422}
423
424async function setAnimated($: EngineInterface, isOn: boolean) {
425  await $.store.set('animate', isOn)
426  await update($, animate, () => isOn)
427  await refresh($)
428}
429
430async function setMessage($: EngineInterface, text: string) {
431  if (text === '') {
432    await $.store.delete('message')
433    await update($, message, () => null)
434  } else {
435    await $.store.set('message', text)
436    await update($, message, () => text)
437  }
438}
439
440// Start or stop the frame timer; the footer reads the state itself, so a
441// still indicator (animation off, auto-resume off, given up) needs no timer
442async function refresh($: EngineInterface) {
443  const isMoving = (await read($, animate)) && (await read($, enabled)) && !(await read($, hasGivenUp))
444  if (isMoving && frames === undefined) {
445    frames = $.clock.every(FRAME_MS, () => void update($, frame, n => n + 1))
446  } else if (!isMoving && frames !== undefined) {
447    frames.cancel()
448    frames = undefined
449  }
450}
451
452async function indicator($: EngineInterface): Promise<{ glyph: string; color: string | null; text: string }> {
453  const n = (await read($, animate)) ? await read($, frame) : 0
454  const p = await read($, pending)
455
456  if (n < (await read($, cheerUntil))) {
457    return { glyph: CHEER[n % CHEER.length] ?? '', color: 'green', text: 'back to work' }
458  }
459  if (!(await read($, enabled))) {
460    return { glyph: '○', color: null, text: 'no-limit off' }
461  }
462  if (await read($, hasGivenUp)) {
463    return { glyph: '(-_-)', color: null, text: 'no-limit stopped retrying · /no-limit test' }
464  }
465  if (p !== null) {
466    const now = await $.clock.now()
467    return { glyph: SNOOZE[n % SNOOZE.length] ?? '', color: 'cyan', text: `resets in ${until(p.resumeAt - now)}` }
468  }
469
470  const five = (await read($, limits)).find(w => w.kind === 'five_hour')
471  const usage = five === undefined ? '' : ` · 5h ${Math.round(five.percentUsed)}%`
472  return { glyph: SWEEP[n % SWEEP.length] ?? '', color: 'green', text: `no-limit${usage}` }
473}
474
475async function describeStatus($: EngineInterface) {
476  const isOn = await read($, enabled)
477  const p = await read($, pending)
478  const head = isOn ? 'Auto-resume is on.' : 'Auto-resume is off.'
479  if (p === null) return `${head} Nothing is pending.\n\n${HELP}`
480
481  const now = await $.clock.now()
482  return `${head} ${label(p.window)} hit; resuming in ${until(p.resumeAt - now)}.\n\n${HELP}`
483}
484
485function label(window: string) {
486  switch (window) {
487    case 'five_hour':
488      return '5-hour limit'
489    case 'seven_day':
490      return 'Weekly limit'
491    case 'test':
492      return 'Test limit'
493    default:
494      return 'Usage limit'
495  }
496}
497
498function progress(p: Pending, now: number) {
499  const span = p.resumeAt - p.armedAt
500  return span <= 0 ? 1 : (now - p.armedAt) / span
501}
502
503// A bar's filled and empty halves, drawn in two colors by the caller
504function bar(fraction: number, width: number): [string, string] {
505  const filled = Math.round(clamp(fraction, 0, 1) * width)
506  return ['█'.repeat(filled), '░'.repeat(width - filled)]
507}
508
509function heat(percent: number) {
510  return percent >= 90 ? 'red' : percent >= 70 ? 'yellow' : 'green'
511}
512
513function clamp(n: number, low: number, high: number) {
514  return Math.min(high, Math.max(low, n))
515}
516
517function until(ms: number) {
518  const minutes = Math.max(0, Math.ceil(ms / MINUTE))
519  const d = Math.floor(minutes / 1440)
520  const h = Math.floor((minutes % 1440) / 60)
521  const m = minutes % 60
522
523  return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
524}
525
526function ago(ms: number) {
527  const minutes = Math.floor(ms / MINUTE)
528  return minutes < 1 ? 'just now' : `${until(ms)} ago`
529}
530
types/index.d.ts 40 lines
1/** A resume waiting for the usage limit to reset. */
2export type Pending = {
3  /** When to resume, in `$.clock.now()` milliseconds. */
4  resumeAt: number
5  /** When the limit was hit, so the wait can be drawn as progress. */
6  armedAt: number
7  /** Which window stopped the session (`five_hour`, `seven_day`), or `unknown`. */
8  window: string
9  /** How many resumes in a row have hit the limit again. */
10  attempts: number
11}
12
13/** The last resume this mod sent, so a limit hit right after it counts as a retry. */
14export type LastResume = { at: number; attempts: number }
15
16/** One resume, for the dashboard's history. */
17export type Resumed = { at: number; window: string; waitedMs: number }
18
19/** One rate-limit window as the last API response reported it. */
20export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
21
22declare module 'claude-code' {
23  interface PluginState {
24    'no-limit': {
25      pending: Pending | null
26      lastResume: LastResume | null
27      history: Resumed[]
28      limits: Limit[]
29      enabled: boolean
30      message: string | null
31      animate: boolean
32      /** The footer indicator's animation frame, counted up while it moves. */
33      frame: number
34      /** The frame the after-resume cheer lasts until. */
35      cheerUntil: number
36      hasGivenUp: boolean
37    }
38  }
39}
40