Status-line meter for context fill, 5-hour and weekly plan limits, and session cost, plus /usage-meter for Remote Control.

Shows where the session stands against its limits. It is read-only and never nudges you to wrap up.
ctx 42% · 5h 81% · 7d 23% · $3.10. That is context fill, the 5-hour and 7-day plan windows, and session cost. Cost is the API-equivalent figure, not subscription usage; /money owns real spend./usage-meter: the same readings with reset times, as a transcript line. Use this one from the phone over Remote Control, where the status line isn't drawn.Plan windows show only on a subscription, after the first response of the session.
Hooks: session.start (registers the command), session.measure, command.run. Calls: $.session.usage, $.ui.status, $.ui.toast, $.clock.now, $.command.register.
Test: claude plugin test . (3 tests). Uninstall: see the repo README.
hooks/register.ts 82 lines1import type { Register, SessionContextUsage, SessionCost, SessionRateLimit } from 'claude-code'
2
3// Plan-limit percentages that raise a one-time toast per window.
4const THRESHOLDS = [80, 95]
5const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
6
7type Usage = { context: SessionContextUsage; rateLimits: SessionRateLimit[]; cost?: SessionCost }
8
9// Toasts already shown, keyed by window, reset time and threshold.
10const warned = new Set<string>()
11
12export function label(kind: string): string {
13 return LABELS[kind] ?? kind
14}
15
16export function statusText(u: Usage): string {
17 const parts: string[] = []
18 if (u.context.percent !== undefined) parts.push(`ctx ${u.context.percent}%`)
19 for (const r of u.rateLimits) parts.push(`${label(r.kind)} ${r.percentUsed}%`)
20 if (u.cost) parts.push(`$${u.cost.usd.toFixed(2)}`)
21 return parts.join(' · ')
22}
23
24function resetIn(iso: string | undefined, now: number): string {
25 if (!iso) return ''
26 const ms = Date.parse(iso) - now
27 if (!Number.isFinite(ms) || ms <= 0) return ''
28 const h = Math.floor(ms / 3_600_000)
29 const m = Math.round((ms % 3_600_000) / 60_000)
30 return h >= 24 ? ` (resets in ${Math.floor(h / 24)}d ${h % 24}h)` : ` (resets in ${h}h ${m}m)`
31}
32
33export function reportText(u: Usage, now: number): string {
34 const lines = ['Usage meter']
35 const ctx = u.context
36 lines.push(
37 ctx.percent === undefined
38 ? `- context: no reading yet (window ${ctx.window.toLocaleString('en-US')} tokens)`
39 : `- context: ${ctx.percent}% of ${ctx.window.toLocaleString('en-US')} tokens (${(ctx.tokens ?? 0).toLocaleString('en-US')} used)`,
40 )
41 if (u.rateLimits.length === 0) lines.push('- plan limits: no reading yet (none reported, or not on a subscription)')
42 for (const r of u.rateLimits) lines.push(`- ${label(r.kind)} limit: ${r.percentUsed}% used${resetIn(r.resetsAt, now)}`)
43 lines.push(u.cost ? `- session cost (API-equivalent, not subscription usage): $${u.cost.usd.toFixed(2)}` : '- session cost: not tracked here')
44 return lines.join('\n')
45}
46
47// Thresholds crossed for the first time this module load.
48export function newWarnings(limits: SessionRateLimit[]): string[] {
49 const out: string[] = []
50 for (const r of limits) {
51 for (const t of THRESHOLDS) {
52 const key = `${r.kind}|${r.resetsAt ?? ''}|${t}`
53 if (r.percentUsed >= t && !warned.has(key)) {
54 warned.add(key)
55 out.push(`${label(r.kind)} plan limit at ${r.percentUsed}%`)
56 }
57 }
58 }
59 return out
60}
61
62export const register: Register = on => {
63 on('session.start', async ($, e, next) => {
64 await $.command.register({
65 name: 'usage-meter',
66 description: 'Show context fill, plan-limit windows and session cost',
67 })
68 return next(e)
69 })
70
71 on('session.measure', async ($, e, next) => {
72 $.ui.status(statusText(e) || undefined)
73 for (const w of newWarnings(e.rateLimits)) $.ui.toast(w)
74 return next(e)
75 })
76
77 on('command.run', { command: 'usage-meter' }, async $ => {
78 const u = await $.session.usage()
79 return { text: reportText(u, await $.clock.now()) }
80 })
81}
82