SLOPSHOPPER

quota

Toasts when a rate-limit window crosses a threshold, with the pace and the account with the most room; /quota prints the board.

newcommandtoastprocess
v0.1.0no licenseupdated 2026-10-02qiushiyan/dotfiles/claude/.claude/mods/quota
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · quota
› 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 › /quota ⎿ quota: 5h quota at 31% ⎿ quota: ⎿ quota: ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
Source 3 files
hooks/register.ts 74 lines
1// quota: what the tmux context chip cannot say. The chip shows each window's
2// fill; this mod keeps the readings of a session, so it can announce a window
3// crossing a threshold once, say when it will run out at the current pace, and
4// name the account with the most room. /quota prints the same and the board.
5//
6// Readings arrive pushed (`session.measure`, when a window moves a whole
7// point); headroom is asked only on an urgent crossing and for /quota.
8
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, Register } from 'claude-code'
11
12import { alertText, describe, record, roomiest, URGENT } from './pace'
13import type { Lane, Limit } from './pace'
14
15const windows = atom({ plugin: 'quota', key: 'windows' } as const, {})
16
17/** `headroom limits` reads its cache from disk alone: no network, no request spent. */
18async function lane($: EngineInterface, limit: Limit): Promise<Lane | undefined> {
19  const ran = await $.process.run(['headroom', 'limits'], { timeoutMs: 5000 }).catch(() => undefined)
20  if (ran === undefined || ran.exitCode !== 0) return undefined
21  try {
22    return roomiest(JSON.parse(ran.stdout), limit.kind, limit.percentUsed)
23  } catch {
24    return undefined
25  }
26}
27
28export const register: Register = on => {
29  on('session.start', async ($, e, next) => {
30    await $.command.register({
31      name: 'quota',
32      description: "This session's rate-limit windows and pace, and every account's usage",
33    })
34
35    return next(e)
36  })
37
38  on('session.measure', async ($, e, next) => {
39    if (!e.changed.includes('rateLimits')) return next(e)
40
41    const now = await $.clock.now()
42    const before = await read($, windows)
43    const after = { ...before }
44    const crossings: { limit: Limit; crossed: number }[] = []
45    for (const limit of e.rateLimits) {
46      const { window, crossed } = record(before[limit.kind], limit.percentUsed, now)
47      after[limit.kind] = window
48      if (crossed !== undefined) crossings.push({ limit, crossed })
49    }
50    await update($, windows, () => after)
51
52    for (const { limit, crossed } of crossings) {
53      const isUrgent = crossed >= URGENT
54      const text = alertText(limit, after[limit.kind]?.samples ?? [], now, isUrgent ? await lane($, limit) : undefined)
55      $.ui.toast(text, { timeoutMs: isUrgent ? 20_000 : 10_000 })
56      // A toast is gone in seconds; the urgent one also stays as a transcript line.
57      if (isUrgent) $.ui.log(text)
58    }
59
60    return next(e)
61  })
62
63  on('command.run', { command: 'quota' }, async $ => {
64    const now = await $.clock.now()
65    const usage = await $.session.usage()
66    const seen = await read($, windows)
67    const own = usage.rateLimits.map(limit => describe(limit, seen[limit.kind]?.samples ?? [], now))
68    const board = await $.process.run(['headroom', 'accounts', '--compact'], { timeoutMs: 20_000 }).catch(() => undefined)
69    const accounts = board === undefined || board.exitCode !== 0 ? 'headroom did not answer.' : board.stdout.trim()
70
71    return { text: [own.length === 0 ? 'No rate-limit reading yet this session.' : own.join('\n'), accounts].join('\n\n') }
72  })
73}
74
hooks/pace.ts 106 lines
1// The mod's pure half: when a window's reading earns an alert, how fast the
2// window is filling, which account has the most room, and the words for it.
3
4import type { QuotaSample, QuotaWindow } from '../types'
5
6/** A window is announced as it passes each of these, once. */
7export const THRESHOLDS = [75, 90] as const
8export const URGENT = 90
9
10const KEPT = 60
11/** A pace is read from this much history, and needs this much to mean anything. */
12const PACE_WINDOW_MS = 45 * 60_000
13const PACE_MIN_SPAN_MS = 5 * 60_000
14const PACE_MIN_RISE = 2
15/** Another account is worth naming when it is this many points emptier. */
16const ROOM_MARGIN = 20
17
18/** The engine's window kinds, as shown, and as headroom names them. */
19const LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
20const HEADROOM_KIND: Record<string, string> = { five_hour: 'session', seven_day: 'weekly_all' }
21
22export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
23export type Pace = { outInMs: number; resetInMs: number }
24export type Lane = { launcher: string; percent: number }
25
26export const label = (kind: string): string => LABEL[kind] ?? kind
27
28/**
29 * A window after one more reading, and the threshold the reading crossed.
30 * Usage only rises inside a window, so a lower reading is a new window.
31 */
32export function record(before: QuotaWindow | undefined, percent: number, now: number): { window: QuotaWindow; crossed?: number } {
33  const last = before?.samples.at(-1)
34  const isSame = before !== undefined && last !== undefined && percent >= last.percent
35  const samples: QuotaSample[] = [...(isSame ? before.samples : []), { at: now, percent }].slice(-KEPT)
36  const alerted = isSame ? before.alerted : 0
37  const crossed = THRESHOLDS.findLast(t => percent >= t && t > alerted)
38  return crossed === undefined ? { window: { samples, alerted } } : { window: { samples, alerted: crossed }, crossed }
39}
40
41/** The pace of a window that will fill before it resets; undefined when it will not, or it is too early to say. */
42export function pace(samples: readonly QuotaSample[], resetsAt: string | undefined, now: number): Pace | undefined {
43  const recent = samples.filter(s => now - s.at <= PACE_WINDOW_MS)
44  const first = recent[0]
45  const last = recent.at(-1)
46  if (first === undefined || last === undefined || resetsAt === undefined) return undefined
47  const rise = last.percent - first.percent
48  const span = last.at - first.at
49  if (span < PACE_MIN_SPAN_MS || rise < PACE_MIN_RISE) return undefined
50  const outInMs = ((100 - last.percent) / rise) * span - (now - last.at)
51  const resetInMs = Date.parse(resetsAt) - now
52  return Number.isFinite(resetInMs) && outInMs < resetInMs ? { outInMs: Math.max(0, outInMs), resetInMs } : undefined
53}
54
55/** A duration as the board writes one: `25 min`, `1h20`, `2.3d`. */
56export function span(ms: number): string {
57  const minutes = Math.max(0, Math.round(ms / 60_000))
58  if (minutes < 60) return `${minutes} min`
59  if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`
60  return `${(minutes / (24 * 60)).toFixed(1)}d`
61}
62
63type BoardLimit = { kind?: unknown; percent?: unknown }
64type BoardAccount = { vendor?: unknown; launcher?: unknown; usage?: { limits?: unknown } }
65
66const percentOf = (limits: readonly BoardLimit[], kind: string): number | undefined => {
67  const percent = limits.find(l => l.kind === kind)?.percent
68  return typeof percent === 'number' ? percent : undefined
69}
70
71/**
72 * The Claude account with the most room in this kind of window, from
73 * headroom's board JSON: named only when it is clearly emptier than here and
74 * its other window is not itself nearly spent.
75 */
76export function roomiest(board: unknown, kind: string, ownPercent: number): Lane | undefined {
77  const wanted = HEADROOM_KIND[kind]
78  const accounts = (board as { accounts?: unknown } | null)?.accounts
79  if (wanted === undefined || !Array.isArray(accounts)) return undefined
80  const other = Object.values(HEADROOM_KIND).filter(k => k !== wanted)
81  let best: Lane | undefined
82  for (const account of accounts as BoardAccount[]) {
83    const limits = account.usage?.limits
84    if (account.vendor !== 'claude' || typeof account.launcher !== 'string' || !Array.isArray(limits)) continue
85    const percent = percentOf(limits as BoardLimit[], wanted)
86    const isSpentElsewhere = other.some(k => (percentOf(limits as BoardLimit[], k) ?? 0) >= URGENT)
87    if (percent === undefined || isSpentElsewhere || percent > ownPercent - ROOM_MARGIN) continue
88    if (best === undefined || percent < best.percent) best = { launcher: account.launcher, percent }
89  }
90  return best
91}
92
93/** One window in words: its fill, its reset, and its pace when it will run out first. */
94export function describe(limit: Limit, samples: readonly QuotaSample[], now: number): string {
95  const resetInMs = limit.resetsAt === undefined ? Number.NaN : Date.parse(limit.resetsAt) - now
96  const reset = Number.isFinite(resetInMs) ? `, resets in ${span(resetInMs)}` : ''
97  const found = pace(samples, limit.resetsAt, now)
98  const out = found === undefined ? '' : `. At this pace it runs out in about ${span(found.outInMs)}`
99  return `${label(limit.kind)} quota at ${Math.round(limit.percentUsed)}%${reset}${out}`
100}
101
102export function alertText(limit: Limit, samples: readonly QuotaSample[], now: number, lane: Lane | undefined): string {
103  const room = lane === undefined ? '' : ` Most room: ${lane.launcher} (${label(limit.kind)} ${lane.percent}%).`
104  return `${describe(limit, samples, now)}.${room}`
105}
106
types/index.d.ts 15 lines
1export type QuotaSample = { at: number; percent: number }
2
3/** One rate-limit window as this session has watched it. */
4export type QuotaWindow = {
5  samples: QuotaSample[]
6  /** The highest threshold already announced for this window; 0 for none. */
7  alerted: number
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    quota: { windows: Record<string, QuotaWindow> }
13  }
14}
15