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

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.
run_in_background, moved there with ctrl+b, or backgrounded after a timeout, tailed live from their output file(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.
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).
The pane opens at session start when the terminal is at least 144 columns wide; otherwise run /logs.
| Key / command | Does |
|---|---|
/logs | Open the pane |
/logs-clear | Remove finished entries |
1–9 or click ▾ | Collapse / expand that entry |
a | Collapse / expand all shown entries |
o | Only show ongoing tasks / show finished again |
f | Toggle between all entries and following the newest ongoing one |
c | Clear 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.
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.
hooks/register.tsx 437 lines1import { 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}
437types/index.d.ts 26 lines1export 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