Live board of /crew agents: state, phase, progress and stale warnings, read from ~/.crew

A Claude Code mod. It shows a live board of the agents in a /crew run. It only reads. It never writes the status files.
In a Claude Code session:
/plugin install crew-board --marketplace sabasayer/crew
To try it from a local clone, without install:
claude --plugin-dir ./mods/board
Type /crew-board. A pane opens. It checks ~/.crew every 2 seconds. No refresh needed.
~/.crew/). The newest ticket is first.3/8 done ▶ 2 ⏸ 1. The rows are in chain order, top to bottom.running agent with no update for 15 minutes shows in red with STALE.unreadable.Show one ticket only: /crew-board <ticket>, for example /crew-board eng-708-mr2. Type /crew-board with no name to see all tickets again.
The first mate can open the board itself. The mod gives it a tool, mcp__crew-board__crew_board_open, with one input: ticket. Claude asks you once to allow it. Allow it for the session or always.
The board folder is ~/.crew. The mod creates it. It survives a restart. Set CREW_BOARD_ROOT to use another folder. The board also shows old runs in /tmp/crew. The header shows the folder path.
Type /crew-board clean to list runs older than 30 days. Nothing is deleted. Type /crew-board clean yes to delete them. It only cleans the board folder, never /tmp/crew.
The plugin also ships a skill: /crew-board:crew. It is the first mate. It runs a ticket through spec, tests, code, review and ship, with gates for you. It writes the status files that the board shows. It reads your project docs for the dev server, the checks and the ship flow. The skill and its briefs are in mods/board/skills/crew/.
The plugin ships a second skill: /crew-board:prove. It runs the checks CI runs before a branch ships. The first mate runs it once, after review.
The crew skill has a file e2e.md. It lists what every e2e run must do. Agents then find the repo's own e2e scripts that do it.
The plugin has a bin folder with two commands. Claude Code puts it on the PATH of the Bash tool when the plugin is on. A session loads it at the start.
crew-status <role> <state> "<summary>" --run <run folder> [--order N] writes a status file. It refuses an empty summary for blocked, done and failed.crew-visual-diff <test-results folder> prints the changed pixels and the changed area of each Playwright *-actual.png and *-expected.png pair. Agents read this text and do not open the images.If you also use mr-triage and its track-work skill, the first mate adds a task for each branch. It moves the task to reviewing and local_testing, and adds the MR link. It skips this when mr-triage is not running.
/crew-board serve starts a small web server and prints the links:
crew-board http://192.168.1.20:7777/?t=Xk3...
Open one on any device in the same network. The page shows the same board: ship, agents, bars, stale and blocked states. It polls every 2 seconds and moves the ship while an agent runs.
/crew-board serve local listens on this computer only. /crew-board serve stop stops it. It also stops when the session ends.port option. The default is 7777./tmp/crew.node mods/board/serve/serve.mjs --port 7777 [--local-only] [--root ~/.crew] [--token abc].&ticket=<name> to show one ticket, or &style=simple for the simple ship.When an agent becomes blocked (a gate waits for the captain), failed, or stale, the mod sends a notification through your Claude Code notification channel. Each change is sent once. Start-up state is not sent. Turn it off with the option alerts set to off.
The Claude mobile app does not dock panes, so the pane does not show there. The mod draws a short summary above the prompt instead: the newest ticket, the done count, and the agents that are running, blocked or failed. This follows the mod API for the mobile surface. It has not been tried in the app yet.
The option style is full or simple. The default is full.
full:gate-*, bandana for red and pin, tricorn for green and move, pointed hat for review-*, hard hat for fixer, sailor cap for browser, plain cap for any other role.red, green, fixer, gate-* and browser. Other roles get a color from their name. So two review-* agents get different colors.simple: each agent is a small one-pixel crew member on the deck. No hats, no faces, and the rows are compact.Switch it with a command. The pane changes at once, and the choice is kept for the next sessions:
/crew-board style switches between full and simple./crew-board style simple or /crew-board style full picks one.The command wins over the setting. The setting is the default for a new install. Change it in the plugin settings, or in ~/.claude/settings.json:
{ "pluginConfigs": { "crew-board": { "options": { "style": "simple" } } } }
The older option characters is gone. It is ignored if it is still in your settings.
One file per agent: ~/.crew/<ticket>/<role>.json. Agents write it with crew-status.
{"role":"red","state":"running","phase":"writing e2e tests","done":12,"total":35,"order":2,"updated":"2026-10-16T14:40:00Z"}
| Field | Needed | Meaning |
|---|---|---|
role | no | Name on the board. The file name is used if missing. |
state | yes | queued, running, blocked, done or failed. |
phase | no | What the agent does now. |
done, total | no | Progress. Without them the bar is a moving block. |
summary | no | Short result text. On done or failed, the board shows it in place of phase. |
updated | yes | ISO time of the last write. |
order | no | Place in the chain. Lower comes first. |
The mod shows any *.json file. It does not need a fixed list of roles. Without order, it sorts by the usual flow: gate-spec, spec, spike, red, pin, gate-tests, green, move, review-*, fixer, browser, gate-captain. Unknown roles go last.
claude plugin validate mods/board
claude plugin test mods/board
The tests cover the logic in mods/board/hooks/board.ts and ship.ts: stale check, chain order, bad JSON, bar and age, alerts, ship shape, hats and faces. The web server is not tested by a script; start it with demo files and open it. The pane drawing is not tested by a script. Check it by eye with demo files:
mkdir -p ~/.crew/demo && echo '{"role":"red","state":"running","phase":"demo","done":3,"total":10,"updated":"'$(date -u +%FT%TZ)'"}' > ~/.crew/demo/red.json
The mod is one folder: mods/board, with its hooks and its skills. It does not use mr-triage or any server. To merge it with other mods, copy that folder and add a line to the other repo's .claude-plugin/marketplace.json.
hooks/register.tsx 349 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Agent, Board, Ticket } from '../types'
5import { LEGACY_ROOT, age, alerts, bar, inFocus, isStale, oldRuns, parseAgent, resolveRoot, sortChain } from './board.ts'
6import { drawShip, halfBlocks, hatSvg, miniHatGrid, shipSvg, shipWidth, water } from './ship.ts'
7
8const PANE = 'crew-board'
9const POLL_MS = 2_000
10const FRAME_MS = 500
11const board = atom({ plugin: 'crew-board', key: 'board' } as const, { tickets: [], now: 0 } as Board)
12
13const focus = atom({ plugin: 'crew-board', key: 'focus' } as const, '')
14const style = atom({ plugin: 'crew-board', key: 'style' } as const, '')
15const TOOL = 'crew_board_open'
16const frame = atom({ plugin: 'crew-board', key: 'frame' } as const, 0)
17const isSailing = (t: Ticket, now: number) => t.agents.some(a => a.state === 'running' && !isStale(a, now))
18
19const LOOK: Record<string, { icon: string; color: string }> = {
20 queued: { icon: '○', color: 'gray' },
21 running: { icon: '▶', color: 'cyan' },
22 blocked: { icon: '⏸', color: 'yellow' },
23 done: { icon: '✔', color: 'green' },
24 failed: { icon: '✖', color: 'red' },
25}
26const UNKNOWN = { icon: '?', color: 'red' }
27
28async function scanRoot($: EngineInterface, root: string): Promise<Ticket[]> {
29 const folders = (await $.fs.list(root).catch(() => [])).filter(f => f.kind === 'dir')
30 const tickets = await Promise.all(
31 folders.map(async (folder): Promise<Ticket> => {
32 const dir = `${root}/${folder.name}`
33 const files = (await $.fs.list(dir).catch(() => [])).filter(f => f.name.endsWith('.json'))
34 const agents = await Promise.all(
35 files.map(async f => parseAgent(f.name, await $.fs.read(`${dir}/${f.name}`).catch(() => '{'), f.mtimeMs)),
36 )
37
38 return { name: folder.name, agents: sortChain(agents), newestMs: Math.max(folder.mtimeMs, ...agents.map(a => a.updatedMs)) }
39 }),
40 )
41
42 return tickets.filter(t => t.agents.length > 0)
43}
44
45async function scan($: EngineInterface, root: string): Promise<Board> {
46 const now = Date.now()
47 const main = await scanRoot($, root)
48 const legacy = root === LEGACY_ROOT ? [] : (await scanRoot($, LEGACY_ROOT)).filter(t => !main.some(m => m.name === t.name))
49
50 return { tickets: [...main, ...legacy].sort((a, b) => b.newestMs - a.newestMs), now }
51}
52
53let seen: Board | null = null
54let isAlerting = true
55type Web = { stream: ReturnType<EngineInterface['process']['spawn']>; urls: string[] }
56let web: Web | null = null
57
58const refresh = async ($: EngineInterface, root: string) => {
59 const next = await scan($, root)
60 if (isAlerting) for (const a of alerts(seen, next)) void $.ui.notify(a.text, { title: 'Crew board' }).catch(() => {})
61 seen = next
62 await update($, board, () => next)
63}
64
65const serve = async ($: EngineInterface, args: string, root: string, port: string) => {
66 const [action = ''] = args.toLowerCase().split(/\s+/)
67 if (action === 'stop') {
68 if (!web) return 'The web view is not running.'
69 await web.stream.return(undefined as never)
70 web = null
71
72 return 'Web view stopped.'
73 }
74 if (web) return `The web view is running:\n${web.urls.join('\n')}`
75 if (action && action !== 'local') return 'Use "serve", "serve local" or "serve stop".'
76 const argv = ['node', `${$.plugin.root}/serve/serve.mjs`, '--root', root, '--port', port, ...(action === 'local' ? ['--local-only'] : [])]
77 const stream = $.process.spawn({ argv })
78
79 return new Promise<string>(resolve => {
80 void (async () => {
81 let output = ''
82 try {
83 for await (const chunk of stream) {
84 output += chunk.text
85 if (!web && chunk.stream === 'stdout') {
86 const urls = output.split('\n').filter(l => l.startsWith('crew-board ')).map(l => l.slice(11))
87 web = { stream, urls }
88 resolve(`Web view started. Open one of these on a device in the same network:\n${urls.join('\n')}\nStop it with /crew-board serve stop.`)
89 }
90 }
91 } catch {}
92 web = null
93 resolve(`The web view did not start. ${output.trim() || 'Needs Node 22.18 or newer.'}`)
94 })()
95 })
96}
97
98const cleanRuns = async ($: EngineInterface, root: string, isConfirmed: boolean) => {
99 await refresh($, root)
100 const { tickets, now } = await read($, board)
101 const folders = (await $.fs.list(root).catch(() => [])).filter(f => f.kind === 'dir').map(f => f.name)
102 const old = oldRuns(tickets, now).filter(t => folders.includes(t.name))
103 if (old.length === 0) return { text: `No runs older than 30 days in ${root}.` }
104 const names = old.map(t => `${t.name} (${Math.floor((now - t.newestMs) / 86_400_000)} days old)`).join('\n')
105 if (!isConfirmed) return { text: `Runs older than 30 days in ${root}:\n${names}\nTo delete them, type: /crew-board clean yes` }
106 const gone = await Promise.all(old.map(t => $.process.run(['rm', '-r', '--', `${root}/${t.name}`]).then(r => r.exitCode === 0)))
107 await refresh($, root)
108
109 return { text: `Deleted ${gone.filter(Boolean).length} of ${old.length} runs from ${root}.` }
110}
111
112let rootPromise: Promise<string> | undefined
113
114const getRoot = ($: EngineInterface, option: unknown) =>
115 (rootPromise ??= (async () => resolveRoot(await $.env.get('CREW_BOARD_ROOT'), option, await $.env.get('HOME')))())
116
117const show = async ($: EngineInterface, ticket: string) => {
118 await update($, focus, () => ticket)
119 await $.ui.open({ id: PANE, title: ticket ? `Crew · ${ticket}` : 'Crew' })
120}
121
122export const register: Register = (on, options) => {
123 const configured = String(options?.style ?? 'full')
124 const rootOption = options?.root
125 const port = String(options?.port ?? '7777')
126 isAlerting = String(options?.alerts ?? 'on') !== 'off'
127
128 on('session.start', async ($, e, next) => {
129 const root = await getRoot($, rootOption)
130 await $.process.run(['mkdir', '-p', root]).catch(() => undefined)
131 await $.command.register({
132 name: 'crew-board',
133 description: 'Show the live board of /crew agents. Add a ticket name to show only that ticket. Use "style" to switch between full and simple, or "style full" or "style simple" to pick one. Use "serve" to start a web view for a browser or phone, "serve local" for this computer only, "serve stop" to stop it. Use "clean" to list runs older than 30 days',
134 argumentHint: '[ticket | style [full|simple] | serve [local|stop] | clean [yes]]',
135 })
136 await $.tool.register({
137 name: TOOL,
138 description: `Open the crew board pane for one ticket. Call it once when a /crew run starts. The ticket is the folder name under ${root}.`,
139 inputSchema: { type: 'object', properties: { ticket: { type: 'string' } }, required: ['ticket'] },
140 isDeferred: false,
141 })
142 const saved = await $.store.get('style')
143 if (saved === 'full' || saved === 'simple') await update($, style, () => saved)
144 await refresh($, root)
145 $.clock.every(POLL_MS, () => refresh($, root))
146 $.clock.every(FRAME_MS, async () => {
147 const { tickets, now } = await read($, board)
148 if (tickets.some(t => isSailing(t, now))) await update($, frame, n => n + 1)
149 })
150
151 return next(e)
152 })
153
154 on('command.run', { command: 'crew-board' }, async ($, e) => {
155 const styleArgs = e.args.trim().match(/^style(?:\s+(\S+))?$/i)
156 if (styleArgs) {
157 const asked = styleArgs[1]?.toLowerCase()
158 if (asked && asked !== 'full' && asked !== 'simple') return { text: 'Style is "full" or "simple".' }
159 const now = (await read($, style)) || configured
160 const next = asked ?? (now === 'simple' ? 'full' : 'simple')
161 await update($, style, () => next)
162 await $.store.set('style', next)
163
164 return { text: `Crew board style: ${next}.` }
165 }
166 const cleanArgs = e.args.trim().match(/^clean(?:\s+(\S+))?$/i)
167 if (cleanArgs) {
168 const asked = cleanArgs[1]?.toLowerCase()
169 if (asked && asked !== 'yes') return { text: 'Use "clean" to list old runs, or "clean yes" to delete them.' }
170
171 return cleanRuns($, await getRoot($, rootOption), asked === 'yes')
172 }
173 const serveArgs = e.args.trim().match(/^serve(?:\s+(.*))?$/i)
174 if (serveArgs) return { text: await serve($, serveArgs[1] ?? '', await getRoot($, rootOption), port) }
175 const ticket = e.args.trim()
176 await show($, ticket)
177
178 return { text: ticket ? `Crew board opened for ${ticket}.` : 'Crew board opened.' }
179 })
180
181 on('tool.call', { tool: 'mcp__crew-board__crew_board_open' }, async ($, e) => {
182 const ticket = String((e as { ticket?: unknown }).ticket ?? '').trim()
183 await show($, ticket)
184
185 return { result: ticket ? `Crew board opened for ${ticket}.` : 'Crew board opened.' }
186 })
187
188 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
189 if (e.surface !== 'mobile') return next(e)
190 const { tickets, now } = await read($, board)
191 const t = tickets[0]
192 if (!t) return next(e)
193 const { Box, Text } = $.ui.resolve(e)
194 const open = t.agents.filter(a => a.state !== 'done' && a.state !== 'queued').slice(0, 4)
195
196 return (
197 <Box flexDirection="column">
198 <Text bold>
199 Crew {t.name}: {t.agents.filter(a => a.state === 'done').length}/{t.agents.length} done
200 </Text>
201 {open.map(a => (
202 <Text color={isStale(a, now) ? 'red' : (LOOK[a.state] ?? UNKNOWN).color} wrap="truncate-end">
203 {(LOOK[a.state] ?? UNKNOWN).icon} {a.role} {a.state} {(a.state === 'failed' ? a.summary : undefined) || a.phase || ''}
204 </Text>
205 ))}
206 </Box>
207 )
208 })
209
210 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
211 const isFull = ((await read($, style)) || configured) !== 'simple'
212 const root = await getRoot($, rootOption)
213 const ui = $.ui.resolve(e)
214 const { Box, Text } = ui
215 const useSvg = e.surface !== 'terminal' && 'Svg' in ui
216 const all = await read($, board)
217 const only = (await read($, focus)).toLowerCase()
218 const tickets = only ? all.tickets.filter(t => inFocus(t.name, only)) : all.tickets
219 const now = all.now
220 const step = await read($, frame)
221 const tick = Math.floor(now / POLL_MS)
222 const room = Math.max(1, (e.viewport?.rows ?? 30) - 4)
223 let used = 0
224
225 if (tickets.length === 0) {
226 return <Text dimColor>{only ? `Waiting for ${root}/${only} ... (/crew-board shows all tickets)` : `No crew run found in ${root}.`}</Text>
227 }
228
229 const row = (a: Agent) => {
230 const stale = isStale(a, now)
231 const look = LOOK[a.state] ?? UNKNOWN
232 const count = a.done != null && a.total != null ? ` ${a.done}/${a.total}` : ''
233
234 const icon = (
235 <Text color={stale ? 'red' : look.color} bold>
236 {look.icon}{' '}
237 </Text>
238 )
239 const rest = [
240 <Text bold>{a.role.padEnd(18)}</Text>,
241 <Text color={look.color}> {a.state.padEnd(8)} </Text>,
242 <Text color={stale ? 'red' : look.color}>{bar(a, 12, tick)}</Text>,
243 <Text dimColor>{count.padEnd(8)}</Text>,
244 <Text> {(a.state === 'done' || a.state === 'failed' ? a.summary : undefined) || a.phase || ''}</Text>,
245 <Text color={stale ? 'red' : undefined} dimColor={!stale} bold={stale}>
246 {' '}
247 {age(now - a.updatedMs)} ago{stale ? ' STALE' : ''}
248 </Text>,
249 ]
250
251 if (!isFull) {
252 return (
253 <Text wrap="truncate-end">
254 {icon}
255 {rest}
256 </Text>
257 )
258 }
259 if (useSvg) {
260 return (
261 <Box>
262 {icon}
263 <ui.Svg {...hatSvg(a.role, 3)} alt={`${a.role} hat`} />
264 <Text wrap="truncate-end"> {rest}</Text>
265 </Box>
266 )
267 }
268
269 return (
270 <Text wrap="truncate-end">
271 {icon}
272 {halfBlocks(miniHatGrid(a.role))[0]!.map(r => (
273 <Text color={r.fg} backgroundColor={r.bg}>
274 {r.t}
275 </Text>
276 ))}
277 <Text> </Text>
278 {rest}
279 </Text>
280 )
281 }
282
283 const svg = (t: Ticket, at: number, n: number) => shipSvg(drawShip(t.agents, n, isSailing(t, at), isFull, at), n, isSailing(t, at), 6)
284
285 const summary = (agents: Agent[]) => (
286 <Text wrap="truncate-end">
287 <Text bold>
288 {agents.filter(a => a.state === 'done').length}/{agents.length} done
289 </Text>
290 {['running', 'blocked', 'failed', 'queued'].map(state => {
291 const n = agents.filter(a => a.state === state).length
292
293 return n ? (
294 <Text color={LOOK[state]!.color}>
295 {' '}
296 {LOOK[state]!.icon} {n}
297 </Text>
298 ) : null
299 })}
300 </Text>
301 )
302
303 return (
304 <Box flexDirection="column">
305 <Text dimColor wrap="truncate-end">
306 Files: {root}
307 {root === LEGACY_ROOT ? '' : ` (old runs in ${LEGACY_ROOT} also show)`}
308 </Text>
309 {tickets.map((t, i) => {
310 const lines = (isFull ? 11 : 9) + t.agents.length * (useSvg || !isFull ? 1 : 2)
311 const sea = water(step, isSailing(t, now), shipWidth(t.agents.length, isFull))
312 const shown = used + lines <= room || i === 0
313 used += lines
314
315 return shown ? (
316 <Box flexDirection="column" marginBottom={1}>
317 <Text bold color="magenta">
318 {t.name}
319 </Text>
320 <Box>
321 {useSvg ? (
322 <ui.Svg {...svg(t, now, step)} alt={`${t.name} ship`} />
323 ) : (
324 <Box flexDirection="column" width={shipWidth(t.agents.length, isFull) + 2} flexShrink={0}>
325 {halfBlocks(drawShip(t.agents, step, isSailing(t, now), isFull, now)).map(runs => (
326 <Text wrap="truncate">
327 {runs.map(r => (
328 <Text color={r.fg} backgroundColor={r.bg}>
329 {r.t}
330 </Text>
331 ))}
332 </Text>
333 ))}
334 <Text color={sea.fg}>{sea.t}</Text>
335 </Box>
336 )}
337 <Box flexDirection="column">{summary(t.agents)}</Box>
338 </Box>
339 {t.agents.map(a => (
340 <Box marginBottom={useSvg || !isFull ? 0 : 1}>{row(a)}</Box>
341 ))}
342 </Box>
343 ) : null
344 })}
345 </Box>
346 )
347 })
348}
349hooks/board.ts 103 lines1import type { Agent, Board, Ticket } from '../types'
2
3export const STALE_MS = 15 * 60_000
4export const OLD_MS = 30 * 24 * 3600_000
5export const LEGACY_ROOT = '/tmp/crew'
6
7const CHAIN = ['gate-spec', 'spec', 'spike', 'red', 'pin', 'gate-tests', 'green', 'move', 'review', 'fixer', 'browser', 'gate-captain']
8
9export function chainIndex(role: string) {
10 const i = CHAIN.findIndex(name => role === name || role.startsWith(`${name}-`))
11
12 return i === -1 ? CHAIN.length : i
13}
14
15export function sortChain(agents: Agent[]) {
16 return [...agents].sort(
17 (a, b) =>
18 (a.order ?? Infinity) - (b.order ?? Infinity) ||
19 chainIndex(a.role) - chainIndex(b.role) ||
20 a.role.localeCompare(b.role),
21 )
22}
23
24export function parseAgent(file: string, text: string, mtimeMs: number): Agent {
25 const fallback = file.replace(/\.json$/, '')
26 try {
27 const j = JSON.parse(text)
28 const updated = Date.parse(j.updated)
29
30 return {
31 role: String(j.role ?? fallback),
32 state: String(j.state ?? 'unknown'),
33 phase: j.phase == null ? undefined : String(j.phase),
34 done: typeof j.done === 'number' ? j.done : undefined,
35 total: typeof j.total === 'number' ? j.total : undefined,
36 summary: j.summary ? String(j.summary) : undefined,
37 order: typeof j.order === 'number' ? j.order : undefined,
38 updatedMs: Number.isNaN(updated) ? mtimeMs : updated,
39 }
40 } catch {
41 return { role: fallback, state: 'unreadable', updatedMs: mtimeMs, broken: true }
42 }
43}
44
45export const isStale = (a: Agent, now: number) => a.state === 'running' && now - a.updatedMs > STALE_MS
46
47export function age(ms: number) {
48 const s = Math.max(0, Math.floor(ms / 1000))
49 if (s < 60) return `${s}s`
50 if (s < 3600) return `${Math.floor(s / 60)}m`
51
52 return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`
53}
54
55export function bar(a: Agent, width: number, tick: number) {
56 if (a.done != null && a.total) {
57 const filled = Math.min(width, Math.round((width * a.done) / a.total))
58
59 return '█'.repeat(filled) + '░'.repeat(width - filled)
60 }
61 if (a.state === 'done') return '█'.repeat(width)
62 if (a.state !== 'running') return '░'.repeat(width)
63 const at = tick % width
64
65 return '░'.repeat(at) + '██' + '░'.repeat(Math.max(0, width - at - 2))
66}
67
68export function resolveRoot(env: string | undefined, option: unknown, home: string | undefined) {
69 const raw = (env || String(option ?? '')).trim() || `${home || ''}/.crew`
70 const path = raw.startsWith('~/') ? `${home || ''}${raw.slice(1)}` : raw
71
72 return path.length > 1 ? path.replace(/\/+$/, '') : path
73}
74
75export const inFocus = (folder: string, only: string) => {
76 const name = folder.toLowerCase()
77
78 return name === only || name.startsWith(`${only}-mr`)
79}
80
81export const oldRuns = (tickets: Ticket[], now: number) => tickets.filter(t => now - t.newestMs > OLD_MS)
82
83export type Alert = { ticket: string; role: string; kind: 'blocked' | 'failed' | 'stale'; text: string }
84
85const flag = (a: Agent, now: number) => (isStale(a, now) ? 'stale' : a.state === 'blocked' || a.state === 'failed' ? a.state : '')
86
87export function alerts(before: Board | null, after: Board): Alert[] {
88 if (!before) return []
89 const seen = new Map<string, string>()
90 for (const t of before.tickets) for (const a of t.agents) seen.set(`${t.name}/${a.role}`, flag(a, before.now))
91
92 return after.tickets.flatMap(t =>
93 t.agents.flatMap(a => {
94 const kind = flag(a, after.now)
95 if (!kind || seen.get(`${t.name}/${a.role}`) === kind) return []
96 const what = kind === 'blocked' ? 'waits for you' : kind === 'failed' ? 'failed' : 'is stale, no update for 15 minutes'
97 const detail = (kind === 'failed' ? a.summary : undefined) || a.phase
98
99 return [{ ticket: t.name, role: a.role, kind, text: `${t.name}: ${a.role} ${what}${detail ? ` - ${detail}` : ''}` } satisfies Alert]
100 }),
101 )
102}
103hooks/ship.ts 213 lines1import type { Agent } from '../types'
2import { isStale } from './board.ts'
3
4export type Run = { t: string; fg?: string; bg?: string }
5type Grid = (string | null)[][]
6
7const PLAIN_WIDTH = 22
8const MAX_FACES = 8
9const FACE_STEP = 6
10const SAIL = '#e8e4d8'
11const WOOD = '#8b5a2b'
12const HULL = '#5c3a1c'
13const SEA = '#3b82c4'
14const SKIN_PLAIN = '#f2c9a0'
15const EYE = '#222222'
16const MOUTH = '#6b2d2d'
17const GOLD = '#e5c04b'
18const BAND = '#3b5fa8'
19const SLEEPY = '#9a9a9a'
20const CREW_X = [2, 4, 6, 10, 12, 14, 16, 18]
21const SKINS = ['#f2c9a0', '#e0ac7e', '#c68642', '#8d5524', '#ffdbac']
22const HATS = ['#4f8fd9', '#9b6fd1', '#3fb3a0', '#d96fa8', '#e08a3c', '#8a8a8a']
23const STATE_COLOR: Record<string, string> = {
24 queued: '#8a8a8a',
25 running: '#33c3d6',
26 blocked: '#e5c04b',
27 done: '#4fbf6f',
28 failed: '#e5484d',
29}
30const HAT_SHAPES: Record<string, { rows: [string, string]; color?: string }> = {
31 gate: { rows: ['.XXX.', 'XXGXX'], color: '#e8e4d8' },
32 red: { rows: ['....X', 'XXXXX'], color: '#d9534f' },
33 pin: { rows: ['....X', 'XXXXX'], color: '#d9534f' },
34 green: { rows: ['X.X.X', 'XXXXX'], color: '#4fbf6f' },
35 move: { rows: ['X.X.X', 'XXXXX'], color: '#4fbf6f' },
36 review: { rows: ['..X..', '.XXX.'] },
37 fixer: { rows: ['.XGX.', 'XXXXX'], color: GOLD },
38 browser: { rows: ['XXXXX', 'BBBBB'], color: '#e8e4d8' },
39}
40const MINI_SHAPES: Record<string, [string, string]> = {
41 gate: ['.X.', 'XGX'],
42 red: ['..X', 'XXX'],
43 pin: ['..X', 'XXX'],
44 green: ['X.X', 'XXX'],
45 move: ['X.X', 'XXX'],
46 review: ['.X.', 'XXX'],
47 fixer: ['XGX', 'XXX'],
48 browser: ['XXX', 'BBB'],
49}
50const MINI_CAP: [string, string] = ['.XX', 'XXX']
51const CAP: { rows: [string, string]; color?: string } = { rows: ['.XXX.', 'XXXXX'] }
52
53const hash = (text: string) => [...text].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7)
54
55export function shipWidth(count: number, characters: boolean) {
56 return characters ? Math.max(24, Math.min(count, MAX_FACES) * FACE_STEP) : PLAIN_WIDTH
57}
58
59export function hatFor(role: string) {
60 const family = Object.keys(HAT_SHAPES).find(name => role === name || role.startsWith(`${name}-`))
61 const shape = family ? HAT_SHAPES[family]! : CAP
62
63 return { rows: shape.rows, color: shape.color ?? HATS[hash(role) % HATS.length]! }
64}
65
66export function miniHatGrid(role: string): Grid {
67 const family = Object.keys(HAT_SHAPES).find(name => role === name || role.startsWith(`${name}-`))
68 const rows = family ? MINI_SHAPES[family]! : MINI_CAP
69 const { color } = hatFor(role)
70 const paint = (ch: string) => (ch === 'X' ? color : ch === 'G' ? GOLD : ch === 'B' ? BAND : null)
71
72 return rows.map(row => [...row].map(paint))
73}
74
75function drawFace(put: (x: number, y: number, color: string) => void, x: number, top: number, a: Agent, isLate: boolean) {
76 const look = hash(a.role)
77 const skin = a.state === 'queued' ? SLEEPY : SKINS[(look >>> 3) % SKINS.length]!
78 const hat = hatFor(a.role)
79 const paint = (ch: string) => (ch === 'X' ? hat.color : ch === 'G' ? GOLD : ch === 'B' ? BAND : null)
80
81 hat.rows.forEach((row, r) => [...row].forEach((ch, c) => paint(ch) && put(x + c, top + r, paint(ch)!)))
82 for (let c = 0; c < 5; c++) {
83 put(x + c, top + 2, skin)
84 put(x + c, top + 3, skin)
85 }
86 for (let c = 1; c <= 3; c++) put(x + c, top + 4, skin)
87 const eye = a.state === 'failed' ? STATE_COLOR.failed! : EYE
88 put(x + 1, top + 2, eye)
89 put(x + 3, top + 2, eye)
90 if (a.state === 'done') {
91 put(x + 1, top + 3, MOUTH)
92 put(x + 3, top + 3, MOUTH)
93 put(x + 2, top + 4, MOUTH)
94 } else if (a.state === 'failed') {
95 put(x + 2, top + 3, MOUTH)
96 put(x + 1, top + 4, MOUTH)
97 put(x + 3, top + 4, MOUTH)
98 } else if (a.state === 'blocked') {
99 for (let c = 1; c <= 3; c++) put(x + c, top + 3, MOUTH)
100 } else if (a.state === 'running') put(x + 2, top + 3, MOUTH)
101 for (let c = 1; c <= 3; c++) put(x + c, top + 5, isLate ? STATE_COLOR.failed! : (STATE_COLOR[a.state] ?? '#e5484d'))
102}
103
104export function drawShip(agents: Agent[], frame: number, isSailing: boolean, characters = false, now = 0): Grid {
105 const width = shipWidth(agents.length, characters)
106 const deck = characters ? 11 : 7
107 const mast = characters ? Math.floor(width / 2) : 8
108 const grid: Grid = Array.from({ length: deck + 3 }, () => Array<string | null>(width).fill(null))
109 const dy = isSailing && frame % 2 ? 1 : 0
110 const put = (x: number, y: number, color: string) => {
111 if (grid[y + dy]?.[x] !== undefined) grid[y + dy]![x] = color
112 }
113 const allDone = agents.length > 0 && agents.every(a => a.state === 'done')
114 const anyFailed = agents.some(a => a.state === 'failed')
115 const flag = anyFailed ? STATE_COLOR.failed! : allDone ? STATE_COLOR.done! : '#d9534f'
116
117 for (let y = 0; y < deck; y++) put(mast, y, WOOD)
118 put(mast + 1, 0, flag)
119 if (isSailing && frame % 2) put(mast + 2, 0, flag)
120 for (let y = 1; y <= 4; y++) {
121 for (let x = mast - y; x < mast; x++) put(x, y, SAIL)
122 for (let x = mast + 1; x <= mast + 1 + y; x++) put(x, y, SAIL)
123 }
124 for (let x = 0; x < width; x++) put(x, deck, HULL)
125 for (let x = 2; x < width - 2; x++) put(x, deck + 1, HULL)
126
127 agents.slice(0, MAX_FACES).forEach((a, i) => {
128 const isLate = now > 0 && isStale(a, now)
129 const hop = a.state === 'running' && !isLate && (frame + i) % 2 === 1 ? 1 : 0
130 if (characters) return drawFace(put, i * FACE_STEP, deck - 6 - hop, a, isLate)
131 put(CREW_X[i]!, deck - 2 - hop, SKIN_PLAIN)
132 put(CREW_X[i]!, deck - 1 - hop, isLate ? STATE_COLOR.failed! : (STATE_COLOR[a.state] ?? '#e5484d'))
133 })
134
135 return grid
136}
137
138export function halfBlocks(grid: Grid): Run[][] {
139 const lines: Run[][] = []
140 for (let y = 0; y < grid.length; y += 2) {
141 const runs: Run[] = []
142 for (let x = 0; x < (grid[y]?.length ?? 0); x++) {
143 const top = grid[y]![x] ?? undefined
144 const bottom = grid[y + 1]?.[x] ?? undefined
145 const cell: Run = !top && !bottom ? { t: ' ' } : top && bottom ? { t: '▀', fg: top, bg: bottom } : top ? { t: '▀', fg: top } : { t: '▄', fg: bottom }
146 const last = runs[runs.length - 1]
147 if (last && last.fg === cell.fg && last.bg === cell.bg) last.t += cell.t
148 else runs.push(cell)
149 }
150 lines.push(runs)
151 }
152
153 return lines
154}
155
156export function water(frame: number, isSailing: boolean, width: number): Run {
157 const shift = isSailing ? frame % 4 : 0
158
159 return { t: '≈≈~~'.repeat(Math.ceil(width / 4) + 1).slice(shift, shift + width), fg: SEA }
160}
161
162function pixelRects(grid: Grid, px: number) {
163 const rects: string[] = []
164 grid.forEach((row, y) => {
165 let x = 0
166 while (x < row.length) {
167 const color = row[x]
168 if (!color) {
169 x++
170 continue
171 }
172 let end = x
173 while (row[end + 1] === color) end++
174 rects.push(`<rect x="${x * px}" y="${y * px}" width="${(end - x + 1) * px}" height="${px}" fill="${color}"/>`)
175 x = end + 1
176 }
177 })
178
179 return rects
180}
181
182const svgOf = (rects: string[], width: number, height: number) => ({
183 source: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${rects.join('')}</svg>`,
184 width,
185 height,
186})
187
188export function shipSvg(grid: Grid, frame: number, isSailing: boolean, px: number) {
189 const rects = pixelRects(grid, px)
190 const shift = isSailing ? frame % 4 : 0
191 const waveTop = grid.length * px
192 const columns = grid[0]?.length ?? 0
193 for (let x = 0; x < columns; x++) {
194 const high = (x + shift) % 4 < 2
195 rects.push(`<rect x="${x * px}" y="${waveTop + (high ? 0 : px / 2)}" width="${px}" height="${px / 2}" fill="${SEA}"/>`)
196 }
197
198 return svgOf(rects, columns * px, waveTop + px)
199}
200
201export function hatGrid(role: string): Grid {
202 const hat = hatFor(role)
203 const paint = (ch: string) => (ch === 'X' ? hat.color : ch === 'G' ? GOLD : ch === 'B' ? BAND : null)
204
205 return hat.rows.map(row => [...row].map(paint))
206}
207
208export function hatSvg(role: string, px: number) {
209 const grid = hatGrid(role)
210
211 return svgOf(pixelRects(grid, px), (grid[0]?.length ?? 0) * px, grid.length * px)
212}
213types/index.d.ts 20 lines1export type Agent = {
2 role: string
3 state: string
4 phase?: string
5 summary?: string
6 done?: number
7 total?: number
8 order?: number
9 updatedMs: number
10 broken?: boolean
11}
12export type Ticket = { name: string; agents: Agent[]; newestMs: number }
13export type Board = { tickets: Ticket[]; now: number }
14
15declare module 'claude-code' {
16 interface PluginState {
17 'crew-board': { board: Board; frame: number; focus: string; style: string }
18 }
19}
20