Interactive band above the prompt: context fill, 5-hour and 7-day plan limits with resets, session cost, and a progress row for any subagents.

Two Claude Code mods — plugins of function hooks that run inside Claude Code (terminal and the Desktop app's Code tab). Needs Claude Code 2.1.287 or later (claude --version).
| Mod | What it does |
|---|---|
| blast-guard | Holds risky shell commands (rm -r, Remove-Item -Recurse, force push, git reset --hard, git clean -f, git checkout ., git branch -D) and asks Cancel / Run it, with a dry-run report of what would be lost. Blocks Python heredocs carrying \1 / \x.. escapes (they write control characters into files). Refuses a 5th concurrent subagent. |
| cache-meter | Status line: prompt-cache hit rate and the 1-hour TTL countdown (context size lives in usage-bar). A toast whenever a request rebuilds the cache instead of reading it, with the token count, the extra cost, and what changed just before (model switch, effort change, idle past the TTL, or a prompt-prefix change). /cache-meter prints the session summary. |
| usage-bar | One slim row above the prompt with solid bars for context fill and your 5-hour and 7-day plan limits (percent used, time to reset), plus the session cost. Bars are green under 60%, amber from 60%, red from 85%; at 90% the label turns into a bold red ⚠ warning and a toast fires once per reset window. more adds plain sentences (used, left, resets); × hides it. While subagents run, the row adds agent dots (pulsing while running, ✓ when done) and an agents button for savvy-progress's /agents-info. /usage-bar hides/shows, /usage-bar details expands and refreshes. |
In a Claude Code terminal session:
/plugin install blast-guard --marketplace satyamk4517/claude-mods
/plugin install cache-meter --marketplace satyamk4517/claude-mods
/plugin install usage-bar --marketplace satyamk4517/claude-mods
Answer y to add the marketplace, then pick a scope (user = every project).
Mods run with your permissions and are not sandboxed. Read the two hook files first, or list what they do without running them:
claude plugin validate ./blast-guard
claude plugin validate ./cache-meter
claude plugin validate ./usage-bar
blast-guard calls $.process.run (git dry runs only), $.ui.ask and $.agent.list. cache-meter calls the clock, status line, toasts and registers one command; it makes no network or file calls. usage-bar calls $.session.usage (the status line's own figures, no network request), $.agent.list, the clock, its store and toasts, and runs /agents-info only when you press agents.
PRICES in cache-meter/hooks/register.ts if your rates differ.$(...) or a script file walks past it. Pair it with deny permission rules for anything that must never run./cost totals, at API prices; on a subscription treat it as relative weight.claude plugin test ./blast-guard, claude plugin test ./cache-meter and claude plugin test ./usage-bar.hooks/register.tsx 217 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, Register, SessionUsage } from 'claude-code'
3
4import type { AgentRow, UsageSnapshot, View } from '../types'
5import { isLive, limitLong, limitName, money, pctColor, tokens, until } from './format'
6
7const usage = atom({ plugin: 'usage-bar', key: 'usage' } as const, null)
8const agents = atom({ plugin: 'usage-bar', key: 'agents' } as const, [])
9const view = atom({ plugin: 'usage-bar', key: 'view' } as const, { isExpanded: false, isHidden: false })
10const now = atom({ plugin: 'usage-bar', key: 'now' } as const, 0)
11
12const LIVE = new Set(['pending', 'running', 'waiting'])
13// From here a limit is a warning: bold red label and a one-time toast.
14const WARN_AT = 90
15
16const snapshotOf = (u: SessionUsage): UsageSnapshot => ({
17 ctxTokens: u.context.tokens ?? 0,
18 ctxWindow: u.context.window,
19 ctxPct: u.context.percent ?? (u.context.window ? Math.round((100 * (u.context.tokens ?? 0)) / u.context.window) : 0),
20 limits: u.rateLimits.map(r => ({ kind: r.kind, pct: r.percentUsed, resetsAt: r.resetsAt })),
21 usd: u.cost?.usd ?? null,
22})
23
24// The batch the band shows: agents started since the prompt, plus any still live.
25const rowsOf = (list: AgentInfo[], before: Set<string>): AgentRow[] =>
26 list
27 .filter(a => a.type !== 'teammate' && (LIVE.has(a.status) || !before.has(a.id)))
28 .map(a => ({ id: a.id, desc: a.description, type: a.type, status: a.status }))
29
30
31export const register: Register = on => {
32 let before = new Set<string>()
33 let isTurnRunning = false
34 const warned = new Set<string>()
35 const warnText = (u: UsageSnapshot, at: number) =>
36 u.limits
37 .filter(l => l.pct >= WARN_AT && !warned.has(l.kind + (l.resetsAt ?? '')))
38 .map(l => {
39 warned.add(l.kind + (l.resetsAt ?? ''))
40 return `${limitLong(l.kind)} at ${Math.round(l.pct)}%${l.resetsAt ? ', resets ' + until(l.resetsAt, at) : ''}`
41 })
42
43 on('session.start', async ($, e, next) => {
44 await $.command.register({ name: 'usage-bar', description: 'Show or hide the usage band; "/usage-bar details" expands it.' })
45 const saved = (await $.store.get('view')) as View | undefined
46 if (saved) await update($, view, () => saved)
47 // Reset countdowns and the plan limits move on their own: refresh twice a minute.
48 $.clock.every(30_000, async () => {
49 await update($, now, () => Date.now())
50 try {
51 const u = await $.session.usage()
52 await update($, usage, () => snapshotOf(u))
53 } catch {}
54 })
55 // While a turn runs, follow subagents closely.
56 $.clock.every(3000, async () => {
57 if (!isTurnRunning) return
58 try {
59 const list = await $.agent.list()
60 await update($, agents, () => rowsOf(list, before))
61 } catch {}
62 })
63 return next(e)
64 })
65
66 on('command.run', { command: 'usage-bar' }, async ($, e) => {
67 const wantsDetails = e.args.trim() === 'details'
68 try {
69 const u = await $.session.usage()
70 await update($, usage, () => snapshotOf(u))
71 } catch {}
72 await update($, view, v => (wantsDetails ? { isHidden: false, isExpanded: !v.isExpanded } : { ...v, isHidden: !v.isHidden }))
73 const v = await read($, view)
74 await $.store.set('view', v)
75 return { text: v.isHidden ? 'usage-bar hidden. /usage-bar shows it.' : v.isExpanded ? 'usage-bar: details shown.' : 'usage-bar shown.' }
76 })
77
78 on('prompt.submit', async ($, e, next) => {
79 try {
80 before = new Set((await $.agent.list()).filter(a => !LIVE.has(a.status)).map(a => a.id))
81 } catch {}
82 isTurnRunning = true
83 await update($, agents, list => list.filter(a => LIVE.has(a.status)))
84 return next(e)
85 }).catch(($, e, next) => next(e))
86
87 on('turn.step', async function* ($, e, next) {
88 const res = yield* next(e)
89 if (e.agentId === undefined) {
90 try {
91 const u = await $.session.usage()
92 const snap = snapshotOf(u)
93 await update($, usage, () => snap)
94 await update($, now, () => Date.now())
95 for (const w of warnText(snap, Date.now())) $.ui.toast('⚠ ' + w, { timeoutMs: 10000 })
96 } catch {}
97 }
98 return res
99 })
100
101 on('turn.complete', async ($, e, next) => {
102 const res = await next(e)
103 if (e.agentId === undefined) {
104 isTurnRunning = false
105 try {
106 const list = await $.agent.list()
107 await update($, agents, () => rowsOf(list, before))
108 } catch {}
109 }
110 return res
111 })
112
113 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
114 const v = await read($, view)
115 const u = await read($, usage)
116 const list = await read($, agents)
117 if (e.props.hasSurvey || v.isHidden || (u === null && list.length === 0)) return next(e)
118
119 const below = await next(e)
120 const { Box, Text, Button } = $.ui.resolve(e)
121 const t = (await read($, now)) || Date.now()
122
123 const toggleDetails = async () => {
124 await update($, view, x => ({ ...x, isExpanded: !x.isExpanded }))
125 await $.store.set('view', await read($, view))
126 }
127 const hide = async () => {
128 await update($, view, x => ({ ...x, isHidden: true }))
129 await $.store.set('view', await read($, view))
130 }
131 const openPanel = async () => {
132 try {
133 await $.command.run({ command: 'agents-info', args: '' })
134 } catch {
135 $.ui.toast('usage-bar: the agents panel needs the savvy-progress mod.')
136 }
137 }
138
139 const buttons = (
140 <Box key="buttons">
141 <Button key="details" label={v.isExpanded ? 'less' : 'more'} plain onPress={toggleDetails} />
142 {list.length > 0 && <Text> </Text>}
143 {list.length > 0 && <Button key="panel" label="agents" plain onPress={openPanel} />}
144 <Text> </Text>
145 <Button key="hide" label="×" plain onPress={hide} />
146 </Box>
147 )
148
149 const details = u && v.isExpanded && (
150 <Box key="details-rows" flexDirection="column">
151 <Text dimColor>
152 Context: {u.ctxTokens.toLocaleString()} of {u.ctxWindow.toLocaleString()} tokens used, {tokens(Math.max(0, u.ctxWindow - u.ctxTokens))} left.
153 </Text>
154 {u.limits.map(l => (
155 <Text key={'det-' + l.kind} dimColor>
156 {limitLong(l.kind)}: {Math.round(l.pct)}% used, {Math.max(0, 100 - Math.round(l.pct))}% left, resets {until(l.resetsAt, t) || 'unknown'}.
157 </Text>
158 ))}
159 {u.limits.length === 0 && <Text dimColor>No plan limits reported yet (they arrive with the next reply, or the account has none).</Text>}
160 {u.usd !== null && <Text dimColor>Session cost as /cost totals it: {money(u.usd)} (API-equivalent on a subscription).</Text>}
161 </Box>
162 )
163
164 // One line of short smooth bars, drawn with the surface's own text (terminal and desktop alike).
165 // Each bar is two solid blocks (fill and track) drawn with the box's own
166 // background: a real bar on desktop and in the terminal, in any font.
167 const CELLS = 10
168 const TRACK = '#3d444d'
169 const mini = (key: string, label: string, pct: number, tail: string) => {
170 const used = Math.max(0, Math.min(CELLS, Math.round((CELLS * pct) / 100)))
171 const isWarn = pct >= WARN_AT
172 return (
173 <Box key={key} flexShrink={0} alignItems="center" marginRight={3}>
174 <Text bold={isWarn} color={isWarn ? 'red' : undefined} dimColor={!isWarn} wrap="truncate-end">
175 {isWarn ? '⚠ ' : ''}{label}{' '}
176 </Text>
177 <Box key="bar" flexShrink={0} width={CELLS} height={1}>
178 {used > 0 && <Box key="fill" width={used} height={1} backgroundColor={pctColor(pct)} />}
179 {used < CELLS && <Box key="track" width={CELLS - used} height={1} backgroundColor={TRACK} />}
180 </Box>
181 <Text bold color={pctColor(pct)} wrap="truncate-end"> {Math.round(pct)}%</Text>
182 {tail ? <Text dimColor wrap="truncate-end"> {tail}</Text> : null}
183 </Box>
184 )
185 }
186 const done = list.filter(a => !isLive(a)).length
187 return (
188 <Box flexDirection="column">
189 <Box key="line" flexWrap="nowrap" alignItems="center">
190 {u && mini('ctx', 'ctx', u.ctxPct, tokens(u.ctxTokens))}
191 {u &&
192 u.limits.slice(0, 2).map(l =>
193 mini('lim-' + l.kind, l.kind === 'five_hour' ? '5h' : l.kind === 'seven_day' ? '7d' : limitName(l.kind), l.pct, l.resetsAt ? '↻' + until(l.resetsAt, t).replace('in ', '') : ''),
194 )}
195 {u && u.usd !== null && (
196 <Box key="cost" flexShrink={0} marginRight={3}>
197 <Text dimColor>{money(u.usd)}</Text>
198 </Box>
199 )}
200 {list.length > 0 && (
201 <Text>
202 <Text dimColor>agents </Text>
203 {list.slice(0, 8).map(a => (
204 <Text key={'dot-' + a.id} color={isLive(a) ? 'yellow' : 'green'}>{isLive(a) ? '●' : '✓'}</Text>
205 ))}
206 <Text dimColor> {done}/{list.length}</Text>
207 </Text>
208 )}
209 {buttons}
210 </Box>
211 {details}
212 {below}
213 </Box>
214 )
215 })
216}
217hooks/format.ts 116 lines1// Pure helpers for the band: no engine calls, so tests can import them.
2
3export type Limit = { kind: string; pct: number; resetsAt?: string }
4export type Usage = { ctxTokens: number; ctxWindow: number; ctxPct: number; limits: Limit[]; usd: number | null }
5export type Agent = { id: string; desc: string; type: string; status: string }
6
7const LIVE = new Set(['pending', 'running', 'waiting'])
8export const isLive = (a: Agent) => LIVE.has(a.status)
9
10export const pctColor = (pct: number) => (pct >= 85 ? 'red' : pct >= 60 ? 'yellow' : 'green')
11const HEX = { green: '#3fb950', yellow: '#d29922', red: '#f85149' } as const
12export const pctHex = (pct: number) => HEX[pctColor(pct)]
13
14export const bar = (pct: number, cells: number) => {
15 const filled = Math.max(0, Math.min(cells, Math.round((cells * pct) / 100)))
16 return '▰'.repeat(filled) + '▱'.repeat(cells - filled)
17}
18
19// A bar at eighth-of-a-cell resolution, padded with a light track.
20const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
21export const smoothBar = (pct: number, cells: number) => {
22 const eighths = Math.max(0, Math.min(cells * 8, Math.round((cells * 8 * pct) / 100)))
23 const full = Math.floor(eighths / 8)
24 const part = EIGHTHS[eighths % 8] ?? ''
25 const used = full + (part ? 1 : 0)
26 return { fill: '█'.repeat(full) + part, track: '░'.repeat(Math.max(0, cells - used)) }
27}
28
29export const tokens = (n: number) =>
30 n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
31
32// "in 2h10m", "in 3d4h", "now": relative, so no time zone is assumed.
33export const until = (iso: string | undefined, now: number) => {
34 if (!iso) return ''
35 const ms = Date.parse(iso) - now
36 if (!Number.isFinite(ms)) return ''
37 if (ms <= 0) return 'now'
38 const m = Math.round(ms / 60000)
39 const d = Math.floor(m / 1440)
40 const h = Math.floor((m % 1440) / 60)
41 return d > 0 ? `in ${d}d${h}h` : h > 0 ? `in ${h}h${String(m % 60).padStart(2, '0')}m` : `in ${m}m`
42}
43
44export const limitName = (kind: string) =>
45 kind === 'five_hour' ? '5-hour' : kind === 'seven_day' ? '7-day' : kind === 'spend_limit' ? 'Spend' : kind.replace(/_/g, ' ')
46
47export const limitLong = (kind: string) =>
48 kind === 'five_hour' ? '5-hour limit' : kind === 'seven_day' ? '7-day limit' : kind === 'spend_limit' ? 'Spend limit' : kind
49
50export const money = (usd: number) => '$' + usd.toFixed(2)
51
52const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
53const clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
54
55// The desktop strip: one 30px row of labelled mini bars, cost and agent dots.
56export const svgCard = (u: Usage | null, agents: Agent[], now: number) => {
57 const H = 30
58 const parts: string[] = []
59 let x = 10
60 const seg = (label: string, pct: number, tail: string, tip: string) => {
61 const bw = 64
62 const fill = (Math.max(0, Math.min(100, pct)) / 100) * bw
63 parts.push(`<g><title>${esc(tip)}</title>
64 <text x="${x}" y="19" class="mute sm">${esc(label)}</text>
65 <rect x="${x + 44}" y="11" width="${bw}" height="8" rx="4" class="trackfill"/>
66 <rect x="${x + 44}" y="11" width="${fill.toFixed(1)}" height="8" rx="4" fill="${pctHex(pct)}"/>
67 <text x="${x + 50 + bw}" y="19" class="fg b">${Math.round(pct)}%</text>
68 ${tail ? `<text x="${x + 84 + bw}" y="19" class="mute sm">${esc(tail)}</text>` : ''}</g>`)
69 x += 84 + bw + (tail ? tail.length * 6 + 14 : 4)
70 }
71 if (u) {
72 seg('ctx', u.ctxPct, tokens(u.ctxTokens), `Context: ${u.ctxTokens.toLocaleString()} of ${u.ctxWindow.toLocaleString()} tokens, ${tokens(Math.max(0, u.ctxWindow - u.ctxTokens))} left`)
73 for (const l of u.limits.slice(0, 2)) {
74 const left = Math.max(0, 100 - Math.round(l.pct))
75 const when = until(l.resetsAt, now)
76 seg(l.kind === 'five_hour' ? '5h' : l.kind === 'seven_day' ? '7d' : limitName(l.kind), l.pct, when ? '↻' + when.replace('in ', '') : '',
77 `${limitLong(l.kind)}: ${Math.round(l.pct)}% used, ${left}% left${when ? ', resets ' + when : ''}`)
78 }
79 if (u.usd !== null) {
80 parts.push(`<g><title>Session cost as /cost totals it (API-equivalent on a subscription)</title><text x="${x}" y="19" class="fg b">${money(u.usd)}</text></g>`)
81 x += 58
82 }
83 }
84 if (agents.length) {
85 const done = agents.filter(a => !isLive(a)).length
86 parts.push(`<text x="${x}" y="19" class="mute sm">agents ${done}/${agents.length}</text>`)
87 x += 66
88 agents.slice(0, 8).forEach(a => {
89 const live = isLive(a)
90 parts.push(`<g><title>${esc(`${a.type}: ${a.desc} (${a.status})`)}</title><circle cx="${x}" cy="15" r="4" fill="${live ? HEX.yellow : HEX.green}">${live ? '<animate attributeName="opacity" values="1;0.25;1" dur="1.4s" repeatCount="indefinite"/>' : ''}</circle></g>`)
91 x += 12
92 })
93 x += 6
94 }
95 const W = Math.max(120, Math.ceil(x))
96 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="Segoe UI, system-ui, sans-serif">
97<style>
98 .panel{fill:#f6f8fa;stroke:#d0d7de} .fg{fill:#1f2328} .mute{fill:#656d76} .trackfill{fill:#d0d7de}
99 .b{font-size:12px;font-weight:600} .sm{font-size:11px}
100 @media (prefers-color-scheme: dark){ .panel{fill:#161b22;stroke:#30363d} .fg{fill:#e6edf3} .mute{fill:#8b949e} .trackfill{fill:#30363d} }
101</style>
102<rect x="0.5" y="0.5" width="${W - 1}" height="${H - 1}" rx="8" class="panel"/>
103${parts.join('\n')}
104</svg>`
105}
106
107export const cardAlt = (u: Usage | null, agents: Agent[], now: number) =>
108 [
109 u ? `Context ${u.ctxPct}% (${tokens(u.ctxTokens)} of ${tokens(u.ctxWindow)})` : '',
110 ...(u ? u.limits.map(l => `${limitLong(l.kind)} ${Math.round(l.pct)}% used${l.resetsAt ? ', resets ' + until(l.resetsAt, now) : ''}`) : []),
111 u && u.usd !== null ? `session ${money(u.usd)}` : '',
112 agents.length ? `agents ${agents.filter(a => !isLive(a)).length}/${agents.length} done` : '',
113 ]
114 .filter(Boolean)
115 .join('; ')
116types/index.d.ts 18 lines1export type UsageSnapshot = {
2 ctxTokens: number
3 ctxWindow: number
4 ctxPct: number
5 limits: { kind: string; pct: number; resetsAt?: string }[]
6 usd: number | null
7}
8
9export type AgentRow = { id: string; desc: string; type: string; status: string }
10
11export type View = { isExpanded: boolean; isHidden: boolean }
12
13declare module 'claude-code' {
14 interface PluginState {
15 'usage-bar': { usage: UsageSnapshot | null; agents: AgentRow[]; view: View; now: number }
16 }
17}
18