A crew panel for standard subagents (/crew): each agent type is a crab character with its own role, look and lines, animated by its status and progress.

A Claude Code mod that turns your subagents into a crew of pixel crabs. Every agent type is played by its own character, with a role, a costume, a few lines of its own and a name. Each crab is animated by what its agent is doing and how far along it is.
It works with the standard agents (general-purpose, Explore, Plan and the rest) and with any custom agent type. No skills, prompts or tool calls are needed from the model: the mod watches the agents itself.

/crew toggles it). At the top is Captain Clawd, the main thread: at the helm while Claude works, asleep when it waits for you. Under the captain is one row per subagent:
| Agent type | Character | Names | Trait | Prop |
|---|---|---|---|---|
general-purpose | Deckhand | Barnacle, Rusty, Sal… | Can-do all-rounder | mop |
Explore | Scout | Pip, Wren, Kit… | Curious and quick; looks, never touches | spyglass |
Plan | Navigator | Ada, Vela, Orion… | Measures twice, plots the course | chart |
claude-code-guide | Professor | Kelp, Sage, Quill… | Has read every manual twice | book, mortarboard |
statusline-setup | Signaller | Sparky, Dot, Morse | Tinkers with the lights and signals | semaphore flag |
fork | Understudy | Echo, Mirror, Twin | Knows everything the Captain knows | — |
| any other type | Cook, Sleuth, Wizard or Diver | dealt by the type's name, so the same type always gets the same character | pan, magnifier, wand, snorkel |
A second crab of the same character takes the next name; after the last name the list starts again with II (Pip II).

