SLOPSHOPPER

shell-monitor

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

newpaneguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · shell-monitor
› fix the failing auth test and add an audit log call ⏺ 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 › /shells-list ⎿ shell-monitor: No shells yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

shell-monitor

A Claude Code mod that shows the live output (stdout + stderr) of the background shells Claude starts, in a Shells pane beside the transcript.

  • Captures Bash/PowerShell calls that end up in the background: started with run_in_background, or moved there later (Ctrl+B, a timeout). Foreground commands are ignored.
  • Up to 3 windows stacked in one pane, equal height. Further shells wait in a queue; closing a window lets the next one in.
  • Windows never close on their own when a command finishes. The line under the output and the border show the status: ● running, ✓ exit 0, ✗ exit N, ■ killed.
  • That status line, right under the last output line, also shows the run time, which ticks each second while the shell runs.
  • Each window scrolls on its own: the mouse wheel moves the window under the pointer, the scroll keys the one last wheeled. Scrolled up, a window stays put (↑N ↓N in its title); [ end ] or scrolling back down follows new output again.
  • Each window has a clickable [ close ] (keys 1–3 while the pane is focused); [ close finished ] sits at the bottom.

Commands

CommandWhat it does
/shells-listList 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-clearForget finished shells

Closing the whole pane (its [X], Esc, or ctrl+x x) hides every window and clears the queue; /shell brings them back.

Install

/plugin install shell-monitor --marketplace isaac-pathlab/claude-monitor

Notes

  • Output is read from the file Claude Code writes for each background task, about every 0.4 s.
  • The shell list lives for the session only; the last 200 KB of each shell's output is kept.
  • Panes the mod opens on its own appear when the terminal is at least 144 columns wide; otherwise a toast points to /shell n.

Development

claude --plugin-dir <path-to-clone>/shell-monitor
claude plugin validate shell-monitor
claude plugin test shell-monitor

License

MIT

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