Lists background subagents and shells in a band above the prompt

A Claude Code mod (function hooks) that lists background subagents and background shells in the band above the prompt (AbovePrompt), so you can see what is running and what just finished without opening the tasks list.
⏵ Explore search auth flow · 14 tools (Grep) · 1m23s
⏵ $ ./gradlew testDebugUnitTest · 3m10s
✓ general-purpose update README · done 2m ago
✗ $ npm test · exit 1 · 5m ago
$ and the command for a shell), the description and the elapsed time. Subagent rows also show the number of tool calls and the latest tool.✓ on success and ✗ on failure or kill, with the time since they ended. They are dimmed, and only the ✗ is red.45s, 3m10s or 1h05m, and the time since a job ended in minutes. While a job is shown, the band is redrawn once a minute and whenever a job changes, so the seconds are exact only at each redraw.… to fit the band width (bodyColumns).$.agent.list() is polled every 3 seconds while a job is shown and every 15 seconds otherwise, and status changes are diffed: completed is a success, failed and killed are failures, and any other status counts as still running. agent.spawn adds a background subagent immediately and remembers foreground ones so they are not listed; an Agent call that later reports async_launched (moved to the background) is listed from then on.tool.call events carrying the subagent's agentId are counted.Bash call whose result carries backgroundTaskId (run_in_background, Ctrl+B, or an auto-backgrounded timeout).prompt.submit with origin task-notification), parsed for <task-id>, <status> and the exit code in <summary>. A TaskStop call marks its task as killed.TaskStop still ends them).claude plugin validate claude/mods/background-jobs-band
claude plugin test claude/mods/background-jobs-band
MIT
hooks/register.tsx 311 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register } from 'claude-code'
3
4import type { Job } from '../types'
5
6const ACTIVE_POLL_MS = 3000
7const IDLE_POLL_MS = 15 * 1000
8const TICK_MS = 60 * 1000
9const LINGER_MS = 5 * 60 * 1000
10const ENDED_STATUSES = ['completed', 'failed', 'killed']
11const PERSON_ORIGINS = ['composer', 'bridge', 'sdk']
12
13const jobs = atom({ plugin: 'background-jobs-band', key: 'jobs' } as const, [] as Job[])
14const foreground = atom({ plugin: 'background-jobs-band', key: 'foreground' } as const, [] as string[])
15const tick = atom({ plugin: 'background-jobs-band', key: 'tick' } as const, 0)
16
17const isRunning = (job: Job) => job.endedAt === undefined
18
19const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
20
21const charWidth = (codePoint: number) =>
22 (codePoint >= 0x1100 && codePoint <= 0x115f) ||
23 (codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
24 (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
25 (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
26 (codePoint >= 0xfe30 && codePoint <= 0xfe4f) ||
27 (codePoint >= 0xff00 && codePoint <= 0xff60) ||
28 (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
29 (codePoint >= 0x1f300 && codePoint <= 0x1faff) ||
30 (codePoint >= 0x20000 && codePoint <= 0x3fffd)
31 ? 2
32 : 1
33
34const textWidth = (text: string) => [...text].reduce((sum, ch) => sum + charWidth(ch.codePointAt(0) ?? 0), 0)
35
36const truncate = (text: string, columns: number) => {
37 if (textWidth(text) <= columns) return text
38 if (columns <= 0) return ''
39 let out = ''
40 let used = 0
41 for (const ch of text) {
42 const w = charWidth(ch.codePointAt(0) ?? 0)
43 if (used + w > columns - 1) break
44 out += ch
45 used += w
46 }
47 return `${out}…`
48}
49
50const formatElapsed = (ms: number) => {
51 const seconds = Math.floor(Math.max(0, ms) / 1000)
52 const minutes = Math.floor(seconds / 60)
53 if (minutes < 1) return `${seconds}s`
54 if (minutes < 60) return `${minutes}m${String(seconds % 60).padStart(2, '0')}s`
55 return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
56}
57
58const formatAgo = (ms: number) => {
59 const minutes = Math.floor(Math.max(0, ms) / 60000)
60 if (minutes < 1) return 'just now'
61 if (minutes < 60) return `${minutes}m ago`
62 return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m ago`
63}
64
65type Row = { mark: string; text: string; isFinished: boolean; isFailed: boolean }
66
67const describeJob = (job: Job, now: number, columns: number): Row => {
68 const isFinished = !isRunning(job)
69 const isFailed = job.outcome === 'fail'
70 const mark = !isFinished ? '⏵' : isFailed ? '✗' : '✓'
71 const head = job.kind === 'shell' ? '$ ' : `${job.label} `
72 const segments: string[] = []
73 if (!isFinished) {
74 if (job.kind === 'agent' && job.toolCalls > 0) {
75 const noun = job.toolCalls === 1 ? 'tool' : 'tools'
76 segments.push(`${job.toolCalls} ${noun}${job.lastTool ? ` (${job.lastTool})` : ''}`)
77 }
78 segments.push(formatElapsed(now - job.startedAt))
79 } else if (isFailed) {
80 if (job.detail) segments.push(job.detail)
81 segments.push(formatAgo(now - (job.endedAt ?? now)))
82 } else {
83 segments.push(`done ${formatAgo(now - (job.endedAt ?? now))}`)
84 }
85 const tail = segments.map(s => ` · ${s}`).join('')
86 const room = columns - textWidth(`${mark} `) - textWidth(head) - textWidth(tail)
87 const body = room > 0 ? `${head}${truncate(job.description, room)}${tail}` : truncate(`${head}${job.description}${tail}`, columns - textWidth(`${mark} `))
88 return { mark, text: body, isFinished, isFailed }
89}
90
91const order = (list: Job[]) => [
92 ...list.filter(isRunning).sort((a, b) => a.startedAt - b.startedAt),
93 ...list.filter(j => !isRunning(j)).sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0)),
94]
95
96const addJob = ($: EngineInterface, job: Job) =>
97 update($, jobs, list => (list.some(j => j.id === job.id) ? list : [...list, job]))
98
99const finishJob = ($: EngineInterface, matches: (job: Job) => boolean, endedAt: number, outcome: Job['outcome'], detail?: string) =>
100 update($, jobs, list => list.map(j => (isRunning(j) && matches(j) ? { ...j, endedAt, outcome, detail } : j)))
101
102const prune = (list: Job[], now: number) => list.filter(j => isRunning(j) || now - (j.endedAt ?? now) < LINGER_MS)
103
104const reconcile = (list: Job[], agents: AgentInfo[], hidden: string[], now: number) => {
105 let next = list
106 for (const agent of agents) {
107 if (hidden.includes(agent.id)) continue
108 const isActive = !ENDED_STATUSES.includes(agent.status)
109 const known = next.find(j => j.id === agent.id)
110 if (!known) {
111 if (!isActive) continue
112 next = [...next, { id: agent.id, kind: 'agent', label: agent.type, description: oneLine(agent.description), startedAt: now, toolCalls: 0 }]
113 continue
114 }
115 if (isActive || !isRunning(known)) continue
116 const outcome = agent.status === 'completed' ? 'ok' : 'fail'
117 next = next.map(j => (j.id === agent.id ? { ...j, endedAt: now, outcome, detail: outcome === 'fail' ? agent.status : undefined } : j))
118 }
119 return prune(next, now)
120}
121
122const poll = async ($: EngineInterface) => {
123 const [agents, hidden, now] = await Promise.all([$.agent.list(), read($, foreground), $.clock.now()])
124 const current = await read($, jobs)
125 const next = reconcile(current, agents, hidden, now)
126 await forgetForeground($, agents)
127 if (JSON.stringify(next) === JSON.stringify(current)) return
128 await update($, jobs, list => reconcile(list, agents, hidden, now))
129}
130
131const hasVisibleJobs = async ($: EngineInterface) => {
132 const [list, now] = await Promise.all([read($, jobs), $.clock.now()])
133 return prune(list, now).length > 0
134}
135
136const schedulePoll = ($: EngineInterface, delay: number) => {
137 $.clock.after(delay, () => void pollAndReschedule($))
138}
139
140const pollAndReschedule = async ($: EngineInterface) => {
141 let delay = IDLE_POLL_MS
142 try {
143 await poll($)
144 if (await hasVisibleJobs($)) delay = ACTIVE_POLL_MS
145 } catch {
146 delay = IDLE_POLL_MS
147 } finally {
148 schedulePoll($, delay)
149 }
150}
151
152const refreshTick = async ($: EngineInterface) => {
153 if (!(await hasVisibleJobs($))) return
154 const now = await $.clock.now()
155 await update($, tick, () => now)
156}
157
158const forgetForeground = async ($: EngineInterface, agents: AgentInfo[]) => {
159 const ended = agents.filter(a => ENDED_STATUSES.includes(a.status)).map(a => a.id)
160 const hidden = await read($, foreground)
161 if (!hidden.some(id => ended.includes(id))) return
162 await update($, foreground, ids => ids.filter(id => !ended.includes(id)))
163}
164
165const tagOf = (text: string, tag: string) => text.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))?.[1]?.trim()
166
167const parseNotification = (text: string) => {
168 const taskId = tagOf(text, 'task-id')
169 const toolUseId = tagOf(text, 'tool-use-id')
170 const status = tagOf(text, 'status')
171 if ((!taskId && !toolUseId) || !status) return undefined
172 const exitCode = tagOf(text, 'summary')?.match(/exit code (-?\d+)/)?.[1]
173 return { taskId, toolUseId, status, exitCode: exitCode === undefined ? undefined : Number(exitCode) }
174}
175
176const settleNotification = async ($: EngineInterface, text: string) => {
177 const note = parseNotification(text)
178 if (!note) return
179 const now = await $.clock.now()
180 const matches = (job: Job) => (note.taskId !== undefined && job.id === note.taskId) || (note.toolUseId !== undefined && job.toolUseId === note.toolUseId)
181 const isOk = note.status === 'completed' && (note.exitCode === undefined || note.exitCode === 0)
182 const detail = isOk ? undefined : note.exitCode !== undefined && note.exitCode !== 0 ? `exit ${note.exitCode}` : note.status
183 await finishJob($, matches, now, isOk ? 'ok' : 'fail', detail)
184}
185
186export const register: Register = on => {
187 on('session.start', async ($, e, next) => {
188 const result = await next(e)
189 schedulePoll($, ACTIVE_POLL_MS)
190 $.clock.every(TICK_MS, () => void refreshTick($).catch(() => {}))
191 return result
192 })
193
194 on('agent.spawn', async ($, e, next) => {
195 const startedAt = await $.clock.now()
196 const result = await next(e)
197 if (result.deny !== undefined || result.agentId === undefined) return result
198 const agentId = result.agentId
199 if (!e.background) {
200 await update($, foreground, ids => [...ids, agentId])
201 return result
202 }
203 await addJob($, {
204 id: agentId,
205 kind: 'agent',
206 label: e.subagentType || 'general-purpose',
207 description: oneLine(e.description),
208 startedAt,
209 toolCalls: 0,
210 })
211 return result
212 })
213
214 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
215 const result = await next(e)
216 if (e.tool !== 'Agent' || result.deny !== undefined || result.isError) return result
217 const record = result.result
218 if (!('status' in record) || record.status !== 'async_launched') return result
219 const agentId = record.agentId
220 if (!(await read($, foreground)).includes(agentId)) return result
221 await update($, foreground, ids => ids.filter(id => id !== agentId))
222 await addJob($, {
223 id: agentId,
224 kind: 'agent',
225 label: e.subagent_type || 'general-purpose',
226 description: oneLine(record.description || e.description),
227 startedAt: await $.clock.now(),
228 toolCalls: 0,
229 })
230 return result
231 })
232
233 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
234 const startedAt = await $.clock.now()
235 const result = await next(e)
236 if (e.tool !== 'Bash' || e.agentId !== undefined || result.deny !== undefined || result.isError) return result
237 const taskId = result.result.backgroundTaskId
238 if (!taskId) return result
239 await addJob($, {
240 id: taskId,
241 kind: 'shell',
242 label: '$',
243 description: oneLine(e.command),
244 startedAt,
245 toolCalls: 0,
246 toolUseId: e.tool_use_id,
247 })
248 return result
249 })
250
251 on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
252 const result = await next(e)
253 if (e.tool !== 'TaskStop' || result.deny !== undefined || result.isError) return result
254 const taskId = result.result.task_id
255 const now = await $.clock.now()
256 await finishJob($, job => job.id === taskId, now, 'fail', 'killed')
257 return result
258 })
259
260 on('tool.call', async ($, e, next) => {
261 const agentId = e.agentId
262 if (agentId !== undefined && (await read($, jobs)).some(j => j.id === agentId && isRunning(j))) {
263 const tool = String(e.tool)
264 await update($, jobs, list => list.map(j => (j.id === agentId && isRunning(j) ? { ...j, toolCalls: j.toolCalls + 1, lastTool: tool } : j)))
265 }
266 return next(e)
267 })
268
269 on('prompt.submit', async ($, e, next) => {
270 if (e.origin.kind === 'task-notification') {
271 await settleNotification($, e.text)
272 } else if (PERSON_ORIGINS.includes(e.origin.kind) && (await read($, jobs)).some(j => !isRunning(j))) {
273 await update($, jobs, list => list.filter(isRunning))
274 }
275 return next(e)
276 })
277
278 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
279 if (e.props.hasSurvey) return next(e)
280 const [list, now] = await Promise.all([read($, jobs), $.clock.now(), read($, tick)])
281 const visible = order(prune(list, now))
282 if (visible.length === 0) return next(e)
283 const { Box, Text } = $.ui.resolve(e)
284 const columns = e.props.bodyColumns
285 return (
286 <Box flexDirection="column">
287 {visible.map(job => {
288 const row = describeJob(job, now, columns)
289 if (row.isFailed) {
290 return (
291 <Box key={job.id} flexDirection="row">
292 <Text color="red">{row.mark}</Text>
293 <Text dimColor wrap="truncate-end">
294 {` ${row.text}`}
295 </Text>
296 </Box>
297 )
298 }
299 return (
300 <Box key={job.id} flexDirection="row">
301 <Text dimColor={row.isFinished} wrap="truncate-end">
302 {`${row.mark} ${row.text}`}
303 </Text>
304 </Box>
305 )
306 })}
307 </Box>
308 )
309 })
310}
311types/index.d.ts 24 lines1export type JobKind = 'agent' | 'shell'
2
3export type JobOutcome = 'ok' | 'fail'
4
5export type Job = {
6 id: string
7 kind: JobKind
8 label: string
9 description: string
10 startedAt: number
11 toolCalls: number
12 lastTool?: string
13 toolUseId?: string
14 endedAt?: number
15 outcome?: JobOutcome
16 detail?: string
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 'background-jobs-band': { jobs: Job[]; foreground: string[]; tick: number }
22 }
23}
24