SLOPSHOPPER

Background Watch

See Claude's background shell commands: live output in a pane, status, and a toast when one fails

newpaneguardcommandtoaststatus
v0.1.0MITupdated 2026-10-03Arunprakaash/claude-mods/bg-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bg-watch
│ ┃ Background ✕ › fix the failing auth test and add an audit log call │ ┃ No background commands yet. │ ┃ When Claude runs one, its output streams ⏺ Read(src/auth.ts) │ ┃ here. ⎿ 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-watch │ ⎿ bg-watch: Background pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Background
No background commands yet. When Claude runs one, its output streams here.
README

bg-watch

See what Claude's background shell commands are doing, while they run.

When Claude starts a dev server, a build or a test run in the background, its output goes to a file you never see. bg-watch opens a pane that follows it live, marks each command running, done or failed, and pops a toast when one fails.

bg-watch in the terminal

bg-watch in Claude Desktop

Install

In Claude Code (v2.1.287 or later):

/plugin install bg-watch --marketplace Arunprakaash/claude-mods

Or from your shell:

claude plugin marketplace add Arunprakaash/claude-mods
claude plugin install bg-watch@arun-mods

Use

The pane opens on its own the first time Claude runs a command in the background, including one you push there with Ctrl+B.

  • Terminal: the five most recent commands, each with its status, run time and exit code, over the live tail of the one you picked. Focus the pane (Ctrl+X, then Tab) and press 1–5 to switch.
  • Desktop app (Code tab): a dropdown to pick the command, a timeline of every command coloured by status, and the output in a code block.
  • /bg-watch reopens the pane. It runs at once, even while Claude is mid-turn. /bg-watch text prints a plain summary instead.
  • When a command fails, a toast names it and its exit code.

In places that can't draw a pane, such as the VS Code extension's chat panel or claude -p, the mod still tracks commands, and /bg-watch answers with the text summary.

What it can reach

Mods run inside Claude Code with your permissions, so here is all of what this one does. claude plugin validate on this folder lists the same.

  • Watches: Bash tool calls, to note the ones that went to the background. It never changes or blocks a call.
  • Reads: the background command's own output file, with tail -n 300, and the file's size, to know when it changed.
  • Listens: for Claude Code's "background command finished" notices, to mark each command done or failed.
  • Draws: its pane, a status line while the pane is closed, and toasts.
  • Does not: make network requests, write files, read your settings or environment, or start anything except tail.

Limits

  • It tracks commands started after it loads; a reload of the mod clears the list.
  • It covers Bash commands, not background agents.
  • It reads output with tail, so on Windows the pane lists commands without their output.
  • It relies on the wording Claude Code uses for its background-command messages. If that changes, tracking can stop until the mod is updated.

Develop

