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

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):
-->
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.
<!--
-->
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.
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.
./setupclaude agents in any repo. That is the agents view, and it is how 90% of my Claude Code use happens.git-guard refuse it and name the branch-and-pull-request route. The refusal comes from a hook, not a model call./denial-triage. It turns the calls the guards refused into permission fixes, each behind its own approval, so the prompts get rarer every week./memory-dream:dream on your busiest project. memory-dream is one of the default plugins.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 ithooks/: 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 upstreamskills-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 themharnesses/: 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.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.
./setuppapercut: 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./hov-setuppro-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.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.~/.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.
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.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.dcg, the third-party destructive-command guard this harness runs alongside its own hooks: https://github.com/Dicklesworthstone/destructive_command_guardMIT. Copyright (c) 2026 StartupBros LLC. Built by Will Mitchell for House of Vibe.
hooks/register.tsx 433 lines1// 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}
433hooks/delegates.ts 168 lines1// 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}
168types/index.d.ts 26 lines1export 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