SLOPSHOPPER

squad

A live panel of this session's subagents as tiny vehicles: what each is doing, its model, effort, steps, context, tokens and time, with an activity bar and a…

newpanebandguardcommandtoast
v1.0.0MITupdated 2026-10-15anpato/claude-plugins/plugins/squad
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · squad
│ ┃ Squad ✕ › fix the failing auth test and add an audit log call │ ┃ No subagents yet this session. │ ⏺ 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 │ │ › /squad │ ⎿ squad: Squad panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Squad
No subagents yet this session.
README

squad

A Claude Code mod: a live panel of the session's subagents, each drawn as a tiny animated vehicle that shows what it is doing.

Agent is…Vehicle
Searching or reading (Read, Grep, Glob)Submarine
Editing (Edit, Write)Bulldozer
Running commands (Bash)Race car
On the web or using MCP toolsRocket
Writing its final answerTypewriter
Not started yetParked car

Each vehicle is painted in its model's color (Opus clay, Sonnet blue, Haiku green; grey if it failed). In Ghostty and kitty the vehicles are real images; every other terminal gets the same vehicles in block characters.

Each row also shows the title, agent type, model and effort, steps reported so far with the agent's note, context use, tokens and elapsed time, plus an activity bar that sweeps while the agent works. Press a title to open that agent's transcript. When the agents don't fit, rows collapse to title and bar. Finished rows clear themselves an hour after the agent ends.

Use

CommandDoes
/squadopen the panel
/squad closeclose it
/squad resetclear every row

The panel also opens by itself when an agent starts, and a one-line band above the prompt shows how many are running.

Settings (/config)

  • autoOpen (default on): open the panel when an agent starts.
  • askForSteps (default on): add one line to each subagent's prompt asking it to report its steps through the mod's step tool.

Install

claude plugin marketplace add anpato/claude-plugins
claude plugin install squad@apato-plugins

Mods are an early-access Claude Code feature, and their API can change between releases.

Develop

claude plugin validate plugins/squad and claude plugin test plugins/squad.

