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…

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

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.
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.
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.
Shown in /config under the plugin, or asked at install time:
| Option | Default | Meaning |
|---|---|---|
daily_percent | 14.3 | Percent 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_tools | true | Also deny tool calls and abort the turn when the line is crossed mid-turn. Off: only new prompts are blocked. |
$.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.$.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.
claude plugin validate .
claude plugin test
Tests live in tests/ and run against Claude Code's own engine with no session or network.
MIT, see the repository's LICENSE.
hooks/register.tsx 359 lines1import { 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
359types/index.d.ts 30 lines1export 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