SLOPSHOPPER

bg-tasks

Shows the background shell tasks of the session on the status line or in the shared sidebar, with the oldest one's age, and stops one from the /bg-tasks pane.

newpaneguardcommandstatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bg-tasks
│ ┃ Background tasks ✕ › fix the failing auth test and add an audit log call │ ┃ No background shell task is running. │ ⏺ 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 │ │ › /bg-tasks │ ⎿ bg-tasks: pane open: Enter on a row stops it, Esc closes │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Background tasks
No background shell task is running.
README

bg-tasks

The model starts a dev server or a file watcher in the background, moves on, and forgets about it. An hour later it is still running and you have no idea where it came from. This mod keeps every background shell task of the session in sight until it ends, and lets you stop any of them with one press.

What it does

  1. It watches the Bash tool. Any call that returns a backgroundTaskId goes on the list: one the model started with run_in_background, or one you sent to the background with Ctrl+B.
  2. A task leaves the list when:
  3. its notification arrives (<task-notification> with a <task-id> and a status other than running). The main loop reads it as a prompt. A subagent that is still running reads it as a queued message inside its own loop. A subagent that had already answered is resumed with it, and the mod then reads that agent's messages at the end of its turn;
  4. a subagent that ran in the foreground answers. The engine ends that agent's background tasks along with its answer and sends no notification, so the mod closes them as killed;
  5. the model stops it with the TaskStop tool;
  6. you stop it in the pane.
  7. The status line shows how many are running, the age of the oldest one and its command, redrawn every 30 seconds:

bg-tasks: 2 running · oldest 12m (npm run dev)

With nothing running there is no line.

  1. /bg-tasks opens a pane with one row per task, oldest first:

age who command [ stop ] 12m model npm run dev [ stop ] 3m you tail -f logs/app.log

Enter on a row stops that task through the engine's TaskStop tool, with your press as the consent. The pane then says stopped: npm run dev, or not stopped: ... with the reason, in which case the task stays listed.

  1. With the sidebar open, the list goes there instead and the status line stays empty. It is one section titled with the count (2 running), holding the same rows and a [ stop ... ] button per task. In a row the age and who started it are faint and the command is in the default colour; an age of an hour or more turns yellow, so a runaway task stands out. A button runs /bg-tasks stop <id>, which stops the task the same way. Without the sidebar, everything works as above.
  2. A task that ends by itself also leaves one entry in the sidebar's stream. That way the pane keeps a record of what ended, while the section above holds only what still runs. The entry says how the task ended, and only that word is coloured: finished green for completed, killed yellow, failed (or any other status the engine reports) red:

bg-tasks: task finished sleep 600 · finished after 12m

bg-tasks: task failed npm test · failed after 3m

A task you or the model stopped writes no such entry; the pane already says stopped: <task>. With the sidebar closed nothing is written, because the engine's own task notification already reports the end.

In the live check on 2.1.282 all three subagent paths closed their task: a foreground subagent's sleep 5 that it waited out, a foreground subagent's sleep 120 that it left running when it answered, and a background subagent's sleep 15 that ended after the agent did.

In an earlier live check the model started sleep 900 in the background. The status line showed 1 running · oldest <1m (sleep 900). A press on its row in the pane stopped the process, and both the status line and the engine's 1 shell footer went away.

Command

/bg-tasks opens or closes the pane /bg-tasks list the tasks as text, with their ids /bg-tasks stop <id> stops that task; this is what a sidebar button runs /bg-tasks on | off on by default; off clears the list

The command is not /bg, because the engine keeps /bg for its built-in /background.

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install bg-tasks@kilimcininkoroglu-mods

Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.

After installing

  1. Restart Claude Code.

What it can reach

Validated with claude plugin validate on Claude Code 2.1.283:

