Band above the prompt: weekly quota used vs. the pace your usual week allows, with a traffic light.

A Claude Code mod: a band above the prompt that shows how much of the weekly quota you have used, against the pace your usual week allows, with a traffic light.
🟢 מכסה שבועית ███████│░░░░░░░ 42% יעד 38% · צפי 91% · איפוס בעוד 3י׳ 4ש׳
│ mark is where your usual week stands at this hour.scripts/build-profile.mjs scans the last 91 days of local Claude Code transcripts (~/.claude/projects) and sums price-weighted tokens per hour. It runs in a child node process once a day.seven_day percentage every 30 minutes. Finished weeks phase in over the transcript estimate and replace it after 3 full weeks.Transcripts cover this machine only; usage on claude.ai or other devices is missing until the quota readings take over.
seven_day window exists only there)node on PATHAt the prompt of a terminal Claude Code session:
/plugin install weekly-pace --marketplace Dyaromim/weekly-pace
Answer y to add the marketplace, then pick the user scope.
claude plugin validate .
claude plugin test .hooks/register.tsx 149 lines1// Keeps the seven_day quota reading and the usual-week profile, and draws the pacing band above the prompt.
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { HourlyUsage, PaceModel, QuotaReading, QuotaSample } from '../types'
5import { HOUR_MS, WEEK_MS, pace, usualCurve } from './pace'
6import type { Light } from './pace'
7
8const READING = { plugin: 'weekly-pace', key: 'reading' } as const
9const MODEL = { plugin: 'weekly-pace', key: 'model' } as const
10const NOW = { plugin: 'weekly-pace', key: 'now' } as const
11
12const PROFILE_DAYS = '91'
13const PROFILE_MAX_AGE_MS = 24 * HOUR_MS
14const SAMPLE_EVERY_MS = 30 * 60_000
15const SAMPLES_KEPT_MS = 16 * WEEK_MS
16const TICK_MS = 10 * 60_000
17
18const LIGHTS: Record<Light, { dot: string; color: string }> = {
19 green: { dot: '🟢', color: 'success' },
20 yellow: { dot: '🟡', color: 'warning' },
21 red: { dot: '🔴', color: 'error' },
22}
23
24const LABELS = {
25 title: 'מכסה שבועית',
26 target: 'יעד',
27 projected: 'צפי',
28 resetsIn: 'איפוס בעוד',
29 days: 'י׳',
30 hours: 'ש׳',
31 waiting: 'מכסה שבועית: ממתין לקריאה ראשונה מהשרת',
32}
33
34const sevenDay = (limits: SessionRateLimit[]): QuotaReading | null => {
35 const window = limits.find(l => l.kind === 'seven_day')
36 const resetsAt = window?.resetsAt ? Date.parse(window.resetsAt) : NaN
37 return window && Number.isFinite(resetsAt) ? { percentUsed: window.percentUsed, resetsAt } : null
38}
39
40const loadModel = async ($: EngineInterface): Promise<PaceModel> => ({
41 hourly: ((await $.store.get('hourly')) ?? {}) as HourlyUsage,
42 builtAt: Number((await $.store.get('builtAt')) ?? 0),
43 samples: ((await $.store.get('samples')) ?? []) as QuotaSample[],
44})
45
46/** Scans the transcripts in a child process: they are far larger than the sandbox may read. */
47const rebuildProfile = async ($: EngineInterface) => {
48 const script = `${$.plugin.root}/scripts/build-profile.mjs`
49 const { exitCode, stdout, stderr } = await $.process.run(['node', script, PROFILE_DAYS], {
50 timeoutMs: 600_000,
51 })
52 if (exitCode !== 0) {
53 $.ui.log(`weekly-pace: profile build failed: ${stderr.slice(0, 300)}`)
54 return
55 }
56 const { hours } = JSON.parse(stdout) as { hours: HourlyUsage }
57 const builtAt = await $.clock.now()
58 await $.store.set('hourly', hours)
59 await $.store.set('builtAt', builtAt)
60 const { value: current } = await $.state.get(MODEL)
61 await $.state.set(MODEL, { samples: current?.samples ?? [], hourly: hours, builtAt })
62}
63
64const recordSample = async ($: EngineInterface, quota: QuotaReading) => {
65 const t = await $.clock.now()
66 const samples = ((await $.store.get('samples')) ?? []) as QuotaSample[]
67 const last = samples[samples.length - 1]
68 const isFresh = last && last.resetsAt === quota.resetsAt && t - last.t < SAMPLE_EVERY_MS
69 if (isFresh && last.pct === quota.percentUsed) return
70 const kept = samples.filter(s => t - s.t < SAMPLES_KEPT_MS)
71 // Within the throttle window the newer reading replaces the older one.
72 if (isFresh) kept.pop()
73 kept.push({ t, pct: quota.percentUsed, resetsAt: quota.resetsAt })
74 await $.store.set('samples', kept)
75 const { value: current } = await $.state.get(MODEL)
76 if (current) await $.state.set(MODEL, { ...current, samples: kept })
77}
78
79const setReading = async ($: EngineInterface, limits: SessionRateLimit[]) => {
80 const quota = sevenDay(limits)
81 if (!quota) return
82 await $.state.set(READING, quota)
83 await $.state.set(NOW, await $.clock.now())
84 await recordSample($, quota)
85}
86
87const bar = (used: number, target: number, width: number) => {
88 const cell = (pct: number) => Math.min(Math.round((pct / 100) * width), width)
89 const filled = cell(used)
90 const mark = Math.min(cell(target), width - 1)
91 return Array.from({ length: width }, (_, i) =>
92 i === mark ? '│' : i < filled ? '█' : '░',
93 ).join('')
94}
95
96const resetsIn = (resetsAt: number, t: number) => {
97 const hours = Math.max(Math.round((resetsAt - t) / HOUR_MS), 0)
98 const days = Math.floor(hours / 24)
99 return `${LABELS.resetsIn} ${days > 0 ? `${days}${LABELS.days} ` : ''}${hours % 24}${LABELS.hours}`
100}
101
102export const register: Register = on => {
103 on('session.start', async ($, e, next) => {
104 const loaded = await loadModel($)
105 await $.state.set(MODEL, loaded)
106 await $.state.set(NOW, await $.clock.now())
107 await setReading($, (await $.session.usage()).rateLimits)
108
109 $.clock.every(TICK_MS, () => void $.clock.now().then(t => $.state.set(NOW, t)))
110 if ((await $.clock.now()) - loaded.builtAt > PROFILE_MAX_AGE_MS) {
111 // Off the start path: the scan takes a minute or two.
112 $.clock.after(5_000, () => void rebuildProfile($))
113 }
114 return next(e)
115 })
116
117 on('session.measure', async ($, e, next) => {
118 if (e.changed.includes('rateLimits')) await setReading($, e.rateLimits)
119 return next(e)
120 })
121
122 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
123 if (e.props.hasSurvey) return next(e)
124 const { Box, Text } = $.ui.resolve(e)
125 const { value: quota } = await $.state.get(READING)
126 if (!quota) return <Text dimColor>{LABELS.waiting}</Text>
127
128 const { value: t = 0 } = await $.state.get(NOW)
129 const { value: m } = await $.state.get(MODEL)
130 const curve = usualCurve(m?.hourly ?? {}, m?.samples ?? [], quota.resetsAt, t)
131 const { target, light, projected } = pace(quota.percentUsed, curve, quota.resetsAt, t)
132 const width = Math.max(Math.min(e.props.bodyColumns - 60, 40), 10)
133
134 return (
135 <Box flexDirection="row" gap={1}>
136 <Text>{LIGHTS[light].dot}</Text>
137 <Text dimColor>{LABELS.title}</Text>
138 <Text color={LIGHTS[light].color}>{bar(quota.percentUsed, target, width)}</Text>
139 <Text bold>{Math.round(quota.percentUsed)}%</Text>
140 <Text dimColor>
141 {LABELS.target} {Math.round(target)}%
142 {projected === null ? '' : ` · ${LABELS.projected} ${Math.round(projected)}%`}
143 {` · ${resetsIn(quota.resetsAt, t)}`}
144 </Text>
145 </Box>
146 )
147 })
148}
149hooks/pace.ts 132 lines1// Pure pacing math: the expected share of the weekly quota at a point in the window,
2// learned from past weeks, and the traffic light against it. No `$` here, so it tests directly.
3import type { HourlyUsage, QuotaSample } from '../types'
4
5export const HOUR_MS = 3_600_000
6export const WEEK_HOURS = 168
7export const WEEK_MS = WEEK_HOURS * HOUR_MS
8
9const PAST_WEEKS = 12
10// Observed quota readings beat transcript estimates; from this many full weeks on they rule alone.
11const OBSERVED_WEEKS_TO_TRUST = 3
12// A week used this little (vs. the median week) is a vacation, not a pattern.
13const QUIET_WEEK_SHARE = 0.2
14
15/** Cumulative share of the week's usage at each hour: curve[0] = 0, curve[168] = 1. */
16export type Curve = number[]
17
18export type Light = 'green' | 'yellow' | 'red'
19
20export type Pace = {
21 /** Percent of the quota your usual week has used by now. */
22 target: number
23 light: Light
24 /** Percent you end the week at if the rest of it follows your usual pattern. */
25 projected: number | null
26}
27
28export const linearCurve = (): Curve =>
29 Array.from({ length: WEEK_HOURS + 1 }, (_, h) => h / WEEK_HOURS)
30
31const cumulative = (buckets: number[]): Curve => {
32 const total = buckets.reduce((sum, v) => sum + v, 0)
33 const curve = [0]
34 let running = 0
35 for (const v of buckets) curve.push((running += v) / total)
36 return curve
37}
38
39const average = (curves: Curve[]): Curve =>
40 linearCurve().map((_, h) => curves.reduce((sum, c) => sum + (c[h] ?? 0), 0) / curves.length)
41
42const median = (values: number[]): number => {
43 const sorted = [...values].sort((a, b) => a - b)
44 return sorted[Math.floor(sorted.length / 2)] ?? 0
45}
46
47/** The usual week from transcript usage, aligned to the quota window that ends at `resetsAt`. */
48export const transcriptCurve = (hourly: HourlyUsage, resetsAt: number): Curve | null => {
49 const currentStart = resetsAt - WEEK_MS
50 const weeks: number[][] = []
51 for (let k = 1; k <= PAST_WEEKS; k++) {
52 const startHour = Math.floor((currentStart - k * WEEK_MS) / HOUR_MS)
53 weeks.push(Array.from({ length: WEEK_HOURS }, (_, i) => hourly[startHour + i] ?? 0))
54 }
55 const totals = weeks.map(w => w.reduce((sum, v) => sum + v, 0))
56 const floor = median(totals.filter(t => t > 0)) * QUIET_WEEK_SHARE
57 const usual = weeks.filter((_, i) => (totals[i] ?? 0) > 0 && (totals[i] ?? 0) >= floor)
58 return usual.length === 0 ? null : average(usual.map(cumulative))
59}
60
61/** The usual week from seven_day readings of finished windows. */
62export const observedCurves = (samples: QuotaSample[], now: number): Curve[] => {
63 const byWindow = new Map<number, QuotaSample[]>()
64 for (const s of samples) {
65 if (s.resetsAt > now) continue
66 byWindow.set(s.resetsAt, [...(byWindow.get(s.resetsAt) ?? []), s])
67 }
68 const curves: Curve[] = []
69 for (const [resetsAt, window] of byWindow) {
70 const start = resetsAt - WEEK_MS
71 const points = window
72 .map(s => ({ h: (s.t - start) / HOUR_MS, pct: s.pct }))
73 .sort((a, b) => a.h - b.h)
74 const final = Math.max(...points.map(p => p.pct))
75 // A window seen only at its start says nothing about how the week unfolds.
76 if (final < 5 || (points[points.length - 1]?.h ?? 0) < WEEK_HOURS - 24) continue
77 const known = [{ h: 0, pct: 0 }, ...points, { h: WEEK_HOURS, pct: final }]
78 curves.push(
79 Array.from({ length: WEEK_HOURS + 1 }, (_, h) => {
80 const after = known.findIndex(p => p.h >= h)
81 const b = known[after]!
82 const a = known[Math.max(after - 1, 0)]!
83 const t = b.h === a.h ? 1 : (h - a.h) / (b.h - a.h)
84 return Math.min((a.pct + t * (b.pct - a.pct)) / final, 1)
85 }),
86 )
87 }
88 return curves
89}
90
91/** Observed weeks phase in over transcript ones; a straight line until either exists. */
92export const usualCurve = (
93 hourly: HourlyUsage,
94 samples: QuotaSample[],
95 resetsAt: number,
96 now: number,
97): Curve => {
98 const fromTranscripts = transcriptCurve(hourly, resetsAt)
99 const observed = observedCurves(samples, now)
100 if (observed.length === 0) return fromTranscripts ?? linearCurve()
101 const fromReadings = average(observed)
102 if (!fromTranscripts || observed.length >= OBSERVED_WEEKS_TO_TRUST) return fromReadings
103 const w = observed.length / OBSERVED_WEEKS_TO_TRUST
104 return fromReadings.map((v, h) => w * v + (1 - w) * (fromTranscripts[h] ?? 0))
105}
106
107const at = (curve: Curve, hours: number): number => {
108 const h = Math.min(Math.max(hours, 0), WEEK_HOURS)
109 const low = Math.floor(h)
110 const high = Math.min(low + 1, WEEK_HOURS)
111 const below = curve[low] ?? 0
112 return below + (h - low) * ((curve[high] ?? below) - below)
113}
114
115/**
116 * Green up to 10% over target, yellow up to 25% over, red past that.
117 * Early in the week the target is tiny, so the bands never shrink below 2 and 5 points.
118 */
119export const lightFor = (used: number, target: number): Light => {
120 if (used <= Math.max(target * 1.1, target + 2)) return 'green'
121 if (used <= Math.max(target * 1.25, target + 5)) return 'yellow'
122 return 'red'
123}
124
125export const pace = (used: number, curve: Curve, resetsAt: number, now: number): Pace => {
126 const share = at(curve, (now - (resetsAt - WEEK_MS)) / HOUR_MS)
127 const target = share * 100
128 // Below 5% of the usual week a projection is noise.
129 const projected = share >= 0.05 ? used / share : null
130 return { target, light: lightFor(used, target), projected }
131}
132types/index.d.ts 26 lines1/** Weighted usage per epoch hour (ms / 3_600_000), from the local transcripts. */
2export type HourlyUsage = Record<string, number>
3
4/** One observed reading of the seven_day window. */
5export type QuotaSample = { t: number; pct: number; resetsAt: number }
6
7/** The latest seven_day reading. */
8export type QuotaReading = { percentUsed: number; resetsAt: number }
9
10/** What the band draws from. */
11export type PaceModel = {
12 hourly: HourlyUsage
13 builtAt: number
14 samples: QuotaSample[]
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'weekly-pace': {
20 reading: QuotaReading | null
21 model: PaceModel | null
22 now: number
23 }
24 }
25}
26