Air-traffic control for subagents: a live pane listing every agent, what each one is doing now, and controls to message or stop one. Open it with /atc.

Air-traffic control for subagents, inside Claude Code.
3 running · 2 done
main: ✓ Agent billing api · 4s ago
────────────────────────────────────────
● 1: billing api running 2m Edit billing/subscriptions.ts
›● 2: webhook tests running 40s Bash bun test webhooks
◐ 3: security audit quiet 2m Bash pnpm test auth
✓ 4: research done in 38s
webhook tests
general-purpose · opus-5-5 · started 14:32:01 · 18 calls · 41k tok
14:32:04 ✓ Read billing/service.ts
14:32:09 ✓ Edit billing/subscriptions.ts
14:32:16 … Bash bun test webhooks
[ Message this agent… Send ]
[ Stop ] [ Back ]
/atc opens the pane and gives it the keyboard. Esc hands the keyboard back. /atc close closes it.1–9 picks an agent. Its trail, model, call count and tokens show below the list.TaskStop, with the teammate's own id).agent-atc: N running whenever any are running. A waiting agent (on its own background work, a plan approval or a child agent) counts as running; an idle teammate doesn't.Setting autoOpen (default on) opens the pane by itself when the first subagent starts. This only happens on terminals 144+ columns wide. Closing it yourself keeps it closed until you run /atc.
Install: see ../README.md.
hooks/register.tsx 529 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register } from 'claude-code'
3
4import type { AtcAgent, AtcMain, AtcStep, AtcStepState } from '../types'
5
6const PANE = 'agent-atc'
7const TITLE = 'Agents'
8const POLL_MS = 3_000
9// A running agent with no tool call for this long is drawn as quiet: stuck on
10// a permission prompt, a long command, or thinking hard.
11const QUIET_MS = 90_000
12// Only the last few are drawn; every subagent tool call copies what is kept.
13const STEPS_KEPT = 12
14const AGENTS_KEPT = 60
15
16type Agents = Record<string, AtcAgent>
17type Row = { agent: AtcAgent; depth: number }
18
19const agents = atom({ plugin: 'agent-atc', key: 'agents' } as const, {})
20const selected = atom({ plugin: 'agent-atc', key: 'selected' } as const, null)
21const now = atom({ plugin: 'agent-atc', key: 'now' } as const, 0)
22const main = atom({ plugin: 'agent-atc', key: 'main' } as const, { lastAt: 0, calls: 0 })
23
24// The task statuses that mean an agent has stopped for good. Any other one is live:
25// running, pending, or waiting on a background shell of its own.
26const ENDED = new Set(['completed', 'failed', 'killed', 'stopped', 'cancelled', 'canceled', 'error'])
27const isLive = (status: string): boolean => !ENDED.has(status)
28// Live and doing something: an idle teammate is waiting for a message, not running.
29const isBusy = (status: string): boolean => isLive(status) && status !== 'idle'
30
31const oneLine = (text: string, max = 80): string => {
32 const line = text.replace(/\s+/g, ' ').trim()
33 return line.length > max ? `${line.slice(0, max - 1)}…` : line
34}
35
36const shortPath = (path: string): string => path.split('/').filter(Boolean).slice(-2).join('/')
37
38const DETAIL_KEYS = ['command', 'file_path', 'notebook_path', 'pattern', 'description', 'url', 'query', 'skill', 'path', 'prompt']
39
40// What a trail line says about a call: the one input field a person would
41// recognise it by, whatever the tool.
42const detailOf = (input: Record<string, unknown>): string => {
43 for (const key of DETAIL_KEYS) {
44 const value = input[key]
45 if (typeof value === 'string' && value.trim() !== '') {
46 return key.endsWith('path') ? shortPath(value) : oneLine(value)
47 }
48 }
49 return ''
50}
51
52const ago = (ms: number): string => {
53 const seconds = Math.max(0, Math.round(ms / 1000))
54 if (seconds < 60) return `${seconds}s`
55 const minutes = Math.floor(seconds / 60)
56 if (minutes < 60) return `${minutes}m`
57 return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`
58}
59
60const clockTime = (at: number): string => new Date(at).toTimeString().slice(0, 8)
61
62const kTokens = (tokens: number): string =>
63 tokens < 1000 ? `${tokens} tok` : `${Math.round(tokens / 1000)}k tok`
64
65const shortModel = (model: string | undefined): string | undefined => model?.replace(/^claude-/, '')
66
67const blank = (id: string, at: number): AtcAgent => ({
68 id,
69 label: id.slice(0, 8),
70 type: 'agent',
71 status: 'running',
72 startedAt: at,
73 lastAt: at,
74 calls: 0,
75 errors: 0,
76 steps: [],
77})
78
79// Keeps the newest agents, and every live one however old.
80const capped = (all: Agents): Agents => {
81 const list = Object.values(all)
82 if (list.length <= AGENTS_KEPT) return all
83 const kept = [...list]
84 .sort((a, b) => b.startedAt - a.startedAt)
85 .filter((agent, i) => i < AGENTS_KEPT || isLive(agent.status))
86 return Object.fromEntries(kept.map(agent => [agent.id, agent]))
87}
88
89const withStep = (all: Agents, id: string, step: AtcStep): Agents => {
90 const agent = all[id] ?? blank(id, step.at)
91 return capped({
92 ...all,
93 [id]: {
94 ...agent,
95 status: isLive(agent.status) ? agent.status : 'running',
96 lastAt: step.at,
97 calls: agent.calls + 1,
98 steps: [...agent.steps, step].slice(-STEPS_KEPT),
99 },
100 })
101}
102
103const withStepEnded = (all: Agents, id: string, stepId: string, state: AtcStepState, at: number): Agents => {
104 const agent = all[id]
105 if (agent === undefined) return all
106 return {
107 ...all,
108 [id]: {
109 ...agent,
110 lastAt: at,
111 errors: agent.errors + (state === 'err' ? 1 : 0),
112 steps: agent.steps.map(step => (step.id === stepId ? { ...step, state } : step)),
113 },
114 }
115}
116
117// The engine's list is the truth for status (it alone knows `killed`) and
118// for agents started before this module loaded. Undefined when nothing moved.
119const reconciled = (all: Agents, listed: readonly AgentInfo[], at: number): Agents | undefined => {
120 let hasMoved = false
121 const next = { ...all }
122 for (const info of listed) {
123 const agent = next[info.id]
124 // Past the cap, an ended agent the map dropped stays dropped, or every poll would
125 // re-add it as new and push out another.
126 if (agent === undefined && !isLive(info.status) && Object.keys(next).length >= AGENTS_KEPT) continue
127 if (agent === undefined) {
128 next[info.id] = {
129 ...blank(info.id, at),
130 label: oneLine(info.description, 60) || info.id.slice(0, 8),
131 type: info.type,
132 name: info.name,
133 teammateId: info.teammateId,
134 parentId: info.parentId,
135 status: info.status,
136 }
137 hasMoved = true
138 } else if (agent.type === 'agent' && info.type !== 'agent') {
139 // First seen through a tool call, before the list: fill in what it really is.
140 next[info.id] = {
141 ...agent,
142 label: oneLine(info.description, 60) || agent.label,
143 type: info.type,
144 name: agent.name ?? info.name,
145 teammateId: agent.teammateId ?? info.teammateId,
146 parentId: agent.parentId ?? info.parentId,
147 status: info.status,
148 }
149 hasMoved = true
150 } else if (agent.status !== info.status) {
151 next[info.id] = {
152 ...agent,
153 status: info.status,
154 endedAt: isLive(info.status) ? undefined : (agent.endedAt ?? at),
155 }
156 hasMoved = true
157 }
158 }
159 return hasMoved ? capped(next) : undefined
160}
161
162const treeOrder = (list: readonly AtcAgent[]): Row[] => {
163 const ids = new Set(list.map(agent => agent.id))
164 const children = new Map<string | undefined, AtcAgent[]>()
165 for (const agent of list) {
166 const parent = agent.parentId !== undefined && ids.has(agent.parentId) ? agent.parentId : undefined
167 children.set(parent, [...(children.get(parent) ?? []), agent])
168 }
169 const rows: Row[] = []
170 const walk = (parent: string | undefined, depth: number): void => {
171 if (depth > 8) return
172 for (const agent of [...(children.get(parent) ?? [])].sort((a, b) => a.startedAt - b.startedAt)) {
173 rows.push({ agent, depth })
174 walk(agent.id, depth + 1)
175 }
176 }
177 walk(undefined, 0)
178 return rows
179}
180
181const look = (agent: AtcAgent, at: number): { glyph: string; color: string; word: string } => {
182 if (agent.status === 'waiting') {
183 return { glyph: '◐', color: 'cyan', word: `waiting ${ago(at - agent.lastAt)}` }
184 }
185 if (agent.status === 'idle') {
186 return { glyph: '○', color: 'cyan', word: `idle ${ago(at - agent.lastAt)}` }
187 }
188 if (isLive(agent.status)) {
189 const idle = at - agent.lastAt
190 return idle > QUIET_MS
191 ? { glyph: '◐', color: 'yellow', word: `quiet ${ago(idle)}` }
192 : { glyph: '●', color: 'green', word: `running ${ago(at - agent.startedAt)}` }
193 }
194 const took = ago((agent.endedAt ?? agent.lastAt) - agent.startedAt)
195 if (agent.status === 'completed') return { glyph: '✓', color: 'gray', word: `done in ${took}` }
196 if (agent.status === 'failed') return { glyph: '✗', color: 'red', word: `failed after ${took}` }
197 return { glyph: '■', color: 'red', word: `${agent.status} after ${took}` }
198}
199
200const STEP_MARK: Record<AtcStepState, string> = { run: '…', ok: '✓', err: '✗', deny: '⊘' }
201
202const stepLine = (step: AtcStep): string => `${step.tool} ${step.detail}`.trim()
203
204let shownStatus: string | undefined
205
206const showStatus = ($: EngineInterface, all: Agents): void => {
207 const live = Object.values(all).filter(agent => isBusy(agent.status)).length
208 const text = live > 0 ? `${live} running · /atc` : undefined
209 if (text !== shownStatus) {
210 shownStatus = text
211 $.ui.status(text)
212 }
213}
214
215const sync = async ($: EngineInterface): Promise<void> => {
216 const [listed, at, all] = await Promise.all([$.agent.list(), $.clock.now(), read($, agents)])
217 const merged = reconciled(all, listed, at)
218 if (merged !== undefined) {
219 await update($, agents, current => reconciled(current, listed, at) ?? current)
220 }
221 const latest = merged ?? all
222 showStatus($, latest)
223 // Redraws the pane while something runs, so ages and quiet marks move.
224 if (Object.values(latest).some(agent => isBusy(agent.status))) {
225 await update($, now, () => at)
226 }
227}
228
229export const register: Register = (on, options) => {
230 const isAutoOpen = options['autoOpen'] !== false
231 let isDismissed = false
232 let hasAutoOpened = false
233 // Set from any terminal drawing: whether a pane would dock as a sidebar. On the
234 // main screen it opens inline above the prompt, which is no place to open unasked.
235 let isDocking = false
236
237 on('session.start', async ($, e, next) => {
238 await $.command.register({
239 name: 'atc',
240 description: 'Open the agent control pane (/atc close to close it)',
241 })
242 $.clock.every(POLL_MS, () => {
243 sync($).catch(() => undefined)
244 })
245 return next(e)
246 })
247
248 on('session.end', async ($, e, next) => {
249 if (e.reason === 'clear') {
250 await update($, agents, () => ({}))
251 await update($, selected, () => null)
252 await update($, main, () => ({ lastAt: 0, calls: 0 }))
253 showStatus($, {})
254 }
255 return next(e)
256 })
257
258 on('command.run', { command: 'atc' }, async ($, e) => {
259 if (e.args.trim() === 'close') {
260 isDismissed = true
261 await $.ui.close({ id: PANE })
262 return { text: 'Agent control closed.' }
263 }
264 isDismissed = false
265 const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true })
266 return {
267 text: opened.isPlaced
268 ? 'Agent control open. Esc gives the keyboard back; /atc close closes it.'
269 : `Agent control is waiting for room: ${opened.reason}`,
270 }
271 })
272
273 on('ui.close', ($, e, next) => {
274 if (e.id === PANE && e.origin.kind === 'person') isDismissed = true
275 return next(e)
276 })
277
278 on('agent.spawn', async ($, e, next) => {
279 const ran = await next(e)
280 const id = ran.agentId
281 if (id === undefined) return ran
282
283 const at = await $.clock.now()
284 await update($, agents, all => {
285 const agent = all[id] ?? blank(id, at)
286 return capped({
287 ...all,
288 [id]: {
289 ...agent,
290 label: oneLine(e.description, 60) || agent.label,
291 type: e.subagentType,
292 model: ran.model,
293 name: e.name,
294 teammateId: ran.teammateId,
295 parentId: e.parentAgentId,
296 status: 'running',
297 startedAt: Math.min(agent.startedAt, at),
298 },
299 })
300 })
301 showStatus($, await read($, agents))
302
303 // Opened unasked, the engine seats it only on a wide terminal.
304 if (isAutoOpen && isDocking && !isDismissed && !hasAutoOpened) {
305 hasAutoOpened = true
306 $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
307 }
308 return ran
309 })
310
311 on('tool.call', async ($, e, next) => {
312 const at = await $.clock.now()
313 const step: AtcStep = {
314 id: e.tool_use_id ?? `${at}-${String(e.tool)}`,
315 at,
316 tool: String(e.tool),
317 detail: detailOf(e as unknown as Record<string, unknown>),
318 state: 'run',
319 }
320 const agentId = e.agentId
321 if (agentId === undefined) {
322 await update($, main, top => ({ lastAt: at, calls: top.calls + 1, step }))
323 } else {
324 await update($, agents, all => withStep(all, agentId, step))
325 }
326
327 let state: AtcStepState = 'err'
328 try {
329 const ran = await next(e)
330 state = ran.deny !== undefined ? 'deny' : ran.isError === true ? 'err' : 'ok'
331 return ran
332 } finally {
333 const end = await $.clock.now()
334 if (agentId === undefined) {
335 await update($, main, top =>
336 top.step?.id === step.id ? { ...top, lastAt: end, step: { ...step, state } } : top,
337 )
338 } else {
339 await update($, agents, all => withStepEnded(all, agentId, step.id, state, end))
340 }
341 }
342 })
343
344 on('turn.complete', async ($, e, next) => {
345 const agentId = e.agentId
346 if (agentId !== undefined) {
347 const at = await $.clock.now()
348 const usage = e.usage
349 const tokens =
350 usage === undefined
351 ? undefined
352 : usage.input_tokens + usage.output_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
353 const status = e.reason === 'answer' ? 'completed' : e.reason === 'aborted' ? 'stopped' : 'failed'
354 await update($, agents, all => {
355 const agent = all[agentId]
356 if (agent === undefined) return all
357 return {
358 ...all,
359 [agentId]: {
360 ...agent,
361 status,
362 endedAt: at,
363 lastAt: at,
364 answer: e.answer.trim() === '' ? agent.answer : oneLine(e.answer, 200),
365 tokens: tokens === undefined ? agent.tokens : (agent.tokens ?? 0) + tokens,
366 },
367 }
368 })
369 showStatus($, await read($, agents))
370 }
371 return next(e)
372 })
373
374 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
375 if (e.surface === 'terminal') isDocking = e.viewport?.isFullscreen === true
376 return next(e)
377 })
378
379 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
380 const ui = $.ui.resolve(e)
381 const { Box, Text, Button } = ui
382 const Input = 'Input' in ui ? ui.Input : undefined
383
384 const [all, picked, , top, at] = await Promise.all([
385 read($, agents),
386 read($, selected),
387 read($, now),
388 read($, main),
389 $.clock.now(),
390 ])
391 const width = Math.max(30, e.props.bodyColumns)
392 const rows = treeOrder(Object.values(all))
393 const live = rows.filter(row => isBusy(row.agent.status)).length
394 const done = rows.filter(row => row.agent.status === 'completed').length
395 const other = rows.filter(row => !isLive(row.agent.status) && row.agent.status !== 'completed').length
396 const focusId = picked ?? e.props.view.agentId ?? null
397 const focus = focusId === null ? undefined : all[focusId]
398
399 const pick = (id: string) => () => {
400 void update($, selected, current => (current === id ? null : id))
401 }
402
403 const send = (agent: AtcAgent) => async (text: string) => {
404 const message = text.trim()
405 if (message === '') return
406 const sent = await $.session.send({ to: { agentId: agent.id }, text: message })
407 $.ui.toast(sent.isDelivered ? `Sent to ${agent.label}` : `Not sent: ${sent.reason}`)
408 }
409
410 const stop = (agent: AtcAgent) => async () => {
411 const ran = await $.tool.call({
412 tool: 'TaskStop',
413 task_id: agent.teammateId ?? agent.id,
414 consent: `The user pressed "Stop" on agent ${agent.label} in the agent control pane.`,
415 })
416 if (ran.deny !== undefined) $.ui.toast(`Not stopped: ${ran.deny}`)
417 else if (ran.isError === true) $.ui.toast(`Not stopped: ${ran.text}`)
418 else $.ui.toast(`Stopped ${agent.label}`)
419 }
420
421 return (
422 <Box flexDirection="column" width={width}>
423 <Text bold wrap="truncate-end">
424 {rows.length === 0
425 ? 'No subagents yet'
426 : `${live} running · ${done} done${other > 0 ? ` · ${other} stopped or failed` : ''}`}
427 </Text>
428 {top.step !== undefined && (
429 <Text dimColor wrap="truncate-end">
430 main: {STEP_MARK[top.step.state]} {stepLine(top.step)} · {ago(at - top.lastAt)} ago
431 </Text>
432 )}
433 <Text dimColor>{'─'.repeat(width)}</Text>
434
435 {rows.length === 0 && (
436 <Text dimColor wrap="wrap">
437 Each subagent shows here the moment it starts, with what it is doing now.
438 </Text>
439 )}
440 {rows.map(({ agent, depth }, i) => {
441 const { glyph, color, word } = look(agent, at)
442 const last = agent.steps[agent.steps.length - 1]
443 return (
444 <Box key={`row-${agent.id}`} flexDirection="row">
445 <Text color={color}>
446 {' '.repeat(depth)}
447 {agent.id === focusId ? '›' : ' '}
448 {glyph}{' '}
449 </Text>
450 <Button
451 key={`pick-${agent.id}`}
452 plain
453 hotkey={i < 9 ? String(i + 1) : undefined}
454 label={agent.name ?? agent.label}
455 onPress={pick(agent.id)}
456 />
457 <Text dimColor wrap="truncate-end">
458 {' '}
459 {word}
460 {isLive(agent.status) && last !== undefined ? ` ${stepLine(last)}` : ''}
461 </Text>
462 </Box>
463 )
464 })}
465
466 {focus !== undefined && (
467 <Box flexDirection="column" marginTop={1}>
468 <Text bold wrap="truncate-end">
469 {focus.name ?? focus.label}
470 </Text>
471 <Text dimColor wrap="truncate-end">
472 {[
473 focus.type,
474 shortModel(focus.model),
475 `started ${clockTime(focus.startedAt)}`,
476 `${focus.calls} calls`,
477 focus.errors > 0 ? `${focus.errors} errors` : undefined,
478 focus.tokens !== undefined ? kTokens(focus.tokens) : undefined,
479 ]
480 .filter(part => part !== undefined)
481 .join(' · ')}
482 </Text>
483 {focus.name !== undefined && (
484 <Text dimColor wrap="truncate-end">
485 {focus.label}
486 </Text>
487 )}
488 {focus.steps.map(step => (
489 <Box key={`step-${step.id}`} flexDirection="row">
490 <Text dimColor>{clockTime(step.at)} </Text>
491 <Text color={step.state === 'err' || step.state === 'deny' ? 'red' : undefined}>
492 {STEP_MARK[step.state]}{' '}
493 </Text>
494 <Text wrap="truncate-end">{stepLine(step)}</Text>
495 </Box>
496 ))}
497 {focus.steps.length === 0 && <Text dimColor>No tool calls yet.</Text>}
498 {focus.answer !== undefined && !isLive(focus.status) && (
499 <Text wrap="wrap">Answer: {focus.answer}</Text>
500 )}
501 {Input !== undefined && (
502 <Input
503 key={`msg-${focus.id}`}
504 placeholder={isLive(focus.status) ? 'Message this agent…' : 'Ask it a follow-up…'}
505 submitLabel="Send"
506 onSubmit={send(focus)}
507 />
508 )}
509 <Box flexDirection="row" gap={1}>
510 {isLive(focus.status) && (
511 <Button key="stop" label="Stop" onPress={stop(focus)} />
512 )}
513 {picked !== null && (
514 <Button key="back" label="Back" hotkey="b" onPress={() => void update($, selected, () => null)} />
515 )}
516 </Box>
517 </Box>
518 )}
519
520 {rows.length > 0 && (
521 <Text dimColor wrap="truncate-end">
522 {'\n'}1-9 picks an agent · Tab walks the controls · Esc gives the keyboard back
523 </Text>
524 )}
525 </Box>
526 )
527 })
528}
529types/index.d.ts 55 lines1// agent-atc's state contract: every value the pane draws from, held by the
2// host in $.state so a hot reload keeps it.
3
4export type AtcStepState = 'run' | 'ok' | 'err' | 'deny'
5
6/** One tool call an agent made, as the trail shows it. */
7export type AtcStep = {
8 id: string
9 at: number
10 tool: string
11 detail: string
12 state: AtcStepState
13}
14
15/** One subagent of the session. */
16export type AtcAgent = {
17 id: string
18 label: string
19 type: string
20 model?: string
21 name?: string
22 /** What TaskStop takes for a teammate, whose loop id it refuses. */
23 teammateId?: string
24 parentId?: string
25 /** The engine's task status (`running`, `completed`, `failed`, `killed`, ...). */
26 status: string
27 startedAt: number
28 lastAt: number
29 endedAt?: number
30 calls: number
31 errors: number
32 tokens?: number
33 /** First line of the agent's last answer. */
34 answer?: string
35 steps: AtcStep[]
36}
37
38/** The main loop's latest tool call, for the header line. */
39export type AtcMain = {
40 lastAt: number
41 calls: number
42 step?: AtcStep
43}
44
45declare module 'claude-code' {
46 interface PluginState {
47 'agent-atc': {
48 agents: Record<string, AtcAgent>
49 selected: string | null
50 now: number
51 main: AtcMain
52 }
53 }
54}
55