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.

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.
backgroundTaskId goes on the list: one the model started with run_in_background, or one you sent to the background with Ctrl+B.<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;killed;bg-tasks: 2 running · oldest 12m (npm run dev)
With nothing running there is no line.
/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.
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.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.
/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.
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.
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.
/reload-plugins or an update, tasks started earlier are unknown to it.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
hooks/register.tsx 256 lines1import 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}
256hooks/tasks.ts 143 lines1/** 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