SLOPSHOPPER

weekly-pace

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

bandprocesstimer
v0.1.0no licenseupdated 2026-10-18Dyaromim/weekly-pace
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · weekly-pace
› 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 מכסה שבועית: ממתין לקריאה ראשונה מהשרת ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
מכסה שבועית: ממתין לקריאה ראשונה מהשרת
README

weekly-pace

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ש׳
  • Bar: quota used so far. The │ mark is where your usual week stands at this hour.
  • Light: green up to 10% over target, yellow up to 25% over, red past that (never tighter than 2 / 5 points, so early-week noise stays green).
  • Projection: where you end the week if the rest follows your usual pattern.

How the usual week is learned

  1. Transcripts: 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.
  2. Quota readings: the mod stores the real 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.

Requirements

  • Claude Code on a subscription plan (the seven_day window exists only there)
  • node on PATH

Install

At 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.

Develop

claude plugin validate .
claude plugin test .
Source 3 files
hooks/register.tsx 149 lines
1// 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}
149
hooks/pace.ts 132 lines
1// 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}
132
types/index.d.ts 26 lines
1/** 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