SLOPSHOPPER

usage-meter

Five-hour and weekly plan usage as one line under the prompt, with reset countdown and pace forecast

newcommandtoaststatustimer
v0.1.0no licenseupdated 2026-10-10ianwilson97/claude-mods/usage-meter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-meter
› 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-meter ⎿ usage-meter: Usage line hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

ianwilson-mods

Claude Code mods (function-hook plugins). Each folder is one plugin; .claude-plugin/marketplace.json makes this repo a marketplace.

ModWhat it does
cheap-worker/worker spins up Claude Code workers from a picker; /workers lists, messages and closes them. Team mode lets workers find each other (ListAgents) and talk (SendMessage).
agent-progress/progress: animated progress bar per agent (main, subagents, workers) from their task lists.
agent-map/map: project file tree; red 🔨 = an agent is editing it, green ✓ = done, ready for review.
context-barContext-window breakdown above the prompt.
usage-meter5-hour / weekly usage in the footer.
quick-settings⚙ footer button for model and effort.
skins/skins: colour themes (Tokyo Night, Dracula, Gruvbox, …).
cw-pane, driver-seat-guard, evidence-bandFor the driver-seat workflow; skip them elsewhere.

Install

/plugin marketplace add ianwilson97/claude-mods
/plugin install cheap-worker@ianwilson-mods
/plugin install agent-progress@ianwilson-mods
/plugin install agent-map@ianwilson-mods

On the machine where this folder is the live copy (loaded through CLAUDE_CODE_PLUGIN_DIRS), don't also install from the marketplace: each mod would load twice.

cheap-worker: two switches

Set per machine in /config (or pluginConfigs.cheap-worker.options in ~/.claude/settings.json):

OptionValues
provideropenrouter (default)Any OpenRouter model; key read from $CW_OPENROUTER_API_KEY (export it in ~/.zshenv). Workers skip user settings, hooks and plugins.
bedrockClaude only (Sonnet / Opus / Haiku). Workers keep your user settings, so the Bedrock env there (CLAUDE_CODE_USE_BEDROCK, AWS_REGION, AWS_PROFILE, …) applies. No call to openrouter.ai.
launchpanes (default)All workers tiled in tmux session cw: tmux new -A -s cw.
tabsEach worker gets a tmux session of its own and opens in a background tab: a cmux tab when Claude Code runs inside cmux, else a Ghostty tab (focus stays where you were).

Work laptop: provider = bedrock, launch = tabs.

Reaching a worker: crew

The mod installs crew into ~/.local/bin (that folder must be on your PATH; it never overwrites a different crew).

crew            # list running workers, pick one by number
crew ab12c      # attach to cw-ab12c

If Ghostty can't be scripted (macOS asks once to let Claude control Ghostty; a managed Mac may refuse), the worker still runs: press CMD+T and run crew. Closing a tab only detaches; the worker keeps running until you close it from /workers.

Needs: tmux; for auto-opened tabs, cmux or Ghostty 1.3+.

First run on a new machine: checks

  1. /plugin lists the mods as enabled. Managed settings can block marketplaces or plugins; if so, nothing loads.
  2. /worker, launch one with task say hi; a Ghostty tab opens (or the toast says CMD+T, then crew …).
  3. Turn on team, launch two workers, then from /workers send one a message: messaging is Claude Code's own (ListAgents / SendMessage) and works under Bedrock unless policy turns it off.
