SLOPSHOPPER

background-jobs-band

Lists background subagents and shells in a band above the prompt

newbandguardprompttimeragents
A shopper browsing a rack in a slop shop
README

background-jobs-band

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.

Behavior

  • One row per job:
  ⏵ Explore  search auth flow · 14 tools (Grep) · 1m23s
  ⏵ $ ./gradlew testDebugUnitTest · 3m10s
  ✓ general-purpose  update README · done 2m ago
  ✗ $ npm test · exit 1 · 5m ago
  • Running rows show the kind (the agent type for a subagent, $ 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.
  • Finished rows show ✓ on success and ✗ on failure or kill, with the time since they ended. They are dimmed, and only the ✗ is red.
  • A finished row is removed when the person submits the next prompt or 5 minutes after it ended, whichever comes first.
  • Elapsed time is shown as 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.
  • The description is cut with … to fit the band width (bodyColumns).
  • When there is nothing to show, or a survey holds the band, the mod passes and the band is not drawn.

Data sources

  • Subagents and teammates: $.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.
  • Subagent progress: tool.call events carrying the subagent's agentId are counted.
  • Background shells: a main-loop Bash call whose result carries backgroundTaskId (run_in_background, Ctrl+B, or an auto-backgrounded timeout).
  • Shell completion: the background task notification (a 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.

Limitations

  • Background shells started inside a subagent are not tracked, because their notifications go to the subagent rather than to the main session.
  • The notification text has no structured API; if its format changes, finished shells stay listed as running until the session ends (a TaskStop still ends them).
  • Workflows and monitors are not shown.

Development

claude plugin validate claude/mods/background-jobs-band
claude plugin test claude/mods/background-jobs-band

License

MIT

Source 2 files
hooks/register.tsx 311 lines
1import { 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}
311
types/index.d.ts 24 lines
1export 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