SLOPSHOPPER

crew-board

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

panebandguardcommandtoolprocess
v0.14.5no licenseupdated 2026-10-26sabasayer/crew/mods/board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crew-board
│ ┃ Crew ✕ › fix the failing auth test and add an audit log call │ ┃ No crew run found in /Users/dev/.crew. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /crew-board │ ⎿ crew-board: Crew board opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Crew
No crew run found in /Users/dev/.crew.
README

crew-board

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.

Install

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

Use

Type /crew-board. A pane opens. It checks ~/.crew every 2 seconds. No refresh needed.

  • One block per ticket (a folder in ~/.crew/). The newest ticket is first.
  • Each ticket has a tiny pixel ship (half-block characters in the terminal, an SVG in the desktop app). One crew member stands on deck for each agent, colored by state. The ship bobs and the sea moves while an agent runs and is not stale. Crew members of running agents hop. A stale or idle ship stays still. The flag turns green when all agents are done and red if one failed.
  • A summary beside the ship shows the counts, like 3/8 done ▶ 2 ⏸ 1. The rows are in chain order, top to bottom.
  • One row per agent: role, state, bar, count, phase, time since the last update.
  • A running agent with no update for 15 minutes shows in red with STALE.
  • A file that is not valid JSON shows as 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 crew skill

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.

Web view (browser or phone)

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

  • The link has a random token. Without it the server answers 403. It is made fresh at each start.
  • /crew-board serve local listens on this computer only. /crew-board serve stop stops it. It also stops when the session ends.
  • Change the port with the port option. The default is 7777.
  • It shows the same folders as the pane: the board folder, and old runs in /tmp/crew.
  • It needs Node 22.18 or newer. You can also run it alone: node mods/board/serve/serve.mjs --port 7777 [--local-only] [--root ~/.crew] [--token abc].
  • Add &ticket=<name> to show one ticket, or &style=simple for the simple ship.
  • Anyone on your network with the link can see ticket names and phases. It is plain HTTP. To reach it away from home, use a VPN such as Tailscale and open the Tailscale address (it is in the printed list). Do not open the port to the internet.

Alerts

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.

Mobile app

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.

Style (option)

The option style is full or simple. The default is full.

  • full:
  • Each agent is a small pixel face with a hat on the ship, up to 8.
  • The hat shape follows the role: captain cap for 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.
  • The hat color is fixed for red, green, fixer, gate-* and browser. Other roles get a color from their name. So two review-* agents get different colors.
  • The face shows the state: smile when done, frown when failed, flat mouth when blocked, small mouth when running, gray face when queued. A running face hops. The collar has the state color. A stale agent has a red collar.
  • Each agent row shows the hat before the role name: 3 by 2 pixels in the terminal, 5 by 2 in the desktop app.
  • 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.

Status file contract

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"}
FieldNeededMeaning
rolenoName on the board. The file name is used if missing.
stateyesqueued, running, blocked, done or failed.
phasenoWhat the agent does now.
done, totalnoProgress. Without them the bar is a moving block.
summarynoShort result text. On done or failed, the board shows it in place of phase.
updatedyesISO time of the last write.
ordernoPlace 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.

Develop

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

Move this mod

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.

Source 4 files
hooks/register.tsx 349 lines
1import { 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}
349
hooks/board.ts 103 lines
1import 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}
103
hooks/ship.ts 213 lines
1import 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}
213
types/index.d.ts 20 lines
1export 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