Hub band, Token Weather and Blast Radius for Claude Code sessions.

Three Claude Code mods in one plugin (Claude Code 2.1.296, early-access function hooks). No network calls. The only writes go to ~/.claude/state/mods/.
Hub band. In a session started in ~/repos, one line above the prompt: /prep not run until /prep has run in the session, then open lanes (rows of ~/.claude/state/lanes/index.tsv from the last 3 days whose report is missing or whose out.log has no exit line), unticked items in ~/.claude/state/muse-inbox.md, and Client mail: N new (unticked lines containing Client mail: or Client alert:). Counts are read from those private files at run time and refreshed every 5 seconds.
Token Weather. From the context percent: clear under 40%, showers 40-60%, storm past 60%. It adds "hand off and end" past 60% in ~/repos (the hub rule) and past 80% everywhere. Shown on the same line above the prompt.
Blast Radius. A safety net on Bash. It holds rm -rf, docker system|volume|image prune, docker compose down -v, docker volume rm, git push --force / -f, and production deploys (--prod, or redeploy aimed at production). Interactively it lists what the command would touch and asks 1 - Run it / 2 - Refuse. In a non-interactive session (claude -p, the Console lanes) it never waits: force pushes and production deploys are denied with a reason, the rest are allowed, and each verdict is appended to ~/.claude/state/mods/blast-radius.log. Regenerable caches and paths strictly inside /tmp, $TMPDIR, or an individual lane's scratch area are allowed without a prompt and logged as allowed (own scratch). If the guard itself throws, the command is denied. The match is best effort (a net, not a shell parser). Existing hooks and permission rules still apply.
claude plugin marketplace add TortoiseWolfe/Claude_Commandz
claude plugin install afa-mods@afa-mods --scope user
On this machine the marketplace is the local clone (claude plugin marketplace add ~/repos/Claude_Commandz), so a git pull plus /reload-plugins picks up edits.
claude plugin disable afa-mods # keep it installed, stop it loading
claude plugin uninstall afa-mods # remove it
claude plugin validate mods/afa-mods
claude plugin test mods/afa-modssrc/index.tsx 224 lines1import { atom, read, update } from 'claude-code'
2import type { Engine, Register } from 'claude-code'
3
4import {
5 bandText,
6 classify,
7 countInbox,
8 hasExitLine,
9 ownScratchTargets,
10 recentLanes,
11 targetsOf,
12 weather,
13} from './lib'
14import type { Hold } from './lib'
15
16const isHub = atom({ plugin: 'afa-mods', key: 'isHub' } as const, false)
17const prepRan = atom({ plugin: 'afa-mods', key: 'prepRan' } as const, false)
18const percent = atom({ plugin: 'afa-mods', key: 'percent' } as const, null)
19const band = atom({ plugin: 'afa-mods', key: 'band' } as const, { lanes: 0, inbox: 0, client: 0 })
20
21const readText = async ($: Engine, path: string) =>
22 (await $.fs.exists(path)) ? String(await $.fs.read(path)) : ''
23
24// Open lanes, the unticked inbox lines and client lines, read from private
25// files at run time. A file that fails to read counts as nothing.
26async function refreshBand($: Engine) {
27 const home = (await $.env.get('HOME')) ?? ''
28 const state = `${home}/.claude/state`
29 const lanes = recentLanes(
30 await readText($, `${state}/lanes/index.tsv`).catch(() => ''),
31 await $.clock.now(),
32 )
33 let open = 0
34 for (const l of lanes) {
35 const dir = `${state}/lanes/${l.date}-${l.name}`
36 try {
37 const report = l.report !== '-' ? l.report : `${dir}/report.md`
38 if (!(await $.fs.exists(report))) open += 1
39 else if (await $.fs.exists(`${dir}/out.log`)) {
40 if (!hasExitLine(await readText($, `${dir}/out.log`))) open += 1
41 }
42 } catch {
43 // unreadable lane: not counted
44 }
45 }
46 const { inbox, client } = countInbox(await readText($, `${state}/muse-inbox.md`).catch(() => ''))
47 const next = { lanes: open, inbox, client }
48 const prev = await read($, band)
49 if (JSON.stringify(prev) !== JSON.stringify(next)) await update($, band, () => next)
50}
51
52const LOG = '.claude/state/mods/blast-radius.log'
53
54const firstLines = (text: string, n: number) => {
55 const lines = text.split('\n').filter(l => l.trim() !== '')
56 return lines.slice(0, n).join('\n') + (lines.length > n ? `\n... ${lines.length - n} more` : '')
57}
58
59const run = async ($: Engine, argv: string[]) => {
60 try {
61 const r = await $.process.run(argv, { timeoutMs: 10_000 })
62 return r.exitCode === 0 ? r.stdout : ''
63 } catch {
64 return ''
65 }
66}
67
68// What one hold would touch, in a few lines. Read-only probes only; a remote
69// URL is never printed (it can carry a token).
70async function touches($: Engine, home: string, hold: Hold): Promise<string> {
71 const head = `${hold.kind}: ${hold.segment.slice(0, 300)}${hold.segment.length > 300 ? ' ... (cut, full command is longer)' : ''}`
72 if (hold.kind === 'rm -rf') {
73 const out: string[] = []
74 const all = targetsOf(hold.segment)
75 if (all.length > 8) out.push(` ... ${all.length - 8} more targets not listed`)
76 for (const raw of all.slice(0, 8)) {
77 const path = raw.replace(/^~(?=\/|$)/, home).replace(/^["']|["']$/g, '')
78 if (/[*?[]/.test(path)) {
79 out.push(` ${path} (pattern, expands when it runs)`)
80 continue
81 }
82 try {
83 const st = await $.fs.stat(path)
84 if (st.kind === 'directory') {
85 const n = (await $.fs.list(path)).length
86 out.push(` ${path} (folder, ${n} entries at the top level, all removed)`)
87 } else out.push(` ${path} (${st.kind}, ${st.size} bytes)`)
88 } catch {
89 out.push(` ${path} (not found)`)
90 }
91 }
92 return [head, ...out].join('\n')
93 }
94 if (/^docker (system|image) prune/.test(hold.kind)) {
95 return `${head}\n${firstLines(await run($, ['docker', 'system', 'df']), 6)}`
96 }
97 if (hold.kind.startsWith('docker volume') || hold.kind === 'docker compose down -v') {
98 const named = hold.kind === 'docker volume rm' ? targetsOf(hold.segment).slice(2) : []
99 const vols =
100 named.length > 0
101 ? named.join('\n')
102 : firstLines(await run($, ['docker', 'volume', 'ls', '--format', '{{.Name}}']), 10)
103 return `${head}\nvolumes in play:\n${vols}`
104 }
105 if (hold.kind === 'git push --force') {
106 const branch = (await run($, ['git', 'branch', '--show-current'])).trim()
107 return `${head}\nrewrites remote history; current branch: ${branch || '(detached)'}`
108 }
109 return head
110}
111
112// Non-interactive runs have nobody to ask: the verdict is written down here.
113async function note($: Engine, home: string, verdict: string, hold: Hold) {
114 try {
115 const path = `${home}/${LOG}`
116 const old = (await $.fs.exists(path)) ? String(await $.fs.read(path)) : ''
117 const line = `${new Date(await $.clock.now()).toISOString()} | ${verdict} | ${hold.kind} | ${hold.segment.slice(0, 160)}`
118 const kept = old.split('\n').filter(Boolean).slice(-299)
119 await $.fs.write(path, [...kept, line].join('\n') + '\n')
120 } catch {
121 // a failed log line must not change the verdict
122 }
123}
124
125export const register: Register = on => {
126 on('session.start', async ($, e, next) => {
127 const home = (await $.env.get('HOME')) ?? ''
128 const hub = e.cwd.replace(/\/$/, '') === `${home}/repos`
129 await update($, isHub, () => hub)
130 if (hub) {
131 await refreshBand($).catch(() => undefined)
132 $.clock.every(5000, () => refreshBand($).catch(() => undefined))
133 }
134 return next(e)
135 })
136
137 // Hub band: /prep has run in this session
138 on('command.run', async ($, e, next) => {
139 if (e.command === 'prep') await update($, prepRan, () => true)
140 return next(e)
141 })
142 on('prompt.submit', async ($, e, next) => {
143 if (/^\s*\/prep\b/.test(e.text)) await update($, prepRan, () => true)
144 return next(e)
145 })
146
147 // Token Weather
148 on('session.measure', async ($, e, next) => {
149 const p = e.context.percent
150 await update($, percent, () => (p === undefined ? null : Math.round(p)))
151 return next(e)
152 })
153
154 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
155 if (e.props.hasSurvey) return next(e)
156 const hub = await read($, isHub)
157 const p = await read($, percent)
158 const w = p === null ? null : weather(p, hub)
159 if (!hub && w === null) return next(e)
160
161 const { Box, Text } = $.ui.resolve(e)
162 const color = w?.kind === 'storm' ? 'error' : w?.kind === 'showers' ? 'warning' : undefined
163 return (
164 <Box>
165 {hub && <Text dimColor>{bandText(await read($, prepRan), await read($, band))}</Text>}
166 {hub && w && <Text dimColor> | </Text>}
167 {w && (
168 <Text color={color} dimColor={color === undefined}>
169 {w.text}
170 </Text>
171 )}
172 </Box>
173 )
174 })
175
176 // Blast Radius
177 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
178 const home = (await $.env.get('HOME')) ?? ''
179 const tmpdir = (await $.env.get('TMPDIR')) ?? undefined
180 const scratchEnv = { home, tmpdir }
181 const ownScratch = ownScratchTargets(e.command, scratchEnv)
182 const holds = classify(e.command, 0, scratchEnv)
183 const noteScratch = async () => {
184 for (const { targets } of ownScratch) {
185 await note($, home, 'allowed (own scratch)', {
186 kind: 'rm -rf',
187 hard: false,
188 segment: targets.join(' '),
189 })
190 }
191 }
192 if (holds.length === 0) {
193 await noteScratch()
194 return next(e)
195 }
196 const isInteractive = (await $.session.surfaces()).length > 0
197
198 if (!isInteractive) {
199 const hard = holds.find(h => h.hard)
200 if (hard) {
201 await note($, home, 'denied', hard)
202 return {
203 deny: `Blast Radius: ${hard.kind} is refused in a non-interactive session (nobody to ask).`,
204 }
205 }
206 for (const h of holds) await note($, home, 'allowed', h)
207 await noteScratch()
208 return next(e)
209 }
210
211 const lines: string[] = []
212 for (const h of holds) lines.push(await touches($, home, h))
213 const choice = await $.ui.ask(
214 `Blast Radius holds this command. It would touch:\n${lines.join('\n')}\nRun it?`,
215 ['1 - Run it', '2 - Refuse'],
216 )
217 if (!choice.startsWith('1')) return { deny: 'Blast Radius: refused at the prompt.' }
218 await noteScratch()
219 return next(e)
220 }).catch(($, e, next) =>
221 next.called ? next(e) : { deny: 'Blast Radius: its guard failed, so the command is held.' },
222 )
223}
224src/lib.ts 232 lines1// Pure helpers for the three mods: text in, text out, no engine calls.
2
3export type Band = { lanes: number; inbox: number; client: number }
4
5// Open lines (- [ ]) in muse-inbox.md. Client mail and client alerts are
6// counted apart from the rest so a line never shows twice.
7export function countInbox(md: string): { inbox: number; client: number } {
8 let inbox = 0
9 let client = 0
10 for (const line of md.split('\n')) {
11 if (!/^- \[ \]/.test(line)) continue
12 if (/Client mail:|Client alert:/.test(line)) client += 1
13 else inbox += 1
14 }
15 return { inbox, client }
16}
17
18// Lane rows from the last `days` days: { name, report } from index.tsv
19// (date, name, folder, model, billing, prompt, report, ...).
20export function recentLanes(
21 tsv: string,
22 nowMs: number,
23 days = 3,
24): { date: string; name: string; report: string }[] {
25 const cutoff = new Date(nowMs - days * 86_400_000).toISOString().slice(0, 10)
26 const rows: { date: string; name: string; report: string }[] = []
27 for (const line of tsv.split('\n')) {
28 const c = line.split('\t')
29 if (c.length < 2 || !/^\d{4}-\d{2}-\d{2}$/.test(c[0]) || c[0] < cutoff) continue
30 rows.push({ date: c[0], name: c[1], report: c[6] ?? '-' })
31 }
32 return rows
33}
34
35// out.log of a headless lane ends with "exit <code>" when the process is done.
36export function hasExitLine(log: string): boolean {
37 const last = log.trimEnd().split('\n').pop() ?? ''
38 return /^exit \d+$/.test(last)
39}
40
41export function bandText(prepRan: boolean, band: Band): string {
42 const parts: string[] = []
43 if (!prepRan) parts.push('/prep not run')
44 if (band.lanes > 0) parts.push(`${band.lanes} lane${band.lanes === 1 ? '' : 's'} open`)
45 if (band.inbox > 0) parts.push(`Muse inbox: ${band.inbox} open`)
46 if (band.client > 0) parts.push(`Client mail: ${band.client} new`)
47 return parts.length > 0 ? parts.join(' | ') : 'hub quiet'
48}
49
50export type Weather = { kind: 'clear' | 'showers' | 'storm'; text: string }
51
52// Clear under 40%, showers 40-60%, storm past 60%. The hub hands off past 60%;
53// every other folder hands off past 80%.
54export function weather(percent: number, isHub: boolean): Weather {
55 const kind = percent < 40 ? 'clear' : percent <= 60 ? 'showers' : 'storm'
56 const handOff = (isHub && percent > 60) || percent > 80
57 return {
58 kind,
59 text: `Weather: ${kind} ${percent}%${handOff ? ' - hand off and end' : ''}`,
60 }
61}
62
63export type Hold = { kind: string; hard: boolean; segment: string }
64export type ScratchEnv = { home?: string; tmpdir?: string }
65
66const strip = (s: string) =>
67 s
68 .trim()
69 .replace(/^(?:\w+=\S*\s+)+/, '')
70 .replace(/^(?:sudo|command|time)\s+/, '')
71
72// Which dangerous patterns a Bash command holds. Best effort, a net and not a
73// parser: it splits on shell separators and looks inside sh -c. `hard` ones (force push,
74// production deploy) are refused outright where nobody can be asked.
75export function classify(command: string, depth = 0, env: ScratchEnv = {}): Hold[] {
76 return classifyWithEnv(command, depth, env)
77}
78
79function classifyWithEnv(command: string, depth: number, env: ScratchEnv): Hold[] {
80 const holds: Hold[] = []
81 for (const raw of command.split(/&|\||;|\n|\$\(|`|\(|\)/)) {
82 const seg = strip(raw).replace(/^\\/, '')
83 if (seg === '') continue
84 const wrapped = /^(?:ba|z)?sh\s+-c\s+(.*)$/.exec(seg)
85 if (wrapped && depth < 3) {
86 holds.push(...classifyWithEnv(wrapped[1].replace(/^["']|["']$/g, ''), depth + 1, env))
87 }
88 const t = seg.split(/\s+/)
89 const flags = t.filter(x => x.startsWith('-'))
90 const add = (kind: string, hard: boolean) => holds.push({ kind, hard, segment: seg })
91
92 if (/^(?:\S*\/)?rm$/.test(t[0])) {
93 const r = flags.some(f => /^-[^-]*[rR]/.test(f) || f === '--recursive')
94 const f = flags.some(f => /^-[^-]*f/.test(f) || f === '--force')
95 if (r && f && !ownScratchTargets(command, env, depth).some(group => group.segment === seg)) {
96 add('rm -rf', false)
97 }
98 }
99 if (/^docker\s+(system|volume|image)\s+prune\b/.test(seg)) add(`docker ${t[1]} prune`, false)
100 if (/^docker\s+volume\s+(rm|remove)\b/.test(seg)) add('docker volume rm', false)
101 if (/^(docker\s+compose|docker-compose)\b/.test(seg) && t.includes('down')) {
102 const after = t.slice(t.indexOf('down'))
103 if (after.some(x => x === '--volumes' || /^-[^-]*v/.test(x))) add('docker compose down -v', false)
104 }
105 if (/^git\b/.test(seg) && t.includes('push')) {
106 const after = t.slice(t.indexOf('push'))
107 if (after.some(x => x === '-f' || x.startsWith('--force') || /^\+\S/.test(x))) add('git push --force', true)
108 }
109 if (/--prod(uction)?\b/.test(seg) || (/redeploy/i.test(seg) && /\bprod/i.test(seg))) {
110 add('production deploy', true)
111 }
112 }
113 return holds
114}
115
116type OwnScratchGroup = { segment: string; targets: string[] }
117
118// Returns targets from rm -rf segments that are safe scratch locations. This
119// uses the same best-effort segmentation as classify, not a full shell parser.
120export function ownScratchTargets(command: string, env: ScratchEnv = {}, depth = 0): OwnScratchGroup[] {
121 const assignments = new Map<string, string | null>()
122 if (env.tmpdir) assignments.set('TMPDIR', env.tmpdir)
123 const mktemp = /(?:^|[;&|]\s*)([A-Za-z_]\w*)=\$\(\s*mktemp(?:\s+[^)]*)?\)/g
124 for (const match of command.matchAll(mktemp)) assignments.set(match[1], null)
125 const literals = /(?:^|[;&|]\s*)(?:export\s+)?([A-Za-z_]\w*)=("[^"]*"|'[^']*'|[^\s;&|]+)/g
126 for (const match of command.matchAll(literals)) {
127 if (!match[2].startsWith('$(')) assignments.set(match[1], unquote(match[2]))
128 }
129 const groups: OwnScratchGroup[] = []
130 for (const raw of command.split(/&|\||;|\n|\$\(|`|\(|\)/)) {
131 const seg = strip(raw).replace(/^\\/, '')
132 if (seg === '') continue
133 const wrapped = /^(?:ba|z)?sh\s+-c\s+(.*)$/.exec(seg)
134 if (wrapped && depth < 3) {
135 groups.push(
136 ...ownScratchTargets(wrapped[1].replace(/^["']|["']$/g, ''), env, depth + 1).map(
137 targets => ({ segment: seg, targets }),
138 ),
139 )
140 }
141 const t = seg.split(/\s+/)
142 if (!/^(?:\S*\/)?rm$/.test(t[0])) continue
143 const flags = t.filter(x => x.startsWith('-'))
144 if (
145 !flags.some(f => /^-[^-]*[rR]/.test(f) || f === '--recursive') ||
146 !flags.some(f => /^-[^-]*f/.test(f) || f === '--force')
147 ) continue
148 const targets = t.slice(1).filter(x => !x.startsWith('-')).map(unquote)
149 if (targets.length > 0 && targets.every(target => isOwnScratch(target, assignments, env))) {
150 groups.push({ segment: seg, targets })
151 }
152 }
153 return groups
154}
155
156const unquote = (value: string) => value.replace(/^(["'])(.*)\1$/, '$2')
157
158function isOwnScratch(
159 input: string,
160 assignments: Map<string, string | null>,
161 env: ScratchEnv,
162): boolean {
163 if (!input) return false
164 let path = input
165 const variable = /^\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))(\/.*)?$/.exec(path)
166 if (variable ? /[*?[\]{}]/.test(variable[3] ?? '') : /[*?[\]{}]/.test(input)) {
167 return false
168 }
169 if (variable) {
170 const name = variable[1] ?? variable[2]
171 if (!assignments.has(name)) return false
172 const value = assignments.get(name)
173 if (value === null) {
174 const suffix = normalizePath((variable[3] ?? '').replace(/^\//, ''))
175 return !suffix.startsWith('../') && suffix !== '..' && !suffix.startsWith('/')
176 }
177 if (value === undefined) return false
178 path = `${value}${variable[3] ?? ''}`
179 } else if (path.startsWith('$')) {
180 return false
181 }
182
183 if (path === '~' || path.startsWith('~/')) path = `${env.home ?? '~'}${path.slice(1)}`
184 else if (path.startsWith('~')) return false
185 if (/[$*?[\]{}]/.test(path)) return false
186 const normalized = normalizePath(path)
187
188 const cacheNames = new Set([
189 '__pycache__',
190 '.pytest_cache',
191 '.mypy_cache',
192 '.ruff_cache',
193 '.expo',
194 '.turbo',
195 ])
196 const parts = normalized.split('/').filter(Boolean)
197 // A cache name directly in home is user state, not scratch (~/.expo holds the Expo login).
198 const parent = `${normalized.startsWith('/') ? '/' : ''}${parts.slice(0, -1).join('/')}`
199 const homeLevel = parent === '~' || (env.home !== undefined && parent === normalizePath(env.home))
200 if (
201 (!homeLevel && cacheNames.has(parts.at(-1) ?? '')) ||
202 (parts.at(-2) === 'node_modules' && parts.at(-1) === '.cache')
203 ) return true
204
205 const tmpRoots = ['/tmp', env.tmpdir ? normalizePath(env.tmpdir) : undefined].filter(
206 (root): root is string => Boolean(root),
207 )
208 if (normalized.startsWith('/') && tmpRoots.some(
209 root => normalized !== root && normalized.startsWith(`${root}/`),
210 )) return true
211
212 const laneRoot = `${normalizePath(`${env.home ?? '~'}/.claude/state/lanes`)}/`
213 return normalized.startsWith(laneRoot) && normalized.slice(laneRoot.length).split('/').filter(Boolean).length >= 2
214}
215
216function normalizePath(path: string): string {
217 const absolute = path.startsWith('/')
218 const parts: string[] = []
219 for (const part of path.split('/')) {
220 if (!part || part === '.') continue
221 if (part === '..' && parts.length > 0 && parts.at(-1) !== '..') parts.pop()
222 else if (part === '..' && !absolute) parts.push(part)
223 else if (part !== '..') parts.push(part)
224 }
225 return `${absolute ? '/' : ''}${parts.join('/')}` || (absolute ? '/' : '.')
226}
227
228// Non-flag words after the command word: what an rm names.
229export function targetsOf(segment: string): string[] {
230 return segment.split(/\s+/).slice(1).filter(x => !x.startsWith('-'))
231}
232src/state.d.ts 13 lines1export type AfaModsBand = { lanes: number; inbox: number; client: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'afa-mods': {
6 isHub: boolean
7 prepRan: boolean
8 percent: number | null
9 band: AfaModsBand
10 }
11 }
12}
13