SLOPSHOPPER

agent-progress

Live progress bars for running subagents above the prompt

newbandguardcommandtimeragents
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-progress
› 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 › /agent-progress ⎿ agent-progress: Agent progress bars off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-mods

A few mods for Claude Code, packaged as a plugin marketplace.

ModWhat it does
pomodoroA pixel-art pomodoro timer in a side pane. Day and night skies, animated sun, clouds, stars and meteors, a grass field and a daily tomato tally. Toast and chime when a phase ends.
agent-progressLive Powerline-style progress bars for running subagents, with a moving glint and the model each one runs on.
cmd-guardStops destructive shell commands (rm -rf on /, ~ or *, force pushes, hard resets, dropping tables, mkfs, dd…) and asks what to do: refuse, allow once, trust for this session, or use a safer alternative.
model-routerRuns Explore subagents on haiku and general-purpose subagents on sonnet to save cost. An explicit model on the Agent call always wins; forks and workflows are left alone. Optionally lets Jev pick the model per task.

<img src="docs/pomodoro-focus.png" alt="Pomodoro focus mode: day sky with sun and clouds" width="280"> &nbsp; <img src="docs/pomodoro-break.png" alt="Pomodoro break mode: night sky with moon and stars" width="280">

Install

claude plugin marketplace add redjackfred/claude-code-mods
claude plugin install pomodoro@claude-code-mods
claude plugin install agent-progress@claude-code-mods
claude plugin install cmd-guard@claude-code-mods
claude plugin install model-router@claude-code-mods

Install only the ones you want. Restart Claude Code afterwards.

Usage

pomodoro

/focus [minutes]   start a focus session (default 25)
/focus skip        skip to the next phase
/focus stop        stop the timer
/focus show|hide   show or hide the side pane
  • Runs entirely locally: the timer never calls the model and costs no tokens.
  • Best in a terminal with a Nerd Font and Unicode 13 sextant glyphs (Ghostty, WezTerm, Kitty, iTerm2…).
  • The chime plays through afplay, so sound is macOS only.

agent-progress

agent-progress: three subagents running with Powerline progress bars

/agent-progress toggles the bars on and off.

cmd-guard

cmd-guard asking what to do with a broad rm -rf

Works automatically; /guard off turns it off for the session and /guard on back on. When nobody answers the prompt (or it fails), the command is blocked.

The dialog speaks the language you mostly write in during the session: English, or Traditional Chinese (繁體中文).

It matches commands with regexes, not a shell parser, so treat it as a seatbelt against slips rather than a sandbox: a determined command can still get past it. It also can't tell running a command from mentioning one, so a commit message or heredoc that only quotes a risky command gets stopped too. Reword the text, or /guard off for a moment.

model-router

<img src="docs/model-router.png" alt="model-router toasts: Explore routed to haiku, general-purpose to sonnet" width="360">

Works automatically and shows a toast for each subagent it reroutes. To steer the main agent, you can add this to your ~/.claude/CLAUDE.md:

A model-router mod runs Explore subagents on haiku and general-purpose ones on sonnet
unless the Agent call names a model. For complex reasoning, large refactors, or
hard-to-find bugs, pass `model: "opus"` on the Agent call.
Optional: let Jev pick the model

Jev is a fast "System One" decision model. With it on, each general-purpose subagent's task is classified as haiku, sonnet or opus work before it starts. In a quick test it took about 0.7 s per call and costs well under a cent.

export TYPESAFE_API_KEY=...   # then restart Claude Code
/router jev on                # /router jev off to stop
  • Privacy: it's off by default because turning it on sends each general-purpose task (its description plus the first 4000 characters of the prompt) to TypeSafe's API.
  • Confidence: Jev's pick is used only when its confidence is at least 0.8.
  • Fallback: if Jev is slow (over 1.5 s), fails, or isn't sure, the subagent falls back to sonnet.
  • Explore subagents always stay on haiku.
  • Toast: shows Jev's pick, e.g. general-purpose → haiku · jev 1.00.

Development

Each mod is a Claude Code plugin with TypeScript hooks in hooks/.

claude plugin validate .        # the marketplace
claude plugin validate pomodoro # one plugin
claude plugin test pomodoro     # its tests

License

MIT

