A band above the prompt showing running background tasks, their elapsed time and last output line

Five Claude Code mods that remove the routine I kept doing by hand. Each one comes from an audit of my last 30 Claude Code sessions. A mod is a plugin of function hooks: it runs inside Claude Code and can draw a band above the prompt, a pane, a toast or a status line, and can step into prompts, tool calls and the session's start and end.
| Mod | The habit it removes | What you see | Usage cost |
|---|---|---|---|
merge-followthrough | Typing "616 merged" (61 times in 30 sessions), then Claude spending a turn on git checkout main && git pull and deleting the branch; ! git pull by hand at session start | Band of your open PRs with CI ✓ ✗ …; toast when a merge has been pulled; /prs | None |
handoff-on-clear | "give me a prompt to restart", "I've lost your feedback, can you redisplay" after /clear | Toast on save; band ↺ Handoff from last session [Load] [Dismiss]; /handoff | One cached fork per /clear (option summarize, on by default) |
bg-task-band | "how are we looking", "any updates", "when's the estimated end time?" | Band: ⏳ Wait for cp-22 recap 12m/25m · TASK [drain]; toast on finish | None |
review-ledger | "what's left to do from the review?", updating the H/M/L list by hand | Pane of items by severity (/review); toast when an item's PR merges | None |
tts-lite | A Stop hook starting a full claude -p per reply to speak a summary, which left 200 stray transcripts | Audio only | Short replies none; longer ones one bare Haiku completion |
merge-followthrough: polls gh pr list --author @me every 60 s. When a PR it saw open is merged, it checks out the default branch, pull --ff-onlys, deletes the local branch and prunes, as long as the working tree is clean. On your next prompt it tells Claude what it already did, so Claude doesn't redo it. Typing 616 merged runs the same steps at once. At session start it fast-forwards the default branch if you're on it with no changes.
handoff-on-clear: on session.end (a /clear or an exit) it saves the last answer, your recent asks, PR numbers and artifact links mentioned, the branch, and (with summarize) a 200-word handoff from one $.model.fork, which the API serves mostly from the prompt cache. It's kept per directory for three days. It goes to Claude when you press Load, run /handoff, or ask something like "redisplay" or "prompt to restart".
bg-task-band: records background Bash, Monitor and Agent calls as they start, reads each one's output file every 10 s for the last line, and marks it done from the task notification that arrives when it ends. A TaskStop marks it stopped. Finished tasks stay on the band for 90 s.
review-ledger: registers the tool mcp__review-ledger__items, which Claude calls to record findings (H1, M3, L2) and their state: open, in-progress with a PR number, done, or won't do. It's stored per repository root, so it survives /clear and new sessions. Every 5 minutes it checks in-progress PRs and marks an item done when its PR merges. A prompt that says "review" or names an item gets the ledger as context.
tts-lite: on turn.complete it reads replies of 20 words or fewer out as they are, and asks Haiku for one sentence about anything longer. It plays them through Piper and paplay, or espeak. It reads the same CLAUDE_TTS, CLAUDE_TTS_SPEED, CLAUDE_TTS_VOICE and CLAUDE_TTS_MODEL variables as the shell hook it replaces, and stays silent while that hook (tts-speak.sh) is still in ~/.claude/settings.json, so you never hear double.
Requirements: Claude Code 2.1.288 or later (function-hook mods are early access and the API moves between releases), plus git and an authenticated gh for merge-followthrough and review-ledger. tts-lite needs paplay and Piper or espeak.
Load them for every session by listing the folders in CLAUDE_CODE_PLUGIN_DIRS (colon-separated) in the env block of ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/code/claude-mods/merge-followthrough:~/code/claude-mods/handoff-on-clear:~/code/claude-mods/bg-task-band:~/code/claude-mods/review-ledger:~/code/claude-mods/tts-lite"
}
}
For one session only:
claude --plugin-dir ./merge-followthrough --plugin-dir ./bg-task-band
Or copy them into a session's hot-reload folder:
scripts/sync.sh ~/.claude/dev-mods/<session-id>
<mod>/
.claude-plugin/plugin.json manifest (name, version, options, state contract)
hooks/hooks.json { "modules": ["./register.tsx"] }
hooks/register.tsx the hooks module
types/index.d.ts $.state contract (mods that keep state)
tests/*.test.ts claude plugin test
scripts/
check-mods.sh validate / test the mods given files touch
typecheck.sh tsc against Claude Code's generated API types
sync.sh copy mods into a load folder
pip install pre-commit && pre-commit install
claude plugin validate <mod>
claude plugin test <mod>
scripts/typecheck.sh # all mods
The pre-commit hooks run standard hygiene, gitleaks, shellcheck, and for the mods a commit touches: claude plugin validate, a TypeScript check and claude plugin test. The type-check needs the API declarations Claude Code writes into .claude-plugin/types/ when it loads a mod. Those are generated per build, git-ignored, and found automatically in any ~/.claude/dev-mods copy. Without them the type-check is skipped with a note.
See AGENTS.md for the conventions an agent (or a person) should follow when changing a mod.
hooks/register.tsx 171 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BgTask } from '../types'
5
6// Answers "how are we looking?" without a turn: a band of the background
7// shells, monitors and agents Claude started, how long each has run against
8// its cap, and the last line each wrote. A toast when one ends.
9
10const tasks = atom({ plugin: 'bg-task-band', key: 'tasks' } as const, [])
11const now = atom({ plugin: 'bg-task-band', key: 'now' } as const, 0)
12
13const TICK_MS = 10_000
14const KEEP_DONE_MS = 90_000
15
16function minutes(ms: number): string {
17 const m = Math.floor(ms / 60_000)
18 return m < 1 ? `${Math.floor(ms / 1000)}s` : m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${m % 60}m`
19}
20
21function clean(line: string): string {
22 // eslint-disable-next-line no-control-regex
23 return line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').replace(/\s+/g, ' ').trim().slice(0, 120)
24}
25
26async function findOutput($: EngineInterface, id: string): Promise<string> {
27 const uid = (await $.process.run(['id', '-u'])).stdout.trim()
28 const r = await $.process.run(['find', `/tmp/claude-${uid}`, '-maxdepth', '5', '-name', `${id}.output`])
29 .catch(() => undefined)
30 return r?.stdout.split('\n')[0]?.trim() ?? ''
31}
32
33async function lastLine($: EngineInterface, t: BgTask): Promise<string> {
34 if (!t.outputFile) return t.last
35 const r = await $.process.run(['tail', '-n', '5', t.outputFile]).catch(() => undefined)
36 if (!r || r.exitCode !== 0) return t.last
37 const lines = r.stdout.split('\n').map(clean).filter(l => l !== '')
38 return lines.at(-1) ?? t.last
39}
40
41async function track($: EngineInterface, t: Omit<BgTask, 'startedAt' | 'last' | 'status' | 'endedAt'>) {
42 const startedAt = await $.clock.now()
43 const outputFile = t.outputFile || (await findOutput($, t.id))
44 await update($, tasks, list => [
45 ...list.filter(x => x.id !== t.id),
46 { ...t, outputFile, startedAt, last: '', status: 'running' as const, endedAt: 0 },
47 ])
48}
49
50async function tick($: EngineInterface) {
51 const at = await $.clock.now()
52 const list = await read($, tasks)
53 const kept = list.filter(t => t.status === 'running' || at - t.endedAt < KEEP_DONE_MS)
54 const refreshed = await Promise.all(
55 kept.map(async t => (t.status === 'running' ? { ...t, last: await lastLine($, t) } : t)),
56 )
57 await update($, tasks, () => refreshed)
58 await update($, now, () => at)
59}
60
61export const register: Register = on => {
62 on('session.start', async ($, e, next) => {
63 $.clock.every(TICK_MS, () => void tick($))
64 return next(e)
65 })
66
67 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
68 const ran = await next(e)
69 if (ran.deny !== undefined || ran.isError) return ran
70 const id = ran.result.backgroundTaskId
71 if (id) {
72 await track($, {
73 id,
74 kind: 'shell',
75 label: e.description || e.command.slice(0, 80),
76 capMs: e.timeout ?? 0,
77 outputFile: '',
78 })
79 }
80 return ran
81 })
82
83 on('tool.call', { tool: 'Monitor' }, async ($, e, next) => {
84 const ran = await next(e)
85 if (ran.deny !== undefined || ran.isError) return ran
86 await track($, {
87 id: ran.result.taskId,
88 kind: 'monitor',
89 label: e.description,
90 capMs: ran.result.persistent ? 0 : ran.result.timeoutMs,
91 outputFile: '',
92 })
93 return ran
94 })
95
96 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
97 const ran = await next(e)
98 if (ran.deny !== undefined || ran.isError) return ran
99 const r = ran.result
100 if ('status' in r && r.status === 'async_launched') {
101 await track($, { id: r.agentId, kind: 'agent', label: r.description, capMs: 0, outputFile: r.outputFile })
102 }
103 return ran
104 })
105
106 // Background tasks report back as task-notification prompts.
107 on('prompt.submit', async ($, e, next) => {
108 if (e.origin.kind !== 'task-notification') return next(e)
109 const id = e.text.match(/<task-id>([^<]+)<\/task-id>/)?.[1]
110 const status = e.text.match(/<status>([^<]+)<\/status>/)?.[1]
111 const event = e.text.match(/<event>([\s\S]*?)<\/event>/)?.[1]
112 const list = await read($, tasks)
113 const t = list.find(x => x.id === id)
114 if (!t) return next(e)
115
116 const at = await $.clock.now()
117 if (status === 'completed' || status === 'failed' || status === 'killed') {
118 await update($, tasks, l => l.map(x => (x.id === t.id ? { ...x, status, endedAt: at } : x)))
119 const mark = status === 'completed' ? '✓' : '✗'
120 $.ui.toast(`${mark} ${t.label} (${status}, ${minutes(at - t.startedAt)})`, { timeoutMs: 8000 })
121 } else if (event) {
122 const line = clean(event.split('\n').filter(l => l.trim() !== '').at(-1) ?? '')
123 await update($, tasks, l => l.map(x => (x.id === t.id ? { ...x, last: line } : x)))
124 }
125 return next(e)
126 })
127
128 // A TaskStop ends the task without a notification.
129 on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
130 const ran = await next(e)
131 const id = e.task_id ?? e.shell_id ?? ''
132 const at = await $.clock.now()
133 await update($, tasks, l => l.map(x => (x.id === id ? { ...x, status: 'killed' as const, endedAt: at } : x)))
134 return ran
135 })
136
137 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
138 const list = await read($, tasks)
139 const at = await read($, now)
140 if (list.length === 0 || e.props.hasSurvey) return next(e)
141 const { Box, Text } = $.ui.resolve(e)
142 const running = list.filter(t => t.status === 'running')
143 const icon = { running: '⏳', completed: '✓', failed: '✗', killed: '■' } as const
144 const color = { running: 'yellow', completed: 'green', failed: 'red', killed: 'gray' } as const
145
146 return (
147 <Box flexDirection="column">
148 {list.slice(-4).map(t => {
149 const elapsed = minutes(Math.max(0, (t.status === 'running' ? at : t.endedAt) - t.startedAt))
150 return (
151 <Box key={t.id}>
152 <Text color={color[t.status]}>{icon[t.status]} </Text>
153 <Text>{t.label.slice(0, 50)} </Text>
154 <Text dimColor>
155 {elapsed}
156 {t.capMs > 0 ? `/${minutes(t.capMs)}` : ''}{' '}
157 </Text>
158 {t.last !== '' && (
159 <Text dimColor wrap="truncate">
160 · {t.last}
161 </Text>
162 )}
163 </Box>
164 )
165 })}
166 {running.length > 4 && <Text dimColor>{running.length} running in all</Text>}
167 </Box>
168 )
169 })
170}
171types/index.d.ts 21 lines1export type BgTask = {
2 id: string
3 kind: 'shell' | 'monitor' | 'agent'
4 label: string
5 startedAt: number
6 capMs: number
7 outputFile: string
8 last: string
9 status: 'running' | 'completed' | 'failed' | 'killed'
10 endedAt: number
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'bg-task-band': {
16 tasks: BgTask[]
17 now: number
18 }
19 }
20}
21