SLOPSHOPPER

agent-radar

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

newpanebandguardcommandtoast
v0.1.2no licenseupdated 2026-10-14aidan-nunn-sys/claude-config/mods/agent-radar
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 yet this session. ⏺ Read(src/auth.ts) │ ┃ [ Clear finished ] [ Close ] ⎿ Read 6 lines │ ⏺ 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 yet this session. [ Clear finished ] [ Close ]
README

claude-config

My Claude Code setup: mods, plugins, skills and settings, so a new machine is one clone away.

New machine

git clone git@github-personal:aidan-nunn-sys/claude-config.git ~/Work/personal/claude-config
~/Work/personal/claude-config/install.sh

Then:

  • Fill in any <SET-ME> values install.sh reports in ~/.claude.json (MCP secrets are never committed).
  • optimaizr CLI (audited 0.11.0; re-audit before bumping), in ~/.config/mise/config.toml: "npm:optimaizr" = { version = "0.11.0", allow_low_downloads = true }
  • Restart Claude Code.

After changing settings

Mods and skills are symlinked from this repo, so edits land here directly. Settings changed through Claude Code live in ~/.claude; run ./capture.sh and review git diff to bring them back.

Layout

PathWhat
mods/Mods loaded through CLAUDE_CODE_PLUGIN_DIRS (built by install.sh from this folder)
plugins/plugins.jsonMarketplaces and which marketplace plugins are enabled
skills/Personal skills, symlinked into ~/.claude/skills
settings/settings.json~/.claude/settings.json minus plugin dirs, plugins and hooks; ${HOME} for paths
settings/mcp.jsonUser MCP servers, secrets blanked
settings/rulebook.jsonGeneric example rules (copied only when the machine has none; not captured)
docs/specs/Design specs

Mods

ModSourceLocal changes
hudownprompt hint, context weather bar, cache countdown, kaomoji mascot
rulebookownhard rules in ~/.claude/rulebook.json, /rules, mcp__rulebook__add_rule
xagentowndispatch/route tools for Codex, Copilot, Antigravity
handoffown/handoff [next] writes a handoff doc to ~/.claude/handoffs/
agent-radarhamzafer/claude-code-mods @ 87f108479bbf/radar runs mid-turn (immediate) and toggles closed; shows xagent's external agents
mission-controlhamzafer/claude-code-mods @ 87f108479bbfshows xagent's external agents; code-map view (Haiku diff summaries, headless Chrome) stripped
blast-radiushamzafer/claude-code-mods @ 87f108479bbf60 s auto-cancel
review-watchhamzafer/claude-code-mods @ 87f108479bbfnone
where-am-ihamzafer/claude-code-mods @ 87f108479bbfshows only "next"
secret-boxray-amjad/claude-code-mods @ 467b20300d8enone
task-boardMarcoCarnevali/claude-code-mods @ a3e528dd00ce/board (not /tasks), no auto-open, mid-turn + toggle

Upstream commits are the repos' HEAD on 2026-10-14, when this repo was made; installs were from around then. Read upstream changes before pulling them in.

Not in this repo

  • ~/.claude/.credentials.json and MCP secrets.
  • Managed by other tools: omarchy skills (diagnose-crash, omarchy, symlinks into /usr/share/omarchy), themes/omarchy.json, the herdr SessionStart hook.
  • Per-machine state: memory, history, sessions, plans.
  • .claude-plugin/types/ in each mod: SDK types Claude Code writes itself.
  • Hiding Claude Code's native subagent area: the mod API exposes no component for it (checked 2026-10-14; Spinner props are word, message, suffix, mode).
