SLOPSHOPPER

agent-radar

Live side pane (/radar) showing what Claude and its subagents are doing right now: running tools, agent roster, recent activity and files touched.

newpaneguardcommandprompttimer
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 │ ┃ ○ idle · turn 0s · 9 calls · 5h 31% │ ┃ ctx █████░░░░░ 49% ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Now ⏺ Update(src/auth.ts) │ ┃ idle ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Agents (0 spawned) ⎿ 3 pass, 1 fail │ ┃ none spawned │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Building up in context (estimate) │ ┃ ████░░░░ Replies 18 ✻ Worked for 42s · done 4:20 PM │ ┃ ██░░░░░░ 9 │ ┃ █░░░░░░░ You 6 › /radar │ ┃ biggest: ⎿ agent-radar: Radar opened. Esc closes it. │ ┃ 9 undefined │ ┃ │ ┃ Activity │ ┃ ✓ Bash cat .env 0s │ ┃ → DATABASE_URL=postgres://app:hunter2@d… │ ┃ ✓ Bash rm -rf build && git push --force ori… │ ┃ ✓ Bash git status --porcelain 0s │ ┃ → M src/auth.ts │ ┃ ✗ Bash bun test 0s │ ┃ → 1 fail │ ┃ ✓ Write src/cache.ts 0s ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent radar
○ idle · turn 0s · 9 calls · 5h 31% ctx █████░░░░░ 49% Now idle Agents (0 spawned) none spawned Building up in context (estimate) ████░░░░ Replies 18 ██░░░░░░ 9 █░░░░░░░ You 6 biggest: 9 undefined Activity ✓ Bash cat .env 0s → DATABASE_URL=postgres://app:hunter2@db.internal:5432/… ✓ Bash rm -rf build && git push --force origin main 0s ✓ Bash git status --porcelain 0s → M src/auth.ts ✗ Bash bun test 0s → 1 fail ✓ Write src/cache.ts 0s → File created successfully at: /work/app/src/cache.ts ✓ Write src/audit.ts 0s → File created successfully at: /work/app/src/audit.ts ✓ Edit src/auth.ts 0s → The file /work/app/src/auth.ts has been updated. ✓ Grep refresh\( 0s → src/api.ts:12 ✓ Read src/auth.ts 0s → import { verify } from './jwt' Files touched src/auth.ts src/audit.ts src/cache.ts
README

agent-radar

A live side window showing what your agents are doing, in parallel.

Opens a pane beside the conversation that updates in real time. It shows what each loop is doing right now (thinking, writing its next message, starting a tool call) and the tool running (with a ticking timer) and what the model last said in its visible messages, every subagent with its recent calls and latest message, an activity log with a one-line result under each call, the files edited this session, and in the header a working/idle state, turn time, a context bar with the window split by category (system prompt, tools, skills, messages) and a ranked list of what is filling the conversation (which tools' results, your prompts, the model's replies, and the biggest single results; token figures there are estimates), and 5h usage. Ideal when you fan out several subagents and want to see who is doing what.

Install

/plugin marketplace add cloudcruncher/claude-mod-garage
/plugin install agent-radar@mod-garage

Run /reload-plugins (or restart Claude Code) afterwards. To try it from a local clone without installing: claude --plugin-dir ./dev-mods/agent-radar

Use it

  • /radar opens the pane (Esc closes it).
  • Set option autoOpen to open it at every session start.

What you'll see

Illustrative output.

/radar pane

● working (2 tools running)  ·  turn 1m12s  ·  14 calls  ·  5h 8%
ctx ███░░░░░░░ 34%  (+6% this turn)
  System prompt 3.9k  ·  System tools 9.2k  ·  Skills 7.5k  ·  Messages 29k

Now
● main writing: “Running the suite before I touch the parser…”  3s
▶ Bash npm test -- --watch=false  8s
▶ [a3f1] Grep validateToken  2s
  “Running the suite before I touch the parser, then I'll fix whatever fails.”

