SLOPSHOPPER

who-wants-the-job

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

newpanebandguardcommandtool
★ 1v0.3.0MITupdated 2026-10-09danielriddell21/skills/plugins/who-wants-the-job
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · who-wants-the-job
│ ┃ Crew ✕ › fix the failing auth test and add an audit log call │ ┃ No subagents yet. Scout, engineer, spy and │ ┃ sniper show up here when they start. ⏺ 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 │ ⎿ who-wants-the-job: Crew pane opened │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Crew
No subagents yet. Scout, engineer, spy and sniper show up here when they start.
README

who-wants-the-job

Hire the cheapest worker that can do the job.

  • Agents (TF2): scout (haiku, read-only search), engineer (sonnet, implements), spy (opus, design and plans), sniper (opus, read-only review).
  • Skill 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).
  • Mod /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.
Source 4 files
hooks/register.tsx 186 lines
1import { 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}
186
hooks/chips.ts 42 lines
1// 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)
42
hooks/crew.ts 204 lines
1// 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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
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}
204
types/index.d.ts 27 lines
1export 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