SLOPSHOPPER

sub-meter

Usage meter in the prompt footer: an Enterprise or Team seat's monthly budget, or a subscription's tightest rate-limit window.

newspinnernetwork
A shopper browsing a rack in a slop shop
README

Sub Meter

A Claude Code mod that shows how much of your usage is left, at the bottom right of the prompt footer.

⏵⏵ auto mode on                        budget ███░░░░░░░ 31% · resets 19d
  • Enterprise or Team seat (claude.ai login): your seat's monthly budget, read from the same place /usage reads it. It resets on the 1st of the month (UTC).
  • Pro or Max subscription: whichever window is closest to its limit, the 5-hour or the weekly one.
  • API key or third-party provider: nothing to show; the footer stays as it is.

The bar turns yellow at 75% and red at 90%. Below 100 columns the bar and reset time drop; below 70 the meter hides.

Install

/plugin marketplace add Delavalom/delavalom-stack
/plugin install sub-meter@delavalom-stack

How it works

One hooks module, hooks/register.tsx:

  • On a subscription, the engine reports rate-limit windows at start ($.session.usage()) and after each response (session.measure). The meter shows the highest one.
  • With no windows, it calls GET https://api.anthropic.com/api/oauth/usage and reads extra_usage (is_enabled, utilization). It asks at session start, then at most every 5 minutes after a response. The request goes through $.http.fetch with the $.session.authorize() handle: the engine attaches your login for Anthropic's own host, and the token never reaches the mod. With CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC set, the request is refused and the meter keeps its last reading.
  • The last reading is kept in $.store, so a new session shows it right away.

Development

claude plugin validate ./plugins/sub-meter
claude plugin test ./plugins/sub-meter
Source 2 files
hooks/register.tsx 110 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit } from 'claude-code'
3
4import type { Reading } from '../types'
5
6const reading = atom({ plugin: 'sub-meter', key: 'reading' } as const, null as Reading | null)
7
8// Enterprise and Team seats get no rate-limit windows from the engine; their monthly budget is in the
9// account's usage, the endpoint /usage reads. The engine attaches the login; the token never reaches here.
10const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
11const POLL_MS = 5 * 60_000
12const LABELS: Record<string, string> = { five_hour: '5h', seven_day: 'week', spend_limit: 'budget' }
13const BAR = 10
14// ponytail: fixed breakpoints on terminal width, not a measure of the footer's free room; measure if it clips.
15const FULL_COLUMNS = 100
16const SHORT_COLUMNS = 70
17
18let polledAt = -Infinity
19
20/** The window closest to its limit, or null when the engine measured none. */
21function tightest (limits: readonly SessionRateLimit[]): Reading | null {
22  const known = limits.filter(limit => limit.kind in LABELS)
23  if (known.length === 0) return null
24  const top = known.reduce((a, b) => (b.percentUsed > a.percentUsed ? b : a))
25  return { label: LABELS[top.kind], percent: top.percentUsed, resetsAt: top.resetsAt }
26}
27
28/** The seat's monthly budget; null off a claude.ai login, without a budget, or when the request fails. */
29async function seatBudget ($: EngineInterface): Promise<Reading | null> {
30  try {
31    const auth = await $.session.authorize()
32    if (auth?.kind !== 'bearer') return null
33    const res = await $.http.fetch(USAGE_URL, { auth: auth.handle })
34    if (!res.ok) return null
35    const budget = JSON.parse(res.text)?.extra_usage
36    if (!budget?.is_enabled || typeof budget.utilization !== 'number') return null
37    const now = new Date(await $.clock.now())
38    // ponytail: the API sends no reset for the budget; the 1st of next month, UTC, matches its overage reset.
39    const resetsAt = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)).toISOString()
40    return { label: 'budget', percent: Math.round(budget.utilization * 10) / 10, resetsAt }
41  } catch {
42    return null
43  }
44}
45
46async function save ($: EngineInterface, next: Reading) {
47  await update($, reading, () => next)
48  await $.store.set('reading', next)
49}
50
51/** Takes the engine's windows when it has any, else asks for the seat's budget at most every POLL_MS. */
52async function refresh ($: EngineInterface, limits: readonly SessionRateLimit[]) {
53  const window = tightest(limits)
54  if (window) return save($, window)
55  const now = await $.clock.now()
56  if (now - polledAt < POLL_MS) return
57  polledAt = now
58  const budget = await seatBudget($)
59  if (budget) await save($, budget)
60}
61
62/** "19d", "4h", "12m": the largest whole unit left. */
63function countdown (ms: number) {
64  const minutes = Math.max(1, Math.floor(ms / 60_000))
65  if (minutes >= 1440) return `${Math.floor(minutes / 1440)}d`
66  if (minutes >= 60) return `${Math.floor(minutes / 60)}h`
67  return `${minutes}m`
68}
69
70const tone = (percent: number) => (percent >= 90 ? 'error' : percent >= 75 ? 'warning' : 'success')
71
72export const register: Register = on => {
73  on('session.start', async ($, e, next) => {
74    const stored = await $.store.get('reading')
75    if (stored && typeof stored === 'object') await update($, reading, () => stored as Reading)
76    await refresh($, (await $.session.usage()).rateLimits)
77    return next(e)
78  })
79
80  on('session.measure', async ($, e, next) => {
81    await refresh($, e.rateLimits)
82    return next(e)
83  })
84
85  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
86    const beneath = await next(e)
87    const shown = await read($, reading)
88    const columns = e.surface === 'terminal' ? e.viewport?.columns ?? Infinity : Infinity
89    if (!shown || columns < SHORT_COLUMNS) return beneath
90
91    const left = shown.resetsAt === undefined ? undefined : Date.parse(shown.resetsAt) - (await $.clock.now())
92    const percent = left !== undefined && left <= 0 ? 0 : shown.percent
93    const isFull = columns >= FULL_COLUMNS
94    const filled = Math.round((Math.min(Math.max(percent, 0), 100) / 100) * BAR)
95    const { Box, Text } = $.ui.resolve(e)
96
97    return (
98      <Box flexDirection="row">
99        <Text dimColor>{shown.label + ' '}</Text>
100        {isFull ? <Text color={tone(percent)}>{'█'.repeat(filled)}</Text> : null}
101        {isFull ? <Text dimColor>{'░'.repeat(BAR - filled) + ' '}</Text> : null}
102        <Text color={tone(percent)}>{`${Math.round(percent)}%`}</Text>
103        {isFull && left !== undefined && left > 0 ? <Text dimColor>{` · resets ${countdown(left)}`}</Text> : null}
104        {e.props.modes.length > 0 ? <Text dimColor>{'  '}</Text> : null}
105        {beneath}
106      </Box>
107    )
108  })
109}
110
types/index.d.ts 9 lines
1/** What the meter shows: one usage figure against its limit. */
2export type Reading = { label: string; percent: number; resetsAt?: string }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'sub-meter': { reading: Reading | null }
7  }
8}
9