SLOPSHOPPER

factory-board

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

paneguardcommandtoasttimeragents
★ 1v0.1.0no licenseupdated 2026-10-16Joaovsales/jplugin-agentic-development/mods/factory-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · factory-board
│ ┃ Factory ✕ › fix the failing auth test and add an audit log call │ ┃ No sub-agents yet. Cards appear as the │ ┃ session spawns them. ⏺ Read(src/auth.ts) │ ┃ PLAN 0 ⎿ Read 6 lines │ ┃ BUILD 0 ⏺ Update(src/auth.ts) │ ┃ REVIEW 0 ⎿ Added 2 lines, removed 1 line │ ┃ HUMAN 0 ⏺ Bash(bun test) │ ┃ DONE 0 ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /factory-board │ ⎿ factory-board: Factory board opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Factory
No sub-agents yet. Cards appear as the session spawns them. PLAN 0 BUILD 0 REVIEW 0 HUMAN 0 DONE 0
README

factory-board — a Claude Code mod

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 …
  • Columns: 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).
  • Cards are buttons. Press one: the main transcript scrolls to the Agent tool row that spawned it, and a detail box opens under the board with its status, duration, agent id, parent and the tail of its own transcript. Press it again to close the detail. ▶ marks the card whose transcript the person has on screen through the engine's tasks list.
  • A yellow line at the top says when the main session is waiting for an answer.
  • /factory-board opens (and focuses) the pane; it also opens by itself at session start where the terminal is wide enough to dock it.

Layout

.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.

Run it

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-board
Source 2 files
hooks/register.tsx 263 lines
1import { 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}
263
types/index.d.ts 33 lines
1/** 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