Source 5 files
hooks/register.tsx 516 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SquadActivity, SquadAgent, SquadUsage } from '../types'
5import { formatDuration, formatTokens, modelLabel, totalTokens } from './format'
6import { HIRES_COLUMNS, HIRES_ROWS, hiresPixels } from './hires'
7import { ACTIVITY_LABEL, SPRITE_COLUMNS, SPRITE_ROWS, hex, modelColor, spriteCells } from './sprite'
8
9const PANE = 'squad'
10const STEP_TOOL = 'mcp__squad__step'
11const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
12const DONE_COLOR = '#5fb87a'
13const FAILED_COLOR = '#e06c75'
14const BAR_TRACK = '#3a3f4b'
15const BAR_TRACK_RGB = 0x3a3f4b
16const VIEWED_COLOR = '#61afef'
17// Cells kept clear at the pane's left and right edges.
18const PANE_PADDING = 2
19// The bar shows activity, not progress: a pulse sweeps left to right, fading
20// behind it, while the agent works; finished it is solid.
21const SWEEP_MS = 100
22const SWEEP_TAIL = 10
23// Rows a full agent row takes (four lines and a gap) and a compact one (title and bar).
24const FULL_ROW_LINES = 5
25const COMPACT_ROW_LINES = 2
26
27const STEP_NOTE =
28  '\n\n(Progress reporting: if `mcp__squad__step` is among your tools, call it once you have ' +
29  'a plan, with `total` steps and `done: 0`, then again with `done` and a few-word `note` as ' +
30  'each step finishes. Skip it if the tool is absent.)'
31
32const agents = atom({ plugin: 'squad', key: 'agents' } as const, [])
33const tick = atom({ plugin: 'squad', key: 'tick' } as const, 0)
34const sweep = atom({ plugin: 'squad', key: 'sweep' } as const, 0)
35const openable = atom({ plugin: 'squad', key: 'openable' } as const, [])
36const contextWindow = atom({ plugin: 'squad', key: 'contextWindow' } as const, 0)
37const isFinishedCollapsed = atom({ plugin: 'squad', key: 'isFinishedCollapsed' } as const, false)
38const hasPixels = atom({ plugin: 'squad', key: 'hasPixels' } as const, false)
39
40// A finished agent's row is dropped this long after it ended.
41const FINISHED_ROW_TTL_MS = 60 * 60 * 1000
42
43const SEARCH_TOOLS = new Set(['Read', 'Grep', 'Glob', 'LSP', 'ToolSearch', 'NotebookRead'])
44const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
45const RUN_TOOLS = new Set(['Bash', 'PowerShell', 'Monitor', 'TaskStop'])
46const WEB_TOOLS = new Set(['WebFetch', 'WebSearch'])
47
48// What a tool call says the agent is doing; undefined for calls that say nothing.
49const activityOfTool = (tool: string): SquadActivity | undefined => {
50  if (tool === STEP_TOOL) return undefined
51  if (SEARCH_TOOLS.has(tool)) return 'search'
52  if (EDIT_TOOLS.has(tool)) return 'edit'
53  if (RUN_TOOLS.has(tool)) return 'run'
54  if (WEB_TOOLS.has(tool) || tool.startsWith('mcp__')) return 'web'
55  return undefined
56}
57
58const withActivity = (a: SquadAgent, activity: SquadActivity): SquadAgent => ({
59  ...a,
60  activity,
61  activityCounts: { ...a.activityCounts, [activity]: (a.activityCounts?.[activity] ?? 0) + 1 },
62})
63
64// The vehicle to draw: what a running agent is doing now, what a finished one
65// did most; an Explore agent that has not called a tool yet is searching.
66const vehicleOf = (a: SquadAgent): SquadActivity | undefined => {
67  if (a.status !== 'running' && a.activityCounts !== undefined) {
68    const counted = Object.entries(a.activityCounts) as [SquadActivity, number][]
69    const top = counted.filter(([kind]) => kind !== 'answer').sort((x, y) => y[1] - x[1])[0]
70    if (top !== undefined) return top[0]
71  }
72  return a.activity ?? (a.type === 'Explore' ? 'search' : undefined)
73}
74
75const ZERO: SquadUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
76
77const addUsage = (a: SquadUsage, b: SquadUsage): SquadUsage => ({
78  input: a.input + b.input,
79  output: a.output + b.output,
80  cacheRead: a.cacheRead + b.cacheRead,
81  cacheWrite: a.cacheWrite + b.cacheWrite,
82})
83
84const patchAgent = ($: EngineInterface, id: string, patch: (a: SquadAgent) => SquadAgent) =>
85  update($, agents, list => list.map(a => (a.id === id ? patch(a) : a)))
86
87const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Squad', columns: 52 })
88
89const isPaneOpen = async ($: EngineInterface) =>
90  (await $.ui.panes().catch(() => [])).some(p => p.id === PANE)
91
92// The agents whose transcripts can still be opened, read once a second and on
93// each spawn rather than on every redraw.
94const refreshOpenable = async ($: EngineInterface) => {
95  const listed = await $.agent.list().catch(() => [])
96  const ids = listed.map(a => a.id).sort()
97  const current = await read($, openable)
98  if (ids.join() !== [...current].sort().join()) await update($, openable, () => ids)
99}
100
101// A whole, non-negative count from whatever the model sent; undefined for
102// anything that is not a finite number (or numeric string).
103const toCount = (value: unknown): number | undefined => {
104  const n = typeof value === 'string' && value.trim() !== '' ? Number(value) : value
105  return typeof n === 'number' && Number.isFinite(n) ? Math.max(0, Math.round(n)) : undefined
106}
107
108// A color between two, `t` from 0 (the first) to 1 (the second).
109const mix = (from: number, to: number, t: number): number => {
110  const ch = (shift: number) =>
111    Math.round(((from >> shift) & 0xff) + (((to >> shift) & 0xff) - ((from >> shift) & 0xff)) * t)
112  return (ch(16) << 16) | (ch(8) << 8) | ch(0)
113}
114
115// The lit stretch of a working bar: a pulse at `head` with a tail fading out
116// over the SWEEP_TAIL - 1 cells behind it. Every frame lights at least one
117// cell (the cycle is width + SWEEP_TAIL - 1 long, so it never goes dark).
118const sweepPulse = (width: number, step: number): { from: number; levels: number[] } => {
119  const head = step % (width + SWEEP_TAIL - 1)
120  const from = Math.max(0, head - SWEEP_TAIL + 1)
121  const to = Math.min(width - 1, head)
122  const levels: number[] = []
123  for (let i = from; i <= to; i++) levels.push(1 - (head - i) / SWEEP_TAIL)
124  return { from, levels }
125}
126
127const stepsLabel = (a: SquadAgent): string => {
128  if (a.stepsTotal !== undefined) {
129    const done = a.status === 'done' ? a.stepsTotal : Math.min(a.stepsTotal, a.stepsDone ?? 0)
130    return `${done}/${a.stepsTotal}`
131  }
132  return a.status === 'running' ? '…' : ''
133}
134
135export const register: Register = (on, options) => {
136  // Whether the pane is open, refreshed once a second: the sweep only runs then.
137  let isOpen = false
138
139  on('session.start', async ($, e, next) => {
140    await $.command.register({
141      name: 'squad',
142      description: 'Show the subagents panel (open, close or reset)',
143      argumentHint: '[open|close|reset]',
144    })
145    await $.tool.register({
146      name: 'step',
147      description:
148        'Report progress on your own task to the squad panel. After reading your task, call it ' +
149        'with `total` (your plan in 3-8 steps) and `done: 0`; call it again as each step ' +
150        'finishes. Silent and cheap: it only draws a progress bar.',
151      inputSchema: {
152        type: 'object',
153        properties: {
154          done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
155          total: { type: 'integer', minimum: 1, description: 'Steps planned; may change.' },
156          note: { type: 'string', description: 'The step in progress, a few words.' },
157        },
158        required: ['done'],
159      },
160      isDeferred: false,
161    })
162
163    // Ghostty and kitty draw the Image element as real pixels; elsewhere it
164    // would fall back to text, so those terminals keep the block vehicles.
165    const program = (await $.env.get('TERM_PROGRAM')) ?? ''
166    const term = (await $.env.get('TERM')) ?? ''
167    await update($, hasPixels, () => /ghostty|kitty/i.test(`${program} ${term}`))
168
169    const { context } = await $.session.usage()
170    await update($, contextWindow, () => context.window)
171
172    // Redraw clocks and walking mascots once a second while anything runs, and
173    // drop finished rows an hour after they ended.
174    $.clock.every(1000, async () => {
175      isOpen = await isPaneOpen($)
176      if (isOpen) await refreshOpenable($)
177      const list = await read($, agents)
178      if (list.some(a => a.status === 'running')) await update($, tick, n => n + 1)
179
180      const now = await $.clock.now()
181      const isExpired = (a: SquadAgent) =>
182        a.status !== 'running' && a.endedAt !== undefined && now - a.endedAt > FINISHED_ROW_TTL_MS
183      if (list.some(isExpired)) await update($, agents, all => all.filter(a => !isExpired(a)))
184    })
185    // Only while the pane is open: nothing else shows the sweep.
186    $.clock.every(SWEEP_MS, async () => {
187      if (!isOpen) return
188      const list = await read($, agents)
189      if (list.some(a => a.status === 'running')) await update($, sweep, n => n + 1)
190    })
191
192    return next(e)
193  })
194
195  on('command.run', { command: 'squad' }, async ($, e) => {
196    const arg = e.args.trim()
197    if (arg === 'close') {
198      await $.ui.close({ id: PANE })
199      return { text: 'Squad panel closed.' }
200    }
201    if (arg === 'reset') {
202      await update($, agents, () => [])
203      return { text: 'Squad cleared.' }
204    }
205    await openPane($)
206    return { text: 'Squad panel opened.' }
207  })
208
209  on('agent.spawn', async ($, e, next) => {
210    const canRewrite = e.workflow === undefined && e.resume === undefined
211    const shouldAsk = options.askForSteps !== false && canRewrite && !e.prompt.includes(STEP_TOOL)
212    const started = await next(shouldAsk ? { ...e, prompt: e.prompt + STEP_NOTE } : e)
213    if (started.deny !== undefined || started.agentId === undefined) return started
214
215    const agent: SquadAgent = {
216      id: started.agentId,
217      title: e.description || e.subagentType,
218      type: e.subagentType,
219      model: started.model,
220      status: 'running',
221      startedAt: await $.clock.now(),
222      usage: ZERO,
223      contextTokens: 0,
224    }
225    await update($, agents, list => [...list.filter(a => a.id !== agent.id), agent].slice(-100))
226    await refreshOpenable($)
227
228    if (options.autoOpen !== false && !(await isPaneOpen($))) {
229      void openPane($).catch(() => undefined)
230    }
231    return started
232  }).catch(($, e, next) => next(e)) // never block a spawn over the panel's bookkeeping
233
234  // A teammate wakes for another turn: it is running again.
235  on('turn.start', async ($, e, next) => {
236    const { agentId } = e
237    if (agentId !== undefined) {
238      await patchAgent($, agentId, a =>
239        a.status === 'running' ? a : { ...a, status: 'running', endedAt: undefined },
240      )
241    }
242    return next(e)
243  })
244
245  // Each model request of a subagent: its tokens, effort and live context.
246  on('turn.step', async function* ($, e, next) {
247    const result = yield* next(e)
248    const { agentId } = e
249    const reported = result.usage
250    if (agentId === undefined) return result
251    if (result.toolUses.length === 0 && result.answer !== '') {
252      await patchAgent($, agentId, a => withActivity(a, 'answer'))
253    }
254    if (reported === null) return result
255
256    const usage: SquadUsage = {
257      input: reported.input_tokens,
258      output: reported.output_tokens,
259      cacheRead: reported.cache_read_input_tokens,
260      cacheWrite: reported.cache_creation_input_tokens,
261    }
262    await patchAgent($, agentId, a => ({
263      ...a,
264      model: reported.model,
265      effort: e.effort === undefined ? a.effort : String(e.effort),
266      usage: addUsage(a.usage, usage),
267      contextTokens: usage.input + usage.cacheRead + usage.cacheWrite,
268    }))
269    return result
270  })
271
272  on('turn.complete', async ($, e, next) => {
273    const { agentId } = e
274    if (agentId !== undefined) {
275      const endedAt = await $.clock.now()
276      const status = e.reason === 'answer' ? 'done' : 'failed'
277      await patchAgent($, agentId, a => ({ ...a, status, endedAt }))
278    }
279    return next(e)
280  })
281
282  // Every tool a subagent calls says what it is doing; the step tool is
283  // answered here too (one hook: two on tool.call are too deep for the checker).
284  on('tool.call', async ($, e, next) => {
285    // Read as a plain shape: the input's full union is too deep for the checker.
286    const input = e as { agentId?: string; tool: string; done?: unknown; total?: unknown; note?: unknown }
287    const { agentId, tool } = input
288
289    if (tool === STEP_TOOL) {
290      if (agentId === undefined) return { result: 'ignored: only subagents report steps' }
291      // A missing, invalid or zero total keeps the plan already reported.
292      const total = toCount(input.total)
293      const done = toCount(input.done)
294      const note = typeof input.note === 'string' ? input.note.trim() : ''
295      await patchAgent($, agentId, a => ({
296        ...a,
297        stepsTotal: total !== undefined && total > 0 ? total : a.stepsTotal,
298        stepsDone: done ?? a.stepsDone,
299        note: note || undefined,
300      }))
301      return { result: 'ok' }
302    }
303
304    const activity = agentId === undefined ? undefined : activityOfTool(tool)
305    if (agentId !== undefined && activity !== undefined) {
306      await patchAgent($, agentId, a => withActivity(a, activity))
307    }
308    return next(e)
309  }).catch(($, e, next) => next(e)) // never block a tool over the panel's bookkeeping
310
311  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
312    const ui = $.ui.resolve(e)
313    const { Box, Text, Button } = ui
314    const Raster = e.surface === 'terminal' && 'Raster' in ui ? ui.Raster : undefined
315    const Image = e.surface === 'terminal' && 'Image' in ui && (await read($, hasPixels)) ? ui.Image : undefined
316
317    const list = await read($, agents)
318    const frame = await read($, tick)
319    const step = await read($, sweep)
320    const window = await read($, contextWindow)
321    const isCollapsed = await read($, isFinishedCollapsed)
322    const now = await $.clock.now()
323    const viewed = e.props.view.agentId
324    // Claude Code keeps a finished agent's transcript only for a while, and none
325    // from an earlier process: only the agents it still lists can be opened.
326    const canOpen = new Set(await read($, openable))
327
328    const running = list.filter(a => a.status === 'running')
329    const finished = list.filter(a => a.status !== 'running')
330
331    const barWidth = Math.max(4, e.props.bodyColumns - PANE_PADDING * 2 - (Image || Raster ? SPRITE_COLUMNS + 2 : 0))
332
333    // More agents than the pane can show in full: each becomes its title and bar.
334    const chrome = 2 + (viewed !== undefined ? 1 : 0) + (running.length > 0 ? 2 : 0) + (finished.length > 0 ? 2 : 0)
335    const shown = running.length + (isCollapsed ? 0 : finished.length)
336    const isCompact = chrome + shown * FULL_ROW_LINES > e.props.scroll.bodyRows
337    const compactBarWidth = Math.max(4, e.props.bodyColumns - PANE_PADDING * 2)
338
339    // Opens the agent's transcript in the main view, as the tasks list does;
340    // pressing the one already shown goes back to the main conversation.
341    const show = async (agentId: string) => {
342      const shown = await $.ui.view(viewed === agentId ? {} : { agentId })
343      if (shown.deny !== undefined) $.ui.toast(`squad: can't open that transcript (${shown.deny})`)
344    }
345
346    const row = (a: SquadAgent) => {
347      const color = hex(modelColor(a.model))
348      const vehicle = vehicleOf(a)
349      const isViewed = viewed === a.id
350      const mark =
351        a.status === 'running' ? (
352          <Text color={color}>{SPINNER[frame % SPINNER.length]}</Text>
353        ) : a.status === 'done' ? (
354          <Text color={DONE_COLOR}>✓</Text>
355        ) : (
356          <Text color={FAILED_COLOR}>✗</Text>
357        )
358      const ctx = window > 0 ? `ctx ${Math.round((a.contextTokens / window) * 100)}% · ` : ''
359      const meta = `${ctx}${formatTokens(totalTokens(a.usage))} · ${formatDuration((a.endedAt ?? now) - a.startedAt)}`
360
361      const bar = (width: number) => {
362        if (a.status !== 'running') {
363          return <Text color={a.status === 'failed' ? FAILED_COLOR : color}>{'━'.repeat(width)}</Text>
364        }
365        // Working: a dim track with a bright pulse moving left to right, drawn
366        // as the track before it, its lit cells, and the track after it.
367        const paint = modelColor(a.model)
368        const { from, levels } = sweepPulse(width, step)
369        return (
370          <Text>
371            <Text color={BAR_TRACK}>{'─'.repeat(from)}</Text>
372            {levels.map(level => (
373              <Text color={hex(mix(BAR_TRACK_RGB, paint, level))}>━</Text>
374            ))}
375            <Text color={BAR_TRACK}>{'─'.repeat(Math.max(0, width - from - levels.length))}</Text>
376          </Text>
377        )
378      }
379      const title = canOpen.has(a.id) ? (
380        <Button key={`open-${a.id}`} plain onPress={() => show(a.id)}>
381          <Text bold color={isViewed ? VIEWED_COLOR : undefined}>
382            {isViewed ? '▶ ' : ''}
383            {a.title}
384          </Text>
385        </Button>
386      ) : (
387        <Text bold dimColor wrap="truncate-end">
388          {a.title}
389        </Text>
390      )
391
392      if (isCompact) {
393        return (
394          <Box key={a.id} flexDirection="column">
395            <Box flexDirection="row" justifyContent="space-between" columnGap={1}>
396              {title}
397              <Text>
398                <Text dimColor>{stepsLabel(a)} </Text>
399                {mark}
400              </Text>
401            </Box>
402            {bar(compactBarWidth)}
403          </Box>
404        )
405      }
406
407      return (
408        <Box key={a.id} flexDirection="row" columnGap={2} marginBottom={1}>
409          {Image ? (
410            <Image
411              key={`sprite-${a.id}`}
412              columns={HIRES_COLUMNS}
413              rows={HIRES_ROWS}
414              alt=" "
415              source={hiresPixels({
416                activity: vehicle,
417                paint: modelColor(a.model),
418                isFailed: a.status === 'failed',
419                frame: a.status === 'running' ? frame : 0,
420              })}
421            />
422          ) : (
423            Raster && (
424              <Raster
425                key={`sprite-${a.id}`}
426                columns={SPRITE_COLUMNS}
427                rows={SPRITE_ROWS}
428                cells={spriteCells({
429                  activity: vehicle,
430                  model: a.model,
431                  isFailed: a.status === 'failed',
432                  frame: a.status === 'running' ? frame : 0,
433                })}
434              />
435            )
436          )}
437          <Box flexDirection="column" flexGrow={1}>
438            <Box flexDirection="row" justifyContent="space-between">
439              {title}
440              {mark}
441            </Box>
442            <Text wrap="truncate-end">
443              <Text color={color}>{a.type}</Text> {modelLabel(a.model)}
444              {a.effort !== undefined ? ` · ${a.effort}` : ''}
445              {vehicle !== undefined ? <Text dimColor> · {ACTIVITY_LABEL[vehicle]}</Text> : ''}
446            </Text>
447            <Box flexDirection="row" justifyContent="space-between" columnGap={1}>
448              <Text wrap="truncate-end">
449                {stepsLabel(a)}
450                {a.note !== undefined && a.status === 'running' ? <Text dimColor> {a.note}</Text> : ''}
451              </Text>
452              <Text dimColor>{meta}</Text>
453            </Box>
454            {bar(barWidth)}
455          </Box>
456        </Box>
457      )
458    }
459
460    return (
461      <Box flexDirection="column" paddingLeft={PANE_PADDING} paddingRight={PANE_PADDING}>
462        {viewed !== undefined && (
463          <Button key="back" plain onPress={() => $.ui.view({})}>
464            <Text color={VIEWED_COLOR}>← back to the main conversation</Text>
465          </Button>
466        )}
467        {list.length === 0 && <Text dimColor>No subagents yet this session.</Text>}
468        {list.length > 0 && (
469          <Text dimColor>Press a title to open its transcript (dim ones are closed).</Text>
470        )}
471        {running.length > 0 && (
472          <Box flexDirection="column" marginTop={1}>
473            <Text bold>Running · {running.length}</Text>
474            {running.map(row)}
475          </Box>
476        )}
477        {finished.length > 0 && (
478          <Box flexDirection="column" marginTop={1}>
479            <Button
480              key="toggle-finished"
481              plain
482              onPress={() => update($, isFinishedCollapsed, v => !v)}
483            >
484              {isCollapsed ? '▸' : '▾'} Finished · {finished.length}
485            </Button>
486            {!isCollapsed && finished.slice().reverse().map(row)}
487          </Box>
488        )}
489      </Box>
490    )
491  })
492
493  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
494    const list = await read($, agents)
495    const running = list.filter(a => a.status === 'running')
496    if (e.props.hasSurvey || running.length === 0) return next(e)
497
498    const { Box, Text, Button } = $.ui.resolve(e)
499    const frame = await read($, tick)
500    const now = await $.clock.now()
501    const done = list.length - running.length
502    const span = now - Math.min(...running.map(a => a.startedAt))
503
504    return (
505      <Box flexDirection="row" columnGap={1}>
506        <Text color={hex(modelColor(running[0]?.model ?? ''))}>{SPINNER[frame % SPINNER.length]}</Text>
507        <Text>
508          squad · {running.length} running{done > 0 ? ` · ${done} done` : ''}
509        </Text>
510        <Text dimColor>{formatDuration(span)}</Text>
511        <Button key="open" label="open" onPress={() => openPane($)} />
512      </Box>
513    )
514  })
515}
516
hooks/format.ts 29 lines
1import type { SquadUsage } from '../types'
2
3export const totalTokens = (usage: SquadUsage): number =>
4  usage.input + usage.output + usage.cacheRead + usage.cacheWrite
5
6// 'claude-opus-5-5' -> 'Opus 5.5'; anything else is shown as given.
7export const modelLabel = (model: string): string => {
8  const match = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d+))?/i.exec(model)
9  if (!match) return model
10  const [, family = '', major, minor] = match
11  const name = family.charAt(0).toUpperCase() + family.slice(1).toLowerCase()
12  return minor === undefined ? `${name} ${major}` : `${name} ${major}.${minor}`
13}
14
15export const formatTokens = (n: number): string => {
16  if (n < 1000) return String(n)
17  if (n < 10_000) return `${(n / 1000).toFixed(1)}k`
18  if (n < 1_000_000) return `${Math.round(n / 1000)}k`
19  return `${(n / 1_000_000).toFixed(1)}M`
20}
21
22export const formatDuration = (ms: number): string => {
23  const total = Math.max(0, Math.floor(ms / 1000))
24  const h = Math.floor(total / 3600)
25  const m = Math.floor((total % 3600) / 60)
26  const s = String(total % 60).padStart(2, '0')
27  return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`
28}
29
hooks/hires.ts 219 lines
1import type { SquadActivity } from '../types'
2
3// High-resolution vehicles for terminals that draw real pixels (Ghostty, kitty):
4// 28x24 pixel art, scaled up 4x with hard edges and sent as an `Image` over the
5// same 7x3 cells the block version uses. Same vehicles, same model paint.
6
7export const HIRES_COLUMNS = 7
8export const HIRES_ROWS = 3
9const W = 28
10const H = 24
11const SCALE = 4
12
13type Look = { activity?: SquadActivity; paint: number; isFailed: boolean; frame: number }
14
15const FAILED_PAINT = 0x6b6b6b
16const GLASS = 0xa9d6f5
17const GLINT = 0xeaf6ff
18const TYRE = 0x3a3a3c
19const STEEL = 0x9aa0a8
20const STEEL_DARK = 0x6e737b
21const WHITE = 0xf4f3ee
22const INK = 0x2b2b2b
23const YELLOW = 0xf5c542
24const FLAME = 0xff7b3a
25const RED = 0xe0565b
26const DIRT = 0x8a5a33
27const BUBBLE = 0xbfe3fb
28
29const shade = (color: number, factor: number): number => {
30  const ch = (shift: number) => Math.min(255, Math.max(0, Math.round(((color >> shift) & 0xff) * factor)))
31  return (ch(16) << 16) | (ch(8) << 8) | ch(0)
32}
33
34// A 28x24 canvas: 0 is transparent, anything else is 0x1RRGGBB (bit 24 marks it set).
35class Canvas {
36  readonly px = new Uint32Array(W * H)
37
38  rect(x: number, y: number, w: number, h: number, color: number): this {
39    for (let j = Math.max(0, y); j < Math.min(H, y + h); j++) {
40      for (let i = Math.max(0, x); i < Math.min(W, x + w); i++) this.px[j * W + i] = color | 0x1000000
41    }
42    return this
43  }
44
45  // A filled ellipse centred on pixel centres (cx, cy are in pixel units).
46  oval(cx: number, cy: number, rx: number, ry: number, color: number): this {
47    for (let j = 0; j < H; j++) {
48      for (let i = 0; i < W; i++) {
49        const dx = (i + 0.5 - cx) / rx
50        const dy = (j + 0.5 - cy) / ry
51        if (dx * dx + dy * dy <= 1) this.px[j * W + i] = color | 0x1000000
52      }
53    }
54    return this
55  }
56
57  disc(cx: number, cy: number, r: number, color: number): this {
58    return this.oval(cx, cy, r, r, color)
59  }
60
61  dot(x: number, y: number, color: number): this {
62    return this.rect(x, y, 1, 1, color)
63  }
64}
65
66const wheel = (c: Canvas, cx: number, cy: number, r: number, frame: number) => {
67  c.disc(cx, cy, r, TYRE).disc(cx, cy, r * 0.45, STEEL)
68  // The spoke flips between horizontal and vertical as the wheel turns.
69  if (frame === 0) c.rect(Math.round(cx - r * 0.7), Math.round(cy - 0.5), Math.round(r * 1.4), 1, STEEL_DARK)
70  else c.rect(Math.round(cx - 0.5), Math.round(cy - r * 0.7), 1, Math.round(r * 1.4), STEEL_DARK)
71}
72
73type Draw = (c: Canvas, paint: number, frame: number) => void
74
75// A small parked car: nothing has started yet.
76const idle: Draw = (c, paint) => {
77  const dark = shade(paint, 0.7)
78  const light = shade(paint, 1.25)
79  c.rect(9, 7, 11, 1, light).rect(8, 8, 13, 5, paint)
80  c.rect(10, 9, 4, 3, GLASS).rect(15, 9, 4, 3, GLASS).dot(10, 9, GLINT).dot(15, 9, GLINT)
81  c.rect(3, 13, 22, 5, paint).rect(3, 13, 22, 1, light).rect(3, 17, 22, 1, dark)
82  c.rect(23, 14, 2, 2, YELLOW).rect(3, 14, 1, 2, RED)
83  wheel(c, 8.5, 19.5, 3, 0)
84  wheel(c, 19.5, 19.5, 3, 0)
85}
86
87const VEHICLES: Record<SquadActivity, Draw> = {
88  // Submarine with a periscope; the propeller turns and bubbles rise.
89  search: (c, paint, frame) => {
90    const dark = shade(paint, 0.7)
91    const light = shade(paint, 1.25)
92    c.rect(16, 2, 1, 6, STEEL).rect(16, 2, 4, 1, STEEL).rect(19, 2, 1, 2, GLASS)
93    c.rect(11, 7, 7, 5, paint).rect(11, 7, 7, 1, light)
94    c.oval(14, 15.5, 11.5, 5.5, paint)
95    // The keel: the hull's lower rows in the darker shade.
96    for (let j = 18; j < 22; j++) {
97      for (let i = 0; i < W; i++) if (c.px[j * W + i]) c.px[j * W + i] = dark | 0x1000000
98    }
99    c.rect(8, 11, 12, 1, light)
100    for (const x of [9, 14, 19]) c.disc(x, 14.5, 1.6, GLASS).dot(x - 1, 13, GLINT)
101    c.rect(1, 15, 2, 1, STEEL_DARK)
102    if (frame === 0) c.rect(0, 12, 2, 3, STEEL).rect(0, 16, 2, 3, STEEL)
103    else c.rect(0, 14, 2, 3, STEEL)
104    if (frame === 0) c.disc(24.5, 8.5, 1.2, BUBBLE).disc(26.5, 4.5, 1.6, BUBBLE)
105    else c.disc(25.5, 6.5, 1.6, BUBBLE).disc(23.5, 2.5, 1.2, BUBBLE)
106  },
107
108  // Bulldozer; the treads roll and the blade pushes dirt.
109  edit: (c, paint, frame) => {
110    const dark = shade(paint, 0.7)
111    const light = shade(paint, 1.25)
112    c.rect(15, 5, 1, 6, STEEL_DARK).rect(14, 4, 3, 1, STEEL_DARK)
113    c.rect(5, 4, 8, 7, paint).rect(5, 4, 8, 1, light).rect(7, 6, 4, 3, GLASS).dot(7, 6, GLINT)
114    c.rect(3, 11, 16, 6, paint).rect(3, 11, 16, 1, light).rect(3, 16, 16, 1, dark)
115    c.rect(19, 13, 4, 2, dark)
116    c.oval(11, 20.5, 9.5, 3, TYRE)
117    for (const x of [5, 9, 13, 17]) c.disc(x, 20.5, 1.6, STEEL)
118    for (let x = 3 + frame; x < 20; x += 2) c.dot(x, 17, STEEL_DARK)
119    c.rect(22, 9, 3, 13, STEEL).rect(24, 9, 1, 13, STEEL_DARK).rect(22, 21, 4, 1, STEEL_DARK)
120    if (frame === 0) c.disc(26.5, 20.5, 1.8, DIRT).dot(27, 18, DIRT)
121    else c.disc(26.5, 21, 1.4, DIRT).dot(26, 17, DIRT)
122  },
123
124  // Race car with a spoiler; the wheels spin and exhaust puffs out behind it.
125  run: (c, paint, frame) => {
126    const dark = shade(paint, 0.7)
127    const light = shade(paint, 1.25)
128    c.rect(1, 8, 6, 2, dark).rect(3, 10, 1, 4, dark)
129    c.disc(13.5, 11, 2.6, WHITE).rect(13, 10, 3, 1, INK)
130    c.rect(2, 13, 21, 5, paint).rect(2, 13, 21, 1, light)
131    for (let i = 0; i < 4; i++) c.rect(23 + i, 14 + Math.floor(i / 2), 1, 4 - Math.floor(i / 2), paint)
132    c.rect(2, 17, 24, 1, dark)
133    c.disc(17, 15.5, 1.8, WHITE).dot(17, 15, INK)
134    c.rect(9, 15, 4, 1, light)
135    wheel(c, 6.5, 19, 3.4, frame)
136    wheel(c, 21.5, 19, 3.4, frame)
137    if (frame === 0) c.disc(1, 17.5, 1.3, STEEL).dot(0, 15, STEEL)
138    else c.disc(0.5, 16, 1, STEEL)
139  },
140
141  // Rocket; the flame flickers and a star twinkles.
142  web: (c, paint, frame) => {
143    const dark = shade(paint, 0.7)
144    const light = shade(paint, 1.25)
145    c.rect(6, 4, 3, 1, dark).rect(6, 5, 4, 4, dark)
146    c.rect(6, 16, 4, 4, dark).rect(6, 20, 3, 1, dark)
147    c.rect(7, 9, 14, 7, paint).rect(7, 9, 14, 1, light).rect(7, 15, 14, 1, dark)
148    for (let i = 0; i < 4; i++) c.rect(21 + i, 9 + i, 1, 7 - 2 * i, paint)
149    c.dot(25, 12, light)
150    c.disc(15, 12.5, 2.4, WHITE).disc(15, 12.5, 1.6, GLASS).dot(14, 11, GLINT)
151    c.rect(5, 10, 2, 5, STEEL)
152    if (frame === 0) c.rect(1, 11, 4, 3, FLAME).rect(3, 12, 2, 1, YELLOW).dot(0, 12, FLAME)
153    else c.rect(2, 10, 3, 5, FLAME).rect(0, 12, 4, 1, YELLOW)
154    const [sx, sy] = frame === 0 ? [25, 3] : [23, 20]
155    c.dot(sx, sy, YELLOW).dot(sx - 1, sy, YELLOW).dot(sx + 1, sy, YELLOW).dot(sx, sy - 1, YELLOW).dot(sx, sy + 1, YELLOW)
156  },
157
158  // Typewriter; the keys go down and a line of text grows on the page.
159  answer: (c, paint, frame) => {
160    const dark = shade(paint, 0.7)
161    const light = shade(paint, 1.25)
162    c.rect(8, 1, 12, 9, WHITE).rect(10, 3, 8, 1, STEEL).rect(10, 5, frame === 0 ? 4 : 7, 1, STEEL)
163    c.rect(5, 9, 18, 3, INK).rect(3, 9, 2, 3, STEEL).rect(23, 9, 2, 3, STEEL).rect(24, 7, 3, 1, STEEL)
164    c.rect(4, 12, 20, 9, paint).rect(4, 12, 20, 1, light).rect(4, 20, 20, 1, dark)
165    for (let row = 0; row < 2; row++) {
166      for (let k = 0; k < 6; k++) {
167        const isPressed = (frame + row + k) % 4 === 0
168        c.rect(6 + k * 3, 14 + row * 3 + (isPressed ? 1 : 0), 2, 2, isPressed ? STEEL_DARK : WHITE)
169      }
170    }
171  },
172}
173
174const base64 = (bytes: Uint8Array): string => {
175  let binary = ''
176  for (const byte of bytes) binary += String.fromCharCode(byte)
177  return btoa(binary)
178}
179
180// The picture as RGBA bytes, `SCALE` times larger with hard edges, so the
181// terminal's own scaling into the 7x3 box does not blur it.
182type Picture = { rgba: string; width: number; height: number }
183
184// The panel redraws ten times a second while agents work (the bar's sweep), and
185// each picture only changes with its look, so pictures are kept by look.
186const cache = new Map<string, Picture>()
187
188export const hiresPixels = (look: Look): Picture => {
189  const key = `${look.activity ?? 'idle'}|${look.paint}|${look.isFailed}|${Math.abs(Math.trunc(look.frame)) % 2}`
190  const kept = cache.get(key)
191  if (kept !== undefined) return kept
192  const picture = drawPicture(look)
193  if (cache.size > 200) cache.clear()
194  cache.set(key, picture)
195  return picture
196}
197
198const drawPicture = ({ activity, paint, isFailed, frame }: Look): Picture => {
199  const c = new Canvas()
200  const draw = activity === undefined ? idle : VEHICLES[activity]
201  draw(c, isFailed ? FAILED_PAINT : paint, Math.abs(Math.trunc(frame)) % 2)
202
203  const width = W * SCALE
204  const height = H * SCALE
205  const out = new Uint8Array(width * height * 4)
206  for (let y = 0; y < height; y++) {
207    for (let x = 0; x < width; x++) {
208      const p = c.px[Math.floor(y / SCALE) * W + Math.floor(x / SCALE)] ?? 0
209      if (p === 0) continue
210      const o = (y * width + x) * 4
211      out[o] = (p >> 16) & 0xff
212      out[o + 1] = (p >> 8) & 0xff
213      out[o + 2] = p & 0xff
214      out[o + 3] = 0xff
215    }
216  }
217  return { rgba: base64(out), width, height }
218}
219
hooks/sprite.ts 186 lines
1import type { SquadActivity } from '../types'
2
3// Tiny vehicles drawn as a Raster of quadrant blocks: each cell holds 2x2
4// pixels in two colors, so 7 columns by 3 rows carry a 14x6 picture. The
5// vehicle says what the agent is doing, its paint says the model, and a
6// running agent's vehicle moves (two frames).
7
8export const SPRITE_COLUMNS = 7
9export const SPRITE_ROWS = 3
10const WIDTH = SPRITE_COLUMNS * 2
11const HEIGHT = SPRITE_ROWS * 2
12
13const DEFAULT = 0x01000000 // the terminal's own color: a transparent pixel
14const SPACE = 0x20
15// The glyph for each set of lit quadrants: top-left 1, top-right 2,
16// bottom-left 4, bottom-right 8.
17const QUADRANTS = [
18  SPACE, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b,
19  0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588,
20]
21
22const CLAY = 0xd97757
23const FAILED_PAINT = 0x6b6b6b
24
25// Fixed colors; `B` is the model's paint and `D` a darker shade of it.
26const COLORS: Record<string, number> = {
27  W: 0xa9d6f5, // glass
28  w: 0xf4f3ee, // paper
29  K: 0x4a4a48, // tyres, keys (dark grey: black vanishes on a dark terminal)
30  G: 0x8e929a, // steel
31  Y: 0xf5c542, // flame core, stars
32  R: 0xff7b3a, // flame
33  d: 0x8a5a33, // dirt
34  o: 0xbfe3fb, // bubbles
35}
36
37type Frames = [string[], string[]]
38
39// Each picture is 6 rows of 14 pixels; a pixel is half a cell wide and half a
40// cell tall, so two across look square. Every vehicle faces right. A cell draws
41// two colors, so each 2x2 block (even column, even row) holds at most two.
42const IDLE: Frames = [
43  ['..............', '....BBBBBB....', '...BWWBBWWB...', '.BBBBBBBBBBBB.', '.BBBBBBBBBBBB.', '..KK......KK..'],
44  ['..............', '....BBBBBB....', '...BWWBBWWB...', '.BBBBBBBBBBBB.', '.BBBBBBBBBBBB.', '..KK......KK..'],
45]
46
47const VEHICLES: Record<SquadActivity, Frames> = {
48  // Submarine with a periscope; the propeller turns and bubbles rise.
49  search: [
50    ['.......GGG....', '.......G....o.', '...BBBBBBBBB..', 'G.BBWBBWBBWBB.', '.GBBBBBBBBBBB.', '...DDDDDDDDD..'],
51    ['.......GGG..o.', '.......G......', '...BBBBBBBBB..', '.GBBWBBWBBWBB.', 'G.BBBBBBBBBBB.', '...DDDDDDDDD..'],
52  ],
53  // Bulldozer; the treads roll and the blade pushes dirt.
54  edit: [
55    ['..BBBB........', '..BWWB........', '.BBBBBBBBBB.GG', '.BBBBBBBBBBGGG', 'KKKKKKKKKKK.GG', 'KGKGKGKGKGK.dd'],
56    ['..BBBB........', '..BWWB........', '.BBBBBBBBBB.GG', '.BBBBBBBBBBGGG', 'KKKKKKKKKKK.GG', 'GKGKGKGKGKG.d.'],
57  ],
58  // Race car with a spoiler; exhaust puffs out behind it.
59  run: [
60    ['..............', 'D.....WWW.....', 'DBBBBBBBBBB...', 'BBBBBBBBBBBBBB', '..BBBBBBBBBB..', 'GGKK......KK..'],
61    ['..............', 'D.....WWW.....', 'DBBBBBBBBBB...', 'BBBBBBBBBBBBBB', '..BBBBBBBBBB..', '.GKK......KK..'],
62  ],
63  // Rocket; the flame flickers and a star blinks.
64  web: [
65    ['..DD.........Y', '..BBBBBBBBB...', 'RYBBBBWWBBBBB.', 'YRBBBBWWBBBBB.', '..BBBBBBBBB...', '..DD..........'],
66    ['..DD..........', '..BBBBBBBBB...', 'YRBBBBWWBBBBB.', 'RYBBBBWWBBBBB.', '..BBBBBBBBB...', '..DD..........'],
67  ],
68  // Typewriter; the keys go down and a letter lands on the page.
69  answer: [
70    ['...wwwwww.....', '...wKwwww.....', '.DDDDDDDDDD...', '.BBBBBBBBBB.G.', '.BKBKBKBKBB...', '.BBBBBBBBBB...'],
71    ['...wwwwww.....', '...wKwKww.....', '.DDDDDDDDDD...', '.BBBBBBBBBB.G.', '.BBKBKBKBKB...', '.BBBBBBBBBB...'],
72  ],
73}
74
75export const ACTIVITY_LABEL: Record<SquadActivity, string> = {
76  search: 'searching',
77  edit: 'editing',
78  run: 'running commands',
79  web: 'on the web',
80  answer: 'writing up',
81}
82
83export const hex = (color: number): string => `#${color.toString(16).padStart(6, '0')}`
84
85const darker = (color: number): number => {
86  const r = Math.floor(((color >> 16) & 0xff) * 0.7)
87  const g = Math.floor(((color >> 8) & 0xff) * 0.7)
88  const b = Math.floor((color & 0xff) * 0.7)
89  return (r << 16) | (g << 8) | b
90}
91
92const distance = (a: number, b: number): number => {
93  if (a === DEFAULT || b === DEFAULT) return a === b ? 0 : Number.POSITIVE_INFINITY
94  const dr = ((a >> 16) & 0xff) - ((b >> 16) & 0xff)
95  const dg = ((a >> 8) & 0xff) - ((b >> 8) & 0xff)
96  const db = (a & 0xff) - (b & 0xff)
97  return dr * dr + dg * dg + db * db
98}
99
100// One cell from its four pixels (top-left, top-right, bottom-left, bottom-right):
101// a cell draws two colors, so a third is drawn as the nearer of the two kept.
102const cellOf = (pixels: readonly number[]): [number, number, number] => {
103  const counts = new Map<number, number>()
104  for (const p of pixels) counts.set(p, (counts.get(p) ?? 0) + 1)
105  const kept = [...counts.entries()].sort((x, y) => y[1] - x[1]).map(([color]) => color)
106  const first = kept[0] ?? DEFAULT
107  const second = kept[1] ?? DEFAULT
108  // The lit quadrants take the foreground; a transparent color is always the background.
109  const fg = first === DEFAULT ? second : first
110  const bg = first === DEFAULT ? DEFAULT : second
111  if (fg === DEFAULT) return [SPACE, DEFAULT, DEFAULT]
112
113  let mask = 0
114  pixels.forEach((p, i) => {
115    const isFg = p === fg || (p !== bg && distance(p, fg) <= distance(p, bg))
116    if (isFg) mask |= 1 << i
117  })
118  return [QUADRANTS[mask] ?? SPACE, fg, bg]
119}
120
121type Look = { activity?: SquadActivity; model: string; isFailed: boolean; frame: number }
122
123// The Raster's `cells`: base64 of little-endian u32 triplets per cell.
124export const spriteCells = ({ activity, model, isFailed, frame }: Look): string => {
125  const frames = activity === undefined ? IDLE : VEHICLES[activity]
126  const step = Math.abs(Math.trunc(frame)) % 2
127  const picture = frames[step] ?? frames[0]
128  const paint = isFailed ? FAILED_PAINT : modelColor(model)
129
130  const pixel = (x: number, y: number): number => {
131    const glyph = picture[y]?.[x]
132    if (glyph === undefined || glyph === '.') return DEFAULT
133    if (glyph === 'B') return paint
134    if (glyph === 'D') return darker(paint)
135    return COLORS[glyph] ?? DEFAULT
136  }
137
138  const view = new DataView(new ArrayBuffer(SPRITE_COLUMNS * SPRITE_ROWS * 12))
139  for (let row = 0; row < SPRITE_ROWS; row++) {
140    for (let col = 0; col < SPRITE_COLUMNS; col++) {
141      const x = col * 2
142      const y = row * 2
143      const cell = cellOf([pixel(x, y), pixel(x + 1, y), pixel(x, y + 1), pixel(x + 1, y + 1)])
144      const at = (row * SPRITE_COLUMNS + col) * 12
145      cell.forEach((word, k) => view.setUint32(at + k * 4, word, true))
146    }
147  }
148
149  let binary = ''
150  for (const byte of new Uint8Array(view.buffer)) binary += String.fromCharCode(byte)
151  return btoa(binary)
152}
153
154// The picture's pixels, row-major, for previews: 14x6 colors (DEFAULT = none).
155export const spritePixels = (look: Look): number[] => {
156  const cells = Uint8Array.from(atob(spriteCells(look)), c => c.charCodeAt(0))
157  const view = new DataView(cells.buffer)
158  const out = new Array<number>(WIDTH * HEIGHT).fill(DEFAULT)
159  for (let row = 0; row < SPRITE_ROWS; row++) {
160    for (let col = 0; col < SPRITE_COLUMNS; col++) {
161      const at = (row * SPRITE_COLUMNS + col) * 12
162      const glyph = view.getUint32(at, true)
163      const fg = view.getUint32(at + 4, true)
164      const bg = view.getUint32(at + 8, true)
165      const mask = Math.max(0, QUADRANTS.indexOf(glyph))
166      for (let i = 0; i < 4; i++) {
167        const x = col * 2 + (i % 2)
168        const y = row * 2 + Math.floor(i / 2)
169        out[y * WIDTH + x] = mask & (1 << i) ? fg : bg
170      }
171    }
172  }
173  return out
174}
175
176// The model's color, for its name in the row's text.
177const MODEL_COLORS: readonly (readonly [RegExp, number])[] = [
178  [/opus/i, CLAY],
179  [/sonnet/i, 0x6c9cf0],
180  [/haiku/i, 0x63c58a],
181  [/fable|mythos/i, 0xb48ead],
182]
183
184export const modelColor = (model: string): number =>
185  MODEL_COLORS.find(([pattern]) => pattern.test(model))?.[1] ?? 0xb0b0b0
186
types/index.d.ts 52 lines
1export type SquadUsage = {
2  input: number
3  output: number
4  cacheRead: number
5  cacheWrite: number
6}
7
8// What an agent is doing, read from the tools it calls.
9export type SquadActivity = 'search' | 'edit' | 'run' | 'web' | 'answer'
10
11export type SquadAgent = {
12  id: string
13  title: string
14  type: string
15  model: string
16  effort?: string
17  status: 'running' | 'done' | 'failed'
18  startedAt: number
19  endedAt?: number
20  usage: SquadUsage
21  // Input side of the latest request: what the agent's context holds now.
22  contextTokens: number
23  stepsDone?: number
24  stepsTotal?: number
25  note?: string
26  // What it is doing now; and, for a finished agent's costume, how often it did each.
27  activity?: SquadActivity
28  activityCounts?: Partial<Record<SquadActivity, number>>
29}
30
31declare module 'claude-code' {
32  // The mod's own tool, so `tool.call`'s matcher and input know it.
33  interface McpToolInputs {
34    mcp__squad__step: { done: number; total?: number; note?: string }
35  }
36
37  interface PluginState {
38    squad: {
39      agents: SquadAgent[]
40      tick: number
41      // A faster counter that drives the working bars' sweep.
42      sweep: number
43      // Agents Claude Code still lists, so their transcripts can be opened.
44      openable: string[]
45      contextWindow: number
46      isFinishedCollapsed: boolean
47      // The terminal draws real pixels (Ghostty, kitty): vehicles are images, not blocks.
48      hasPixels: boolean
49    }
50  }
51}
52