SLOPSHOPPER

agent-radar

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.

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-radar
│ ┃ Agent Radar ✕ › fix the failing auth test and add an audit log call │ ┃ 0 running · 0 finished │ ┃ No subagents or background commands yet this ⏺ Read(src/auth.ts) │ ┃ session. ⎿ Read 6 lines │ ┃ [ Clear finished ] [ Close ] ⏺ 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 │ │ › /radar │ ⎿ agent-radar: 0 running, 0 finished │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent Radar
0 running · 0 finished No subagents or background commands yet this session. [ Clear finished ] [ Close ]
README

claude-code-mods-collection

A private reference collection of public Claude Code mods, with a security-oriented analysis.

  • ANALYSIS.md: what each mod can reach, which to install, which to avoid
  • SOURCES.md: upstream repo and pinned commit for every folder in 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.

Source 4 files
hooks/register.tsx 346 lines
1// 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}
346
hooks/bands.ts 73 lines
1// 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}
73
hooks/watch.ts 91 lines
1// 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}
91
types/index.d.ts 26 lines
1export 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