SLOPSHOPPER

usage-pacer

Pace Claude Code's weekly usage limit day by day for the projects you choose: a side project is paused once the account runs ahead of its daily share, so your…

bandguardcommandtoastprompttimer
v0.1.0MITupdated 2026-10-16ref/claude/plugins/usage-pacer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-pacer
› 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 › /pacer ⎿ usage-pacer: not paced · /work/app ⎿ usage-pacer: no rate-limit reading yet (subscription plans only; appears after the first response) ⎿ usage-pacer: no paced projects yet — run /pacer on inside one ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

usage-pacer

A Claude Code mod that paces the weekly usage limit day by day for the projects you choose.

usage-pacer above the prompt: a progress bar of the week with the pace line, in three states

Claude Code's weekly limit is one pool for the whole account. A side project can drain it in two evenings and leave nothing for the work that pays. usage-pacer gives such a project a daily allowance instead: on day N of the 7-day window a paced project may run only while the account's usage is at or below N × 14.3%. Once usage is ahead of that line, the paced project is paused: new prompts are dropped, running tool calls are denied and the turn is aborted. Projects you did not mark are never touched, so your main work gets whatever is left.

The figure is the same seven_day reading /usage shows, so usage from claude.ai, the desktop app and other sessions counts. The day is derived from the window's reset time, not the calendar.

Install

In a Claude Code terminal session, add the ref/claude marketplace once, then install:

/plugin marketplace add ref/claude
/plugin install usage-pacer@ref

Pick the user scope, then accept or change the two settings. The mod is active in that session right away and in every session after.

To try it from a local clone instead:

claude --plugin-dir /path/to/claude/plugins/usage-pacer

Requires Claude Code 2.1.287 or later and a Claude Pro or Max subscription (API-key accounts report no rate limits, so the mod stays idle). Tested with 2.1.301.

Use

Inside a project you want paced:

/pacer on        # pace the current directory
/pacer status    # day of the window, % used, % allowed, list of paced projects
/pacer off       # stop pacing the current directory

While a paced project is open, one row above the prompt shows the week as a progress bar: a marker at today's pace line, faint ticks for each day, then 38% of 42.9% and day 3/7 · 5h 64% · resets 4d 7h. The desktop app draws a rounded bar; the terminal draws █░ cells. The colour is a muted traffic light: sage on track, amber within 15% of the line, brick past it (PAUSED).

Paced projects are stored by their absolute path (the directory you start claude in) and shared across sessions.

Settings

Shown in /config under the plugin, or asked at install time:

OptionDefaultMeaning
daily_percent14.3Percent of the week a paced project may have used per day elapsed. Lower it to reserve more for unpaced work: with 10, paced projects can use at most 70% of the week by day 7.
block_toolstrueAlso deny tool calls and abort the turn when the line is crossed mid-turn. Off: only new prompts are blocked.

How it works

  • $.session.usage() and the session.measure event give the account's rate-limit windows as the last API response reported them.
  • prompt.submit drops a prompt in a paced project that is over the line; tool.call denies tool calls and aborts the turn.
  • ui.render on AbovePrompt draws the bar: an Svg element on the desktop, Text cells in the terminal.
  • The list of paced paths lives in $.store, the last reading in $.state.

A single long turn can overshoot the line a little before the next check, since the figure is only as fresh as the last API response.

Develop

claude plugin validate .
claude plugin test

Tests live in tests/ and run against Claude Code's own engine with no session or network.

License

MIT, see the repository's LICENSE.

