Live output of the background shells Claude starts, in up to 3 stacked windows (the rest queued)

A Claude Code mod that shows the live output (stdout + stderr) of the background shells Claude starts, in a Shells pane beside the transcript.
run_in_background, or moved there later (Ctrl+B, a timeout). Foreground commands are ignored.● running, ✓ exit 0, ✗ exit N, ■ killed.↑N ↓N in its title); [ end ] or scrolling back down follows new output again.[ close ] (keys 1–3 while the pane is focused); [ close finished ] sits at the bottom.| Command | What it does | ||
|---|---|---|---|
/shells-list | List this session's background shells: number, status, run time, shown/queued/hidden | ||
/shell [n] | Show shell n. With no number: every running shell, or the newest if none run. Full pane: goes to the front of the queue | ||
| `/shell-close <n\ | done\ | all>` | Close windows (done is the default) |
/shell-clear | Forget finished shells |
Closing the whole pane (its [X], Esc, or ctrl+x x) hides every window and clears the queue; /shell brings them back.
/plugin install shell-monitor --marketplace isaac-pathlab/claude-monitor
/shell n.claude --plugin-dir <path-to-clone>/shell-monitor
claude plugin validate shell-monitor
claude plugin test shell-monitor
hooks/register.tsx 527 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Shell, ShellStatus } from '../types'
5
6const MAX_PANES = 3
7const POLL_MS = 400
8const OUTPUT_CAP = 200_000
9// One pane holding up to MAX_PANES stacked shell windows: the engine shows
10// several panes as tabs, so the stacking is drawn inside a single pane.
11const PANE_ID = 'shells'
12
13const shellsAtom = atom({ plugin: 'shell-monitor', key: 'shells' } as const, [])
14const slotsAtom = atom({ plugin: 'shell-monitor', key: 'slots' } as const, [])
15const queueAtom = atom({ plugin: 'shell-monitor', key: 'queue' } as const, [])
16const nextNAtom = atom({ plugin: 'shell-monitor', key: 'nextN' } as const, 1)
17const tasksDirAtom = atom({ plugin: 'shell-monitor', key: 'tasksDir' } as const, '')
18// Per window, the first line shown while scrolled up; absent means follow the end.
19const scrollAtom = atom({ plugin: 'shell-monitor', key: 'scroll' } as const, {})
20// The second the windows' run times count to; the poll moves it while any shell runs.
21const nowAtom = atom({ plugin: 'shell-monitor', key: 'now' } as const, 0)
22
23type $ = EngineInterface
24
25const glyph: Record<ShellStatus, string> = {
26 running: '●',
27 done: '✓',
28 failed: '✗',
29 killed: '■',
30}
31
32const statusLabel = (shell: Shell) =>
33 shell.status === 'running'
34 ? `${glyph.running} running`
35 : shell.exitCode !== undefined
36 ? `${glyph[shell.status]} exit ${shell.exitCode}`
37 : `${glyph[shell.status]} ${shell.status}`
38
39const clip = (text: string, max: number) =>
40 text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`
41
42const elapsed = (ms: number) => {
43 const seconds = Math.max(0, Math.round(ms / 1000))
44 const minutes = Math.floor(seconds / 60)
45
46 return minutes > 0 ? `${minutes}m ${seconds % 60}s` : `${seconds}s`
47}
48
49const label = (shell: Shell) =>
50 clip((shell.description || shell.command).replace(/\s+/g, ' ').trim(), 40)
51
52const stripAnsi = (text: string) =>
53 text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\x1b\][^\x07]*\x07/g, '')
54
55const capTail = (text: string) =>
56 text.length <= OUTPUT_CAP ? text : text.slice(text.length - OUTPUT_CAP)
57
58/** The output as the window draws it: one entry per terminal line, trailing blanks dropped. */
59const linesOf = (text: string) => {
60 const lines = stripAnsi(text).replace(/\r\n/g, '\n').split('\n')
61 .map(line => line.slice(line.lastIndexOf('\r') + 1))
62 while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
63
64 return lines
65}
66
67/** Equal window heights: the footer row takes 1, each window splits the rest. */
68const layout = (total: number, count: number) => {
69 const height = Math.max(6, Math.floor((total - 1) / count))
70
71 // Border 2 + header 1 + command 1 + the status line under the output 1.
72 return { height, room: Math.max(1, height - 5) }
73}
74
75/** The first line a window shows, its saved place clamped to the output. */
76const topOf = (saved: number | undefined, length: number, room: number) => {
77 const last = Math.max(0, length - room)
78
79 return saved === undefined ? last : Math.min(Math.max(0, saved), last)
80}
81
82const slugOf = (cwd: string) => cwd.replace(/[^A-Za-z0-9]/g, '-')
83
84const exitCodeIn = (text: string | undefined) => {
85 const match = text?.match(/exit code[:\s]+(-?\d+)/i)
86
87 return match ? Number(match[1]) : undefined
88}
89
90function setOutput($: $, n: number, text: string) {
91 return $.state.set({ plugin: 'shell-monitor', key: 'output', id: String(n) }, capTail(text))
92}
93
94function patchShell($: $, n: number, patch: Partial<Shell>) {
95 return update($, shellsAtom, list => list.map(one => (one.n === n ? { ...one, ...patch } : one)))
96}
97
98// Module-local bookkeeping; lost on reload, which the poll tolerates.
99const lastLength = new Map<number, number>()
100let isTicking = false
101// The run times the windows last drew, so the poll redraws only when one changes.
102let lastShown = ''
103// The window the wheel last moved: where the scroll keys go.
104let lastScrolled: number | undefined
105
106const readOutput = async ($: $, n: number) =>
107 (await $.state.get({ plugin: 'shell-monitor', key: 'output', id: String(n) })).value ?? ''
108
109/** Saves window n's first line; at the end it follows new output again. */
110function setTop($: $, n: number, top: number | undefined) {
111 return update($, scrollAtom, map => {
112 const { [String(n)]: _, ...rest } = map
113
114 return top === undefined ? rest : { ...rest, [String(n)]: top }
115 })
116}
117
118async function openPane($: $, n: number, isAsked: boolean) {
119 const opened = await $.ui.open({ id: PANE_ID, title: 'Shells' })
120 if (!opened.isPlaced && !isAsked) {
121 $.ui.toast(`shell #${n} waiting: widen the terminal or run /shell ${n}`)
122 }
123}
124
125async function promote($: $, isAsked: boolean) {
126 for (;;) {
127 const n = (await read($, queueAtom))[0]
128 if (n === undefined) return
129 // Seat inside update() so concurrent promotes never exceed MAX_PANES.
130 let isSeated = false
131 await update($, slotsAtom, list => {
132 isSeated = list.includes(n) || list.length < MAX_PANES
133
134 return isSeated && !list.includes(n) ? [...list, n] : list
135 })
136 if (!isSeated) return
137 await update($, queueAtom, list => list.filter(one => one !== n))
138 await openPane($, n, isAsked)
139 }
140}
141
142/** Shows shell n: a free slot now, else the queue (its head when asked). */
143async function offer($: $, n: number, isAsked: boolean) {
144 const slots = await read($, slotsAtom)
145 if (slots.includes(n)) return
146 await update($, queueAtom, list => {
147 const rest = list.filter(one => one !== n)
148
149 return isAsked ? [n, ...rest] : [...rest, n]
150 })
151 await promote($, isAsked)
152}
153
154/** Takes shell n out of its pane or the queue and fills the freed slot. */
155async function release($: $, n: number, isPaneGone = false) {
156 await update($, slotsAtom, list => list.filter(one => one !== n))
157 await update($, queueAtom, list => list.filter(one => one !== n))
158 await setTop($, n, undefined)
159 await promote($, true)
160 if (!isPaneGone && (await read($, slotsAtom)).length === 0) await $.ui.close({ id: PANE_ID })
161}
162
163async function finish($: $, n: number, patch: Partial<Shell>) {
164 await patchShell($, n, { ...patch, isFinal: true, endedAt: await $.clock.now() })
165 await refreshStatus($)
166}
167
168async function refreshStatus($: $) {
169 const running = (await read($, shellsAtom)).filter(one => one.status === 'running').length
170 $.ui.status(running > 0 ? `shells: ${running} running` : undefined)
171}
172
173async function tick($: $) {
174 if (isTicking) return
175 isTicking = true
176 try {
177 const live = (await read($, shellsAtom)).filter(one => !one.isFinal)
178 if (live.length === 0) return
179 // Writing the time redraws the pane: do it when a running shell's shown seconds change.
180 const now = await $.clock.now()
181 const shown = live.map(one => elapsed(now - one.startedAt)).join()
182 if (shown !== lastShown) {
183 lastShown = shown
184 await update($, nowAtom, () => now)
185 }
186 const dir = await read($, tasksDirAtom)
187 if (!dir) return
188
189 for (const shell of live) {
190 const text = await $.fs.read(`${dir}/${shell.taskId}.output`).catch(() => undefined)
191 if (text !== undefined && text.length !== lastLength.get(shell.n)) {
192 lastLength.set(shell.n, text.length)
193 await setOutput($, shell.n, text)
194 }
195 }
196 } finally {
197 isTicking = false
198 }
199}
200
201/** Closes matching shown windows; with `isQueueToo`, drops matching queued ones as well. */
202async function closeWhere($: $, pick: (shell: Shell) => boolean, isQueueToo = false) {
203 const shells = await read($, shellsAtom)
204 const slots = await read($, slotsAtom)
205 const queue = await read($, queueAtom)
206 const targets = shells.filter(
207 one => pick(one) && (slots.includes(one.n) || (isQueueToo && queue.includes(one.n))),
208 )
209 for (const shell of targets) await release($, shell.n)
210
211 return targets.length
212}
213
214export const register: Register = on => {
215 on('session.start', async ($, e, next) => {
216 for (const spec of [
217 { name: 'shells-list', description: 'List the background shells Claude started this session' },
218 { name: 'shell', description: 'Show a shell in a pane; with no number, every running one (else the newest)', argumentHint: '[n]' },
219 { name: 'shell-close', description: 'Close shell panes', argumentHint: '<n|done|all>' },
220 { name: 'shell-clear', description: 'Forget finished shells' },
221 ]) {
222 await $.command.register({ ...spec, immediate: true })
223 }
224
225 const base =
226 (await $.env.get('CLAUDE_CODE_TMPDIR')) ??
227 (await $.env.get('TEMP')) ??
228 (await $.env.get('TMP')) ??
229 (await $.env.get('TMPDIR')) ??
230 '/tmp'
231 const dir = `${base.replace(/[\\/]+$/, '')}/claude/${slugOf(e.cwd)}/${await $.session.id()}/tasks`
232 await update($, tasksDirAtom, () => dir)
233
234 $.clock.every(POLL_MS, () => void tick($))
235
236 // Earlier versions captured foreground shells too; drop them.
237 const isKept = (n: number, shells: readonly Shell[]) => shells.some(one => one.n === n)
238 const kept = await update($, shellsAtom, list => list.filter(one => one.isBackground && one.taskId))
239 await update($, slotsAtom, list => list.filter(n => isKept(n, kept)))
240 await update($, queueAtom, list => list.filter(n => isKept(n, kept)))
241
242 // After a reload, show again whatever was seated.
243 const seated = await read($, slotsAtom)
244 if (seated.length > 0) void $.ui.open({ id: PANE_ID, title: 'Shells' })
245
246 return next(e)
247 })
248
249 on('tool.call', async ($, e, next) => {
250 if (e.tool !== 'Bash' && e.tool !== 'PowerShell') {
251 if (e.tool === 'TaskStop') {
252 const ran = await next(e)
253 const id = e.task_id ?? e.shell_id
254 if (!ran.deny && !ran.isError && id) {
255 const shell = (await read($, shellsAtom)).find(one => one.taskId === id && !one.isFinal)
256 if (shell) await finish($, shell.n, { status: 'killed' })
257 }
258
259 return ran
260 }
261
262 return next(e)
263 }
264
265 // Only shells that end up in the background are watched: started with
266 // run_in_background, or moved there later (Ctrl+B, a timeout). Either way
267 // the call returns once the shell is backgrounded, carrying its task id.
268 const ran = await next(e)
269 const taskId = (ran.result as { backgroundTaskId?: string } | undefined)?.backgroundTaskId
270 if (ran.deny !== undefined || !taskId) return ran
271
272 const path = ran.text?.match(/([A-Za-z]:)?[^\s"'`]*[\\/]tasks[\\/][^\s"'`]+\.output/)?.[0]
273 if (path) {
274 const dir = path.replace(/[\\/][^\\/]+$/, '')
275 await update($, tasksDirAtom, () => dir)
276 }
277
278 // update() retries on a version miss, so parallel calls each get their own number.
279 const n = (await update($, nextNAtom, value => value + 1)) - 1
280 const shell: Shell = {
281 n,
282 toolUseId: e.tool_use_id ?? String(n),
283 tool: e.tool,
284 command: e.command,
285 description: e.description,
286 agentId: e.agentId,
287 isBackground: true,
288 status: 'running',
289 startedAt: await $.clock.now(),
290 taskId,
291 isFinal: false,
292 }
293 await update($, shellsAtom, list => [...list, shell])
294 await setOutput($, n, '')
295 await refreshStatus($)
296 await offer($, n, false)
297
298 return ran
299 })
300
301 // Background shells finish later: the engine tells the model with a task notification row.
302 on('session.append', async ($, e, next) => {
303 // Read the row before handing it on: storing it changes nothing we need.
304 const raw = JSON.stringify(e.message.content ?? '')
305 if (!raw.includes('task-notification')) return next(e)
306
307 const body = raw.replace(/\\n/g, '\n').replace(/\\"/g, '"')
308 for (const block of body.split(/<task-notification>/).slice(1)) {
309 const taskId = block.match(/<task-id>([^<]+)<\/task-id>/)?.[1]?.trim()
310 const said = block.match(/<status>([^<]+)<\/status>/)?.[1]?.trim().toLowerCase()
311 if (!taskId || !said) continue
312 const shell = (await read($, shellsAtom)).find(one => one.taskId === taskId && !one.isFinal)
313 if (!shell) continue
314
315 const dir = await read($, tasksDirAtom)
316 const text = dir ? await $.fs.read(`${dir}/${taskId}.output`).catch(() => undefined) : undefined
317 if (text !== undefined) await setOutput($, shell.n, text)
318 const status: ShellStatus = /kill|stop/.test(said) ? 'killed' : /fail|error/.test(said) ? 'failed' : 'done'
319 const exitCode = exitCodeIn(block) ?? (status === 'done' ? 0 : undefined)
320 await finish($, shell.n, { status, exitCode })
321 lastLength.delete(shell.n)
322 }
323
324 return next(e)
325 })
326
327 // The person closed the whole pane (its close mark or Esc): hide everything.
328 on('ui.close', async ($, e, next) => {
329 const closed = await next(e)
330 if (e.origin.kind === 'person' && e.id === PANE_ID) {
331 await update($, slotsAtom, () => [])
332 await update($, queueAtom, () => [])
333 }
334
335 return closed
336 })
337
338 on('command.run', { command: 'shells-list' }, async $ => {
339 const shells = await read($, shellsAtom)
340 if (shells.length === 0) return { text: 'No shells yet.' }
341 const slots = await read($, slotsAtom)
342 const queue = await read($, queueAtom)
343 const now = await $.clock.now()
344 const lines = shells.map(shell => {
345 const where = slots.includes(shell.n) ? 'shown' : queue.includes(shell.n) ? 'queued' : 'hidden'
346 const agent = shell.agentId ? ' [subagent]' : ''
347
348 return `#${shell.n} ${statusLabel(shell)} ${elapsed((shell.endedAt ?? now) - shell.startedAt)} ${where}${agent} ${clip(shell.command.replace(/\s+/g, ' '), 80)}`
349 })
350
351 return { text: lines.join('\n') }
352 })
353
354 on('command.run', { command: 'shell' }, async ($, e) => {
355 const shells = await read($, shellsAtom)
356 const arg = e.args.trim().replace(/^#/, '')
357 // Bare /shell shows every running shell; with none running, the newest.
358 const running = shells.filter(one => one.status === 'running')
359 const picked =
360 arg !== ''
361 ? shells.filter(one => one.n === Number(arg))
362 : running.length > 0
363 ? running
364 : shells.slice(-1)
365 if (picked.length === 0) {
366 return { text: arg === '' ? 'No shells yet.' : `No shell #${arg}. /shells-list lists them.` }
367 }
368
369 // Offered newest first so the oldest ends up at the head of the queue.
370 for (const shell of [...picked].reverse()) await offer($, shell.n, true)
371 const slots = await read($, slotsAtom)
372 const shown = picked.filter(one => slots.includes(one.n)).map(one => `#${one.n}`)
373 const queued = picked.filter(one => !slots.includes(one.n)).map(one => `#${one.n}`)
374
375 return {
376 text: [
377 shown.length > 0 ? `Showing ${shown.join(', ')}.` : '',
378 queued.length > 0 ? `Queued next: ${queued.join(', ')}.` : '',
379 ].filter(Boolean).join(' '),
380 }
381 })
382
383 on('command.run', { command: 'shell-close' }, async ($, e) => {
384 const arg = e.args.trim().replace(/^#/, '') || 'done'
385 const count =
386 arg === 'all'
387 ? await closeWhere($, () => true, true)
388 : arg === 'done'
389 ? await closeWhere($, one => one.status !== 'running')
390 : await closeWhere($, one => one.n === Number(arg), true)
391
392 return { text: `Closed ${count} shell pane${count === 1 ? '' : 's'}.` }
393 })
394
395 on('command.run', { command: 'shell-clear' }, async $ => {
396 const shells = await read($, shellsAtom)
397 const finished = shells.filter(one => one.status !== 'running')
398 for (const shell of finished) {
399 await release($, shell.n)
400 await setOutput($, shell.n, '')
401 }
402 await update($, shellsAtom, list => list.filter(one => one.status === 'running'))
403
404 return { text: `Forgot ${finished.length} finished shell${finished.length === 1 ? '' : 's'}.` }
405 })
406
407 // Each window scrolls on its own: the wheel moves the one under the pointer,
408 // the scroll keys the one the wheel last moved (or the only one shown).
409 on('ui.scroll', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
410 const slots = await read($, slotsAtom)
411 if (slots.length === 0) return {}
412 const { height, room } = layout(e.bodyRows, slots.length)
413 const n = e.pointer
414 ? slots[Math.floor(e.pointer.row / height)]
415 : lastScrolled !== undefined && slots.includes(lastScrolled)
416 ? lastScrolled
417 : slots.length === 1
418 ? slots[0]
419 : undefined
420 if (n === undefined) return {}
421 if (e.pointer) lastScrolled = n
422
423 const length = linesOf(await readOutput($, n)).length
424 const last = Math.max(0, length - room)
425 const top = topOf((await read($, scrollAtom))[String(n)], length, room)
426 const moved = Math.min(Math.max(0, top + e.by), last)
427 await setTop($, n, moved >= last ? undefined : moved)
428
429 return {}
430 })
431
432 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e, next) => {
433 const shells = await read($, shellsAtom)
434 const shown = (await read($, slotsAtom))
435 .map(n => shells.find(one => one.n === n))
436 .filter((one): one is Shell => one !== undefined)
437 if (shown.length === 0) return next(e)
438 const outputs = await Promise.all(shown.map(shell => readOutput($, shell.n)))
439 const queued = (await read($, queueAtom)).length
440 const saved = await read($, scrollAtom)
441 // Read so the poll's once-a-second write redraws the run times.
442 const now = Math.max(await read($, nowAtom), await $.clock.now())
443 const { Box, Text, Button } = $.ui.resolve(e)
444
445 const columns = Math.max(10, e.props.bodyColumns - 2)
446 const total = e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 30
447 const { height, room } = layout(total, shown.length)
448
449 return (
450 <Box flexDirection="column">
451 {shown.map((shell, index) => {
452 const lines = linesOf(outputs[index] ?? '')
453 const top = topOf(saved[String(shell.n)], lines.length, room)
454 const tail = lines.slice(top, top + room)
455 const below = lines.length - top - tail.length
456 const color = shell.status === 'failed' ? 'error' : shell.status === 'done' ? 'success' : undefined
457
458 return (
459 <Box
460 key={`shell-${shell.n}`}
461 flexDirection="column"
462 borderStyle="round"
463 borderColor={color}
464 height={height}
465 overflow="hidden"
466 >
467 <Box flexDirection="row" justifyContent="space-between">
468 <Text bold wrap="truncate-end">
469 #{shell.n}
470 {top > 0 && <Text dimColor>{` ↑${top}`}</Text>}
471 {below > 0 && <Text dimColor>{` ↓${below}`}</Text>}
472 {` ${label(shell)}`}
473 </Text>
474 <Box flexDirection="row" gap={1}>
475 {below > 0 && (
476 <Button
477 key={`end-${shell.n}`}
478 label="end"
479 dimColor
480 onPress={() => setTop($, shell.n, undefined)}
481 />
482 )}
483 <Button
484 key={`close-${shell.n}`}
485 hotkey={String(index + 1)}
486 label="close"
487 dimColor
488 onPress={() => release($, shell.n)}
489 />
490 </Box>
491 </Box>
492 <Text dimColor wrap="truncate-end">
493 {shell.tool === 'PowerShell' ? 'PS> ' : '$ '}
494 {clip(shell.command.replace(/\s+/g, ' '), columns - 4)}
495 </Text>
496 {/* The status line follows the last output line, not the window's bottom edge. */}
497 <Box flexDirection="column" overflow="hidden">
498 {tail.length === 0 && (
499 <Text dimColor>{shell.status === 'running' ? 'waiting for output…' : '(no output)'}</Text>
500 )}
501 {tail.map(line => (
502 <Text wrap="truncate-end">
503 {line === '' ? ' ' : clip(line, columns)}
504 </Text>
505 ))}
506 <Text wrap="truncate-end">
507 <Text color={color}>{statusLabel(shell)}</Text>
508 <Text dimColor>{` · ${elapsed((shell.endedAt ?? now) - shell.startedAt)}`}</Text>
509 </Text>
510 </Box>
511 </Box>
512 )
513 })}
514 <Box flexDirection="row" gap={1}>
515 <Button
516 key="close-done"
517 label="close finished"
518 dimColor
519 onPress={() => closeWhere($, one => one.status !== 'running')}
520 />
521 {queued > 0 && <Text dimColor>+{queued} queued</Text>}
522 </Box>
523 </Box>
524 )
525 })
526}
527types/index.d.ts 33 lines1export type ShellStatus = 'running' | 'done' | 'failed' | 'killed'
2
3export type Shell = {
4 n: number
5 toolUseId: string
6 tool: string
7 command: string
8 description?: string
9 agentId?: string
10 isBackground: boolean
11 status: ShellStatus
12 exitCode?: number
13 startedAt: number
14 endedAt?: number
15 taskId?: string
16 isFinal: boolean
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 'shell-monitor': {
22 shells: Shell[]
23 slots: number[]
24 queue: number[]
25 nextN: number
26 tasksDir: string
27 scroll: Record<string, number>
28 now: number
29 output: StateFamily<string>
30 }
31 }
32}
33