SLOPSHOPPER

afa-mods

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

newbandguardpromptprocesstimer
v0.1.0no licenseupdated 2026-10-15TortoiseWolfe/Claude_Commandz/mods/afa-mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · afa-mods
› 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(rm -rf build && git push --force origin main) ⎿ Denied by afa-mods: Blast Radius: refused at the prompt. ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM Weather: showers 49% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Weather: showers 49%
README

afa-mods

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/.

What they do

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.

Install

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.

Turn it off

claude plugin disable afa-mods     # keep it installed, stop it loading
claude plugin uninstall afa-mods   # remove it

Develop

claude plugin validate mods/afa-mods
claude plugin test mods/afa-mods
Source 3 files
src/index.tsx 224 lines
1import { 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}
224
src/lib.ts 232 lines
1// 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}
232
src/state.d.ts 13 lines
1export 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