Agents  (3 spawned)
▶ Explore · map auth flow
    ✓ Read src/auth.ts  0s
    ✓ Grep validateToken  1s
    “Token checks live in src/auth.ts and middleware/session.ts.”
· Plan · outline migration

Building up in context  (estimate)
  ████████ Read ×6  11k
  ███░░░░░ Bash ×3  4.1k
  █░░░░░░░ Replies  1.2k
  biggest:
    7.0k  Read src/auth.ts
    2.5k  Bash npm test

Activity
✓ Bash npm test  14s
    → 12 passed
✗ Bash npx tsc --noEmit  6s
    → src/auth.ts(41,5): error TS2322: Type 'string' is not assignable…
✓ Read src/auth.ts  0s

Files touched
  auth/token.test.ts ×2
  src/auth.ts

Options

OptionDefaultWhat it does
autoOpenfalseOpen the pane automatically at session start

Set options when installing, or later in /plugin → the mod → configure.

Notes

  • Panes dock beside the transcript from about 110 terminal columns; narrower terminals show it above the prompt.
  • [a3f1] is the first 4 characters of a subagent id; calls with no tag are the main session's.
  • Shows the model's visible messages only. Its private reasoning is never read or displayed.
  • Redraws once a second while the pane is open.
Source 2 files
hooks/register.tsx 178 lines
1import type { Register } from 'claude-code'
2import { clip, filesTouched, finish, fmtDur, label, bar, contextHogs, fmtTok, lastCalls, nextLive, preview, running, sayText, start, type Call, type Live } from './lib'
3
4const PANE = 'radar'
5// Engine bookkeeping calls that are noise in a view of what the agents are doing.
6const INTERNAL = new Set(['SubagentHandback'])
7
8export const register: Register = (on, options) => {
9  let calls: Call[] = []
10  // The latest thing each loop wrote to the conversation, keyed by agent id ('main' for the main loop).
11  let says: Record<string, string> = {}
12  // What each loop is doing between tool calls (thinking, writing, starting a call), from the response stream.
13  let liveState: Record<string, Live> = {}
14  let turnStart = 0
15  let ctxAtTurnStart: number | undefined
16  // What is filling the window; recomputed at most every 3s because it walks the whole transcript.
17  let ctxInfo: { at: number; parts: { name: string; tokens: number }[]; hogs: ReturnType<typeof contextHogs> } | undefined
18  let isOpen = false
19  const autoOpen = options?.autoOpen === true
20
21  on('session.start', async ($, e, next) => {
22    await $.command.register({ name: 'radar', description: 'Open the live pane showing what Claude and its subagents are doing' })
23    // Redraw once a second so elapsed times tick; only while the pane is open.
24    $.clock.every(1000, () => { if (isOpen) $.ui.invalidate('ui.render') })
25    if (autoOpen) { isOpen = true; void $.ui.open({ id: PANE, title: 'Agent radar' }) }
26    return next(e)
27  })
28
29  on('command.run', { command: 'radar' }, async $ => {
30    isOpen = true
31    await $.ui.open({ id: PANE, title: 'Agent radar' })
32    return { text: 'Radar opened. Esc closes it.' }
33  })
34
35  on('prompt.submit', async ($, e, next) => {
36    turnStart = await $.clock.now()
37    ctxAtTurnStart = (await $.session.usage().catch(() => undefined))?.context?.percent
38    return next(e)
39  }).catch(($, e, next) => next(e))
40
41  on('tool.call', async ($, e, next) => {
42    if (INTERNAL.has(e.tool)) return next(e)
43    const now = await $.clock.now()
44    const args = e as unknown as Record<string, unknown>
45    calls = start(calls, { id: e.tool_use_id, agentId: e.agentId, tool: e.tool, label: label(e.tool, args), start: now })
46    $.ui.invalidate('ui.render')
47    const out = await next(e)
48    const failed = ('isError' in out && out.isError === true) || ('deny' in out && !!out.deny)
49    const text = 'deny' in out && out.deny ? out.deny : 'text' in out ? out.text : ''
50    // An Agent call's own result is launch bookkeeping; the agent's rows tell the real story.
51    calls = finish(calls, e.tool_use_id, await $.clock.now(), failed, e.tool === 'Agent' ? '' : preview(text, 90, failed))
52    $.ui.invalidate('ui.render')
53    return out
54  }).catch(($, e, next) => next(e))
55
56  // What the model writes to you (its visible messages). Thinking blocks are never read.
57  on('session.append', { door: 'response' }, ($, e, next) => {
58    const text = sayText(e.message.content)
59    if (text) {
60      says = { ...says, [e.agentId ?? 'main']: clip(text, 400) }
61      $.ui.invalidate('ui.render')
62    }
63    return next(e)
64  }).catch(($, e, next) => next(e))
65
66  // The model's response as it streams. Text is shown live, thinking only as "thinking"; every chunk passes on untouched.
67  on('turn.step', async function* ($, e, next) {
68    const key = e.agentId ?? 'main'
69    liveState = { ...liveState, [key]: { phase: 'waiting', since: await $.clock.now(), text: '' } }
70    $.ui.invalidate('ui.render')
71    const stream = next(e)
72    try {
73      for (;;) {
74        const it = await stream.next()
75        if (it.done) return it.value
76        const c = it.value as { kind: string; text?: string; name?: string }
77        if (c.kind === 'text' || c.kind === 'thinking' || c.kind === 'tool') {
78          const before = liveState[key]
79          const after = nextLive(before, c, await $.clock.now())
80          liveState = { ...liveState, [key]: after }
81          if (!before || before.phase !== after.phase) $.ui.invalidate('ui.render')
82        }
83        yield it.value
84      }
85    } finally {
86      const { [key]: _gone, ...rest } = liveState
87      liveState = rest
88      $.ui.invalidate('ui.render')
89    }
90  })
91
92  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
93    isOpen = true
94    const { Box, Text } = $.ui.resolve(e)
95    const now = await $.clock.now()
96    const agents = await $.agent.list()
97    const usage = await $.session.usage().catch(() => undefined)
98    if (!ctxInfo || now - ctxInfo.at > 3000) {
99      const bd = (await $.session.usage({ breakdown: 'summary' }).catch(() => undefined))?.context?.breakdown
100      const msgs = await $.session.messages().catch(() => [])
101      ctxInfo = {
102        at: now,
103        parts: (bd?.categories ?? []).filter(c => c.kind === 'used' && c.tokens > 0).map(c => ({ name: c.name, tokens: c.tokens })),
104        hogs: contextHogs(msgs),
105      }
106    }
107    const hogs = ctxInfo.hogs
108    const hogTotal = hogs.groups.reduce((a, g) => a + g.tokens, 0) || 1
109    const live = running(calls)
110    const busy = live.length + Object.keys(liveState).length
111    const spawned = calls.filter(c => c.tool === 'Agent').length
112    const rows = Math.max(8, (e.viewport?.rows ?? 30) - 4)
113    const recent = calls.slice(-Math.max(4, Math.floor(rows / 3))).reverse()
114    const files = filesTouched(calls).slice(0, 5)
115    const ctx = usage?.context?.percent
116    const five = usage?.rateLimits?.find(r => /five|5/.test(String(r.kind)))?.percentUsed
117    const mark = (c: Call) => (c.end === undefined ? '▶' : c.isError ? '✗' : '✓')
118    const age = (c: Call) => fmtDur((c.end ?? now) - c.start)
119    const mainSays = says.main
120
121    return (
122      <Box flexDirection="column">
123        <Text bold>{busy ? `● working (${live.length} tool${live.length === 1 ? '' : 's'} running)` : '○ idle'}{turnStart ? `  ·  turn ${fmtDur(now - turnStart)}` : ''}  ·  {calls.length} calls{five !== undefined ? `  ·  5h ${Math.round(five)}%` : ''}</Text>
124        {ctx !== undefined && <Text color={ctx >= 85 ? 'red' : ctx >= 70 ? 'yellow' : undefined}>ctx {bar(ctx)} {Math.round(ctx)}%{ctxAtTurnStart !== undefined && ctx - ctxAtTurnStart >= 1 ? `  (+${Math.round(ctx - ctxAtTurnStart)}% this turn)` : ''}</Text>}
125        {ctxInfo.parts.length > 0 && <Text wrap="wrap" dimColor>  {ctxInfo.parts.map(p => `${p.name} ${fmtTok(p.tokens)}`).join('  ·  ')}</Text>}
126        <Text> </Text>
127        <Text bold>Now</Text>
128        {Object.entries(liveState).map(([k, l]) => (
129          <Text wrap="wrap" color="cyan">● {k === 'main' ? 'main' : `[${k.slice(0, 4)}]`} {l.phase === 'thinking' ? 'thinking…' : l.phase === 'writing' ? `writing: “${clip(l.text, 160)}”` : l.phase === 'calling' ? `starting ${l.tool ?? 'a tool'}…` : 'waiting for the model…'}  {fmtDur(now - l.since)}</Text>
130        ))}
131        {live.length === 0 && Object.keys(liveState).length === 0 && <Text dimColor>  idle</Text>}
132        {live.slice(-4).map(c => (
133          <Text wrap="wrap" color="yellow">▶ {c.agentId ? `[${c.agentId.slice(0, 4)}] ` : ''}{c.tool} {clip(c.label, 140)}  {age(c)}</Text>
134        ))}
135        {mainSays && <Text wrap="wrap" dimColor>  “{clip(mainSays, 220)}”</Text>}
136        <Text> </Text>
137        <Text bold>Agents  ({spawned} spawned)</Text>
138        {agents.length === 0 && <Text dimColor>  {spawned ? `${spawned} finished, none running` : 'none spawned'}</Text>}
139        {agents.slice(-5).map(a => {
140          const recentCalls = lastCalls(calls, a.id, 3)
141          const said = says[a.id]
142          return (
143            <Box flexDirection="column">
144              <Text wrap="truncate" dimColor={a.status !== 'running'}>{a.status === 'running' ? '▶' : '·'} {a.type} · {a.description}</Text>
145              {recentCalls.map(c => <Text wrap="truncate" dimColor>    {mark(c)} {c.tool} {c.label}  {age(c)}</Text>)}
146              {said && <Text wrap="truncate" dimColor>    “{clip(said, 120)}”</Text>}
147            </Box>
148          )
149        })}
150        <Text> </Text>
151        <Text bold>Building up in context  (estimate)</Text>
152        {hogs.groups.length === 0 && <Text dimColor>  nothing yet</Text>}
153        {hogs.groups.slice(0, 6).map(g => (
154          <Text wrap="truncate">  {bar((g.tokens / hogTotal) * 100, 8)} {g.name}{g.count > 1 ? ` ×${g.count}` : ''}  {fmtTok(g.tokens)}</Text>
155        ))}
156        {hogs.biggest.length > 0 && <Text dimColor>  biggest:</Text>}
157        {hogs.biggest.map(b => <Text wrap="truncate" dimColor>    {fmtTok(b.tokens)}  {clip(b.name, 90)}</Text>)}
158        <Text> </Text>
159        <Text bold>Activity</Text>
160        {recent.length === 0 && <Text dimColor>  nothing yet</Text>}
161        {recent.map(c => (
162          <Box flexDirection="column">
163            <Text wrap="truncate" dimColor={c.end !== undefined && !c.isError} color={c.isError ? 'red' : undefined}>
164              {mark(c)} {c.agentId ? `[${c.agentId.slice(0, 4)}] ` : ''}{c.tool} {c.label}  {age(c)}
165            </Text>
166            {c.out ? <Text wrap="truncate" dimColor color={c.isError ? 'red' : undefined}>    → {c.out}</Text> : null}
167          </Box>
168        ))}
169        {files.length > 0 && <Text> </Text>}
170        {files.length > 0 && <Text bold>Files touched</Text>}
171        {files.map(f => (
172          <Text wrap="truncate">  {f.path}{f.count > 1 ? ` ×${f.count}` : ''}</Text>
173        ))}
174      </Box>
175    )
176  })
177}
178
hooks/lib.ts 150 lines
1export type Call = {
2  id: string
3  agentId?: string
4  tool: string
5  label: string
6  start: number
7  end?: number
8  isError?: boolean
9  /** First line of what the tool returned, once it finished. */
10  out?: string
11}
12
13const MAX_CALLS = 300
14const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
15
16const base = (p: unknown) => String(p ?? '').split('/').filter(Boolean).slice(-2).join('/')
17const oneLine = (s: unknown, n: number) => {
18  const t = String(s ?? '').replace(/\s+/g, ' ').trim()
19  return t.length > n ? t.slice(0, n - 1) + '…' : t
20}
21
22/** One short human line for a tool call: what it is acting on. */
23export function label(tool: string, e: Record<string, unknown>): string {
24  switch (tool) {
25    case 'Bash': return oneLine(e.command, 70)
26    case 'Read': case 'Edit': case 'Write': case 'MultiEdit': case 'NotebookEdit':
27      return base(e.file_path ?? e.notebook_path)
28    case 'Grep': case 'Glob': return oneLine(e.pattern, 60)
29    case 'Agent': return oneLine(e.description ?? e.prompt, 60)
30    case 'WebFetch': return oneLine(e.url, 60)
31    case 'WebSearch': return oneLine(e.query, 60)
32    default: return ''
33  }
34}
35
36export function start(list: Call[], call: Call): Call[] {
37  return [...list, call].slice(-MAX_CALLS)
38}
39
40export function finish(list: Call[], id: string, end: number, isError?: boolean, out?: string): Call[] {
41  return list.map(c => (c.id === id ? { ...c, end, isError, out } : c))
42}
43
44/** One line of a tool's output for a row: the first, or for a failure the last (where the error message is). */
45export function preview(text: unknown, n = 90, fromEnd = false): string {
46  const lines = String(text ?? '').split('\n').map(l => l.trim()).filter(Boolean)
47  const line = (fromEnd ? lines[lines.length - 1] : lines[0]) ?? ''
48  return line.length > n ? line.slice(0, n - 1) + '…' : line
49}
50
51/** The visible text of an assistant row: its text blocks only (nothing else is read). */
52export function sayText(content: unknown): string {
53  if (!Array.isArray(content)) return ''
54  return content
55    .filter((b): b is { type: 'text'; text: string } => !!b && b.type === 'text' && typeof b.text === 'string')
56    .map(b => b.text)
57    .join(' ')
58    .replace(/\s+/g, ' ')
59    .trim()
60}
61
62export function clip(text: string, n: number): string {
63  return text.length > n ? text.slice(0, n - 1) + '…' : text
64}
65
66/** The newest n calls made inside one loop, oldest first. */
67export function lastCalls(list: Call[], agentId: string | undefined, n: number): Call[] {
68  return list.filter(c => c.agentId === agentId).slice(-n)
69}
70
71export function running(list: Call[]): Call[] {
72  return list.filter(c => c.end === undefined)
73}
74
75/** Files written by edit tools, most-touched first. */
76export function filesTouched(list: Call[]): { path: string; count: number }[] {
77  const counts = new Map<string, number>()
78  for (const c of list) {
79    if (EDIT_TOOLS.has(c.tool) && c.label) counts.set(c.label, (counts.get(c.label) ?? 0) + 1)
80  }
81  return [...counts].map(([path, count]) => ({ path, count })).sort((a, b) => b.count - a.count)
82}
83
84/** The newest call made inside a given agent's loop (or the main loop when undefined). */
85export function latestFor(list: Call[], agentId?: string): Call | undefined {
86  for (let i = list.length - 1; i >= 0; i--) if (list[i].agentId === agentId) return list[i]
87  return undefined
88}
89
90export function fmtDur(ms: number): string {
91  const s = Math.max(0, Math.round(ms / 1000))
92  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
93}
94
95/** What one loop is doing between tool calls, from the model's response stream. */
96export type Live = { phase: 'waiting' | 'thinking' | 'writing' | 'calling'; since: number; text: string; tool?: string }
97
98/** Folds one streamed chunk into a loop's live state. Thinking text is never kept, only the fact of thinking. */
99export function nextLive(prev: Live | undefined, c: { kind: string; text?: string; name?: string }, now: number): Live {
100  const p: Live = prev ?? { phase: 'waiting', since: now, text: '' }
101  if (c.kind === 'text') {
102    if (p.phase !== 'writing') return { phase: 'writing', since: now, text: clip((c.text ?? '').trimStart(), 240) }
103    return { ...p, text: clip((p.text + (c.text ?? '')).slice(-240).trimStart(), 240) }
104  }
105  if (c.kind === 'thinking') return p.phase === 'thinking' ? p : { phase: 'thinking', since: now, text: p.text }
106  if (c.kind === 'tool') return { phase: 'calling', since: now, text: p.text, tool: c.name }
107  return p
108}
109
110/** A fixed-width bar for a percentage, e.g. ███░░░░░░░. */
111export function bar(pct: number, width = 10): string {
112  const n = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
113  return '█'.repeat(n) + '░'.repeat(width - n)
114}
115
116/** 1234 -> 1.2k, 56789 -> 57k. */
117export function fmtTok(n: number): string {
118  if (n < 1000) return String(Math.round(n))
119  return n < 10000 ? `${(n / 1000).toFixed(1)}k` : `${Math.round(n / 1000)}k`
120}
121
122export type Hog = { name: string; tokens: number; count: number }
123type Msg = { role: string; text?: string; toolUses?: { tool: string; input?: Record<string, unknown>; text?: string }[] }
124
125/**
126 * Where the conversation's tokens are going, estimated at four characters a token:
127 * your prompts, the model's replies, and each tool's calls plus results, largest first;
128 * and the three single biggest tool results.
129 */
130export function contextHogs(messages: readonly Msg[]): { groups: Hog[]; biggest: { name: string; tokens: number }[] } {
131  const by = new Map<string, Hog>()
132  const add = (name: string, chars: number) => {
133    const h = by.get(name) ?? { name, tokens: 0, count: 0 }
134    by.set(name, { name, tokens: h.tokens + chars / 4, count: h.count + 1 })
135  }
136  const singles: { name: string; tokens: number }[] = []
137  for (const m of messages) {
138    if (m.text) add(m.role === 'user' ? 'You' : 'Replies', m.text.length)
139    for (const u of m.toolUses ?? []) {
140      const chars = JSON.stringify(u.input ?? {}).length + (u.text?.length ?? 0)
141      add(u.tool, chars)
142      singles.push({ name: `${u.tool} ${label(u.tool, (u.input ?? {}) as Record<string, unknown>)}`, tokens: chars / 4 })
143    }
144  }
145  return {
146    groups: [...by.values()].map(h => ({ ...h, tokens: Math.round(h.tokens) })).sort((a, b) => b.tokens - a.tokens),
147    biggest: singles.sort((a, b) => b.tokens - a.tokens).slice(0, 3).map(x => ({ ...x, tokens: Math.round(x.tokens) })),
148  }
149}
150