SLOPSHOPPER

script-logs

A live pane for the background shell commands and monitors Claude runs

newpaneguardcommandtoaststatus
v0.4.0MITupdated 2026-10-07naughty00shortie/script-logs
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · script-logs
│ ┃ Script logs ✕ › fix the failing auth test and add an audit log call │ ┃ [ Follow latest ] [ Only ongoing ] [ Collaps │ ┃ Nothing in the background yet. Background ⏺ Read(src/auth.ts) │ ┃ commands and monitors show up 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 │ │ › /logs │ ⎿ script-logs: Script logs pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Script logs
[ Follow latest ] [ Only ongoing ] [ Collapse all ] [ Clear Nothing in the background yet. Background commands and monitors show up here.
README

script-logs

A Claude Code mod that adds a live Script logs pane for the work Claude runs in the background, which the main window doesn't show as it happens.

  • Background shell commands: started with run_in_background, moved there with ctrl+b, or backgrounded after a timeout, tailed live from their output file
  • Monitor watches, with each event as it arrives
  • Subagents' background work, tagged (agent)

Ordinary foreground commands are left out: their output already shows in the main window. The mod writes nothing to the status line.

Status icons: ◆ running in the background · ✓ done · ✗ failed · ■ stopped

Each entry's header shows how long it has run (· 1m 12s), then how long it took once it ends (· took 24s), and the exit code when it is not 0 (· exit 1). A running entry has a [stop] button that stops the task through Claude Code's own TaskStop.

Install

At the prompt of a Claude Code terminal session:

/plugin install script-logs --marketplace naughty00shortie/script-logs

Answer y to add the marketplace, then pick a scope (user scope loads it in every session).

Use

The pane opens at session start when the terminal is at least 144 columns wide; otherwise run /logs.

Key / commandDoes
/logsOpen the pane
/logs-clearRemove finished entries
1–9 or click ▾Collapse / expand that entry
aCollapse / expand all shown entries
oOnly show ongoing tasks / show finished again
fToggle between all entries and following the newest ongoing one
cClear finished entries
click [stop]Stop that background task

Keys work while the pane has focus (click it, or ctrl+x tab).

The mod only watches: if its own bookkeeping fails, Claude's commands still run untouched.

Develop

claude --plugin-dir /path/to/script-logs   # run it from a checkout
claude plugin validate .                    # check the manifest and hooks
claude plugin test .                        # run tests/*.test.tsx

Function-hook mods are an early-access Claude Code feature; the API may change between releases.

Source 2 files
hooks/register.tsx 437 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
3
4import type { LogEntry, LogStatus, LogView } from '../types'
5
6const PANE = 'script-logs'
7const TITLE = 'Script logs'
8const MAX_ENTRIES = 100
9const MAX_LINES = 400
10const POLL_MS = 1000
11
12const entries = atom({ plugin: 'script-logs', key: 'entries' } as const, [])
13const view = atom({ plugin: 'script-logs', key: 'view' } as const, 'overview')
14const onlyLive = atom({ plugin: 'script-logs', key: 'onlyLive' } as const, false)
15// The time the pane last ticked, so running durations count up.
16const now = atom({ plugin: 'script-logs', key: 'now' } as const, 0)
17
18type Outcome = {
19  stdout?: string
20  stderr?: string
21  interrupted?: boolean
22  backgroundTaskId?: string
23  taskId?: string
24}
25
26const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07/g
27
28export const toLines = (text: string | undefined): string[] =>
29  (text ?? '')
30    .replace(ANSI, '')
31    .replace(/\r\n?/g, '\n')
32    .split('\n')
33    .map(line => line.replace(/\t/g, '  '))
34    .filter((line, i, all) => line !== '' || i < all.length - 1)
35
36const clip = (lines: string[]) => lines.slice(-MAX_LINES)
37
38const EXITED = /^\[exited with code (-?\d+)\]$/
39
40// A finished task's output file ends with "[exited with code N]": take the code out of the lines.
41export const splitExit = (lines: string[]): { lines: string[]; exitCode?: number } => {
42  const end = lines.findLastIndex(line => line.trim() !== '')
43  const code = end >= 0 ? lines[end]!.trim().match(EXITED)?.[1] : undefined
44  if (code === undefined) return { lines }
45  const kept = lines.slice(0, end)
46  while (kept.length > 0 && kept[kept.length - 1]!.trim() === '') kept.pop()
47  return { lines: kept, exitCode: Number(code) }
48}
49
50export const formatDuration = (ms: number) => {
51  const total = Math.max(0, Math.round(ms / 1000))
52  const h = Math.floor(total / 3600)
53  const m = Math.floor((total % 3600) / 60)
54  const sec = total % 60
55  return h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${sec}s` : `${sec}s`
56}
57
58// The model is told where a background task writes its output; take the path from that text.
59export const outputPathIn = (text: string | undefined) =>
60  text?.match(/([A-Za-z]:[\\/][^\s"'`<>]+?\.output|\/[^\s"'`<>]+?\.output)\b/)?.[1]
61
62const ICON: Record<LogStatus, string> = {
63  running: '▶',
64  background: '◆',
65  done: '✓',
66  failed: '✗',
67  stopped: '■',
68}
69
70const COLOR: Record<LogStatus, string> = {
71  running: 'yellow',
72  background: 'cyan',
73  done: 'green',
74  failed: 'red',
75  stopped: 'gray',
76}
77
78// Byte size of each background task's output file at the last read.
79const seen = new Map<string, number>()
80
81function patch($: EngineInterface, id: string, fn: (one: LogEntry) => LogEntry) {
82  return update($, entries, list => list.map(one => (one.id === id ? fn(one) : one)))
83}
84
85// Foreground commands already show in the transcript; only work that went to the
86// background (run_in_background, ctrl+b, a timeout, or a Monitor) gets an entry.
87async function track(
88  $: EngineInterface,
89  id: string,
90  tool: string,
91  title: string,
92  command: string,
93  agentId: string | undefined,
94  run: () => Promise<ToolCallResult>,
95) {
96  const startedAt = await $.clock.now()
97  const ran = await run()
98  if (ran.deny !== undefined) return ran
99
100  const out = (ran.result ?? {}) as Outcome
101  const taskId = out.backgroundTaskId ?? (tool === 'Monitor' ? out.taskId : undefined)
102  if (taskId === undefined) return ran
103
104  const entry: LogEntry = {
105    id,
106    tool,
107    title,
108    command,
109    status: 'background',
110    lines: clip([...toLines(out.stdout), ...toLines(out.stderr)]),
111    startedAt,
112    agentId,
113    taskId,
114    outputPath: outputPathIn(ran.text),
115  }
116  await update($, entries, list => [...list, entry].slice(-MAX_ENTRIES))
117
118  return ran
119}
120
121// Read a background task's output file when it has grown since the last read.
122async function readOutput($: EngineInterface, one: LogEntry) {
123  if (one.outputPath === undefined) return
124  try {
125    const { size } = await $.fs.stat(one.outputPath)
126    if (seen.get(one.id) === size) return
127    seen.set(one.id, size)
128    const text = await $.fs.read(one.outputPath)
129    const { lines, exitCode } = splitExit(toLines(text))
130    await patch($, one.id, it => ({ ...it, lines: clip(lines), exitCode: exitCode ?? it.exitCode }))
131  } catch {
132    // Gone or too large to read: keep what we have.
133  }
134}
135
136// Tail the output files of background commands and monitors, and tick running durations.
137async function pollOutputs($: EngineInterface) {
138  const list = await read($, entries)
139  for (const one of list) {
140    if (one.status === 'background') await readOutput($, one)
141  }
142  if (list.some(isLive)) {
143    const at = await $.clock.now()
144    await update($, now, () => at)
145  }
146}
147
148function isLive(one: LogEntry) {
149  return one.status === 'running' || one.status === 'background'
150}
151
152async function markEnded($: EngineInterface, taskId: string, status: LogStatus) {
153  const at = await $.clock.now()
154  await update($, entries, list =>
155    list.map(one => (one.taskId === taskId && isLive(one) ? { ...one, status, endedAt: at } : one)),
156  )
157}
158
159// Stops a background task the way Claude would, through TaskStop, at the person's press.
160async function stopTask($: EngineInterface, one: LogEntry) {
161  if (one.taskId === undefined || !isLive(one)) return
162  const ran = await $.tool.call({
163    tool: 'TaskStop',
164    task_id: one.taskId,
165    consent: `The user pressed "Stop" on "${one.title}" in the Script logs pane`,
166  })
167  if (ran.deny !== undefined || ran.isError) {
168    $.ui.toast(`Could not stop "${one.title}": ${ran.deny ?? ran.text ?? 'TaskStop failed'}`)
169    return
170  }
171  await markEnded($, one.taskId, 'stopped')
172}
173
174export type Notification = {
175  taskId: string
176  status?: string
177  event?: string
178  outputPath?: string
179}
180
181const tag = (text: string, name: string) =>
182  text.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))?.[1]?.trim()
183
184export const notificationsIn = (text: string): Notification[] =>
185  [...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)].flatMap(([, body = '']) => {
186    const taskId = tag(body, 'task-id')
187    return taskId === undefined
188      ? []
189      : [{ taskId, status: tag(body, 'status'), event: tag(body, 'event'), outputPath: tag(body, 'output-file') }]
190  })
191
192const ENDED: Record<string, LogStatus> = { completed: 'done', failed: 'failed', killed: 'stopped', stopped: 'stopped' }
193
194async function applyNotification($: EngineInterface, note: Notification) {
195  const at = await $.clock.now()
196  await update($, entries, list =>
197    list.map(one => {
198      if (one.taskId !== note.taskId) return one
199      // Until we know its output file, a monitor's events reach us only here.
200      const lines =
201        note.event !== undefined && one.outputPath === undefined
202          ? clip([...one.lines, ...toLines(note.event)])
203          : one.lines
204      const status = (note.status && ENDED[note.status]) || one.status
205      const endedAt = isLive(one) && !isLive({ ...one, status }) ? at : one.endedAt
206      return { ...one, lines, status, endedAt, outputPath: one.outputPath ?? note.outputPath }
207    }),
208  )
209  // Pick up the last lines the task wrote before it ended.
210  if (note.status !== undefined) {
211    const one = (await read($, entries)).find(it => it.taskId === note.taskId)
212    if (one !== undefined) await readOutput($, one)
213  }
214}
215
216function toggle($: EngineInterface, id: string) {
217  return patch($, id, one => ({ ...one, isCollapsed: !one.isCollapsed }))
218}
219
220function setCollapsed($: EngineInterface, ids: string[], isCollapsed: boolean) {
221  return update($, entries, list => list.map(one => (ids.includes(one.id) ? { ...one, isCollapsed } : one)))
222}
223
224// This mod only watches: if its bookkeeping fails, the call goes on untouched.
225const passThrough = <E, R>(_$: unknown, e: E, next: (e: E) => R) => next(e)
226
227export const register: Register = on => {
228  on('session.start', async ($, e, next) => {
229    await $.command.register({ name: 'logs', description: 'Open the script logs pane' })
230    await $.command.register({ name: 'logs-clear', description: 'Clear the script logs pane' })
231    void $.ui.open({ id: PANE, title: TITLE })
232    // Earlier versions wrote a running count to the status line; clear any left over.
233    $.ui.status(undefined)
234
235    $.clock.every(POLL_MS, () => void pollOutputs($))
236
237    return next(e)
238  })
239
240  on('command.run', { command: 'logs' }, async $ => {
241    await $.ui.open({ id: PANE, title: TITLE })
242
243    return { text: 'Script logs pane opened.' }
244  })
245
246  on('command.run', { command: 'logs-clear' }, async $ => {
247    await update($, entries, list => list.filter(isLive))
248
249    return { text: 'Cleared finished scripts from the log pane.' }
250  })
251
252  on('tool.call', { tool: 'Bash' }, ($, e, next) =>
253    track($, e.tool_use_id ?? crypto.randomUUID(), 'Bash', e.description ?? e.command, e.command, e.agentId, () => next(e)),
254  ).catch(passThrough)
255
256  on('tool.call', { tool: 'PowerShell' }, ($, e, next) =>
257    track($, e.tool_use_id ?? crypto.randomUUID(), 'PowerShell', e.description ?? e.command, e.command, e.agentId, () => next(e)),
258  ).catch(passThrough)
259
260  on('tool.call', { tool: 'Monitor' }, ($, e, next) =>
261    track(
262      $,
263      e.tool_use_id ?? crypto.randomUUID(),
264      'Monitor',
265      e.description,
266      e.command ?? e.ws?.url ?? '',
267      e.agentId,
268      () => next(e),
269    ),
270  ).catch(passThrough)
271
272  on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
273    const ran = await next(e)
274    const id = e.task_id ?? e.shell_id
275    if (id !== undefined && ran.deny === undefined && !ran.isError) {
276      await markEnded($, id, 'stopped')
277    }
278
279    return ran
280  }).catch(passThrough)
281
282  // A background task's notification: its final status, and for monitors without a file, its events.
283  on('session.append', async ($, e, next) => {
284    const kept = await next(e)
285
286    // Notifications arrive as their own rows or folded into reminders; match on the text either way.
287    const text = e.message.content
288      .map(block => (typeof block.text === 'string' ? block.text : ''))
289      .join('\n')
290    if (!text.includes('<task-notification>')) return kept
291
292    for (const note of notificationsIn(text)) {
293      await applyNotification($, note)
294    }
295
296    return kept
297  }).catch(passThrough)
298
299  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
300    const { Box, Text, Button } = $.ui.resolve(e)
301    const list = await read($, entries)
302    const mode: LogView = await read($, view)
303    const rows = Math.max(4, (e.viewport?.rows ?? 30) - 4)
304    const at = (await read($, now)) || (await $.clock.now())
305
306    // "· 1m 12s" while running, "· took 24s" once ended, then the exit code when it is not 0.
307    const meta = (one: LogEntry) => {
308      const parts = [
309        isLive(one)
310          ? formatDuration(at - one.startedAt)
311          : one.endedAt !== undefined
312            ? `took ${formatDuration(one.endedAt - one.startedAt)}`
313            : undefined,
314        one.exitCode !== undefined && one.exitCode !== 0 ? `exit ${one.exitCode}` : undefined,
315        one.isCollapsed && one.lines.length > 0 ? `${one.lines.length} lines` : undefined,
316      ].filter(Boolean)
317      return parts.length > 0 ? ` · ${parts.join(' · ')}` : ''
318    }
319
320    // hotkey: 1-9 for the entries drawn top to bottom; the toggle collapses or expands one.
321    const header = (one: LogEntry, hotkey?: string) => (
322      <Box>
323        <Button
324          key={`toggle-${one.id}`}
325          plain
326          hotkey={hotkey}
327          label={one.isCollapsed ? '▸' : '▾'}
328          onPress={() => toggle($, one.id)}
329        />
330        <Text color={COLOR[one.status]}> {ICON[one.status]} </Text>
331        <Text bold wrap="truncate-end">
332          {one.tool}
333          {one.agentId ? ' (agent)' : ''}: {one.title}
334        </Text>
335        <Text dimColor>{meta(one)} </Text>
336        {isLive(one) && one.taskId !== undefined && (
337          <Button key={`stop-${one.id}`} plain dimColor label="[stop]" onPress={() => stopTask($, one)} />
338        )}
339      </Box>
340    )
341
342    const isFiltered = await read($, onlyLive)
343    const visible = isFiltered ? list.filter(isLive) : list
344    const hidden = list.length - visible.length
345
346    const allCollapsed = visible.length > 0 && visible.every(one => one.isCollapsed)
347    const controls = (
348      <Box>
349        <Button
350          key="mode"
351          hotkey="f"
352          label={mode === 'follow' ? 'Show all' : 'Follow latest'}
353          onPress={() => update($, view, v => (v === 'follow' ? 'overview' : 'follow'))}
354        />
355        <Text> </Text>
356        <Button
357          key="filter"
358          hotkey="o"
359          label={isFiltered ? `Show finished (${hidden})` : 'Only ongoing'}
360          onPress={() => update($, onlyLive, isOn => !isOn)}
361        />
362        <Text> </Text>
363        <Button
364          key="all"
365          hotkey="a"
366          label={allCollapsed ? 'Expand all' : 'Collapse all'}
367          onPress={() => setCollapsed($, visible.map(one => one.id), !allCollapsed)}
368        />
369        <Text> </Text>
370        <Button
371          key="clear"
372          hotkey="c"
373          label="Clear finished"
374          onPress={() => update($, entries, all => all.filter(isLive))}
375        />
376      </Box>
377    )
378
379    if (visible.length === 0) {
380      return (
381        <Box flexDirection="column">
382          {controls}
383          <Text dimColor>
384            {list.length === 0
385              ? 'Nothing in the background yet. Background commands and monitors show up here.'
386              : `Nothing running. ${hidden} finished hidden: press o to show them.`}
387          </Text>
388        </Box>
389      )
390    }
391
392    if (mode === 'follow') {
393      const one = [...list].reverse().find(isLive) ?? list[list.length - 1]!
394      return (
395        <Box flexDirection="column">
396          {controls}
397          {header({ ...one, isCollapsed: false })}
398          <Text dimColor wrap="truncate-end">$ {one.command}</Text>
399          {one.lines.slice(-(rows - 3)).map(line => (
400            <Text>{line}</Text>
401          ))}
402          {isLive(one) && one.lines.length === 0 && <Text dimColor>waiting for output…</Text>}
403        </Box>
404      )
405    }
406
407    // Overview: fill the room newest first; live entries get more lines than finished ones.
408    let room = rows - 1
409    const shown: { one: LogEntry; want: number }[] = []
410    for (const one of [...visible].reverse()) {
411      if (room < 1) break
412      const cap = one.isCollapsed ? 0 : isLive(one) ? 12 : 4
413      const want = Math.min(one.lines.length, cap, Math.max(0, room - 1))
414      shown.unshift({ one, want })
415      room -= 1 + want
416    }
417
418    return (
419      <Box flexDirection="column">
420        {controls}
421        {shown.map(({ one, want }, i) => (
422          <Box flexDirection="column">
423            {header(one, i < 9 ? String(i + 1) : undefined)}
424            {want > 0 &&
425              one.lines.slice(-want).map(line => (
426                <Text dimColor={!isLive(one)} wrap="truncate-end">
427                  {'    '}
428                  {line}
429                </Text>
430              ))}
431          </Box>
432        ))}
433      </Box>
434    )
435  })
436}
437
types/index.d.ts 26 lines
1export type LogStatus = 'running' | 'background' | 'done' | 'failed' | 'stopped'
2
3export type LogEntry = {
4  id: string
5  tool: string
6  title: string
7  command: string
8  status: LogStatus
9  lines: string[]
10  startedAt: number
11  agentId?: string
12  taskId?: string
13  outputPath?: string
14  isCollapsed?: boolean
15  endedAt?: number
16  exitCode?: number
17}
18
19export type LogView = 'overview' | 'follow'
20
21declare module 'claude-code' {
22  interface PluginState {
23    'script-logs': { entries: LogEntry[]; view: LogView; onlyLive: boolean; now: number }
24  }
25}
26