SLOPSHOPPER

quota-meter

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…

newtoaststatus
v0.1.0no licenseupdated 2026-10-08dillonmohr8777/claude-skills-repo/mods/quota-meter
A shopper browsing a rack in a slop shop
Source 2 files
hooks/register.ts 42 lines
1import 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}
42
hooks/quota.ts 60 lines
1import 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