One live line above the prompt per running subagent or background shell: time, tool count and what it's doing. /radar shows every agent and its messages.

A private reference collection of public Claude Code mods, with a security-oriented analysis.
mods/Nothing here is installed or executed. All code is by its upstream authors under their own licences (MIT, one Apache-2.0); each folder keeps its original LICENSE.
hooks/register.tsx 346 lines1// Agent Radar: what are my subagents and background commands cooking?
2// The band above the prompt has one live line per running subagent or background Bash
3// command: status, time and what it is doing right now; a finished one shows a check for
4// 30 s. A toast says when each finishes. /radar opens a pane with every one of them: a
5// subagent's messages, a command's output tail.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register, Timer } from 'claude-code'
8
9import type { RadarAgent } from '../types'
10import { bandsFile, parseOrder, stackBands } from './bands'
11import { TAILS_SH, baseName, blocks, clean, outputPathOf, parseOpen, shellLabel, tailLines } from './watch'
12
13const PANE = 'agent-radar'
14const SHOW_DONE_MS = 30_000 // a finished agent stays in the band this long
15const BAND_ROWS = 4
16const POLL_MS = 5_000
17const MISSES_TO_FINISH = 2 // polls that find a shell's output file closed before it counts as over
18const UNSEEN_MIN_MS = 15_000 // a shell never seen open is over only after this long and the misses
19const LIMIT = 40
20
21// Held by the host, so the radar survives a hot reload of this file.
22const agents = atom({ plugin: 'agent-radar', key: 'agents' } as const, [] as RadarAgent[])
23const selected = atom({ plugin: 'agent-radar', key: 'selected' } as const, null as string | null)
24const now = atom({ plugin: 'agent-radar', key: 'now' } as const, 0)
25
26let bandOrder: Promise<string[]> | undefined // read once per session: restart to apply
27let isPolling = false // a slow lsof must not let two polls overlap
28let lsofBin: string | null | undefined // undefined: not tried yet; null: none works
29
30export const register: Register = on => {
31 let timers: Timer[] = []
32
33 on('session.start', async ($, e, next) => {
34 const r = await next(e)
35 await $.command.register({ name: 'radar', description: 'Show what each subagent and background command is doing, in the Agent Radar pane' }).catch(() => {}) // a name Claude Code already has is refused: start anyway
36 for (const t of timers) t.cancel()
37 timers = []
38 // A clock for the elapsed times, ticking only while something runs.
39 timers.push(
40 $.clock.every(1000, () => {
41 void (async () => {
42 // A few seconds past the 30 s too, so the redraw that drops a finished line happens.
43 if ((await read($, agents)).some(a => isShown(a, Date.now() - 3_000))) await update($, now, () => Date.now())
44 })().catch(() => {})
45 }),
46 )
47 timers.push($.clock.every(POLL_MS, () => void poll($).catch(() => {})))
48 return r
49 })
50
51 on('agent.spawn', async ($, e, next) => {
52 const r = await next(e)
53 if (r.agentId) {
54 const id = r.agentId
55 const one: RadarAgent = { id, kind: 'agent', description: e.description || e.name || e.subagentType, type: e.subagentType, status: 'running', startedAt: Date.now(), tools: 0, last: 'starting' }
56 await update($, agents, list => [one, ...list.filter(a => a.id !== id)].slice(0, 30))
57 }
58 return r
59 })
60
61 on('tool.call', async ($, e, next) => {
62 if (e.agentId) {
63 const id = e.agentId
64 const line = describe(e as unknown as Record<string, unknown>)
65 await update($, agents, list => list.map(a => (a.id === id ? { ...a, tools: a.tools + 1, last: line } : a)))
66 }
67 return next(e)
68 })
69
70 // A background Bash command: a line until its output file is no longer open.
71 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
72 const r = await next(e)
73 if (r.deny || r.isError) return r
74 const result = r.result as { backgroundTaskId?: string; backgroundOutputPath?: string } | undefined
75 if (!e.run_in_background && !result?.backgroundTaskId) return r // it ran in the foreground (or Ctrl+B / a timeout moved it)
76 const out = outputPathOf(r.text, result)
77 if (!out) return r // no output file, no way to tell when it ends: better no line than a stuck one
78 const command = e.command
79 const { label, detail } = shellLabel(command, e.description)
80 const id = `sh:${baseName(out)}`
81 const one: RadarAgent = { id, kind: 'shell', description: label, type: 'shell', status: 'running', startedAt: Date.now(), tools: 0, last: 'running', command, detail, out, misses: 0 }
82 await update($, agents, list => [one, ...list.filter(a => a.id !== id)].slice(0, LIMIT))
83 return r
84 })
85
86 on('turn.complete', async ($, e, next) => {
87 const r = await next(e)
88 if (e.agentId) {
89 const id = e.agentId
90 const failed = e.reason === 'error' || e.reason === 'aborted'
91 const list = await update($, agents, all =>
92 all.map(a => (a.id === id ? { ...a, status: failed ? 'failed' : 'done', endedAt: Date.now(), last: failed ? `stopped: ${e.reason}` : 'finished' } : a)),
93 )
94 const one = list.find(a => a.id === id)
95 if (one) $.ui.toast(`${failed ? '✗' : '✓'} ${one.description} ${failed ? 'failed' : 'done'} (${elapsed(one)} · ${one.tools} tools)`)
96 }
97 return r
98 })
99
100 on('command.run', { command: 'radar' }, async $ => {
101 await adopt($)
102 await update($, selected, () => null)
103 await $.ui.open({ id: PANE, title: 'Agent Radar', focus: true })
104 const list = await read($, agents)
105 const running = list.filter(a => a.status === 'running').length
106 return { text: `${running} running, ${list.length - running} finished` }
107 })
108
109 // The band: one live line per running subagent or command, and a ✓ for 30 s once one finishes.
110 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
111 const rest = await next(e) // what other mods and Claude Code draw here stays
112 await read($, now) // subscribes the band to the clock
113 const shown = (await read($, agents)).filter(a => isShown(a))
114 if (e.props.hasSurvey || shown.length === 0) return rest
115 const { Box, Text } = $.ui.resolve(e)
116 const mine = (
117 <Box flexDirection="column">
118 {shown.slice(0, BAND_ROWS).map(a => (
119 <Text wrap="truncate-end">
120 <Text color={color(a)} bold>{` ${icon(a)} ${a.description}`}</Text>
121 <Text dimColor>{` ${tag(a)} ${elapsed(a)}${kindOf(a) === 'agent' ? ` · ${a.tools} tools` : ''}`}</Text>
122 {a.status === 'running' && <Text>{` · ${a.last}`}</Text>}
123 {a.status === 'failed' && <Text color="red">{` · ${a.last}`}</Text>}
124 </Text>
125 ))}
126 {shown.length > BAND_ROWS && <Text dimColor>{` +${shown.length - BAND_ROWS} more · /radar`}</Text>}
127 </Box>
128 )
129 return stackBands(rest, 'agent-radar', mine, 'above', await (bandOrder ??= readBandOrder($).catch(() => []))) // above what other mods draw, unless the band order says otherwise
130 })
131
132 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
133 const { Box, Text, Button } = $.ui.resolve(e)
134 await read($, now) // subscribes the pane to the clock
135 const list = await read($, agents)
136 const pick = await read($, selected)
137 const one = list.find(a => a.id === pick)
138
139 if (one && kindOf(one) === 'shell') {
140 const lines = one.lines ?? []
141 return (
142 <Box flexDirection="column">
143 <Text>
144 <Text color={color(one)} bold>{`${icon(one)} ${one.description}`}</Text>
145 <Text dimColor>{` shell · ${elapsed(one)} · ${one.status}`}</Text>
146 </Text>
147 <Text dimColor wrap="truncate-end">{`$ ${clean(one.command ?? '').replace(/\s+/g, ' ').slice(0, 300)}`}</Text>
148 {one.out && <Text dimColor wrap="truncate-end">{`output: ${clean(one.out)}`}</Text>}
149 <Text dimColor>{'─'.repeat(Math.max(10, e.props.bodyColumns - 2))}</Text>
150 {lines.length === 0 && <Text dimColor>No output yet.</Text>}
151 {lines.map(l => (
152 <Text wrap="truncate-end">{l}</Text>
153 ))}
154 <Box flexDirection="row" gap={1}>
155 <Button key="back" label="Back" hotkey="b" variant="primary" onPress={() => update($, selected, () => null)} />
156 <Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
157 </Box>
158 </Box>
159 )
160 }
161
162 if (one) {
163 const found = await $.session.messages({ agentId: one.id })
164 const messages = 'deny' in found ? [] : found.slice(-6)
165 return (
166 <Box flexDirection="column">
167 <Text>
168 <Text color={color(one)} bold>{`${icon(one)} ${one.description}`}</Text>
169 <Text dimColor>{` ${one.type} · ${elapsed(one)} · ${one.tools} tools`}</Text>
170 </Text>
171 <Text dimColor wrap="truncate-end">{`now: ${one.last}`}</Text>
172 <Text dimColor>{'─'.repeat(Math.max(10, e.props.bodyColumns - 2))}</Text>
173 {messages.length === 0 && <Text dimColor>No messages yet.</Text>}
174 {messages.map(m => (
175 <Text wrap="truncate-end">
176 <Text color={m.role === 'assistant' ? 'cyan' : undefined} bold>{m.role === 'assistant' ? 'agent: ' : 'task: '}</Text>
177 <Text>{m.text.replace(/\s+/g, ' ').slice(0, 400) || '(tool calls)'}</Text>
178 </Text>
179 ))}
180 <Box flexDirection="row" gap={1}>
181 <Button key="back" label="Back" hotkey="b" variant="primary" onPress={() => update($, selected, () => null)} />
182 <Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
183 </Box>
184 </Box>
185 )
186 }
187
188 const running = list.filter(a => a.status === 'running').length
189 return (
190 <Box flexDirection="column">
191 <Text bold>{`${running} running · ${list.length - running} finished`}</Text>
192 {list.length === 0 && <Text dimColor>No subagents or background commands yet this session.</Text>}
193 {list.slice(0, 9).map((a, i) => (
194 <Button
195 key={`agent-${a.id}`}
196 plain
197 hotkey={String(i + 1)}
198 dimColor={a.status !== 'running'}
199 label={`${icon(a)} ${a.description} · ${tag(a)} · ${elapsed(a)}${kindOf(a) === 'agent' ? ` · ${a.tools} tools` : ''}${a.status === 'done' ? '' : ` · ${a.last}`}`}
200 onPress={() => update($, selected, () => a.id)}
201 />
202 ))}
203 {list.length > 9 && <Text dimColor>{`… ${list.length - 9} older`}</Text>}
204 <Box flexDirection="row" gap={1}>
205 <Button key="clear" label="Clear finished" hotkey="c" onPress={() => update($, agents, all => all.filter(x => x.status === 'running'))} />
206 <Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
207 </Box>
208 </Box>
209 )
210 })
211}
212
213// The band order file, as the claude-code-mods checklist saves it; [] on any error.
214async function readBandOrder($: EngineInterface): Promise<string[]> {
215 const file = bandsFile(await $.env.get('CLAUDE_CONFIG_DIR'), await $.env.get('HOME'))
216 return file && (await $.fs.exists(file)) ? parseOrder(await $.fs.read(file)) : []
217}
218
219// Forgets the cached band order, for tests.
220export function resetBandOrder() {
221 bandOrder = undefined
222 lsofBin = undefined
223 isPolling = false
224}
225
226// Agents that started before this mod loaded: take them from the engine's own list.
227async function adopt($: EngineInterface) {
228 const known = new Set((await read($, agents)).map(a => a.id))
229 const fresh = (await $.agent.list())
230 .filter(a => !known.has(a.id))
231 .map((a): RadarAgent => ({
232 id: a.id,
233 kind: 'agent',
234 description: a.description,
235 type: a.type,
236 status: a.status === 'running' ? 'running' : a.status === 'completed' ? 'done' : 'failed',
237 startedAt: Date.now(),
238 tools: 0,
239 last: a.status === 'running' ? 'running (started before the radar)' : a.status,
240 }))
241 if (fresh.length > 0) await update($, agents, list => [...list, ...fresh])
242}
243
244// ---------------------------------------------------------------- the poll: background commands
245
246async function poll($: EngineInterface) {
247 if (isPolling) return
248 isPolling = true
249 try {
250 await pollOnce($)
251 } finally {
252 isPolling = false
253 }
254}
255
256async function pollOnce($: EngineInterface) {
257 // 1. Which running shells still hold their output file open (one lsof for all of them).
258 const list = await read($, agents)
259 const shells = list.filter(a => kindOf(a) === 'shell' && a.status === 'running' && a.out)
260 if (shells.length === 0) return
261 const open = await openFiles($, shells.map(a => a.out!))
262
263 // 2. The tails of their output (one process).
264 const tailOf = new Map<string, string>()
265 const t = await $.process.run(['sh', '-c', TAILS_SH, 'sh', ...new Set(shells.map(a => a.out!))], { timeoutMs: 8_000 }).catch(() => null)
266 if (t && t.exitCode === 0) for (const b of blocks(t.stdout)) tailOf.set(b.path, b.body)
267
268 // 3. Apply: lines and last for each, then the shells that closed twice in a row are over.
269 const finished: string[] = []
270 await update($, agents, all =>
271 all.map(a => {
272 if (kindOf(a) !== 'shell' || a.status !== 'running' || !a.out) return a
273 const lines = tailOf.has(a.out) ? tailLines(tailOf.get(a.out)!) : a.lines
274 const last = lines?.at(-1) ?? a.last
275 if (!open) return { ...a, lines, last }
276 if (open.has(baseName(a.out))) return { ...a, lines, last, seen: true, misses: 0 }
277 const misses = (a.misses ?? 0) + 1
278 if (misses >= MISSES_TO_FINISH && (a.seen || Date.now() - a.startedAt >= UNSEEN_MIN_MS)) {
279 finished.push(a.id)
280 return { ...a, lines, last: last === 'running' ? 'finished' : last, misses, status: 'done', endedAt: Date.now() }
281 }
282 return { ...a, lines, last, misses }
283 }),
284 )
285 for (const id of finished) {
286 const one = (await read($, agents)).find(a => a.id === id)
287 if (one) $.ui.toast(`✓ ${one.description} done (${elapsed(one)})`)
288 }
289}
290
291// Which of these files some process holds open: one `lsof -Fn` for all. Undefined when lsof cannot say
292// (not installed, failed, timed out): nothing is guessed done.
293async function openFiles($: EngineInterface, files: string[]): Promise<Set<string> | undefined> {
294 if (lsofBin === null) return undefined
295 for (const bin of lsofBin ? [lsofBin] : ['/usr/sbin/lsof', 'lsof']) {
296 const r = await $.process.run([bin, '-Fn', '--', ...files], { timeoutMs: 8_000 }).catch(() => null)
297 if (!r) continue // would not start (or timed out): try the next one
298 lsofBin = bin
299 return r.exitCode === 0 || r.exitCode === 1 ? parseOpen(r.stdout) : undefined // 1: none of them open
300 }
301 if (lsofBin === undefined) lsofBin = null
302 return undefined
303}
304
305// ---------------------------------------------------------------- display helpers
306
307function kindOf(a: Pick<RadarAgent, 'kind'>) {
308 return a.kind ?? 'agent'
309}
310
311function tag(a: Pick<RadarAgent, 'kind'>) {
312 return kindOf(a) === 'agent' ? 'sub' : 'sh'
313}
314
315// Running, or finished in the last 30 s.
316export function isShown(a: RadarAgent, at = Date.now()) {
317 return a.status === 'running' || (a.endedAt !== undefined && at - a.endedAt < SHOW_DONE_MS)
318}
319
320function icon(a: RadarAgent) {
321 return a.status === 'running' ? '●' : a.status === 'done' ? '✓' : '✗'
322}
323
324function color(a: RadarAgent) {
325 return a.status === 'running' ? 'yellow' : a.status === 'done' ? 'green' : 'red'
326}
327
328export function elapsed(a: Pick<RadarAgent, 'startedAt' | 'endedAt'>, at = Date.now()) {
329 const s = Math.max(0, Math.round(((a.endedAt ?? at) - a.startedAt) / 1000))
330 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
331}
332
333// A short label for one tool call: what a person would say the agent is doing.
334export function describe(e: Record<string, unknown>): string {
335 const tool = String(e.tool)
336 const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
337 const file = (p: string) => p.split('/').slice(-2).join('/')
338 if (tool === 'Bash') return `running ${s('description') || s('command').slice(0, 50)}`
339 if (tool === 'Edit' || tool === 'Write') return `editing ${file(s('file_path'))}`
340 if (tool === 'Read') return `reading ${file(s('file_path'))}`
341 if (tool === 'Grep' || tool === 'Glob') return `searching ${s('pattern').slice(0, 30)}`
342 if (tool.includes('playwright')) return `browser: ${tool.split('__').pop()?.replace(/^browser_/, '')}`
343 if (tool.startsWith('mcp__')) return `using ${tool.split('__').slice(1).join(' ')}`
344 return `using ${tool}`
345}
346hooks/bands.ts 73 lines1// Band order: every band mod ships this same file (canonical copy: shared/bands.ts in the repo).
2// The order file, <configDir>/claude-code-mods/bands.json, lists mod names top to bottom
3// (the last sits right above the prompt). Each band mod wraps its band in a Box keyed
4// `band:<name>` and returns one column keyed `bands`; the next band mod unpacks that
5// column, adds its own band and sorts again, so the last one to draw sorts them all.
6// Edit shared/bands.ts, then copy it over every mod's hooks/bands.ts (a repo test checks).
7// Reading the file needs $, which Claude Code follows only within one file, so each mod
8// reads it in its own register file with bandsFile and parseOrder, once per session.
9import type { RenderElement, RenderNode } from 'claude-code'
10
11const COLUMN = 'bands'
12const PREFIX = 'band:'
13
14// Where the checklist saves the order: CLAUDE_CONFIG_DIR, or ~/.claude. Undefined with neither.
15export function bandsFile(configDir: string | undefined, home: string | undefined): string | undefined {
16 const dir = configDir ? configDir.replace(/\/+$/, '') : home ? `${home}/.claude` : ''
17 return dir ? `${dir}/claude-code-mods/bands.json` : undefined
18}
19
20// The names in an order file; [] when it is no JSON or not { order: string[] }.
21export function parseOrder(text: string): string[] {
22 try {
23 const order = (JSON.parse(text) as { order?: unknown } | null)?.order
24 return Array.isArray(order) && order.every(n => typeof n === 'string') ? order : []
25 } catch {
26 return []
27 }
28}
29
30/**
31 * One column of bands: `mine` wrapped in a Box keyed `band:<name>`, placed
32 * `at` (above or below) what `next(e)` returned, then sorted by `order`.
33 * With an order, items that are no band come first, then bands not in the
34 * order (in their current order), then the listed ones in file order. With
35 * no order nothing moves, so the screen looks as it did before.
36 */
37export function stackBands(rest: RenderElement | null | undefined, name: string, mine: RenderNode, at: 'above' | 'below', order: string[]): RenderElement {
38 const own: RenderElement = { type: 'Box', props: { key: PREFIX + name, flexDirection: 'column' }, children: [mine] }
39 const others = unpack(rest)
40 const items = at === 'above' ? [own, ...others] : [...others, own]
41 return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children: order.length ? sorted(items, order) : items }
42}
43
44// Another band mod's column gives up its items; anything else is one item.
45function unpack(rest: RenderElement | null | undefined): RenderNode[] {
46 if (!rest) return []
47 if (!isColumn(rest)) return [rest]
48 const kids = rest.children ?? []
49 const owner = rest.part?.plugin
50 // The engine stamped the column with its owner: that stamp belongs on the owner's own band.
51 return kids.map(k => (owner && typeof k !== 'string' && bandName(k) === owner && !('part' in k && k.part) ? { ...k, part: rest.part } : k))
52}
53
54function isColumn(el: RenderElement): el is RenderElement & { type: 'Box' } {
55 return el.type === 'Box' && el.props?.key === COLUMN
56}
57
58function bandName(node: RenderNode): string | undefined {
59 if (typeof node === 'string' || node.type !== 'Box') return undefined
60 const key = node.props?.key
61 return typeof key === 'string' && key.startsWith(PREFIX) ? key.slice(PREFIX.length) : undefined
62}
63
64function sorted(items: RenderNode[], order: string[]): RenderNode[] {
65 const rank = (n: RenderNode) => {
66 const name = bandName(n)
67 if (name === undefined) return -2 // not a band: first
68 const i = order.indexOf(name)
69 return i < 0 ? -1 : i // a band not in the order: on top of the listed ones
70 }
71 return items.map((n, i) => ({ n, i, r: rank(n) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.n)
72}
73hooks/watch.ts 91 lines1// Pure helpers for background Bash commands: labels, output paths and output tails.
2// Nothing here touches `$`, so the tests call it directly.
3
4// Where a command starts: after `;`, `&&`, `|`, `(`, then `timeout 900`, `nohup`, `FOO=1` and the like.
5const START = String.raw`(?:^|[;&|(])\s*(?:\w+=\S*\s+)*(?:(?:timeout|nohup|nice|env|time|command)(?:\s+[-\w.=:]+)*\s+)?(?:\S*\/)?`
6const AGENT = new RegExp(`${START}(?:(codex)\\s+exec|(opencode)\\s+run|(cursor-agent|agy|crush|copilot|qwen|cline|omp|hermes))(?=\\s|$)`)
7const MODEL = /(?:^|\s)(?:--model|-m)(?:\s+|=)["']?(\w[\w.:/-]*)/
8const CODEX_MODEL = /\s-c\s+["']?model\s*=\s*\\?["']?([\w.:/-]+)/
9
10const MAX_COMMAND = 4096 // the agent CLI and its --model come early; a huge command must not stall the regexes
11
12// The agent CLI a shell command runs and its `--model`, or undefined for any other command.
13export function agentOf(full: string): { agent: string; model?: string } | undefined {
14 const command = full.slice(0, MAX_COMMAND)
15 const m = AGENT.exec(command)
16 if (!m) return undefined
17 const agent = m[1] ?? m[2] ?? m[3]
18 if (!agent) return undefined
19 const part = command.slice(m.index).split(/\s*(?:&&|\|\||;|\|(?!&))\s*/)[0]!
20 // A quoted prompt may mention --model: look past quoted strings, except as a flag's own value.
21 const bare = part.replace(/(?<!(?:--model|-m)[\s=])(["'])(?:(?!\1).)*\1/g, ' _ ')
22 const model = MODEL.exec(bare)?.[1] ?? CODEX_MODEL.exec(part)?.[1]
23 return { agent, model }
24}
25
26// What the band calls a background shell: the agent and model it runs, else the Bash
27// description, else the first 50 characters of the command. `detail` is the other one, for the pane.
28export function shellLabel(command: string, description?: string): { label: string; detail: string } {
29 const flat = clean(command.slice(0, MAX_COMMAND)).replace(/\s+/g, ' ').trim()
30 const text = clean(description ?? '').trim().slice(0, 200) || flat.slice(0, 50)
31 const a = agentOf(command)
32 return a ? { label: a.model ? `${a.agent} ${a.model}` : a.agent, detail: text } : { label: text, detail: flat.slice(0, 200) }
33}
34
35// Where Claude Code keeps a background shell's output: `.../tasks/<id>.output` in 2.1.x
36// ("Output is being written to: ..."), "written to: ....output" in older text, or the result's own field.
37// Only an absolute path with no control characters or `..` is taken: lsof and tail run on it.
38export function outputPathOf(text: string | undefined, result?: unknown): string | undefined {
39 const ok = (p: unknown): p is string => typeof p === 'string' && /^\/[^\x00-\x20\x7f-\x9f]+\.output$/.test(p) && !p.split('/').includes('..')
40 const r = result && typeof result === 'object' ? (result as { backgroundOutputPath?: unknown }) : undefined
41 if (ok(r?.backgroundOutputPath)) return r.backgroundOutputPath
42 const t = (text ?? '').slice(0, 4096)
43 const found = [/(?:written to|output (?:file )?(?:is|at)):?\s+(\/\S+\.output)/i.exec(t)?.[1], /(\/[^\s"'`]*\/tasks\/[\w-]+\.output)/.exec(t)?.[1]]
44 return found.find(ok)
45}
46
47export function baseName(path: string) {
48 return path.slice(path.lastIndexOf('/') + 1)
49}
50
51// Text from a command is shown on the terminal: drop every escape sequence (CSI, OSC such as the
52// clipboard write, DCS and the like, 7- and 8-bit) and every other control character, keeping tab and line breaks.
53const ESCAPES = /\x1b(?:\[[0-?]*[ -/]*[@-~]|[\]PX^_][\s\S]*?(?:\x07|\x1b\\|$)|[@-Z\\-_])|\x9b[0-?]*[ -/]*[@-~]|[\x90\x98\x9d\x9e\x9f][\s\S]*?(?:\x07|\x9c|$)|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f--]/g
54export function clean(text: string) {
55 return text.replace(ESCAPES, '')
56}
57
58// The last n non-empty lines of an output tail (carriage-return progress included), each cut short.
59export function tailLines(text: string, n = 8): string[] {
60 return clean(text)
61 .split(/[\r\n]+/)
62 .map(l => l.trimEnd())
63 .filter(l => l.trim() !== '')
64 .slice(-n)
65 .map(l => l.slice(0, 200))
66}
67
68// The tail of each file named, each after a \001 line with its path: one process for all of them.
69export const TAILS_SH = `# radar:tails
70for f in "$@"; do printf '\\001%s\\n' "$f"; tail -c 16384 -- "$f" 2>/dev/null | LC_ALL=C tr -d '\\001'; done
71exit 0
72`
73
74// Splits "\001path\n<body>" blocks.
75export function blocks(stdout: string): { path: string; body: string }[] {
76 return stdout
77 .split('\x01')
78 .filter(Boolean)
79 .map(b => {
80 const nl = b.indexOf('\n')
81 return nl < 0 ? { path: b.trim(), body: '' } : { path: b.slice(0, nl).trim(), body: b.slice(nl + 1) }
82 })
83}
84
85// File names `lsof -Fn` reports open, as base names (the task id is unique; /tmp and /private/tmp agree).
86export function parseOpen(stdout: string): Set<string> {
87 const open = new Set<string>()
88 for (const line of stdout.split('\n')) if (line.startsWith('n')) open.add(baseName(line.slice(1).trim()))
89 return open
90}
91types/index.d.ts 26 lines1export type RadarKind = 'agent' | 'shell'
2
3export type RadarAgent = {
4 id: string
5 description: string
6 type: string
7 status: 'running' | 'done' | 'failed'
8 startedAt: number
9 endedAt?: number
10 tools: number
11 last: string
12 kind?: RadarKind // absent: a subagent
13 command?: string // shell: the Bash command
14 detail?: string // shell: the Bash description, else the start of the command
15 out?: string // shell: the file Claude Code writes its output to
16 seen?: boolean // shell: its output file has been seen open
17 misses?: number // shell: consecutive polls that found its output file closed
18 lines?: string[] // shell: the last lines of its output, for the pane
19}
20
21declare module 'claude-code' {
22 interface PluginState {
23 'agent-radar': { agents: RadarAgent[]; selected: string | null; now: number }
24 }
25}
26