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

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.
/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
/radar opens the pane (Esc closes it).autoOpen to open it at every session start.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
| Option | Default | What it does |
|---|---|---|
autoOpen | false | Open the pane automatically at session start |
Set options when installing, or later in /plugin → the mod → configure.
[a3f1] is the first 4 characters of a subagent id; calls with no tag are the main session's.hooks/register.tsx 178 lines1import 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}
178hooks/lib.ts 150 lines1export 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