SLOPSHOPPER

usage-band

A line above the prompt with the five-hour and seven-day limit windows, this session's context fill and cost, plus a nudge to clear when the window gets…

newbandspinnercommandtoast
★ 1v0.4.1MITupdated 2026-09-21yash-gadodia/claude-mods/usage-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-band
› 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 › /usage-band ⎿ usage-band: usage band on usage app claude-opus-5-5 5h 31% ctx 49% 97k $0.42 last 2k in · 1k out · 95k cache ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
usage app claude-opus-5-5 5h 31% ctx 49% 97k $0.42 last 2k in · 1k out · 95k cache ⟨Claude Code's own drawing⟩
README

claude-mods

Mods that keep an agent honest — plus a few that make the terminal fun.

claude-code · mod · function-hooks · typescript · macos

License: MIT Claude Code — mod test

usage  max  volty  opus-5  5h 34%  7d 12%  ctx 41% 82k  $1.23
scope: 3/4 files
 ▸

<sub>Thirteen mods, each drawing or guarding its own slice of the session. Above: usage-band and scope-guard.</sub>

usage-band, wod-band and wod-timer above the prompt <sub>usage-band, wod-band and wod-timer in a live session.</sub>

Why

Claude Code will tell you a deploy worked because git push exited 0. It will turn a one-line fix into a nine-file refactor and never mention it. Written rules in CLAUDE.md help until the model forgets them, and you find out on the deploy that breaks.

These are the same rules, moved out of prose and into the engine — where they hold whether or not the model remembers.

What a mod is

A mod is a Claude Code plugin whose behaviour lives in a TypeScript hooks module — register(on, options) wiring handlers onto engine events (tool.call, ui.render, turn.complete) rather than markdown the model reads. A mod can deny a tool call, rewrite it in flight, draw above the prompt, or put evidence in front of the model that it cannot argue with.

Every mod here is source you can read in one sitting. None of them phone home: there is no $.http.fetch anywhere in this repo.

Install

Function hooks are behind a flag. Set it first, in your shell profile or settings.json env:

export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1

Then, in Claude Code:

/plugin marketplace add yash-gadodia/claude-mods
/plugin install scope-guard@claude-mods

Install only what you want — each mod is independent. Update with claude plugin update <name>@claude-mods.

The mods

Discipline

ModWhat it does
scope-guardCounts the distinct files one turn edits. At the threshold it stops and makes the goal get restated, so a small ask cannot quietly become a refactor. /scope sets it.
deploy-verifyAfter a deploy command succeeds, waits for the GitHub Actions run it started, then curls the live URL with cache-busting and puts the verdict in the model's context. A deploy cannot be claimed without evidence.
receiptThe turn footer becomes a receipt: edits, runs and curls, with a warning when edits ran nothing. Destructive commands are never folded into a tool group, a claim of "fixed" with no run puts "unverified claim pending" in the spinner, and Tab suggests running the tests.
diff-reviewA docked pane with each edited file's hunk and keep or revert buttons. Reverting runs git directly; no model turn.
merge-gateDenies gh pr merge, a git merge on trunk, or a push to main unless the latest human message contains the word merge. Ship, push and deploy do not count. /merge-gate toggles it.
mini-offloadRewrites heavy Bash commands (test suites, builds, Docker) to run on a second machine over ssh — syncing the commit there first, because the remote checkout is the real hazard. /mini sets always, ask, or off.

Instruments

ModWhat it does
usage-bandThe 5-hour and 7-day limit windows, this session's context fill and cost, above the prompt. Nudges you to /clear when the window gets expensive.
money-bandLiquid assets, CPF, debt and month-to-date spend, read from a pair of SQLite databases over ssh. Every figure is the database's own; nothing is estimated.
copy-bandClick-to-copy buttons above the prompt for every code block and quoted draft in the last answer, plus a durable stash of older ones. Copying runs pbcopy directly — no model turn.
done-blinkWhen a turn lands, the iTerm2 tab blinks orange every half second until you send the next prompt or three minutes pass, so a finished session is obvious from any other tab. Works inside tmux with no passthrough config: the escape goes to the tmux client's tty. /done-blink 60 sets the ceiling.
chrome-switchSwitches the Claude in Chrome extension between named browser profiles using select_browser, which needs no approval click. /chromep maps them.

Fun

ModWhat it does
wod-bandA pixel-art athlete above the prompt who does a rep every turn. The session is an AMRAP of thrusters, burpees and pull-ups.
wod-timer3, 2, 1, GO in the spinner when you submit, a running gym clock while Claude works, and a whiteboard split in the footer when the turn lands: turn, time, AMRAP total, PR. /wod-timer voice on reads long splits aloud.