Source 2 files
hooks/register.tsx 187 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, Timer } from 'claude-code'
3
4import type { AgentRun } from '../types'
5
6const runs = atom({ plugin: 'agent-progress', key: 'runs' } as const, [])
7const now = atom({ plugin: 'agent-progress', key: 'now' } as const, 0)
8// running average of finished subagent durations; the bar's denominator
9const avgMs = atom({ plugin: 'agent-progress', key: 'avgMs' } as const, 90_000)
10const enabled = atom({ plugin: 'agent-progress', key: 'enabled' } as const, true)
11
12const WIDTH = 16
13const FRAME_MS = 120
14const LINGER_MS = 8_000
15
16// Catppuccin Mocha, same as ~/.claude/statusline.sh
17const C = {
18  mauve: '#cba6f7', blue: '#89b4fa', teal: '#94e2d5', green: '#a6e3a1', red: '#f38ba8',
19  pink: '#f5c2e7', lavender: '#b4befe', rosewater: '#f5e0dc',
20  base: '#1e1e2e', surface: '#313244', overlay: '#45475a', text: '#cdd6f4', sub: '#9399b2',
21}
22const SEP = ''
23const CAP = ''
24const ROBOT = '\u{f06a9}'
25const TIMER = '\u{f051f}'
26const WRENCH = ''
27const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
28
29// ponytail: subagents report no real progress, so % = elapsed / avg past duration, capped at 95 until done
30export const percent = (run: AgentRun, at: number, avg: number) =>
31  run.end !== undefined ? 100 : Math.min(95, Math.floor(((at - run.start) / avg) * 100))
32
33// one cell of the bar: filled ▰ / empty ▱, lit by a glint sweeping left to right
34export const cells = (pct: number, frame: number, base: string, shine: boolean) => {
35  const filled = Math.round((pct / 100) * WIDTH)
36  const head = (frame % (WIDTH + 8)) - 4
37  return Array.from({ length: WIDTH }, (_, i) => {
38    const on = i < filled
39    const d = shine ? Math.abs(i - head) : 99
40    const color = !on
41      ? d === 0 ? C.lavender : d === 1 ? '#7f849c' : '#585b70'
42      : d === 0 ? C.rosewater : d === 1 ? C.pink : d === 2 ? C.lavender : base
43    return { ch: on ? '▰' : '▱', color }
44  })
45}
46
47// `claude-sonnet-5-5` → `sonnet 5.5`, `haiku` → `haiku`; anything else as given
48export const shortModel = (model: string) => {
49  const m = /(haiku|sonnet|opus|fable)(?:-(\d+)-(\d+))?/i.exec(model)
50  return m ? (m[2] ? `${m[1]!.toLowerCase()} ${m[2]}.${m[3]}` : m[1]!.toLowerCase()) : model
51}
52
53let timer: Timer | undefined
54
55const tick = ($: EngineInterface) => {
56  if (timer) return
57  timer = $.clock.every(FRAME_MS, async () => {
58    const t = await $.clock.now()
59    const list = await read($, runs)
60    if (list.length === 0) {
61      timer?.cancel()
62      timer = undefined
63      return
64    }
65    await update($, now, () => t)
66    if (list.some(r => r.end !== undefined && t - r.end > LINGER_MS)) {
67      await update($, runs, l => l.filter(r => r.end === undefined || t - r.end <= LINGER_MS))
68    }
69  })
70}
71
72export const register: Register = on => {
73  on('session.start', async ($, e, next) => {
74    await $.command.register({
75      name: 'agent-progress',
76      description: 'Toggle subagent progress bars (on | off)',
77    })
78    const saved = await $.store.get('enabled')
79    if (typeof saved === 'boolean') await update($, enabled, () => saved)
80    if ((await read($, runs)).length > 0) tick($)
81    return next(e)
82  })
83
84  on('command.run', { command: 'agent-progress' }, async ($, e) => {
85    const arg = e.args.trim()
86    const value = arg === 'on' ? true : arg === 'off' ? false : !(await read($, enabled))
87    await update($, enabled, () => value)
88    await $.store.set('enabled', value)
89    return { text: `Agent progress bars ${value ? 'on' : 'off'}.` }
90  })
91
92  on('agent.spawn', async ($, e, next) => {
93    const res = await next(e)
94    if ('agentId' in res && res.agentId) {
95      const t = await $.clock.now()
96      const run: AgentRun = { id: res.agentId, type: e.subagentType, desc: e.description, start: t, tools: 0, model: res.model ? shortModel(res.model) : undefined }
97      await update($, now, () => t)
98      await update($, runs, l => [...l.filter(r => r.id !== run.id), run])
99      tick($)
100    }
101    return res
102  })
103
104  on('tool.call', async ($, e, next) => {
105    if (e.agentId) {
106      const id = e.agentId
107      await update($, runs, l =>
108        l.map(r => (r.id === id ? { ...r, tools: r.tools + 1, lastTool: e.tool } : r)),
109      )
110    }
111    return next(e)
112  })
113
114  on('turn.complete', async ($, e, next) => {
115    if (e.agentId) {
116      const id = e.agentId
117      const t = await $.clock.now()
118      const run = (await read($, runs)).find(r => r.id === id)
119      if (run && run.end === undefined) {
120        await update($, avgMs, a => Math.max(5_000, Math.round(a * 0.7 + (t - run.start) * 0.3)))
121        await update($, runs, l =>
122          l.map(r => (r.id === id ? { ...r, end: t, failed: e.reason !== 'answer' } : r)),
123        )
124      }
125    }
126    return next(e)
127  })
128
129  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
130    const list = await read($, runs)
131    if (e.props.hasSurvey || list.length === 0 || !(await read($, enabled))) return next(e)
132
133    const { Box, Text } = $.ui.resolve(e)
134    const t = await read($, now)
135    const avg = await read($, avgMs)
136    const frame = Math.floor(t / FRAME_MS)
137
138    // powerline chain: [bg, fg, content][] → cap, segments joined by , closing 
139    const chain = (segs: [string, string, RenderChildren][]) => {
140      const out: RenderChildren[] = [<Text color={segs[0]![0]}>{CAP}</Text>]
141      segs.forEach(([bg, fg, body], i) => {
142        if (i > 0) out.push(<Text color={segs[i - 1]![0]} backgroundColor={bg}>{SEP}</Text>)
143        out.push(<Text color={fg} backgroundColor={bg}>{body}</Text>)
144      })
145      out.push(<Text color={segs[segs.length - 1]![0]}>{SEP}</Text>)
146      return out
147    }
148
149    const shown = list.slice(-Math.max(1, e.props.maxRows - 1))
150    // pad every row's variable-width parts to the widest so separators line up
151    const typeW = Math.max(...shown.map(r => r.type.length))
152    const modelW = Math.max(...shown.map(r => r.model?.length ?? 0))
153    const toolW = Math.max(...shown.map(r => (r.end === undefined && r.lastTool ? r.lastTool.length + 3 : 0)))
154
155    // other mods' bands (pomodoro, ...) stack underneath
156    const below = await next(e)
157
158    return (
159      <Box flexDirection="column">
160        {shown.map(r => {
161          const running = r.end === undefined
162          const pct = percent(r, t, avg)
163          const secs = Math.round(((r.end ?? t) - r.start) / 1000)
164          const accent = r.failed ? C.red : running ? C.mauve : C.green
165          const icon = r.failed ? '✗' : running ? SPIN[frame % SPIN.length] : '✓'
166          const barBase = r.failed ? C.red : running ? C.teal : C.green
167          const bar = cells(pct, frame, barBase, running).map(c => <Text color={c.color}>{c.ch}</Text>)
168          const tool = (running && r.lastTool ? ` (${r.lastTool})` : '').padEnd(toolW)
169
170          return (
171            <Text key={r.id} wrap="truncate-end">
172              {chain([
173                [accent, C.base, <Text bold> {icon} {ROBOT} {r.type.padEnd(typeW)} </Text>],
174                ...(modelW > 0 ? [[C.surface, accent, <Text> {(r.model ?? '').padEnd(modelW)} </Text>] as [string, string, RenderChildren]] : []),
175                [C.overlay, C.text, <Text> {bar} <Text color={barBase} bold>{String(pct).padStart(3)}%</Text> </Text>],
176                [C.base, C.text, <Text> {TIMER} {String(secs).padStart(3)}s {WRENCH} {String(r.tools).padStart(2)}<Text color={C.sub}>{tool}</Text> </Text>],
177              ])}
178              <Text color={C.sub}> {r.desc}</Text>
179            </Text>
180          )
181        })}
182        {below}
183      </Box>
184    )
185  })
186}
187
types/index.d.ts 19 lines
1export type AgentRun = {
2  id: string
3  type: string
4  desc: string
5  start: number
6  end?: number
7  tools: number
8  // what the subagent runs on, as agent.spawn answered: an alias or a full id
9  model?: string
10  lastTool?: string
11  failed?: boolean
12}
13
14declare module 'claude-code' {
15  interface PluginState {
16    'agent-progress': { runs: AgentRun[]; now: number; avgMs: number; enabled: boolean }
17  }
18}
19