❯ ./register.tsx hooks: session.start, command.run{command=bg-tasks}, tool.call{tool=Bash}, tool.call{tool=TaskStop}, prompt.submit{origin has {kind=task-notification}}, prompt.attachment{type=queued_command}, turn.complete, ui.render{component=Pane} ❯ ./register.tsx calls: $.clock.every, $.clock.now, $.command.register, $.session.messages (via afterAgentTurn), $.sidebar.clear (via offSidebar), $.sidebar.set (via toFinished, toSidebar), $.store.get (via readSettings), $.store.set (via runCommand), $.tool.call (via stopTask), $.ui.close (via togglePane), $.ui.invalidate (via changed), $.ui.open (via togglePane), $.ui.panes (via togglePane), $.ui.resolve, $.ui.status (via showStatus)

Reach L2: it calls a tool.

  1. Reads: the command and the result of each Bash call; the text of task notifications; the task id of each TaskStop call; at the end of a subagent's turn, the messages of a subagent that started a listed task
  2. Runs: the engine's TaskStop tool, only on your press in the pane or on the sidebar's button
  3. Sends: nothing to the model; the status line and the pane are drawn for you only
  4. Persists: in $.store, the on/off setting; the task list lives in memory for the session
  5. Hostile input: a task id reaches TaskStop only from the list the engine's own Bash results built; notification text is only matched for ids

Limits

  • Only tasks started while the mod is loaded are listed. After a resume, a /reload-plugins or an update, tasks started earlier are unknown to it.
  • Only background shells are listed; subagents, workflows and monitors are not.
  • A task that ends while its notification is still queued (the session was busy) stays listed until the notification arrives.

Development

make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test

