SLOPSHOPPER

crew

Live pane of subagents with avatars; each agent opens in its own pane where you can message it

newpanespinnerguardcommandtimer
v0.1.0MITupdated 2026-10-22rezaulhreza/claude-code-mods/crew
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crew
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ Agents none yet │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /crew │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
Agents none yet
README

crew

A pane of subagents. Each agent has a pixel avatar: the colour comes from its type, the pattern from its id. Press an agent to open it in its own tab: a live feed of its tool calls and answer, and a field to send it a message. /crew opens the pane; /crew explore opens one agent.

Source 4 files
hooks/register.tsx 353 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, StateDollar } from 'claude-code'
3
4import type { AgentRow, FeedItem } from '../types'
5import { COLUMNS, ROWS, avatarCells, hex, hueOf } from './avatar'
6import { clip, describe, duration, noteOf, target } from './describe'
7
8const PANE = 'crew'
9const RED = '#F0776B'
10
11const agents = atom({ plugin: 'crew', key: 'agents' } as const, [])
12const feeds = atom({ plugin: 'crew', key: 'feeds' } as const, {})
13const drafts = atom({ plugin: 'crew', key: 'drafts' } as const, {})
14const tick = atom({ plugin: 'crew', key: 'tick' } as const, 0)
15
16export const paneIdOf = (agentId: string) => `agent-${agentId.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 58)}`
17const shortId = (agentId: string) => agentId.replace(/^agent-?/, '').slice(0, 4)
18const modelName = (model: string) => /^claude-([a-z]+)/.exec(model)?.[1] ?? model
19
20async function push($: StateDollar, agentId: string, item: FeedItem) {
21  await update($, feeds, all => ({ ...all, [agentId]: [...(all[agentId] ?? []), item].slice(-200) }))
22}
23
24async function patch($: StateDollar, agentId: string, itemId: string, change: Partial<FeedItem>) {
25  await update($, feeds, all => ({
26    ...all,
27    [agentId]: (all[agentId] ?? []).map(item => (item.id === itemId ? { ...item, ...change } : item)),
28  }))
29}
30
31async function setAgent($: StateDollar, agentId: string, change: (agent: AgentRow) => Partial<AgentRow>) {
32  await update($, agents, list => list.map(agent => (agent.id === agentId ? { ...agent, ...change(agent) } : agent)))
33}
34
35function openAgent($: EngineInterface, agent: AgentRow) {
36  return $.ui.open({ id: paneIdOf(agent.id), title: `${agent.type} ${shortId(agent.id)}`, focus: true })
37}
38
39// Running agents first, newest first; then finished ones, newest first.
40export function ordered(list: readonly AgentRow[]): AgentRow[] {
41  const running = list.filter(agent => agent.status === 'running')
42  const ended = list.filter(agent => agent.status !== 'running')
43
44  return [...running, ...ended]
45}
46
47const glyphOf = (status: AgentRow['status']) => (status === 'running' ? '●' : status === 'done' ? '✓' : '✗')
48const colourOf = (status: AgentRow['status'], accent: string) => (status === 'running' ? accent : status === 'failed' ? RED : undefined)
49const elapsed = (agent: AgentRow, now: number) => duration((agent.endedAt ?? now) - agent.startedAt)
50
51export const register: Register = (on, options) => {
52  const ACCENT = String(options.accent || '#D97757')
53  let cwd = ''
54  let isFullscreen = false
55  let seq = 0
56
57  on('session.start', async ($, e, next) => {
58    const started = await next(e)
59    cwd = e.cwd
60    await $.command.register({
61      name: 'crew',
62      description: 'Show the subagents pane; /crew <type, task or id> opens one agent',
63    })
64
65    // Redraw elapsed times every 5 s while an agent runs.
66    $.clock.every(5000, () => {
67      void (async () => {
68        if ((await read($, agents)).some(agent => agent.status === 'running')) {
69          await update($, tick, n => n + 1)
70        }
71      })()
72    })
73
74    return started
75  })
76
77  // The prompt hint is drawn in every layout: it tells us whether a pane would dock.
78  on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
79    isFullscreen = e.viewport?.isFullscreen === true
80
81    return next(e)
82  })
83
84  on('agent.spawn', async ($, e, next) => {
85    const spawned = await next(e)
86
87    if (spawned.deny !== undefined || !spawned.agentId) {
88      return spawned
89    }
90
91    const row: AgentRow = {
92      id: spawned.agentId,
93      type: e.subagentType,
94      task: e.description,
95      model: modelName(spawned.model),
96      status: 'running',
97      startedAt: await $.clock.now(),
98      endedAt: null,
99      tools: 0,
100      live: 'starting',
101    }
102    await update($, agents, list => [row, ...list.filter(agent => agent.id !== row.id)].slice(0, 50))
103
104    if (isFullscreen && options.autoOpen !== 'command') {
105      void $.ui.open({ id: PANE, title: 'Agents' })
106    }
107
108    return spawned
109  }).catch(($, e, next) => next(e))
110
111  on('turn.start', async ($, e, next) => {
112    const agentId = e.agentId
113
114    if (agentId) {
115      const now = await $.clock.now()
116      await setAgent($, agentId, agent =>
117        agent.status === 'running' ? {} : { status: 'running', startedAt: now, endedAt: null, live: 'resumed' },
118      )
119    }
120
121    return next(e)
122  })
123
124  on('tool.call', async ($, e, next) => {
125    const agentId = e.agentId
126
127    if (!agentId) {
128      return next(e)
129    }
130
131    seq += 1
132    const input = e as Parameters<typeof describe>[0]
133    const itemId = e.tool_use_id ?? `call-${seq}`
134    const { verb, what } = target(input, cwd)
135    const live = describe(input, cwd)
136    await setAgent($, agentId, agent => ({ tools: agent.tools + 1, live: live[0]!.toLowerCase() + live.slice(1) }))
137    await push($, agentId, { id: itemId, kind: 'tool', verb, text: what, state: 'running', note: '' })
138
139    const ran = await next(e)
140    const isError = ran.deny !== undefined || ran.isError === true
141    await patch($, agentId, itemId, { state: isError ? 'error' : 'done', note: noteOf(e.tool, ran) })
142
143    return ran
144  }).catch(($, e, next) => next(e))
145
146  on('turn.complete', async ($, e, next) => {
147    const done = await next(e)
148    const agentId = e.agentId
149
150    if (agentId) {
151      const isFailed = e.reason !== 'answer'
152      const now = await $.clock.now()
153      await setAgent($, agentId, () => ({
154        status: isFailed ? 'failed' : 'done',
155        endedAt: now,
156        live: e.isAborted ? 'interrupted' : isFailed ? 'stopped on an error' : 'finished',
157      }))
158
159      if (e.answer.trim()) {
160        await push($, agentId, { id: `answer-${e.turnId}`, kind: 'text', verb: '', text: clip3(e.answer), state: 'done', note: '' })
161      }
162    }
163
164    return done
165  })
166
167  on('command.run', { command: 'crew' }, async ($, e) => {
168    const query = e.args.trim().toLowerCase()
169
170    if (!query) {
171      await $.ui.open({ id: PANE, title: 'Agents' })
172
173      return {}
174    }
175
176    const match = ordered(await read($, agents)).find(
177      agent =>
178        agent.id.toLowerCase().startsWith(query) ||
179        agent.type.toLowerCase().startsWith(query) ||
180        agent.task.toLowerCase().includes(query),
181    )
182
183    if (!match) {
184      return { text: `No agent matches "${e.args.trim()}". Run /crew to see them all.` }
185    }
186
187    await openAgent($, match)
188
189    return {}
190  })
191
192  // The crew pane: one block per agent.
193  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
194    const els = $.ui.resolve(e)
195    const { Box, Button, Text } = els
196    await read($, tick)
197    const list = ordered(await read($, agents))
198    const now = await $.clock.now()
199    const running = list.filter(agent => agent.status === 'running').length
200    const done = list.filter(agent => agent.status === 'done').length
201    const failed = list.length - running - done
202    const counts = [`${running} running`, `${done} done`, failed ? `${failed} failed` : ''].filter(Boolean).join(' · ')
203    const tools = list.reduce((sum, agent) => sum + agent.tools, 0)
204    const time = list.reduce((sum, agent) => sum + ((agent.endedAt ?? now) - agent.startedAt), 0)
205
206    return (
207      <Box flexDirection="column" gap={1}>
208        <Text>
209          <Text bold>Agents</Text>
210          <Text dimColor>{list.length ? `  ${counts}` : '  none yet'}</Text>
211        </Text>
212        {list.map(agent => {
213          const isRunning = agent.status === 'running'
214
215          return (
216            <Box key={`row-${agent.id}`} flexDirection="row" gap={2}>
217              {'Raster' in els ? (
218                <els.Raster key={`av-${agent.id}`} columns={COLUMNS} rows={ROWS} cells={avatarCells(agent.id, agent.type, !isRunning)} />
219              ) : (
220                <Text color={hex(hueOf(agent.type))}>■</Text>
221              )}
222              <Box flexDirection="column" flexGrow={1}>
223                <Button key={`open-${agent.id}`} plain onPress={() => openAgent($ as EngineInterface, agent)}>
224                  <Text color={colourOf(agent.status, ACCENT)} dimColor={agent.status === 'done'}>
225                    {glyphOf(agent.status)}
226                  </Text>{' '}
227                  <Text dimColor={!isRunning}>{agent.type}</Text>
228                  <Text dimColor>{`  ${elapsed(agent, now)}`}</Text>
229                </Button>
230                <Text dimColor={!isRunning} wrap="truncate-end">
231                  {agent.task}
232                </Text>
233                <Text dimColor wrap="truncate-end">
234                  {isRunning ? `${agent.model} · ${agent.tools} · ${agent.live}` : `${agent.model} · ${agent.tools} tools · ${agent.live}`}
235                </Text>
236              </Box>
237            </Box>
238          )
239        })}
240        {list.length > 0 && <Text dimColor>{`${tools} tools · ${duration(time)} agent time`}</Text>}
241      </Box>
242    )
243  })
244
245  // One agent's pane: header, live feed, message field.
246  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
247    if (!e.requestId.startsWith('agent-')) {
248      return next(e)
249    }
250
251    const agent = (await read($, agents)).find(one => paneIdOf(one.id) === e.requestId)
252
253    if (!agent) {
254      return next(e)
255    }
256
257    const els = $.ui.resolve(e)
258    const { Box, Button, Text } = els
259    await read($, tick)
260    const now = await $.clock.now()
261    const feed = (await read($, feeds))[agent.id] ?? []
262    const draft = (await read($, drafts))[agent.id] ?? ''
263    const hue = hex(hueOf(agent.type))
264    const isRunning = agent.status === 'running'
265    const room = Math.max(3, e.props.scroll.bodyRows - 9)
266    const width = Math.max(10, e.props.bodyColumns)
267
268    const send = async (value: string) => {
269      const text = value.trim()
270
271      if (!text) {
272        return
273      }
274
275      seq += 1
276      const itemId = `you-${seq}`
277      await update($, drafts, all => ({ ...all, [agent.id]: '' }))
278      await push($, agent.id, { id: itemId, kind: 'you', verb: 'you', text, state: 'running', note: 'sending' })
279      const sent = await $.session.send({ to: { agentId: agent.id }, text })
280      await patch(
281        $,
282        agent.id,
283        itemId,
284        sent.isDelivered ? { state: 'done', note: 'delivered' } : { state: 'error', note: clip(sent.reason, 60) },
285      )
286    }
287
288    return (
289      <Box flexDirection="column">
290        <Box flexDirection="row" gap={2}>
291          {'Raster' in els ? (
292            <els.Raster key="avatar" columns={COLUMNS} rows={ROWS} cells={avatarCells(agent.id, agent.type, !isRunning)} />
293          ) : (
294            <Text color={hue}>■</Text>
295          )}
296          <Box flexDirection="column" flexGrow={1}>
297            <Text wrap="truncate-end">
298              <Text bold>{agent.type}</Text>
299              {`  ${agent.task}`}
300            </Text>
301            <Text wrap="truncate-end">
302              <Text color={colourOf(agent.status, ACCENT)} dimColor={agent.status === 'done'}>
303                {glyphOf(agent.status)}
304              </Text>
305              <Text dimColor>{` ${agent.status} · ${elapsed(agent, now)} · ${agent.model} · ${agent.tools} tools`}</Text>
306            </Text>
307            <Button key="transcript" plain hotkey="v" onPress={() => $.ui.view({ agentId: agent.id })}>
308              <Text dimColor>full transcript</Text>
309            </Button>
310          </Box>
311        </Box>
312        <Text dimColor>{'─'.repeat(width)}</Text>
313        {feed.length === 0 && <Text dimColor>No activity yet.</Text>}
314        {feed.slice(-room).map(item =>
315          item.kind === 'text' ? (
316            <Text color={hue}>{`⏺ ${item.text}`}</Text>
317          ) : (
318            <Text wrap="truncate-end">
319              <Text color={item.kind === 'you' ? ACCENT : item.state === 'error' ? RED : item.state === 'running' ? ACCENT : undefined}>
320                {item.kind === 'you' ? 'you' : item.state === 'running' ? '●' : item.state === 'error' ? '✗' : '✓'}
321              </Text>
322              {item.kind === 'you' ? '     ' : ` ${item.verb.padEnd(7)} `}
323              <Text color={item.state === 'error' && item.kind === 'tool' ? RED : undefined}>{item.text}</Text>
324              {item.note ? <Text dimColor>{`  ${item.note}`}</Text> : null}
325            </Text>
326          ),
327        )}
328        <Text dimColor>{'─'.repeat(width)}</Text>
329        {'Input' in els ? (
330          <els.Input
331            key="message"
332            label={`Message ${agent.type}`}
333            placeholder={isRunning ? 'It reads this at its next step' : 'It resumes with this message'}
334            value={draft}
335            onInput={value => void update($, drafts, all => ({ ...all, [agent.id]: value }))}
336            onSubmit={value => void send(value)}
337          />
338        ) : (
339          <Text dimColor>Messages need a surface with text input.</Text>
340        )}
341      </Box>
342    )
343  })
344}
345
346// The first three lines of an answer, so the feed stays readable.
347function clip3(text: string): string {
348  const lines = text.trim().split('\n').filter(line => line.trim())
349  const head = lines.slice(0, 3).join('\n')
350
351  return lines.length > 3 ? `${head}\n…` : head
352}
353
hooks/avatar.ts 95 lines
1// A 6×6 mirrored identicon per agent: colour from its type, pattern from its id.
2// Drawn as a Raster of 6 columns × 3 rows, each cell two pixels high.
3
4const HUES: Record<string, number> = {
5  Explore: 0x4fa3a5,
6  Plan: 0x6a8fd8,
7  'general-purpose': 0xc9a66b,
8  fork: 0x7faf6a,
9}
10
11const SPARE = [0x9b84d6, 0xcf7f9a, 0x7faf6a, 0x4fa3a5, 0x6a8fd8, 0xc9a66b]
12
13const DEFAULT = 0x01000000
14const UPPER = 0x2580
15const LOWER = 0x2584
16const FULL = 0x2588
17const SPACE = 0x20
18
19export const COLUMNS = 6
20export const ROWS = 3
21
22export function hash(text: string): number {
23  let h = 2166136261
24
25  for (let i = 0; i < text.length; i++) {
26    h ^= text.charCodeAt(i)
27    h = Math.imul(h, 16777619)
28  }
29
30  h ^= h >>> 15
31  h = Math.imul(h, 0x2c1b3c6d)
32  h ^= h >>> 12
33
34  return h >>> 0
35}
36
37export function hueOf(type: string): number {
38  return HUES[type] ?? SPARE[hash(type) % SPARE.length]!
39}
40
41export const hex = (color: number) => '#' + color.toString(16).padStart(6, '0')
42
43function dim(color: number): number {
44  const scale = (shift: number) => Math.round(((color >> shift) & 0xff) * 0.45) << shift
45
46  return scale(16) | scale(8) | scale(0)
47}
48
49export function pixels(id: string, type: string): boolean[][] {
50  const h = hash(id + ':' + type)
51
52  return Array.from({ length: 6 }, (_, y) =>
53    Array.from({ length: 6 }, (_, x) => ((h >>> (y * 3 + (x < 3 ? x : 5 - x))) & 1) === 1),
54  )
55}
56
57const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
58
59function base64(bytes: Uint8Array): string {
60  let out = ''
61
62  for (let i = 0; i < bytes.length; i += 3) {
63    const a = bytes[i]!
64    const b = bytes[i + 1]
65    const c = bytes[i + 2]
66    const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
67    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
68    out += b === undefined ? '=' : B64[(n >> 6) & 63]!
69    out += c === undefined ? '=' : B64[n & 63]!
70  }
71
72  return out
73}
74
75// The Raster's `cells`: one [codePoint, foreground, background] triplet per cell.
76export function avatarCells(id: string, type: string, isDim: boolean): string {
77  const grid = pixels(id, type)
78  const color = isDim ? dim(hueOf(type)) : hueOf(type)
79  const words = new Uint32Array(COLUMNS * ROWS * 3)
80
81  for (let row = 0; row < ROWS; row++) {
82    for (let x = 0; x < COLUMNS; x++) {
83      const top = grid[row * 2]![x]!
84      const bottom = grid[row * 2 + 1]![x]!
85      const glyph = top && bottom ? FULL : top ? UPPER : bottom ? LOWER : SPACE
86      const at = (row * COLUMNS + x) * 3
87      words[at] = glyph
88      words[at + 1] = glyph === SPACE ? DEFAULT : color
89      words[at + 2] = DEFAULT
90    }
91  }
92
93  return base64(new Uint8Array(words.buffer))
94}
95
hooks/describe.ts 269 lines
1type Input = { tool: string } & Record<string, unknown>
2
3export type FileDiff = { file: string; added: number; removed: number }
4
5export type TurnStat = { durationMs: number; tools: number; files: number; added: number; removed: number }
6
7const HOME = /^\/(Users|home)\/[^/]+/
8
9export function relative(path: string, cwd: string): string {
10  if (cwd && path.startsWith(cwd + '/')) {
11    return path.slice(cwd.length + 1)
12  }
13
14  return path.replace(HOME, '~')
15}
16
17export function clip(text: string, max: number): string {
18  const line = text.split('\n')[0]!.replace(/\s+/g, ' ').trim()
19
20  return line.length > max ? line.slice(0, max - 1) + '…' : line
21}
22
23function str(input: Input, key: string): string {
24  const value = input[key]
25
26  return typeof value === 'string' ? value : ''
27}
28
29// What the main loop is doing, as one short present-tense phrase.
30export function describe(input: Input, cwd: string): string {
31  const file = relative(str(input, 'file_path') || str(input, 'notebook_path'), cwd)
32
33  switch (input.tool) {
34    case 'Read':
35      return `Reading ${file}`
36    case 'Edit':
37    case 'MultiEdit':
38    case 'NotebookEdit':
39      return `Editing ${file}`
40    case 'Write':
41      return `Writing ${file}`
42    case 'Bash':
43      return `Running ${clip(str(input, 'command'), 60)}`
44    case 'WebFetch':
45      return `Fetching ${clip(str(input, 'url').replace(/^https?:\/\//, ''), 50)}`
46    case 'WebSearch':
47      return `Searching the web for "${clip(str(input, 'query'), 40)}"`
48    case 'Agent': {
49      const type = str(input, 'subagent_type') || 'agent'
50
51      return `Waiting on ${type}: ${clip(str(input, 'description'), 40)}`
52    }
53    case 'Skill':
54      return `Using skill ${str(input, 'skill')}`
55    case 'TodoWrite':
56    case 'TaskCreate':
57    case 'TaskUpdate':
58      return 'Updating tasks'
59  }
60
61  const mcp = /^mcp__(.+?)__(.+)$/.exec(input.tool)
62
63  if (mcp) {
64    return `Calling ${mcp[1]} ${mcp[2]}`
65  }
66
67  return `Using ${input.tool}`
68}
69
70// The tool's short verb and what it acts on, for a ledger-style row.
71export function target(input: Input, cwd: string): { verb: string; what: string } {
72  const file = relative(str(input, 'file_path') || str(input, 'notebook_path'), cwd)
73
74  switch (input.tool) {
75    case 'Read':
76    case 'Write':
77      return { verb: input.tool, what: file }
78    case 'Edit':
79    case 'MultiEdit':
80    case 'NotebookEdit':
81      return { verb: 'Edit', what: file }
82    case 'Bash':
83      return { verb: 'Bash', what: clip(str(input, 'command'), 200) }
84    case 'WebFetch':
85      return { verb: 'Fetch', what: clip(str(input, 'url').replace(/^https?:\/\//, ''), 200) }
86    case 'WebSearch':
87      return { verb: 'Search', what: `"${clip(str(input, 'query'), 200)}" on the web` }
88    case 'Agent':
89      return { verb: 'Agent', what: `${str(input, 'subagent_type') || 'agent'} · ${clip(str(input, 'description'), 200)}` }
90    case 'Skill':
91      return { verb: 'Skill', what: str(input, 'skill') }
92  }
93
94  const mcp = /^mcp__(.+?)__(.+)$/.exec(input.tool)
95
96  if (mcp) {
97    return { verb: mcp[1]!, what: mcp[2]! }
98  }
99
100  const first = Object.entries(input).find(([key, value]) => key !== 'tool' && typeof value === 'string')
101
102  return { verb: input.tool, what: first ? clip(String(first[1]), 200) : '' }
103}
104
105type Ran = { result?: unknown; text?: string; isError?: boolean; deny?: string }
106
107// One plain summary of a finished call: lines read, diff size, the first error line.
108export function noteOf(tool: string, ran: Ran): string {
109  if (ran.deny !== undefined) {
110    return clip(`refused: ${ran.deny}`, 80)
111  }
112
113  if (ran.isError) {
114    const lines = (ran.text ?? '').split('\n').map(one => one.trim())
115    const code = lines.map(one => /^(?:error: )?exit code (\d+)/i.exec(one)).find(Boolean)
116    const first = lines.find(one => one && !/^(?:error: )?exit code \d+/i.test(one))
117    const parts = [code ? `exit ${code[1]}` : '', first ?? (code ? '' : 'failed')].filter(Boolean)
118
119    return clip(parts.join(' · '), 80)
120  }
121
122  const r = (ran.result ?? {}) as Record<string, unknown>
123
124  if (tool === 'Read') {
125    const file = r.file as { numLines?: number } | undefined
126
127    return typeof file?.numLines === 'number' ? `${file.numLines} lines` : ''
128  }
129
130  const diffs = diffsOf(tool, ran.result)
131
132  if (tool === 'Write' && r.type === 'create' && diffs[0]) {
133    return `new · ${diffs[0].added} lines`
134  }
135
136  if (diffs.length) {
137    const added = diffs.reduce((sum, d) => sum + d.added, 0)
138    const removed = diffs.reduce((sum, d) => sum + d.removed, 0)
139
140    return removed ? `+${added} −${removed}` : `+${added}`
141  }
142
143  return ''
144}
145
146type Hunk = { lines: string[] }
147
148function count(file: string, hunks: readonly Hunk[]): FileDiff {
149  let added = 0
150  let removed = 0
151
152  for (const hunk of hunks) {
153    for (const line of hunk.lines) {
154      if (line.startsWith('+')) added += 1
155      else if (line.startsWith('-')) removed += 1
156    }
157  }
158
159  return { file, added, removed }
160}
161
162// The files a finished call changed, with line counts, from the tool's own result.
163export function diffsOf(tool: string, result: unknown): FileDiff[] {
164  if (!result || typeof result !== 'object') {
165    return []
166  }
167
168  const r = result as Record<string, unknown>
169
170  if ((tool === 'Edit' || tool === 'Write') && typeof r.filePath === 'string') {
171    if (tool === 'Write' && r.type === 'create' && typeof r.content === 'string') {
172      return [{ file: r.filePath, added: r.content.replace(/\n$/, '').split('\n').length, removed: 0 }]
173    }
174
175    return [count(r.filePath, (r.structuredPatch as Hunk[] | undefined) ?? [])]
176  }
177
178  if (tool === 'Bash' && r.bashEditDiff && typeof r.bashEditDiff === 'object') {
179    const files = (r.bashEditDiff as { files?: { filePath: string; hunks: Hunk[] }[] }).files ?? []
180
181    return files.map(one => count(one.filePath, one.hunks))
182  }
183
184  return []
185}
186
187export function duration(ms: number): string {
188  const s = Math.round(ms / 1000)
189
190  if (s < 60) return `${s}s`
191  if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
192
193  return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
194}
195
196const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? '' : 's'}`
197
198export function turnLine(durationMs: number, stat: TurnStat | undefined): string {
199  const parts = [`✓ Done in ${duration(durationMs)}`]
200
201  if (stat) {
202    parts.push(stat.tools ? plural(stat.tools, 'tool') : 'no tools')
203    if (stat.files) parts.push(`${plural(stat.files, 'file')} changed`)
204    if (stat.added || stat.removed) parts.push(`+${stat.added} −${stat.removed}`)
205  }
206
207  return parts.join(' · ')
208}
209
210// The turn whose recorded length is nearest to the line's, within 1.5 s.
211export function closest(list: readonly TurnStat[], durationMs: number): TurnStat | undefined {
212  let best: TurnStat | undefined
213  let gap = 1500
214
215  for (const stat of list) {
216    const d = Math.abs(stat.durationMs - durationMs)
217
218    if (d <= gap) {
219      best = stat
220      gap = d
221    }
222  }
223
224  return best
225}
226
227export function modelLabel(model: string): string {
228  const m = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(model)
229
230  return m ? `${m[1]}-${m[2]}.${m[3]}` : model.replace(/^claude-/, '')
231}
232
233type Usage = {
234  context?: { percent?: number; tokens?: number; window?: number }
235  rateLimits?: readonly { kind: string; percentUsed: number }[]
236  cost?: { usd: number }
237}
238
239// 950, 76k, 1M, 1.2M: token counts the way the status line reads them.
240export function tokenCount(n: number): string {
241  if (n < 1000) return String(n)
242  if (n < 1_000_000) return `${Math.round(n / 1000)}k`
243
244  return `${Number((n / 1_000_000).toFixed(1))}M`
245}
246
247export function statusLine(model: string, usage: Usage, effort?: string | number, warnAt = 75): string {
248  const parts = [effort === undefined ? modelLabel(model) : `${modelLabel(model)} ${effort}`]
249  const pct = usage.context?.percent
250  const tokens = usage.context?.tokens
251  const window = usage.context?.window
252
253  if (pct !== undefined) {
254    const fill = tokens !== undefined && window ? `context ${tokenCount(tokens)}/${tokenCount(window)} (${pct}%), ${tokenCount(Math.max(0, window - tokens))} left` : `context ${pct}%`
255    parts.push(pct >= warnAt ? `${fill}, compact soon` : fill)
256  }
257
258  for (const limit of usage.rateLimits ?? []) {
259    if (limit.kind === 'five_hour') parts.push(`5h limit ${Math.round(limit.percentUsed)}%`)
260    if (limit.kind === 'seven_day') parts.push(`week limit ${Math.round(limit.percentUsed)}%`)
261  }
262
263  if (usage.cost) {
264    parts.push(`$${usage.cost.usd.toFixed(2)}`)
265  }
266
267  return parts.join(' · ')
268}
269
types/index.d.ts 34 lines
1export type AgentStatus = 'running' | 'done' | 'failed'
2
3export type AgentRow = {
4  id: string
5  type: string
6  task: string
7  model: string
8  status: AgentStatus
9  startedAt: number
10  endedAt: number | null
11  tools: number
12  live: string
13}
14
15export type FeedItem = {
16  id: string
17  kind: 'tool' | 'text' | 'you'
18  verb: string
19  text: string
20  state: 'running' | 'done' | 'error'
21  note: string
22}
23
24declare module 'claude-code' {
25  interface PluginState {
26    crew: {
27      agents: AgentRow[]
28      feeds: Record<string, FeedItem[]>
29      drafts: Record<string, string>
30      tick: number
31    }
32  }
33}
34