Pins your 7-day plan usage against Dillon's weekly cap (default 20%) with pace and a toast when it nears or passes the cap. Reads the engine's own rate-limit…

hooks/register.ts 42 lines1import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
2
3import { levelOf, meterText, type Level } from './quota'
4
5// Status text is plain (the engine's status pin takes no color), so the meter uses block
6// characters. A toast fires when the level steps up: near (80% of the cap) or over.
7let cap = 20
8let last: Level | undefined
9
10async function show($: EngineInterface, limits: readonly SessionRateLimit[]) {
11 $.ui.status(meterText(limits, await $.clock.now(), cap))
12 const week = limits.find(l => l.kind === 'seven_day')
13 if (!week) return
14 const level = levelOf(week.percentUsed, cap)
15 if (level !== last && level !== 'ok') {
16 const used = +week.percentUsed.toFixed(1)
17 $.ui.toast(level === 'over' ? `Weekly plan usage ${used}% is past your ${cap}% cap` : `Weekly plan usage ${used}%, close to your ${cap}% cap`, { timeoutMs: 10000 })
18 }
19 last = level
20}
21
22export const register: Register = (on, options) => {
23 const asked = Number(options.weeklyCapPercent)
24 cap = asked > 0 ? asked : 20
25 last = undefined
26
27 on('session.start', async ($, e, next) => {
28 const r = await next(e)
29 try {
30 await show($, (await $.session.usage()).rateLimits)
31 } catch {
32 // no reading yet; the next measurement draws it
33 }
34 return r
35 })
36
37 on('session.measure', async ($, e, next) => {
38 if (e.changed.includes('rateLimits')) await show($, e.rateLimits)
39 return next(e)
40 })
41}
42hooks/quota.ts 60 lines1import type { SessionRateLimit } from 'claude-code'
2
3// Pure helpers: no `$`, so the tests drive them directly. The only data source is the
4// engine's own rate-limit windows (the same figures the status line gets). No window,
5// no text: a missing reading is never drawn as zero.
6
7export const WEEK_MS = 7 * 24 * 60 * 60 * 1000
8
9export type Level = 'ok' | 'near' | 'over'
10export type Pace = 'ahead' | 'on' | 'unknown'
11
12export const levelOf = (used: number, cap: number): Level => (used >= cap ? 'over' : used >= cap * 0.8 ? 'near' : 'ok')
13
14/** How far into the 7-day window we are, 0 to 1; undefined without a reset time. */
15export const elapsedFrac = (resetsAt: string | undefined, nowMs: number): number | undefined => {
16 const t = resetsAt === undefined ? NaN : Date.parse(resetsAt)
17 if (!Number.isFinite(t)) return undefined
18 return Math.min(1, Math.max(0, 1 - (t - nowMs) / WEEK_MS))
19}
20
21// ponytail: straight-line budget (cap * elapsed share) with 25% slack; no burn-rate model.
22export const paceOf = (used: number, cap: number, frac: number | undefined): Pace => {
23 if (frac === undefined || frac < 0.05) return 'unknown'
24 return used > cap * frac * 1.25 + 0.5 ? 'ahead' : 'on'
25}
26
27export const countdown = (resetsAt: string | undefined, nowMs: number): string => {
28 const t = resetsAt === undefined ? NaN : Date.parse(resetsAt)
29 if (!Number.isFinite(t) || t <= nowMs) return ''
30 const m = Math.floor((t - nowMs) / 60000)
31 const d = Math.floor(m / 1440)
32 const h = Math.floor((m % 1440) / 60)
33 if (d > 0) return `${d}d ${h}h`
34 return h > 0 ? `${h}h ${m % 60}m` : `${m}m`
35}
36
37const pct = (n: number) => `${+n.toFixed(1)}%`
38
39export const bar = (used: number, cap: number, cells = 10): string => {
40 const on = Math.round(Math.min(1, used / cap) * cells)
41 return '▰'.repeat(on) + '▱'.repeat(cells - on)
42}
43
44/** The pinned line, or undefined when the engine has no 7-day reading. */
45export const meterText = (limits: readonly SessionRateLimit[], nowMs: number, cap: number): string | undefined => {
46 const week = limits.find(l => l.kind === 'seven_day')
47 if (!week) return undefined
48 const five = limits.find(l => l.kind === 'five_hour')
49 const level = levelOf(week.percentUsed, cap)
50 const pace = paceOf(week.percentUsed, cap, elapsedFrac(week.resetsAt, nowMs))
51 const resets = countdown(week.resetsAt, nowMs)
52 const parts = [
53 `${level === 'over' ? 'OVER CAP ' : ''}weekly ${pct(week.percentUsed)} of ${cap}% cap ${bar(week.percentUsed, cap)}`,
54 pace === 'unknown' ? '' : pace === 'ahead' ? 'ahead of pace' : 'on pace',
55 five ? `5h ${pct(five.percentUsed)}` : '',
56 resets ? `resets ${resets}` : '',
57 ]
58 return parts.filter(Boolean).join(' · ')
59}
60