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

hooks/register.ts 74 lines1// 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}
74hooks/pace.ts 106 lines1// 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}
106types/index.d.ts 15 lines1export 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