Source 2 files
hooks/register.tsx 105 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Meter, Window } from '../types'
5
6const meter = atom({ plugin: 'usage-meter', key: 'meter' } as const, null as Meter | null)
7const isHidden = atom({ plugin: 'usage-meter', key: 'footerHidden' } as const, false)
8const warned = atom({ plugin: 'usage-meter', key: 'warned' } as const, [] as string[])
9
10const HOUR = 3_600_000
11const SPAN: Record<string, number> = { five_hour: 5 * HOUR, seven_day: 7 * 24 * HOUR }
12const LABEL: Record<string, string> = { five_hour: 'session', seven_day: 'weekly', spend_limit: 'spend' }
13const STEPS = [75, 90]
14
15
16export function duration(ms: number): string {
17  const m = Math.max(0, Math.round(ms / 60_000))
18  const d = Math.floor(m / 1440)
19  const h = Math.floor((m % 1440) / 60)
20  return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m % 60}m` : `${m}m`
21}
22
23export type Pace = { elapsedPct: number; resetsIn: number; emptyIn: number | null }
24
25// Where the window is in time, and when it runs dry at the rate spent so far
26// (null when it won't before the reset).
27export function pace(w: Window, now: number): Pace | null {
28  const span = SPAN[w.kind]
29  if (span === undefined || w.resetsAt === undefined) return null
30  const resetsIn = Date.parse(w.resetsAt) - now
31  const elapsed = span - resetsIn
32  if (!(elapsed > 0) || !(resetsIn >= 0)) return null
33  const ratePerMs = w.percentUsed / elapsed
34  const emptyIn = ratePerMs > 0 ? (100 - w.percentUsed) / ratePerMs : Infinity
35  return {
36    elapsedPct: Math.min(100, (100 * elapsed) / span),
37    resetsIn,
38    emptyIn: emptyIn < resetsIn ? emptyIn : null,
39  }
40}
41
42const SHORT: Record<string, string> = { five_hour: '5h', seven_day: 'wk', spend_limit: '$' }
43
44// The footer line: `5h ▰▰▰▱▱ 62% ↻2h · wk ▰▱▱▱▱ 18% ↻4d`, a ⚠ ETA when the pace runs dry first.
45export function compact(windows: readonly Window[], now: number): string | undefined {
46  if (windows.length === 0) return undefined
47  return windows
48    .map(w => {
49      const pct = Math.round(w.percentUsed)
50      const filled = Math.min(5, Math.round(pct / 20))
51      const p = pace(w, now)
52      const reset = p ? ` ↻${duration(p.resetsIn).split(' ')[0]}` : ''
53      const eta = p?.emptyIn != null ? ` ⚠ out in ${duration(p.emptyIn)}` : ''
54      return `${SHORT[w.kind] ?? w.kind} ${'▰'.repeat(filled)}${'▱'.repeat(5 - filled)} ${pct}%${reset}${eta}`
55    })
56    .join(' · ')
57}
58
59async function footer($: EngineInterface) {
60  const m = await read($, meter)
61  const isOff = await read($, isHidden)
62  $.ui.status(m && !isOff ? compact(m.windows, await $.clock.now()) : undefined)
63}
64
65async function capture($: EngineInterface, windows: readonly Window[], costUsd: number | null) {
66  const now = await $.clock.now()
67  await update($, meter, () => ({ windows: [...windows], costUsd, measuredAt: now }))
68  await footer($)
69  const seen = await read($, warned)
70  for (const w of windows) {
71    const step = [...STEPS].reverse().find(s => w.percentUsed >= s)
72    const key = `${w.kind}:${step}:${w.resetsAt ?? ''}`
73    if (step !== undefined && !seen.includes(key)) {
74      $.ui.toast(`${LABEL[w.kind] ?? w.kind} limit at ${Math.round(w.percentUsed)}%`)
75      await update($, warned, list => [...list, key])
76    }
77  }
78}
79
80async function seed($: EngineInterface) {
81  const usage = await $.session.usage().catch(() => null)
82  if (usage) await capture($, usage.rateLimits, usage.cost?.usd ?? null)
83}
84
85export const register: Register = on => {
86  on('session.start', async ($, e, next) => {
87    await $.command.register({ name: 'usage-meter', description: 'Show or hide the usage line under the prompt' })
88    await seed($)
89    // ponytail: minute tick only refreshes countdowns; figures arrive via session.measure
90    $.clock.every(60_000, () => footer($))
91    return next(e)
92  })
93
94  on('session.measure', async ($, e, next) => {
95    await capture($, e.rateLimits, e.cost?.usd ?? null)
96    return next(e)
97  })
98
99  on('command.run', { command: 'usage-meter' }, async $ => {
100    await update($, isHidden, h => !h)
101    await footer($)
102    return { text: (await read($, isHidden)) ? 'Usage line hidden.' : 'Usage line shown.' }
103  })
104}
105
types/index.d.ts 9 lines
1export type Window = { kind: string; percentUsed: number; resetsAt?: string }
2export type Meter = { windows: Window[]; costUsd: number | null; measuredAt: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'usage-meter': { meter: Meter | null; footerHidden: boolean; warned: string[] }
7  }
8}
9