claude --plugin-dir ./bg-watch      # load this checkout for one session
claude plugin validate ./bg-watch   # what it hooks and calls
claude plugin test ./bg-watch       # the test suite
Source 2 files
hooks/register.tsx 281 lines
1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import { COLOR, ICON, cleanLines, elapsed, parseNotifications, parseStart, timelineSvg } from './lib'
4import type { Task } from './lib'
5
6const PANE = 'bg-watch'
7// `/bg` is the built-in /background's alias.
8const COMMAND = 'bg-watch'
9const TAIL_LINES = 300
10const LIST_ROWS = 5
11
12// Module state: what the hooks record, the pane draws. A reload starts it over.
13const tasks = new Map<string, Task>()
14const tails = new Map<string, string[]>()
15const sizes = new Map<string, number>()
16let selected: string | null = null
17let isOpen = false
18let isClosedByPerson = false
19let ticker: Timer | null = null
20
21function current(): Task | undefined {
22  if (selected && tasks.has(selected)) return tasks.get(selected)
23  const all = [...tasks.values()]
24  return all.reverse().find(t => t.status === 'running') ?? all[0]
25}
26
27function labelOf(t: Task, now: number): string {
28  const took = elapsed((t.endedAt ?? now) - t.startedAt)
29  const exit = t.exitCode !== undefined && t.exitCode !== 0 ? ` · exit ${t.exitCode}` : ''
30  return `${ICON[t.status]} ${t.label} · ${took}${exit}`
31}
32
33function statusLine(): string | undefined {
34  if (tasks.size === 0) return undefined
35  const count = (s: Task['status']) => [...tasks.values()].filter(t => t.status === s).length
36  const parts = [
37    count('running') ? `${ICON.running}${count('running')}` : '',
38    count('completed') ? `${ICON.completed}${count('completed')}` : '',
39    count('failed') ? `${ICON.failed}${count('failed')}` : '',
40  ].filter(Boolean)
41  return `bg ${parts.join(' ')}`
42}
43
44async function readTail($: EngineInterface, t: Task): Promise<void> {
45  if (!t.outputFile) return
46  try {
47    const { size } = await $.fs.stat(t.outputFile)
48    if (sizes.get(t.id) === size && tails.has(t.id)) return
49    sizes.set(t.id, size)
50    const r = await $.process.run(['tail', '-n', String(TAIL_LINES), t.outputFile], { timeoutMs: 5_000 })
51    tails.set(t.id, cleanLines(r.stdout))
52  } catch {
53    // The file can vanish once the session cleans up its tasks folder.
54  }
55}
56
57async function tick($: EngineInterface): Promise<void> {
58  const t = current()
59  if (t && isOpen) await readTail($, t)
60  // The pane already shows all of this; the status line is for when it's closed.
61  $.ui.status(isOpen ? undefined : statusLine())
62  $.ui.invalidate('ui.render')
63  const isIdle = ![...tasks.values()].some(x => x.status === 'running')
64  if (isIdle && !isOpen && ticker) {
65    ticker.cancel()
66    ticker = null
67  }
68}
69
70function startTicker($: EngineInterface): void {
71  ticker ??= $.clock.every(1_000, () => void tick($))
72}
73
74async function textSummary($: EngineInterface): Promise<string> {
75  if (tasks.size === 0) return 'No background commands in this session yet.'
76  const now = await $.clock.now()
77  const rows = [...tasks.values()].reverse().map(t => labelOf(t, now))
78  const t = current()
79  if (!t) return rows.join('\n')
80  await readTail($, t)
81  const tail = (tails.get(t.id) ?? []).slice(-15)
82  const where = t.outputFile ? `\n(full output: ${t.outputFile})` : ''
83  return [...rows, '', `── ${t.label} ──`, ...tail].join('\n') + where
84}
85
86export const register: Register = on => {
87  on('session.start', async ($, e, next) => {
88    try {
89      await $.command.register({
90        name: COMMAND,
91        description: "Show Claude's background commands and their live output",
92        argumentHint: '[text]',
93        // Background commands matter most while Claude is mid-turn.
94        immediate: true,
95      })
96    } catch (error) {
97      // The pane and toasts still work without the command.
98      $.ui.toast(`bg-watch: /${COMMAND} unavailable (${String(error).slice(0, 80)})`)
99    }
100    return next(e)
101  })
102
103  on('command.run', { command: COMMAND }, async ($, e) => {
104    if (e.args.trim() === 'text') return { text: await textSummary($) }
105    isClosedByPerson = false
106    const opened = await $.ui.open({ id: PANE, title: 'Background', rows: 16 })
107    if (!opened.isPlaced) return { text: await textSummary($) }
108    isOpen = true
109    startTicker($)
110    void tick($)
111    return { text: 'Background pane opened.' }
112  })
113
114  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
115    const ran = await next(e)
116    const result = ran.result as { backgroundTaskId?: string } | undefined
117    const id = result?.backgroundTaskId
118    if (!id || tasks.has(id)) return ran
119
120    const start = parseStart(ran.text)
121    tasks.set(id, {
122      id,
123      label: e.description?.trim() || (e.command.split('\n')[0] ?? '').slice(0, 60),
124      command: e.command,
125      outputFile: start?.outputFile,
126      startedAt: await $.clock.now(),
127      status: 'running',
128    })
129    selected = null
130    startTicker($)
131    if (!isClosedByPerson && !isOpen) {
132      // Nothing draws under `claude -p` or the VS Code panel; tracking goes on.
133      isOpen = await $.ui.open({ id: PANE, title: 'Background', rows: 16 }).then(
134        opened => opened.isPlaced,
135        () => false,
136      )
137    }
138    void tick($)
139    return ran
140  })
141
142  on('session.append', async ($, e, next) => {
143    const text = JSON.stringify(e.message.content)
144    if (!text.includes('task-notification')) return next(e)
145
146    const now = await $.clock.now()
147    for (const n of parseNotifications(text.replace(/\\n/g, '\n'))) {
148      const t = tasks.get(n.id)
149      if (!t || t.status !== 'running') continue
150      t.status = n.status
151      t.exitCode = n.exitCode
152      t.endedAt = now
153      t.outputFile ??= n.outputFile
154      sizes.delete(t.id)
155      await readTail($, t)
156      if (n.status === 'failed') {
157        $.ui.toast(`✗ ${t.label} failed${n.exitCode !== undefined ? ` (exit ${n.exitCode})` : ''}  ·  /${COMMAND}`)
158      }
159    }
160    void tick($)
161    return next(e)
162  })
163
164  on('ui.close', { id: PANE }, async ($, e, next) => {
165    isOpen = false
166    if (e.origin.kind === 'person') isClosedByPerson = true
167    return next(e)
168  })
169
170  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
171    isOpen = true
172    const t = current()
173    const now = await $.clock.now()
174
175    // Desktop draws native controls and SVG; give it a layout of its own.
176    if (e.surface === 'desktop') {
177      const { Box, Text, Button, Select, Svg, Code } = $.ui.resolve(e)
178      if (!t) {
179        return (
180          <Box flexDirection="column" padding={1} gap={1}>
181            <Text bold>No background commands yet</Text>
182            <Text dimColor>When Claude starts a long-running command, its output streams here.</Text>
183          </Box>
184        )
185      }
186      const recent = [...tasks.values()].reverse().slice(0, 8)
187      const tail = (tails.get(t.id) ?? []).slice(-60).join('\n')
188      return (
189        <Box flexDirection="column" padding={1} gap={1}>
190          <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={2}>
191            <Select
192              key="task"
193              options={recent.map(x => ({ key: x.id, value: x.id, label: `${ICON[x.status]} ${x.label}` }))}
194              value={t.id}
195              onSelect={value => {
196                selected = value
197                sizes.delete(value)
198                void tick($)
199              }}
200            />
201            <Text color={COLOR[t.status]} bold>
202              {t.status} · {elapsed((t.endedAt ?? now) - t.startedAt)}
203              {t.exitCode ? ` · exit ${t.exitCode}` : ''}
204            </Text>
205          </Box>
206          <Svg source={timelineSvg(recent, now)} alt="Timeline of background commands by status" />
207          <Code source={`$ ${t.command}`} language="bash" wrap="wrap" />
208          {tail ? (
209            <Code source={tail} wrap="truncate-end" />
210          ) : (
211            <Text dimColor>{t.outputFile ? 'No output yet.' : 'Output file unknown until the command finishes.'}</Text>
212          )}
213          {selected ? (
214            <Button
215              key="follow"
216              label="Follow latest"
217              variant="secondary"
218              onPress={() => {
219                selected = null
220                void tick($)
221              }}
222            />
223          ) : null}
224        </Box>
225      )
226    }
227
228    const { Box, Text, Button } = $.ui.resolve(e)
229    if (!t) {
230      return (
231        <Box flexDirection="column">
232          <Text dimColor>No background commands yet.</Text>
233          <Text dimColor>When Claude runs one, its output streams here.</Text>
234        </Box>
235      )
236    }
237
238    const recent = [...tasks.values()].reverse().slice(0, LIST_ROWS)
239    const rows = Math.max(3, e.props.scroll.bodyRows - recent.length - 1)
240    const lines = (tails.get(t.id) ?? []).slice(-rows)
241
242    return (
243      <Box flexDirection="column">
244        {recent.map((x, i) => (
245          <Box key={x.id} flexDirection="row">
246            <Button
247              key={`pick-${x.id}`}
248              label={x.label}
249              hotkey={String(i + 1)}
250              plain
251              dimColor={x.id !== t.id}
252              onPress={() => {
253                selected = x.id
254                sizes.delete(x.id)
255                void tick($)
256              }}
257            />
258            <Text color={COLOR[x.status]} wrap="truncate-end">
259              {'  '}
260              {ICON[x.status]} {elapsed((x.endedAt ?? now) - x.startedAt)}
261              {x.exitCode ? ` · exit ${x.exitCode}` : ''}
262            </Text>
263          </Box>
264        ))}
265        <Text color={COLOR[t.status]} wrap="truncate-end">
266          {ICON[t.status]} {t.status} · $ {t.command.split('\n')[0] ?? ''}
267        </Text>
268        {lines.length === 0 ? (
269          <Text dimColor>{t.outputFile ? 'No output yet.' : 'Output file unknown until the command finishes.'}</Text>
270        ) : (
271          lines.map((line, i) => (
272            <Text key={`l${i}`} wrap="truncate-end">
273              {line || ' '}
274            </Text>
275          ))
276        )}
277      </Box>
278    )
279  })
280}
281
hooks/lib.ts 119 lines
1export type TaskStatus = 'running' | 'completed' | 'failed' | 'killed'
2
3export type Task = {
4  id: string
5  label: string
6  command: string
7  outputFile?: string
8  startedAt: number
9  endedAt?: number
10  status: TaskStatus
11  exitCode?: number
12}
13
14export type Notification = {
15  id: string
16  status: TaskStatus
17  outputFile?: string
18  exitCode?: number
19}
20
21// Bash answers a backgrounded command with this sentence; the path is where
22// its output streams.
23export function parseStart(text: string | undefined): { id: string; outputFile?: string } | null {
24  if (!text) return null
25  const id = /running in background with ID: (\w+)/.exec(text)?.[1]
26  if (!id) return null
27  const outputFile = /Output is being written to: (\S+?\.output)\b/.exec(text)?.[1]
28  return { id, outputFile }
29}
30
31// A finished background command reaches the conversation as a
32// <task-notification> block. The text may be JSON-escaped, so match tags only.
33export function parseNotifications(text: string): Notification[] {
34  const found: Notification[] = []
35  for (const m of text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)) {
36    const body = m[1] ?? ''
37    const id = /<task-id>(\w+)<\/task-id>/.exec(body)?.[1]
38    const raw = /<status>(\w+)<\/status>/.exec(body)?.[1]
39    if (!id || !raw) continue
40    const exit = /exit code (\d+)/.exec(body)?.[1]
41    found.push({
42      id,
43      status: raw === 'completed' ? 'completed' : raw === 'failed' ? 'failed' : 'killed',
44      outputFile: /<output-file>([^<]+)<\/output-file>/.exec(body)?.[1],
45      exitCode: exit === undefined ? undefined : Number(exit),
46    })
47  }
48  return found
49}
50
51const ANSI = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07/g
52
53export function cleanLines(text: string): string[] {
54  const lines = text
55    .replace(ANSI, '')
56    .replace(/\r(?!\n)/g, '\n')
57    .split('\n')
58    // Claude Code appends its own exit line; the pane shows the exit code already.
59    .filter(line => !/^\[exited with code \d+\]$/.test(line.trim()))
60  while (lines.length && (lines[lines.length - 1] ?? '').trim() === '') lines.pop()
61  return lines
62}
63
64export function elapsed(ms: number): string {
65  const s = Math.max(0, Math.round(ms / 1000))
66  if (s < 60) return `${s}s`
67  const m = Math.floor(s / 60)
68  if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
69  return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
70}
71
72export const ICON: Record<TaskStatus, string> = {
73  running: '◐',
74  completed: '✓',
75  failed: '✗',
76  killed: '■',
77}
78
79export const COLOR: Record<TaskStatus, string> = {
80  running: 'yellow',
81  completed: 'green',
82  failed: 'red',
83  killed: 'gray',
84}
85
86// Mid-tone fills that read on both light and dark themes.
87const FILL: Record<TaskStatus, string> = {
88  running: '#d4a72c',
89  completed: '#2da44e',
90  failed: '#cf222e',
91  killed: '#8c959f',
92}
93
94function escapeXml(s: string): string {
95  return s.replace(/[<>&"']/g, c => `&#${c.charCodeAt(0)};`)
96}
97
98// One bar per command on a shared time axis, newest at the top: where each
99// started, how long it ran, and how it ended.
100export function timelineSvg(list: readonly Task[], now: number, width = 640): string {
101  const row = 22
102  const labelW = Math.round(width * 0.38)
103  const barW = width - labelW - 8
104  const from = Math.min(...list.map(t => t.startedAt))
105  const span = Math.max(1, now - from)
106  const bars = list.map((t, i) => {
107    const y = i * row
108    const x = labelW + ((t.startedAt - from) / span) * barW
109    const w = Math.max(3, (((t.endedAt ?? now) - t.startedAt) / span) * barW)
110    const label = t.label.length > 34 ? `${t.label.slice(0, 33)}…` : t.label
111    return (
112      `<text x="0" y="${y + 15}" font-size="12" font-family="system-ui,sans-serif" fill="#8c959f">${escapeXml(label)}</text>` +
113      `<rect x="${x.toFixed(1)}" y="${y + 5}" width="${w.toFixed(1)}" height="12" rx="3" fill="${FILL[t.status]}"/>`
114    )
115  })
116  const height = list.length * row
117  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">${bars.join('')}</svg>`
118}
119