Source 2 files
hooks/register.tsx 256 lines
1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2import { byAge, doneLine, doneTitle, endedTasks, endingWith, hasAgentTask, labelOf, listText, rowText, sectionKey, sidebarButtons, sidebarLines, statusText, type Task } from './tasks.ts'
3
4type Elements = ReturnType<EngineInterface['ui']['resolve']>
5
6const ENABLED_KEY = 'enabled'
7
8const PANE_ID = 'bg-tasks'
9
10const USAGE = 'expects nothing (the pane), list, stop <id>, on or off'
11
12/** How often the status line's ages are redrawn. */
13const TICK_MS = 30_000
14
15/** The running tasks by id, the on/off setting, and the last stop's result for the pane. */
16type State = { tasks: Map<string, Task>; enabled: boolean; message?: string }
17
18/**
19 * Reads the on/off setting from the store, which every window shares, so a change made in another
20 * window applies here at the next hook that acts on it. A mod turned off drops its list, as `off` does.
21 */
22async function readSettings($: EngineInterface, state: State): Promise<void> {
23  state.enabled = (await $.store.get(ENABLED_KEY)) !== false
24  if (!state.enabled) state.tasks.clear()
25}
26
27/**
28 * The background task a Bash call started, by the model's `run_in_background` or the person's Ctrl+B,
29 * and whether the engine ends it with the final answer of the subagent that started it.
30 */
31function startedTask(r: ToolCallResult<'Bash'>): { id: string; byUser: boolean; endsWithAgent: boolean } | undefined {
32  if (r.deny !== undefined || r.isError === true) return undefined
33  const id = r.result.backgroundTaskId
34  return id === undefined ? undefined : { id, byUser: r.result.backgroundedByUser === true, endsWithAgent: r.result.backgroundEndsWithFinalResponse === true }
35}
36
37function errorText(err: unknown): string {
38  return err instanceof Error ? err.message : String(err)
39}
40
41/** Writes the task list into the shared sidebar; false when the sidebar mod is absent or closed. */
42async function toSidebar($: EngineInterface, tasks: Task[], now: number): Promise<boolean> {
43  try {
44    return await $.sidebar.set({
45      consumer: 'bg-tasks',
46      key: 'tasks',
47      title: `${tasks.length} running`,
48      lines: sidebarLines(tasks, now),
49      buttons: sidebarButtons(tasks),
50      until: 'session',
51      // Below every order-20 section (cache-warm, memory-save), which sorts by consumer name.
52      order: 21,
53    })
54  } catch {
55    return false
56  }
57}
58
59/**
60 * A task that ended by itself, as an entry in the sidebar's stream named and coloured by its status.
61 * The engine's own task notification already tells the person, so a closed sidebar gets no second
62 * line here.
63 */
64async function toFinished($: EngineInterface, task: Task, status: string, now: number): Promise<void> {
65  try {
66    await $.sidebar.set({
67      consumer: 'bg-tasks',
68      key: sectionKey(task.id),
69      title: doneTitle(status),
70      lines: [doneLine(task, status, now)],
71      until: 'stream',
72    })
73  } catch {
74    // The sidebar mod is not installed.
75  }
76}
77
78async function offSidebar($: EngineInterface): Promise<void> {
79  try {
80    await $.sidebar.clear({ consumer: 'bg-tasks', key: 'tasks' })
81  } catch {
82    // The sidebar mod is not installed; there is nothing to clear.
83  }
84}
85
86/** The sidebar takes the list while it is open; otherwise the status line shows it, as before. */
87async function showStatus($: EngineInterface, state: State): Promise<void> {
88  const tasks = byAge(state.tasks.values())
89  const now = await $.clock.now()
90  if (state.enabled && tasks.length > 0 && (await toSidebar($, tasks, now))) {
91    $.ui.status(undefined)
92    return
93  }
94  await offSidebar($)
95  $.ui.status(state.enabled ? statusText(tasks, now) : undefined)
96}
97
98/** Redraws the status line, the sidebar and the pane after the task list changed. */
99async function changed($: EngineInterface, state: State): Promise<void> {
100  await showStatus($, state)
101  $.ui.invalidate('ui.render')
102}
103
104/** Stops one task through the engine's TaskStop tool, on the person's press. */
105async function stopTask($: EngineInterface, state: State, task: Task): Promise<void> {
106  try {
107    const r = await $.tool.call({ tool: 'TaskStop', task_id: task.id, consent: `The user pressed "stop" for "${task.label}"` })
108    if (r.deny !== undefined || r.isError === true) throw new Error(r.deny ?? r.text ?? 'TaskStop failed')
109    state.tasks.delete(task.id)
110    state.message = `stopped: ${task.label}`
111  } catch (err) {
112    state.message = `not stopped: ${task.label}: ${errorText(err)}`
113  }
114  await changed($, state)
115}
116
117/**
118 * Drops the tasks a notification reports as ended and writes each into the stream. A task already
119 * dropped is not found again, so a notification read on two paths writes one entry.
120 */
121async function closeEnded($: EngineInterface, state: State, text: string): Promise<void> {
122  await closeTasks($, state, endedTasks(text).flatMap(({ id, status }) => {
123    const task = state.tasks.get(id)
124    return task === undefined ? [] : [{ task, status }]
125  }))
126}
127
128/** Drops the tasks that ended and writes each into the stream by the status it ended in. */
129async function closeTasks($: EngineInterface, state: State, ended: { task: Task; status: string }[]): Promise<void> {
130  if (ended.length === 0) return
131  const now = await $.clock.now()
132  for (const { task, status } of ended) {
133    state.tasks.delete(task.id)
134    await toFinished($, task, status, now)
135  }
136  await changed($, state)
137}
138
139/**
140 * The end of a subagent's turn. A subagent the engine resumed with the notification of its own task
141 * reads it as a message no hook sees, so its messages are read for the notifications of its tasks. A
142 * task the engine ends with the agent's final answer sends no notification, so it closes as killed.
143 */
144async function afterAgentTurn($: EngineInterface, state: State, agentId: string): Promise<void> {
145  if (!hasAgentTask(state.tasks.values(), agentId)) return
146  const messages = await $.session.messages({ agentId })
147  if (!('deny' in messages)) await closeEnded($, state, messages.filter(m => m.role === 'user').map(m => m.text).join('\n'))
148  await closeTasks($, state, endingWith(state.tasks.values(), agentId).map(task => ({ task, status: 'killed' })))
149}
150
151async function togglePane($: EngineInterface, state: State): Promise<string> {
152  if ((await $.ui.panes()).some(p => p.id === PANE_ID)) {
153    await $.ui.close({ id: PANE_ID })
154    return 'pane closed'
155  }
156  state.message = undefined
157  const rows = Math.min(state.tasks.size + 4, 20)
158  await $.ui.open({ id: PANE_ID, title: 'Background tasks', focus: true, closeOnEscape: true, holdToasts: true, rows })
159  return 'pane open: Enter on a row stops it, Esc closes'
160}
161
162/** Stops the task a sidebar button names, so a press reaches this mod as `/bg-tasks stop <id>`. */
163async function stopById($: EngineInterface, state: State, id: string): Promise<string> {
164  const task = state.tasks.get(id)
165  if (task === undefined) return `no running task with id ${id}`
166  await stopTask($, state, task)
167  return state.message ?? `stopped ${task.label}`
168}
169
170async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
171  const word = args.trim()
172  // The pane, a stop and the list start from the setting as the store holds it.
173  await readSettings($, state)
174  if (word.startsWith('stop ')) return stopById($, state, word.slice(5).trim())
175  if (word === 'on' || word === 'off') {
176    await $.store.set(ENABLED_KEY, word === 'on')
177    state.enabled = word === 'on'
178    if (!state.enabled) state.tasks.clear()
179    await changed($, state)
180    return word === 'on' ? 'on: background shell tasks started from now on are listed' : 'off: background tasks are not listed'
181  }
182  if (word !== 'list') return word === '' ? togglePane($, state) : USAGE
183  return `${state.enabled ? 'on' : 'off'}\n${listText([...state.tasks.values()], await $.clock.now())}`
184}
185
186function paneTree(els: Elements, state: State, now: number, onStop: (task: Task) => void) {
187  const { Box, Button, Text } = els
188  const tasks = byAge(state.tasks.values())
189  return (
190    <Box flexDirection="column">
191      {tasks.length === 0 ? <Text>No background shell task is running.</Text> : <Text dimColor>{'   age  who    command'}</Text>}
192      {tasks.map((t, i) => (
193        <Button key={`stop:${t.id}`} plain {...(i === 0 ? { autoFocus: true as const } : {})} label={`[ stop ] ${rowText(t, now)}`} onPress={() => onStop(t)} />
194      ))}
195      {state.message === undefined ? null : <Text dimColor>{state.message}</Text>}
196    </Box>
197  )
198}
199
200export const register: Register = on => {
201  const state: State = { tasks: new Map(), enabled: true }
202
203  on('session.start', async ($, e, next) => {
204    const r = await next(e)
205    await $.command.register({ name: 'bg-tasks', description: 'Background shell tasks: the pane with stop buttons, list, stop <id>, on, off (bg-tasks)', argumentHint: '[list | stop <id> | on | off]' })
206    await readSettings($, state)
207    $.clock.every(TICK_MS, () => void readSettings($, state).then(() => showStatus($, state)))
208    return r
209  })
210
211  // The engine prints the plugin name in front of command text and the status line, so the texts do not repeat it.
212  on('command.run', { command: 'bg-tasks' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
213
214  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
215    const r = await next(e)
216    const started = startedTask(r)
217    if (started === undefined) return r
218    await readSettings($, state)
219    if (!state.enabled) return r
220    const agentId = (e as { agentId?: string }).agentId
221    state.tasks.set(started.id, { ...started, label: labelOf(e.command), startedAt: await $.clock.now(), ...(agentId === undefined ? {} : { agentId }) })
222    await changed($, state)
223    return r
224  })
225
226  on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
227    const r = await next(e)
228    const id = e.task_id ?? e.shell_id
229    if (r.deny === undefined && r.isError !== true && id !== undefined && state.tasks.delete(id)) await changed($, state)
230    return r
231  })
232
233  on('prompt.submit', { origin: { kind: 'task-notification' } }, async ($, e, next) => {
234    await closeEnded($, state, e.text)
235    return next(e)
236  })
237
238  // A subagent reads the notification of its own task inside its loop, never as a main-loop prompt.
239  on('prompt.attachment', { type: 'queued_command' }, async ($, e, next) => {
240    await closeEnded($, state, e.text)
241    return next(e)
242  })
243
244  on('turn.complete', async ($, e, next) => {
245    const r = await next(e)
246    if (e.agentId !== undefined) await afterAgentTurn($, state, e.agentId)
247    return r
248  })
249
250  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
251    if (e.requestId !== PANE_ID) return next(e)
252    const now = await $.clock.now()
253    return paneTree($.ui.resolve(e), state, now, task => void stopTask($, state, task))
254  })
255}
256
hooks/tasks.ts 143 lines
1/** The background shell tasks the session started, and their texts on the status line and in the pane. */
2
3/**
4 * One background shell: its task id, what it runs, when it started and whether the person backgrounded
5 * it; for a subagent's, the agent's id, and whether the engine ends it with the agent's final answer.
6 */
7export type Task = { id: string; label: string; startedAt: number; byUser: boolean; agentId?: string; endsWithAgent?: boolean }
8
9/** The tasks an agent started that the engine ends with that agent's final answer. */
10export function endingWith(tasks: Iterable<Task>, agentId: string): Task[] {
11  return [...tasks].filter(t => t.agentId === agentId && t.endsWithAgent === true)
12}
13
14/** Whether any task was started in that agent's loop. */
15export function hasAgentTask(tasks: Iterable<Task>, agentId: string): boolean {
16  return [...tasks].some(t => t.agentId === agentId)
17}
18
19const MINUTE = 60_000
20
21/** The longest label shown. */
22const MAX_LABEL = 80
23
24/** The first line of the command, cut to `MAX_LABEL` characters. */
25export function labelOf(command: string): string {
26  const line = command.trim().split('\n')[0] ?? ''
27  return line.length > MAX_LABEL ? `${line.slice(0, MAX_LABEL - 1)}…` : line
28}
29
30/** Durations as limit-watch and cache-warm write them: `<1m`, `45m`, `2h 36m`, `3h`, `5d 11h`. */
31export function durationText(ms: number): string {
32  const minutes = Math.floor(Math.max(0, ms) / MINUTE)
33  if (minutes < 1) return '<1m'
34  if (minutes < 60) return `${minutes}m`
35  const hours = Math.floor(minutes / 60)
36  if (hours < 24) return minutes % 60 > 0 ? `${hours}h ${minutes % 60}m` : `${hours}h`
37  return `${Math.floor(hours / 24)}d ${hours % 24}h`
38}
39
40/** A task a notification reports as ended, with the status it ended in. */
41export type Ended = { id: string; status: string }
42
43/**
44 * The tasks a notification reports as ended. The engine sends
45 * `<task-notification><task-id>ID</task-id>…<status>completed</status>…` (measured on 2.1.278).
46 */
47export function endedTasks(text: string): Ended[] {
48  const ended: Ended[] = []
49  for (const block of text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)) {
50    const id = /<task-id>([^<]+)<\/task-id>/.exec(block[1] ?? '')?.[1]
51    const status = /<status>([^<]+)<\/status>/.exec(block[1] ?? '')?.[1]?.trim() ?? 'ended'
52    if (id !== undefined && status !== 'running') ended.push({ id: id.trim(), status })
53  }
54  return ended
55}
56
57/** Tasks from the oldest to the newest. */
58export function byAge(tasks: Iterable<Task>): Task[] {
59  return [...tasks].sort((a, b) => a.startedAt - b.startedAt)
60}
61
62/** The status line text, or undefined for no line. */
63export function statusText(tasks: readonly Task[], now: number): string | undefined {
64  const oldest = byAge(tasks)[0]
65  if (oldest === undefined) return undefined
66  return `${tasks.length} running · oldest ${durationText(now - oldest.startedAt)} (${oldest.label})`
67}
68
69/** One pane or list row: age, who backgrounded it, the command. */
70export function rowText(task: Task, now: number): string {
71  return `${durationText(now - task.startedAt).padStart(6)}  ${task.byUser ? 'you  ' : 'model'}  ${task.label}`
72}
73
74/** A task that has run this long draws its age in yellow, so a runaway one stands out. */
75const LONG_MS = 60 * MINUTE
76
77/**
78 * One sidebar row, the same text the pane draws: the age faint, or yellow past an hour, who
79 * backgrounded it faint, and the command in the default colour.
80 */
81function rowLine(task: Task, now: number): Line {
82  const age = now - task.startedAt
83  const parts: Part[] = [
84    { text: durationText(age).padStart(6), kind: age >= LONG_MS ? 'warn' : 'dim' },
85    { text: '  ' },
86    { text: task.byUser ? 'you  ' : 'model', kind: 'dim' },
87    { text: `  ${task.label}` },
88  ]
89  return { text: parts.map(p => p.text).join(''), parts }
90}
91
92/** The sidebar section's lines: the same rows the pane draws. */
93export function sidebarLines(tasks: readonly Task[], now: number): Line[] {
94  return byAge(tasks).map(t => rowLine(t, now))
95}
96
97/** The sidebar section's buttons: one stop per task, run as `/bg-tasks stop <id>`. */
98export function sidebarButtons(tasks: readonly Task[]): { label: string; command: string; args: string }[] {
99  return byAge(tasks).map(t => ({ label: `stop ${t.label}`, command: 'bg-tasks', args: `stop ${t.id}` }))
100}
101
102type Kind = 'ok' | 'warn' | 'error' | 'dim'
103
104/** A piece of a sidebar line in its own colour. */
105type Part = { text: string; kind?: Kind }
106
107/** A sidebar line made of parts; `text` holds the whole line for a sidebar that draws no parts. */
108export type Line = { text: string; kind?: Kind; parts?: Part[] }
109
110/**
111 * How an ended task is named and coloured: `completed` finished in green, `killed` in yellow, and
112 * `failed` or any status the engine adds later in red, under its own name.
113 */
114function endedWord(status: string): { word: string; kind: Kind } {
115  if (status === 'completed') return { word: 'finished', kind: 'ok' }
116  return status === 'killed' ? { word: 'killed', kind: 'warn' } : { word: status, kind: 'error' }
117}
118
119/** The stream entry's title for an ended task: `task finished`, `task failed`, `task killed`. */
120export function doneTitle(status: string): string {
121  return `task ${endedWord(status).word}`
122}
123
124/**
125 * The stream entry of a task that ended by itself: what it ran, how it ended and how long it took.
126 * Only the status word is coloured, so a failed task does not read as a finished one.
127 */
128export function doneLine(task: Task, status: string, now: number): Line {
129  const { word, kind } = endedWord(status)
130  const parts: Part[] = [{ text: `${task.label} · ` }, { text: word, kind }, { text: ` after ${durationText(now - task.startedAt)}` }]
131  return { text: parts.map(p => p.text).join(''), parts }
132}
133
134/** A sidebar section key: the task id cut to what the sidebar takes, so each task keeps its own entry. */
135export function sectionKey(id: string): string {
136  return `done-${id.replace(/[^A-Za-z0-9._:-]+/g, '-')}`.slice(0, 64)
137}
138
139export function listText(tasks: readonly Task[], now: number): string {
140  if (tasks.length === 0) return 'no background shell task is running'
141  return byAge(tasks).map(t => `${t.id}  ${rowText(t, now)}`).join('\n')
142}
143