SLOPSHOPPER

agent-crew

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

newpaneguardcommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-crew
│ ┃ Agent Crew ✕ › fix the failing auth test and add an audit log call │ ┃ z▐─█─▌ │ ┃ ▀▜▀▛▀ ⏺ Read(src/auth.ts) │ ┃ The crew is napping. Ask for something big ⎿ Read 6 lines │ ┃ and they'll get to work. ⏺ 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 │ ⎿ agent-crew: Agent Crew pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent Crew
z▐─█─▌ ▀▜▀▛▀ The crew is napping. Ask for something big and they'll get to work.
README

agent-crew

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

Agent Crew pane: four subagents at work, then clocking out

What you see

  • One mini Clawd per subagent, two rows tall, each in its own body color and cap (the first goes bareheaded in Clawd orange). It blinks and waves while working, holds up a flag with happy ^ ^ eyes when done, goes grey with × × eyes on failure, and dozes if stopped.
  • Role and model: 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.
  • What it's doing right now, based on the last tool it called ("sniffing through files", "hammering code", "cranking the shell"…).
  • Progress, steps, tokens and elapsed time for each agent, plus a crew-wide summary at the top.
  • Mail between agents. When one agent messages another with SendMessage, a bracket in the sender's color runs down the lane on the left, from ╭─ 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.

Install

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

Source 2 files
hooks/register.tsx 599 lines
1import { 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}
599
types/index.d.ts 33 lines
1export 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