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
★ 1v0.1.2MITupdated 2026-10-26StartupBros-com/hov-stack/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 or delegated work 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 delegated work yet this session. [ Clear finished ] [ Close ]
README

hov-stack

I'm Will Mitchell. I'm not the president of YC and I'm not on the React core team. I'm a marketer who has been vibe coding full time for almost four years, shipping solo on Windows. hov-stack is my answer. This is the Claude Code harness I work in every day, not a copy of it. My Codex and Pi setups load skills from it too.

Fork it. Improve it. Make it yours.

Receipts, not adjectives:

<!-- Screenshots return when the PNGs land in docs/images/ (none are in the repo yet): Skill usage across 20,610 Claude Code sessions 30 agents running in the Claude Code agents view during a live workshop -->

In the 60 days to 2026-10-19: 4,302 pull requests merged across 17 repos. Everything that got there is in this repo: the rules, the guards, the permission lists, the skills.

<!-- GitHub contributions, 2026 -->

What nobody else ships: the permission lists. 291 allow, 75 ask and 70 deny entries, grown from refusals through /denial-triage, plus 12 hooks that run before a tool call lands. The other published stacks ship skills; this one ships the part that decides what an agent may run.

Who this is for

  • Non-technical builders who want a working setup without learning any of it. Paste the sentence below and you are done.
  • Solo operators running several businesses on one machine. The guards and the permission lists exist so parallel agents stop colliding.
  • Claude Code users with a setup already who want to cherry-pick the guards, the rules file, or the memory workflow.

Install, about a minute

Builders:

git clone --depth 1 https://github.com/StartupBros-com/hov-stack ~/hov-stack && cd ~/hov-stack && ./setup

Everyone else, open Claude Code anywhere and paste:

Set me up from https://github.com/StartupBros-com/hov-stack. Read its README and HARNESS.md first, run its setup, apply what fits this machine, and tell me what you skipped and why.

./setup needs git and node. Its plugin steps also need Claude Code 2.1.76 or later, since older releases refuse the hov marketplace's manifest. It symlinks this repo into ~/.claude, so the checkout has to stay where you cloned it. It keeps your existing memory and secrets where they are, and moves anything it replaces to ~/.hov-stack-backups/. If you already have a settings.json it is left alone and the seed lands next to it as settings.hov-stack.json. Your own settings, CLAUDE.md additions and environment variables go in the private overlay (below), which setup reads and never writes. It then installs dcg after checking its checksum and signature (it shows dcg's licence first; ./setup --no-dcg leaves dcg out), adds the hov marketplace, and installs the default plugins (papercut, skill-tuner, memory-dream, design-rails). A step that cannot run (offline, no claude yet, a platform dcg has no build for) is reported with the command to run later, and ./setup --check shows what is in place without changing anything. tests/repo/setup.test.sh checks these promises on every push, against a stand-in claude and a stubbed download, and dcg/tests/install-test.sh checks that a tampered archive or a signature from another key installs nothing.

Then start a new Claude Code session and type /hov-setup, setup's second stage. Five plugins need what a script cannot do for you: a login, a sudo or Windows prompt, or a paid plan. They are pro-gate, wsl-cdp, token-eater, product-video and sign-it. The skill shows which of them you have and installs the ones you pick. It then takes each through its own setup, one at a time, ending in a check the session runs. Passwords and tokens go only into each plugin's own login, never into the chat.

Quick start

  1. Run ./setup
  2. Run claude agents in any repo. That is the agents view, and it is how 90% of my Claude Code use happens.
  3. Ask an agent to push to main. Watch git-guard refuse it and name the branch-and-pull-request route. The refusal comes from a hook, not a model call.
  4. After a day of work, run /denial-triage. It turns the calls the guards refused into permission fixes, each behind its own approval, so the prompts get rarer every week.
  5. Run /memory-dream:dream on your busiest project. memory-dream is one of the default plugins.
  6. Stop there. You'll know if this is for you.