| Pose | When | Motion |
|---|---|---|
| walk | working, last tool call a few seconds ago | legs step, body bobs, the prop moves (mop swishes, spyglass sweeps, flag waves…) |
| scurry | a tool call in the last 3 seconds | the same, at double pace |
| think | waiting on the model | eyes up, body sways, thought bubble with blinking dots |
| wait | a tool call running for more than 20 seconds | taps a foot under a flipping hourglass |
| cheer | just finished (6 seconds) | jumps with both claws up |
| rest | finished | happy eyes, sits still |
| dizzy | ended on an error or refusal | upside down, legs wiggling, stars |
| sleep | stopped by you | eyes closed, dimmed, z z rising |
All motion is CSS and stops when the system asks for reduced motion. The terminal draws the classic ASCII crab (\/)(°,,°)(\/) on a text lane, with frames switched once a second.
Standard agents don't report their progress, so the lane uses the best signal available:
TodoWrite, or TaskCreate / TaskUpdate): completed items out of all items, with half credit for the one in progress. Shown as 3/5 todos.≈45%. The crab reaches the flag only when the agent actually finishes.Tokens are summed from each model request's usage. The mod shows no cost: it has no reliable price table to base one on.
At the prompt of a Claude Code terminal session:
/plugin install crab-crew --marketplace marwansaab/Claude-Mods
Answer y to add the marketplace and choose a scope. Then type /crew, or just let Claude dispatch an agent.
| Command | Does |
|---|---|
/crew | Shows or hides the crew panel. |
Set them in /config, or for a mod loaded with --plugin-dir, in ~/.claude/settings.json:
{ "pluginConfigs": { "crab-crew": { "options": { "autoOpen": false, "showQuips": true } } } }
| Option | Default | Effect |
|---|---|---|
autoOpen | true | Opens the panel the first time a subagent starts in a session. In a narrow terminal (under 144 columns) a pane that opens by itself waits until there is room; /crew always opens it. |
showQuips | true | Speech bubbles and lines in character. Off, the rows keep only the plain facts. |
The mod hooks the engine's own events, so the model does nothing special:
| Event | Used for |
|---|---|
agent.spawn | casts the new subagent: its character from the agent type, and the next free name |
turn.step | marks the crab as thinking; adds the request's tokens |
tool.call | the crab's activity and target (reading config.ts), tool count, errors, todo progress; the captain's activity on the main thread |
turn.complete | how the run ended: back aboard, overboard or called back |
prompt.submit | clears the band once nobody is out |
ui.render (Pane, AbovePrompt) | draws the panel and the band: SVG on the desktop, text on the terminal |
Every hook only observes, and is written so that a failure inside the mod can never fail a tool call, a spawn or a turn.
Inspired by savvy-progress by johnnyvizz (MIT), whose panel and pixel crabs showed the way. Crab Crew is a separate implementation that needs no skill or reporting tool.
MIT, see the repository's LICENSE.
hooks/register.tsx 500 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Activity, Captain, CrewMember, CrewPanel } from '../types'
5import {
6 BAND_H,
7 FINISHED_H,
8 HEADER_H,
9 ROSTER_H,
10 RUNNING_H,
11 asciiLane,
12 bandSvg,
13 finishedSvg,
14 headerSvg,
15 rosterSvg,
16 runningSvg,
17} from './art'
18import {
19 CAPTAIN,
20 CHEER_MS,
21 ROSTER,
22 actionOf,
23 activityOf,
24 captainQuip,
25 characterKeyOf,
26 characterOf,
27 crewNameOf,
28 crewProgress,
29 crewSummary,
30 crewTotals,
31 detailOf,
32 elapsed,
33 fmtTime,
34 fmtTokens,
35 modelName,
36 poseOf,
37 progressLabel,
38 progressOf,
39 quipOf,
40 statusOf,
41 titleOf,
42 todoPatch,
43} from './crew'
44
45const crew = atom({ plugin: 'crab-crew', key: 'crew' } as const, [])
46const captain = atom({ plugin: 'crab-crew', key: 'captain' } as const, {
47 isBusy: false,
48 activity: 'other',
49 detail: '',
50 lastActiveAt: 0,
51})
52const panel = atom({ plugin: 'crab-crew', key: 'panel' } as const, {
53 isDoneCollapsed: false,
54 isRosterOpen: false,
55 hasAutoOpened: false,
56 bandSince: 0,
57})
58const now = atom({ plugin: 'crab-crew', key: 'now' } as const, 0)
59
60const PANE = 'crab-crew'
61const PANE_TITLE = 'Crab Crew'
62const MAX_CREW = 60
63const CLAY = '#D97757'
64const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗', stopped: '■' }
65
66/** The panel is decoration: nothing it does may break a tool call, a spawn or a turn. */
67async function safely(work: () => Promise<unknown>): Promise<void> {
68 try {
69 await work()
70 } catch {
71 // Dropped: a missed update only costs a frame of the drawing.
72 }
73}
74
75const touch = ($: EngineInterface, agentId: string, fn: (m: CrewMember) => CrewMember) =>
76 update($, crew, list => list.map(m => (m.agentId === agentId ? fn(m) : m)))
77
78const setCaptain = ($: EngineInterface, patch: Partial<Captain>) => update($, captain, c => ({ ...c, ...patch }))
79
80async function openPane($: EngineInterface): Promise<void> {
81 const at = await $.clock.now()
82 await update($, now, () => at)
83 await $.ui.open({ id: PANE, title: PANE_TITLE })
84}
85
86// Opens the crew pane, or closes it when it is up; true when it ends up open.
87async function togglePane($: EngineInterface): Promise<boolean> {
88 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
89 if (isOpen) {
90 await $.ui.close({ id: PANE })
91 return false
92 }
93 await openPane($)
94 return true
95}
96
97/** Crew the band shows: started or finished after the person last moved on. */
98const bandCrew = (list: readonly CrewMember[], p: CrewPanel): CrewMember[] =>
99 list.filter(m => m.startedAt > p.bandSince || (m.endedAt ?? -1) > p.bandSince)
100
101export const register: Register = (on, options) => {
102 const autoOpen = options.autoOpen !== false
103 const showQuips = options.showQuips !== false
104
105 on('session.start', async ($, e, next) => {
106 const started = await next(e)
107 await $.command.register({
108 name: 'crew',
109 description: 'Show or hide the Crab Crew: every subagent as a crab with a role, animated by its status and progress',
110 })
111
112 // Ticks the clocks and the animations' poses while anyone works or celebrates; quiet otherwise.
113 $.clock.every(1000, () => {
114 void safely(async () => {
115 const list = await read($, crew)
116 const at = await $.clock.now()
117 const isLive = list.some(m => m.status === 'running' || (m.endedAt !== undefined && at - m.endedAt < CHEER_MS + 1000))
118 if (isLive) await update($, now, () => at)
119 })
120 })
121 return started
122 })
123
124 on('command.run', { command: 'crew' }, async $ => {
125 const isOpen = await togglePane($)
126 return { text: isOpen ? 'All hands on deck: the Crab Crew is up.' : 'Crab Crew stood down.' }
127 })
128
129 // A new subagent joins the crew, cast by its agent type.
130 on('agent.spawn', async ($, e, next) => {
131 const started = await next(e)
132 if (started.deny !== undefined) return started
133
134 await safely(async () => {
135 const at = await $.clock.now()
136 const character = characterKeyOf(e.subagentType)
137 await update($, crew, list => {
138 const member: CrewMember = {
139 id: started.agentId ?? e.tool_use_id,
140 agentId: started.agentId,
141 type: e.subagentType,
142 character,
143 name: crewNameOf(character, list),
144 description: e.description,
145 model: started.model,
146 status: 'running',
147 startedAt: at,
148 lastActiveAt: at,
149 activity: 'start',
150 detail: '',
151 toolCalls: 0,
152 errors: 0,
153 steps: 0,
154 contextTokens: 0,
155 tokens: 0,
156 }
157 return [...list.filter(m => m.id !== member.id), member].slice(-MAX_CREW)
158 })
159 await update($, now, () => at)
160 if (!autoOpen) return
161 const p = await read($, panel)
162 if (p.hasAutoOpened) return
163 await update($, panel, prev => ({ ...prev, hasAutoOpened: true }))
164 await $.ui.open({ id: PANE, title: PANE_TITLE })
165 })
166 return started
167 }).catch(($, e, next) => next(e))
168
169 // Each model request: the crab (or the captain) is thinking; then its tokens.
170 on('turn.step', async function* ($, e, next) {
171 const agentId = e.agentId
172 await safely(async () => {
173 const at = await $.clock.now()
174 if (agentId) {
175 await touch($, agentId, m => ({
176 ...m,
177 status: 'running',
178 endedAt: undefined,
179 activity: 'think',
180 detail: '',
181 lastActiveAt: at,
182 effort: typeof e.effort === 'string' ? e.effort : m.effort,
183 }))
184 } else {
185 await setCaptain($, { isBusy: true, activity: 'think', detail: '', lastActiveAt: at })
186 }
187 })
188
189 const result = yield* next(e)
190
191 await safely(async () => {
192 const usage = result.usage
193 if (!agentId || !usage) return
194 const sum =
195 (usage.input_tokens || 0) +
196 (usage.output_tokens || 0) +
197 (usage.cache_read_input_tokens || 0) +
198 (usage.cache_creation_input_tokens || 0)
199 await touch($, agentId, m => ({
200 ...m,
201 model: usage.model || m.model,
202 contextTokens: sum,
203 tokens: m.tokens + sum,
204 steps: m.steps + 1,
205 }))
206 })
207 return result
208 })
209
210 // Every tool call: what the crab is doing, its todos, and how its calls went.
211 on('tool.call', async ($, e, next) => {
212 const tool = String(e.tool)
213 const input = e as unknown as Record<string, unknown>
214 const activity: Activity = activityOf(tool)
215 const detail = detailOf(input)
216 const agentId = e.agentId
217
218 await safely(async () => {
219 const at = await $.clock.now()
220 if (agentId) {
221 await touch($, agentId, m => ({
222 ...m,
223 activity,
224 detail,
225 lastActiveAt: at,
226 toolCalls: m.toolCalls + 1,
227 ...todoPatch(tool, input, m),
228 }))
229 } else {
230 await setCaptain($, { isBusy: true, activity, detail, lastActiveAt: at })
231 }
232 })
233
234 const result = await next(e)
235
236 await safely(async () => {
237 if (!agentId) return
238 const at = await $.clock.now()
239 await touch($, agentId, m => ({ ...m, lastActiveAt: at, errors: m.errors + (result.isError ? 1 : 0) }))
240 })
241 return result
242 }).catch(($, e, next) => next(e))
243
244 // A run ends: back aboard, overboard, or called back.
245 on('turn.complete', async ($, e, next) => {
246 await safely(async () => {
247 const at = await $.clock.now()
248 if (e.agentId) {
249 const usage = e.usage
250 await touch($, e.agentId as string, m => ({
251 ...m,
252 status: statusOf(e.reason),
253 endedAt: at,
254 lastActiveAt: at,
255 ...(m.steps === 0 && usage
256 ? {
257 model: usage.model || m.model,
258 tokens:
259 usage.input_tokens + usage.output_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens,
260 }
261 : {}),
262 }))
263 } else {
264 await setCaptain($, { isBusy: false, activity: 'other', detail: '', lastActiveAt: at })
265 }
266 await update($, now, () => at)
267 })
268 return next(e)
269 })
270
271 // A new prompt with nobody out: the band clears; the panel keeps the history.
272 on('prompt.submit', async ($, e, next) => {
273 await safely(async () => {
274 const list = await read($, crew)
275 if (list.some(m => m.status === 'running')) return
276 const at = await $.clock.now()
277 await update($, panel, p => ({ ...p, bandSince: at }))
278 })
279 return next(e)
280 }).catch(($, e, next) => next(e))
281
282 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
283 const ui = $.ui.resolve(e)
284 const { Box, Text, Button } = ui
285 const list = await read($, crew)
286 const c = await read($, captain)
287 const p = await read($, panel)
288 const at = Math.max(await read($, now), ...list.map(m => m.startedAt), 0)
289 const tick = Math.floor(at / 1000)
290
291 const running = list.filter(m => m.status === 'running').reverse()
292 const finished = list.filter(m => m.status !== 'running').reverse()
293 const isRosterShown = p.isRosterOpen || list.length === 0
294
295 const toggleRoster = (
296 <Button
297 key="roster"
298 label={p.isRosterOpen ? 'Hide roster' : 'Meet the crew'}
299 plain
300 onPress={() => update($, panel, prev => ({ ...prev, isRosterOpen: !prev.isRosterOpen }))}
301 />
302 )
303 const toggleDone = (
304 <Button
305 key="done"
306 label={`${p.isDoneCollapsed ? '▸' : '▾'} Back from duty · ${finished.length}`}
307 plain
308 onPress={() => update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
309 />
310 )
311
312 if (e.surface === 'desktop' && 'Svg' in ui) {
313 const { Svg } = ui
314 const W = Math.max(260, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
315 return (
316 <Box flexDirection="column">
317 <Svg
318 source={headerSvg(W, c, list, at)}
319 alt={`${CAPTAIN.name}: ${captainQuip(c)}. ${crewSummary(list, at)}`}
320 width={W}
321 height={HEADER_H}
322 />
323 {list.length > 0 && toggleRoster}
324 {running.length > 0 && <Text dimColor>At work · {running.length}</Text>}
325 {running.map(m => (
326 <Svg
327 key={m.id}
328 source={runningSvg(W, m, at, showQuips)}
329 alt={`${titleOf(m)}: ${m.description}. ${actionOf(m, at)}, ${progressLabel(m)}`}
330 width={W}
331 height={RUNNING_H}
332 />
333 ))}
334 {finished.length > 0 && toggleDone}
335 {!p.isDoneCollapsed &&
336 finished.map(m => (
337 <Svg
338 key={m.id}
339 source={finishedSvg(W, m, at, showQuips)}
340 alt={`${titleOf(m)}: ${m.description}. ${actionOf(m, at)}`}
341 width={W}
342 height={FINISHED_H}
343 />
344 ))}
345 {isRosterShown && <Text dimColor>{list.length ? 'The roster' : 'No crew out yet. Meet the roster:'}</Text>}
346 {isRosterShown &&
347 ROSTER.map(r => (
348 <Svg
349 key={`roster-${r.character.key}`}
350 source={rosterSvg(W, r.character, r.types)}
351 alt={`${r.character.role} (${r.types}): ${r.character.trait}`}
352 width={W}
353 height={ROSTER_H}
354 />
355 ))}
356 </Box>
357 )
358 }
359
360 // Terminal and the other text surfaces: the same crew in text, ASCII crabs on their lanes.
361 const cols = Math.max(28, e.props.bodyColumns || 40)
362 const row = (m: CrewMember) => {
363 const ch = characterOf(m.character)
364 const pose = poseOf(m, at)
365 const lane = asciiLane(pose, tick, progressOf(m).value, Math.min(cols - 2, 48))
366 const isRunning = m.status === 'running'
367 return (
368 <Box key={m.id} flexDirection="column" marginBottom={1}>
369 <Box flexDirection="row" gap={1}>
370 <Text color={ch.color} bold wrap="truncate-end">
371 {titleOf(m)}
372 </Text>
373 <Text dimColor wrap="truncate-end">
374 {m.description || m.type}
375 </Text>
376 <Text color={m.status === 'failed' ? 'red' : m.status === 'done' ? 'green' : ch.color}>{STATUS_GLYPH[m.status]}</Text>
377 </Box>
378 {isRunning ? (
379 <Text wrap="truncate-end">
380 <Text dimColor>{lane.before}</Text>
381 <Text color={CLAY}>{lane.crab}</Text>
382 <Text color={ch.color}>{lane.after}</Text>
383 </Text>
384 ) : (
385 <Text wrap="truncate-end">
386 <Text color={CLAY}>{lane.crab}</Text>
387 </Text>
388 )}
389 {showQuips && (
390 <Text italic dimColor wrap="truncate-end">
391 {' “'}
392 {quipOf(m, at)}
393 {'”'}
394 </Text>
395 )}
396 <Text dimColor wrap="truncate-end">
397 {' '}
398 {[modelName(m.model), actionOf(m, at), progressLabel(m), `${m.toolCalls} tools`, m.tokens ? `${fmtTokens(m.tokens)} tok` : '', fmtTime(elapsed(m, at))]
399 .filter(Boolean)
400 .join(' · ')}
401 </Text>
402 </Box>
403 )
404 }
405
406 const t = crewTotals(list, at)
407 return (
408 <Box flexDirection="column">
409 <Text wrap="truncate-end">
410 <Text color={CLAY}>{c.isBusy ? '(\\/)(°,,°)(\\/)' : '(\\/)(-,,-)(\\/) z'}</Text>{' '}
411 <Text bold color={CAPTAIN.color}>
412 {CAPTAIN.name}
413 </Text>
414 <Text dimColor> · {captainQuip(c)}</Text>
415 </Text>
416 <Text dimColor wrap="truncate-end">
417 {list.length ? `${crewSummary(list, at)} · ${fmtTokens(t.tokens)} tok · ${fmtTime(t.time)}` : 'No crew dispatched yet.'}
418 </Text>
419 {list.length > 0 && toggleRoster}
420 <Box flexDirection="column" marginTop={1}>
421 {running.length > 0 && <Text dimColor>At work · {running.length}</Text>}
422 {running.map(row)}
423 {finished.length > 0 && toggleDone}
424 {!p.isDoneCollapsed && finished.map(row)}
425 {isRosterShown && <Text dimColor>{list.length ? 'The roster' : 'Meet the roster:'}</Text>}
426 {isRosterShown &&
427 ROSTER.map(r => (
428 <Text key={`roster-${r.character.key}`} wrap="truncate-end">
429 <Text color={r.character.color} bold>
430 {r.character.role}
431 </Text>
432 <Text dimColor>
433 {' '}
434 ({r.types}) {r.character.trait}
435 </Text>
436 </Text>
437 ))}
438 </Box>
439 </Box>
440 )
441 })
442
443 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
444 if (e.props.hasSurvey) return next(e)
445 const list = await read($, crew)
446 const p = await read($, panel)
447 const shown = bandCrew(list, p)
448 if (shown.length === 0) return next(e)
449
450 const ui = $.ui.resolve(e)
451 const { Box, Text, Button } = ui
452 const at = Math.max(await read($, now), ...shown.map(m => m.startedAt))
453 const progress = crewProgress(shown)
454 const crewButton = <Button key="crab-crew-open" label={`Crew ×${shown.length}`} plain onPress={() => void togglePane($)} />
455 const dismiss = (
456 <Button
457 key="crab-crew-dismiss"
458 label="✕"
459 plain
460 role="dismiss"
461 onPress={async () => {
462 const t = await $.clock.now()
463 await update($, panel, prev => ({ ...prev, bandSince: t }))
464 }}
465 />
466 )
467
468 if ('Svg' in ui) {
469 const { Svg } = ui
470 const width = Math.max(200, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 120))
471 return (
472 <Box flexDirection="row" alignItems="center" gap={1}>
473 <Svg source={bandSvg(width, shown, at, progress)} alt={`Crab Crew: ${crewSummary(shown, at)}`} width={width} height={BAND_H} />
474 {crewButton}
475 {dismiss}
476 </Box>
477 )
478 }
479
480 const running = shown.filter(m => m.status === 'running')
481 const lead = running[0]
482 const tick = Math.floor(at / 1000)
483 const crabFace = running.length ? (tick % 2 ? '(\\|)(°,,°)(|/)' : '(\\/)(°,,°)(\\/)') : '\\(^,,^)/'
484 const barW = Math.max(6, Math.min(20, e.props.bodyColumns - 60))
485 const filled = Math.round(barW * progress)
486 return (
487 <Box flexDirection="row" gap={1}>
488 <Text color={CLAY}>{crabFace}</Text>
489 <Text wrap="truncate-end">
490 {lead ? `${titleOf(lead)}: ${quipOf(lead, at)}` : 'All back aboard'}
491 <Text dimColor> · {crewSummary(shown, at)}</Text>
492 </Text>
493 <Text color={running.length ? CLAY : 'green'}>{'█'.repeat(filled) + '░'.repeat(Math.max(0, barW - filled))}</Text>
494 {crewButton}
495 {dismiss}
496 </Box>
497 )
498 })
499}
500hooks/art.ts 476 lines1import type { Captain, CrewMember } from '../types'
2import {
3 CAPTAIN,
4 actionOf,
5 captainQuip,
6 characterOf,
7 crewSummary,
8 crewTotals,
9 elapsed,
10 fmtTime,
11 fmtTokens,
12 modelName,
13 poseOf,
14 progressLabel,
15 progressOf,
16 quipOf,
17 titleOf,
18} from './crew'
19import type { Character, Pose } from './crew'
20
21// ---------------------------------------------------------------------------
22// Pixel crabs: a 30×28 grid, one costume per character. The body keeps the
23// brand clay; the character lives in its hat, its prop and how it moves.
24
25const CLAY = '#D97757'
26const INK = '#1F1E1D'
27const GOLD = '#F5C542'
28
29/** `cls` puts a pixel in a group: `bd` (default) body and costume, `la`/`lb` the leg pairs, else a prop. */
30type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
31
32const stamp = (f: Fill, x: number, y: number, rows: readonly string[], map: Record<string, string>, cls?: string): void =>
33 rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
34
35type Face = 'open' | 'up' | 'happy' | 'x' | 'closed'
36
37const eyes = (f: Fill, face: Face): void => {
38 for (const x of [9, 19]) {
39 if (face === 'open') f(x, 12, 2, 2, INK)
40 else if (face === 'up') f(x, 11, 2, 2, INK)
41 else if (face === 'happy') stamp(f, x - 1, 12, ['.kk.', 'k..k'], { k: INK })
42 else if (face === 'x') stamp(f, x - 1, 11, ['k.k', '.k.', 'k.k'], { k: INK })
43 else f(x - 1, 13, 4, 1, INK)
44 }
45}
46
47type Look = { face: Face; armL: number; armR: number }
48
49const body = (f: Fill, look: Look): void => {
50 f(7, 10, 16, 12, CLAY)
51 f(3, 14 + look.armL, 4, 4, CLAY)
52 f(23, 14 + look.armR, 4, 4, CLAY)
53 eyes(f, look.face)
54 f(7, 22, 2, 4, CLAY, 'la')
55 f(17, 22, 2, 4, CLAY, 'la')
56 f(11, 22, 2, 4, CLAY, 'lb')
57 f(21, 22, 2, 4, CLAY, 'lb')
58}
59
60type Costume = {
61 /** How far the right claw is raised to hold the prop (negative is up). */
62 hold: number
63 draw: (f: Fill, color: string) => void
64}
65
66const COSTUMES: Record<string, Costume> = {
67 // Captain: navy cap with a gold badge; turns the helm while the main thread works.
68 captain: {
69 hold: 0,
70 draw: f => {
71 stamp(f, 6, 5, ['....nnnnnnnnnn....', '..nnnnnnggnnnnnn..', '.nnnnnnggggnnnnnn.', '.nnnnnnnggnnnnnnn.', 'kkkkkkkkkkkkkkkkkk'], { n: '#2B3A67', g: GOLD, k: INK })
72 stamp(f, 22, 12, ['...h...', '.bbbbb.', '.b.b.b.', 'hbbsbbh', '.b.b.b.', '.bbbbb.', '...h...'], { b: '#8B5A2B', h: '#C08A50', s: GOLD }, 'wh')
73 },
74 },
75 // Deckhand: red bandana; swishes a mop.
76 deckhand: {
77 hold: 0,
78 draw: (f, t) => {
79 stamp(f, 7, 7, ['....rrrrrrrr....', '..rrrrwrrrrwrr..', 'rrrrrrrrrrrrrrrr'], { r: t, w: '#F8F6F1' })
80 f(5, 9, 2, 1, t)
81 f(4, 10, 2, 1, t)
82 stamp(f, 24, 4, ['..h..', '..h..', '..h..', '..h..', '..h..', '..h..', '..h..', '..h..', '..h..', '..h..', 'mmmmm', 'm.m.m', 'm.m.m'], { h: '#A0703F', m: '#E9E4D6' }, 'mop')
83 },
84 },
85 // Scout: bush hat; sweeps the horizon with a spyglass.
86 scout: {
87 hold: -4,
88 draw: (f, t) => {
89 stamp(f, 6, 6, ['....gggggggggg....', '...gyyyyyyyyyyg...', 'gggggggggggggggggg'], { g: t, y: '#C9A86A' })
90 stamp(f, 23, 7, ['bbbbbll', 'bbbbbll'], { b: '#B8862B', l: '#7A5A1E' }, 'sg')
91 f(29, 7, 1, 1, '#A9D6F5', 'sg')
92 },
93 },
94 // Navigator: bicorne; unrolls a chart.
95 navigator: {
96 hold: 0,
97 draw: f => {
98 stamp(f, 6, 6, ['kk..............kk', 'kkkk..kkkkkk..kkkk', '.kkkkkkkkkkkkkkkk.', '..gggggggggggggg..'], { k: '#2F2A3A', g: GOLD })
99 stamp(f, 8, 16, ['pppppppppppppp', 'p.pp.pp.ppprpp', 'pppppppppprrrp', 'pp.pp.pp.pprpp'], { p: '#EBD9A8', '.': '#C9A86A', r: '#D0453F' }, 'map')
100 },
101 },
102 // Professor: mortarboard and spectacles (drawn after the eyes); flips the pages of a book.
103 professor: {
104 hold: 0,
105 draw: f => {
106 stamp(f, 5, 5, ['.....kkkkkkkkkk.....', 'kkkkkkkkkkkkkkkkkkkk', '.....kkkkkkkkkk.....', '......kkkkkkkkk.....'], { k: '#2F2A3A' })
107 f(23, 6, 1, 4, GOLD, 'ts')
108 f(22, 10, 3, 1, GOLD, 'ts')
109 for (const x of [8, 18]) {
110 f(x, 11, 4, 1, '#3A3A3C')
111 f(x, 14, 4, 1, '#3A3A3C')
112 f(x, 11, 1, 4, '#3A3A3C')
113 f(x + 3, 11, 1, 4, '#3A3A3C')
114 }
115 f(12, 12, 6, 1, '#3A3A3C')
116 stamp(f, 0, 13, ['bbb.bbb', 'bwwbwwb', 'bwwbwwb', 'bbbbbbb'], { b: '#6B3E9E', w: '#F8F6F1' })
117 f(4, 14, 2, 2, '#E9E4D6', 'pg')
118 },
119 },
120 // Signaller: headset; waves a semaphore flag.
121 signaller: {
122 hold: -4,
123 draw: f => {
124 f(8, 7, 14, 1, '#3A3A3C')
125 f(7, 8, 1, 3, '#3A3A3C')
126 f(22, 8, 1, 3, '#3A3A3C')
127 f(5, 10, 2, 4, '#D0453F')
128 f(23, 7, 2, 3, '#D0453F')
129 f(26, 2, 1, 12, '#8E929A')
130 stamp(f, 27, 2, ['yyy', 'ryy', 'rry'], { y: GOLD, r: '#D0453F' }, 'fl')
131 },
132 },
133 // Echo (a fork): beanie with a pompom; a faint double of the captain.
134 echo: {
135 hold: 0,
136 draw: (f, t) => {
137 stamp(f, 7, 5, ['.......pp.......', '....pppppppp....', '..pppppppppppp..', '.pppppppppppppp.', 'wwwwwwwwwwwwwwww'], { p: t, w: '#C9BFFA' })
138 },
139 },
140 // Cook: toque; tosses an omelette.
141 cook: {
142 hold: -4,
143 draw: f => {
144 stamp(f, 7, 1, ['...wwwwwwwwww...', '.wwwwwwwwwwwwww.', 'wwwwwwwwwwwwwwww', 'wwwwwwgwwwwgwwww', '.wwwwwgwwwwgwww.', '..wwwwgwwwwgww..', '..gggggggggggg..', '..wwwwwwwwwwww..', '..wwwwwwwwwwww..'], { w: '#F4F3EE', g: '#D2D1C8' })
145 f(22, 9, 7, 2, '#4A4A48')
146 f(24, 8, 3, 1, '#F5B731', 'eg')
147 },
148 },
149 // Sleuth: deerstalker; peers through a magnifier.
150 sleuth: {
151 hold: -4,
152 draw: f => {
153 stamp(f, 6, 4, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
154 stamp(f, 23, 2, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'mg')
155 f(24, 3, 3, 3, 'rgba(169,214,245,.7)', 'mg')
156 f(25, 7, 1, 3, '#7A4A26', 'mg')
157 },
158 },
159 // Wizard: starry hat; the wand sparkles.
160 wizard: {
161 hold: -4,
162 draw: f => {
163 stamp(f, 7, 0, ['.......bb.......', '......bbbb......', '......bsbb......', '.....bbbbbb.....', '.....bbbbsb.....', '....bbbbbbbb....', '...bsbbbbbbbb...', '..bbbbbbbbbbbb..', 'bbbbbbbbbbbbbbbb'], { b: '#4B3B8F', s: GOLD })
164 f(6, 8, 18, 1, '#4B3B8F')
165 f(26, 5, 1, 6, '#5A3519')
166 stamp(f, 25, 2, ['.s.', 'sss', '.s.'], { s: GOLD }, 'sp')
167 },
168 },
169 // Diver: mask and snorkel; bubbles rise.
170 diver: {
171 hold: 0,
172 draw: f => {
173 f(7, 10, 16, 1, '#2E8BC0')
174 stamp(f, 7, 11, ['mggggm..mggggm', 'mggggmmmmggggm', 'mmmmmm..mmmmmm'], { m: '#2E8BC0', g: 'rgba(169,214,245,.45)' })
175 f(23, 4, 1, 8, '#F07E2E')
176 f(21, 11, 2, 1, '#F07E2E')
177 f(24, 1, 2, 2, '#A9D6F5', 'bu')
178 f(26, 0, 1, 1, '#A9D6F5', 'bu2')
179 },
180 },
181}
182
183
184const lookOf = (pose: Pose, hold: number): Look => {
185 switch (pose) {
186 case 'think':
187 return { face: 'up', armL: 0, armR: hold }
188 case 'cheer':
189 return { face: 'happy', armL: -8, armR: -8 }
190 case 'rest':
191 return { face: 'happy', armL: 0, armR: hold }
192 case 'dizzy':
193 return { face: 'x', armL: 0, armR: 0 }
194 case 'sleep':
195 return { face: 'closed', armL: 0, armR: 0 }
196 default:
197 return { face: 'open', armL: 0, armR: hold }
198 }
199}
200
201/**
202 * Pure CSS on the compositor, no redraws. Periods divide one second, so the
203 * once-a-second redraw of a running row restarts them in phase.
204 */
205export const CRAB_CSS = `<style>
206g{transform-box:fill-box}
207.p-walk .la{animation:st .5s steps(1) infinite}.p-walk .lb{animation:st .5s steps(1) infinite -.25s}.p-walk .bd{animation:bob .5s steps(1) infinite -.125s}
208.p-fast .la{animation:st .25s steps(1) infinite}.p-fast .lb{animation:st .25s steps(1) infinite -.125s}.p-fast .bd{animation:bob .25s steps(1) infinite}
209@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
210.p-think .bd{transform-origin:50% 100%;animation:sway 1s ease-in-out infinite}@keyframes sway{25%{transform:rotate(-3deg)}75%{transform:rotate(3deg)}}
211.p-wait .lb{animation:tap .5s steps(1) infinite}@keyframes tap{50%{transform:translateY(-2px)}}
212.p-cheer{animation:jump .5s ease-out infinite}@keyframes jump{40%{transform:translateY(-4px)}}
213.p-dizzy .la{animation:wig .25s steps(1) infinite}.p-dizzy .lb{animation:wig .25s steps(1) infinite -.125s}@keyframes wig{50%{transform:translateX(1px)}}
214.zz{opacity:0;animation:zz 2s linear infinite}.zz2{animation-delay:-1s}@keyframes zz{0%{opacity:0;transform:translate(0,0)}20%{opacity:1}100%{opacity:0;transform:translate(4px,-8px)}}
215.dot{animation:blink 1s steps(1) infinite}.dot2{animation-delay:-.66s}.dot3{animation-delay:-.33s}@keyframes blink{50%{opacity:.15}}
216.star{animation:blink .5s steps(1) infinite}.star2{animation-delay:-.25s}
217.hg{transform-origin:50% 50%;animation:hg 1s steps(1) infinite}@keyframes hg{50%{transform:rotate(180deg)}}
218.on .wh{transform-origin:50% 50%;animation:spin 1s linear infinite}@keyframes spin{to{transform:rotate(90deg)}}
219.on .mop{transform-origin:50% 0;animation:swish .5s ease-in-out infinite}@keyframes swish{25%{transform:rotate(-14deg)}75%{transform:rotate(14deg)}}
220.on .sg{transform-origin:0 50%;animation:scan 1s ease-in-out infinite}@keyframes scan{25%{transform:rotate(-12deg)}75%{transform:rotate(6deg)}}
221.on .map{transform-origin:0 50%;animation:unroll 1s ease-in-out infinite}@keyframes unroll{50%{transform:scaleX(.55)}}
222.on .pg{transform-origin:0 50%;animation:flip .5s steps(1) infinite}@keyframes flip{50%{transform:scaleX(-1)}}
223.on .ts{transform-origin:50% 0;animation:swing 1s ease-in-out infinite}@keyframes swing{25%{transform:rotate(20deg)}75%{transform:rotate(-20deg)}}
224.on .fl{transform-origin:0 50%;animation:wave .25s steps(1) infinite}@keyframes wave{50%{transform:skewY(-14deg) scaleX(.85)}}
225.on .eg{animation:toss 1s ease-in-out infinite}@keyframes toss{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:none}}
226.on .mg{animation:peer 1s steps(1) infinite}@keyframes peer{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
227.on .sp{animation:blink .25s steps(1) infinite}
228.on .bu,.on .bu2{animation:rise 1s linear infinite}.on .bu2{animation-delay:-.5s}@keyframes rise{0%{opacity:0;transform:translateY(2px)}30%{opacity:1}100%{opacity:0;transform:translateY(-6px)}}
229@media (prefers-reduced-motion: reduce){*{animation:none!important}.zz{opacity:1}}
230</style>`
231
232/** One crab at (x, y), in the costume of `key` (a character, or `captain`), drawn in `pose`. */
233export const crab = (x: number, y: number, key: string, pose: Pose, scale = 1.1, color = '#888780'): string => {
234 const groups = new Map<string, string[]>([['bd', []]])
235 const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
236 const list = groups.get(cls) ?? []
237 groups.set(cls, list)
238 list.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
239 }
240 const costume = COSTUMES[key] ?? COSTUMES.deckhand
241 body(f, lookOf(pose, costume?.hold ?? 0))
242 costume?.draw(f, color)
243
244 const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
245 const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
246 const inner = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>${group('la')}${group('lb')}`
247 const isOn = pose === 'walk' || pose === 'fast'
248 const flipped = pose === 'dizzy' ? `<g transform="rotate(180 15 17)">${inner}</g>` : inner
249
250 const extras: string[] = []
251 if (pose === 'dizzy') {
252 extras.push(`<g fill="${GOLD}"><rect class="star" x="6" y="27" width="3" height="1"/><rect class="star" x="7" y="26" width="1" height="3"/><rect class="star star2" x="21" y="27" width="3" height="1"/><rect class="star star2" x="22" y="26" width="1" height="3"/></g>`)
253 }
254 if (pose === 'sleep') {
255 extras.push(`<text class="zz m" x="24" y="9" font-family="${FONT}" font-size="7" font-weight="700">z</text><text class="zz zz2 m" x="27" y="5" font-family="${FONT}" font-size="5" font-weight="700">z</text>`)
256 }
257 if (pose === 'wait') {
258 const hg: string[] = []
259 stamp((cx, cy, w, h, c) => hg.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`), 24, 0, ['hhhhh', '.sss.', '..s..', '.s.s.', 'hhhhh'], { h: '#8B5A2B', s: GOLD })
260 extras.push(`<g class="hg">${hg.join('')}</g>`)
261 }
262
263 return `<g transform="translate(${x},${y}) scale(${scale})" shape-rendering="crispEdges"${pose === 'sleep' && key !== 'captain' ? ' opacity="0.7"' : ''}><g class="c-${key} p-${pose}${isOn ? ' on' : ''}">${flipped}</g>${extras.join('')}</g>`
264}
265
266// ---------------------------------------------------------------------------
267// Text measurement and SVG scaffolding.
268
269export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif"
270
271export const xml = (s: string): string =>
272 s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
273
274/** Rough advance of system UI text, in em; enough to size bubbles and cut lines. */
275const charEm = (ch: string): number => (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Zmw@%]/.test(ch) ? 0.72 : 0.56)
276
277export const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
278
279export const fitText = (s: string, size: number, maxW: number): string => {
280 if (textWidth(s, size) <= maxW) return s
281 let out = ''
282 for (const ch of s) {
283 if (textWidth(out + ch + '…', size) > maxW) break
284 out += ch
285 }
286 return out + '…'
287}
288
289const PALETTE = `<style>
290.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.ln{stroke:#e4e4e1}.sand{fill:#E9DCBC}.fp{fill:#CDB98C}.bub{fill:#ffffff;stroke:#d8d6d0}.k{fill:#ecebe8}
291@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.ln{stroke:#333331}.sand{fill:#4a4232}.fp{fill:#6b5f45}.bub{fill:#2b2b2a;stroke:#444442}.k{fill:#2c2c2b}}
292.live{animation:p 1s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
293@media (prefers-reduced-motion: reduce){.live{animation:none}}
294</style>`
295
296const svg = (W: number, H: number, body: string): string =>
297 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PALETTE}${CRAB_CSS}${body}</svg>`
298
299const statusMark = (x: number, y: number, status: string, color: string): string => {
300 if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
301 if (status === 'done') return `<path d="M${x - 5} ${y}l3.5 3.5 6.5-7" fill="none" stroke="#3B9C5F" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
302 if (status === 'failed') return `<path d="M${x - 4} ${y - 4}l8 8M${x + 4} ${y - 4}l-8 8" stroke="#D0453F" stroke-width="1.8" stroke-linecap="round"/>`
303 return `<rect x="${x - 4}" y="${y - 4}" width="8" height="8" rx="1.5" fill="#9a9a96"/>`
304}
305
306/** A speech bubble, or a thought bubble with dots that blink in turn. */
307const bubble = (x: number, y: number, text: string, isFlipped: boolean, isThought: boolean, maxW: number): string => {
308 const size = 11
309 const dotsW = isThought ? 16 : 0
310 const shown = fitText(text, size, Math.max(30, maxW - 14 - dotsW))
311 const w = Math.round(textWidth(shown, size) + 14 + dotsW)
312 const h = 18
313 const tailX = isFlipped ? x + w - 12 : x + 12
314 const tail = isThought
315 ? `<circle class="bub" cx="${tailX + (isFlipped ? 2 : -2)}" cy="${y + h + 3}" r="2.2"/><circle class="bub" cx="${tailX + (isFlipped ? 5 : -5)}" cy="${y + h + 7}" r="1.4"/>`
316 : `<path class="bub" d="M${tailX - 4} ${y + h - 0.5}L${tailX + (isFlipped ? 4 : -4)} ${y + h + 6}L${tailX + 4} ${y + h - 0.5}Z"/><rect class="bub" style="stroke:none" x="${tailX - 3.4}" y="${y + h - 2}" width="6.8" height="2"/>`
317 const dots = isThought
318 ? [0, 1, 2].map(i => `<circle class="m dot${i ? ` dot${i + 1}` : ''}" cx="${x + w - dotsW - 2 + i * 5 + 3}" cy="${y + h / 2}" r="1.5"/>`).join('')
319 : ''
320 return `<g><rect class="bub" x="${x}" y="${y}" width="${w}" height="${h}" rx="9"/>${tail}<text class="t" x="${x + 7}" y="${y + 12.5}" font-family="${FONT}" font-size="${size}">${xml(shown)}</text>${dots}</g>`
321}
322
323// ---------------------------------------------------------------------------
324// The panel's drawings (desktop): each row is one SVG.
325
326export const RUNNING_H = 98
327export const FINISHED_H = 54
328export const HEADER_H = 58
329export const ROSTER_H = 44
330export const BAND_H = 26
331
332const CRAB_W = 33
333
334export const runningSvg = (W: number, m: CrewMember, at: number, showQuips: boolean): string => {
335 const c = characterOf(m.character)
336 const pose = poseOf(m, at)
337 const { value } = progressOf(m)
338 const right = `${fmtTime(elapsed(m, at))}`
339 const meta = [modelName(m.model), actionOf(m, at), `${m.toolCalls} tools`, m.tokens ? `${fmtTokens(m.tokens)} tok` : ''].filter(Boolean).join(' · ')
340 const label = progressLabel(m)
341 const metaW = W - textWidth(label, 11) - 14
342
343 // The lane: the crab walks toward the flag as its work progresses.
344 const laneBottom = RUNNING_H - 8
345 const crabX = Math.round(value * (W - CRAB_W - 20))
346 const crabY = laneBottom - 31
347 const prints: string[] = []
348 for (let fx = 4, k = 0; fx < crabX - 2; fx += 7, k++) prints.push(`<rect class="fp" x="${fx}" y="${laneBottom - 2 + (k % 2)}" width="3" height="1.5" rx=".75"/>`)
349
350 let speech = ''
351 if (showQuips) {
352 const text = quipOf(m, at)
353 const room = W - 20
354 const bw = Math.min(room, textWidth(text, 11) + 14 + (pose === 'think' ? 16 : 0))
355 const isFlipped = crabX + 26 + bw > W - 18
356 const bx = isFlipped ? Math.max(0, crabX + 8 - bw) : crabX + 26
357 speech = bubble(bx, 38, text, isFlipped, pose === 'think', room)
358 }
359
360 return svg(
361 W,
362 RUNNING_H,
363 `<text x="0" y="14" font-family="${FONT}" font-size="13"><tspan class="t" font-weight="600">${xml(m.name)}</tspan><tspan fill="${c.color}" font-weight="600"> the ${xml(c.role)}</tspan><tspan class="s"> · ${xml(fitText(m.description || m.type, 13, Math.max(40, W - 60 - textWidth(`${m.name} the ${c.role} · `, 13))))}</tspan></text>
364<text class="s" x="${W - 14}" y="14" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${right}</text>
365${statusMark(W - 4, 10, m.status, c.color)}
366<text class="s" x="0" y="30" font-family="${FONT}" font-size="11">${xml(fitText(meta, 11, metaW))}</text>
367<text x="${W}" y="30" text-anchor="end" font-family="${FONT}" font-size="11" font-weight="600" fill="${c.color}" font-variant-numeric="tabular-nums">${label}</text>
368<rect class="sand" x="0" y="${laneBottom}" width="${W}" height="3" rx="1.5"/>
369${prints.join('')}
370<rect class="m" x="${W - 11}" y="${laneBottom - 26}" width="1.5" height="26"/>
371<path d="M${W - 9.5} ${laneBottom - 26}L${W - 1} ${laneBottom - 21.5}L${W - 9.5} ${laneBottom - 17}Z" fill="${c.color}"/>
372${speech}
373${crab(crabX, crabY, m.character, pose, 1.1, c.color)}
374<line class="ln" x1="0" y1="${RUNNING_H - 0.5}" x2="${W}" y2="${RUNNING_H - 0.5}"/>`,
375 )
376}
377
378export const finishedSvg = (W: number, m: CrewMember, at: number, showQuips: boolean): string => {
379 const c = characterOf(m.character)
380 const pose = poseOf(m, at)
381 const how = m.status === 'done' ? 'Back aboard' : m.status === 'failed' ? 'Went overboard' : 'Called back'
382 const meta = [how, fmtTime(elapsed(m, at)), modelName(m.model), `${m.toolCalls} tools`, m.tokens ? `${fmtTokens(m.tokens)} tok` : ''].filter(Boolean).join(' · ')
383 const textW = W - 40 - 16
384 return svg(
385 W,
386 FINISHED_H,
387 `${crab(0, 16, m.character, pose, 1, c.color)}
388<text x="40" y="14" font-family="${FONT}" font-size="13"><tspan class="t" font-weight="600">${xml(m.name)}</tspan><tspan fill="${c.color}" font-weight="600"> the ${xml(c.role)}</tspan><tspan class="s"> · ${xml(fitText(m.description || m.type, 13, Math.max(40, textW - textWidth(`${m.name} the ${c.role} · `, 13))))}</tspan></text>
389<text class="s" x="40" y="30" font-family="${FONT}" font-size="11">${xml(fitText(meta, 11, textW))}</text>
390${showQuips ? `<text class="m" x="40" y="45" font-family="${FONT}" font-size="11" font-style="italic">“${xml(fitText(quipOf(m, at), 11, textW))}”</text>` : ''}
391${statusMark(W - 6, 10, m.status, c.color)}
392<line class="ln" x1="0" y1="${FINISHED_H - 0.5}" x2="${W}" y2="${FINISHED_H - 0.5}"/>`,
393 )
394}
395
396export const headerSvg = (W: number, captain: Captain, crew: readonly CrewMember[], at: number): string => {
397 const pose: Pose = captain.isBusy ? (captain.activity === 'think' ? 'think' : 'walk') : 'sleep'
398 const t = crewTotals(crew, at)
399 const stats = crew.length ? `${crewSummary(crew, at)} · ${fmtTokens(t.tokens)} tok · ${fmtTime(t.time)}` : 'No crew dispatched yet'
400 const textW = W - 44
401 return svg(
402 W,
403 HEADER_H,
404 `${crab(0, 18, 'captain', pose, 1.1, CAPTAIN.color)}
405<text x="44" y="15" font-family="${FONT}" font-size="14"><tspan class="t" font-weight="600">${CAPTAIN.name}</tspan><tspan class="s" font-size="12"> · ${CAPTAIN.role}</tspan></text>
406<text class="s" x="44" y="32" font-family="${FONT}" font-size="12">${xml(fitText(captainQuip(captain), 12, textW))}</text>
407<text class="m" x="44" y="49" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(fitText(stats, 11, textW))}</text>
408<line class="ln" x1="0" y1="${HEADER_H - 0.5}" x2="${W}" y2="${HEADER_H - 0.5}"/>`,
409 )
410}
411
412export const rosterSvg = (W: number, c: Character, types: string): string =>
413 svg(
414 W,
415 ROSTER_H,
416 `${crab(0, 8, c.key, 'walk', 1, c.color)}
417<text x="40" y="16" font-family="${FONT}" font-size="12"><tspan fill="${c.color}" font-weight="600">${xml(c.role)}</tspan><tspan class="s"> · ${xml(types)} · ${xml(c.names.slice(0, 3).join(', '))}…</tspan></text>
418<text class="m" x="40" y="32" font-family="${FONT}" font-size="11" font-style="italic">${xml(fitText(c.trait, 11, W - 44))}</text>`,
419 )
420
421/** The band above the prompt: the crew at work, walking, and how far along they are. */
422export const bandSvg = (W: number, crew: readonly CrewMember[], at: number, progress: number): string => {
423 const running = crew.filter(m => m.status === 'running')
424 const shown = (running.length ? running : crew).slice(-Math.max(1, Math.floor((W * 0.45) / 26)))
425 const crabs = shown.map((m, i) => crab(i * 26, 3, m.character, running.length ? poseOf(m, at) : 'rest', 0.72, characterOf(m.character).color)).join('')
426 const x0 = shown.length * 26 + 6
427 const names = running.length
428 ? `${running.slice(0, 3).map(m => m.name).join(', ')}${running.length > 3 ? ` +${running.length - 3}` : ''}: ${crewSummary(crew, at)}`
429 : `All back aboard · ${crewSummary(crew, at)}`
430 const barW = Math.max(40, Math.min(160, W * 0.2))
431 const textW = W - x0 - barW - 52
432 const barX = W - barW - 40
433 return svg(
434 W,
435 BAND_H,
436 `${crabs}
437<text class="t" x="${x0}" y="17" font-family="${FONT}" font-size="12.5">${xml(fitText(names, 12.5, textW))}</text>
438<rect class="k" x="${barX}" y="9" width="${barW}" height="8" rx="4"/>
439<rect x="${barX}" y="9" width="${Math.round(barW * progress)}" height="8" rx="4" fill="${running.length ? CLAY : '#3B9C5F'}"/>
440<text class="s" x="${W}" y="17" text-anchor="end" font-family="${FONT}" font-size="11.5" font-variant-numeric="tabular-nums">${running.length ? '≈' : ''}${Math.round(progress * 100)}%</text>`,
441 )
442}
443
444// ---------------------------------------------------------------------------
445// Terminal crabs: the classic ASCII crab, its frame switched by the clock tick.
446
447export const asciiCrab = (pose: Pose, tick: number): string => {
448 const odd = tick % 2 === 1
449 switch (pose) {
450 case 'walk':
451 case 'fast':
452 return odd ? '(\\|)(°,,°)(|/)' : '(\\/)(°,,°)(\\/)'
453 case 'think':
454 return odd ? '(\\/)(˘,,˘)(\\/)' : '(\\/)(°,,°)(\\/)'
455 case 'wait':
456 return '(\\/)(°,,°)(\\/)'
457 case 'cheer':
458 return odd ? '\\(^,,^)/' : '_(^,,^)_'
459 case 'rest':
460 return '(\\/)(^,,^)(\\/)'
461 case 'dizzy':
462 return '(/\\)(x,,x)(/\\)'
463 case 'sleep':
464 return '(\\/)(-,,-)(\\/)'
465 }
466}
467
468/** A crab on its lane in text: footprints behind it, the flag ahead. */
469export const asciiLane = (pose: Pose, tick: number, value: number, width: number): { before: string; crab: string; after: string } => {
470 const c = asciiCrab(pose, tick)
471 const room = Math.max(0, width - c.length - 2)
472 const isMoving = pose === 'walk' || pose === 'fast'
473 const pos = Math.min(room, Math.round(value * room) + (isMoving && tick % 2 ? 1 : 0))
474 return { before: '.'.repeat(pos), crab: c, after: ' '.repeat(Math.max(0, room - pos)) + ' ⚑' }
475}
476hooks/crew.ts 465 lines1import type { Activity, Captain, CrewMember, CrewStatus } from '../types'
2
3// ---------------------------------------------------------------------------
4// The roster: one crab character per standard agent type, and a pool that
5// any other agent type (a plugin's, a project's own) is dealt from by name.
6
7/** What a crab says depends on its mood: its activity, or how its run ended. */
8export type Mood = Activity | 'wait' | 'done' | 'failed' | 'stopped'
9
10/** How a crab is drawn: each pose is a body, a face and a motion. */
11export type Pose = 'walk' | 'fast' | 'think' | 'wait' | 'cheer' | 'rest' | 'dizzy' | 'sleep'
12
13export type Character = {
14 key: string
15 role: string
16 names: readonly string[]
17 color: string
18 trait: string
19 /** Tool calls in a typical job: sets the estimated-progress curve. */
20 pace: number
21 quips: Partial<Record<Mood, readonly string[]>>
22}
23
24const GENERIC: Record<Mood, readonly string[]> = {
25 start: ['Reporting for duty!', 'Aye aye!', 'Claws ready.'],
26 think: ['Hmm, let me think', 'Pondering', 'Mulling it over'],
27 read: ['Reading {x}', 'Peeking at {x}'],
28 search: ['Searching for {x}', 'Combing the sand'],
29 edit: ['Tweaking {x}', 'Patching {x}'],
30 shell: ['Running {x}', 'Cranking the engine'],
31 web: ['Checking {x}', 'Asking the wider sea'],
32 delegate: ['Calling in help: {x}', 'Need a hand here!'],
33 plan: ['Making a list', 'Checking it twice'],
34 other: ['On it', 'Busy, busy'],
35 wait: ['Still waiting on {x}', 'Tap, tap, tap'],
36 done: ['Back aboard!', 'Job done.', 'Nailed it.'],
37 failed: ['Man overboard!', 'That went sideways.'],
38 stopped: ['Called back to the ship.', 'Standing down.'],
39}
40
41export const CHARACTERS: Record<string, Character> = {
42 deckhand: {
43 key: 'deckhand',
44 role: 'Deckhand',
45 names: ['Barnacle', 'Rusty', 'Sal', 'Dory', 'Mack'],
46 color: '#D0453F',
47 trait: 'Can-do all-rounder: mops, fixes, hauls.',
48 pace: 22,
49 quips: {
50 start: ["Aye, I'll handle it!", 'Rolling up me claws.'],
51 edit: ['Hammering {x} into shape', 'Swabbing {x}'],
52 shell: ['Hauling on {x}'],
53 wait: ['Leaning on the mop'],
54 done: ['Shipshape and done!', "Deck's clean, Cap'n."],
55 failed: ['Slipped on the deck!'],
56 },
57 },
58 scout: {
59 key: 'scout',
60 role: 'Scout',
61 names: ['Pip', 'Wren', 'Kit', 'Marlo', 'Juno'],
62 color: '#4E9A3A',
63 trait: 'Curious and quick; looks, never touches.',
64 pace: 14,
65 quips: {
66 start: ['Off to explore!', 'Spyglass ready.'],
67 read: ['Spyglass on {x}', 'Ooh, what is {x}?'],
68 search: ['Land ho? Hunting {x}', 'Scanning for {x}'],
69 think: ['Mapping what I saw'],
70 done: ['Scouting report ready!', 'Charted it all!'],
71 failed: ['Lost in the fog!'],
72 },
73 },
74 navigator: {
75 key: 'navigator',
76 role: 'Navigator',
77 names: ['Ada', 'Vela', 'Orion', 'Mags'],
78 color: '#378ADD',
79 trait: 'Measures twice, plots the course.',
80 pace: 16,
81 quips: {
82 start: ['Unrolling the charts.'],
83 think: ['Plotting a course', 'Weighing the winds'],
84 read: ['Studying {x}'],
85 search: ['Taking soundings: {x}'],
86 plan: ['Marking waypoints'],
87 done: ['Course plotted!', 'Heading set, Captain.'],
88 failed: ['Off the edge of the map!'],
89 },
90 },
91 professor: {
92 key: 'professor',
93 role: 'Professor',
94 names: ['Kelp', 'Sage', 'Quill', 'Tomes'],
95 color: '#7F77DD',
96 trait: 'Has read every manual twice.',
97 pace: 10,
98 quips: {
99 start: ['Ahem. To the library!'],
100 read: ['Consulting {x}'],
101 web: ['Looking it up: {x}'],
102 think: ['Fascinating, one moment'],
103 done: ['Footnotes included.'],
104 failed: ['The page is missing!'],
105 },
106 },
107 signaller: {
108 key: 'signaller',
109 role: 'Signaller',
110 names: ['Sparky', 'Dot', 'Morse'],
111 color: '#BA7517',
112 trait: 'Tinkers with the lights and signals.',
113 pace: 8,
114 quips: {
115 start: ['Signals up!'],
116 edit: ['Rewiring {x}'],
117 shell: ['Testing the lamp: {x}'],
118 done: ["Signal's lit!"],
119 },
120 },
121 echo: {
122 key: 'echo',
123 role: 'Understudy',
124 names: ['Echo', 'Mirror', 'Twin'],
125 color: '#8F7CEC',
126 trait: 'Knows everything the Captain knows.',
127 pace: 20,
128 quips: {
129 start: ['Same brain, new claws.'],
130 think: ['As the Captain would'],
131 done: ['Echo complete.'],
132 },
133 },
134 // The pool: other agent types are dealt one of these by name.
135 cook: {
136 key: 'cook',
137 role: 'Cook',
138 names: ['Cookie', 'Saffron', 'Basil'],
139 color: '#D85A30',
140 trait: 'Throws it all in the pan and flips.',
141 pace: 20,
142 quips: {
143 start: ['Firing up the galley!'],
144 edit: ['Stirring {x}', 'A pinch of {x}'],
145 shell: ['Turning up the heat'],
146 done: ['Order up!'],
147 failed: ['Burnt the omelette!'],
148 },
149 },
150 sleuth: {
151 key: 'sleuth',
152 role: 'Sleuth',
153 names: ['Inky', 'Clue', 'Sherl'],
154 color: '#A0703F',
155 trait: 'Follows every clue to the end.',
156 pace: 18,
157 quips: {
158 start: ['The game is afoot!'],
159 search: ['Following the trail: {x}'],
160 read: ['Examining {x}'],
161 think: ['Connecting the dots'],
162 done: ['Case closed.'],
163 failed: ['The trail went cold.'],
164 },
165 },
166 wizard: {
167 key: 'wizard',
168 role: 'Wizard',
169 names: ['Merla', 'Coral', 'Zap'],
170 color: '#7B6BD8',
171 trait: 'Mutters spells at the code.',
172 pace: 20,
173 quips: {
174 start: ['By my shell!'],
175 edit: ['Enchanting {x}'],
176 shell: ['Casting {x}'],
177 think: ['Consulting the stars'],
178 done: ['Ta-da!'],
179 failed: ['The spell fizzled!'],
180 },
181 },
182 diver: {
183 key: 'diver',
184 role: 'Diver',
185 names: ['Bubbles', 'Finn', 'Marina'],
186 color: '#2E8BC0',
187 trait: 'Goes deep, comes up with treasure.',
188 pace: 20,
189 quips: {
190 start: ['Taking the plunge!'],
191 search: ['Diving for {x}'],
192 read: ['Deep in {x}'],
193 done: ['Surfaced with treasure!'],
194 failed: ['Out of air!'],
195 },
196 },
197}
198
199const POOL = ['cook', 'sleuth', 'wizard', 'diver'] as const
200
201const BY_TYPE: Record<string, string> = {
202 'general-purpose': 'deckhand',
203 claude: 'deckhand',
204 Explore: 'scout',
205 Plan: 'navigator',
206 'claude-code-guide': 'professor',
207 'statusline-setup': 'signaller',
208 fork: 'echo',
209}
210
211/** The roster as the panel shows it: the standard crew first, then the pool. */
212export const ROSTER: readonly { character: Character; types: string }[] = [
213 { character: CHARACTERS.deckhand as Character, types: 'general-purpose' },
214 { character: CHARACTERS.scout as Character, types: 'Explore' },
215 { character: CHARACTERS.navigator as Character, types: 'Plan' },
216 { character: CHARACTERS.professor as Character, types: 'claude-code-guide' },
217 { character: CHARACTERS.signaller as Character, types: 'statusline-setup' },
218 { character: CHARACTERS.echo as Character, types: 'fork' },
219 ...POOL.map(k => ({ character: CHARACTERS[k] as Character, types: 'other agent types' })),
220]
221
222export const CAPTAIN = {
223 name: 'Captain Clawd',
224 role: 'main thread',
225 color: '#4A6FB5',
226 quips: {
227 start: ['All hands!'],
228 think: ['Charting the course'],
229 read: ['Reviewing {x}'],
230 search: ['Scanning the horizon: {x}'],
231 edit: ['Taking the wheel: {x}'],
232 shell: ['Full steam: {x}'],
233 web: ['Signalling ashore: {x}'],
234 delegate: ['Dispatching the crew: {x}'],
235 plan: ['Updating the log'],
236 other: ['At the helm'],
237 } as Record<Activity, readonly string[]>,
238 idle: 'Off watch, awaiting orders',
239}
240
241/** `savvy:helper` and `helper` name the same agent; the plugin prefix is dropped. */
242export const bareType = (type: string): string => type.replace(/^[^:]*:/, '')
243
244/** FNV-1a: a stable pick from a name, the same on every load. */
245export const hash = (s: string): number => {
246 let h = 0x811c9dc5
247 for (let i = 0; i < s.length; i++) {
248 h ^= s.charCodeAt(i)
249 h = Math.imul(h, 0x01000193)
250 }
251 return h >>> 0
252}
253
254export const characterKeyOf = (type: string): string => {
255 const known = BY_TYPE[type] ?? BY_TYPE[bareType(type)]
256 return known ?? POOL[hash(bareType(type)) % POOL.length] ?? 'deckhand'
257}
258
259export const characterOf = (key: string): Character => CHARACTERS[key] ?? (CHARACTERS.deckhand as Character)
260
261const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X']
262
263/** The next free name for this character: Pip, Wren, ..., then Pip II. */
264export const crewNameOf = (key: string, crew: readonly CrewMember[]): string => {
265 const { names } = characterOf(key)
266 const taken = crew.filter(m => m.character === key).length
267 const base = names[taken % names.length] ?? 'Crab'
268 const round = Math.floor(taken / names.length) + 1
269 return round > 1 ? `${base} ${ROMAN[round] ?? round}` : base
270}
271
272export const titleOf = (m: Pick<CrewMember, 'name' | 'character'>): string => `${m.name} the ${characterOf(m.character).role}`
273
274// ---------------------------------------------------------------------------
275// What a tool call says about a crab's activity.
276
277export const activityOf = (tool: string): Activity => {
278 if (/^(Read|NotebookRead)$/.test(tool)) return 'read'
279 if (/^(Grep|Glob|LS|ToolSearch)$/.test(tool)) return 'search'
280 if (/^(Edit|MultiEdit|Write|NotebookEdit)$/.test(tool)) return 'edit'
281 if (/^(Bash|PowerShell|Monitor|BashOutput)$/.test(tool)) return 'shell'
282 if (/^(WebFetch|WebSearch)$/.test(tool) || (tool.startsWith('mcp__') && /browser|chrome|fetch|web/i.test(tool))) return 'web'
283 if (/^(Agent|Task|SendMessage|Workflow)$/.test(tool)) return 'delegate'
284 if (/^(TodoWrite|TaskCreate|TaskUpdate|TaskList|TaskGet|EnterPlanMode|ExitPlanMode)$/.test(tool)) return 'plan'
285 return 'other'
286}
287
288const str = (v: unknown): string => (typeof v === 'string' ? v : '')
289
290const baseName = (p: string): string => p.split(/[\\/]/).filter(Boolean).pop() ?? p
291
292export const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
293
294/** The call's target in a few characters: a file's name, a pattern, a command's head, a host. */
295export const detailOf = (input: Record<string, unknown>): string => {
296 const file = str(input.file_path) || str(input.notebook_path)
297 if (file) return baseName(file)
298 if (str(input.pattern)) return clip(str(input.pattern), 28)
299 const command = str(input.command).trim().split('\n')[0] ?? ''
300 if (command) return clip(command, 28)
301 const url = str(input.url)
302 if (url) return /^[a-z]+:\/\/([^/?#]+)/i.exec(url)?.[1] ?? clip(url, 28)
303 if (str(input.query)) return clip(str(input.query), 28)
304 if (str(input.description)) return clip(str(input.description), 28)
305 if (str(input.skill)) return str(input.skill)
306 if (str(input.path)) return baseName(str(input.path))
307 return ''
308}
309
310type Todo = { status?: unknown; activeForm?: unknown; content?: unknown }
311
312/**
313 * Progress a standard agent reports without knowing it: its own todo list.
314 * TodoWrite carries the whole list; TaskCreate and TaskUpdate are counted.
315 */
316export const todoPatch = (tool: string, input: Record<string, unknown>, m: CrewMember): Partial<CrewMember> => {
317 if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
318 const todos = input.todos as Todo[]
319 const done = todos.filter(t => t.status === 'completed').length
320 const active = todos.find(t => t.status === 'in_progress')
321 return {
322 todoTotal: todos.length,
323 todoDone: done + (active ? 0.5 : 0),
324 todoNote: active ? str(active.activeForm) || str(active.content) || undefined : undefined,
325 }
326 }
327 if (tool === 'TaskCreate') return { todoTotal: (m.todoTotal ?? 0) + 1, todoDone: m.todoDone ?? 0 }
328 if (tool === 'TaskUpdate') {
329 const total = m.todoTotal ?? 0
330 const done = Math.floor(m.todoDone ?? 0)
331 if (input.status === 'completed') return { todoDone: Math.min(total, done + 1), todoNote: undefined }
332 if (input.status === 'deleted') return { todoTotal: Math.max(0, total - 1), todoDone: Math.min(done, Math.max(0, total - 1)) }
333 if (input.status === 'in_progress') return { todoDone: done + 0.5, todoNote: str(input.activeForm) || str(input.subject) || undefined }
334 }
335 return {}
336}
337
338export const statusOf = (reason: string): CrewStatus =>
339 reason === 'answer' ? 'done' : reason === 'aborted' ? 'stopped' : 'failed'
340
341// ---------------------------------------------------------------------------
342// Progress, pose and lines.
343
344/** No call for this long while running: the crab waits, tapping a foot. */
345export const STALL_MS = 20_000
346/** A call this recent: the crab scurries. */
347export const BURST_MS = 3_000
348/** How long a crab that just finished celebrates. */
349export const CHEER_MS = 6_000
350
351export type Progress = { value: number; isEstimate: boolean }
352
353/**
354 * Real progress when the agent keeps a todo list; otherwise an estimate that
355 * climbs with its tool calls toward 92% at the character's usual pace and
356 * only reaches the flag when the agent is done.
357 */
358export const progressOf = (m: CrewMember): Progress => {
359 if (m.status === 'done') return { value: 1, isEstimate: false }
360 if (m.todoTotal) return { value: Math.min(1, (m.todoDone ?? 0) / m.todoTotal), isEstimate: false }
361 const pace = characterOf(m.character).pace
362 return { value: 0.92 * (1 - Math.exp(-(m.toolCalls + 0.5) / pace)), isEstimate: true }
363}
364
365export const progressLabel = (m: CrewMember): string => {
366 if (m.status === 'done') return '100%'
367 if (m.todoTotal) return `${Math.floor(m.todoDone ?? 0)}/${m.todoTotal} todos`
368 return `≈${Math.round(progressOf(m).value * 100)}%`
369}
370
371export const poseOf = (m: CrewMember, at: number): Pose => {
372 if (m.status === 'done') return at - (m.endedAt ?? 0) < CHEER_MS ? 'cheer' : 'rest'
373 if (m.status === 'failed') return 'dizzy'
374 if (m.status === 'stopped') return 'sleep'
375 if (m.activity === 'think') return 'think'
376 if (at - m.lastActiveAt > STALL_MS) return 'wait'
377 return at - m.lastActiveAt < BURST_MS ? 'fast' : 'walk'
378}
379
380export const moodOf = (m: CrewMember, at: number): Mood => {
381 if (m.status !== 'running') return m.status
382 return poseOf(m, at) === 'wait' ? 'wait' : m.activity
383}
384
385const fill = (template: string, detail: string): string => template.replace('{x}', clip(detail, 24) || 'the code')
386
387/** The crab's line: in character, and stable until it does something new. */
388export const quipOf = (m: CrewMember, at: number): string => {
389 const mood = moodOf(m, at)
390 const list = characterOf(m.character).quips[mood] ?? GENERIC[mood]
391 const i = hash(m.id + mood) + (m.status === 'running' ? m.toolCalls : 0)
392 return fill(list[i % list.length] ?? '', m.detail)
393}
394
395export const captainQuip = (c: Captain): string => {
396 if (!c.isBusy) return CAPTAIN.idle
397 const list = CAPTAIN.quips[c.activity] ?? CAPTAIN.quips.other
398 return fill(list[0] ?? '', c.detail)
399}
400
401/** What the crab is doing, plainly: the tool's target, or its todo in progress. */
402export const actionOf = (m: CrewMember, at: number): string => {
403 if (m.status === 'done') return 'back aboard'
404 if (m.status === 'failed') return 'went overboard'
405 if (m.status === 'stopped') return 'called back'
406 if (m.todoNote) return m.todoNote
407 if (m.activity === 'think') return 'thinking'
408 if (m.activity === 'start') return 'setting out'
409 const verb = { read: 'reading', search: 'searching', edit: 'editing', shell: 'running', web: 'browsing', delegate: 'delegating', plan: 'planning', other: 'working' }[m.activity]
410 const waited = at - m.lastActiveAt > STALL_MS ? ` · waiting ${fmtTime(at - m.lastActiveAt)}` : ''
411 return `${verb}${m.detail ? ' ' + m.detail : ''}${waited}`
412}
413
414// ---------------------------------------------------------------------------
415// Formatting.
416
417export const modelName = (id: string): string => {
418 const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
419 const [, family = '', major = '', minor] = m ?? []
420 if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
421 return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
422}
423
424export const fmtTokens = (n: number): string =>
425 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
426
427export const fmtTime = (ms: number): string => {
428 const s = Math.max(0, Math.round(ms / 1000))
429 const h = Math.floor(s / 3600)
430 const m = Math.floor((s % 3600) / 60)
431 const ss = String(s % 60).padStart(2, '0')
432 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
433}
434
435export const elapsed = (m: CrewMember, at: number): number => (m.endedAt ?? Math.max(at, m.startedAt)) - m.startedAt
436
437export const crewTotals = (crew: readonly CrewMember[], at: number) => {
438 const tokens = crew.reduce((s, m) => s + m.tokens, 0)
439 const start = Math.min(...crew.map(m => m.startedAt))
440 const end = Math.max(...crew.map(m => m.endedAt ?? Math.max(at, m.startedAt)))
441 return {
442 running: crew.filter(m => m.status === 'running').length,
443 done: crew.filter(m => m.status === 'done').length,
444 failed: crew.filter(m => m.status === 'failed').length,
445 stopped: crew.filter(m => m.status === 'stopped').length,
446 tokens,
447 time: crew.length ? end - start : 0,
448 }
449}
450
451export const crewSummary = (crew: readonly CrewMember[], at: number): string => {
452 const t = crewTotals(crew, at)
453 const parts = [
454 t.running ? `${t.running} at work` : '',
455 t.done ? `${t.done} back aboard` : '',
456 t.failed ? `${t.failed} overboard` : '',
457 t.stopped ? `${t.stopped} called back` : '',
458 ].filter(Boolean)
459 return parts.join(' · ') || 'no crew out'
460}
461
462/** Mean progress of the given crew, 0 to 1. */
463export const crewProgress = (crew: readonly CrewMember[]): number =>
464 crew.length ? crew.reduce((s, m) => s + (m.status === 'running' ? progressOf(m).value : 1), 0) / crew.length : 0
465types/index.d.ts 62 lines1export type CrewStatus = 'running' | 'done' | 'failed' | 'stopped'
2
3/** What a crab is doing right now, from the tool it last called. */
4export type Activity = 'start' | 'think' | 'read' | 'search' | 'edit' | 'shell' | 'web' | 'delegate' | 'plan' | 'other'
5
6export type CrewMember = {
7 id: string
8 agentId?: string
9 /** The agent type as spawned: `general-purpose`, `Explore`, `Plan`, a plugin's agent. */
10 type: string
11 /** Key into the character roster. */
12 character: string
13 /** The crab's given name, e.g. `Pip`. */
14 name: string
15 description: string
16 model: string
17 effort?: string
18 status: CrewStatus
19 startedAt: number
20 endedAt?: number
21 lastActiveAt: number
22 activity: Activity
23 /** The target of the last call: a file name, a pattern, a command. */
24 detail: string
25 toolCalls: number
26 errors: number
27 steps: number
28 contextTokens: number
29 tokens: number
30 /** From the agent's own TodoWrite / TaskCreate / TaskUpdate calls, when it makes any. */
31 todoDone?: number
32 todoTotal?: number
33 todoNote?: string
34}
35
36/** The main thread, drawn as the captain at the top of the panel. */
37export type Captain = {
38 isBusy: boolean
39 activity: Activity
40 detail: string
41 lastActiveAt: number
42}
43
44export type CrewPanel = {
45 isDoneCollapsed: boolean
46 isRosterOpen: boolean
47 hasAutoOpened: boolean
48 /** The band above the prompt shows crew started or ended after this time. */
49 bandSince: number
50}
51
52declare module 'claude-code' {
53 interface PluginState {
54 'crab-crew': {
55 crew: CrewMember[]
56 captain: Captain
57 panel: CrewPanel
58 now: number
59 }
60 }
61}
62