Source 2 files
hooks/register.tsx 359 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Snapshot, Window } from '../types'
5
6const DAY_MS = 86_400_000
7const WEEK_DAYS = 7
8const STORE_KEY = 'paced'
9
10const snapshot = atom({ plugin: 'usage-pacer', key: 'snapshot' } as const, null)
11
12type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
13
14// A muted traffic light: sage, amber, brick
15const TONE = {
16  ok: { terminal: 'green', svg: '#6f9a7a' },
17  warn: { terminal: 'yellow', svg: '#c9a04e' },
18  over: { terminal: 'red', svg: '#b8604a' },
19} as const
20type Tone = keyof typeof TONE
21
22// ---------- math ----------
23
24function toWindow(limit: RateLimit | undefined): Window | null {
25  if (!limit) return null
26  const ms = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
27  return { percent: limit.percentUsed, resetsAtMs: Number.isFinite(ms) ? ms : null }
28}
29
30/** 1..7: which day of the 7-day window we are in, counted back from its reset time. */
31function dayOfWindow(resetsAtMs: number | null, nowMs: number): number {
32  if (resetsAtMs === null) return WEEK_DAYS
33  const daysLeft = (resetsAtMs - nowMs) / DAY_MS
34  return Math.min(WEEK_DAYS, Math.max(1, WEEK_DAYS - Math.floor(daysLeft)))
35}
36
37function buildSnapshot(limits: readonly RateLimit[], nowMs: number, dailyPercent: number): Snapshot {
38  const week = toWindow(limits.find(l => l.kind === 'seven_day'))
39  const session = toWindow(limits.find(l => l.kind === 'five_hour'))
40  const day = week ? dayOfWindow(week.resetsAtMs, nowMs) : 0
41  const threshold = week ? Math.min(100, Math.round(day * dailyPercent * 10) / 10) : 100
42  return {
43    week,
44    session,
45    day,
46    threshold,
47    isOver: week ? week.percent > threshold : false,
48    dailyPercent,
49  }
50}
51
52function timeLeft(resetsAtMs: number | null, nowMs: number): string {
53  if (resetsAtMs === null) return '?'
54  const total = Math.max(0, Math.round((resetsAtMs - nowMs) / 60_000))
55  const d = Math.floor(total / 1440)
56  const h = Math.floor((total % 1440) / 60)
57  const m = total % 60
58  if (d > 0) return `${d}d ${h}h`
59  if (h > 0) return `${h}h ${m.toString().padStart(2, '0')}m`
60  return `${m}m`
61}
62
63function pct(n: number): string {
64  return `${Number.isInteger(n) ? n : n.toFixed(1)}%`
65}
66
67function toneOf(s: Snapshot): Tone {
68  if (!s.week) return 'ok'
69  if (s.isOver) return 'over'
70  if (s.week.percent > s.threshold * 0.85) return 'warn'
71  return 'ok'
72}
73
74// ---------- persistence ----------
75
76async function loadPaced($: EngineInterface): Promise<string[]> {
77  const saved = await $.store.get(STORE_KEY)
78  return Array.isArray(saved) ? saved.filter((p): p is string => typeof p === 'string') : []
79}
80
81async function isCwdPaced($: EngineInterface): Promise<boolean> {
82  const list = await loadPaced($)
83  const cwd = await $.session.cwd()
84  return list.includes(cwd)
85}
86
87async function setPaced($: EngineInterface, on: boolean): Promise<string> {
88  const cwd = await $.session.cwd()
89  const current = await loadPaced($)
90  const list = on ? Array.from(new Set([...current, cwd])) : current.filter(p => p !== cwd)
91  await $.store.set(STORE_KEY, list)
92  $.ui.invalidate('ui.render')
93  return cwd
94}
95
96/** Re-reads the account's rate limits and stores a fresh snapshot. */
97async function refresh($: EngineInterface, dailyPercent: number): Promise<Snapshot> {
98  const usage = await $.session.usage()
99  const now = await $.clock.now()
100  const next = buildSnapshot(usage.rateLimits, now, dailyPercent)
101  await update($, snapshot, () => next)
102  return next
103}
104
105// ---------- text ----------
106
107function blockReason(s: Snapshot): string {
108  const used = s.week ? pct(s.week.percent) : '?'
109  return (
110    `usage-pacer: this project is paced and the weekly limit is ahead of schedule ` +
111    `(day ${s.day}/7: ${used} used, ${pct(s.threshold)} allowed). ` +
112    `Stop here and end the turn. Do not retry or look for another way; the user will continue tomorrow or run /pacer off.`
113  )
114}
115
116function statusText(s: Snapshot, paced: boolean, cwd: string, all: readonly string[], nowMs: number): string {
117  const head = `${paced ? 'paced' : 'not paced'} · ${cwd}`
118  const body = s.week
119    ? `day ${s.day}/7 · used ${pct(s.week.percent)} · allowed ${pct(s.threshold)}` +
120      `${s.isOver ? ' · OVER' : ' · ok'} · resets in ${timeLeft(s.week.resetsAtMs, nowMs)}` +
121      (s.session ? `\n5h window · used ${pct(s.session.percent)} · resets in ${timeLeft(s.session.resetsAtMs, nowMs)}` : '')
122    : 'no rate-limit reading yet (subscription plans only; appears after the first response)'
123  const list = all.length
124    ? `paced projects:\n${all.map(p => '  ' + p).join('\n')}`
125    : 'no paced projects yet — run /pacer on inside one'
126  return `${head}\n${body}\n${list}`
127}
128
129/** The right-hand caption: day, 5h window, time to the weekly reset. */
130function caption(s: Snapshot, nowMs: number): string {
131  const parts = [`day ${s.day}/7`]
132  if (s.session) parts.push(`5h ${pct(s.session.percent)}`)
133  if (s.week) parts.push(`resets ${timeLeft(s.week.resetsAtMs, nowMs)}`)
134  return parts.join(' · ')
135}
136
137function headline(s: Snapshot): string {
138  if (!s.week) return ''
139  return s.isOver ? `PAUSED · ${pct(s.week.percent)} of ${pct(s.threshold)}` : `${pct(s.week.percent)} of ${pct(s.threshold)}`
140}
141
142// ---------- SVG row for the desktop ----------
143
144const ROW_H = 22
145const BAR_H = 14
146const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
147
148const xml = (s: string): string =>
149  s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
150
151function rowSvg(s: Snapshot, W: number, nowMs: number): string {
152  const week = s.week!
153  const tone = TONE[toneOf(s)].svg
154  const title = 'pacer'
155  const head = headline(s)
156  const cap = caption(s, nowMs)
157
158  const titleW = 40
159  const headW = Math.round(head.length * 6.4 + 8)
160  const capW = Math.round(cap.length * 6.1 + 8)
161  const BAR_X = 16 + titleW
162  const BAR_W = Math.max(80, W - BAR_X - headW - capW - 24)
163  const y0 = (ROW_H - BAR_H) / 2
164
165  const fillW = Math.round((BAR_W * Math.min(100, week.percent)) / 100)
166  const markX = Math.round((BAR_W * s.threshold) / 100)
167
168  // Day ticks on the track, one per day of the week
169  const ticks: string[] = []
170  for (let d = 1; d < WEEK_DAYS; d++) {
171    const x = Math.round((BAR_W * Math.min(100, d * s.dailyPercent)) / 100)
172    ticks.push(`<rect x="${x}" y="${BAR_H / 2 - 3}" width="1" height="6" rx="0.5"/>`)
173  }
174
175  const headX = BAR_X + BAR_W + 10
176  const capX = W - 8
177
178  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${ROW_H}" viewBox="0 0 ${W} ${ROW_H}">
179<style>
180.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e6e6e3}.tk{fill:#c9c9c5}.mk{fill:#3a3a3a}
181@media (prefers-color-scheme: dark){.t{fill:#e8e8e6}.m{fill:#9a9a9a}.k{fill:#2a2a2a}.tk{fill:#4a4a4a}.mk{fill:#dadada}}
182</style>
183<circle cx="6" cy="${ROW_H / 2}" r="3.5" fill="${tone}"/>
184<text class="t" x="16" y="${ROW_H / 2 + 4.5}" font-family="${FONT}" font-size="12.5" font-weight="500">${title}</text>
185<g transform="translate(${BAR_X},${y0})">
186<defs><clipPath id="c"><rect width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath></defs>
187<rect class="k" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>
188<g clip-path="url(#c)">
189<rect width="${fillW}" height="${BAR_H}" fill="${tone}"/>
190<g class="tk">${ticks.join('')}</g>
191</g>
192<rect class="mk" x="${markX - 1}" y="-2" width="2" height="${BAR_H + 4}" rx="1"/>
193</g>
194<text x="${headX}" y="${ROW_H / 2 + 4.5}" font-family="${FONT}" font-size="12" font-weight="600" fill="${tone}" font-variant-numeric="tabular-nums">${xml(head)}</text>
195<text class="m" x="${capX}" y="${ROW_H / 2 + 4.5}" text-anchor="end" font-family="${FONT}" font-size="11.5" font-variant-numeric="tabular-nums">${xml(cap)}</text>
196</svg>`
197}
198
199// ---------- terminal fallback ----------
200
201function barCells(width: number, percent: number, marker: number): { fill: string; rest: string; markAt: number } {
202  const w = Math.max(8, width)
203  const filled = Math.min(w, Math.round((Math.max(0, percent) / 100) * w))
204  const markAt = Math.min(w - 1, Math.round((marker / 100) * w))
205  return { fill: '█'.repeat(filled), rest: '░'.repeat(w - filled), markAt }
206}
207
208export const register: Register = (on, options) => {
209  const dailyPercent = Number(options.daily_percent ?? 14.3)
210  const blockTools = options.block_tools !== false
211
212  let turnId: string | null = null
213  let abortedTurn: string | null = null
214  let wasOver = false
215  let nowMs = Date.now()
216
217  on('session.start', async ($, e, next) => {
218    await refresh($, dailyPercent)
219    nowMs = await $.clock.now()
220    // Keep the countdown in the band current
221    $.clock.every(60_000, async () => {
222      nowMs = await $.clock.now()
223      $.ui.invalidate('ui.render')
224    })
225    try {
226      await $.command.register({
227        name: 'pacer',
228        description: 'Pace this project against the weekly limit: on, off, status',
229        argumentHint: '[on|off|status]',
230        immediate: true,
231      })
232    } catch {
233      // Already registered by a previous load; nothing to do
234    }
235    return next(e)
236  })
237
238  // /clear, /resume and /branch reset $.state, so load it again
239  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
240    await refresh($, dailyPercent)
241    return next(e)
242  })
243
244  on('session.measure', async ($, e, next) => {
245    nowMs = await $.clock.now()
246    const s = buildSnapshot(e.rateLimits, nowMs, dailyPercent)
247    await update($, snapshot, () => s)
248    if (s.isOver && !wasOver && (await isCwdPaced($)) && s.week) {
249      $.ui.toast(`Weekly limit ahead of schedule: ${pct(s.week.percent)} used, ${pct(s.threshold)} allowed. This project is paused.`)
250    }
251    wasOver = s.isOver
252    return next(e)
253  })
254
255  on('turn.start', ($, e, next) => {
256    turnId = e.turnId
257    return next(e)
258  })
259
260  on('prompt.submit', async ($, e, next) => {
261    if (!(await isCwdPaced($))) return next(e)
262    const s = await refresh($, dailyPercent)
263    if (!s.isOver || !s.week) return next(e)
264    $.ui.toast(`Paused: ${pct(s.week.percent)} of the week used, ${pct(s.threshold)} allowed on day ${s.day}.`)
265    return { drop: blockReason(s) }
266  }).catch(($, e, next) => {
267    // A broken pacer must never lock the person out of their own session
268    $.ui.log(`pacer check failed (${next.error.kind}); the prompt went through`)
269    return next(e)
270  })
271
272  on('tool.call', async ($, e, next) => {
273    if (!blockTools) return next(e)
274    if (!(await isCwdPaced($))) return next(e)
275    const s = (await read($, snapshot)) ?? (await refresh($, dailyPercent))
276    if (!s.isOver || !s.week) return next(e)
277    if (turnId && abortedTurn !== turnId) {
278      abortedTurn = turnId
279      $.ui.toast(`Weekly limit ahead of schedule (${pct(s.week.percent)} > ${pct(s.threshold)}). Stopping this turn.`)
280      void $.turn.abort({ turnId }).catch(() => {})
281    }
282    return { deny: blockReason(s) }
283  }).catch(($, e, next) => next(e))
284
285  on('command.run', { command: 'pacer' }, async ($, e) => {
286    const arg = (e.args ?? '').trim().toLowerCase()
287
288    if (arg === 'on' || arg === 'off') {
289      const cwd = await setPaced($, arg === 'on')
290      await refresh($, dailyPercent)
291      return { text: arg === 'on' ? `pacing ${cwd}` : `no longer pacing ${cwd}` }
292    }
293
294    const s = await refresh($, dailyPercent)
295    nowMs = await $.clock.now()
296    const cwd = await $.session.cwd()
297    const all = await loadPaced($)
298    return { text: statusText(s, all.includes(cwd), cwd, all, nowMs) }
299  })
300
301  // ---------- the band above the prompt ----------
302
303  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
304    if (e.props.hasSurvey || !(await isCwdPaced($))) return next(e)
305    const s = await read($, snapshot)
306    if (!s || !s.week) return next(e)
307
308    // Keep the other mods' bands beneath this row; the engine's own placeholder is never nested
309    const drawn = await Promise.resolve(next(e)).catch(() => null)
310    const below = drawn && typeof drawn === 'object' && (drawn as { type?: string }).type !== 'engine' ? drawn : null
311
312    const ui = $.ui.resolve(e)
313    const { Box, Text } = ui
314    const tone = toneOf(s)
315
316    if (e.surface !== 'terminal' && 'Svg' in ui) {
317      const { Svg } = ui
318      // About 8 CSS px per reported column
319      const width = Math.max(320, Math.min(1400, (e.props.bodyColumns || 100) * 8 - 32))
320      return (
321        <Box flexDirection="column">
322          <Svg source={rowSvg(s, width, nowMs)} alt={`pacer: ${headline(s)}, ${caption(s, nowMs)}`} width={width} height={ROW_H} />
323          {below}
324        </Box>
325      )
326    }
327
328    const head = headline(s)
329    const cap = caption(s, nowMs)
330    const width = Math.max(10, Math.min(40, e.props.bodyColumns - head.length - cap.length - 14))
331    const { fill, rest, markAt } = barCells(width, s.week.percent, s.threshold)
332    const cells = fill + rest
333    const before = cells.slice(0, markAt)
334    const after = cells.slice(markAt + 1)
335    const filledBefore = Math.min(fill.length, before.length)
336    const filledAfter = Math.max(0, fill.length - markAt - 1)
337    const color = TONE[tone].terminal
338
339    return (
340      <Box flexDirection="column">
341        <Box flexDirection="row" gap={1}>
342          <Text color={color}>●</Text>
343          <Text>pacer</Text>
344          <Box>
345            <Text color={color}>{before.slice(0, filledBefore)}</Text>
346            <Text dimColor>{before.slice(filledBefore)}</Text>
347            <Text bold>│</Text>
348            <Text color={color}>{after.slice(0, filledAfter)}</Text>
349            <Text dimColor>{after.slice(filledAfter)}</Text>
350          </Box>
351          <Text color={color} bold>{head}</Text>
352          <Text dimColor>{cap}</Text>
353        </Box>
354        {below}
355      </Box>
356    )
357  })
358}
359
types/index.d.ts 30 lines
1export type Window = {
2  /** Percent of the window used, as the last API response reported it */
3  percent: number
4  /** When the window resets, ms since the epoch; null when unknown */
5  resetsAtMs: number | null
6}
7
8export type Snapshot = {
9  /** The 7-day window; null when the engine has no reading (API key, or before the first response) */
10  week: Window | null
11  /** The 5-hour window; null when the engine has no reading */
12  session: Window | null
13  /** 1..7: which day of the 7-day window we are in */
14  day: number
15  /** Percent a paced project may have used by now */
16  threshold: number
17  /** week.percent > threshold */
18  isOver: boolean
19  /** Daily share of the week, from the plugin's settings */
20  dailyPercent: number
21}
22
23declare module 'claude-code' {
24  interface PluginState {
25    'usage-pacer': {
26      snapshot: Snapshot | null
27    }
28  }
29}
30