SLOPSHOPPER

usage-warn

Warns as the 5-hour and weekly usage windows fill, with a circle per window above the prompt, a pace forecast and a status line

newbandtoaststatustimeraudio
v0.3.0no licenseupdated 2026-10-15MagnusPladsen/claude-mods/usage-warn
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-warn
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 usage-five_hour ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
usage-five_hour
README

usage-warn

A Claude Code mod that warns as your subscription usage windows fill up.

Features

  • One circle per usage window in use, at the right end of the band above the prompt: green below the start percent, the theme's warning color from it, the error color from the notification percent. Nothing is drawn at 0%. Hover the row for 5h 63% · resets in 1h12m · 100% by 13:40; click a circle to pin the details open, click again to hide them
  • A toast at every step of the 5-hour window from the start percent: 50, 60, 70, 80, 90% by default
  • The same for the 7-day window, on its own track, and for any model-scoped weekly window the engine reports (seven_day_opus draws as 7d opus)
  • A pace forecast: on pace for 100% at 13:40 when the current pace runs out before the reset. It uses the last hour of readings, and falls back to the average since the window opened
  • A status line under the prompt once past the start: 5h 63% · resets in 1h12m · 100% by 13:40, refreshed every minute
  • A native notification and a chime from 90%
  • Each step warns once per window across all sessions (kept in the mod's $.store), and a jump from 45% to 73% warns once, at 70%

Settings

Set in /config, or under pluginConfigs in ~/.claude/settings.json.

SettingDefaultOptions
Start warning at5030 40 50 60 70 80
Warn every105 10 20
Native notification from90off 70 80 90 95
Toast seconds84 8 15 30
Status lineonon off
Usage circlesonon off
Pace forecastonon off
7-day windowonon off
Soundwith notificationwith notification every step off

Development

claude plugin validate ~/.claude/mods/usage-warn
claude plugin test ~/.claude/mods/usage-warn
Source 3 files
hooks/register.tsx 124 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3import type { Reading } from '../types'
4import {
5  addSample,
6  circleColor,
7  forecastFull,
8  isTracked,
9  readSettings,
10  statusPart,
11  stepFor,
12  warningText,
13  type Sample,
14  type Settings,
15} from './logic'
16
17type Warned = { window: string; step: number }
18type Samples = { window: string; points: Sample[] }
19
20const isWarned = (v: unknown): v is Warned =>
21  typeof v === 'object' && v !== null && typeof (v as Warned).window === 'string' && typeof (v as Warned).step === 'number'
22
23const isSamples = (v: unknown): v is Samples =>
24  typeof v === 'object' && v !== null && typeof (v as Samples).window === 'string' && Array.isArray((v as Samples).points)
25
26const STATUS_REFRESH_MS = 60_000
27
28// The latest reading of each window, for the band and the status line; in $.state so a reload keeps them.
29const readings = atom({ plugin: 'usage-warn', key: 'readings' } as const, [] as Reading[])
30// Whether the band's info text is pinned open by a click on a circle.
31const isOpen = atom({ plugin: 'usage-warn', key: 'isOpen' } as const, false)
32
33async function drawStatus($: EngineInterface, s: Settings) {
34  if (!s.statusLine) return
35  const now = await $.clock.now()
36  const shown = (await read($, readings)).filter(r => r.percentUsed >= s.start)
37  $.ui.status(shown.length ? shown.map(r => statusPart(r, r.fullAt, now)).join('  ·  ') : undefined)
38}
39
40async function check($: EngineInterface, s: Settings, limits: readonly SessionRateLimit[]) {
41  const now = await $.clock.now()
42  const next: Reading[] = []
43
44  for (const limit of limits) {
45    const kind = limit.kind
46    if (!isTracked(kind, s.sevenDay)) continue
47    const window = limit.resetsAt ?? 'unknown'
48
49    // Samples feed the forecast; $.store is shared, so every session adds to the same history.
50    let fullAt: number | undefined
51    if (s.forecast) {
52      const stored = await $.store.get(`samples:${kind}`)
53      const points = isSamples(stored) && stored.window === window ? stored.points : []
54      const updated = addSample(points, [now, limit.percentUsed])
55      await $.store.set(`samples:${kind}`, { window, points: updated } satisfies Samples)
56      fullAt = forecastFull(limit, updated, now)
57    }
58    next.push({ kind, percentUsed: limit.percentUsed, resetsAt: limit.resetsAt, fullAt })
59
60    const step = stepFor(limit.percentUsed, s)
61    if (step === 0) continue
62    const last = await $.store.get(`warned:${kind}`)
63    const warnedStep = isWarned(last) && last.window === window ? last.step : 0
64    if (step <= warnedStep) continue
65
66    await $.store.set(`warned:${kind}`, { window, step } satisfies Warned)
67    const text = warningText(limit, step, fullAt, now)
68    $.ui.toast(text, { timeoutMs: s.toastMs })
69
70    const isLoud = s.notifyAt !== undefined && step >= s.notifyAt
71    if (isLoud) await $.ui.notify(text, { title: 'Claude usage' }).catch(() => undefined)
72    if (s.sound === 'every' || (s.sound === 'notify' && isLoud)) {
73      await $.audio.play({ asset: 'sounds/alert.wav' }).catch(() => undefined)
74    }
75  }
76
77  await update($, readings, () => next)
78  await drawStatus($, s)
79}
80
81export const register: Register = (on, options) => {
82  const s = readSettings(options)
83
84  on('session.start', async ($, e, next) => {
85    const result = await next(e)
86    const usage = await $.session.usage().catch(() => undefined)
87    if (usage) await check($, s, usage.rateLimits)
88    if (s.statusLine) $.clock.every(STATUS_REFRESH_MS, () => void drawStatus($, s))
89    return result
90  })
91
92  on('session.measure', async ($, e, next) => {
93    if (e.changed.includes('rateLimits')) await check($, s, e.rateLimits)
94    return next(e)
95  })
96
97  // One circle per window in use, at the right end of the band above the prompt.
98  // Hovering the row reveals the details; a click on a circle pins them open.
99  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
100    if (!s.indicator || e.props.hasSurvey) return next(e)
101    const shown = (await read($, readings)).filter(r => r.percentUsed > 0)
102    if (shown.length === 0) return next(e)
103
104    const now = await $.clock.now()
105    const isPinned = await read($, isOpen)
106    const toggle = () => update($, isOpen, open => !open)
107    const revealOnHover = isPinned ? {} : { display: 'none' as const, hover: { display: 'flex' as const } }
108    const { Box, Button, Text } = $.ui.resolve(e)
109
110    return (
111      <Box key="usage" flexDirection="row" justifyContent="flex-end" gap={1}>
112        <Box {...revealOnHover}>
113          <Text dimColor>{shown.map(r => statusPart(r, r.fullAt, now)).join('  ·  ')}</Text>
114        </Box>
115        {shown.map(r => (
116          <Button key={`usage-${r.kind}`} plain onPress={toggle}>
117            <Text color={circleColor(r.percentUsed, s)}>●</Text>
118          </Button>
119        ))}
120      </Box>
121    )
122  })
123}
124
hooks/logic.ts 146 lines
1import type { PluginOptions, SessionRateLimit } from 'claude-code'
2
3const HOUR_MS = 60 * 60 * 1000
4
5/** The windows the mod tracks: the 5-hour one, and every weekly one (7d, 7d opus, ...). */
6export const isTracked = (kind: string, sevenDay: boolean) =>
7  kind === 'five_hour' || (sevenDay && kind.startsWith('seven_day'))
8
9/** 5h, 7d, or 7d opus for a model-scoped weekly window. */
10export const windowLabel = (kind: string) => {
11  if (kind === 'five_hour') return '5h'
12  if (kind === 'seven_day') return '7d'
13  if (kind.startsWith('seven_day_')) return `7d ${kind.slice('seven_day_'.length).replace(/_/g, ' ')}`
14  return kind
15}
16
17const windowLength = (kind: string) => {
18  if (kind === 'five_hour') return 5 * HOUR_MS
19  if (kind.startsWith('seven_day')) return 7 * 24 * HOUR_MS
20  return undefined
21}
22
23export type Settings = {
24  start: number
25  step: number
26  notifyAt: number | undefined
27  toastMs: number
28  statusLine: boolean
29  forecast: boolean
30  sevenDay: boolean
31  sound: 'off' | 'notify' | 'every'
32  indicator: boolean
33}
34
35const num = (value: unknown, fallback: number) => {
36  const n = Number(value)
37  return Number.isFinite(n) && n > 0 ? n : fallback
38}
39
40export const readSettings = (o: PluginOptions): Settings => ({
41  start: num(o.start, 50),
42  step: num(o.step, 10),
43  notifyAt: o.notifyAt === 'off' ? undefined : num(o.notifyAt, 90),
44  toastMs: num(o.toastSeconds, 8) * 1000,
45  statusLine: o.statusLine !== 'off',
46  forecast: o.forecast !== 'off',
47  sevenDay: o.sevenDay !== 'off',
48  sound: o.sound === 'off' ? 'off' : o.sound === 'every step' ? 'every' : 'notify',
49  indicator: o.indicator !== 'off',
50})
51
52/** The circle's theme color: green below the start, warning from it, error from the notification percent. */
53export const circleColor = (percent: number, s: Settings): 'success' | 'warning' | 'error' => {
54  if (percent >= (s.notifyAt ?? 90)) return 'error'
55  if (percent >= s.start) return 'warning'
56  return 'success'
57}
58
59/** The highest warning step reached, or 0 below the start. */
60export const stepFor = (percent: number, s: Settings) => {
61  if (percent < s.start) return 0
62  return s.start + Math.floor((percent - s.start) / s.step) * s.step
63}
64
65export type Sample = [time: number, percent: number]
66
67const SAMPLE_SPAN_MS = HOUR_MS
68const MIN_SPAN_MS = 10 * 60 * 1000
69
70/** Keeps the last hour of samples, at most one a minute. */
71export const addSample = (samples: readonly Sample[], next: Sample): Sample[] => {
72  const last = samples[samples.length - 1]
73  const kept = last && next[0] - last[0] < 60_000 ? samples.slice(0, -1) : samples
74  return [...kept, next].filter(([t]) => next[0] - t <= SAMPLE_SPAN_MS)
75}
76
77/**
78 * When the window hits 100% at the current pace, or undefined when that is
79 * after the reset or the pace is unknown. Uses the last hour's samples, and
80 * falls back to the average since the window opened.
81 */
82export const forecastFull = (
83  limit: SessionRateLimit,
84  samples: readonly Sample[],
85  now: number,
86): number | undefined => {
87  const resetsAt = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
88  if (Number.isNaN(resetsAt) || limit.percentUsed >= 100) return undefined
89
90  let ratePerMs = 0
91  const first = samples[0]
92  if (first && now - first[0] >= MIN_SPAN_MS && limit.percentUsed > first[1]) {
93    ratePerMs = (limit.percentUsed - first[1]) / (now - first[0])
94  } else {
95    const length = windowLength(limit.kind)
96    const elapsed = length ? now - (resetsAt - length) : 0
97    if (elapsed >= MIN_SPAN_MS) ratePerMs = limit.percentUsed / elapsed
98  }
99  if (ratePerMs <= 0) return undefined
100
101  const fullAt = now + (100 - limit.percentUsed) / ratePerMs
102  return fullAt < resetsAt ? fullAt : undefined
103}
104
105const pad = (n: number) => String(n).padStart(2, '0')
106const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
107
108/** 14:30 today, or Thu 14:30 for another day. */
109export const clockTime = (ms: number, now: number) => {
110  const at = new Date(ms)
111  const hm = `${pad(at.getHours())}:${pad(at.getMinutes())}`
112  return at.toDateString() === new Date(now).toDateString() ? hm : `${DAYS[at.getDay()]} ${hm}`
113}
114
115/** 1h12m, 45m, or 3d4h. */
116export const duration = (ms: number) => {
117  const mins = Math.max(0, Math.round(ms / 60_000))
118  if (mins < 60) return `${mins}m`
119  const hours = Math.floor(mins / 60)
120  if (hours < 48) return `${hours}h${pad(mins % 60)}m`
121  return `${Math.floor(hours / 24)}d${hours % 24}h`
122}
123
124export const warningText = (
125  limit: SessionRateLimit,
126  step: number,
127  fullAt: number | undefined,
128  now: number,
129) => {
130  const label = windowLabel(limit.kind)
131  const resetsAt = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
132  const parts = [`${label} usage at ${step}%`]
133  if (!Number.isNaN(resetsAt)) parts.push(`resets ${clockTime(resetsAt, now)}`)
134  if (fullAt !== undefined) parts.push(`on pace for 100% at ${clockTime(fullAt, now)}`)
135  return parts.join(' · ')
136}
137
138export const statusPart = (limit: SessionRateLimit, fullAt: number | undefined, now: number) => {
139  const label = windowLabel(limit.kind)
140  const resetsAt = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
141  let text = `${label} ${Math.round(limit.percentUsed)}%`
142  if (!Number.isNaN(resetsAt)) text += ` · resets in ${duration(resetsAt - now)}`
143  if (fullAt !== undefined) text += ` · 100% by ${clockTime(fullAt, now)}`
144  return text
145}
146
types/index.d.ts 14 lines
1/** One usage window as last measured, kept for the band and the status line. */
2export type Reading = {
3  kind: string
4  percentUsed: number
5  resetsAt?: string
6  fullAt?: number
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    'usage-warn': { readings: Reading[]; isOpen: boolean }
12  }
13}
14