Kanban pane of the sub-agents in this session: Plan / Build / Review / Human / Done, one clickable card per agent.

A live Kanban pane of the sub-agents in the current session, grouped by the stage of the software-factory harness they work in:
PLAN 1 BUILD 2 REVIEW 1 HUMAN 1 DONE 3
◐ planner · ◐ general-purpose · ◐ code-reviewer · ⚑ critic · slice 2 ✓ Explore · find …
slice 4 ▶ slice 3 tests slice 2 ✓ general-purpose …
◐ backend-developer … ✗ security-reviewer …
PLAN (planner, Plan, Explore, grilling), BUILD (everything else that is running), REVIEW (code-reviewer, critic, security-reviewer, software-design-expert-review, validators), HUMAN (an agent with an AskUserQuestion dialog open, or one that finished with [AMBIGUITY] lines in its answer), DONE (finished: ✓ answered, ✗ failed, aborted or killed).▶ marks the card whose transcript the person has on screen through the engine's tasks list./factory-board opens (and focuses) the pane; it also opens by itself at session start where the terminal is wide enough to dock it..claude-plugin/plugin.json manifest (name, version, the state contract)
hooks/hooks.json names the one hooks module
hooks/register.tsx the hooks: agent.spawn, turn.complete, tool.call(AskUserQuestion), ui.render(Pane)
hooks/register.test.tsx claude plugin test
types/index.d.ts Card, Stage and the $.state contract
State lives in $.state (cards, asking, selected), so a hot reload keeps the board; a $.clock.every sync folds $.agent.list() in every 5 s so killed or background agents the hooks never saw still show their engine status.
claude --plugin-dir "$(pwd)/mods/factory-board"
Or copy the folder into the session's ~/.claude/dev-mods/<session>/ folder while the plugin-authoring skill is loaded and accept "Enable hot reloading".
claude plugin validate mods/factory-board
claude plugin test mods/factory-boardhooks/register.tsx 263 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register, SessionMessage } from 'claude-code'
3
4import type { Card, Stage } from '../types'
5
6const PANE = 'factory-board'
7const MAIN = 'main'
8const SYNC_MS = 5000
9const MAX_CARDS = 100
10const TAIL_ROWS = 6
11
12const cards = atom({ plugin: 'factory-board', key: 'cards' } as const, [] as Card[])
13const asking = atom({ plugin: 'factory-board', key: 'asking' } as const, [] as string[])
14const selected = atom({ plugin: 'factory-board', key: 'selected' } as const, null as string | null)
15
16const COLUMNS: ReadonlyArray<{ stage: Stage; title: string }> = [
17 { stage: 'plan', title: 'PLAN' },
18 { stage: 'build', title: 'BUILD' },
19 { stage: 'review', title: 'REVIEW' },
20 { stage: 'human', title: 'HUMAN' },
21 { stage: 'done', title: 'DONE' },
22]
23
24const MIN_COLUMN = 16
25
26/** Which factory stage an agent definition works in, by its name. */
27function stageOfType(type: string): Stage {
28 const name = type.toLowerCase()
29 if (/review|critic|security|validator/.test(name)) return 'review'
30 if (/plan|explore|scout|grill/.test(name)) return 'plan'
31 return 'build'
32}
33
34/** The column a card sits in right now. */
35function stageOf(card: Card, isAsking: boolean): Stage {
36 if (isAsking) return 'human'
37 if (card.status !== 'running') return card.ambiguities > 0 ? 'human' : 'done'
38 return stageOfType(card.type)
39}
40
41function statusOf(engineStatus: AgentInfo['status']): Card['status'] {
42 switch (engineStatus) {
43 case 'pending':
44 case 'running':
45 case 'waiting':
46 case 'idle':
47 return 'running'
48 case 'completed':
49 return 'done'
50 case 'failed':
51 case 'killed':
52 return 'failed'
53 default:
54 return engineStatus satisfies never
55 }
56}
57
58function countAmbiguities(answer: string): number {
59 return answer.split('\n').filter(line => line.trimStart().startsWith('[AMBIGUITY]')).length
60}
61
62function glyph(card: Card, isAsking: boolean): string {
63 if (isAsking) return '⚑'
64 if (card.status === 'running') return '◐'
65 if (card.status === 'failed') return '✗'
66 return card.ambiguities > 0 ? '⚑' : '✓'
67}
68
69function fit(text: string, width: number): string {
70 const line = text.replace(/\s+/g, ' ').trim()
71 if (width <= 1) return line.slice(0, Math.max(0, width))
72 return line.length <= width ? line : `${line.slice(0, width - 1)}…`
73}
74
75function cardLabel(card: Card, isAsking: boolean, isViewed: boolean, width: number): string {
76 const mark = isViewed ? '▶ ' : ''
77 return fit(`${glyph(card, isAsking)} ${mark}${card.type} · ${card.description}`, width)
78}
79
80function seconds(ms: number | undefined): string {
81 return ms === undefined ? '' : ` · ${Math.round(ms / 1000)}s`
82}
83
84/**
85 * Fold the engine's own agent list into the cards: new agents join and running cards follow the
86 * engine. A card already done or failed keeps the status its turn.complete recorded.
87 */
88function merge(known: Card[], live: AgentInfo[]): Card[] {
89 const next = known.map(card => {
90 const agent = live.find(one => one.id === card.agentId)
91 if (agent === undefined || card.status !== 'running') return card
92 const status = statusOf(agent.status)
93 return status === card.status ? card : { ...card, status }
94 })
95 for (const agent of live) {
96 if (next.some(card => card.agentId === agent.id)) continue
97 next.push({
98 agentId: agent.id,
99 type: agent.type,
100 description: agent.description,
101 parentAgentId: agent.parentId,
102 status: statusOf(agent.status),
103 ambiguities: 0,
104 })
105 }
106 return next.slice(-MAX_CARDS)
107}
108
109function summary(message: SessionMessage): string {
110 if (message.text.trim() !== '') return message.text
111 if (message.toolUses.length > 0) return message.toolUses.map(use => use.tool).join(', ')
112 if (message.toolResults !== undefined && message.toolResults.length > 0) {
113 return `${message.toolResults.length} tool result(s)`
114 }
115 return '(no text)'
116}
117
118async function transcriptTail($: EngineInterface, agentId: string): Promise<string[]> {
119 const found = await $.session.messages({ agentId })
120 if (!Array.isArray(found)) return [`transcript unavailable: ${found.deny}`]
121 return found.slice(-TAIL_ROWS).map(message => `${message.role === 'user' ? '›' : '•'} ${summary(message)}`)
122}
123
124export const register: Register = on => {
125 on('session.start', async ($, e, next) => {
126 await $.command.register({
127 name: 'factory-board',
128 description: 'Show the sub-agents of this session as a Kanban board',
129 })
130 const sync = async () => {
131 const live = await $.agent.list()
132 if (live.length === 0) return
133 await update($, cards, known => merge(known, live))
134 }
135 await sync()
136 void $.ui.open({ id: PANE, title: 'Factory' })
137 $.clock.every(SYNC_MS, () => void sync())
138
139 return next(e)
140 })
141
142 on('command.run', { command: 'factory-board' }, async $ => {
143 await $.ui.open({ id: PANE, title: 'Factory', focus: true })
144
145 return { text: 'Factory board opened.' }
146 })
147
148 on('agent.spawn', async ($, e, next) => {
149 const started = await next(e)
150 if (started.agentId === undefined) return started
151 const card: Card = {
152 agentId: started.agentId,
153 toolUseId: e.tool_use_id,
154 type: e.subagentType,
155 description: e.description,
156 parentAgentId: e.parentAgentId,
157 status: 'running',
158 ambiguities: 0,
159 }
160 await update($, cards, list =>
161 [...list.filter(one => one.agentId !== card.agentId), card].slice(-MAX_CARDS),
162 )
163
164 return started
165 })
166
167 on('turn.complete', async ($, e, next) => {
168 if (e.agentId !== undefined) {
169 const agentId = e.agentId
170 const status: Card['status'] = e.reason === 'answer' ? 'done' : 'failed'
171 const ambiguities = countAmbiguities(e.answer)
172 await update($, cards, list =>
173 list.map(card =>
174 card.agentId === agentId ? { ...card, status, ambiguities, durationMs: e.durationMs } : card,
175 ),
176 )
177 if (ambiguities > 0) $.ui.toast(`${ambiguities} [AMBIGUITY] line(s) from a sub-agent — see the HUMAN column`)
178 }
179
180 return next(e)
181 })
182
183 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
184 const who = e.agentId ?? MAIN
185 await update($, asking, list => (list.includes(who) ? list : [...list, who]))
186 try {
187 return await next(e)
188 } finally {
189 await update($, asking, list => list.filter(one => one !== who))
190 }
191 })
192
193 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
194 const { Box, Text, Button } = $.ui.resolve(e)
195 const [list, open, picked] = await Promise.all([read($, cards), read($, asking), read($, selected)])
196 const width = e.props.bodyColumns
197 const viewing = e.props.view?.agentId
198 const isWide = width >= COLUMNS.length * MIN_COLUMN
199 const columnWidth = isWide ? Math.floor(width / COLUMNS.length) : width
200 const detail = picked === null ? undefined : list.find(card => card.agentId === picked)
201 const tail = detail === undefined ? [] : await transcriptTail($, detail.agentId)
202
203 return (
204 <Box flexDirection="column">
205 {open.includes(MAIN) && (
206 <Text color="yellow" bold>
207 ⚑ the main session is waiting for your answer
208 </Text>
209 )}
210 {list.length === 0 && <Text dimColor>No sub-agents yet. Cards appear as the session spawns them.</Text>}
211 <Box flexDirection={isWide ? 'row' : 'column'}>
212 {COLUMNS.map(column => {
213 const inColumn = list.filter(card => stageOf(card, open.includes(card.agentId)) === column.stage)
214 return (
215 <Box flexDirection="column" width={columnWidth} paddingRight={1}>
216 <Text bold dimColor={inColumn.length === 0}>
217 {column.title} {inColumn.length}
218 </Text>
219 {inColumn.map(card => (
220 <Button
221 key={`card:${card.agentId}`}
222 plain
223 variant={card.agentId === picked ? 'primary' : undefined}
224 label={cardLabel(card, open.includes(card.agentId), card.agentId === viewing, columnWidth - 2)}
225 onPress={() => {
226 // Select it (again to clear) and bring its spawning row into view.
227 void update($, selected, current => (current === card.agentId ? null : card.agentId))
228 if (card.toolUseId !== undefined) {
229 $.ui
230 .scroll({ to: { requestId: card.toolUseId }, block: 'center' })
231 .catch((reason: unknown) =>
232 $.ui.log(`scroll to ${card.toolUseId} failed: ${String(reason)}`, { to: 'debug' }),
233 )
234 }
235 }}
236 />
237 ))}
238 </Box>
239 )
240 })}
241 </Box>
242 {detail !== undefined && (
243 <Box flexDirection="column" marginTop={1} borderStyle="round" borderDimColor paddingX={1}>
244 <Text bold>
245 {detail.type} · {detail.description}
246 </Text>
247 <Text dimColor>
248 {detail.status}
249 {seconds(detail.durationMs)} · agent {detail.agentId}
250 {detail.parentAgentId === undefined ? '' : ` · spawned by ${detail.parentAgentId}`}
251 {detail.ambiguities > 0 ? ` · ${detail.ambiguities} [AMBIGUITY]` : ''}
252 </Text>
253 {tail.map(line => (
254 <Text wrap="truncate-end">{fit(line, Math.max(1, width - 4))}</Text>
255 ))}
256 <Text dimColor>Open its full transcript from the engine's tasks list; press the card again to close this.</Text>
257 </Box>
258 )}
259 </Box>
260 )
261 })
262}
263types/index.d.ts 33 lines1/** One sub-agent of the session, as the board tracks it. */
2export type Card = {
3 /** The agent's id: `$.agent.list()`'s `id`, `turn.complete`'s `agentId`. */
4 agentId: string
5 /** The spawning Agent tool call, the transcript row a press scrolls to; absent for an agent the engine listed before this mod saw it spawn. */
6 toolUseId?: string
7 /** The agent definition it runs as (`planner`, `code-reviewer`, ...). */
8 type: string
9 /** The Agent tool's short description of the task. */
10 description: string
11 /** The subagent whose loop spawned it; absent when the main loop did. */
12 parentAgentId?: string
13 status: 'running' | 'done' | 'failed'
14 /** How many `[AMBIGUITY]` lines its final answer carried. */
15 ambiguities: number
16 /** Wall-clock length of its run once it ended, in milliseconds. */
17 durationMs?: number
18}
19
20export type Stage = 'plan' | 'build' | 'review' | 'human' | 'done'
21
22declare module 'claude-code' {
23 interface PluginState {
24 'factory-board': {
25 cards: Card[]
26 /** Agent ids (or `main`) with an AskUserQuestion dialog open right now. */
27 asking: string[]
28 /** The card the person pressed last, by agent id; null for none. */
29 selected: string | null
30 }
31 }
32}
33