Source 2 files
hooks/register.tsx 220 lines
1// Agent Radar: what are my subagents cooking?
2//   The band above the prompt has one live line per running subagent: status, time, tool
3//   count and what it is doing right now; a finished one shows a check for 30 s. A toast
4//   says when each finishes. /radar opens a pane with every agent and its messages.
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import type { RadarAgent, XAgent } from '../types'
9
10const PANE = 'agent-radar'
11const SHOW_DONE_MS = 30_000 // a finished agent stays in the band this long
12const BAND_ROWS = 4
13
14// Held by the host, so the radar survives a hot reload of this file.
15const agents = atom({ plugin: 'agent-radar', key: 'agents' } as const, [] as RadarAgent[])
16const selected = atom({ plugin: 'agent-radar', key: 'selected' } as const, null as string | null)
17const now = atom({ plugin: 'agent-radar', key: 'now' } as const, 0)
18
19// xagent's external agents (Codex, Copilot, Antigravity), read-only: shown like subagents.
20const external = atom({ plugin: 'xagent', key: 'agents' } as const, [] as XAgent[])
21
22async function everyone($: EngineInterface): Promise<RadarAgent[]> {
23  const own = await read($, agents)
24  const ext = (await read($, external).catch(() => [] as XAgent[])).map(x => ({
25    id: x.id, description: `[${x.backend}] ${x.task}`, type: x.backend, status: x.status, startedAt: x.startedAt, endedAt: x.endedAt, tools: 0, last: x.last,
26  }))
27  return [...ext, ...own]
28}
29
30export const register: Register = on => {
31  on('session.start', async ($, e, next) => {
32    const r = await next(e)
33    await $.command.register({ name: 'radar', description: 'Show what each subagent is doing, in the Agent Radar pane', immediate: true }).catch(() => {}) // a name Claude Code already has is refused: start anyway
34    // A clock for the elapsed times, ticking only while something runs.
35    $.clock.every(1000, () => {
36      void (async () => {
37        // A few seconds past the 30 s too, so the redraw that drops a finished line happens.
38        if ((await everyone($)).some(a => isShown(a, Date.now() - 3_000))) await update($, now, () => Date.now())
39      })().catch(() => {})
40    })
41    return r
42  })
43
44  on('agent.spawn', async ($, e, next) => {
45    const r = await next(e)
46    if (r.agentId) {
47      const id = r.agentId
48      const one: RadarAgent = { id, description: e.description || e.name || e.subagentType, type: e.subagentType, status: 'running', startedAt: Date.now(), tools: 0, last: 'starting' }
49      await update($, agents, list => [one, ...list.filter(a => a.id !== id)].slice(0, 30))
50    }
51    return r
52  })
53
54  on('tool.call', async ($, e, next) => {
55    if (e.agentId) {
56      const id = e.agentId
57      const line = describe(e as unknown as Record<string, unknown>)
58      await update($, agents, list => list.map(a => (a.id === id ? { ...a, tools: a.tools + 1, last: line } : a)))
59    }
60    return next(e)
61  })
62
63  on('turn.complete', async ($, e, next) => {
64    const r = await next(e)
65    if (e.agentId) {
66      const id = e.agentId
67      const failed = e.reason === 'error' || e.reason === 'aborted'
68      const list = await update($, agents, all =>
69        all.map(a => (a.id === id ? { ...a, status: failed ? 'failed' : 'done', endedAt: Date.now(), last: failed ? `stopped: ${e.reason}` : 'finished' } : a)),
70      )
71      const one = list.find(a => a.id === id)
72      if (one) $.ui.toast(`${failed ? '✗' : '✓'} ${one.description} ${failed ? 'failed' : 'done'} (${elapsed(one)} · ${one.tools} tools)`)
73    }
74    return r
75  })
76
77  on('command.run', { command: 'radar' }, async $ => {
78    // Run again to close.
79    if ((await $.ui.panes()).some(p => p.id === PANE)) {
80      await $.ui.close({ id: PANE })
81      return { text: 'Agent Radar closed.' }
82    }
83    await adopt($)
84    await update($, selected, () => null)
85    await $.ui.open({ id: PANE, title: 'Agent Radar', focus: true })
86    const list = await everyone($)
87    const running = list.filter(a => a.status === 'running').length
88    return { text: `${running} running, ${list.length - running} finished` }
89  })
90
91  // The band: one live line per running agent, and a ✓ for 30 s once one finishes.
92  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
93    const rest = await next(e) // what other mods and Claude Code draw here stays
94    await read($, now) // subscribes the band to the clock
95    const shown = (await everyone($)).filter(a => isShown(a))
96    if (e.props.hasSurvey || shown.length === 0) return rest
97    const { Box, Text } = $.ui.resolve(e)
98    return (
99      <Box flexDirection="column">
100        {shown.slice(0, BAND_ROWS).map(a => (
101          <Text wrap="truncate-end">
102            <Text color={color(a)} bold>{` ${icon(a)} ${a.description}`}</Text>
103            <Text dimColor>{`  ${elapsed(a)} · ${a.tools} tools`}</Text>
104            {a.status === 'running' && <Text>{` · ${a.last}`}</Text>}
105            {a.status === 'failed' && <Text color="red">{` · ${a.last}`}</Text>}
106          </Text>
107        ))}
108        {shown.length > BAND_ROWS && <Text dimColor>{`   +${shown.length - BAND_ROWS} more · /radar`}</Text>}
109        {rest}
110      </Box>
111    )
112  })
113
114  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
115    const { Box, Text, Button } = $.ui.resolve(e)
116    await read($, now) // subscribes the pane to the clock
117    const list = await everyone($)
118    const pick = await read($, selected)
119    const one = list.find(a => a.id === pick)
120
121    if (one) {
122      const found = await $.session.messages({ agentId: one.id })
123      const messages = 'deny' in found ? [] : found.slice(-6)
124      return (
125        <Box flexDirection="column">
126          <Text>
127            <Text color={color(one)} bold>{`${icon(one)} ${one.description}`}</Text>
128            <Text dimColor>{`  ${one.type} · ${elapsed(one)} · ${one.tools} tools`}</Text>
129          </Text>
130          <Text dimColor wrap="truncate-end">{`now: ${one.last}`}</Text>
131          <Text dimColor>{'─'.repeat(Math.max(10, e.props.bodyColumns - 2))}</Text>
132          {messages.length === 0 && <Text dimColor>No messages yet.</Text>}
133          {messages.map(m => (
134            <Text wrap="truncate-end">
135              <Text color={m.role === 'assistant' ? 'cyan' : undefined} bold>{m.role === 'assistant' ? 'agent: ' : 'task:  '}</Text>
136              <Text>{m.text.replace(/\s+/g, ' ').slice(0, 400) || '(tool calls)'}</Text>
137            </Text>
138          ))}
139          <Box flexDirection="row" gap={1}>
140            <Button key="back" label="Back" hotkey="b" variant="primary" onPress={() => update($, selected, () => null)} />
141            <Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
142          </Box>
143        </Box>
144      )
145    }
146
147    const running = list.filter(a => a.status === 'running').length
148    return (
149      <Box flexDirection="column">
150        <Text bold>{`${running} running · ${list.length - running} finished`}</Text>
151        {list.length === 0 && <Text dimColor>No subagents yet this session.</Text>}
152        {list.slice(0, 9).map((a, i) => (
153          <Button
154            key={`agent-${a.id}`}
155            plain
156            hotkey={String(i + 1)}
157            dimColor={a.status !== 'running'}
158            label={`${icon(a)} ${a.description} · ${elapsed(a)} · ${a.tools} tools${a.status === 'done' ? '' : ` · ${a.last}`}`}
159            onPress={() => update($, selected, () => a.id)}
160          />
161        ))}
162        {list.length > 9 && <Text dimColor>{`… ${list.length - 9} older`}</Text>}
163        <Box flexDirection="row" gap={1}>
164          <Button key="clear" label="Clear finished" hotkey="c" onPress={() => update($, agents, all => all.filter(x => x.status === 'running'))} />
165          <Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
166        </Box>
167      </Box>
168    )
169  })
170}
171
172// Agents that started before this mod loaded: take them from the engine's own list.
173async function adopt($: EngineInterface) {
174  const known = new Set((await read($, agents)).map(a => a.id))
175  const fresh = (await $.agent.list())
176    .filter(a => !known.has(a.id))
177    .map((a): RadarAgent => ({
178      id: a.id,
179      description: a.description,
180      type: a.type,
181      status: a.status === 'running' ? 'running' : a.status === 'completed' ? 'done' : 'failed',
182      startedAt: Date.now(),
183      tools: 0,
184      last: a.status === 'running' ? 'running (started before the radar)' : a.status,
185    }))
186  if (fresh.length > 0) await update($, agents, list => [...list, ...fresh])
187}
188
189// Running, or finished in the last 30 s.
190export function isShown(a: RadarAgent, at = Date.now()) {
191  return a.status === 'running' || (a.endedAt !== undefined && at - a.endedAt < SHOW_DONE_MS)
192}
193
194function icon(a: RadarAgent) {
195  return a.status === 'running' ? '●' : a.status === 'done' ? '✓' : '✗'
196}
197
198function color(a: RadarAgent) {
199  return a.status === 'running' ? 'yellow' : a.status === 'done' ? 'green' : 'red'
200}
201
202export function elapsed(a: Pick<RadarAgent, 'startedAt' | 'endedAt'>, at = Date.now()) {
203  const s = Math.max(0, Math.round(((a.endedAt ?? at) - a.startedAt) / 1000))
204  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
205}
206
207// A short label for one tool call: what a person would say the agent is doing.
208export function describe(e: Record<string, unknown>): string {
209  const tool = String(e.tool)
210  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
211  const file = (p: string) => p.split('/').slice(-2).join('/')
212  if (tool === 'Bash') return `running ${s('description') || s('command').slice(0, 50)}`
213  if (tool === 'Edit' || tool === 'Write') return `editing ${file(s('file_path'))}`
214  if (tool === 'Read') return `reading ${file(s('file_path'))}`
215  if (tool === 'Grep' || tool === 'Glob') return `searching ${s('pattern').slice(0, 30)}`
216  if (tool.includes('playwright')) return `browser: ${tool.split('__').pop()?.replace(/^browser_/, '')}`
217  if (tool.startsWith('mcp__')) return `using ${tool.split('__').slice(1).join(' ')}`
218  return `using ${tool}`
219}
220
types/index.d.ts 32 lines
1export type RadarAgent = {
2  id: string
3  description: string
4  type: string
5  status: 'running' | 'done' | 'failed'
6  startedAt: number
7  endedAt?: number
8  tools: number
9  last: string
10}
11
12declare module 'claude-code' {
13  interface PluginState {
14    'agent-radar': { agents: RadarAgent[]; selected: string | null; now: number }
15    xagent: { agents: XAgent[] }
16  }
17}
18
19// xagent's agents, as xagent writes them (mods/xagent/types: XAgent). Read-only here.
20export type XAgent = {
21  id: string
22  backend: 'codex' | 'copilot' | 'agy'
23  model: string
24  effort: string
25  task: string
26  status: 'running' | 'done' | 'failed'
27  startedAt: number
28  endedAt?: number
29  last: string
30  quota: string
31}
32