Turning them off

Every mod checks one environment variable before doing anything:

CLAUDE_MODS_DISABLE=all            # every mod in this repo becomes a pass-through
CLAUDE_MODS_DISABLE=scope-guard    # just that one
CLAUDE_MODS_DISABLE=wod-band,wod-timer

A disabled mod registers no command and every hook falls straight through to next(e).

Configuration

Mods that touch your machine declare their settings in plugin.json userConfig, so they are editable through /config rather than by hand:

  • scope-guard — /scope <n> sets the file threshold. /scope judge on|off (default on) lets a one-shot Haiku call decide at the threshold whether the next edit is still inside the goal you stated first; a yes raises the ceiling by one for that turn, a no or a failed call falls back to asking. /scope off disables the guard.
  • merge-gate — /merge-gate on|off. "merge x3" or "merge after each" in your message grants that many merges.
  • mini-offload — host (ssh alias, default mini), remotePath (the PATH export prefixed to every offloaded command). Per-repo overrides live at <repo>/.claude/mini-offload.json.
  • money-band — host, networthDb, financeDb. Expects SQLite databases with accounts/balances and transactions tables. efAccount (default UOB One) and efTarget (default 30000) feed the EF 41% footer label.
  • usage-band — sgdRate (default 1.30) for the S$ footer label; /usage-band sgd off hides it.
  • receipt — /receipt on|off|status.
  • done-blink — /done-blink on|off|status|<seconds> (default 180, max 900).
  • diff-review — /diff-review open|close|on|off.
  • deploy-verify — per-repo, at <repo>/.claude/deploy-verify.json: ``json { "url": "https://example.com", "matchFile": "VERSION" } ``
  • chrome-switch — ~/.claude/chrome-browsers.json, mapping labels to deviceIds.

Evidence, not decoration

scope-guard and deploy-verify also write a block into the model's own context (prompt.context), replacing their previous copy rather than accumulating:

# deployVerify
Last live deploy check, 2 minutes ago:
  VERIFIED live: https://example.com served "v3.10.10"
This is the only evidence about the live site in this session. Do not describe the deploy as
verified unless a line above starts with VERIFIED, and do not re-state an older claim over it.

A band above the prompt is for you. A context block is for the model — and it cannot be talked around. Repeated advisories are hashed and suppressed for a cooldown so this costs context once, not once per tool call; verdicts themselves are never throttled, because a verdict is evidence.

Tests

npm install
npm test

npm test typechecks every mod, runs its suite under claude plugin test (the official kit, claude-code/testing, with a mocked clock, store and process table), and checks each mod's footprint: the hooks, $ calls and env reads that claude plugin validate reports, pinned in <mod>/FOOTPRINT. A mod that starts calling $.http.fetch fails the build instead of a README sentence going stale. scripts/footprint.sh --write re-pins after a deliberate change.

The interesting half of deploy-verify's suite is the clean baseline: commands that mention a deploy without being one — echo "git push", grep -r "wrangler deploy", git push --dry-run, a commit message quoting make deploy, a heredoc containing one. A false positive curls a live URL nothing was pushed to and then reports a verdict about it, which is worse than not checking at all.

Design rules

The ones that survived contact with real sessions:

  1. A deny always carries the fix. Blocking without saying what to do instead strands the model in a retry loop. Every refusal here names the next action.
  2. Never block when there is no way through. If the only outcomes are "denied" and "denied again", let it run and say something instead.
  3. A render hook that throws takes the whole mod down with it. Every band wraps its frame in try/catch and falls back to what was there.
  4. Hooks have ten seconds of their own time. next(e) and $ calls are free; $.clock.sleep is not. Past the budget, or on a throw, the engine skips the hook silently unless it declares .catch — so every guard here catches and denies, and slow work belongs on a timer.
  5. Bands yield. e.props.hasSurvey means the engine wants that slot; give it back.

Requirements

Claude Code 2.1.271+ with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. macOS — copy-band shells out to pbcopy, and mini-offload/money-band assume ssh and a Homebrew path on the remote.

License

MIT

