Right model for the job: pinned agents, routing, cost report, and a live /crew pane with an animated crew, progress, context and cost

Hire the cheapest worker that can do the job.
scout (haiku, read-only search), engineer (sonnet, implements), spy (opus, design and plans), sniper (opus, read-only review).hey-you-do-it: the single routing table (which agent, or inline) and when to delegate./whats-the-damage [n]: token usage by model and tool from the last n session transcripts (scripts/cost-report.sh, needs jq)./crew: a live pane of every subagent (model, progress, context %, estimated cost, time) with each run as a row: its animated pixel crab on the left, then who it is, what it does, stats and a progress bar (SVG on desktop and VS Code, a glyph beside each line in the terminal; reduced-motion stops the animation), plus a ♟ crew chip in the shared chip row with a Crew button. Agents report their own steps through the step tool (mcp__who-wants-the-job__step); without it the bar shows context fill. Cost is a rough estimate from token counts (PRICES in hooks/crew.ts), not a bill. The pane opens by itself when a crew agent (scout, engineer, spy, sniper) starts, whether matchmaker routed to it or you did; option autoOpen: false turns that off.hooks/register.tsx 186 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CrewRun } from '../types'
5
6import { RANK, chipKey, injectChips, isEmpty } from './chips'
7import { chipText, costOf, ctxOf, ctxPct, fmtCost, fmtTime, fmtTokens, memberOf, rowSvg, rowText, tokensOf, totals, windowOf } from './crew'
8
9const PANE = 'crew'
10const TONE = { running: 'yellow', done: 'green', failed: 'red' } as const
11const agents = atom({ plugin: 'who-wants-the-job', key: 'agents' } as const, [] as CrewRun[])
12const now = atom({ plugin: 'who-wants-the-job', key: 'now' } as const, 0)
13
14// Opens the crew pane, or closes it when it is up; true when it ends up open.
15async function toggle($: EngineInterface): Promise<boolean> {
16 if ((await $.ui.panes()).some(p => p.id === PANE)) {
17 await $.ui.close({ id: PANE })
18 return false
19 }
20 await update($, now, () => 0)
21 await $.ui.open({ id: PANE, title: 'Crew' })
22 return true
23}
24
25export const register: Register = (on, options) => {
26 on('session.start', async ($, e, next) => {
27 const started = await next(e)
28 await $.tool.register({
29 name: 'step',
30 description:
31 'For crew agents: report progress on your own task to the /crew pane. Right after reading the brief call it with `total` (your plan in 3-8 steps) and `done: 0`; ' +
32 'call it again as each step finishes. Cheap and silent: it only draws a bar.',
33 inputSchema: {
34 type: 'object',
35 properties: {
36 done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
37 total: { type: 'integer', minimum: 1, description: 'Steps planned; may change.' },
38 note: { type: 'string', description: 'The step in progress, a few words.' },
39 },
40 required: ['done'],
41 },
42 })
43 await $.command.register({ name: 'crew', description: 'Show or hide the live panel of subagents: model, progress, context, cost and time' })
44 // Ticks the clocks of running agents; quiet when nothing runs.
45 $.clock.every(1000, () => {
46 void (async () => {
47 if (!(await read($, agents)).some(a => a.status === 'running')) return
48 const at = await $.clock.now()
49 await update($, now, () => at)
50 })()
51 })
52 return started
53 })
54
55 on('command.run', { command: 'crew' }, async $ => ({ text: (await toggle($)) ? 'Crew pane opened' : 'Crew pane closed' }))
56
57 // A worker's own progress: the call runs in the worker's loop, so agentId names it.
58 on('tool.call', { tool: 'mcp__who-wants-the-job__step' }, async ($, e) => {
59 const input = e as unknown as { done?: number; total?: number; note?: string }
60 if (!e.agentId) return { result: 'ignored: only subagents report steps' }
61 await update($, agents, list =>
62 list.map(a => {
63 if (a.agentId !== e.agentId) return a
64 const total = Math.max(0, Math.round(input.total ?? a.stepTotal ?? 0))
65 const done = Math.max(0, Math.round(input.done ?? a.stepDone ?? 0))
66 return { ...a, stepTotal: total, stepDone: total ? Math.min(total, done) : done, stepNote: input.note?.trim() || undefined }
67 }),
68 )
69 return { result: 'ok' }
70 })
71
72 on('agent.spawn', async ($, e, next) => {
73 const started = await next(e)
74 if (started.deny === undefined) {
75 const at = await $.clock.now()
76 const run: CrewRun = {
77 id: started.agentId ?? e.tool_use_id,
78 agentId: started.agentId,
79 type: e.subagentType,
80 description: e.description,
81 model: started.model,
82 status: 'running',
83 startedAt: at,
84 ctxTokens: 0,
85 ctxMax: windowOf(started.model),
86 tokens: 0,
87 costUsd: 0,
88 steps: 0,
89 }
90 await update($, agents, list => [...list.filter(a => a.id !== run.id), run].slice(-60))
91 await update($, now, () => at)
92 const isCrew = memberOf(e.subagentType).name !== 'agent'
93 if (options.autoOpen !== false && isCrew && !(await $.ui.panes()).some(p => p.id === PANE)) void $.ui.open({ id: PANE, title: 'Crew' })
94 }
95 return started
96 })
97
98 // Each model request of a subagent: live context, tokens and cost.
99 on('turn.step', async function* ($, e, next) {
100 const result = yield* next(e)
101 const usage = result.usage
102 if (e.agentId && usage) {
103 const model = usage.model || e.model
104 await update($, agents, list =>
105 list.map(a =>
106 a.agentId === e.agentId
107 ? { ...a, model, status: 'running', endedAt: undefined, ctxTokens: ctxOf(usage), ctxMax: windowOf(model), tokens: a.tokens + tokensOf(usage), costUsd: a.costUsd + costOf(model, usage), steps: a.steps + 1 }
108 : a,
109 ),
110 )
111 }
112 return result
113 })
114
115 on('turn.complete', async ($, e, next) => {
116 if (e.agentId) {
117 const at = await $.clock.now()
118 await update($, agents, list =>
119 list.map(a => {
120 if (a.agentId !== e.agentId) return a
121 // A run whose steps went unseen still gets the turn's own sum.
122 const fill = a.steps === 0 && e.usage ? { model: e.usage.model || a.model, tokens: tokensOf(e.usage), costUsd: costOf(e.usage.model || a.model, e.usage) } : {}
123 return { ...a, ...fill, status: e.reason === 'answer' ? 'done' : 'failed', endedAt: at }
124 }),
125 )
126 await update($, now, () => at)
127 }
128 return next(e)
129 })
130
131 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
132 const list = await read($, agents)
133 if (e.props.hasSurvey || list.length === 0) return next(e)
134
135 const { Box, Text, Button } = $.ui.resolve(e)
136 const rest = await next(e)
137 const at = Math.max(await read($, now), ...list.map(a => a.startedAt))
138 const running = list.some(a => a.status === 'running')
139 const chip = (
140 <Box key={chipKey(RANK.crew, 'crew')}>
141 <Text key="crew" color={running ? 'yellow' : 'green'}>{`${chipText(list, at)} `}</Text>
142 <Button key="open" label="Crew" onPress={() => void toggle($)} />
143 </Box>
144 )
145 const joined = injectChips(rest, chip)
146 if (joined) return joined as never
147 if (isEmpty(rest)) return <Box key="chips" flexWrap="wrap">{chip}</Box>
148 return (
149 <Box flexDirection="column">
150 <Box key="chips" flexWrap="wrap">{chip}</Box>
151 {rest}
152 </Box>
153 )
154 })
155
156 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
157 const { Box, Text, Button, Svg } = $.ui.resolve(e)
158 const list = await read($, agents)
159 if (list.length === 0) return <Text dimColor>No subagents yet. Scout, engineer, spy and sniper show up here when they start.</Text>
160
161 const at = Math.max(await read($, now), ...list.map(a => a.startedAt), ...list.map(a => a.endedAt ?? 0))
162 const t = totals(list, at)
163 const order = [...list.filter(a => a.status === 'running'), ...list.filter(a => a.status !== 'running').reverse()]
164 const svg = e.surface !== 'terminal' && Svg
165
166 return (
167 <Box flexDirection="column">
168 <Text bold>{`♟ Crew ${t.running} running · ${t.count} total · ≈${fmtCost(t.cost)} · ${fmtTokens(t.tokens)} tokens · ${fmtTime(t.time)}`}</Text>
169 <Text dimColor>{'─'.repeat(40)}</Text>
170 {order.map(a =>
171 svg ? (
172 <Svg key={`run:${a.id}`} source={rowSvg(a, at)} alt={`${memberOf(a.type).label}: ${a.description}`} />
173 ) : (
174 <Box key={`run:${a.id}`} flexDirection="column">
175 <Text color={TONE[a.status]}>{`${memberOf(a.type).glyph} ${rowText(a, at)}`}</Text>
176 <Text dimColor>{` ${a.description || '(no description)'} · ctx ${ctxPct(a)}% of ${fmtTokens(a.ctxMax)} · ${a.steps} requests`}</Text>
177 </Box>
178 ),
179 )}
180 <Text dimColor>{'─'.repeat(40)}</Text>
181 <Button key="clear" label="Clear finished" onPress={() => void update($, agents, l => l.filter(a => a.status === 'running'))} />
182 </Box>
183 )
184 })
185}
186hooks/chips.ts 42 lines1// Chip row: small status chips from different plugins share one row above the prompt.
2// - The row is a Box with props.key === 'chips' (wrapping, so a narrow terminal gets a second line
3// instead of cut-off buttons).
4// - Each chip is a Box keyed `chip:<rank>:<name>`; chips sort by rank so the order is the same
5// whichever plugin happens to draw first: gate 5, context 10, route 20, crew 25, result 30.
6// Plain data in, plain data out, so it works on whatever `next(e)` returns.
7export type Node = { type?: string; props?: Record<string, unknown>; children?: unknown[] }
8
9export const CHIPS_KEY = 'chips'
10export const GAP = ' '
11export const RANK = { gate: 5, context: 10, route: 20, crew: 25, result: 30 } as const
12
13export const chipKey = (rank: number, name: string): string => `chip:${String(rank).padStart(3, '0')}:${name}`
14
15const isNode = (n: unknown): n is Node => typeof n === 'object' && n !== null
16
17const keyOf = (n: unknown): string => (isNode(n) && typeof n.props?.key === 'string' ? n.props.key : '')
18
19/** Chips in rank order with a gap between each. */
20export const arrange = (chips: unknown[]): unknown[] =>
21 [...chips]
22 .sort((a, b) => keyOf(a).localeCompare(keyOf(b)))
23 .flatMap((c, i) => (i === 0 ? [c] : [GAP, c]))
24
25/** Add `chip` to the chips row inside `node`; undefined when there is none. */
26export const injectChips = (node: unknown, chip: unknown): Node | undefined => {
27 if (!isNode(node)) return undefined
28 if (node.props?.key === CHIPS_KEY) {
29 const existing = (node.children ?? []).filter(c => c !== GAP)
30 return { ...node, children: arrange([...existing, chip]) }
31 }
32 const kids = node.children ?? []
33 for (let i = kids.length - 1; i >= 0; i--) {
34 const hit = injectChips(kids[i], chip)
35 if (hit) return { ...node, children: kids.map((c, j) => (j === i ? hit : c)) }
36 }
37 return undefined
38}
39
40/** True when the band beneath drew nothing. */
41export const isEmpty = (node: unknown): boolean => node == null || (isNode(node) && (node.children ?? []).length === 0)
42hooks/crew.ts 204 lines1// Pure logic for the crew pane and chip: no engine calls, so it is unit-testable.
2import type { CrewRun } from '../types'
3
4export type Member = { name: string; label: string; color: string; glyph: string }
5
6export const CREW: Record<string, Member> = {
7 scout: { name: 'scout', label: 'Scout', color: '#e0713b', glyph: '»' },
8 engineer: { name: 'engineer', label: 'Engineer', color: '#d9a21b', glyph: '⚙' },
9 spy: { name: 'spy', label: 'Spy', color: '#8a5fd0', glyph: '◐' },
10 sniper: { name: 'sniper', label: 'Sniper', color: '#3f9d6a', glyph: '⌖' },
11}
12
13const OTHER: Member = { name: 'agent', label: 'Agent', color: '#7a8594', glyph: '●' }
14
15/** `who-wants-the-job:scout`, `Scout` and `scout` are the same member; anything else is a plain agent. */
16export const memberOf = (type: string): Member => CREW[type.split(':').pop()?.trim().toLowerCase() ?? ''] ?? OTHER
17
18// Rough $ per million tokens (input, output). An estimate, not a bill.
19const PRICES: [RegExp, number, number][] = [
20 [/haiku/i, 1, 5],
21 [/sonnet/i, 3, 15],
22 [/opus|fable/i, 5, 25],
23]
24
25export type Usage = {
26 input_tokens?: number
27 output_tokens?: number
28 cache_read_input_tokens?: number
29 cache_creation_input_tokens?: number
30}
31
32export const tokensOf = (u: Usage): number =>
33 (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
34
35/** Context in use after a request: everything the request carried plus the reply. */
36export const ctxOf = tokensOf
37
38export const costOf = (model: string, u: Usage): number => {
39 const [, inp, out] = PRICES.find(([re]) => re.test(model)) ?? [0, 3, 15]
40 const perTok = (n: number | undefined, price: number): number => ((n ?? 0) * price) / 1e6
41 return (
42 perTok(u.input_tokens, inp) +
43 perTok(u.output_tokens, out) +
44 perTok(u.cache_read_input_tokens, inp * 0.1) +
45 perTok(u.cache_creation_input_tokens, inp * 1.25)
46 )
47}
48
49export const windowOf = (model: string): number => (/1m/i.test(model) ? 1_000_000 : 200_000)
50
51export const fmtTokens = (n: number): string => {
52 if (n < 1000) return String(n)
53 return n < 10_000 ? `${(n / 1000).toFixed(1)}k` : `${Math.round(n / 1000)}k`
54}
55
56export const fmtCost = (usd: number): string => (usd < 0.01 ? '<$0.01' : `$${usd.toFixed(2)}`)
57
58export const fmtTime = (ms: number): string => {
59 const s = Math.max(0, Math.round(ms / 1000))
60 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
61}
62
63export const shortModel = (model: string): string => /haiku|sonnet|opus|fable/i.exec(model)?.[0].toLowerCase() ?? (model || '?')
64
65export const elapsed = (r: CrewRun, at: number): number => Math.max(0, (r.endedAt ?? at) - r.startedAt)
66
67export const ctxPct = (r: CrewRun): number => (r.ctxMax ? Math.min(100, Math.round((r.ctxTokens / r.ctxMax) * 100)) : 0)
68
69export const totals = (runs: readonly CrewRun[], at: number) => ({
70 running: runs.filter(r => r.status === 'running').length,
71 count: runs.length,
72 tokens: runs.reduce((s, r) => s + r.tokens, 0),
73 cost: runs.reduce((s, r) => s + r.costUsd, 0),
74 time: runs.reduce((m, r) => Math.max(m, elapsed(r, at)), 0),
75})
76
77export const bar = (frac: number, width: number): string => {
78 const filled = Math.round(Math.max(0, Math.min(1, frac)) * width)
79 return '█'.repeat(filled) + '░'.repeat(width - filled)
80}
81
82const MARK = { running: '●', done: '✓', failed: '✗' } as const
83
84/** One run as a line of text: status, member, model, steps (or context), cost, time. */
85export const rowText = (r: CrewRun, at: number): string => {
86 const m = memberOf(r.type)
87 const prog = r.stepTotal ? `${bar((r.stepDone ?? 0) / r.stepTotal, 6)} ${r.stepDone ?? 0}/${r.stepTotal}` : `ctx ${ctxPct(r)}%`
88 const note = r.stepNote ? ` · ${r.stepNote}` : ''
89 return `${MARK[r.status]} ${m.label} · ${shortModel(r.model)} · ${prog} · ${fmtCost(r.costUsd)} · ${fmtTime(elapsed(r, at))}${note}`
90}
91
92/** The chip above the prompt. */
93export const chipText = (runs: readonly CrewRun[], at: number): string => {
94 const t = totals(runs, at)
95 return t.running ? `♟ crew ${t.running}/${t.count} running · ≈${fmtCost(t.cost)}` : `♟ crew ${t.count} done · ≈${fmtCost(t.cost)}`
96}
97
98export const esc = (s: string): string => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
99
100const CLAY = '#D97757'
101const INK = '#1F1E1D'
102
103// A pixel crab on a 30x28 grid, in the Claude mascot's clay colour; the costume says which agent it is.
104// `cls` puts a pixel in a named group: `bd` (default) is the body and costume, `la`/`lb` the two leg pairs,
105// anything else a prop with its own motion.
106type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
107
108const body = (f: Fill, raised = false): void => {
109 f(7, 10, 16, 12, CLAY)
110 f(3, 14, 4, 4, CLAY)
111 f(23, raised ? 10 : 14, 4, 4, CLAY)
112 f(9, 12, 2, 2, INK)
113 f(19, 12, 2, 2, INK)
114 f(7, 22, 2, 4, CLAY, 'la')
115 f(17, 22, 2, 4, CLAY, 'la')
116 f(11, 22, 2, 4, CLAY, 'lb')
117 f(21, 22, 2, 4, CLAY, 'lb')
118}
119
120const COSTUMES: Record<string, (f: Fill) => void> = {
121 // Scout: red cap, swings a bat from the raised claw.
122 scout: f => {
123 body(f, true)
124 f(10, 6, 10, 4, '#C0392B'); f(10, 9, 14, 1, '#8E2A20'); f(14, 7, 2, 1, '#E8E4DC')
125 f(24, 2, 2, 12, '#CDA66B', 'it'); f(24, 2, 2, 2, '#A9854F', 'it')
126 },
127 // Engineer: yellow hard hat, turns a wrench.
128 engineer: f => {
129 body(f, true)
130 f(9, 5, 12, 4, '#F2C230'); f(8, 9, 14, 1, '#C99A1E'); f(14, 5, 2, 4, '#FBE08A')
131 f(24, 8, 2, 6, '#9AA4B0', 'it'); f(23, 5, 1, 4, '#9AA4B0', 'it'); f(26, 5, 1, 4, '#9AA4B0', 'it'); f(23, 8, 4, 1, '#9AA4B0', 'it')
132 },
133 // Spy: fedora and a mask; the eyes glance side to side.
134 spy: f => {
135 body(f)
136 f(8, 11, 14, 4, '#1C1C24')
137 f(9, 12, 2, 2, '#fff', 'pk'); f(19, 12, 2, 2, '#fff', 'pk')
138 f(11, 5, 8, 4, '#2B2B36'); f(11, 8, 8, 1, '#8A5FD0'); f(7, 9, 16, 1, '#2B2B36')
139 },
140 // Sniper: slouch hat, rifle across both claws with a glinting muzzle.
141 sniper: f => {
142 body(f)
143 f(11, 6, 8, 3, '#8A6A3C'); f(6, 9, 18, 1, '#6F5430')
144 f(4, 15, 23, 2, '#4A4A4A'); f(13, 13, 5, 2, '#333333'); f(27, 15, 2, 2, '#F5C542', 'gl')
145 },
146}
147
148const CRAB_CSS =
149 '.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}.run .bd{animation:bob .5s steps(1) infinite -.125s}' +
150 '.run g{transform-box:fill-box}.run .it{animation:swing .8s ease-in-out infinite;transform-origin:50% 100%}.c-engineer.run .it{animation:turn 1.2s linear infinite;transform-origin:50% 75%}' +
151 '.run .pk{animation:peek 1.6s steps(1) infinite}.run .gl{animation:glint 1.4s steps(1) infinite}.run .live{animation:glint 1s steps(1) infinite}' +
152 '@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}@keyframes swing{50%{transform:rotate(40deg)}}' +
153 '@keyframes turn{to{transform:rotate(360deg)}}@keyframes peek{25%{transform:translateX(1px)}75%{transform:translateX(-1px)}}@keyframes glint{50%{opacity:.15}}' +
154 '@media (prefers-reduced-motion:reduce){*{animation:none!important}}'
155
156const crab = (name: string, dim: boolean, running: boolean, x: number, y: number, scale: number): string => {
157 const groups = new Map<string, string[]>([['bd', []]])
158 const f: Fill = (x, y, w, h, c, cls = 'bd') => {
159 if (!groups.has(cls)) groups.set(cls, [])
160 groups.get(cls)?.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${c}"/>`)
161 }
162 ;(COSTUMES[name] ?? ((g: Fill) => body(g)))(f)
163 const group = (cls: string): string => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
164 const props = [...groups.keys()].filter(k => !['bd', 'la', 'lb'].includes(k))
165 const main = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
166 return `<g class="c-${name}${running ? ' run' : ''}" opacity="${dim ? 0.5 : 1}" shape-rendering="crispEdges" transform="translate(${x} ${y}) scale(${scale})">${main}${group('la')}${group('lb')}</g>`
167}
168
169const ROW_H = 56
170const CRAB_W = 54
171
172const clipTo = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n - 1).trimEnd()}…` : s)
173
174const ROW_CSS =
175 '.t{fill:#1f1f1f}.s{fill:#6b6b68}.tr{fill:#e4e4e1}@media (prefers-color-scheme:dark){.t{fill:#ececec}.s{fill:#a8a8a4}.tr{fill:#333331}}'
176
177const progressOf = (r: CrewRun): number => {
178 if (r.stepTotal) return (r.stepDone ?? 0) / r.stepTotal
179 return r.status === 'done' ? 1 : ctxPct(r) / 100
180}
181
182const badgeOf = (r: CrewRun, width: number, color: string): string => {
183 if (r.status === 'failed') return `<text x="${width - 14}" y="16" font-size="14" fill="#d04b4b">✗</text>`
184 if (r.status === 'done') return `<text x="${width - 14}" y="16" font-size="14" fill="#3f9d6a">✓</text>`
185 return `<circle class="live" cx="${width - 8}" cy="11" r="3.5" fill="${color}"/>`
186}
187
188/** One run as a row: its crab on the left, then who it is, what it does, and how far along it is. */
189export const rowSvg = (r: CrewRun, at: number, width = 380): string => {
190 const m = memberOf(r.type)
191 const running = r.status === 'running'
192 const frac = progressOf(r)
193 const stats = [
194 shortModel(r.model),
195 r.stepTotal ? `${r.stepDone ?? 0}/${r.stepTotal} steps` : `ctx ${ctxPct(r)}%`,
196 fmtCost(r.costUsd),
197 fmtTime(elapsed(r, at)),
198 ].join(' · ')
199 const note = r.stepNote ? ` · ${r.stepNote}` : ''
200 const badge = badgeOf(r, width, m.color)
201 const bw = width - CRAB_W - 8
202 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${ROW_H}" width="${width}" height="${ROW_H}" font-family="system-ui,sans-serif"><style>${CRAB_CSS}${ROW_CSS}</style>${crab(m.name, !running, running, 2, 4, 1.65)}<text class="t" x="${CRAB_W}" y="17" font-size="13" font-weight="600">${esc(m.label)}<tspan class="s" font-weight="400"> ${esc(clipTo(r.description || '', 34))}</tspan></text><text class="s" x="${CRAB_W}" y="34" font-size="11">${esc(clipTo(stats + note, 52))}</text><rect class="tr" x="${CRAB_W}" y="42" width="${bw}" height="4" rx="2"/><rect x="${CRAB_W}" y="42" width="${Math.max(0, Math.min(1, frac)) * bw}" height="4" rx="2" fill="${m.color}"/>${badge}</svg>`
203}
204types/index.d.ts 27 lines1export type CrewStatus = 'running' | 'done' | 'failed'
2
3export type CrewRun = {
4 id: string
5 agentId?: string
6 type: string
7 description: string
8 model: string
9 status: CrewStatus
10 startedAt: number
11 endedAt?: number
12 ctxTokens: number
13 ctxMax: number
14 tokens: number
15 costUsd: number
16 steps: number
17 stepDone?: number
18 stepTotal?: number
19 stepNote?: string
20}
21
22declare module 'claude-code' {
23 interface PluginState {
24 'who-wants-the-job': { agents: CrewRun[]; now: number }
25 }
26}
27