A live pane of your subagents as a little pixel crew: who is working, on what, and how far along

A live pane of your subagents as a little pixel crew: who is working, on what, and how far along.

^ ^ eyes when done, goes grey with × × eyes on failure, and dozes if stopped.Explore shows up as a scout, Plan as an architect, general-purpose as a builder, and so on, along with the model it runs on.╭─ at the sender to ╰▶ at the receiver, with a little ✉ riding it (⌂ is the lead, your main conversation). The sender says 💬 → builder: "found 3 call sites" and the receiver, with a ! beside it and wide-open eyes, shows 📬 from mapper: "found 3 call sites" in the same color. Each agent counts what it sent and got (✉2↑3↓). A message also wakes a teammate that went idle. When an agent finishes and hands its report back, that report flies home to ⌂ as a letter too, and mail addressed to main or team-lead lands at the lead.The pane opens on its own when the first subagent spawns. When the whole crew clocks out you get a toast, and the pane closes 10 seconds later. Press c to clear finished agents, or run /crew to open the pane at any time.
/plugin marketplace add xinhuagu/oh-my-claude-mods
/plugin install agent-crew@oh-my-claude-mods
Restart the session afterwards.
Third-party marketplaces don't auto-update by default. To get new versions, turn on auto-update under /plugin → Marketplaces, or run /plugin marketplace update oh-my-claude-mods.
hooks/register.tsx 599 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CrewMember, CrewMood, CrewStatus, Letter } from '../types'
5
6const PANE = 'agent-crew'
7const TITLE = 'Agent Crew'
8const MAX_CREW = 12
9/** How long the pane lingers after the last agent clocks out, so the finish can be seen. */
10const CLOSE_DELAY_MS = 10_000
11/** How long a letter takes to fly from sender to receiver: slow enough to follow it down the lane. */
12const FLIGHT_MS = 2_000
13/** How long a sender keeps talking, and a receiver keeps its ears up after the letter lands. */
14const MOOD_MS = 4_000
15/** The main conversation, as the mail lane knows it. */
16const LEAD = 'lead'
17
18let pendingClose: { cancel: () => void } | undefined
19
20function cancelClose(): void {
21 pendingClose?.cancel()
22 pendingClose = undefined
23}
24
25const crew = atom({ plugin: 'agent-crew', key: 'crew' } as const, [])
26const frame = atom({ plugin: 'agent-crew', key: 'frame' } as const, 0)
27const mail = atom({ plugin: 'agent-crew', key: 'mail' } as const, [])
28const posted = atom({ plugin: 'agent-crew', key: 'posted' } as const, 0)
29
30let letterId = 0
31
32// ── the little critters ──────────────────────────────────────────────
33
34/** Each crew member is a Clawd: body colour from look % 6, cap colour from look / 6. */
35const BODY_COLORS = ['#D77757', '#E5B35C', '#E07A9A', '#8DBF6A', '#6FA8DC', '#A98ADB']
36/** The cap tints the top of the head; the first Clawd goes bareheaded. */
37const CAP_COLORS = [null, '#F5F0E6', '#D2453A', '#2BA89A', '#34508F', '#5A4636']
38/** Every sprite row is this many cells: a status mark, a mini Clawd with room to raise an arm, and a gap. */
39export const SPRITE_WIDTH = 8
40
41/** Clawd's own eyes are notches cut in the head; every other look is a glyph drawn on it. */
42const EYES_OPEN = ['▛', '▜'] as const
43const EYES_SHUT = ['─', '─'] as const
44const EYES_ALERT = ['●', '●'] as const
45const EYES_HAPPY = ['^', '^'] as const
46const EYES_DAZED = ['×', '×'] as const
47const EYE_COLOR = '#1E1A22'
48/** Arms out at the shoulders over two little legs. */
49const BODY = '▀▜▀▛'
50
51const MARK_WORK = '*'
52const MARK_BLINK = '·'
53const MARK_TALK = '~'
54const MARK_LISTEN = '!'
55const MARK_DONE = '✓'
56const MARK_FAILED = '×'
57const MARK_STOPPED = 'z'
58const TALK_COLOR = '#7FB8E6'
59const LISTEN_COLOR = '#E8C46B'
60const DONE_COLOR = '#5FB86A'
61/** A done Clawd holds up a little grey flag: the pole is the cell's left half, the banner stays put. */
62const FLAG = '▛'
63const FLAG_COLOR = '#B5B5B5'
64const FAILED_COLOR = '#E05A4F'
65const STOPPED_COLOR = '#8A8A8A'
66
67const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
68
69/** A run of cells in one foreground and optional background colour. */
70export type Span = [text: string, fg?: string, bg?: string]
71
72/** Washes a colour most of the way to grey, for crew members who failed or were sent home. */
73export function grey(hex: string): string {
74 const mix = (at: number) => Math.round(0.35 * parseInt(hex.slice(at, at + 2), 16) + 0.65 * 110)
75 return `#${[1, 3, 5].map(at => mix(at).toString(16).padStart(2, '0')).join('').toUpperCase()}`
76}
77
78/** The right arm: down at the side, raised beside the head, or raised holding the done flag. */
79type Arm = 'down' | 'up' | 'flag'
80
81type Pose = {
82 body: string
83 cap: string | null
84 eyes: readonly [string, string]
85 arm: Arm
86 mark: string
87 markColor?: string
88}
89
90/** A mini Clawd, two rows tall: the head with its eyes, then arms and legs. */
91function clawd({ body, cap, eyes, arm, mark, markColor }: Pose): Span[][] {
92 const eye = (glyph: string): Span => (eyes === EYES_OPEN ? [glyph, body] : [glyph, EYE_COLOR, body])
93 const isUp = arm !== 'down'
94 return [
95 [
96 markColor === undefined ? [mark] : [mark, markColor],
97 ['▐', body],
98 eye(eyes[0]),
99 cap === null ? ['█', body] : ['▀', cap, body],
100 eye(eyes[1]),
101 [isUp ? '▙' : '▌', body],
102 arm === 'flag' ? [FLAG, FLAG_COLOR] : [' '],
103 [' '],
104 ],
105 [[' '], [BODY, body], [isUp ? '▘' : '▀', body], [' ']],
106 ]
107}
108
109/** The mood showing right now: a listener's starts only once the letter lands. */
110function moodAt(member: CrewMember, now: number): CrewMood | undefined {
111 const mood = member.mood
112 return mood !== undefined && now >= mood.since && now < mood.until ? mood : undefined
113}
114
115export function sprite(member: CrewMember, tick: number, now: number): Span[][] {
116 const body = BODY_COLORS[member.look % BODY_COLORS.length]!
117 const cap = CAP_COLORS[Math.floor(member.look / BODY_COLORS.length) % CAP_COLORS.length]!
118 const off = { body: grey(body), cap: cap === null ? null : grey(cap), arm: 'down' as const }
119 if (member.status === 'done') return clawd({ body, cap, eyes: EYES_HAPPY, arm: 'flag', mark: MARK_DONE, markColor: DONE_COLOR })
120 if (member.status === 'failed') return clawd({ ...off, eyes: EYES_DAZED, mark: MARK_FAILED, markColor: FAILED_COLOR })
121 if (member.status === 'stopped') return clawd({ ...off, eyes: EYES_SHUT, mark: MARK_STOPPED, markColor: STOPPED_COLOR })
122 const mood = moodAt(member, now)
123 if (mood?.kind === 'talk') {
124 return clawd({ body, cap, eyes: EYES_OPEN, arm: tick % 2 === 0 ? 'up' : 'down', mark: MARK_TALK, markColor: TALK_COLOR })
125 }
126 if (mood?.kind === 'listen') return clawd({ body, cap, eyes: EYES_ALERT, arm: 'down', mark: MARK_LISTEN, markColor: LISTEN_COLOR })
127 const phase = (tick + member.look) % 4
128 return clawd({
129 body,
130 cap,
131 eyes: phase === 1 ? EYES_SHUT : EYES_OPEN,
132 arm: phase === 3 ? 'up' : 'down',
133 mark: phase % 2 === 0 ? MARK_WORK : MARK_BLINK,
134 markColor: body,
135 })
136}
137
138/** The Clawd on the empty pane, asleep. */
139const NAPPING = clawd({ body: BODY_COLORS[0]!, cap: null, eyes: EYES_SHUT, arm: 'down', mark: MARK_STOPPED, markColor: STOPPED_COLOR })
140
141// ── words ────────────────────────────────────────────────────────────
142
143const ROLES: Record<string, string> = {
144 Explore: 'scout',
145 Plan: 'architect',
146 'general-purpose': 'builder',
147 claude: 'handyman',
148 fork: 'twin',
149 teammate: 'teammate',
150}
151const ROLE_COLORS: Record<string, string> = {
152 scout: '#D9A72B',
153 architect: '#6BB8E8',
154 builder: '#2FAE7A',
155 handyman: '#E8826B',
156 twin: '#C38BE8',
157 teammate: '#E86BA8',
158 specialist: '#8FD16B',
159}
160
161function role(type: string): string {
162 return ROLES[type] ?? 'specialist'
163}
164
165function activity(tool: string | undefined): string {
166 if (tool === undefined) return 'stretching before work'
167 if (tool === 'Read') return 'reading the fine print'
168 if (tool === 'Grep' || tool === 'Glob') return 'sniffing through files'
169 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') return 'hammering code'
170 if (tool === 'Bash') return 'cranking the shell'
171 if (tool === 'WebSearch' || tool === 'WebFetch') return 'surfing the web'
172 if (tool === 'Agent') return 'hatching a helper'
173 if (tool.startsWith('mcp__')) return `poking ${tool.split('__')[1] ?? 'a connector'}`
174 return `tinkering with ${tool}`
175}
176
177const DONE_QUIPS = ['clocked out', 'nailed it', 'back to the burrow', 'job done, snack time', 'shipped it']
178
179function quip(member: CrewMember): string {
180 if (member.status === 'failed') return 'tripped over a cable'
181 if (member.status === 'stopped') return 'sent home early'
182 return DONE_QUIPS[member.look % DONE_QUIPS.length]!
183}
184
185function shortModel(model: string): string {
186 const m = /(opus|sonnet|haiku|fable|mythos)[-_ ]?(\d+)?[-_.]?(\d+)?/i.exec(model)
187 if (m === null) return model
188 const name = m[1]!.charAt(0).toUpperCase() + m[1]!.slice(1).toLowerCase()
189 const version = [m[2], m[3]].filter(Boolean).join('.')
190 return version ? `${name} ${version}` : name
191}
192
193function tokens(n: number): string {
194 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
195 if (n >= 1000) return `${Math.round(n / 1000)}k`
196 return String(n)
197}
198
199function clock(ms: number): string {
200 const s = Math.max(0, Math.floor(ms / 1000))
201 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
202}
203
204/** No agent says how far along it is, so the bar creeps toward 95% with each step. */
205export function progress(member: CrewMember): number {
206 if (member.status !== 'working') return 1
207 return Math.min(0.95, 1 - 1 / (1 + member.steps / 6))
208}
209
210export function bar(fraction: number, width: number): { filled: string; empty: string } {
211 const cells = Math.max(1, width)
212 const full = Math.round(fraction * cells)
213 return { filled: '█'.repeat(full), empty: '░'.repeat(cells - full) }
214}
215
216function statusOf(reason: string): CrewStatus {
217 if (reason === 'aborted') return 'stopped'
218 if (reason === 'answer') return 'done'
219 return 'failed'
220}
221
222// ── mail ─────────────────────────────────────────────────────────────
223
224/** What a SendMessage `to` may call the main conversation: teammates say `team-lead`, background subagents `main`. */
225const LEAD_ALIASES = new Set(['team-lead', 'main', LEAD])
226
227/** The crew member (or the lead) a SendMessage `to` names: a name, an agent id, or `name@team`. */
228export function addressee(list: readonly CrewMember[], to: string): string | undefined {
229 const bare = to.split('@')[0]!
230 if (LEAD_ALIASES.has(bare)) return LEAD
231 return list.find(m => m.id === to || m.name === to || m.name === bare)?.id
232}
233
234function nameOf(list: readonly CrewMember[], id: string): string {
235 if (id === LEAD) return LEAD
236 const m = list.find(x => x.id === id)
237 return m === undefined ? '?' : (m.name ?? role(m.type))
238}
239
240function snippet(text: string): string {
241 const line = text.trim().split('\n')[0]!.trim()
242 return line.length > 36 ? `${line.slice(0, 35)}…` : line
243}
244
245/** The body color a letter from this sender travels in; the lead's is white. */
246function inkOf(list: readonly CrewMember[], id: string): string {
247 const m = list.find(x => x.id === id)
248 return m === undefined ? '#E8E8E8' : BODY_COLORS[m.look % BODY_COLORS.length]!
249}
250
251export type LaneCell = { char: string; color?: string; isFaded?: boolean }
252
253/** Side-by-side tracks in the lane, so letters crossing the same stretch don't hide each other. */
254const TRACKS = 2
255export const LANE_WIDTH = TRACKS * 2
256/** Pane lines per crew member: a blank line, the head line with the task, the arms line, and the bar. */
257const CARD_ROWS = 4
258
259/**
260 * The mail lane, LANE_WIDTH cells per pane line: the header line is the lead's
261 * house, and each crew member's face line is where its letters leave and land. A
262 * letter draws a bracket in its sender's color, `╭─` at the sender and `╰▶` at the
263 * receiver, with ✉ riding it; once it lands the bracket fades and lingers while the
264 * receiver reads. Letters whose stretches overlap take separate tracks.
265 */
266export function lane(list: readonly CrewMember[], letters: readonly Letter[], now: number): LaneCell[][] {
267 const rows = 1 + list.length * CARD_ROWS
268 const grid: LaneCell[][] = Array.from({ length: rows }, () => Array.from({ length: LANE_WIDTH }, () => ({ char: ' ' })))
269 grid[0]![0] = { char: '⌂', color: '#E8E8E8' }
270 const anchor = (id: string): number | undefined => {
271 if (id === LEAD) return 0
272 const i = list.findIndex(m => m.id === id)
273 return i < 0 ? undefined : 1 + i * CARD_ROWS + 1
274 }
275 const taken: [number, number][][] = Array.from({ length: TRACKS }, () => [])
276 for (const letter of [...letters].sort((x, y) => x.sentAt - y.sentAt)) {
277 const a = anchor(letter.from)
278 const b = anchor(letter.to)
279 if (a === undefined || b === undefined) continue
280 const lo = Math.min(a, b)
281 const hi = Math.max(a, b)
282 const free = taken.findIndex(spans => spans.every(([l, h]) => hi < l || lo > h))
283 const track = free < 0 ? TRACKS - 1 : free
284 taken[track]!.push([lo, hi])
285
286 const x = track * 2
287 const p = (now - letter.sentAt) / FLIGHT_MS
288 const color = inkOf(list, letter.from)
289 const isFaded = p >= 1
290 const put = (row: number, col: number, char: string, faded = isFaded) => {
291 grid[row]![col] = { char, color, isFaded: faded }
292 }
293 for (let row = lo + 1; row < hi; row++) put(row, x, '│')
294 for (const [row, isEnd] of [[a, false], [b, true]] as const) {
295 if (row === 0) {
296 // the lead's house: run the arm back to ⌂, which glows in the sender's color
297 for (let col = 1; col < x; col++) put(0, col, '─')
298 if (x > 0) put(0, x, '┐')
299 grid[0]![0] = { char: '⌂', color }
300 continue
301 }
302 put(row, x, row === lo ? '╭' : '╰')
303 for (let col = x + 1; col < LANE_WIDTH - 1; col++) put(row, col, '─')
304 put(row, LANE_WIDTH - 1, isEnd ? '▶' : '─')
305 }
306 if (!isFaded) {
307 const at = Math.round(a + (b - a) * Math.max(0, p))
308 if (at !== 0) put(at, x, '✉', false)
309 }
310 }
311 return grid
312}
313
314/** Sends a letter down the lane: the sender talks, the receiver perks up once it lands. */
315async function post($: EngineInterface, from: string, to: string, body: string): Promise<void> {
316 const now = await $.clock.now()
317 const text = snippet(body)
318 await update($, crew, all =>
319 all.map(m => {
320 if (m.id === from) {
321 return { ...m, sent: m.sent + 1, mood: { kind: 'talk' as const, peer: nameOf(all, to), ink: inkOf(all, from), text, since: now, until: now + MOOD_MS } }
322 }
323 if (m.id !== to) return m
324 const mood: CrewMood = { kind: 'listen', peer: nameOf(all, from), ink: inkOf(all, from), text, since: now + FLIGHT_MS, until: now + FLIGHT_MS + MOOD_MS }
325 // A message wakes a teammate that went idle, or a subagent it resumes.
326 const awake = m.status === 'working' ? {} : { status: 'working' as const, endedAt: undefined }
327 return { ...m, ...awake, received: m.received + 1, mood }
328 }),
329 )
330 await update($, mail, all => [...all, { id: ++letterId, from, to, sentAt: now }])
331 await update($, posted, n => n + 1)
332 if (to !== LEAD) cancelClose()
333}
334
335// ── hooks ────────────────────────────────────────────────────────────
336
337export const register: Register = on => {
338 on('session.start', async ($, e, next) => {
339 await $.command.register({
340 name: 'crew',
341 description: 'Open the Agent Crew pane: your subagents at work',
342 })
343 $.clock.every(250, () => {
344 void (async () => {
345 const list = await read($, crew)
346 const letters = await read($, mail)
347 const now = await $.clock.now()
348 const isStale = (l: Letter) => now - l.sentAt >= FLIGHT_MS + MOOD_MS
349 if (letters.some(isStale)) await update($, mail, all => all.filter(l => !isStale(l)))
350 const isMoody = list.some(m => m.mood !== undefined && now < m.mood.until)
351 if (list.some(m => m.status === 'working') || letters.length > 0 || isMoody) {
352 await update($, frame, n => n + 1)
353 }
354 })()
355 })
356
357 return next(e)
358 })
359
360 on('command.run', { command: 'crew' }, async $ => {
361 await $.ui.open({ id: PANE, title: TITLE })
362
363 return { text: 'Agent Crew pane opened.' }
364 })
365
366 on('agent.spawn', async ($, e, next) => {
367 const started = await next(e)
368 if (started.agentId === undefined) return started
369
370 const now = await $.clock.now()
371 const member: CrewMember = {
372 id: started.agentId,
373 task: e.description || e.subagentType,
374 type: e.subagentType,
375 model: started.model,
376 startedAt: now,
377 steps: 0,
378 tokens: 0,
379 status: 'working',
380 look: 0,
381 name: started.teammateId?.split('@')[0] ?? e.name,
382 sent: 0,
383 received: 0,
384 }
385 cancelClose()
386 // A new batch after the last one all clocked out starts a fresh crew. Decided inside
387 // the update, so agents spawned in parallel see each other instead of each starting over.
388 let isFresh = false
389 await update($, crew, list => {
390 isFresh = list.every(m => m.status !== 'working')
391 const kept = isFresh ? [] : list
392 const look = kept.length === 0 ? 0 : (kept[kept.length - 1]!.look + 1) % 60
393 return [...kept, { ...member, look }].slice(-MAX_CREW)
394 })
395 if (isFresh) await update($, mail, () => [])
396 void $.ui.open({ id: PANE, title: TITLE })
397
398 return started
399 }).catch(($, e, next) => next(e))
400
401 on('session.send', async ($, e, next) => {
402 const result = await next(e)
403 if (!result.isDelivered) return result
404
405 const list = await read($, crew)
406 const from = e.agentId === undefined ? LEAD : list.find(m => m.id === e.agentId)?.id
407 const to = addressee(list, e.to)
408 if (from === undefined || to === undefined || from === to) return result
409
410 await post($, from, to, e.text)
411
412 return result
413 }).catch(($, e, next) => next(e))
414
415 on('tool.call', async ($, e, next) => {
416 const agentId = e.agentId
417 if (agentId !== undefined) {
418 await update($, crew, list =>
419 list.map(m =>
420 m.id === agentId && m.status === 'working'
421 ? { ...m, steps: m.steps + 1, lastTool: String(e.tool) }
422 : m,
423 ),
424 )
425 }
426
427 return next(e)
428 }).catch(($, e, next) => next(e))
429
430 on('turn.complete', async ($, e, next) => {
431 const agentId = e.agentId
432 if (agentId === undefined) return next(e)
433
434 const now = await $.clock.now()
435 const used = e.usage
436 ? e.usage.input_tokens +
437 e.usage.output_tokens +
438 e.usage.cache_read_input_tokens +
439 (e.usage.cache_creation_input_tokens ?? 0)
440 : 0
441 const status = statusOf(e.reason)
442 let isKnown = false
443 await update($, crew, list =>
444 list.map(m => {
445 if (m.id !== agentId) return m
446 isKnown = true
447 return {
448 ...m,
449 status,
450 endedAt: now,
451 tokens: m.tokens + used,
452 model: e.usage?.model ?? m.model,
453 }
454 }),
455 )
456 // The engine hands an answered run's report to the lead without a SendMessage, so mail it here.
457 if (isKnown && status === 'done') await post($, agentId, LEAD, e.answer || 'report')
458 await update($, frame, n => n + 1)
459
460 const after = await read($, crew)
461 if (isKnown && after.length >= 2 && after.every(m => m.status !== 'working')) {
462 const done = after.filter(m => m.status === 'done').length
463 $.ui.toast(`🎉 Crew's done: ${done}/${after.length} agents clocked out`)
464 }
465 if (isKnown && after.every(m => m.status !== 'working')) {
466 cancelClose()
467 pendingClose = $.clock.after(CLOSE_DELAY_MS, () => {
468 pendingClose = undefined
469 void (async () => {
470 const list = await read($, crew)
471 if (list.every(m => m.status !== 'working')) await $.ui.close({ id: PANE })
472 })()
473 })
474 }
475
476 return next(e)
477 })
478
479 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
480 const { Box, Text, Button } = $.ui.resolve(e)
481 const list = await read($, crew)
482 const tick = await read($, frame)
483 const now = await $.clock.now()
484 const width = Math.max(30, e.props.bodyColumns)
485 const drawn = (rows: Span[][], id: string) => (
486 <Box flexDirection="column" width={SPRITE_WIDTH}>
487 {rows.map((spans, row) => (
488 <Text key={`${id}-${row}`}>
489 {spans.map(([text, fg, bg], k) => (
490 <Text key={`${id}-${row}-${k}`} color={fg} backgroundColor={bg}>
491 {text}
492 </Text>
493 ))}
494 </Text>
495 ))}
496 </Box>
497 )
498
499 if (list.length === 0) {
500 return (
501 <Box flexDirection="column">
502 {drawn(NAPPING, 'napping')}
503 <Text dimColor>The crew is napping. Ask for something big and they'll get to work.</Text>
504 </Box>
505 )
506 }
507
508 const working = list.filter(m => m.status === 'working').length
509 const finished = list.length - working
510 const total = list.reduce((sum, m) => sum + m.tokens, 0)
511 const firstStart = Math.min(...list.map(m => m.startedAt))
512 const lastEnd = working > 0 ? now : Math.max(...list.map(m => m.endedAt ?? now))
513 const letters = await read($, mail)
514 const messages = await read($, posted)
515 const hasMail = list.some(m => m.sent + m.received > 0) || letters.length > 0
516 const cells = hasMail ? lane(list, letters, now) : []
517 const gutter = (row: number) =>
518 hasMail ? (
519 <Text key={`lane-${row}`}>
520 {(cells[row] ?? []).map((cell, col) => (
521 <Text key={`${row}-${col}`} color={cell.color} dimColor={cell.isFaded === true}>
522 {cell.char}
523 </Text>
524 ))}
525 </Text>
526 ) : null
527
528 return (
529 <Box flexDirection="column">
530 <Box>
531 {gutter(0)}
532 <Text bold>{working > 0 ? `${SPINNER[tick % SPINNER.length]} ` : '✓ '}</Text>
533 <Text bold>{working} working</Text>
534 <Text dimColor>
535 {' · '}
536 {finished} done · {tokens(total)} tok · {clock(lastEnd - firstStart)}
537 {messages > 0 ? ` · 💌 ${messages}` : ''}
538 </Text>
539 </Box>
540 {list.map((m, i) => {
541 const mood = moodAt(m, now)
542 const body = BODY_COLORS[m.look % BODY_COLORS.length]!
543 const r = role(m.type)
544 const elapsed = clock((m.endedAt ?? now) - m.startedAt)
545 const post = m.sent + m.received > 0 ? ` · ✉${m.sent}↑${m.received}↓` : ''
546 const stats = ` ${m.steps} steps · ${tokens(m.tokens)} · ${elapsed}${post}`
547 const { filled, empty } = bar(progress(m), width - SPRITE_WIDTH - (hasMail ? LANE_WIDTH : 0) - stats.length - 1)
548 const isLive = m.status === 'working'
549 const barColor = m.status === 'failed' ? 'error' : m.status === 'stopped' ? 'warning' : isLive ? body : 'success'
550 const doing =
551 mood?.kind === 'talk'
552 ? `💬 → ${mood.peer}: "${mood.text}"`
553 : mood?.kind === 'listen'
554 ? `📬 from ${mood.peer}: "${mood.text}"`
555 : isLive
556 ? `${SPINNER[(tick + m.look) % SPINNER.length]} ${activity(m.lastTool)}…`
557 : quip(m)
558 const top = 1 + i * CARD_ROWS
559
560 return (
561 <Box key={m.id} flexDirection="row">
562 {hasMail && <Box flexDirection="column">{Array.from({ length: CARD_ROWS }, (_, k) => gutter(top + k))}</Box>}
563 <Box flexDirection="row" marginTop={1} flexGrow={1}>
564 {drawn(sprite(m, tick, now), m.id)}
565 <Box flexDirection="column" flexGrow={1}>
566 <Text wrap="truncate-end">
567 <Text bold dimColor={!isLive}>{m.task}</Text>
568 <Text>{' '}</Text>
569 <Text color={ROLE_COLORS[r]!}>{r}</Text>
570 <Text dimColor> {m.name !== undefined ? `${m.name} · ` : ''}{shortModel(m.model)}</Text>
571 </Text>
572 <Text wrap="truncate-end" color={mood?.ink} dimColor={!isLive && mood === undefined}>
573 {doing}
574 </Text>
575 <Text wrap="truncate-end">
576 <Text color={barColor}>{filled}</Text>
577 <Text dimColor>{empty}</Text>
578 <Text dimColor>{stats}</Text>
579 </Text>
580 </Box>
581 </Box>
582 </Box>
583 )
584 })}
585 {finished > 0 && (
586 <Box marginTop={1}>
587 <Button
588 key="clear"
589 label="Clear finished"
590 hotkey="c"
591 onPress={() => update($, crew, all => all.filter(m => m.status === 'working'))}
592 />
593 </Box>
594 )}
595 </Box>
596 )
597 })
598}
599types/index.d.ts 33 lines1export type CrewStatus = 'working' | 'done' | 'failed' | 'stopped'
2
3export type CrewMember = {
4 id: string
5 task: string
6 type: string
7 model: string
8 startedAt: number
9 endedAt?: number
10 steps: number
11 lastTool?: string
12 tokens: number
13 status: CrewStatus
14 look: number
15 /** What SendMessage addresses it by, when it has a name. */
16 name?: string
17 sent: number
18 received: number
19 /** A short-lived face: talking while its letter flies, all ears when one lands. */
20 mood?: CrewMood
21}
22
23export type CrewMood = { kind: 'talk' | 'listen'; peer: string; ink: string; text: string; since: number; until: number }
24
25/** A letter in flight between two crew members; `lead` is the main conversation. */
26export type Letter = { id: number; from: string; to: string; sentAt: number }
27
28declare module 'claude-code' {
29 interface PluginState {
30 'agent-crew': { crew: CrewMember[]; frame: number; mail: Letter[]; posted: number }
31 }
32}
33