Source 1 files
hooks/register.tsx 316 lines
1/* @jsx h */
2import type { EngineInterface, Register, SessionMeasureInput } from 'claude-code'
3
4// The limit windows and this session's context fill on one line above the prompt.
5//
6// session.measure pushes what the last API response already reported, after each turn and whenever a
7// limit window moves a whole point, so it costs no request and no tokens and the render hook, which
8// runs on every keystroke, never reads anything. Before the first response there is no reading at all.
9//
10// The nudge exists because long windows are where the money goes: /usage says most of the spend
11// happens above 150k context, and a dim band is easy to stop seeing, so each new tenth of the
12// window past the threshold also toasts once.
13
14const NUDGE_AT = 55
15const SHOWN_KEY = 'usage-band:shown'
16const SGD_KEY = 'usage-band:sgd'
17let SGD_RATE = 1.3
18
19type Reading = {
20  fiveHour?: number
21  fiveHourResetsAt?: number
22  sevenDay?: number
23  ctxPercent?: number
24  ctxTokens?: number
25  usd?: number
26}
27
28type Delta = { in: number; out: number; cache: number }
29
30let reading: Reading | undefined
31let delta: Delta | undefined
32let account: string | undefined
33let model: string | undefined
34let project: string | undefined
35let shown = true
36let sgdShown = true
37let nudgedAt = 0
38
39// Which login the figures belong to. $.session.usage() does not carry it, and the same terminal
40// switches accounts mid-session with /login, so it is re-read each turn from the file the CLI
41// writes the logged-in account into.
42const readAccount = async ($: EngineInterface): Promise<void> => {
43  const home = await $.env.get('HOME').catch(() => undefined)
44  if (!home) return
45  const raw = await $.fs.read(`${home}/.claude.json`).catch(() => undefined)
46  if (typeof raw !== 'string') return
47  const found = accountIn(raw)
48  if (found) account = found.email
49}
50
51// The CLI rewrites this file, so a half-written one is unreadable rather than a logout: undefined
52// keeps the account as it was, a parsed file without an email is a logout.
53export const accountIn = (raw: string): { email: string | undefined } | undefined => {
54  let parsed: unknown
55  try {
56    parsed = JSON.parse(raw)
57  } catch {
58    return undefined
59  }
60  const email = (parsed as { oauthAccount?: { emailAddress?: unknown } } | null)?.oauthAccount?.emailAddress
61  return { email: typeof email === 'string' ? email : undefined }
62}
63
64// The model can change mid-session with /model and the folder is fixed, but both are one cheap
65// in-process read, so they go with the account each turn rather than keeping their own state.
66const readContext = async ($: EngineInterface): Promise<void> => {
67  model = await $.session.model().catch(() => undefined)
68  const cwd = await $.session.cwd().catch(() => undefined)
69  project = cwd ? cwd.split('/').filter(Boolean).at(-1) : undefined
70}
71
72type Usage = Pick<SessionMeasureInput, 'context' | 'rateLimits' | 'cost'>
73
74const take = (u: Usage): void => {
75  const window = (kind: string) => u.rateLimits.find((l) => l.kind === kind)
76  const resetsAt = Date.parse(window('five_hour')?.resetsAt ?? '')
77  reading = {
78    fiveHour: window('five_hour')?.percentUsed,
79    fiveHourResetsAt: Number.isFinite(resetsAt) ? resetsAt : undefined,
80    sevenDay: window('seven_day')?.percentUsed,
81    ctxPercent: u.context.percent,
82    ctxTokens: u.context.tokens,
83    usd: u.cost?.usd,
84  }
85}
86
87const read = async ($: EngineInterface): Promise<void> => {
88  const u = await $.session.usage().catch(() => undefined)
89  if (u) take(u)
90}
91
92const nudge = ($: EngineInterface): void => {
93  const percent = reading?.ctxPercent
94  if (percent === undefined) return
95  // A clear or a compact drops the fill, and the nudge arms itself again from there.
96  if (percent < nudgedAt) nudgedAt = 0
97  if (percent >= NUDGE_AT && percent >= nudgedAt + 10) {
98    nudgedAt = Math.floor(percent / 10) * 10
99    $.ui.toast(`context ${Math.round(percent)}% · /clear if the next thing is a new task, /compact to keep going`, {
100      timeoutMs: 8000,
101    })
102  }
103}
104
105type Part = { text: string; dim?: boolean; bold?: boolean; color?: string }
106type Segment = { parts: Part[]; drop: number }
107
108const family = (id: string) => /opus|sonnet|fable|haiku/.exec(id)?.[0] ?? id.replace(/^claude-/, '').replace(/\[1m\]$/, '')
109
110const GAP = '  '
111const width = (list: Segment[]) => list.reduce((n, s) => n + s.parts.reduce((m, p) => m + p.text.length, 0), 0) + GAP.length * Math.max(0, list.length - 1)
112
113// The whole line as one string, measured against the band's columns and shed by priority until it
114// fits: the turn delta, project, account, then the model to its family word, then cost, then the
115// nudge. The windows and the context fill are never shed. The "usage" label goes under 60 columns.
116export const layout = (state: {
117  account?: string
118  project?: string
119  model?: string
120  reading: Reading
121  delta?: Delta
122  now: number
123  columns: number
124}): Part[] => {
125  const { fiveHour, fiveHourResetsAt, sevenDay, ctxPercent, ctxTokens, usd } = state.reading
126  const left = fiveHourResetsAt !== undefined ? countdown(fiveHourResetsAt - state.now) : undefined
127  const segments: Segment[] = []
128  if (state.columns >= 60) segments.push({ parts: [{ text: 'usage', dim: true }], drop: 0 })
129  if (state.account !== undefined) segments.push({ parts: [{ text: state.account, color: 'cyan' }], drop: 3 })
130  if (state.project !== undefined) segments.push({ parts: [{ text: state.project, color: 'magenta' }], drop: 2 })
131  if (state.model !== undefined) segments.push({ parts: [{ text: state.model, dim: true }], drop: 4 })
132  if (fiveHour !== undefined) {
133    segments.push({
134      parts: [{ text: '5h ', dim: true }, { text: `${Math.round(fiveHour)}%`, bold: true, color: heat(fiveHour) }, ...(left ? [{ text: ` ↻${left}`, dim: true }] : [])],
135      drop: 0,
136    })
137  }
138  if (sevenDay !== undefined) {
139    segments.push({ parts: [{ text: '7d ', dim: true }, { text: `${Math.round(sevenDay)}%`, bold: true, color: heat(sevenDay) }], drop: 0 })
140  }
141  if (ctxPercent !== undefined) {
142    segments.push({
143      parts: [
144        { text: 'ctx ', dim: true },
145        { text: `${Math.round(ctxPercent)}%`, bold: true, color: heat(ctxPercent) },
146        ...(ctxTokens !== undefined ? [{ text: ` ${tokens(ctxTokens)}`, dim: true }] : []),
147      ],
148      drop: 0,
149    })
150  }
151  if (usd !== undefined) segments.push({ parts: [{ text: `$${usd.toFixed(2)}`, dim: true }], drop: 5 })
152  if (state.delta) {
153    const d = state.delta
154    segments.push({ parts: [{ text: `last ${tokens(d.in)} in · ${tokens(d.out)} out · ${tokens(d.cache)} cache`, dim: true }], drop: 1 })
155  }
156  if (ctxPercent !== undefined && ctxPercent >= NUDGE_AT) segments.push({ parts: [{ text: '· /clear on a new task', color: 'yellow' }], drop: 6 })
157
158  let kept = segments
159  let shortened = false
160  while (width(kept) > state.columns) {
161    const droppable = kept.filter((s) => s.drop > 0)
162    if (!droppable.length) break
163    const gone = droppable.reduce((a, b) => (a.drop < b.drop ? a : b))
164    if (gone.drop === 4 && !shortened && state.model !== undefined) {
165      shortened = true
166      kept = kept.map((s) => (s === gone ? { parts: [{ text: family(state.model ?? ''), dim: true }], drop: 4 } : s))
167      continue
168    }
169    kept = kept.filter((s) => s !== gone)
170  }
171  return kept.flatMap((s, i) => (i ? [{ text: GAP, dim: true }, ...s.parts] : s.parts))
172}
173
174export const countdown = (ms: number) => {
175  const m = Math.max(0, Math.ceil(ms / 60000))
176  return m >= 60 ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m` : `${m}m`
177}
178
179const heat = (percent: number) => (percent >= 75 ? 'red' : percent >= 50 ? 'yellow' : 'green')
180
181const tokens = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`)
182
183// The first thing anyone does when a mod misbehaves is try to turn it off. `CLAUDE_MODS_DISABLE=all`,
184// or a comma list naming this mod, makes every hook here a pass-through and registers no command.
185const MOD = 'usage-band'
186let disabled = false
187const readDisabled = async ($: EngineInterface): Promise<boolean> => {
188  const raw = (await $.env.get('CLAUDE_MODS_DISABLE').catch(() => undefined)) ?? ''
189  disabled = raw
190    .split(',')
191    .map(v => v.trim())
192    .some(v => v === 'all' || v === MOD)
193  return disabled
194}
195
196export const register: Register = (on, options) => {
197  const rate = Number(options.sgdRate)
198  if (Number.isFinite(rate) && rate > 0) SGD_RATE = rate
199
200  on('session.start', async ($, e, next) => {
201    const r = await next(e)
202    if (await readDisabled($)) return r
203    if ((await $.store.get(SHOWN_KEY).catch(() => undefined)) === false) shown = false
204    if ((await $.store.get(SGD_KEY).catch(() => undefined)) === false) sgdShown = false
205    await readAccount($)
206    await readContext($)
207    await $.command
208      .register({
209        name: 'usage-band',
210        description: 'The usage line above the prompt: limit windows, context fill, cost (usage-band)',
211        argumentHint: '[on | off | sgd on | sgd off]',
212        immediate: true,
213      })
214      .catch((err) => $.ui.log(`usage-band: /usage-band not registered: ${err}`))
215    return r
216  })
217
218  on('command.run', { command: 'usage-band' }, async ($, e) => {
219    const arg = e.args.trim().toLowerCase()
220    if (arg === 'sgd on' || arg === 'sgd off') {
221      sgdShown = arg === 'sgd on'
222      await $.store.set(SGD_KEY, sgdShown).catch(() => undefined)
223      $.ui.invalidate('ui.render')
224      return { text: sgdShown ? `S$ footer label on (rate ${SGD_RATE}, set sgdRate in /config)` : 'S$ footer label off' }
225    }
226    if (arg === 'off') {
227      shown = false
228      await $.store.set(SHOWN_KEY, false).catch(() => undefined)
229      $.ui.invalidate('ui.render')
230      return { text: 'usage band off' }
231    }
232    if (arg === 'on' || arg === '') {
233      shown = true
234      await $.store.set(SHOWN_KEY, true).catch(() => undefined)
235      await read($)
236      $.ui.invalidate('ui.render')
237      return { text: 'usage band on' }
238    }
239    return { text: `usage-band: no such argument "${arg}" — use on, off, sgd on or sgd off` }
240  })
241
242  on('session.measure', async ($, e, next) => {
243    if (disabled) return next(e)
244    const r = await next(e)
245    if (!shown) return r
246    take(e)
247    nudge($)
248    $.ui.invalidate('ui.render')
249    return r
250  })
251
252  // The last turn's own token counts come with the turn, not the measurement; the account, model and
253  // folder are re-read here because a turn is when any of them can have changed.
254  on('turn.complete', async ($, e, next) => {
255    if (disabled) return next(e)
256    const r = await next(e)
257    if (!shown || e.agentId) return r
258    if (e.usage) {
259      delta = {
260        in: e.usage.input_tokens,
261        out: e.usage.output_tokens,
262        cache: e.usage.cache_read_input_tokens + e.usage.cache_creation_input_tokens,
263      }
264    }
265    await readAccount($)
266    await readContext($)
267    $.ui.invalidate('ui.render')
268    return r
269  })
270
271  // The session's cost in dollars that mean something here, as a footer mode label beside `focus`.
272  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
273    if (disabled || !shown || !sgdShown || reading?.usd === undefined) return next(e)
274    const label = `S$${(reading.usd * SGD_RATE).toFixed(2)}`
275    return next({ ...e, props: { ...e.props, modes: [...e.props.modes, label] } })
276  })
277
278  // A render hook that throws unmounts the module and takes the whole mod with it, so a bad frame
279  // falls back to the band as it was rather than costing the session its usage line.
280  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
281    if (disabled) return next(e)
282    if (!shown || e.props.hasSurvey || e.surface !== 'terminal') return next(e)
283    try {
284      const { Box, Text } = await $.ui.resolve(e)
285      const rest = await next(e)
286
287      if (!reading) {
288        return (
289          <Box flexDirection="column">
290            <Text dimColor wrap="truncate">{account ? `usage · ${account} · no reading yet` : 'usage · no reading yet'}</Text>
291            {rest}
292          </Box>
293        )
294      }
295
296      const now = await $.clock.now()
297      const parts = layout({ account, project, model, reading, delta, now, columns: e.props.bodyColumns || 80 })
298      return (
299        <Box flexDirection="column">
300          <Text wrap="truncate">
301            {parts.map((p, i) => (
302              <Text key={String(i)} dimColor={p.dim} bold={p.bold} color={p.color}>
303                {p.text}
304              </Text>
305            ))}
306          </Text>
307          {rest}
308        </Box>
309      )
310    } catch (err) {
311      $.ui.log(`usage-band: render failed: ${err}`)
312      return next(e)
313    }
314  })
315}
316