Live progress bars for running subagents above the prompt

A few mods for Claude Code, packaged as a plugin marketplace.
| Mod | What it does |
|---|---|
| pomodoro | A 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-progress | Live Powerline-style progress bars for running subagents, with a moving glint and the model each one runs on. |
| cmd-guard | Stops 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-router | Runs 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"> <img src="docs/pomodoro-break.png" alt="Pomodoro break mode: night sky with moon and stars" width="280">
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.
/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
afplay, so sound is macOS only.
/agent-progress toggles the bars on and off.

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.
<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.
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
general-purpose → haiku · jev 1.00.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
MIT
hooks/register.tsx 187 lines1import { 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}
187types/index.d.ts 19 lines1export 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