What's here

  • CLAUDE.md, the global rules file every session and subagent loads (15 KB), and HARNESS.md with 34 detail docs in harness/ explaining the decisions behind it
  • hooks/: 20 hook scripts that settings.json registers across 7 hook events; 12 of them run before a tool call (git-guard, settings-guard, loop-guard, read-ceiling-chunk and the rest), and dcg runs beside them when it is installed. The hook suite is the 46 test files in hooks/tests/.
  • settings.json: the allow / ask / deny lists I run under bypass-permissions mode (291 / 75 / 70 entries)
  • plugins/hov-stack/skills/ (also reachable as skills/): 45 skills, 31 of them slash-command only so they never cost tokens until called, plus patches to 2 third-party skills in skills-patches/ (papers, and de-slopify's fact-check scripts), each with a note of its upstream
  • skills-stack/: 2 more slash-command skills (memory-mine, review-adversarial-first) that run this repo's scripts, so ./setup links them and the plugin does not ship them
  • harnesses/: my Codex, fx, Pi and pi-lens config, which install.sh installs and ./setup leaves alone. Nothing under it runs in this repo's CI.
  • windows/: the Windows side of a WSL2 machine (WezTerm and Windows Terminal config, .wslconfig, and the disk and Defender scripts)
  • install.sh and sync.sh are the full installers for my own machines (systemd units, the destructive-command guard, the managed deny floor). ./setup is the one for yours. The systemd units that make sense without them are documented, with copy-and-enable commands, in systemd/README.md.

The plugins

This repository is the hov marketplace, and ./setup adds it. To add it by hand, in Claude Code: /plugin marketplace add StartupBros-com/hov-stack, then /plugin install <name>@hov. Claude Code leaves auto-update off for a third-party marketplace. To turn it on, open /plugin, select Marketplaces, choose hov and select Enable auto-update; updates then apply at the start of a session.

Installed by ./setup

  • papercut: logs every hard tool failure from a hook, ranks them by how many distinct sessions hit each, and carries one family at a time into a tracked work item whose verification refuses to call it fixed on silence alone.
  • skill-tuner: tells whether a change to a SKILL.md, AGENTS.md or CLAUDE.md worked, by paired comparison with a confidence interval. /skill-tuner:tune fixes what three independent skeptics confirm and checks the description still routes.
  • memory-dream: the sleep cycle for Claude Code's auto-memory. /memory-dream:dream finds superseded, stale and oversized notes and drafts the fixes, and you approve each change before anything is written. /memory-dream:eval measures whether recall improved.
  • design-rails: keeps agents on your design system. It checks whether one exists and is enforced, derives a DESIGN.md under design/ from the code you already ship, and ratchets drift budgets that only move down. It never edits application source.

Set up through /hov-setup

  • pro-gate: a final pull-request review by the Pro model your ChatGPT account selects. It fixes what it safely can, leaves a report and never merges. It needs a ChatGPT Pro plan.
  • wsl-cdp: lets Claude Code in WSL2 drive your logged-in Windows browser (Chrome, Edge or Brave) over the Chrome DevTools Protocol. Its setup has one elevated Windows step, and the agent's browser profile keeps sessions, never passwords.
  • token-eater: puts expiring AI credits to work on code cleanup. It works in an isolated copy, verifies and reviews the result, and opens a draft pull request; it never merges. It runs shell commands unsandboxed on the target repository, after its first run asks for your consent, and its reviews need Every's compound-engineering plugin (/plugin marketplace add EveryInc/compound-engineering-plugin, then /plugin install compound-engineering).
  • product-video: records a product tour in a real, logged-in browser and renders a checked MP4.
  • sign-it: /sign-it finds a document's signature line, stamps your stored signature and today's date, and writes <file>-signed.pdf. It refuses to guess a missing or ambiguous line and never draws a signature for you.

On request

  • harness-vet: an adoption gate that vets a skill, plugin, MCP server or repo against your own harness, reading it the way an adversary would.
  • rent-check: audits what CLAUDE.md and always-on skills cost per prompt, measures which rules earn their tokens from real transcripts, and proposes demotions for the ones that do not.
  • harness-optimizer: investigates recurring problems in your Claude Code setup across six evidence lanes, then has independent agents challenge its diagnosis, proposed changes and reported outcome. It runs only when you invoke it.

What's not here

  • My memory notes, secrets, and business-specific skills. Those live in a private sibling.
  • The private overlay. Values the tree cannot carry live in ~/.config/hov-stack/ (or $XDG_CONFIG_HOME/hov-stack/, or $HOV_STACK_OVERLAY). ./setup and claude-env.sh count only an absolute overlay path: a relative $HOV_STACK_OVERLAY is reported and read by nothing, and a relative $XDG_CONFIG_HOME is ignored. The scripts read:
  • private-tokens, the public-safety scan's fixed-string layer;
  • foreign-units, one externally owned unit stem per line, excluded from sync's undeclared-unit findings (blank lines and # comments ignored);
  • memory-filename-allowlist.txt, the memory audit's reviewed-name list (the tracked copy ships no entries);
  • supabase-projects.json and migration-targets.json, whose shapes config/*.example.json show;
  • repo-routes.json, which replaces the tracked placeholder.

Without one of those, its consumer fails closed and names it. ./setup also reads these, each optional, and does nothing without them:

  • settings.json, merged into the seed setup writes. Its keys win, its lists add to the template's without repeats, a null removes a key, and a hook of its that runs dcg is left out when dcg is not installed, as the template's is. It never changes a settings.json you already have. One that gives a setting another kind of value than the template's, such as a string where the template has an object, gets no seed written, and the run exits 1.
  • CLAUDE.md, imported in every project by the rules/hov-stack-overlay.md setup writes in the config dir. Into a rules/ that is a symlink setup writes nothing, and names the line to add there yourself.
  • env, NAME=value lines that claude-env.sh exports before each Bash call.

config/settings.example.json, config/CLAUDE.example.md and config/env.example show each, and ./setup --check names an import not yet written, out of step with the overlay or whose file is gone, an env that does not parse, and a settings.json setup would refuse.

  • A release train for the harness. It changes when my setup changes: main is its only version, git pull is the update, and no editions or support are owed. Each plugin carries its own version, which the hov marketplace serves.
  • The private repo's test dispatcher. CI here runs the three repository suites under tests/repo/ (the fresh-HOME setup test, the layout and marketplace-collision check, the suite runner's own test), the hook tests, every unit suite tests/run-claude-tests.sh finds in the tree, a secrets scan (gitleaks) and the public-safety scan: scripts/public-safety-scan.sh fails on a home-directory path, a personal address, a private-network host or a private repository name anywhere in the tree, the skipped suites' sources included, and .public-safety-allow names each exception and why. tests/claude-tests.skips names the 5 suites that cannot pass from this repo and why: 4 read the author's host roster, install manifest or install-smoke launcher, 1 needs real Docker. The runner still runs the first 4, each with the failure signature its log must match, and fails the day one of them passes, times out, or fails for a different reason, so the list cannot go stale quietly.

Where it came from

MIT. Copyright (c) 2026 StartupBros LLC. Built by Will Mitchell for House of Vibe.

Source 3 files
hooks/register.tsx 433 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.
5//   Delegated work this session started outside itself (Codex, a Pro review, CE's peer, a
6//   headless Claude) gets a line too, and a toast when its process ends: ./delegates.ts.
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import type { RadarAgent } from '../types'
11import { MAX_PS_MS, PS, grepArgv, grepPatterns, judge, ours, parsePs, rowFor, stillRuns, triage, type Delegate, type Proc, type Verdicts } from './delegates'
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 HISTORY = 30 // finished agents kept in the list; a running one is never dropped
17const LOOK_MS = 5_000 // how often the radar looks for delegated work, while anyone watches
18
19// Held by the host, so the radar survives a hot reload of this file.
20const agents = atom({ plugin: 'agent-radar', key: 'agents' } as const, [] as RadarAgent[])
21const selected = atom({ plugin: 'agent-radar', key: 'selected' } as const, null as string | null)
22const now = atom({ plugin: 'agent-radar', key: 'now' } as const, 0)
23// Every id the radar has taken in, spawned or adopted, so adopt() imports each once: clearing
24// the finished entries, or the 30-entry cap evicting one, must not bring it back.
25const seen = atom({ plugin: 'agent-radar', key: 'seen' } as const, [] as string[])
26// Every session id the conversation has had in this process: a /clear moves the id, and work
27// started before it still carries the old one. A resume starts it over (`forget`).
28const sessions = atom({ plugin: 'agent-radar', key: 'sessions' } as const, [] as string[])
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' }).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 read($, agents)).some(a => isShown(a, Date.now() - 3_000))) await update($, now, () => Date.now())
39      })().catch(() => {})
40    })
41    $.clock.every(LOOK_MS, () => {
42      void look($)
43    })
44    return r
45  })
46
47  // A /clear or a resume moves the session id; what the radar keeps across each: `ends`.
48  on('session.end', async ($, e, next) => {
49    await ends($, e.reason, e.sessionId).catch(() => {})
50    return next(e)
51  })
52
53  on('agent.spawn', async ($, e, next) => {
54    const r = await next(e)
55    if (r.agentId) {
56      const id = r.agentId
57      const one: RadarAgent = { id, description: e.description || e.name || e.subagentType, type: e.subagentType, status: 'running', startedAt: Date.now(), tools: 0, last: 'starting' }
58      await update($, seen, ids => (ids.includes(id) ? ids : [...ids, id]))
59      await update($, agents, list => keep([one, ...list.filter(a => a.id !== id)]))
60    }
61    return r
62  })
63
64  on('tool.call', async ($, e, next) => {
65    if (e.agentId) {
66      const id = e.agentId
67      const line = describe(e as unknown as Record<string, unknown>)
68      await update($, agents, list => list.map(a => (a.id === id ? { ...a, tools: a.tools + 1, last: line } : a)))
69    }
70    return next(e)
71  })
72
73  on('turn.complete', async ($, e, next) => {
74    const r = await next(e)
75    if (e.agentId) {
76      const id = e.agentId
77      const failed = e.reason === 'error' || e.reason === 'aborted'
78      const list = await update($, agents, all =>
79        all.map(a => (a.id === id ? { ...a, status: failed ? 'failed' : 'done', endedAt: Date.now(), last: failed ? `stopped: ${e.reason}` : 'finished' } : a)),
80      )
81      const one = list.find(a => a.id === id)
82      if (one) $.ui.toast(`${failed ? '✗' : '✓'} ${one.description} ${failed ? 'failed' : 'done'} (${duration(one)} · ${one.tools} tools)`)
83    }
84    return r
85  })
86
87  on('command.run', { command: 'radar' }, async $ => {
88    await adopt($)
89    await look($, true)
90    await update($, selected, () => null)
91    await $.ui.open({ id: PANE, title: 'Agent Radar', focus: true })
92    const list = await read($, agents)
93    const running = list.filter(a => a.status === 'running').length
94    return { text: `${running} running, ${list.length - running} finished` }
95  })
96
97  // The band: one live line per running agent, and a ✓ for 30 s once one finishes.
98  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
99    const rest = await next(e) // what other mods and Claude Code draw here stays
100    await read($, now) // subscribes the band to the clock
101    const shown = (await read($, agents)).filter(a => isShown(a))
102    if (e.props.hasSurvey || shown.length === 0) return rest
103    const { Box, Text } = $.ui.resolve(e)
104    return (
105      <Box flexDirection="column">
106        {shown.slice(0, BAND_ROWS).map(a => (
107          <Text wrap="truncate-end">
108            <Text color={color(a)} bold>{` ${icon(a)} ${a.description}`}</Text>
109            <Text dimColor>{`  ${duration(a)} · ${isDelegated(a) ? where(a) : `${a.tools} tools`}`}</Text>
110            {a.status === 'running' && !isDelegated(a) && <Text>{` · ${a.last}`}</Text>}
111            {a.status === 'failed' && <Text color="red">{` · ${a.last}`}</Text>}
112          </Text>
113        ))}
114        {shown.length > BAND_ROWS && <Text dimColor>{`   +${shown.length - BAND_ROWS} more · /radar`}</Text>}
115        {rest}
116      </Box>
117    )
118  })
119
120  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
121    const { Box, Text, Button } = $.ui.resolve(e)
122    await read($, now) // subscribes the pane to the clock
123    const list = await read($, agents)
124    const pick = await read($, selected)
125    const one = list.find(a => a.id === pick)
126
127    // Delegated work has no messages here: what the radar knows is its process.
128    if (one && isDelegated(one)) {
129      return (
130        <Box flexDirection="column">
131          <Text>
132            <Text color={color(one)} bold>{`${icon(one)} ${one.description}`}</Text>
133            <Text dimColor>{`  delegated · ${duration(one)}`}</Text>
134          </Text>
135          <Text dimColor wrap="truncate-end">{`pid ${one.pid}${one.cwd ? ` in ${one.cwd}` : ''}`}</Text>
136          <Text dimColor>
137            {one.status === 'running'
138              ? 'Running outside this session; the radar looks for it every few seconds while you watch.'
139              : 'Its process has ended. How it went is in its own output; the radar sees only that it is gone.'}
140          </Text>
141          <Box flexDirection="row" gap={1}>
142            <Button key="back" label="Back" hotkey="b" variant="primary" onPress={() => update($, selected, () => null)} />
143            <Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
144          </Box>
145        </Box>
146      )
147    }
148
149    if (one) {
150      const found = await $.session.messages({ agentId: one.id })
151      const messages = 'deny' in found ? [] : found.slice(-6)
152      return (
153        <Box flexDirection="column">
154          <Text>
155            <Text color={color(one)} bold>{`${icon(one)} ${one.description}`}</Text>
156            <Text dimColor>{`  ${one.type} · ${duration(one)} · ${one.tools} tools`}</Text>
157          </Text>
158          <Text dimColor wrap="truncate-end">{`now: ${one.last}`}</Text>
159          <Text dimColor>{'─'.repeat(Math.max(10, e.props.bodyColumns - 2))}</Text>
160          {messages.length === 0 && <Text dimColor>No messages yet.</Text>}
161          {messages.map(m => (
162            <Text wrap="truncate-end">
163              <Text color={m.role === 'assistant' ? 'cyan' : undefined} bold>{m.role === 'assistant' ? 'agent: ' : 'task:  '}</Text>
164              <Text>{m.text.replace(/\s+/g, ' ').slice(0, 400) || '(tool calls)'}</Text>
165            </Text>
166          ))}
167          <Box flexDirection="row" gap={1}>
168            <Button key="back" label="Back" hotkey="b" variant="primary" onPress={() => update($, selected, () => null)} />
169            <Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
170          </Box>
171        </Box>
172      )
173    }
174
175    const running = list.filter(a => a.status === 'running').length
176    return (
177      <Box flexDirection="column">
178        <Text bold>{`${running} running · ${list.length - running} finished`}</Text>
179        {list.length === 0 && <Text dimColor>No subagents or delegated work yet this session.</Text>}
180        {list.slice(0, 9).map((a, i) => (
181          <Button
182            key={`agent-${a.id}`}
183            plain
184            hotkey={String(i + 1)}
185            dimColor={a.status !== 'running'}
186            label={
187              isDelegated(a)
188                ? `${icon(a)} ${a.description} · ${duration(a)} · ${where(a)}${a.status === 'running' ? '' : ' · ended'}`
189                : `${icon(a)} ${a.description} · ${duration(a)} · ${a.tools} tools${a.status === 'done' ? '' : ` · ${a.last}`}`
190            }
191            onPress={() => update($, selected, () => a.id)}
192          />
193        ))}
194        {list.length > 9 && <Text dimColor>{`… ${list.length - 9} older`}</Text>}
195        <Box flexDirection="row" gap={1}>
196          <Button key="clear" label="Clear finished" hotkey="c" onPress={() => update($, agents, all => all.filter(x => x.status === 'running'))} />
197          <Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
198        </Box>
199      </Box>
200    )
201  })
202}
203
204// Agents that started before this mod loaded: take them from the engine's own list, each once.
205// Each check runs inside the update it guards, against that value as it is then: a spawn that
206// lands while the list call is pending must not give the same agent a second row.
207async function adopt($: EngineInterface) {
208  const listed = await $.agent.list()
209  let fresh: string[] = []
210  await update($, seen, had => {
211    fresh = listed.map(a => a.id).filter(id => !had.includes(id))
212    return fresh.length > 0 ? [...had, ...fresh] : had
213  })
214  if (fresh.length === 0) return
215  const taken = listed
216    .filter(a => fresh.includes(a.id))
217    .map((a): RadarAgent => ({
218      id: a.id,
219      description: a.description,
220      type: a.type,
221      status: a.status === 'running' ? 'running' : a.status === 'completed' ? 'done' : 'failed',
222      startedAt: Date.now(),
223      tools: 0,
224      last: a.status === 'running' ? 'running (started before the radar)' : a.status,
225      adopted: true,
226    }))
227  await update($, agents, list => {
228    const have = new Set(list.map(a => a.id))
229    return keep([...list, ...taken.filter(a => !have.has(a.id))])
230  })
231}
232
233// Every running agent, and the first HISTORY finished ones in list order (newest spawned first).
234// A running agent is never dropped: its id is in `seen`, so nothing would ever bring it back.
235export function keep(list: RadarAgent[]) {
236  let finished = 0
237  return list.filter(a => a.status === 'running' || finished++ < HISTORY)
238}
239
240// Running, or finished in the last 30 s.
241export function isShown(a: RadarAgent, at = Date.now()) {
242  return a.status === 'running' || (a.endedAt !== undefined && at - a.endedAt < SHOW_DONE_MS)
243}
244
245function icon(a: RadarAgent) {
246  return a.status === 'running' ? '●' : a.status === 'done' ? '✓' : a.status === 'ended' ? '■' : '✗'
247}
248
249function color(a: RadarAgent) {
250  return a.status === 'running' ? 'yellow' : a.status === 'done' ? 'green' : a.status === 'ended' ? 'blue' : 'red'
251}
252
253// A row for delegated work: the one kind with a process, so the one kind with a pid.
254function isDelegated(a: RadarAgent): a is RadarAgent & { pid: number } {
255  return a.pid !== undefined
256}
257
258// Where delegated work runs: the last part of its directory, or its pid when that is unknown.
259function where(a: RadarAgent & { pid: number }) {
260  return a.cwd ? a.cwd.slice(a.cwd.lastIndexOf('/') + 1) || a.cwd : `pid ${a.pid}`
261}
262
263// Whether anything draws this session. A read that fails counts as watched.
264async function isWatched($: EngineInterface) {
265  return $.session.surfaces().then(
266    s => s.length > 0,
267    () => true,
268  )
269}
270
271// Delegated work: the look in flight, if any; each process judged once (./delegates.ts), and the
272// last time each running row was seen, so one that ended while nobody watched ends then. These
273// start over when the module reloads; the rows are the host's and stay, matched again by rowFor.
274let looking: Promise<boolean> | null = null
275const verdicts: Verdicts = new Map()
276const lastSeen = new Map<string, number>()
277// Every session id the conversation has had in this process, twice over: the host's list
278// (`sessions`) outlives a reload of this module, and this one outlives whatever a /clear does to
279// the host's values.
280const hadIds = new Set<string>()
281// The id a resume left, until the session is seen under another (`arrive`).
282let left: string | null = null
283
284async function remember($: EngineInterface, id: string) {
285  hadIds.add(id)
286  return update($, sessions, had => (had.includes(id) ? had : [...had, id]))
287}
288
289// The session `id` ends (`session.end`). A /clear goes on under a new id: `id` is kept, so the work
290// it started stays in view even when no look ran under it; a resume before it with no look since is
291// settled first, and one that left the session where it was owes nothing. A resume puts another
292// conversation in its place: the radar starts over. Any other end ends the process.
293async function ends($: EngineInterface, reason: string, id: string) {
294  if (reason === 'resume') return forget($, id)
295  if (reason !== 'clear') return
296  await arrive($, id)
297  left = null
298  await remember($, id)
299}
300
301// A resume leaves the conversation `from` for another: the ids, verdicts and delegated rows the
302// radar holds are the old one's, so it starts over, and the next look judges every process again
303// under the resumed one's id. The rows go with no toast (their work has not ended); subagent rows
304// stay. A look in flight at the resume, or one made before the new id lands, takes the old one's
305// work back, so the start over is owed again until the session is seen under another id.
306async function forget($: EngineInterface, from: string | null) {
307  left = from
308  hadIds.clear()
309  verdicts.clear()
310  lastSeen.clear()
311  await update($, sessions, () => [])
312  await update($, agents, list => list.filter(a => !isDelegated(a)))
313}
314
315// The session is seen under `id`: after a resume, another id than the one it left is the resumed
316// conversation's, and the start over owed is made now.
317async function arrive($: EngineInterface, id: string) {
318  if (left !== null && id !== left) await forget($, null)
319}
320
321// Look for delegated work and bring the rows up to date: a new process gets a running row, and a
322// running row whose process has gone ends, with a toast. Unless asked (`/radar`), it looks only
323// while something draws the session. A look that fails changes nothing.
324// One look at a time: a /radar during one waits for it, so it reads the rows as that look leaves
325// them, and looks itself only when that one stopped short with nothing drawing the session, or a
326// start over is still owed after it (`left`): a resume landed during it, so it may have looked as
327// the conversation left. One that looked and failed is not run again: /radar shows the rows as
328// they were, as when its own look fails, and the next look tries again.
329async function look($: EngineInterface, isAsked = false): Promise<void> {
330  if (looking) {
331    if (isAsked && (!(await looking) || left !== null)) await look($, true)
332    return
333  }
334  looking = lookNow($, isAsked).finally(() => {
335    looking = null
336  })
337  await looking
338}
339
340// One look: false when, not asked, it found nothing drawing the session and stopped short.
341async function lookNow($: EngineInterface, isAsked: boolean): Promise<boolean> {
342  try {
343    if (!isAsked && !(await isWatched($))) return false
344    const id = await $.session.id()
345    await arrive($, id)
346    const ids = [...new Set([...(await remember($, id)), ...hadIds])]
347    const at = Date.now()
348    const looked = await findDelegates($, ids, at)
349    if (looked === null) return true
350    const { found, procs } = looked
351    const list = await read($, agents)
352    const rows = found.map(d => rowFor(list, d))
353    // The rows still running: those of the work found, and any whose process is still listed
354    // though it has no verdict now (after a reload, or an environment grep could not read).
355    const live = new Set([...rows, ...list.filter(a => isDelegated(a) && a.status === 'running' && stillRuns(procs, at, a)).map(a => a.id)])
356    const have = new Set(list.map(a => a.id))
357    const fresh: RadarAgent[] = []
358    for (const [i, d] of found.entries()) {
359      const id = rows[i]!
360      if (have.has(id)) continue
361      const cwd = await $.fs.stat(`/proc/${d.pid}/cwd`, { resolve: true }).then(
362        s => s.realPath,
363        () => undefined,
364      )
365      fresh.push({ id, description: d.label, type: 'delegated', status: 'running', startedAt: d.startedAt, tools: 0, last: 'running', pid: d.pid, cwd })
366    }
367    let ended: RadarAgent[] = []
368    await update($, agents, list => {
369      ended = list.filter(a => isDelegated(a) && a.status === 'running' && !live.has(a.id))
370      const done = new Set(ended.map(a => a.id))
371      const known = new Set(list.map(a => a.id))
372      const updated = list.map(a => (done.has(a.id) ? { ...a, status: 'ended' as const, endedAt: lastSeen.get(a.id) ?? at, last: 'ended' } : a))
373      return keep([...fresh.filter(a => !known.has(a.id)), ...updated])
374    })
375    for (const key of live) lastSeen.set(key, at)
376    for (const a of ended) {
377      const endedAt = lastSeen.get(a.id) ?? at
378      lastSeen.delete(a.id)
379      $.ui.toast(`■ ${a.description} ended (${elapsed({ startedAt: a.startedAt, endedAt })})`)
380    }
381  } catch {
382    // a look that fails changes nothing; the next one tries again
383  }
384  return true
385}
386
387// Every piece of delegated work running now that carries one of `ids`, with the listing it was
388// found in, or null when the look could not be made (ps would not run, was cut short or ran too
389// long to read starts by, or grep's counts were cut short), so the caller ends nothing on a failed
390// look. Either command still running at MAX_PS_MS is killed and the call rejects, which fails the
391// look too. The environments are read by grep alone and never enter the mod.
392async function findDelegates($: EngineInterface, ids: readonly string[], at: number): Promise<{ found: Delegate[]; procs: Proc[] } | null> {
393  const asked = await $.clock.now()
394  const ps = await $.process.run(PS, { timeoutMs: MAX_PS_MS })
395  if (ps.exitCode !== 0 || ps.isStdoutTruncated || (await $.clock.now()) - asked > MAX_PS_MS) return null
396  const procs = parsePs(ps.stdout)
397  const { labels, unjudged } = triage(procs, at, verdicts)
398  if (unjudged.length > 0 && ids.length > 0) {
399    const counts = await $.process.run(grepArgv(unjudged), { stdin: grepPatterns(ids), timeoutMs: MAX_PS_MS })
400    if (counts.isStdoutTruncated) return null
401    judge(counts.stdout, unjudged, procs, at, verdicts)
402  }
403  return { found: ours(procs, labels, verdicts), procs }
404}
405
406export function elapsed(a: Pick<RadarAgent, 'startedAt' | 'endedAt'>, at = Date.now()) {
407  const s = Math.max(0, Math.round(((a.endedAt ?? at) - a.startedAt) / 1000))
408  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
409}
410
411// An adopted agent's startedAt is the moment of its adoption, not its real start, so any clock on
412// it is a lower bound: shown with a trailing + while it runs, and once it finishes under the
413// radar's watch. One that had already finished when adopted has no clock at all.
414export function duration(a: Pick<RadarAgent, 'startedAt' | 'endedAt' | 'status' | 'adopted'>, at = Date.now()) {
415  if (!a.adopted) return elapsed(a, at)
416  if (a.status !== 'running' && a.endedAt === undefined) return 'duration unknown'
417  return `${elapsed(a, at)}+`
418}
419
420// A short label for one tool call: what a person would say the agent is doing.
421export function describe(e: Record<string, unknown>): string {
422  const tool = String(e.tool)
423  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
424  const file = (p: string) => p.split('/').slice(-2).join('/')
425  if (tool === 'Bash') return `running ${s('description') || s('command').slice(0, 50)}`
426  if (tool === 'Edit' || tool === 'Write') return `editing ${file(s('file_path'))}`
427  if (tool === 'Read') return `reading ${file(s('file_path'))}`
428  if (tool === 'Grep' || tool === 'Glob') return `searching ${s('pattern').slice(0, 30)}`
429  if (tool.includes('playwright')) return `browser: ${tool.split('__').pop()?.replace(/^browser_/, '')}`
430  if (tool.startsWith('mcp__')) return `using ${tool.split('__').slice(1).join(' ')}`
431  return `using ${tool}`
432}
433
hooks/delegates.ts 168 lines
1// Delegated work outside the session: a local addition, not from upstream (see ../NOTICE.md).
2//   A process this session started carries its id in CLAUDE_CODE_SESSION_ID, and so does every
3//   process that one starts, detached or not. The radar lists those that run a known kind of
4//   delegated work: a Codex run, a /codex:rescue job, CE's cross-model peer, a Pro review, a
5//   headless Claude. A label comes from the program a process runs, never from its command text.
6
7export type Proc = { pid: number; ppid: number; etimes: number; args: string }
8
9// One piece of delegated work as a look found it: the outermost process of its chain.
10export type Delegate = { pid: number; label: string; startedAt: number }
11
12// Whether a process carries one of the session's ids, judged once per process: keyed by pid,
13// with the start time it had then, so a reused pid is judged again.
14export type Verdicts = Map<number, { startedAt: number; isOurs: boolean }>
15
16// `ps x` is every process this user runs; 300 columns keep a script's name and its first
17// arguments and cut the several-kilobyte command lines a browser's processes carry.
18export const PS = ['ps', 'x', '-o', 'pid=,ppid=,etimes=,args=', '--cols', '300'] as const
19
20// A process younger than this at first sight is left for the next look: a quick status call
21// should not flash a row and a toast.
22const MIN_AGE_S = 3
23// The start time is derived from `etimes`, whole seconds, against the moment the look began; a
24// `ps` that starts late on a loaded host reads them later still. Two readings of one process's
25// start agree to within this when neither `ps` took longer than MAX_PS_MS (a second for the
26// rounding, the rest for the lateness). A pid reused within it would pass for the same process,
27// and reuse needs the whole pid space to wrap first.
28const SAME_START_MS = 5_000
29// A `ps` slower than this reads starts too late to match a row: the look fails and changes nothing.
30// Each command a look runs is killed at this too, so a `ps` or a grep hung on a process's
31// environment (one in uninterruptible sleep) fails the look rather than holding /radar.
32export const MAX_PS_MS = SAME_START_MS - 1_000
33
34export function sameStart(a: number, b: number) {
35  return Math.abs(a - b) <= SAME_START_MS
36}
37
38const INTERPRETERS = new Set(['bash', 'sh', 'dash', 'zsh', 'node', 'bun', 'python', 'python3'])
39const CODEX_RUNS: Record<string, string> = { exec: 'Codex exec', e: 'Codex exec', review: 'Codex review' }
40
41// The lines of `ps -o pid=,ppid=,etimes=,args=`; a line that does not parse is skipped.
42export function parsePs(stdout: string): Proc[] {
43  const procs: Proc[] = []
44  for (const line of stdout.split('\n')) {
45    const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s(.*)$/.exec(line)
46    if (m) procs.push({ pid: Number(m[1]), ppid: Number(m[2]), etimes: Number(m[3]), args: m[4]!.trim() })
47  }
48  return procs
49}
50
51const base = (word: string) => word.slice(word.lastIndexOf('/') + 1)
52
53// What delegated work a process runs, read off the program it runs: its first word, or the
54// script an interpreter runs. Null for anything else, a shell whose command merely mentions one
55// of these included.
56export function classify(args: string): string | null {
57  const words = args.trim().split(/\s+/)
58  let at = 0
59  if (INTERPRETERS.has(base(words[0] ?? ''))) {
60    at = 1
61    while (at < words.length && words[at]!.startsWith('-')) at++
62  }
63  const name = base(words[at] ?? '')
64  const rest = words.slice(at + 1)
65  if (name === 'codex' || name === 'codex.js') return CODEX_RUNS[rest[0] ?? ''] ?? null
66  if (name === 'codex-companion.mjs') return rest[0] === 'task' || rest[0] === 'task-worker' ? 'Codex rescue' : null
67  if (name === 'cross-model-adversarial-review.sh') return 'CE cross-model review'
68  if (name === 'oracle-review.sh') {
69    const pr = numberAfter(rest, '--pr')
70    return pr ? `Pro review #${pr}` : 'Pro review'
71  }
72  if (name === 'pro-gate-loop.sh') return /^\d+$/.test(rest[1] ?? '') ? `Pro gate #${rest[1]}` : 'Pro gate'
73  if (name === 'claude' && rest.some(w => w === '-p' || w === '--print')) return 'headless Claude'
74  return null
75}
76
77function numberAfter(words: string[], flag: string) {
78  const i = words.indexOf(flag)
79  const value = i >= 0 ? words[i + 1] : undefined
80  return value !== undefined && /^\d+$/.test(value) ? value : undefined
81}
82
83// The outermost of each chain: a listed process with a listed ancestor is left out, so a Pro
84// review shows once, not again for the Codex or browser driver it starts.
85export function outermost(procs: Proc[], listed: Set<number>): Proc[] {
86  const parent = new Map(procs.map(p => [p.pid, p.ppid]))
87  return procs.filter(p => {
88    if (!listed.has(p.pid)) return false
89    for (let up = parent.get(p.pid), hops = 0; up !== undefined && up > 1 && hops < 64; up = parent.get(up), hops++) {
90      if (listed.has(up)) return false
91    }
92    return true
93  })
94}
95
96// One look, in steps, with the two commands it needs run by register.tsx (a module may not hand
97// `$` to an imported function). `triage` labels the known kinds of work and names the processes
98// still to be judged, dropping the verdict of a reused pid.
99export function triage(procs: Proc[], at: number, verdicts: Verdicts) {
100  const labels = new Map<number, string>()
101  const unjudged: number[] = []
102  for (const p of procs) {
103    const label = classify(p.args)
104    if (label === null) continue
105    labels.set(p.pid, label)
106    const known = verdicts.get(p.pid)
107    if (known && sameStart(known.startedAt, at - p.etimes * 1000)) continue
108    verdicts.delete(p.pid) // a reused pid: the verdict was another process's
109    if (p.etimes >= MIN_AGE_S) unjudged.push(p.pid)
110  }
111  return { labels, unjudged }
112}
113
114// The grep that judges `pids`: for each environment it reads, a count of the session ids it holds,
115// named by its file (`/proc/7/environ:1`; -H, as grep names none when it reads one file); one it
116// cannot read is left out. The ids reach it on its input (`grepPatterns`), one per line, so they
117// are not in its command line. The environments themselves never enter the mod.
118export function grepArgv(pids: readonly number[]) {
119  return ['grep', '-HcszxF', '-f', '-', '--', ...pids.map(pid => `/proc/${pid}/environ`)]
120}
121
122// grep's input: one whole environment entry per id. With no ids grep would find none in every
123// environment and judge each one another session's, so the caller runs no grep then.
124export function grepPatterns(ids: readonly string[]) {
125  return ids.map(id => `CLAUDE_CODE_SESSION_ID=${id}\n`).join('')
126}
127
128// Records a verdict for each process grep read, from its count. One it did not read (gone, not
129// readable, or grep failed) gets none and is asked about again at the next look; grep's exit code
130// cannot tell those apart, so the counts are the answer.
131export function judge(counts: string, pids: readonly number[], procs: Proc[], at: number, verdicts: Verdicts) {
132  const read = new Map<number, boolean>()
133  for (const line of counts.split('\n')) {
134    const m = /^\/proc\/(\d+)\/environ:(\d+)$/.exec(line)
135    if (m) read.set(Number(m[1]), Number(m[2]) > 0)
136  }
137  const byPid = new Map(procs.map(p => [p.pid, p]))
138  for (const pid of pids) {
139    const p = byPid.get(pid)
140    const isOurs = read.get(pid)
141    if (p && isOurs !== undefined) verdicts.set(pid, { startedAt: at - p.etimes * 1000, isOurs })
142  }
143}
144
145// The delegated work running now: the outermost of each chain of the session's labelled
146// processes. Verdicts for processes that are gone are dropped.
147export function ours(procs: Proc[], labels: Map<number, string>, verdicts: Verdicts): Delegate[] {
148  const alive = new Set(procs.map(p => p.pid))
149  for (const pid of [...verdicts.keys()]) if (!alive.has(pid)) verdicts.delete(pid)
150  const listed = new Set([...labels.keys()].filter(pid => verdicts.get(pid)?.isOurs === true))
151  return outermost(procs, listed).map(p => ({ pid: p.pid, label: labels.get(p.pid)!, startedAt: verdicts.get(p.pid)!.startedAt }))
152}
153
154// The row a piece of delegated work belongs to: the existing row of the same process (its pid,
155// and its start to within SAME_START_MS), else a new one. The module's verdicts start over when
156// it reloads and the rows do not, and a start read again differs by a fraction of a second: an
157// id built from it alone would end the row of a process still running and add a second one.
158export function rowFor(rows: readonly { id: string; pid?: number; startedAt: number }[], d: Delegate) {
159  return rows.find(a => a.pid === d.pid && sameStart(a.startedAt, d.startedAt))?.id ?? `pid:${d.pid}:${d.startedAt}`
160}
161
162// Whether a row's process still runs: its pid is listed with the same start. A row ends only when
163// this is false, never for want of a verdict (a reload starts the verdicts over, and an
164// environment can turn unreadable), so a process still running keeps its row.
165export function stillRuns(procs: readonly Proc[], at: number, row: { pid: number; startedAt: number }) {
166  return procs.some(p => p.pid === row.pid && sameStart(at - p.etimes * 1000, row.startedAt))
167}
168
types/index.d.ts 26 lines
1export type RadarAgent = {
2  id: string
3  description: string
4  type: string
5  // `ended`: delegated work whose process is gone; how it went is not the radar's to know.
6  status: 'running' | 'done' | 'failed' | 'ended'
7  startedAt: number
8  endedAt?: number
9  tools: number
10  last: string
11  // Taken from the engine's own list after it had started: startedAt is the adoption time.
12  adopted?: boolean
13  // Delegated work outside the session (../hooks/delegates.ts): its process, and the directory
14  // it runs in when that could be read.
15  pid?: number
16  cwd?: string
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    // `sessions`: every session id the conversation has had in this process (a resume starts it
22    // over), so work started before a /clear stays in view.
23    'agent-radar': { agents: RadarAgent[]; selected: string | null; now: number; seen: string[]; sessions: string[] }
24  }
25}
26