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

Claude Code mods (function-hook plugins). Each folder is one plugin; .claude-plugin/marketplace.json makes this repo a marketplace.
| Mod | What 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-bar | Context-window breakdown above the prompt. |
usage-meter | 5-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-band | For the driver-seat workflow; skip them elsewhere. |
/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.
Set per machine in /config (or pluginConfigs.cheap-worker.options in ~/.claude/settings.json):
| Option | Values | |
|---|---|---|
provider | openrouter (default) | Any OpenRouter model; key read from $CW_OPENROUTER_API_KEY (export it in ~/.zshenv). Workers skip user settings, hooks and plugins. |
bedrock | Claude 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. | |
launch | panes (default) | All workers tiled in tmux session cw: tmux new -A -s cw. |
tabs | Each 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.
crewThe 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+.
/plugin lists the mods as enabled. Managed settings can block marketplaces or plugins; if so, nothing loads./worker, launch one with task say hi; a Ghostty tab opens (or the toast says CMD+T, then crew …)./workers send one a message: messaging is Claude Code's own (ListAgents / SendMessage) and works under Bedrock unless policy turns it off.hooks/register.tsx 105 lines1import { 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}
105types/index.d.ts 9 lines1export 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