/control: a live pane of every background agent, grouped by what it waits on (needs input, plan validation, draft ready, working, waiting review, done)

hooks/register.tsx 119 lines1// /control: a live pane of every background agent, grouped by what it waits on.
2//
3// The data is `inbox board` (nu/shell/inbox.nu): `claude agents --json`, the watcher's waiting.json and the
4// coordinators' state files. Nothing runs until /control opens the pane; then it refreshes every 15 s, keeps
5// a summary in the status line, and toasts an agent that starts waiting on you. Pressing an agent brings it
6// up in tmux (`inbox attach`); `o` opens every agent waiting on you (`inbox open`), `r` refreshes.
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import type { Board } from '../types'
11import { COLORS, ON_ME, age, arrivals, grouped, parseBoard, splitDetail, summary, urlLabel } from './board'
12
13const PANE = 'control'
14const EVERY_MS = 15_000
15const board = atom({ plugin: 'control-center', key: 'board' } as const, { agents: [], updatedAt: 0, error: null } as Board)
16
17const inbox = (cmd: string) => ['nu', '--no-config-file', '-c', `use ~/.claude/nu/shell/inbox.nu; ${cmd}`]
18
19let timer: { cancel: () => void } | null = null
20let refreshing = false
21
22async function refresh($: EngineInterface) {
23 if (refreshing) return
24 refreshing = true
25 try {
26 const run = await $.process.run(inbox('inbox board | to json'), { timeoutMs: 20_000 })
27 const now = await $.clock.now()
28 if (run.exitCode !== 0) {
29 await update($, board, b => ({ ...b, updatedAt: now, error: run.stderr.trim().split('\n').at(-1) ?? 'inbox board failed' }))
30 return
31 }
32 const agents = parseBoard(run.stdout)
33 const before = (await read($, board)).agents
34 await update($, board, () => ({ agents, updatedAt: now, error: null }))
35 $.ui.status(summary(agents))
36 for (const a of arrivals(before, agents)) $.ui.toast(`${a.label || a.name || a.id} · ${a.next || a.waiting}`)
37 } catch (err) {
38 const now = await $.clock.now()
39 await update($, board, b => ({ ...b, updatedAt: now, error: String(err) }))
40 } finally {
41 refreshing = false
42 }
43}
44
45async function attach($: EngineInterface, id: string) {
46 if (!/^[A-Za-z0-9-]+$/.test(id)) return
47 const run = await $.process.run(inbox(`inbox attach ${id} | to json`))
48 if (run.exitCode !== 0) $.ui.toast(`Not in tmux? Run: claude attach ${id}`)
49}
50
51async function openAll($: EngineInterface) {
52 const run = await $.process.run(inbox('inbox open'), { timeoutMs: 60_000 })
53 $.ui.toast(run.exitCode === 0 ? run.stdout.trim().split('\n').at(-1) ?? 'Opened' : 'inbox open needs tmux')
54}
55
56export const register: Register = on => {
57 on('session.start', async ($, e, next) => {
58 await $.command.register({
59 name: 'control',
60 description: 'Control center: every background agent, grouped by what it waits on',
61 })
62
63 return next(e)
64 })
65
66 on('command.run', { command: 'control' }, async $ => {
67 await $.ui.open({ id: PANE, title: 'Agents', focus: true })
68 if (timer === null) timer = $.clock.every(EVERY_MS, () => void refresh($))
69 await refresh($)
70
71 return { text: 'Control center opened: press an agent to bring it up in tmux, o opens everything on you, r refreshes.' }
72 })
73
74 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
75 const { Box, Text, Button, Link } = $.ui.resolve(e)
76 const b = await read($, board)
77 const now = await $.clock.now()
78 const ago = b.updatedAt === 0 ? 'loading…' : `updated ${Math.round((now - b.updatedAt) / 1000)}s ago`
79
80 return (
81 <Box flexDirection="column">
82 <Box flexDirection="row" gap={1}>
83 <Text bold>{summary(b.agents)}</Text>
84 <Text dimColor>{ago}</Text>
85 </Box>
86 <Box flexDirection="row" gap={1}>
87 <Button key="open-all" label="Open all on me" hotkey="o" onPress={() => void openAll($)} />
88 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => void refresh($)} />
89 </Box>
90 {b.error !== null && <Text color="red">{b.error}</Text>}
91 {grouped(b.agents).map(g => (
92 <Box key={g.group} flexDirection="column" marginTop={1}>
93 <Text bold color={COLORS[g.group]} dimColor={COLORS[g.group] === undefined}>
94 {g.group} ({g.agents.length})
95 </Text>
96 {g.agents.map(a => {
97 const d = splitDetail(a.detail)
98 const next = a.next || d.text
99 return (
100 <Box key={a.id} flexDirection="column">
101 <Button key={a.id} onPress={() => void attach($, a.id)}>
102 {a.label || a.name || a.id} <Text dimColor>{age(a.started, now)}</Text>
103 </Button>
104 {(next !== '' || d.urls.length > 0) && (
105 <Box flexDirection="row" gap={1} marginLeft={2}>
106 {next !== '' && <Text dimColor={!ON_ME.includes(a.waiting)} wrap="truncate-end">→ {next}</Text>}
107 {d.urls.map(u => <Link key={u} href={u} label={urlLabel(u)} />)}
108 </Box>
109 )}
110 </Box>
111 )
112 })}
113 </Box>
114 ))}
115 </Box>
116 )
117 })
118}
119hooks/board.ts 83 lines1// The control center's logic, without the engine: what `inbox board` printed, grouped and summarised.
2import type { Agent } from '../types'
3
4/** The groups, in the order drawn: the waits on the owner, then the rest (nu/shell/inbox.nu GROUPS) */
5export const GROUPS = ['needs input', 'needs plan validation', 'draft ready', 'working', 'waiting review', 'done'] as const
6
7/** The groups waiting on the owner */
8export const ON_ME: readonly string[] = GROUPS.slice(0, 3)
9
10const SHORT: Record<string, string> = {
11 'needs input': 'input',
12 'needs plan validation': 'plan',
13 'draft ready': 'draft',
14 working: 'working',
15 'waiting review': 'review',
16 done: 'done',
17}
18
19export const COLORS: Record<string, string> = {
20 'needs input': 'red',
21 'needs plan validation': 'yellow',
22 'draft ready': 'cyan',
23 working: 'green',
24}
25
26/** `inbox board | to json` → agents; a row missing an id or a group is dropped */
27export function parseBoard(stdout: string): Agent[] {
28 const rows: unknown = JSON.parse(stdout.trim() === '' ? '[]' : stdout)
29 if (!Array.isArray(rows)) return []
30 return rows.flatMap((r: any) =>
31 typeof r?.id === 'string' && typeof r?.waiting === 'string'
32 ? [{
33 id: r.id, name: String(r.name ?? ''), label: String(r.label ?? r.name ?? ''), waiting: r.waiting,
34 detail: String(r.detail ?? ''), next: String(r.next ?? ''), cwd: String(r.cwd ?? ''), started: String(r.started ?? ''),
35 }]
36 : [],
37 )
38}
39
40/** Agents by group, in GROUPS order, empty groups left out (an unknown group goes last) */
41export function grouped(agents: readonly Agent[]): { group: string; agents: Agent[] }[] {
42 const order = (g: string) => (GROUPS as readonly string[]).indexOf(g) === -1 ? GROUPS.length : (GROUPS as readonly string[]).indexOf(g)
43 const groups = [...new Set(agents.map(a => a.waiting))].sort((a, b) => order(a) - order(b))
44 return groups.map(group => ({ group, agents: agents.filter(a => a.waiting === group) }))
45}
46
47/** The status line: what waits on the owner, e.g. "agents: 2 input · 1 plan · 3 draft" */
48export function summary(agents: readonly Agent[]): string {
49 const parts = ON_ME.flatMap(g => {
50 const n = agents.filter(a => a.waiting === g).length
51 return n === 0 ? [] : [`${n} ${SHORT[g]}`]
52 })
53 const working = agents.filter(a => a.waiting === 'working').length
54 return parts.length === 0 ? `agents: nothing on you · ${working} working` : `agents: ${parts.join(' · ')}`
55}
56
57/** Agents that moved into a group on the owner since the previous board */
58export function arrivals(before: readonly Agent[], after: readonly Agent[]): Agent[] {
59 const was = new Map(before.map(a => [a.id, a.waiting]))
60 return after.filter(a => ON_ME.includes(a.waiting) && was.get(a.id) !== a.waiting && before.length > 0)
61}
62
63/** The URLs in a detail, and the text around them */
64export function splitDetail(detail: string): { text: string; urls: string[] } {
65 const words = detail.split(/\s+/).filter(w => w !== '')
66 const urls = words.filter(w => /^https?:\/\//.test(w))
67 return { text: words.filter(w => !urls.includes(w)).join(' '), urls }
68}
69
70/** A short label for a PR or issue URL: "#3994", else the URL */
71export function urlLabel(url: string): string {
72 const m = url.match(/\/pull\/(\d+)/)
73 return m ? `#${m[1]}` : url
74}
75
76/** "5m", "2h", "3d" since a nushell datetime string */
77export function age(started: string, now: number): string {
78 const t = Date.parse(started)
79 if (Number.isNaN(t)) return ''
80 const m = Math.max(0, Math.round((now - t) / 60000))
81 return m < 60 ? `${m}m` : m < 60 * 24 ? `${Math.round(m / 60)}h` : `${Math.round(m / 1440)}d`
82}
83types/index.d.ts 30 lines1/** One background session as `inbox board` lists it */
2export type Agent = {
3 id: string
4 name: string
5 /** the ticket and its short title, "PROD-1 · Fix login" (the name, or the name and the state file's title) */
6 label: string
7 /** one of GROUPS */
8 waiting: string
9 detail: string
10 /** what to do about it, one line: an instruction when it waits on the owner, else its phase */
11 next: string
12 cwd: string
13 /** when it started, as nushell writes a datetime */
14 started: string
15}
16
17export type Board = {
18 agents: Agent[]
19 /** epoch ms of the last refresh, 0 before the first */
20 updatedAt: number
21 /** why the last refresh failed, or null */
22 error: string | null
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 'control-center': { board: Board }
28 }
29}
30