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

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.
hooks/register.tsx 353 lines1import { 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}
353hooks/avatar.ts 95 lines1// 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}
95hooks/describe.ts 269 lines1type 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}
269types/index.d.ts 34 lines1export 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