/board opens a live pane of this repo's PRs, today's merges, the herdr agents working on it, and what needs you, with no model call

/board opens a pane that answers "what's the status, and what needs me?" for the repo you are in. It reads gh and herdr, so no model turn runs.
In 30 sessions this question came up about 20 times, for example "whats the status on builtby? anything that needs my decision?". Each answer was a full Opus turn of gh calls.
blocked state, PRs whose review-failover status failed, and non-draft PRs with red CI.CI ok, CI red, CI …, no CI), review (reviewed, review failed, review …, no review), title. CI reads only the newest run of each check.The pane refreshes every 60 seconds while it is open, and has a Refresh button. Closing the pane stops the timer. /board also prints a 3-line summary in the transcript.
Colors use the theme's warning and error keys, so the pane follows light and dark terminal themes.
None. It runs git, gh and herdr agent list.
tests/swarm-board.test.tsx covers the PR row rules, the "needs you" list, and /board drawing the pane on the terminal and desktop surfaces with fake gh and herdr output.
claude plugin test ~/.claude/mods/swarm-boardhooks/register.tsx 277 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRow, Board, PrRow } from '../types'
5
6// "whats the status on builtby? anything that needs my decision?" was asked
7// about 20 times in 30 sessions, each one a full model turn of gh calls. This
8// pane answers it from gh and herdr directly: no model call, no usage.
9
10const PANE = 'swarm-board'
11const REFRESH_MS = 60_000
12const DAY_MS = 24 * 60 * 60 * 1000
13
14const board = atom({ plugin: 'swarm-board', key: 'board' } as const, null)
15const isLoading = atom({ plugin: 'swarm-board', key: 'isLoading' } as const, false)
16
17type Check = {
18 __typename?: string
19 name?: string
20 workflowName?: string
21 context?: string
22 status?: string
23 conclusion?: string | null
24 state?: string
25 startedAt?: string
26}
27type RawPr = { number: number; title: string; isDraft: boolean; statusCheckRollup?: Check[] }
28type RawMerged = { number: number; title: string; mergedAt: string }
29type RawAgent = {
30 agent_status?: string
31 cwd?: string
32 name?: string
33 focused?: boolean
34 terminal_title_stripped?: string
35 agent_session?: { value?: string }
36}
37
38const GREEN = new Set(['SUCCESS', 'SKIPPED', 'NEUTRAL'])
39const REVIEWERS = new Set(['CodeRabbit', 'review-failover'])
40
41function latest(rollup: readonly Check[]): Check[] {
42 const newest = new Map<string, Check>()
43 for (const c of rollup) {
44 const key = c.context !== undefined ? `status:${c.context}` : `check:${c.workflowName ?? ''}/${c.name ?? ''}`
45 const seen = newest.get(key)
46 if (!seen || String(c.startedAt ?? '') >= String(seen.startedAt ?? '')) newest.set(key, c)
47 }
48 return [...newest.values()]
49}
50
51/** One open PR as a row: CI from its newest check runs, review from the CodeRabbit and review-failover statuses. */
52export function prRow(raw: RawPr): PrRow {
53 const checks = latest(raw.statusCheckRollup ?? [])
54 const ci = checks.filter(c => c.context === undefined)
55 const red = ci.filter(c => c.status === 'COMPLETED' && !GREEN.has(String(c.conclusion))).map(c => String(c.name))
56 const running = ci.some(c => c.status !== 'COMPLETED')
57 const reviews = checks.filter(c => c.context !== undefined && REVIEWERS.has(c.context))
58 const failover = reviews.find(c => c.context === 'review-failover')
59 const review: PrRow['review'] =
60 failover?.state === 'FAILURE' || failover?.state === 'ERROR'
61 ? 'failed'
62 : failover?.state === 'SUCCESS'
63 ? 'passed'
64 : reviews.some(c => c.state === 'PENDING')
65 ? 'waiting'
66 : 'none'
67 return {
68 number: raw.number,
69 title: raw.title,
70 isDraft: raw.isDraft,
71 ci: ci.length === 0 ? 'none' : red.length > 0 ? 'red' : running ? 'running' : 'green',
72 review,
73 red,
74 }
75}
76
77function place(cwd: string, root: string | null): string {
78 const tree = cwd.match(/\/worktrees\/([^/]+)/)?.[1]
79 if (tree) return tree
80 if (root && cwd === root) return 'main checkout'
81 return cwd.split('/').slice(-2).join('/')
82}
83
84/** Builds the board from what gh and herdr said. Pure, so the tests can feed it. */
85export function buildBoard(input: {
86 now: number
87 root: string | null
88 repo: string | null
89 open: RawPr[]
90 merged: RawMerged[]
91 agents: RawAgent[]
92 selfId: string
93 error?: string | null
94}): Board {
95 const open = input.open.map(prRow)
96 const mine = (a: RawAgent) => input.root === null || (a.cwd ?? '').startsWith(input.root)
97 const others = input.agents.filter(a => a.agent_session?.value !== input.selfId)
98 const agents: AgentRow[] = others.filter(mine).map(a => ({
99 name: a.name ?? a.terminal_title_stripped ?? place(a.cwd ?? '', input.root),
100 status: a.agent_status ?? 'unknown',
101 place: place(a.cwd ?? '', input.root),
102 isFocused: a.focused === true,
103 }))
104 const needsYou = [
105 ...agents.filter(a => a.status === 'blocked').map(a => `agent ${a.name} is waiting for an answer (${a.place})`),
106 ...open.filter(p => p.review === 'failed').map(p => `#${p.number} backup review found critical or high issues`),
107 ...open.filter(p => p.ci === 'red' && !p.isDraft).map(p => `#${p.number} CI red: ${p.red.slice(0, 3).join(', ')}`),
108 ]
109 return {
110 at: input.now,
111 repo: input.repo,
112 open,
113 merged: input.merged.filter(m => input.now - Date.parse(m.mergedAt) < DAY_MS).map(m => ({ number: m.number, title: m.title })),
114 agents,
115 idleElsewhere: others.filter(a => !mine(a)).length,
116 needsYou,
117 error: input.error ?? null,
118 }
119}
120
121const ORDER: Record<string, number> = { blocked: 0, working: 1, done: 2, idle: 3 }
122
123/** The board as plain text: what /board prints. */
124export function summary(b: Board): string {
125 const lines = [`${b.repo ?? 'No GitHub repo here'}: ${b.open.length} open PRs, ${b.merged.length} merged in 24h`]
126 lines.push(b.needsYou.length > 0 ? `Needs you: ${b.needsYou.join('; ')}` : 'Needs you: nothing')
127 const counts = new Map<string, number>()
128 for (const a of b.agents) counts.set(a.status, (counts.get(a.status) ?? 0) + 1)
129 if (b.agents.length > 0) lines.push(`Agents: ${[...counts].map(([s, n]) => `${n} ${s}`).join(', ')}`)
130 if (b.error) lines.push(`Could not read: ${b.error}`)
131 return lines.join('\n')
132}
133
134async function run($: EngineInterface, argv: string[], cwd: string): Promise<string> {
135 const { exitCode, stdout, stderr } = await $.process.run(argv, { cwd, timeoutMs: 45_000 })
136 if (exitCode !== 0) throw new Error(`${argv.slice(0, 3).join(' ')}: ${(stderr || stdout).trim().split('\n')[0]?.slice(0, 120)}`)
137 return stdout
138}
139
140async function load($: EngineInterface): Promise<Board> {
141 const cwd = await $.session.cwd()
142 const errors: string[] = []
143 const attempt = async <T,>(what: Promise<T>, fallback: T): Promise<T> => {
144 try {
145 return await what
146 } catch (err) {
147 errors.push(err instanceof Error ? err.message : String(err))
148 return fallback
149 }
150 }
151 const root = await attempt(run($, ['git', 'rev-parse', '--show-toplevel'], cwd).then(s => s.trim()), null as string | null)
152 // A worktree's agents live under the main checkout, so the board groups by that.
153 const common = root ? await attempt(run($, ['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], cwd), '') : ''
154 const mainRoot = common.trim().endsWith('/.git') ? common.trim().slice(0, -5) : root
155 const repo = root
156 ? await attempt(run($, ['gh', 'repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], cwd).then(s => s.trim()), null as string | null)
157 : null
158 const [open, merged, herdr] = await Promise.all([
159 repo ? attempt(run($, ['gh', 'pr', 'list', '--state', 'open', '--limit', '40', '--json', 'number,title,isDraft,statusCheckRollup'], cwd).then(s => JSON.parse(s) as RawPr[]), []) : [],
160 repo ? attempt(run($, ['gh', 'pr', 'list', '--state', 'merged', '--limit', '20', '--json', 'number,title,mergedAt'], cwd).then(s => JSON.parse(s) as RawMerged[]), []) : [],
161 attempt(run($, ['herdr', 'agent', 'list'], cwd).then(s => (JSON.parse(s) as { result?: { agents?: RawAgent[] } }).result?.agents ?? []), [] as RawAgent[]),
162 ])
163 return buildBoard({
164 now: await $.clock.now(),
165 root: mainRoot,
166 repo,
167 open,
168 merged,
169 agents: herdr,
170 selfId: await $.session.id(),
171 error: errors.length > 0 ? errors.join('; ') : null,
172 })
173}
174
175function clock(ms: number): string {
176 const d = new Date(ms)
177 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
178}
179
180const cut = (text: string, width: number) => (text.length > width ? `${text.slice(0, Math.max(1, width - 1))}…` : text)
181
182let timer: { cancel: () => void } | null = null
183
184async function refresh($: EngineInterface): Promise<Board> {
185 await update($, isLoading, () => true)
186 try {
187 const fresh = await load($)
188 await update($, board, () => fresh)
189 return fresh
190 } finally {
191 await update($, isLoading, () => false)
192 }
193}
194
195async function openPane($: EngineInterface): Promise<void> {
196 await $.ui.open({ id: PANE, title: 'Board' })
197 timer?.cancel()
198 timer = $.clock.every(REFRESH_MS, () => void refresh($))
199}
200
201export const register: Register = on => {
202 on('session.start', async ($, e, next) => {
203 await $.command.register({ name: 'board', description: 'Open PRs, merges, herdr agents and what needs you, live, with no model call' })
204 return next(e)
205 })
206
207 on('command.run', { command: 'board' }, async $ => {
208 await openPane($)
209 const fresh = await refresh($)
210 return { text: summary(fresh) }
211 })
212
213 on('ui.close', async ($, e, next) => {
214 if (e.id === PANE) {
215 timer?.cancel()
216 timer = null
217 }
218 return next(e)
219 }).catch(($, e, next) => next(e))
220
221 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
222 const { Box, Button, Text } = $.ui.resolve(e)
223 const b = await read($, board)
224 const loading = await read($, isLoading)
225 const width = Math.max(20, e.props.bodyColumns)
226 const room = Math.max(4, (e.viewport?.rows ?? 30) - 12)
227
228 if (b === null) return <Text dimColor>{loading ? 'Reading gh and herdr…' : 'Run /board to load.'}</Text>
229
230 const ciMark = { green: 'CI ok', red: 'CI red', running: 'CI …', none: 'no CI' } as const
231 const reviewMark = { passed: 'reviewed', failed: 'review failed', waiting: 'review …', none: 'no review' } as const
232 const agents = [...b.agents].sort((x, y) => (ORDER[x.status] ?? 9) - (ORDER[y.status] ?? 9))
233 const busy = agents.filter(a => a.status !== 'idle')
234 const idle = agents.length - busy.length
235
236 return (
237 <Box flexDirection="column">
238 <Box flexDirection="row" columnGap={1}>
239 <Text bold>{cut(b.repo ?? 'No GitHub repo here', width - 22)}</Text>
240 <Text dimColor>{loading ? 'updating…' : `at ${clock(b.at)}`}</Text>
241 <Button key="refresh" label="Refresh" onPress={() => void refresh($)} />
242 </Box>
243
244 <Text bold color={b.needsYou.length > 0 ? 'warning' : undefined}>
245 Needs you{b.needsYou.length === 0 ? ': nothing' : ''}
246 </Text>
247 {b.needsYou.slice(0, room).map(line => (
248 <Text color="warning">{cut(` ${line}`, width)}</Text>
249 ))}
250
251 <Text bold>Open PRs ({b.open.length})</Text>
252 {b.open.length === 0 && <Text dimColor> none</Text>}
253 {b.open.slice(0, room).map(p => (
254 <Text color={p.ci === 'red' || p.review === 'failed' ? 'error' : undefined} dimColor={p.isDraft}>
255 {cut(` #${p.number} ${ciMark[p.ci]} · ${reviewMark[p.review]}${p.isDraft ? ' · draft' : ''} ${p.title}`, width)}
256 </Text>
257 ))}
258
259 <Text bold>Merged in 24h ({b.merged.length})</Text>
260 {b.merged.slice(0, room).map(m => (
261 <Text dimColor>{cut(` #${m.number} ${m.title}`, width)}</Text>
262 ))}
263
264 <Text bold>
265 Agents here ({b.agents.length}){idle > 0 ? `, ${idle} idle` : ''}
266 {b.idleElsewhere > 0 ? `, ${b.idleElsewhere} in other folders` : ''}
267 </Text>
268 {busy.slice(0, room).map(a => (
269 <Text color={a.status === 'blocked' ? 'warning' : undefined}>{cut(` ${a.status.padEnd(8)} ${a.name} · ${a.place}`, width)}</Text>
270 ))}
271
272 {b.error && <Text dimColor>{cut(`Could not read: ${b.error}`, width)}</Text>}
273 </Box>
274 )
275 })
276}
277types/index.d.ts 28 lines1export type PrRow = {
2 number: number
3 title: string
4 isDraft: boolean
5 ci: 'green' | 'red' | 'running' | 'none'
6 review: 'passed' | 'failed' | 'waiting' | 'none'
7 red: string[]
8}
9
10export type AgentRow = { name: string; status: string; place: string; isFocused: boolean }
11
12export type Board = {
13 at: number
14 repo: string | null
15 open: PrRow[]
16 merged: { number: number; title: string }[]
17 agents: AgentRow[]
18 idleElsewhere: number
19 needsYou: string[]
20 error: string | null
21}
22
23declare module 'claude-code' {
24 interface PluginState {
25 'swarm-board': { board: Board | null; isLoading: boolean }
26 }
27}
28