SLOPSHOPPER

agents-panel

Band row and Agents pane showing every subagent of the session: type, model, context fill, tokens, elapsed and status

newpanebandguardcommandprompt
v0.5.0no licenseupdated 2026-10-20jquezada19/claude-mods/agents-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agents-panel
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ 0 agents · 0 · 0:00 Collapse │ ┃ ⏺ Read(src/auth.ts) │ ┃ No subagents yet. ⎿ 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 │ │ › /agents-panel │ ⎿ agents-panel: Agents panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
0 agents · 0 · 0:00 Collapse No subagents yet.
README

agents-panel

A Claude Code mod that shows every subagent of the session, and every external-model or Codex run a Bash call starts: a row above the prompt while any is running, and an Agents pane with one card per agent.

In the terminal the same row is text:

● 3 running · 270k · 2:14 ×3

  • Band row: appears while a subagent, Bash-launched agent or Codex run is going and leaves when the last one finishes. On desktop each running agent walks in front as its own sprite (as many as fit, up to six, then +n); the terminal shows a magenta ● instead. Then the count, the tokens they have spent and how long the oldest has been going. The ×n button opens or closes the pane. The token figure is — when no running agent reports tokens (a lone Codex run or other Bash-launched agent), never 0.
  • Agents pane (/agents-panel toggles it, since /agents is the engine's built-in; it opens itself once per session on the first subagent, Bash-launched agent or Codex run unless autoOpen is off): on desktop, header tiles for agents, tokens and time (the terminal shows one dim line, 3 agents · 612k · 4:40, beside the Collapse button); Running · N above the live cards; one card per subagent with its description, type, model and effort, context fill, tokens and elapsed time, and a status mark. Collapse shrinks it to one sprite per agent on desktop (up to six, then +n, then the totals) or one coloured status mark per agent in the terminal; the ▾ Finished · N fold (▸ when shut) hides finished cards (50 drawn at most, then +n more). With none yet it says No subagents yet.

The pane, open and collapsed:

Every costume, by the agent type that wears it:

What a card says

PartMeaning
Clawd costumethe agent's type: Explore is a pirate, Plan an engineer, claude-code-guide a chef; every other subagent takes the costume its spawn number points at, counting every subagent of the session including those three (plain, pirate, detective, engineer, chef, then round again), so a session shows them all; any Codex agent or run (a codex: type or codex-rescue, /codex:review, codex exec) is not a Clawd but the blue Codex bot, whose screen cursor blinks while it runs; a Bash-launched Kimi run (buddy:kimi…) is a panda, a Gemini one a rainbow droplet whose colors sweep while it runs, a Grok one (or cursor-grok) a ball (black on light, white on dark), a Composer one a grey cube with a raised fist; each walks while its agent runs. Sprites are desktop only: a terminal card has a coloured status mark, the description, a dim type · model · effort line, then a bar and the stats
ctx 41% · 82kthe agent's context after its latest request: input plus cache read plus cache write. The percent is against the session model's window and shows only when the agent runs that model; another model's window is not guessed
196kevery request's four token counts, summed
0:19elapsed since the spawn, to the end for a finished agent; h:mm:ss past an hour
· waiting · cut off · 2 asked · 1 denied · awaiting approvalafter the stats when they apply: the engine's waiting/idle/pending state, an answer stopped at the output limit, permission checks asked or denied, and a call waiting on approval
context bardesktop: the costume's colour, green and full when done; terminal: 6–20 cells of █ on ░, magenta, green and full when done
● ✓ ✗ ○running (magenta), finished (green), failed (red: an error, an interruption, a refusal, or killed), lost (grey)
— · —a lost agent: it vanished from the engine's agent list without ever reporting a request (a remote run, or one that never started); nothing is known about its tokens

Agents started by Bash

The engine lists subagents only, so the mod also reads every Bash call's command for a launch. It recognises these launcher patterns (adapt hooks/launches.ts to your own scripts): buddy-run.sh <model> <dir> and relay.mjs [--model m] (external-model runs, typed buddy:<model>), codex-companion.mjs <verb> and codex exec (typed codex:<verb>). One row per launch the command names. A Codex invocation that only reads the CLI is no launch: --help, -h, --version, -V or a help argument before its command ends (outside quotes, so a prompt mentioning --help still counts), or echo, printf, which, type, man, command -v or grep at the head of its command. A foreground call's rows end with the call: failed on an error, done otherwise. A backgrounded call's rows (run_in_background, ctrl+b, or a timeout that backgrounds it) end on the first of:

  • the call's task notification (completed is done, anything else failed);
  • the launcher's done file landing (<dir>/meta-<model>.txt, whose EXIT: decides done or failed; relay's <out-dir>/result.json);
  • the main turn's end (classic.Stop), once the call's background task is no longer among the engine's background_tasks. This catches a notification that never reached the mod, for example one delivered into a running turn. The done file decides, else done;
  • lost a minute past the run's timeout. That is the shorter of the launcher's own timeout and a wrapper's (timeout [-k N] [-s SIG] DURATION, gtimeout, or perl -e 'alarm shift; exec @ARGV' S in the same command), or 60 minutes for a Codex run that names neither.

Until 2026-10-10 a Codex row had no deadline and closed only on its notification, so one whose notification arrived mid-turn ran forever. The done file's verdict beats the notification's, since a command ending in ; echo "REAL_EXIT=$?" always exits 0. A Codex run inside a subagent is that subagent's own work and gets no second row. These rows carry no token figures: their cards show — · —, and a header or band whose agents are all such rows shows — tokens.

Until 2026-10-12 session.start registered /agents before starting the one-second clock; the engine refuses that built-in name, the throw skipped the clock, and every running row's time sat at 0:00. The clock now starts first and the command is registered under /agents-panel, guarded. A session started before a change to this mod runs the old code until it restarts.

No dollars. Claude Code gives mods no per-model price, so the pane shows tokens and context, never a guessed cost. The session's own cost is on the usage-hp row.

Running means the engine says so. An agent the engine lists as pending, waiting or idle counts as running. A running agent that disappears from the list is marked failed (it had reported requests) or lost (it never did) after 60 seconds of absence; the check runs at the end of every main turn and at most every 10 seconds while an agent runs.

Settings

autoOpen (default on): open the pane once per session when the first subagent, Bash-launched agent or Codex run starts; off, /agents-panel and the band's ×n button open it. Set it in /config under agents-panel, or in settings.json:

{ "pluginConfigs": { "agents-panel": { "options": { "autoOpen": false } } } }

Install instructions are in the mods README one folder up.

Tests: claude plugin test . and python3 tests/mutate.py (mutation control: each rule broken in a scratch copy must make the suite fail; MOD_DIR points it at another copy of the mod).

0.2.0 (2026-10-16)

  • Resumed subagents run again. A SendMessage resume raises no agent.spawn, so a step from a turn other than the one a card finished in reopens it; a late step of the finished turn only adds figures. A spawn carrying resume keeps its card (tokens, costume) instead of starting one at zero. Elapsed carries on from the earlier run's active time, not the idle gap.
  • Parked agents say so. A running card the engine lists as waiting, idle or pending ends its stats with that word; the next step clears it.
  • Early steps are kept. A step, or a completion, that lands before the spawn hook has its agent id is held (20 ids at most). The card opens with them folded in, in one step of the row chain, so nothing in between counts them twice. A card remembers the turn of its last step, so a card finished by the engine's list (not by its own completion) still tells a late step from a resume.
  • Every way a card ends (completion, the list, a launcher verdict, going missing) drops a parked mark.
  • /clear, /resume, /branch drop finished cards (running ones stay) and let the pane open itself again for the next conversation's first subagent.

0.3.0 (2026-10-16)

  • Cards open to their details. A ▸ details button under a card opens what the subagent was asked (its task, 400 characters), its three newest tool uses, and the start of its answer (400 characters). Three cards at most stay open, the newest kept; /clear and /resume shut them.
  • Tool lines never show a command's arguments. A Bash use shows only the program it runs (Bash curl), a web fetch only host and path (WebFetch example.com/docs): arguments, headers and query strings can carry credentials, and the pane may be on a shared screen. Files, patterns and searches show as given.
  • A run cut off at the output limit (a step stopping at max_tokens) ends its card's stats with · cut off, without opening the card.
  • What arrives before a card (steps with their tools, a completion with its answer, clipped) is held and lands on the card when it opens. A resumed spawn shows its new task and drops the earlier run's tools and answer. Panel edits run one at a time, so two presses never drop one.

0.4.0 (2026-10-16)

  • Permission asks on the card. A subagent's permission checks are counted on its card: · 2 asked · 1 denied. An ask shows · awaiting approval until that call returns, the agent steps again, or the run ends. "Asked" counts the engine's ask decisions; the engine does not say whether a person, the auto-mode classifier or a host answered, and a person declining does not always produce a denial. The mod only observes: the engine's decision passes through unchanged. Checks that arrive before the card are held and land on it.

0.5.0 (2026-10-17)

  • Usage is counted per turn. A card remembers how many steps it saw in each of its eight newest turns. A turn none of whose steps was seen (a resumed run whose steps went unreported) adds its completion's usage to what earlier runs counted, once: a repeated completion or a late step of that turn adds nothing. Steps held before the card opens keep their turns too.
  • A late step is not a resume. A step on a finished card reopens it only when it belongs to a turn the card has not seen and began after the card ended. A step already in flight when the engine's list ended the run, or a delayed step of an older turn, only adds figures.
  • Reloading keeps the count. A card left by 0.4.0 carries its turn's step count and its fallback turn into the new per-turn counts the first time an event rewrites it, so a reload never counts a step twice.
Source 6 files
hooks/register.tsx 911 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { AgentRun, EarlyStep, Panel, Session } from '../types'
4import { RECONCILE_MS, applyComplete, applyStep, ctxPercent, applyAnswer, applyCallDone, applyCheck, applyStepDetail, finishRow, noteTurn, openRow, reconcileRows, reopenRow, sumUsage, taskOf, toolLine, totals } from './agents'
5import type { Usage } from './agents'
6import { BAND_H, CARD_H, STRIP_H, bandSvg, bandText, cardAlt, cardMeta, cardStats, cardSvg, headerSvg, headerText, stripSvg } from './draw'
7import { LAUNCH_GRACE_MS, doneStatus, parseLaunches, parseNotifications } from './launches'
8
9const AGENTS = { plugin: 'agents-panel', key: 'agents' } as const
10const PANEL = { plugin: 'agents-panel', key: 'panel' } as const
11const NOW = { plugin: 'agents-panel', key: 'now' } as const
12const SESSION = { plugin: 'agents-panel', key: 'session' } as const
13const LAST_RECONCILE = { plugin: 'agents-panel', key: 'lastReconcileAt' } as const
14const EARLY = { plugin: 'agents-panel', key: 'early' } as const
15/** Ids whose steps can wait for their spawn card; the oldest falls off past this. */
16const EARLY_CAP = 20
17export const PANE = 'agents'
18export const COMMAND = 'agents-panel'
19const BLANK_PANEL: Panel = { isCompact: false, isDoneCollapsed: false, hasAutoOpened: false }
20
21/** Every edit of the row list goes through one chain, so overlapping events apply one at a time. */
22let chain: Promise<void> = Promise.resolve()
23
24/** Read, apply, write with ifVersion; again on a miss, so two edits in flight both land. */
25const edit = async ($: EngineInterface, fn: (list: AgentRun[]) => AgentRun[]): Promise<void> => {
26  for (let tries = 0; tries < 8; tries += 1) {
27    const { value: list = [], version } = await $.state.get(AGENTS)
28    const next = fn(list)
29
30    if (next === list) {
31      return
32    }
33
34    const { isSet } = await $.state.set(AGENTS, next, { ifVersion: version })
35
36    if (isSet) {
37      return
38    }
39  }
40}
41
42export const queue = ($: EngineInterface, fn: (list: AgentRun[]) => AgentRun[]): Promise<void> => {
43  const run = chain.then(() => edit($, fn))
44  chain = run.catch(() => undefined)
45
46  return run
47}
48
49/**
50 * A subagent step, on the row chain: added to its row, or kept under its id when the row
51 * is not there yet (the step beat the spawn hook's answer) for the spawn to fold in.
52 */
53const stepOrKeep = (
54  $: EngineInterface,
55  id: string,
56  usage: Usage | null,
57  model: string,
58  effort: unknown,
59  at: number,
60  began: number,
61  turnId: string,
62  uses: readonly { name: string; input: unknown }[],
63  stop: string | null,
64): Promise<void> => {
65  const run = chain.then(async () => {
66    const { value: list = [] } = await $.state.get(AGENTS)
67
68    // One edit for the step's detail and its figures, so nothing (a card replaced by a
69    // spawn) can land between them.
70    if (list.some(a => a.id === id)) {
71      await edit($, l => {
72        const noted = applyStepDetail(l, id, uses, stop)
73
74        return usage === null ? noted : applyStep(noted, id, usage, model, effort, at, turnId, began)
75      })
76
77      return
78    }
79
80    const sum = usage === null ? null : sumUsage(usage)
81    const tools = uses.map(toolLine)
82
83    if (sum === null && tools.length === 0 && stop !== 'max_tokens') {
84      return
85    }
86
87    const { value: early = {} } = await $.state.get(EARLY)
88    const prev = early[id]
89    const others = Object.keys(early).filter(k => k !== id).slice(-(EARLY_CAP - 1))
90    const kept: Record<string, EarlyStep> = Object.fromEntries(others.map(k => [k, early[k]!]))
91    kept[id] = {
92      ...(prev?.ended === undefined ? {} : { ended: prev.ended }),
93      tokens: (prev?.tokens ?? 0) + (sum?.tokens ?? 0),
94      context: sum?.context ?? prev?.context ?? 0,
95      steps: (prev?.steps ?? 0) + (sum === null ? 0 : 1),
96      model: usage?.model || model || prev?.model || '',
97      turnId,
98      ...(sum === null && prev?.turns === undefined
99        ? {}
100        : { turns: sum === null ? prev!.turns! : noteTurn(prev?.turns, turnId, (prev?.turns?.[turnId] ?? 0) + 1) }),
101      ...(tools.length === 0 && prev?.tools === undefined ? {} : { tools: [...(prev?.tools ?? []), ...tools].slice(-3) }),
102      ...(stop === 'max_tokens' || prev?.isCutOff === true ? { isCutOff: true as const } : {}),
103    }
104    await $.state.set(EARLY, kept)
105  })
106  chain = run.catch(() => undefined)
107
108  return run
109}
110
111/**
112 * Opens a spawn's card in one step of the row chain, with whatever arrived for its id
113 * before it (steps, a completion) folded in and forgotten, so nothing in between can
114 * count them twice or overwrite newer figures with older ones.
115 */
116const openSpawn = ($: EngineInterface, run: AgentRun): Promise<void> => {
117  const go = chain.then(async () => {
118    const { value: early = {} } = await $.state.get(EARLY)
119    const kept = early[run.id]
120
121    await edit($, list => {
122      const card: AgentRun = {
123        ...run,
124        variant: list.filter(a => a.variant !== undefined).length,
125        ...(kept === undefined
126          ? {}
127          : {
128              tokens: kept.tokens,
129              contextTokens: kept.context,
130              steps: kept.steps,
131              model: kept.model || run.model,
132              ...(kept.turnId === undefined ? {} : { turnId: kept.turnId }),
133              ...(kept.turns === undefined ? {} : { turns: kept.turns }),
134              detail: {
135                ...run.detail,
136                ...(kept.tools === undefined ? {} : { tools: kept.tools }),
137                ...(kept.isCutOff === true ? { isCutOff: true as const } : {}),
138              },
139              ...(kept.checks === undefined ? {} : { checks: kept.checks }),
140              ...(kept.pending === undefined || kept.pending.length === 0 ? {} : { pending: kept.pending }),
141            }),
142      }
143      const opened = openRow(list, card)
144      const end = kept?.ended
145
146      return end === undefined
147        ? opened
148        : applyAnswer(applyComplete(opened, run.id, end.reason, end.usage, end.at, end.turnId), run.id, end.answer ?? '')
149    })
150
151    if (kept !== undefined) {
152      const { [run.id]: _folded, ...rest } = early
153      await $.state.set(EARLY, rest)
154    }
155  })
156  chain = go.catch(() => undefined)
157
158  return go
159}
160
161/** A permission check on the row chain: applied to its card, or held until the card opens. */
162const checkOrKeep = ($: EngineInterface, id: string, decision: 'ask' | 'deny', call: string): Promise<void> => {
163  const go = chain.then(async () => {
164    const { value: list = [] } = await $.state.get(AGENTS)
165
166    if (list.some(a => a.id === id)) {
167      await edit($, l => applyCheck(l, id, decision, call))
168
169      return
170    }
171
172    const { value: early = {} } = await $.state.get(EARLY)
173    const prev = early[id] ?? { tokens: 0, context: 0, steps: 0, model: '' }
174    const others = Object.keys(early).filter(k => k !== id).slice(-(EARLY_CAP - 1))
175    const kept: Record<string, EarlyStep> = Object.fromEntries(others.map(k => [k, early[k]!]))
176    const was = prev.checks ?? { asked: 0, denied: 0 }
177    kept[id] = {
178      ...prev,
179      checks: decision === 'ask' ? { ...was, asked: was.asked + 1 } : { ...was, denied: was.denied + 1 },
180      ...(decision === 'ask' ? { pending: [...(prev.pending ?? []), call].slice(-20) } : {}),
181    }
182    await $.state.set(EARLY, kept)
183  })
184  chain = go.catch(() => undefined)
185
186  return go
187}
188
189/**
190 * A subagent's completion, on the row chain: applied to its row, or kept with its early
191 * steps when the row is not there yet, for the spawn to apply as it opens the card.
192 */
193const completeOrKeep = (
194  $: EngineInterface,
195  id: string,
196  reason: string,
197  usage: Usage | null,
198  at: number,
199  turnId: string,
200  answer: string,
201): Promise<void> => {
202  const go = chain.then(async () => {
203    const { value: list = [] } = await $.state.get(AGENTS)
204
205    if (list.some(a => a.id === id)) {
206      await edit($, l => applyAnswer(applyComplete(l, id, reason, usage, at, turnId), id, answer))
207
208      return
209    }
210
211    const { value: early = {} } = await $.state.get(EARLY)
212    const prev = early[id] ?? { tokens: 0, context: 0, steps: 0, model: '' }
213    const others = Object.keys(early).filter(k => k !== id).slice(-(EARLY_CAP - 1))
214    const kept: Record<string, EarlyStep> = Object.fromEntries(others.map(k => [k, early[k]!]))
215    kept[id] = { ...prev, ended: { reason, at, turnId, usage, answer: taskOf(answer) } }
216    await $.state.set(EARLY, kept)
217  })
218  chain = go.catch(() => undefined)
219
220  return go
221}
222
223/** Panel edits (presses, toggles, resets) apply one at a time, so two presses never drop one. */
224let panelChain: Promise<void> = Promise.resolve()
225
226const editPanel = ($: EngineInterface, fn: (p: Panel) => Panel): Promise<void> => {
227  const run = panelChain.then(async () => {
228    const { value: p = BLANK_PANEL } = await $.state.get(PANEL)
229    await $.state.set(PANEL, fn(p))
230  })
231  panelChain = run.catch(() => undefined)
232
233  return run
234}
235
236/** Cards open at once: their details stay well under the drawing's size limit. */
237const OPEN_CAP = 3
238
239/** The launcher's verdict from its done file, or null while the file has not landed. */
240const landed = async ($: EngineInterface, doneFile: string | null): Promise<'done' | 'failed' | null> => {
241  try {
242    if (doneFile === null || !(await $.fs.exists(doneFile))) {
243      return null
244    }
245  } catch {
246    return null
247  }
248
249  try {
250    return doneStatus(await $.fs.read(doneFile))
251  } catch {
252    return 'done'
253  }
254}
255
256/** Every running external row: over when its done file has landed, lost past its deadline. */
257const settleExternal = async ($: EngineInterface, at: number): Promise<void> => {
258  const { value: list = [] } = await $.state.get(AGENTS)
259
260  for (const a of list) {
261    const x = a.external
262
263    if (a.status !== 'running' || x === undefined) {
264      continue
265    }
266
267    const status = await landed($, x.doneFile)
268
269    if (status !== null) {
270      await queue($, l => finishRow(l, a.id, status, at))
271    } else if (x.deadline !== null && at > x.deadline) {
272      await queue($, l => finishRow(l, a.id, 'lost', at))
273    }
274  }
275}
276
277/** $.agent.list() against every running row. Forced by the main turn's end; the tick is throttled. */
278export const reconcile = async ($: EngineInterface, force: boolean): Promise<void> => {
279  const at = await $.clock.now()
280  const { value: last = 0, version } = await $.state.get(LAST_RECONCILE)
281
282  if (!force && at - last < RECONCILE_MS) {
283    return
284  }
285
286  // Claim the slot before the list is awaited (compare-and-set, so an overlapping caller misses);
287  // a rejected list hands the old stamp back, so the next tick retries.
288  const { isSet } = await $.state.set(LAST_RECONCILE, at, { ifVersion: version })
289
290  if (!isSet && !force) {
291    return
292  }
293
294  await settleExternal($, at)
295
296  let infos: readonly { id: string; status: string }[]
297
298  try {
299    infos = await $.agent.list()
300  } catch {
301    await $.state.set(LAST_RECONCILE, last)
302
303    return
304  }
305
306  await queue($, list => reconcileRows(list, infos, at))
307}
308
309/** Opens the pane, or closes it when it is up; true when it ends up open. */
310export const togglePane = async ($: EngineInterface): Promise<boolean> => {
311  const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
312
313  if (isOpen) {
314    await $.ui.close({ id: PANE })
315
316    return false
317  }
318
319  await $.ui.open({ id: PANE, title: 'Agents' })
320
321  return true
322}
323
324const autoOpenOnce = async ($: EngineInterface): Promise<void> => {
325  const { value: p = BLANK_PANEL, version } = await $.state.get(PANEL)
326
327  if (p.hasAutoOpened) {
328    return
329  }
330
331  // Compare-and-set: whoever lands the flag opens the pane; a concurrent caller misses and returns.
332  const { isSet } = await $.state.set(PANEL, { ...p, hasAutoOpened: true }, { ifVersion: version })
333
334  if (!isSet) {
335    return
336  }
337
338  await $.ui.open({ id: PANE, title: 'Agents' })
339}
340
341/** Once per session, on the first subagent: serialized on the chain so two spawns in one tick open it once. */
342export const autoOpen = ($: EngineInterface): Promise<void> => {
343  const run = chain.then(() => autoOpenOnce($))
344  chain = run.catch(() => undefined)
345
346  return run
347}
348
349const tick = async ($: EngineInterface): Promise<void> => {
350  const { value: list = [] } = await $.state.get(AGENTS)
351
352  if (!list.some(a => a.status === 'running')) {
353    return
354  }
355
356  await $.state.set(NOW, await $.clock.now())
357  await reconcile($, false)
358}
359
360export const register: Register = (on, options) => {
361  on('session.start', async ($, e, next) => {
362    const started = await next(e)
363    // The clock first: a throw below must never stop elapsed time from moving (2026-10-12,
364    // when the refused `/agents` name threw here and every running row sat at 0:00).
365    $.clock.every(1000, () => {
366      void tick($).catch(() => undefined)
367    })
368
369    try {
370      // `/agents` is the engine's built-in and is refused, so the toggle has a name of its own.
371      await $.command.register({
372        name: COMMAND,
373        description: "Show or hide the panel of this session's subagents: running, finished, model, context, tokens and time",
374      })
375    } catch {
376      // No slash command; the band's ×n button still toggles the pane.
377    }
378
379    return started
380  })
381
382  // /clear, /resume and /branch end this conversation and start another with no session.start:
383  // finished cards belong to the old one; a run still going keeps its card. The pane may open
384  // itself again for the new conversation's first subagent.
385  on('session.end', async ($, e, next) => {
386    if (e.reason === 'clear' || e.reason === 'resume') {
387      await queue($, list => (list.every(a => a.status === 'running') ? list : list.filter(a => a.status === 'running')))
388      await editPanel($, p => ({ ...p, hasAutoOpened: false, open: [] }))
389      await $.state.set(EARLY, {})
390    }
391
392    return next(e)
393  }).catch(($, e, next) => next(e))
394
395  on('agent.spawn', async ($, e, next) => {
396    const started = await next(e)
397
398    if (started.deny !== undefined || started.agentId === undefined) {
399      return started
400    }
401
402    const at = await $.clock.now()
403    const resumedId = e.resume?.agentId
404
405    // A resumed subagent or a teammate brought back: the row it already has runs again,
406    // its figures and costume kept, rather than a fresh card at zero.
407    if (resumedId !== undefined) {
408      const { value: list = [] } = await $.state.get(AGENTS)
409
410      if (list.some(a => a.id === resumedId)) {
411        // A new run of the same agent: its new task; the earlier run's tools and answer go.
412        const task = e.prompt.trim() === '' ? {} : { detail: { task: taskOf(e.prompt) } }
413        await queue($, l =>
414          l.map(a => {
415            if (a.id !== resumedId) {
416              return a
417            }
418
419            const { detail: _earlier, pending: _asked, ...rest } = reopenRow(a, at)
420
421            return { ...rest, ...task }
422          }),
423        )
424
425        return started
426      }
427    }
428
429    const run: AgentRun = {
430      id: started.agentId,
431      type: e.subagentType,
432      description: e.description,
433      model: started.model,
434      status: 'running',
435      startedAt: at,
436      contextTokens: 0,
437      tokens: 0,
438      steps: 0,
439      ...(e.prompt.trim() === '' ? {} : { detail: { task: taskOf(e.prompt) } }),
440    }
441    // Numbered in spawn order, so agents of the same type take turns through the Clawd costumes.
442    await openSpawn($, run)
443
444    if (options.autoOpen !== false) {
445      await autoOpen($)
446    }
447
448    return started
449  }).catch(($, e, next) => next(e))
450
451  // Permission checks on a subagent's calls reach its card: asks and denials counted,
452  // an ask shown until the agent steps again. Observes only: the decision it returns is
453  // the one beneath it, and a failure here replays the call.
454  on('tool.check', async ($, e, next) => {
455    const decided = await next(e)
456    const id = e.agentId
457
458    const call = e.tool_use_id
459
460    if (id !== undefined && call !== undefined && (decided.decision === 'ask' || decided.decision === 'deny')) {
461      try {
462        await checkOrKeep($, id, decided.decision, call)
463      } catch {
464        // the card misses one count; the call is unaffected
465      }
466    }
467
468    return decided
469  }).catch(($, e, next) => next(e))
470
471  // A subagent's call returned: an ask on it is answered. Observes only.
472  on('tool.call', async ($, e, next) => {
473    const ran = await next(e)
474    const id = e.agentId
475    const call = e.tool_use_id
476
477    if (id !== undefined && call !== undefined) {
478      try {
479        await queue($, list => applyCallDone(list, id, call))
480      } catch {
481        // a pending mark stays until the next step clears it
482      }
483    }
484
485    return ran
486  }).catch(($, e, next) => next(e))
487
488  // A buddy seat or a Codex run started by Bash: a row per launch the command names. Errors
489  // here never re-run the call: the rows open before it, and close after it, in guards of their own.
490  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
491    let ids: string[] = []
492    let timeouts: number[] = []
493
494    try {
495      const env = { HOME: await $.env.get('HOME'), TMPDIR: await $.env.get('TMPDIR') }
496      // In a subagent, a Codex run is that subagent's own work (codex-rescue), which has its row.
497      const launches = parseLaunches(e.command, env).filter(l => e.agentId === undefined || !l.type.startsWith('codex:'))
498      const at = await $.clock.now()
499      const base = e.tool_use_id ?? `bash-${at}`
500      const rows: AgentRun[] = launches.map((launch, i) => ({
501        id: `${base}:${i}`,
502        type: launch.type,
503        description: e.description || launch.type,
504        model: launch.model,
505        status: 'running',
506        startedAt: at,
507        contextTokens: 0,
508        tokens: 0,
509        steps: 0,
510        external: { doneFile: launch.doneFile, deadline: null },
511      }))
512
513      if (rows.length > 0) {
514        await queue($, list => rows.reduce(openRow, list))
515        ids = rows.map(r => r.id)
516        timeouts = launches.map(l => l.timeoutMs)
517
518        if (options.autoOpen !== false) {
519          await autoOpen($)
520        }
521      }
522    } catch {
523      ids = []
524    }
525
526    if (ids.length === 0) {
527      return next(e)
528    }
529
530    const close = async (status: 'done' | 'failed'): Promise<void> => {
531      try {
532        const at = await $.clock.now()
533        await queue($, list => ids.reduce((l, id) => finishRow(l, id, status, at), list))
534      } catch {
535        // The row stays running; the deadline, or the next session, ends it.
536      }
537    }
538    let ran: Awaited<ReturnType<typeof next>>
539
540    try {
541      ran = await next(e)
542    } catch (err) {
543      await close('failed')
544      throw err
545    }
546
547    // Backgrounded by the call, by ctrl+b, or by its timeout: its task notification ends it,
548    // the turn's end ends it once its task is no longer in flight, and its timeout (the
549    // launcher's, a wrapper's, or Codex's default) plus a grace bounds it whatever happens.
550    const taskId = (ran.result as { backgroundTaskId?: string } | undefined)?.backgroundTaskId ?? ''
551    const isBackground = e.run_in_background === true || taskId !== ''
552
553    // A call asked to background that errored without a task id never started: a
554    // PreToolUse hook's deny (background-timeout-gate) comes back as an error result,
555    // and no notification will ever end the row (ghost "Running" rows, 2026-10-12).
556    if (ran.deny !== undefined || (ran.isError === true && taskId === '')) {
557      await close('failed')
558    } else if (!isBackground) {
559      // An error never reaches here: without a task id the branch above failed it.
560      await close('done')
561    } else {
562      try {
563        const at = await $.clock.now()
564        await queue($, list =>
565          ids.reduce((l, id, i) => {
566            const j = l.findIndex(a => a.id === id && a.status === 'running')
567            const a = l[j]
568            const ms = timeouts[i] ?? 0
569
570            if (a === undefined || a.external === undefined || (ms <= 0 && taskId === '')) {
571              return l
572            }
573
574            const external = {
575              ...a.external,
576              ...(ms > 0 ? { deadline: at + ms + LAUNCH_GRACE_MS } : {}),
577              ...(taskId !== '' ? { taskId } : {}),
578            }
579
580            return l.map((b, k) => (k === j ? { ...a, external } : b))
581          }, list),
582        )
583      } catch {
584        // No deadline or task id: the notification still ends it.
585      }
586    }
587
588    return ran
589  })
590
591  // A background task's notification ends the rows its call opened; a launcher's done file,
592  // when it has landed, says done or failed better than the shell's own exit does.
593  on('prompt.submit', async ($, e, next) => {
594    try {
595      const ends = e.text.includes('<task-notification>') ? parseNotifications(e.text) : []
596
597      if (ends.length > 0) {
598        const at = await $.clock.now()
599        const { value: list = [] } = await $.state.get(AGENTS)
600
601        for (const end of ends) {
602          for (const a of list) {
603            if (a.external === undefined || a.status !== 'running' || !a.id.startsWith(`${end.toolUseId}:`)) {
604              continue
605            }
606
607            const status = (await landed($, a.external.doneFile)) ?? (end.status === 'completed' ? 'done' : 'failed')
608            await queue($, l => finishRow(l, a.id, status, at))
609          }
610        }
611      }
612    } catch {
613      // A row left running here ends at its deadline or with the session.
614    }
615
616    return next(e)
617  }).catch(($, e, next) => next(e))
618
619  // The main turn's end lists the background work still in flight. A backgrounded row whose
620  // task is not among it has ended, even when its notification never reached prompt.submit
621  // (one delivered into a running turn): its done file decides, else done. Never blocks.
622  on('classic.Stop', async ($, e, next) => {
623    try {
624      const live = e.background_tasks
625
626      if (Array.isArray(live)) {
627        const inFlight = new Set(live.map(t => t.id))
628        const at = await $.clock.now()
629        const { value: list = [] } = await $.state.get(AGENTS)
630
631        for (const a of list) {
632          const x = a.external
633
634          if (x === undefined || a.status !== 'running' || x.taskId === undefined || inFlight.has(x.taskId)) {
635            continue
636          }
637
638          const status = (await landed($, x.doneFile)) ?? 'done'
639          await queue($, l => finishRow(l, a.id, status, at))
640        }
641      }
642    } catch {
643      // A row left running here ends with its notification or at its deadline.
644    }
645
646    return next(e)
647  }).catch(($, e, next) => next(e))
648
649  on('turn.step', async function* ($, e, next) {
650    // When the request began: a step on a finished row that began before it ended is late, not a resume.
651    const began = e.agentId === undefined ? 0 : await $.clock.now()
652    const result = yield* next(e)
653
654    const usage = result.usage ?? null
655
656    // A subagent's step: what it asked for, whether it hit the output limit, and its
657    // figures, kept whether or not it reported usage.
658    if (e.agentId !== undefined) {
659      const at = await $.clock.now()
660      await stepOrKeep($, e.agentId, usage, e.model, e.effort, at, began, e.turnId, result.toolUses ?? [], result.stopReason ?? null)
661
662      return result
663    }
664
665    if (usage === null) {
666      return result
667    }
668
669    // The main loop: its model and context window, for the cards' context percent.
670    let window: number | null = null
671
672    try {
673      window = (await $.session.usage()).context.window
674    } catch {
675      window = null
676    }
677
678    const session: Session = { model: usage.model || e.model, window }
679    await $.state.set(SESSION, session)
680
681    return result
682  })
683
684  on('turn.complete', async ($, e, next) => {
685    if (e.agentId !== undefined) {
686      const id = e.agentId
687      const at = await $.clock.now()
688      const usage = e.usage ?? null
689      await completeOrKeep($, id, e.reason, usage, at, e.turnId, e.answer)
690    } else {
691      await reconcile($, true)
692    }
693
694    return next(e)
695  })
696
697  on('command.run', { command: COMMAND }, async $ => {
698    const isOpen = await togglePane($)
699
700    return { text: isOpen ? 'Agents panel opened.' : 'Agents panel closed.' }
701  })
702
703  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
704    const { Box, Text, Button } = $.ui.resolve(e)
705    const { value: list = [] } = await $.state.get(AGENTS)
706    const { value: p = BLANK_PANEL } = await $.state.get(PANEL)
707    const { value: session = null } = await $.state.get(SESSION)
708    const { value: tickNow = 0 } = await $.state.get(NOW)
709    const at = Math.max(tickNow, ...list.map(a => a.startedAt), 0)
710    const running = list.filter(a => a.status === 'running').reverse()
711    const finished = list.filter(a => a.status !== 'running').reverse()
712    const t = totals(list, at)
713    const MAX_CARDS = 50
714    const shownFinished = finished.slice(0, MAX_CARDS)
715    const moreFinished = finished.length - shownFinished.length
716    const toggleCompact = (
717      <Button
718        key="compact"
719        label={p.isCompact ? 'Expand' : 'Collapse'}
720        plain
721        onPress={() => void editPanel($, prev => ({ ...prev, isCompact: !prev.isCompact }))}
722      />
723    )
724    const toggleDone = (
725      <Button
726        key="done"
727        label={`${p.isDoneCollapsed ? '▸' : '▾'} Finished · ${finished.length}`}
728        plain
729        onPress={() => void editPanel($, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
730      />
731    )
732
733    // A card opens to its task, newest tool uses, a cut-off, and the start of its answer.
734    const opened = new Set(p.open ?? [])
735    const hasDetail = (a: AgentRun): boolean =>
736      a.detail !== undefined &&
737      (a.detail.task !== undefined || (a.detail.tools ?? []).length > 0 || a.detail.answer !== undefined || a.detail.isCutOff === true)
738    const detailToggle = (a: AgentRun) =>
739      !hasDetail(a) ? null : (
740        <Button
741          key={`detail:${a.id}`}
742          label={opened.has(a.id) ? '▾ details' : '▸ details'}
743          plain
744          onPress={() =>
745            void editPanel($, prev => {
746              // Only cards still listed stay open; the newest OPEN_CAP win.
747              const live = (prev.open ?? []).filter(id => id !== a.id && list.some(r => r.id === id))
748              const next = (prev.open ?? []).includes(a.id) ? live : [...live, a.id].slice(-OPEN_CAP)
749
750              return { ...prev, open: next }
751            })
752          }
753        />
754      )
755    const detailBlock = (a: AgentRun) =>
756      !opened.has(a.id) || a.detail === undefined ? null : (
757        <Box key={`detail-body:${a.id}`} flexDirection="column" paddingLeft={2}>
758          {a.detail.task !== undefined && <Text dimColor wrap="wrap">{`Task: ${a.detail.task}`}</Text>}
759          {(a.detail.tools ?? []).map((line, i) => (
760            <Text key={`tool:${a.id}:${i}`} dimColor wrap="truncate-end">{`· ${line}`}</Text>
761          ))}
762          {a.detail.isCutOff === true && <Text color="yellow">Stopped at the output limit</Text>}
763          {a.detail.answer !== undefined && <Text dimColor wrap="wrap">{`Answer: ${a.detail.answer}`}</Text>}
764        </Box>
765      )
766
767    if (e.surface === 'desktop') {
768      const { Svg } = $.ui.resolve({ ...e, surface: 'desktop' })
769      const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
770
771      if (p.isCompact) {
772        return (
773          <Box flexDirection="column" gap={1}>
774            <Svg source={stripSvg(W, list, t)} alt={headerText(t)} width={W} height={STRIP_H} />
775            {toggleCompact}
776          </Box>
777        )
778      }
779
780      return (
781        <Box flexDirection="column">
782          <Svg source={headerSvg(W, t)} alt={headerText(t)} width={W} height={44} />
783          {toggleCompact}
784          {list.length === 0 && <Text dimColor>No subagents yet.</Text>}
785          {running.length > 0 && <Text dimColor>{`Running · ${running.length}`}</Text>}
786          {running.map(a => (
787            <Box key={`card:${a.id}`} flexDirection="column">
788              <Svg source={cardSvg(W, a, at, session)} alt={cardAlt(a, at, session)} width={W} height={CARD_H} />
789              {detailToggle(a)}
790              {detailBlock(a)}
791            </Box>
792          ))}
793          {finished.length > 0 && toggleDone}
794          {!p.isDoneCollapsed &&
795            shownFinished.map(a => (
796              <Box key={`card:${a.id}`} flexDirection="column">
797                <Svg source={cardSvg(W, a, at, session)} alt={cardAlt(a, at, session)} width={W} height={CARD_H} />
798                {detailToggle(a)}
799                {detailBlock(a)}
800              </Box>
801            ))}
802          {!p.isDoneCollapsed && moreFinished > 0 && <Text dimColor>{`+${moreFinished} more`}</Text>}
803        </Box>
804      )
805    }
806
807    const cols = Math.max(24, e.props.bodyColumns || 40)
808    const barW = Math.max(6, Math.min(20, cols - 34))
809    const GLYPH: Record<AgentRun['status'], string> = { running: '●', done: '✓', failed: '✗', lost: '○' }
810    const colorOf = (a: AgentRun): 'red' | 'green' | 'gray' | 'magenta' =>
811      a.status === 'failed' ? 'red' : a.status === 'done' ? 'green' : a.status === 'lost' ? 'gray' : 'magenta'
812    const row = (a: AgentRun) => {
813      const pct = a.status === 'lost' ? null : ctxPercent(a, session)
814      const fillPct = a.status === 'done' ? 100 : (pct ?? 0)
815      const filled = Math.round((barW * fillPct) / 100)
816
817      return (
818        <Box key={`card:${a.id}`} flexDirection="column" marginBottom={1}>
819          <Box flexDirection="row" gap={1}>
820            <Text color={colorOf(a)}>{GLYPH[a.status]}</Text>
821            <Text bold={a.status !== 'lost'} dimColor={a.status === 'lost'} wrap="truncate-end">
822              {a.description || a.type}
823            </Text>
824          </Box>
825          <Text dimColor wrap="truncate-end">{`  ${cardMeta(a)}`}</Text>
826          <Text wrap="truncate-end">
827            {'  '}
828            <Text color={a.status === 'done' ? 'green' : 'magenta'}>{'█'.repeat(filled)}</Text>
829            <Text dimColor>{'░'.repeat(Math.max(0, barW - filled))}</Text>
830            <Text dimColor>{` ${cardStats(a, at, session)}`}</Text>
831          </Text>
832          {detailToggle(a)}
833          {detailBlock(a)}
834        </Box>
835      )
836    }
837
838    return (
839      <Box flexDirection="column">
840        <Box flexDirection="row" justifyContent="space-between">
841          <Text dimColor>{headerText(t)}</Text>
842          {toggleCompact}
843        </Box>
844        {p.isCompact ? (
845          <Text wrap="truncate-end">
846            {[...running, ...finished].map(a => (
847              <Text key={a.id} color={colorOf(a)}>
848                {GLYPH[a.status]}{' '}
849              </Text>
850            ))}
851          </Text>
852        ) : (
853          <Box flexDirection="column" marginTop={1}>
854            {list.length === 0 && <Text dimColor>No subagents yet.</Text>}
855            {running.length > 0 && <Text dimColor>{`Running · ${running.length}`}</Text>}
856            {running.map(row)}
857            {finished.length > 0 && toggleDone}
858            {!p.isDoneCollapsed && shownFinished.map(row)}
859            {!p.isDoneCollapsed && moreFinished > 0 && <Text dimColor>{`+${moreFinished} more`}</Text>}
860          </Box>
861        )}
862      </Box>
863    )
864  })
865
866  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
867    // Whatever another mod draws in the band stays; this row goes after it.
868    const beneath = await next(e)
869    const { value: list = [] } = await $.state.get(AGENTS)
870    const running = list.filter(a => a.status === 'running')
871
872    if (e.props.hasSurvey || running.length === 0) {
873      return beneath
874    }
875
876    const { value: tickNow = 0 } = await $.state.get(NOW)
877    const at = Math.max(tickNow, ...running.map(a => a.startedAt))
878    const text = bandText(running, at)
879    const { Box, Text, Button } = $.ui.resolve(e)
880    const button = <Button key="agents-open" label={`×${running.length}`} plain onPress={() => void togglePane($)} />
881
882    if (e.surface === 'desktop') {
883      const { Svg } = $.ui.resolve({ ...e, surface: 'desktop' })
884      const W = Math.max(180, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 96))
885
886      return (
887        <Box flexDirection="column">
888          {beneath}
889          <Box flexDirection="row" alignItems="center" gap={1}>
890            <Svg source={bandSvg(W, running, at)} alt={text} width={W} height={BAND_H} />
891            {button}
892          </Box>
893        </Box>
894      )
895    }
896
897    return (
898      <Box flexDirection="column">
899        {beneath}
900        <Box>
901          <Text color="magenta" bold>
902            ●{' '}
903          </Text>
904          <Text>{text} </Text>
905          {button}
906        </Box>
907      </Box>
908    )
909  })
910}
911
hooks/agents.ts 523 lines
1import type { AgentRun, Session } from '../types'
2import type { Costume } from './sprite'
3
4export const ROW_CAP = 200
5/** A running row absent from $.agent.list() for this long is over. */
6export const MISSING_MS = 60_000
7/** The tick's reconcile runs at most this often; the main turn's end forces one. */
8export const RECONCILE_MS = 10_000
9
10export type Usage = {
11  input_tokens?: number
12  output_tokens?: number
13  cache_read_input_tokens?: number
14  cache_creation_input_tokens?: number
15  model?: string
16}
17
18const count = (v: unknown): number => (typeof v === 'number' ? v : 0)
19
20/** The four counts summed, and the input side alone; null when either is not finite. */
21export const sumUsage = (u: Usage): { tokens: number; context: number } | null => {
22  const i = count(u.input_tokens)
23  const o = count(u.output_tokens)
24  const r = count(u.cache_read_input_tokens)
25  const w = count(u.cache_creation_input_tokens)
26  const tokens = i + o + r + w
27  const context = i + r + w
28
29  return Number.isFinite(tokens) && Number.isFinite(context) ? { tokens, context } : null
30}
31
32/** Appends a run, replacing one of the same id; keeps the newest ROW_CAP, dropping finished rows first. */
33export const openRow = (list: AgentRun[], run: AgentRun): AgentRun[] => {
34  const out = [...list.filter(a => a.id !== run.id), run]
35
36  while (out.length > ROW_CAP) {
37    const i = out.findIndex(a => a.status !== 'running')
38
39    if (i < 0) {
40      break
41    }
42
43    out.splice(i, 1)
44  }
45
46  return out
47}
48
49const replaceAt = (list: AgentRun[], i: number, next: AgentRun): AgentRun[] => list.map((a, j) => (j === i ? next : a))
50
51const withoutMissing = (a: AgentRun): AgentRun => {
52  const { missingSince: _dropped, ...rest } = a
53
54  return rest
55}
56
57const withoutEngineStatus = (a: AgentRun): AgentRun => {
58  const { engineStatus: _dropped, ...rest } = a
59
60  return rest
61}
62
63/** A run that is working again or over: neither parked by the engine nor waiting on an approval. */
64const settled = (a: AgentRun): AgentRun => {
65  const { engineStatus: _parked, pending: _pending, ...rest } = a
66
67  return rest
68}
69
70/** The engine's own states for a run that is neither working nor over: shown, never counted as finished. */
71const PARKED = new Set(['pending', 'waiting', 'idle'])
72
73/**
74 * A finished row running again (a SendMessage resume, a teammate woken): running, with
75 * startedAt moved so elapsed carries on from what the earlier run took, not the idle gap.
76 */
77export const reopenRow = (a: AgentRun, at: number): AgentRun => {
78  if (a.status === 'running' || a.endedAt === undefined) {
79    return a
80  }
81
82  const { endedAt, missingSince: _dropped, ...rest } = a
83
84  return { ...rest, status: 'running', startedAt: at - (endedAt - a.startedAt) }
85}
86
87/** How many turns a row remembers its per-turn step counts for. */
88export const TURNS_KEPT = 8
89/** A turn whose figures came from its completion's usage. */
90export const FELL_BACK = -1
91
92/** The per-turn counts with one turn set (moved to newest), trimmed to the newest TURNS_KEPT. */
93export const noteTurn = (turns: Record<string, number> | undefined, turnId: string, value: number): Record<string, number> => {
94  const { [turnId]: _old, ...rest } = turns ?? {}
95  const keys = Object.keys(rest).slice(-(TURNS_KEPT - 1))
96
97  return { ...Object.fromEntries(keys.map(k => [k, rest[k]!])), [turnId]: value }
98}
99
100/**
101 * A row's per-turn counts, carrying a card from 0.4.0 across a reload: its one turn's step
102 * count (turnSteps) and the turn its completion counted (fellBack) become entries. The
103 * legacy fields are dropped from any row an event rewrites.
104 */
105export const turnsOf = (a: AgentRun): Record<string, number> | undefined => {
106  const { turnSteps, fellBack } = a as AgentRun & { turnSteps?: unknown; fellBack?: unknown }
107
108  if (a.turns !== undefined || (turnSteps === undefined && fellBack === undefined)) {
109    return a.turns
110  }
111
112  const out: Record<string, number> = {}
113
114  if (typeof a.turnId === 'string' && typeof turnSteps === 'number' && turnSteps > 0) {
115    out[a.turnId] = turnSteps
116  }
117
118  if (typeof fellBack === 'string') {
119    out[fellBack] = FELL_BACK
120  }
121
122  return out
123}
124
125const withoutLegacy = (a: AgentRun): AgentRun => {
126  const { turnSteps: _s, fellBack: _f, ...rest } = a as AgentRun & { turnSteps?: unknown; fellBack?: unknown }
127
128  return rest
129}
130
131/**
132 * One model request of the subagent: figures move. A step on a done or failed row is a
133 * resume (no agent.spawn fires for a SendMessage resume) only when it belongs to a turn the
134 * row has not seen and began after the row ended; any other step on a finished row is late
135 * and only adds figures. A turn counted by its completion's usage takes no step figures.
136 * An external row has no steps; a lost row keeps its status.
137 */
138export const applyStep = (
139  list: AgentRun[],
140  id: string,
141  usage: Usage,
142  model: string,
143  effort: unknown,
144  at: number,
145  turnId?: string,
146  began?: number,
147): AgentRun[] => {
148  const i = list.findIndex(a => a.id === id)
149
150  if (i < 0) {
151    return list
152  }
153
154  const sum = sumUsage(usage)
155
156  if (sum === null) {
157    return list
158  }
159
160  const was = list[i]!
161  const found: AgentRun = { ...withoutLegacy(was), ...(turnsOf(was) === undefined ? {} : { turns: turnsOf(was) }) }
162  const seen = turnId === undefined ? undefined : found.turns?.[turnId]
163
164  // This turn's usage already came from its completion: a late step would count it twice.
165  if (seen === FELL_BACK) {
166    return list
167  }
168
169  const isNewTurn = turnId !== undefined && seen === undefined && turnId !== found.turnId
170  const isFinished = found.status === 'done' || found.status === 'failed'
171  const isResumed =
172    found.external === undefined &&
173    isFinished &&
174    isNewTurn &&
175    (began === undefined || found.endedAt === undefined || began >= found.endedAt)
176  const a = isResumed ? reopenRow(found, at) : found
177  const next: AgentRun = {
178    ...settled(withoutMissing(a)),
179    model: usage.model || model || a.model,
180    effort: typeof effort === 'string' ? effort : a.effort,
181    contextTokens: sum.context,
182    tokens: a.tokens + sum.tokens,
183    steps: a.steps + 1,
184    ...(turnId === undefined
185      ? {}
186      : {
187          ...(isNewTurn || turnId === a.turnId ? { turnId } : {}),
188          turns: noteTurn(a.turns, turnId, (seen ?? 0) + 1),
189        }),
190  }
191
192  return replaceAt(list, i, next)
193}
194
195/**
196 * The run's end: done on an answer, failed otherwise. A turn none of whose steps was seen
197 * (one that ran before the card, or a resumed run's whose steps went unreported) takes its
198 * figures from the completion's usage, added to what earlier runs counted, and is marked so
199 * a repeated completion or a late step of it adds nothing.
200 */
201export const applyComplete = (
202  list: AgentRun[],
203  id: string,
204  reason: string,
205  usage: Usage | null,
206  at: number,
207  turnId?: string,
208): AgentRun[] => {
209  const i = list.findIndex(a => a.id === id)
210
211  if (i < 0) {
212    return list
213  }
214
215  const was = list[i]!
216  const a: AgentRun = { ...withoutLegacy(was), ...(turnsOf(was) === undefined ? {} : { turns: turnsOf(was) }) }
217  const seen = turnId === undefined ? a.steps : (a.turns?.[turnId] ?? 0)
218  const sum = seen === 0 && usage !== null ? sumUsage(usage) : null
219  const isOlderTurn = turnId !== undefined && turnId !== a.turnId && a.turns?.[turnId] !== undefined
220  const next: AgentRun = {
221    ...settled(a),
222    status: reason === 'answer' ? 'done' : 'failed',
223    endedAt: at,
224    ...(turnId === undefined || isOlderTurn ? {} : { turnId }),
225    ...(sum === null
226      ? {}
227      : {
228          tokens: a.tokens + sum.tokens,
229          contextTokens: sum.context,
230          steps: a.steps + 1,
231          model: usage?.model || a.model,
232          ...(turnId === undefined ? {} : { turns: noteTurn(a.turns, turnId, FELL_BACK) }),
233        }),
234  }
235
236  return replaceAt(list, i, next)
237}
238
239/** What $.agent.list() says about every running row; absence is timed and ends in failed (had steps) or lost (had none). */
240export const reconcileRows = (list: AgentRun[], infos: readonly { id: string; status: string }[], at: number): AgentRun[] => {
241  const status = new Map(infos.map(one => [one.id, one.status]))
242  let changed = false
243  const out = list.map(a => {
244    if (a.status !== 'running' || a.external !== undefined) {
245      return a
246    }
247
248    const s = status.get(a.id)
249
250    if (s === 'completed') {
251      changed = true
252
253      return { ...settled(withoutMissing(a)), status: 'done' as const, endedAt: at }
254    }
255
256    if (s === 'failed' || s === 'killed') {
257      changed = true
258
259      return { ...settled(withoutMissing(a)), status: 'failed' as const, endedAt: at }
260    }
261
262    if (s !== undefined) {
263      const parked = PARKED.has(s) ? (s as NonNullable<AgentRun['engineStatus']>) : undefined
264
265      if (a.missingSince === undefined && a.engineStatus === parked) {
266        return a
267      }
268
269      changed = true
270      const seen = withoutEngineStatus(withoutMissing(a))
271
272      return parked === undefined ? seen : { ...seen, engineStatus: parked }
273    }
274
275    if (a.missingSince === undefined) {
276      changed = true
277
278      return { ...a, missingSince: at }
279    }
280
281    if (at - a.missingSince >= MISSING_MS) {
282      changed = true
283
284      return { ...settled(withoutMissing(a)), status: a.steps > 0 ? ('failed' as const) : ('lost' as const), endedAt: at }
285    }
286
287    return a
288  })
289
290  return changed ? out : list
291}
292
293/** Thousands as k, millions to one decimal. */
294export const fmtTokens = (n: number): string => {
295  if (n < 1000) {
296    return `${Math.round(n)}`
297  }
298
299  const k = Math.round(n / 1000)
300
301  if (k < 1000) {
302    return `${k}k`
303  }
304
305  return `${(Math.round(n / 100_000) / 10).toFixed(1)}M`
306}
307
308/** m:ss under an hour, h:mm:ss above; never negative. */
309export const fmtTime = (ms: number): string => {
310  const s = Math.max(0, Math.round(ms / 1000))
311  const h = Math.floor(s / 3600)
312  const m = Math.floor((s % 3600) / 60)
313  const ss = String(s % 60).padStart(2, '0')
314
315  return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
316}
317
318/** The id without its vendor prefix and any `[...]` suffix, lower-cased: what two ids are compared on. */
319export const modelKey = (id: string): string => id.toLowerCase().replace(/^claude-/, '').replace(/\[[^\]]*\]$/, '')
320
321/** `Opus 5.5` from `claude-opus-5-5[1m]`; an unknown id with the prefix stripped; `—` for nothing. */
322export const modelName = (id: string): string => {
323  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d+))?/i.exec(id)
324
325  if (m === null) {
326    return modelKey(id) || '—'
327  }
328
329  const [, family = '', major = '', minor] = m
330
331  return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor === undefined ? '' : `.${minor}`}`
332}
333
334/** The row's context against the session window, only when it runs the session's model. */
335export const ctxPercent = (row: AgentRun, session: Session | null): number | null => {
336  if (session === null || session.window === null || session.window <= 0) {
337    return null
338  }
339
340  if (modelKey(row.model) !== modelKey(session.model)) {
341    return null
342  }
343
344  // Multiply first: 29 / 200 * 100 is 14.499999… in floating point, 2900 / 200 is exactly 14.5.
345  return Math.min(100, Math.round((row.contextTokens * 100) / session.window))
346}
347
348/** The Clawd costumes a subagent of no type of its own rotates through. */
349export const CLAWDS: readonly Costume[] = ['plain', 'pirate', 'detective', 'engineer', 'chef']
350
351/**
352 * The costume for an agent type, any `<plugin>:` prefix removed: every agent of the
353 * codex plugin is the Codex bot, a Kimi buddy seat (`buddy:kimi-…`) the panda, a
354 * Gemini one the rainbow droplet, a Grok one the ball, a Composer one the cube. Any
355 * other subagent takes the Clawd costume its spawn order (`variant`) points at, so a
356 * session's agents show every one; with no variant it is the plain Clawd.
357 */
358export const costumeOf = (type: string, variant?: number): Costume => {
359  const bare = type.replace(/^[^:]*:/, '')
360
361  if (type.startsWith('codex:') || bare === 'codex-rescue') return 'codex'
362  if (type.startsWith('buddy:') && bare.startsWith('kimi')) return 'panda'
363  if (type.startsWith('buddy:') && bare.startsWith('gemini')) return 'gemini'
364  if (type.startsWith('buddy:') && /^(cursor-)?grok/.test(bare)) return 'grok'
365  if (type.startsWith('buddy:') && bare.startsWith('composer')) return 'composer'
366
367  if (bare === 'Explore') return 'pirate'
368  if (bare === 'Plan') return 'engineer'
369  if (bare === 'claude-code-guide') return 'chef'
370
371  return variant === undefined ? 'plain' : CLAWDS[variant % CLAWDS.length]!
372}
373
374/** An external row's end, as its call's result or its done file said; a row already over keeps its status. */
375export const finishRow = (list: AgentRun[], id: string, status: 'done' | 'failed' | 'lost', at: number): AgentRun[] => {
376  const i = list.findIndex(a => a.id === id)
377
378  if (i < 0 || list[i]!.status !== 'running') {
379    return list
380  }
381
382  return replaceAt(list, i, { ...settled(list[i]!), status, endedAt: at })
383}
384
385export const elapsed = (row: AgentRun, at: number): number => (row.endedAt ?? Math.max(at, row.startedAt)) - row.startedAt
386
387/** Whether the row's token figures mean anything: an external or lost row never reports a request. */
388export const reportsTokens = (row: AgentRun): boolean => row.external === undefined && row.status !== 'lost'
389
390/** Agents, their summed tokens (null when no row reports any: unknown, not 0) and the span they cover. */
391export type Totals = { agents: number; tokens: number | null; time: number }
392
393export const totals = (list: AgentRun[], at: number): Totals => {
394  if (list.length === 0) {
395    return { agents: 0, tokens: 0, time: 0 }
396  }
397
398  const start = Math.min(...list.map(a => a.startedAt))
399  const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
400  const known = list.filter(reportsTokens)
401
402  return { agents: list.length, tokens: known.length === 0 ? null : known.reduce((s, a) => s + a.tokens, 0), time: end - start }
403}
404
405/** Whitespace runs folded, then cut to `n` characters with an ellipsis when it had to cut. */
406export const clip = (text: string, n: number): string => {
407  const flat = text.replace(/\s+/g, ' ').trim()
408
409  return flat.length <= n ? flat : `${flat.slice(0, n - 1)}…`
410}
411
412/** The argument that says most about a tool call, first found in this order. */
413const HINTS = ['file_path', 'path', 'notebook_path', 'pattern', 'url', 'command', 'query', 'description'] as const
414const TOOL_LINE = 80
415
416/**
417 * The program a shell command runs: its first word after any VAR=value prefixes, the
418 * directory dropped. Only that reaches a card: a command's arguments can carry anything
419 * (headers, tokens, bodies), and no pattern list is a safe filter for a pane that may be
420 * on a shared screen.
421 */
422const programOf = (command: string): string => {
423  const word = command
424    .trim()
425    .split(/\s+/)
426    .find(w => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w))
427
428  return (word ?? '').replace(/^["']|["']$/g, '').replace(/^.*\//, '')
429}
430
431/** A URL as host and path: userinfo, query and fragment dropped. */
432const urlShape = (url: string): string => {
433  const m = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/?#]*@)?([^/?#]*)([^?#]*)/i.exec(url.trim())
434
435  return m === null ? url.replace(/[?#].*$/, '') : `${m[1]}${m[2]}`
436}
437
438/** One tool use as a card line: its name and its most telling argument, clipped. */
439export const toolLine = (use: { name: string; input: unknown }): string => {
440  const input = typeof use.input === 'object' && use.input !== null ? (use.input as Record<string, unknown>) : {}
441  const key = HINTS.find(k => typeof input[k] === 'string' && (input[k] as string).trim() !== '')
442  const value = key === undefined ? '' : (input[key] as string)
443  const shown = key === 'command' ? programOf(value) : key === 'url' ? urlShape(value) : value
444
445  return clip(shown === '' ? use.name : `${use.name} ${shown}`, TOOL_LINE)
446}
447
448const TOOLS_KEPT = 3
449const TEXT_KEPT = 400
450
451/** A step's tool uses (the newest three kept, newest last) and whether it stopped at the output limit. */
452export const applyStepDetail = (
453  list: AgentRun[],
454  id: string,
455  uses: readonly { name: string; input: unknown }[],
456  stopReason: string | null,
457): AgentRun[] => {
458  const i = list.findIndex(a => a.id === id)
459
460  if (i < 0) {
461    return list
462  }
463
464  // A step means the agent is working: whatever it waited on was answered.
465  const a = settled(list[i]!)
466  const tools = [...(a.detail?.tools ?? []), ...uses.map(toolLine)].slice(-TOOLS_KEPT)
467  const detail = { ...a.detail, tools, ...(stopReason === 'max_tokens' ? { isCutOff: true } : {}) }
468
469  return replaceAt(list, i, { ...a, detail })
470}
471
472/** The start of the run's answer, kept for the card; an empty answer leaves none. */
473export const applyAnswer = (list: AgentRun[], id: string, answer: string): AgentRun[] => {
474  const i = list.findIndex(a => a.id === id)
475
476  if (i < 0 || answer.trim() === '') {
477    return list
478  }
479
480  const a = list[i]!
481
482  return replaceAt(list, i, { ...a, detail: { ...a.detail, answer: clip(answer, TEXT_KEPT) } })
483}
484
485/** The task a subagent was given, as the card keeps it. */
486export const taskOf = (prompt: string): string => clip(prompt, TEXT_KEPT)
487
488/**
489 * A permission check on one of the subagent's calls: an ask or a denial is counted, and
490 * an ask stays pending on its call until that call returns, the agent steps again, or the
491 * run ends. Who answered an ask (the person, the classifier) is not something the engine
492 * says, so "asked" counts asks, not people asked.
493 */
494export const applyCheck = (list: AgentRun[], id: string, decision: 'allow' | 'ask' | 'deny', toolUseId: string): AgentRun[] => {
495  const i = list.findIndex(a => a.id === id)
496
497  if (i < 0 || decision === 'allow') {
498    return list
499  }
500
501  const a = list[i]!
502  const was = a.checks ?? { asked: 0, denied: 0 }
503  const checks = decision === 'ask' ? { ...was, asked: was.asked + 1 } : { ...was, denied: was.denied + 1 }
504  const pending = decision === 'ask' ? [...(a.pending ?? []).filter(one => one !== toolUseId), toolUseId] : a.pending
505
506  return replaceAt(list, i, { ...a, checks, ...(pending === undefined ? {} : { pending }) })
507}
508
509/** A call of the subagent's returned: an ask on it is no longer pending. */
510export const applyCallDone = (list: AgentRun[], id: string, toolUseId: string): AgentRun[] => {
511  const i = list.findIndex(a => a.id === id)
512  const a = list[i]
513
514  if (a === undefined || !(a.pending ?? []).includes(toolUseId)) {
515    return list
516  }
517
518  const left = a.pending!.filter(one => one !== toolUseId)
519  const { pending: _all, ...rest } = a
520
521  return replaceAt(list, i, left.length === 0 ? rest : { ...rest, pending: left })
522}
523
hooks/draw.ts 196 lines
1import type { AgentRun, Session } from '../types'
2import { costumeOf, ctxPercent, elapsed, fmtTime, fmtTokens, modelName, reportsTokens, totals } from './agents'
3import type { Totals } from './agents'
4import { ACCENT, CLAWD_CSS, CLAWD_W, clawd } from './sprite'
5
6export const CARD_H = 66
7export const BAND_H = 22
8// Every sprite is 24 units tall (the Clawd grid at 1.5 a cell, the bot grids at 1), hats
9// included; the band scales it to fit and centres the whole box, so a hat or a bot's
10// top row is never cut (drawn from y=-4 until 2026-10-13, which clipped them).
11const SPRITE_H = 24
12const BAND_SCALE = 0.85
13const BAND_Y = (BAND_H - SPRITE_H * BAND_SCALE) / 2
14export const STRIP_H = 32
15export const MAX_STRIP = 6
16const MAX_BAND = 6
17const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
18const DONE = '#3B9C5F'
19const FAIL = '#D0453F'
20const LOST = '#9a9a96'
21
22/** Light and dark text classes, a pulsing dot for a live row, and the Clawd motion. */
23const CSS = `<style>
24.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
25@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
26.live{animation:apulse 1.6s ease-in-out infinite}@keyframes apulse{50%{opacity:.3}}
27@media (prefers-reduced-motion: reduce){.live{animation:none}}
28</style>${CLAWD_CSS}`
29
30/** The one escaper every dynamic string passes through before it enters an Svg. */
31export const xml = (s: string): string =>
32  s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
33
34/** A per-character em table stands in for font metrics: no DOM measures text here. */
35const charEm = (ch: string): number => (/[\s.,:;'|!il1()[\]·]/.test(ch) ? 0.3 : /[A-ZА-ЯЁmwшщжюМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56)
36
37export const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
38
39/** Cuts `s` to `maxW` pixels at `size`, with an ellipsis when it had to cut. */
40export const fitText = (s: string, size: number, maxW: number): string => {
41  if (textWidth(s, size) <= maxW) {
42    return s
43  }
44
45  let out = ''
46
47  for (const ch of s) {
48    if (textWidth(`${out}${ch}…`, size) > maxW) {
49      break
50    }
51
52    out += ch
53  }
54
55  return `${out}…`
56}
57
58const svg = (W: number, H: number, body: string): string =>
59  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${CSS}${body}</svg>`
60
61const bareType = (type: string): string => type.replace(/^[^:]*:/, '')
62
63/** Cuts a label the engine never bounds (an agent type, a model id, an effort) so one card stays under the Svg cap. */
64const short = (s: string, max: number): string => (s.length > max ? `${s.slice(0, max - 1)}…` : s)
65
66export const cardType = (row: AgentRun): string => short(bareType(row.type), 48)
67
68export const cardMeta = (row: AgentRun): string =>
69  [cardType(row), short(modelName(row.model), 32), ...(row.effort === undefined ? [] : [short(row.effort, 16)])].join(' · ')
70
71export const cardStats = (row: AgentRun, at: number, session: Session | null): string => {
72  const time = fmtTime(elapsed(row, at))
73
74  // A lost row never reported a request; an external one reports none at all.
75  if (!reportsTokens(row)) {
76    return `— · — · ${time}`
77  }
78
79  const pct = ctxPercent(row, session)
80  const ctx = pct === null ? `ctx ${fmtTokens(row.contextTokens)}` : `ctx ${pct}% · ${fmtTokens(row.contextTokens)}`
81
82  const parked = row.engineStatus === undefined ? '' : ` · ${row.engineStatus}`
83  const cut = row.detail?.isCutOff === true ? ' · cut off' : ''
84  const asked = (row.checks?.asked ?? 0) > 0 ? ` · ${row.checks!.asked} asked` : ''
85  const denied = (row.checks?.denied ?? 0) > 0 ? ` · ${row.checks!.denied} denied` : ''
86  const awaiting = (row.pending ?? []).length > 0 ? ' · awaiting approval' : ''
87
88  return `${ctx} · ${fmtTokens(row.tokens)} · ${time}${parked}${cut}${asked}${denied}${awaiting}`
89}
90
91export const cardAlt = (row: AgentRun, at: number, session: Session | null): string =>
92  `${row.description || row.type}: ${cardMeta(row)}, ${row.status}, ${cardStats(row, at, session)}`
93
94/** The summed tokens, or `—` when no row reports any. */
95const fmtTotal = (tokens: number | null): string => (tokens === null ? '—' : fmtTokens(tokens))
96
97export const headerText = (t: Totals): string => `${t.agents} agent${t.agents === 1 ? '' : 's'} · ${fmtTotal(t.tokens)} · ${fmtTime(t.time)}`
98
99export const bandText = (running: AgentRun[], at: number): string => {
100  const t = totals(running, at)
101
102  return `${running.length} running · ${fmtTotal(t.tokens)} · ${fmtTime(t.time)}`
103}
104
105/** How many Clawds the band draws: what fits in W minus the text, at most six, at most n. */
106export const bandCrabCount = (W: number, n: number): number => Math.min(n, MAX_BAND, Math.max(1, Math.floor((W - 150) / 36)))
107
108const statusMark = (x: number, y: number, status: AgentRun['status'], color: string): string => {
109  if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
110  if (status === 'done') return `<path d="M${x - 5} ${y}l3.5 3.5 6.5-7" fill="none" stroke="${DONE}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
111  if (status === 'failed') return `<path d="M${x - 4} ${y - 4}l8 8M${x + 4} ${y - 4}l-8 8" stroke="${FAIL}" stroke-width="1.8" stroke-linecap="round"/>`
112
113  return `<circle cx="${x}" cy="${y}" r="5" fill="none" stroke="${LOST}" stroke-width="1.4"/>`
114}
115
116/** Three tiles: agents, tokens, time. */
117export const headerSvg = (W: number, t: Totals): string => {
118  const gap = 6
119  const tw = (W - gap * 2) / 3
120  const tile = (i: number, k: string, v: string): string =>
121    `<rect class="tile" x="${i * (tw + gap)}" y="0" width="${tw}" height="40" rx="8"/>` +
122    `<text class="s" x="${i * (tw + gap) + 9}" y="16" font-family="${FONT}" font-size="11">${xml(k)}</text>` +
123    `<text class="t" x="${i * (tw + gap) + 9}" y="33" font-family="${FONT}" font-size="15" font-weight="600" font-variant-numeric="tabular-nums">${xml(v)}</text>`
124
125  return svg(W, 44, `${tile(0, 'Agents', `${t.agents}`)}${tile(1, 'Tokens', fmtTotal(t.tokens))}${tile(2, 'Time', fmtTime(t.time))}`)
126}
127
128/** One subagent: Clawd, description, meta line, stats, a context bar and a status mark. */
129export const cardSvg = (W: number, row: AgentRun, at: number, session: Session | null): string => {
130  const costume = costumeOf(row.type, row.variant)
131  const color = ACCENT[costume]
132  const textW = W - 42 - 22
133  const stats = cardStats(row, at, session)
134  const pct = row.status === 'lost' ? null : ctxPercent(row, session)
135  const fill = row.status === 'done' ? textW : pct === null ? 0 : Math.round((textW * pct) / 100)
136  const barFill = row.status === 'done' ? DONE : color
137  const isDim = row.status === 'lost'
138
139  return svg(
140    W,
141    CARD_H,
142    `${clawd(0, 14, costume, { isWalking: row.status === 'running', dim: isDim })}` +
143      `<text class="${isDim ? 's' : 't'}" x="42" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(row.description || row.type, 13, textW))}</text>` +
144      `<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(cardType(row))}</tspan><tspan class="s">  ${xml(fitText(cardMeta(row).slice(cardType(row).length + 3), 11, textW - textWidth(cardType(row), 11)))}</tspan></text>` +
145      `<text class="s" x="${42 + textW}" y="49" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(stats)}</text>` +
146      `<rect class="k" x="42" y="55" width="${textW}" height="4" rx="2"/><rect x="42" y="55" width="${fill}" height="4" rx="2" fill="${barFill}"/>` +
147      `${statusMark(W - 8, 16, row.status, color)}` +
148      `<line class="ln" x1="0" y1="${CARD_H - 0.5}" x2="${W}" y2="${CARD_H - 0.5}"/>`,
149  )
150}
151
152/** The compact pane: one Clawd per row in a line, running first, then +n and the totals. */
153export const stripSvg = (W: number, rows: AgentRun[], t: Totals): string => {
154  const ordered = [...rows.filter(a => a.status === 'running'), ...rows.filter(a => a.status !== 'running')]
155  const shown = ordered.slice(0, Math.min(MAX_STRIP, Math.max(1, Math.floor((W - 150) / 36))))
156  const more = ordered.length - shown.length
157  const body = shown
158    .map((a, i) => {
159      const costume = costumeOf(a.type, a.variant)
160
161      return (
162        clawd(i * 36, 0, costume, { isWalking: a.status === 'running', dim: a.status === 'lost' }) +
163        (a.status === 'running' ? `<circle class="live" cx="${i * 36 + 32}" cy="4" r="3" fill="${ACCENT[costume]}"/>` : '')
164      )
165    })
166    .join('')
167  const x = shown.length * 36 + 4
168
169  return svg(
170    W,
171    STRIP_H,
172    `${body}${more > 0 ? `<text class="s" x="${x}" y="21" font-family="${FONT}" font-size="12">+${more}</text>` : ''}` +
173      `<text class="s" x="${W}" y="21" text-anchor="end" font-family="${FONT}" font-size="12" font-variant-numeric="tabular-nums">${xml(headerText(t))}</text>`,
174  )
175}
176
177/** The band row: walking Clawds for the running agents, +n past what fits, and the summary text. */
178export const bandSvg = (W: number, running: AgentRun[], at: number): string => {
179  const n = bandCrabCount(W, running.length)
180  const shown = running.slice(0, n)
181  const more = running.length - n
182  const clawds = shown.map((a, i) => clawd(i * 36, BAND_Y, costumeOf(a.type, a.variant), { isWalking: true, scale: BAND_SCALE })).join('')
183  const x = n * 36 + (more > 0 ? 4 : 0)
184  const moreText = more > 0 ? `<text class="s" x="${x}" y="${BAND_H / 2 + 4}" font-family="${FONT}" font-size="12">+${more}</text>` : ''
185  const textX = x + (more > 0 ? 14 + textWidth(`+${more}`, 12) : 0)
186  const room = Math.max(0, W - textX)
187
188  return svg(
189    W,
190    BAND_H,
191    `${clawds}${moreText}<text class="s" x="${textX}" y="${BAND_H / 2 + 4}" font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums">${xml(fitText(bandText(running, at), 12.5, room))}</text>`,
192  )
193}
194
195export { CLAWD_W }
196
hooks/launches.ts 214 lines
1/**
2 * Agents that run outside the engine, started by a Bash call: an external-model seat
3 * (buddy-run.sh, relay.mjs) or a Codex run (codex-companion.mjs, `codex exec`).
4 * The engine never lists them, so their rows open on the call and close on its
5 * result, or, for a backgrounded call, on its task notification or when the
6 * launcher's own done file lands, whichever comes first.
7 */
8export type Launch = {
9  /** `buddy:<model>` or `codex:<verb>`; the costume reads it. */
10  type: string
11  /** The model as the command names it; `codex` when the command names none. */
12  model: string
13  /** The file the launcher writes when it ends, absolute, or null when the command does not say. */
14  doneFile: string | null
15  /** How long the launcher may run before it kills its seat, from its own default when unnamed. */
16  timeoutMs: number
17}
18
19/** Past its timeout plus this, a backgrounded row whose done file never landed is lost. */
20export const LAUNCH_GRACE_MS = 60_000
21const RUN_DEFAULT_S = 300
22const RELAY_DEFAULT_MS = 1_800_000
23const RELAY_DEFAULT_MODEL = 'kimi-k3-high'
24/**
25 * A Codex run names no timeout of its own; without a wrapper's, its row is lost
26 * past this rather than running forever when its notification never comes.
27 */
28export const CODEX_DEFAULT_MS = 3_600_000
29const UNIT_S: Record<string, number> = { '': 1, s: 1, m: 60, h: 3_600, d: 86_400 }
30
31const unquote = (s: string): string => s.replace(/^(['"])(.*)\1$/, '$2')
32
33/**
34 * `NAME=value` assignments earlier in the command, then `$NAME`, `${NAME}` and a
35 * leading `~` expanded from them and from env; null when anything is left unresolved.
36 */
37const resolver = (command: string, env: Record<string, string | undefined>) => {
38  const vars: Record<string, string> = {}
39  const assign = /(?:^|[\s;&(])([A-Za-z_]\w*)=("[^"]*"|'[^']*'|[^\s;&|)]+)/g
40
41  for (let m = assign.exec(command); m !== null; m = assign.exec(command)) {
42    vars[m[1]!] = unquote(m[2]!)
43  }
44
45  const lookup = (name: string): string | undefined => vars[name] ?? env[name]
46
47  return (raw: string): string | null => {
48    let s = unquote(raw)
49
50    if (s === '~' || s.startsWith('~/')) {
51      const home = lookup('HOME')
52
53      if (home === undefined) {
54        return null
55      }
56
57      s = home + s.slice(1)
58    }
59
60    for (let i = 0; i < 4 && s.includes('$'); i += 1) {
61      s = s.replace(/\$\{([A-Za-z_]\w*)\}|\$([A-Za-z_]\w*)/g, (whole, a?: string, b?: string) => lookup(a ?? b ?? '') ?? whole)
62    }
63
64    return s.includes('$') || s.includes('`') || !s.startsWith('/') ? null : s
65  }
66}
67
68const ARG = `("[^"]*"|'[^']*'|[^\\s;&|]+)`
69
70/**
71 * The timeout a wrapper puts on the launch at `at`: coreutils `timeout [-k N]
72 * [-s SIG] DURATION` (or `gtimeout`), or `perl -e 'alarm shift; exec @ARGV' S`,
73 * in the same command segment before it; null when none does.
74 */
75export const wrapperTimeoutMs = (command: string, at: number): number | null => {
76  const before = command.slice(0, at)
77  // separators count outside quotes only (perl's 'alarm shift; exec @ARGV' holds a ;)
78  const masked = maskQuotes(before)
79  const cut = Math.max(...[';', '&', '|', '\n', '('].map(c => masked.lastIndexOf(c)))
80  const segment = before.slice(cut + 1)
81  const coreutils = /(?:^|\s)g?timeout\s+(?:(?:-[ks]\s+\S+|--(?:kill-after|signal)=\S+|--[a-z-]+)\s+)*(\d+(?:\.\d+)?)([smhd]?)(?=\s)/.exec(segment)
82
83  if (coreutils !== null) {
84    return Math.round(Number(coreutils[1]) * UNIT_S[coreutils[2] ?? '']! * 1000)
85  }
86
87  const perl = /alarm shift[^'"]*['"]\s+(\d+)(?=\s)/.exec(segment)
88
89  return perl === null ? null : Number(perl[1]) * 1000
90}
91
92/**
93 * The launch's timeout: the shorter of its own and a wrapper's when both are
94 * known, else whichever is, else CODEX_DEFAULT_MS for a Codex run (0, no
95 * deadline, for a buddy seat with neither, which its own default always covers).
96 */
97const withWrapper = (command: string, at: number, launch: Launch): Launch => {
98  const wrap = wrapperTimeoutMs(command, at)
99  const own = launch.timeoutMs
100  const timeoutMs =
101    own > 0 && wrap !== null ? Math.min(own, wrap) : own > 0 ? own : wrap !== null ? wrap : launch.type.startsWith('codex:') ? CODEX_DEFAULT_MS : 0
102
103  return { ...launch, timeoutMs }
104}
105
106/** Quoted text blanked to underscores, length kept, so separators and flags count outside quotes only. */
107const maskQuotes = (s: string): string => s.replace(/'[^']*'|"[^"]*"/g, q => '_'.repeat(q.length))
108
109/** A flag or first argument that makes a Codex invocation print and exit: no agent runs. */
110const READS_CLI = /^\s*help(?=\s|$)|(?:^|\s)(?:--help|-h|--version|-V)(?=[\s=]|$)/
111/** A program that only names the Codex command it is handed (prints, finds, documents it). */
112const NAMES_IT = /^\s*(?:echo|printf|which|type|man|whatis|apropos|command\s+-[vV]|grep|egrep|rg)(?:\s|$)/
113
114/**
115 * Whether the Codex invocation between `at` and `end` (its verb's end) is only a look at
116 * the CLI: `--help`, `-h`, `--version`, `-V` or a `help` argument before its command ends,
117 * or a program like `echo` or `which` at the head of its command (2026-10-12, when
118 * `codex exec --help | grep ...` showed as a finished agent).
119 */
120const isInspection = (command: string, at: number, end: number): boolean => {
121  const masked = maskQuotes(command)
122  const tail = masked.slice(end)
123  const stop = tail.search(/[;&|\n)]/)
124  const args = stop < 0 ? tail : tail.slice(0, stop)
125  const before = masked.slice(0, at)
126  const head = before.slice(Math.max(...[';', '&', '|', '\n', '('].map(c => before.lastIndexOf(c))) + 1)
127
128  return READS_CLI.test(args) || NAMES_IT.test(head)
129}
130
131/** Every external agent the command starts, in the order it names them. */
132export const parseLaunches = (command: string, env: Record<string, string | undefined> = {}): Launch[] => {
133  const resolve = resolver(command, env)
134  const found: { at: number; launch: Launch }[] = []
135  const run = new RegExp(`buddy-run\\.sh['"]?\\s+${ARG}\\s+${ARG}(?:\\s+(\\d+))?`, 'g')
136
137  for (let m = run.exec(command); m !== null; m = run.exec(command)) {
138    const model = unquote(m[1]!)
139    const dir = resolve(m[2]!)
140    found.push({
141      at: m.index,
142      launch: {
143        type: `buddy:${model}`,
144        model,
145        doneFile: dir === null ? null : `${dir.replace(/\/$/, '')}/meta-${model}.txt`,
146        timeoutMs: Number(m[3] ?? RUN_DEFAULT_S) * 1000,
147      },
148    })
149  }
150
151  const relay = /relay\.mjs['"]?((?:\s+(?:"[^"]*"|'[^']*'|[^\s;&|]+))*)/g
152
153  for (let m = relay.exec(command); m !== null; m = relay.exec(command)) {
154    const args = m[1] ?? ''
155    const flag = (name: string): string | undefined => new RegExp(`--${name}(?:=|\\s+)${ARG}`).exec(args)?.[1]
156    const named = flag('model')
157    // With --repo, an approved lane config may pick the model; the command alone cannot say which.
158    const model = named !== undefined ? unquote(named) : /--repo\b/.test(args) ? 'cursor' : RELAY_DEFAULT_MODEL
159    const out = flag('out-dir')
160    const dir = out === undefined ? null : resolve(out)
161    found.push({
162      at: m.index,
163      launch: {
164        type: `buddy:${model}`,
165        model,
166        doneFile: dir === null ? null : `${dir.replace(/\/$/, '')}/result.json`,
167        timeoutMs: Number(flag('timeout') ?? RELAY_DEFAULT_MS),
168      },
169    })
170  }
171
172  const codex = /codex-companion\.mjs['"]?\s+([a-z][a-z-]*)|(?:^|[\s;&|(])codex\s+(exec|review)\b/g
173
174  for (let m = codex.exec(command); m !== null; m = codex.exec(command)) {
175    // past a leading separator the match took, so a wrapper is looked for in this command, not the one before
176    const at = m.index + (m[1] === undefined && /^[\s;&|(]/.test(m[0]) ? 1 : 0)
177
178    if (isInspection(command, at, m.index + m[0].length)) {
179      continue
180    }
181
182    found.push({ at, launch: { type: `codex:${m[1] ?? m[2]}`, model: 'codex', doneFile: null, timeoutMs: 0 } })
183  }
184
185  return found.sort((a, b) => a.at - b.at).map(f => withWrapper(command, f.at, f.launch))
186}
187
188/** The launcher's verdict from its done file: buddy-run writes `EXIT:<code>`; anything else that landed is a finish. */
189export const doneStatus = (text: string): 'done' | 'failed' => {
190  const code = /^EXIT:(\d+)/m.exec(text)?.[1]
191
192  return code === undefined || code === '0' ? 'done' : 'failed'
193}
194
195/**
196 * Every background task a notification reports over: the call that started it and how it
197 * ended (`completed`, `failed`, `killed`). The text is the notification as it entered.
198 */
199export const parseNotifications = (text: string): { toolUseId: string; status: string }[] => {
200  const out: { toolUseId: string; status: string }[] = []
201  const block = /<task-notification>([\s\S]*?)<\/task-notification>/g
202
203  for (let m = block.exec(text); m !== null; m = block.exec(text)) {
204    const id = /<tool-use-id>([^<]+)<\/tool-use-id>/.exec(m[1]!)?.[1]?.trim()
205    const status = /<status>([^<]+)<\/status>/.exec(m[1]!)?.[1]?.trim()
206
207    if (id !== undefined && status !== undefined) {
208      out.push({ toolUseId: id, status })
209    }
210  }
211
212  return out
213}
214
hooks/sprite.ts 449 lines
1/**
2 * Pixel Clawd sprites for the mods' desktop drawings: the Claude Code mascot,
3 * a flat clay slab with two dark eyes, two side nubs for arms and four stubby
4 * legs, on a 22 x 16 cell grid (one <rect> per cell, 1.5 units a cell).
5 * Groups: `bd` body + costume, `ey` the eyes, `la`/`lb` the two leg pairs,
6 * `sp` the sparkles, and a named group per prop. The motion is pure CSS so a
7 * redraw never moves a pixel. `codex`, `panda`, `gemini`, `grok` and `composer`
8 * are not Clawds: the Codex bot, the Kimi panda, the Gemini droplet, the Grok ball
9 * and the Composer cube, each drawn on its own finer grid inside the same box.
10 */
11export type Costume = 'plain' | 'pirate' | 'detective' | 'engineer' | 'chef' | 'codex' | 'panda' | 'gemini' | 'grok' | 'composer'
12
13export const CLAWD_W = 26
14export const CLAWD_SCALE = 1.1
15
16/** The brand clay for the body; a costume's accent colors its hat and prop. */
17export const CLAY = '#E0805A'
18const SHADE = '#C4603F'
19const LIGHT = '#F2A684'
20const INK = '#1F1E1D'
21const WHITE = '#F8F6F1'
22const UNIT = 1.5
23
24export const ACCENT: Record<Costume, string> = {
25  plain: '#E0805A',
26  pirate: '#1D9E75',
27  detective: '#D85A30',
28  engineer: '#BA7517',
29  chef: '#378ADD',
30  codex: '#4273E9',
31  panda: '#7DB343',
32  gemini: '#4C8DF6',
33  grok: '#8A8A8E',
34  composer: '#A0A0A6',
35}
36
37/** Units a cell, where a costume draws on a grid other than the Clawd's. */
38const UNITS: Partial<Record<Costume, number>> = { codex: 1, panda: 1, gemini: 1, grok: 1, composer: 1 }
39
40/** `tone` classes the rect itself, so the theme CSS can recolor it. */
41type Fill = (x: number, y: number, w: number, h: number, color: string, cls?: string, tone?: string) => void
42
43const stamp = (f: Fill, x: number, y: number, rows: readonly string[], map: Record<string, string>, cls?: string): void => {
44  rows.forEach((row, dy) => {
45    ;[...row].forEach((ch, dx) => {
46      const color = map[ch]
47
48      if (color !== undefined) {
49        f(x + dx, y + dy, 1, 1, color, cls)
50      }
51    })
52  })
53}
54
55// Cell layout: arms at columns 0-2 and 17-19, body at 3-16 (14 wide) on rows
56// 5-13 (9 tall), hats above on rows 0-4, legs on rows 14-15. A raised right
57// arm sits three rows higher and its prop reaches columns 18-21.
58const BODY_X = 3
59const BODY_Y = 5
60
61/** The bare Clawd: slab with a light brow and a dark base, eyes, smile, arms, legs. */
62const clawdBody = (f: Fill, body: string, raisedArm = false, armCls?: string): void => {
63  stamp(
64    f,
65    BODY_X,
66    BODY_Y,
67    [
68      'llllllllllllll',
69      'bbbbbbbbbbbbbb',
70      'bbbbbbbbbbbbbb',
71      'bbbbbbbbbbbbbb',
72      'bbbbbbbbbbbbbb',
73      'bbbbbbbbbbbbbb',
74      'bbbbbbbbbbbbbb',
75      'bbbbbbbbbbbbbb',
76      'ssssssssssssss',
77    ],
78    { l: LIGHT, b: body, s: SHADE },
79  )
80  // Eyes: two 2 x 1 dark bars, in their own group so they can blink.
81  f(BODY_X + 2, BODY_Y + 3, 2, 1, INK, 'ey')
82  f(BODY_X + 10, BODY_Y + 3, 2, 1, INK, 'ey')
83  // Smile: two corners and a bottom edge, the pack's happy mouth.
84  f(BODY_X + 5, BODY_Y + 5, 1, 1, WHITE)
85  f(BODY_X + 8, BODY_Y + 5, 1, 1, WHITE)
86  f(BODY_X + 6, BODY_Y + 6, 2, 1, WHITE)
87  // Arms: 3 x 3 nubs at the sides; the right one lifts to hold a prop.
88  f(0, BODY_Y + 5, 3, 3, body)
89  f(0, BODY_Y + 7, 3, 1, SHADE)
90  f(17, BODY_Y + (raisedArm ? 2 : 5), 3, 3, body, armCls)
91  f(17, BODY_Y + (raisedArm ? 4 : 7), 3, 1, SHADE, armCls)
92  // Legs: 1 x 2 stubs with a dark foot; `la` steps while `lb` plants, then the other way.
93  for (const [col, cls] of [
94    [1, 'la'],
95    [4, 'lb'],
96    [9, 'la'],
97    [12, 'lb'],
98  ] as const) {
99    f(BODY_X + col, BODY_Y + 9, 1, 1, body, cls)
100    f(BODY_X + col, BODY_Y + 10, 1, 1, SHADE, cls)
101  }
102}
103
104// The Codex bot from its sprite sheet: a cloud head whose screen reads `>_`, a body
105// with a small `>_` on the chest, side nubs and two feet, on a 20 x 24 grid at one
106// unit a cell. `U` is the screen's cursor; rows 22-23 are the feet.
107const CODEX = [
108  '....KKK.KKKK.KKK....',
109  '...KLLLKLLLLKLLLK...',
110  '..KLLLLLLLLLLLLLLK..',
111  '.KBLLLLLLLLLLLLLLBK.',
112  'KBBBKKKKKKKKKKKKBBBK',
113  'KBBKSSSSSSSSSSSSKBBK',
114  'KBBKSCSSSSSSSSSSKBBK',
115  'KBBKSSCSSSSSSSSSKBBK',
116  '.KBKSSSCSSSSSSSSKBK.',
117  'KBBKSSCSSSSSSSSSKBBK',
118  'KBBKSCSSSUUUUSSSKBBK',
119  'KBBKSSSSSSSSSSSSKBBK',
120  '.KBBKKKKKKKKKKKKBBK.',
121  '..KBBBBBBBBBBBBBBK..',
122  '...KKKKKKKKKKKKKK...',
123  '....KBBBBBBBBBBK....',
124  '.KBBKBBCBBBBBBBKBBK.',
125  '.KBBKBBBCBCCBBBKBBK.',
126  '.KBBKBBCBBBBBBBKBBK.',
127  '..KKKBBBBBBBBBBKKK..',
128  '....KBBBBBBBBBBK....',
129  '....KBBBKKKKBBBK....',
130  '....KBBBK..KBBBK....',
131  '....KKKKK..KKKKK....',
132]
133
134// The Kimi panda from its sprite sheet, standing: ears, eye patches with a shine
135// (`E`, which blinks), pink cheeks, black arms and shoulders, a white belly, two feet.
136// `K` is the black dark mode lifts to slate; `N`, the patches and nose on the white
137// face, stays black.
138const PANDA = [
139  '...KKK........KKK...',
140  '..KKKKKKKKKKKKKKKK..',
141  '..KKKWWWWWWWWWWKKK..',
142  '..KWWWWWWWWWWWWWWK..',
143  '.KWWWWWWWWWWWWWWWWK.',
144  '.KWWNNNWWWWWWNNNWWK.',
145  '.KWNNENWWWWWWNENNWK.',
146  '.KWNNNWWWWWWWWNNNWK.',
147  '.KWPWWWWWNNWWWWWPWK.',
148  '.KWWWWWWWWWWWWWWWWK.',
149  '..KWWWWWWWWWWWWWWK..',
150  '...KKWWWWWWWWWWKK...',
151  '..KKKKKKKKKKKKKKKK..',
152  '.KKKKKKKKKKKKKKKKKK.',
153  'KKKKKKWWWWWWWWKKKKKK',
154  'KKKKKWWWWWWWWWWKKKKK',
155  'KKK.KWWWWWWWWWWK.KKK',
156  '.KK.KWWWWWWWWWWK.KK.',
157  '....KWWWWWWWWWWK....',
158  '....KGWWWWWWWWGK....',
159  '....KKGGWWWWGGKK....',
160  '....KKKKKKKKKKKK....',
161  '....KKKK....KKKK....',
162  '....KKKK....KKKK....',
163]
164
165// The Gemini droplet from its sprite sheet: a rainbow drop, its tip up and right, with
166// closed happy eyes and a smile. Its body cells (`B`) take a rainbow band by their
167// diagonal, numbered `0`-`6`; the bands cycle while it runs. No feet: it bobs.
168const RAINBOW = ['#FF3B30', '#FF9500', '#FFD60A', '#34C759', '#00C7E6', '#1E6FFF', '#C03BF5']
169const GEMINI = [
170  '....................',
171  '...............KK...',
172  '.............KKBK...',
173  '...........KKBBBK...',
174  '........KKKBBBBBK...',
175  '......KKBBBBBBBBK...',
176  '.....KBBBBBBBBBBBK..',
177  '....KBBBBBBBBBBBBK..',
178  '...KBBBBBBBBBBBBBK..',
179  '..KBBBBBBBBBBBBBBBK.',
180  '..KBBBBBBBBBBBBBBBK.',
181  '.KBBBBFFBBBBFFBBBBK.',
182  '.KBBBFBBFBBFBBFBBBK.',
183  '.KBBBBBBBBBBBBBBBBK.',
184  '.KBBFBBBBBBBBBBFBBK.',
185  '.KBBBFBBBBBBBBFBBBK.',
186  '.KBBBBFFFFFFFFBBBBK.',
187  '.KBBBBBBBBBBBBBBBBK.',
188  '.KBBBBBBBBBBBBBBBBK.',
189  '.KBBBBBBBBBBBBBBBBK.',
190  '.KBBBBBBBBBBBBBBBBK.',
191  '..KBBBBBBBBBBBBBBK..',
192  '...KKBBBBBBBBBBKK...',
193  '.....KKKKKKKKKK.....',
194].map((row, y) => row.replace(/B/g, (_, x: number) => `${Math.floor((x + y) / 5) % RAINBOW.length}`))
195
196// The Grok ball from its two sprites: a ball with two slanted pill eyes looking up and
197// right. Light mode draws the black ball with white eyes and a grey sheen on its left
198// rim (`H`); dark mode, through the tone classes, the white ball with black eyes.
199// The eyes (`E`) blink; no feet: it bobs.
200const GROK = [
201  '....................',
202  '....................',
203  '....................',
204  '....................',
205  '.......BBBBBB.......',
206  '.....BHHBBBBBBB.....',
207  '...BBHHHBBBBBBBBB...',
208  '..BHHHBBBBBBBBBBBB..',
209  '..BHHBBBBBBBBEEBBB..',
210  '.BHHBBBBBBBBBBEEBBB.',
211  '.BHBBBBBBEEBBBEEBBB.',
212  'BBBBBBBBBEEBBBBEEBBB',
213  'BBBBBBBBBBEEBBBEEBBB',
214  'BHBBBBBBBBEEBBBBBBBB',
215  'BHBBBBBBBBBEEBBBBBBB',
216  'BHHBBBBBBBBBBBBBBBBB',
217  'BHHBBBBBBBBBBBBBBBBB',
218  '.HHBBBBBBBBBBBBBBBB.',
219  '.BHHBBBBBBBBBBBBBBB.',
220  '..BHHBBBBBBBBBBBBB..',
221  '..BHHHBBBBBBBBBBBB..',
222  '...BBHHHBBBBBBBBB...',
223  '.....BHHBBBBBBB.....',
224  '.......BBBBBB.......',
225]
226
227// The Composer cube from its sprite: a grey hexagonal cube, its top cap split light
228// and dark, a light front face with dot eyes, blush (`b`) and a smile, dark side faces
229// (`D`, `X`), light lower faces (`L`, `P`), one fist waving, one arm down, and boots.
230// Wider than the rest: 24 columns.
231const COMPOSER = [
232  '..........KKKK..........',
233  '........KKMMDDKK........',
234  '.KK...KKMMMMDDDDKK......',
235  'KMMKKKMMMMMMDDDDDDKK....',
236  'KMMKMMMMMMMMDDDDDDDDK...',
237  '.KDKWWWWWWWWWWWWWWWWK...',
238  '.KDKWWWWWWWWWWWWWWWWK...',
239  '..KKDWWWKKWWWWKKWWWXK...',
240  '...KDDWWKKWWWWKKWWXXK...',
241  '...KDDDbWWWWWWWWbXXXKK..',
242  '...KDDDDWWKWWKWWXXXXKDK.',
243  '...KDDDDDWWKKWWXXXXXKDK.',
244  '...KDDDDDDWWWWXXXXXXKMMK',
245  '...KLLLLLLLLPPPPPPPPKMMK',
246  '....KKLLLLLLPPPPPPKK.KK.',
247  '......KKLLLLPPPPKK......',
248  '........KKLLPPKK........',
249  '.......KDKKKKKKDK.......',
250  '.......KDK....KDK.......',
251  '.......KDK....KDK.......',
252  '.....KKKDK....KDKKK.....',
253  '....KMMDDDK..KDDDMMK....',
254  '....KDDDDDK..KDDDDDK....',
255  '....KKKKKKK..KKKKKKK....',
256]
257
258/**
259 * A 24-row sprite at one unit a cell, one <rect> per run of a color so the larger
260 * grid stays small, centred where a Clawd's body sits (a 20-wide one five units in). `U` cells go to
261 * the blinking cursor group, `E` to the blinking eyes; rows 22-23 are the feet, the
262 * left stepping with `la`, the right with `lb`, unless the sprite has none.
263 */
264const gridSprite = (
265  f: Fill,
266  rows: readonly string[],
267  color: Record<string, string>,
268  tones: Record<string, string> = {},
269  hasFeet = true,
270): void => {
271  const width = rows[0]?.length ?? 0
272  const left = Math.floor((30 - width) / 2)
273
274  rows.forEach((row, y) => {
275    for (let x = 0; x < row.length; ) {
276      const ch = row[x]!
277      let end = x + 1
278
279      while (row[end] === ch) {
280        end += 1
281      }
282
283      if (ch !== '.') {
284        f(left + x, y, end - x, 1, color[ch]!, ch === 'U' ? 'cu' : ch === 'E' ? 'ey' : hasFeet && y >= 22 ? (x < width / 2 ? 'la' : 'lb') : 'bd', tones[ch])
285      }
286
287      x = end
288    }
289  })
290}
291
292/** Four coloured sparks around the head that twinkle while Clawd works. */
293const sparkles = (f: Fill): void => {
294  f(1, 2, 1, 1, '#5EF2C4', 'sp')
295  f(2, 1, 1, 1, '#5EF2C4', 'sp')
296  f(18, 1, 1, 1, '#9B5CF6', 'sp')
297  f(20, 3, 1, 1, '#FFE85C', 'sp')
298}
299
300const COSTUMES: Record<Costume, (f: Fill, accent: string, body: string) => void> = {
301  plain: (f, _accent, body) => {
302    clawdBody(f, body)
303    sparkles(f)
304  },
305  // Pirate: red bandana with a tail over the brow, an eye patch, a cutlass in the raised arm that fences.
306  pirate: (f, accent, body) => {
307    clawdBody(f, body, true, 'it')
308    stamp(f, BODY_X, BODY_Y - 1, ['rrrrrrrrrrrrrr', 'rrrrrrrrrrrrrr'], { r: '#D13C38' })
309    f(1, BODY_Y, 2, 1, '#D13C38')
310    f(0, BODY_Y + 1, 2, 1, '#D13C38')
311    // Patch over the right eye, strap across the head.
312    f(BODY_X + 9, BODY_Y + 2, 4, 3, INK)
313    f(BODY_X, BODY_Y + 1, 14, 1, INK)
314    stamp(f, 18, 0, ['...w', '..ww', '.ww.', '.w..', 'ww..', 'g...'], { w: '#D5D8DE', g: accent }, 'it')
315  },
316  // Detective: a deerstalker with ear flaps, a magnifier in the raised arm that sweeps and glints.
317  detective: (f, accent, body) => {
318    clawdBody(f, body, true, 'it')
319    stamp(
320      f,
321      1,
322      1,
323      ['......hhhhhhhh......', '....hhhhhhhhhhhh....', 'ff.hhhhhhhhhhhhhh.ff', 'ffhhhhhhhhhhhhhhhhff'],
324      { h: '#8B5A2B', f: '#A97142' },
325    )
326    f(BODY_X, BODY_Y, 14, 1, '#5A3A1C')
327    f(BODY_X + 4, 2, 6, 1, accent)
328    stamp(f, 17, 0, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
329    f(18, 1, 3, 3, 'rgba(169,214,245,.6)', 'it')
330    f(18, 1, 1, 1, WHITE, 'gl')
331    f(19, 5, 1, 2, '#8B5A2B', 'it')
332  },
333  // Engineer: a yellow hard hat with a brim, a wrench in the raised arm that turns.
334  engineer: (f, accent, body) => {
335    clawdBody(f, body, true, 'it')
336    stamp(
337      f,
338      1,
339      1,
340      ['.....yyyyyyyyyy.....', '...yyylyyyyyyyyyy...', '...yyylyyyyyyyyyy...', 'bbbbbbbbbbbbbbbbbbbb'],
341      { y: '#F5C542', l: '#FBE08A', b: '#C99A1E' },
342    )
343    f(BODY_X + 5, 1, 4, 1, accent)
344    stamp(f, 18, 0, ['s.s', 'sss', '.s.', '.s.', '.s.', '.s.'], { s: '#8E929A' }, 'it')
345  },
346  // Chef: a tall white toque with a band, a pan in the raised arm that tilts while the egg flips.
347  chef: (f, accent, body) => {
348    clawdBody(f, body, true, 'pan')
349    stamp(
350      f,
351      BODY_X,
352      0,
353      ['..wwwwwwwwww..', '.wwwwwwwwwwww.', '.wwwwwwwwwwww.', '..wwwwwwwwww..', '...bbbbbbbb...'],
354      { w: WHITE, b: accent },
355    )
356    f(17, 5, 5, 1, '#4A4A48', 'pan')
357    f(21, 6, 1, 1, '#4A4A48', 'pan')
358    f(18, 4, 3, 1, '#F5B731', 'egg')
359  },
360  codex: (f, _accent, body) =>
361    gridSprite(f, CODEX, { K: '#020619', L: '#6095F7', B: body === CLAY ? ACCENT.codex : body, S: '#192657', C: '#9BF0F8', U: '#9BF0F8' }),
362  panda: (f, _accent, body) =>
363    gridSprite(f, PANDA, { K: '#13202B', N: '#13202B', W: body === CLAY ? '#F2F2EF' : body, G: '#C9CBCB', P: '#F08A7E', E: '#F2F2EF' }, { K: 'pk' }),
364  gemini: (f, _accent, body) =>
365    gridSprite(
366      f,
367      GEMINI,
368      { K: '#111111', F: '#111111', ...Object.fromEntries(RAINBOW.map((c, i) => [`${i}`, body === CLAY ? c : body])) },
369      Object.fromEntries(RAINBOW.map((_, i) => [`${i}`, `g${i}`])),
370      false,
371    ),
372  grok: (f, _accent, body) =>
373    gridSprite(f, GROK, { B: body === CLAY ? '#0B0B0B' : body, H: '#3A3A3A', E: '#F8F6F1' }, { B: 'kb', H: 'kh', E: 'ke' }, false),
374  composer: (f, _accent, body) =>
375    gridSprite(f, COMPOSER, {
376      K: '#111111',
377      W: body === CLAY ? '#F1F1F1' : body,
378      L: '#C4C4C4',
379      P: '#D9D9D9',
380      M: '#8E8E8E',
381      D: '#6A6A6A',
382      X: '#4E4E4E',
383      b: '#B5B5B5',
384    }),
385}
386
387/**
388 * Pure CSS motion, run by the compositor; in dark mode the panda gets a one-cell
389 * slate black, since its black limbs and ears would vanish into the background;
390 * the Grok ball turns white with black eyes; the Gemini droplet's rainbow bands
391 * cycle while it runs. Every costume walks (legs step out of
392 * phase, the body bobs), blinks every few seconds, and the plain one's sparkles
393 * twinkle; each prop adds its own motion. Reduced motion stops all of it.
394 */
395export const CLAWD_CSS = `<style>
396.run .la{animation:cstep .5s steps(1) infinite}.run .lb{animation:cstep .5s steps(1) infinite -.25s}
397.run .bd{animation:cbob .5s steps(1) infinite -.125s}
398.run g{transform-box:fill-box}
399.ey{transform-box:fill-box;transform-origin:50% 50%;animation:cblink 4s steps(1) infinite}
400.sp{opacity:0}.run .sp{animation:ctwinkle 1.2s steps(1) infinite}
401@keyframes cstep{50%{transform:translateY(-1.5px)}}@keyframes cbob{50%{transform:translateY(1.5px)}}
402@keyframes cblink{0%,92%,100%{transform:scaleY(1)}94%,98%{transform:scaleY(.2)}}
403@keyframes ctwinkle{0%,49%{opacity:1}50%,100%{opacity:0}}
404.c-pirate.run .it{transform-origin:0 100%;animation:cfence .5s ease-in-out infinite}
405@keyframes cfence{50%{transform:rotate(28deg)}}
406.c-detective.run .it{animation:csweep 1s steps(1) infinite}.c-detective.run .gl{animation:cglint 1s steps(1) infinite -.5s}
407@keyframes csweep{25%{transform:translate(-1.5px,1.5px)}50%{transform:translate(-3px,3px)}75%{transform:translate(-1.5px,1.5px)}}
408@keyframes cglint{50%{opacity:.15}}
409.c-engineer.run .it{transform-origin:50% 100%;animation:cturn .5s ease-in-out infinite}
410@keyframes cturn{50%{transform:rotate(-30deg)}}
411.c-chef.run .pan{transform-origin:0 50%;animation:ctilt 1s ease-in-out infinite}.c-chef.run .egg{animation:cflip 1s ease-in-out infinite}
412@keyframes ctilt{20%,40%{transform:rotate(-12deg)}}@keyframes cflip{30%{transform:translateY(-6px) scaleY(-1)}60%{transform:translateY(0)}}
413.c-codex.run .cu{animation:ccursor 1s steps(1) infinite}
414@keyframes ccursor{50%{opacity:0}}
415@media (prefers-color-scheme: dark){.c-panda .pk{fill:#6B7584}.c-grok .kb,.c-grok .kh{fill:#F8F8F6}.c-grok .ke{fill:#0B0B0B}}
416.c-gemini.run rect[class^="g"]{animation:crainbow 1.4s step-end infinite}.c-gemini .g1{animation-delay:-.2s}.c-gemini .g2{animation-delay:-.4s}.c-gemini .g3{animation-delay:-.6s}.c-gemini .g4{animation-delay:-.8s}.c-gemini .g5{animation-delay:-1s}.c-gemini .g6{animation-delay:-1.2s}
417@keyframes crainbow{0%{fill:#FF3B30}14.29%{fill:#FF9500}28.57%{fill:#FFD60A}42.86%{fill:#34C759}57.14%{fill:#00C7E6}71.43%{fill:#1E6FFF}85.71%{fill:#C03BF5}100%{fill:#FF3B30}}
418@media (prefers-reduced-motion: reduce){.run,.run g,.run rect,.ey{animation:none!important}}
419</style>`
420
421/**
422 * One Clawd as an SVG `<g>` fragment at (x, y). `dim` fades it, `isWalking` adds
423 * the `run` class the CSS keys on, `tint` recolors the body (a finished ticket
424 * sits green). Eyes, sparkles and props nest inside `bd` so they ride the bob;
425 * legs stay outside.
426 */
427export const clawd = (
428  x: number,
429  y: number,
430  costume: Costume,
431  opts: { dim?: boolean; isWalking?: boolean; scale?: number; tint?: string } = {},
432): string => {
433  const groups = new Map<string, string[]>([['bd', []]])
434  const unit = UNITS[costume] ?? UNIT
435  const f: Fill = (cx, cy, w, h, color, cls = 'bd', tone) => {
436    const list = groups.get(cls) ?? []
437    list.push(`<rect${tone === undefined ? '' : ` class="${tone}"`} x="${cx * unit}" y="${cy * unit}" width="${w * unit}" height="${h * unit}" fill="${color}"/>`)
438    groups.set(cls, list)
439  }
440  COSTUMES[costume](f, ACCENT[costume], opts.tint ?? CLAY)
441  const group = (cls: string): string => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
442  const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb' && k !== 'sp')
443  const sparks = (groups.get('sp') ?? []).map((r, i) => `<g class="sp" style="animation-delay:-${i * 0.3}s">${r}</g>`).join('')
444  const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}${sparks}</g>`
445  const scale = opts.scale ?? CLAWD_SCALE
446
447  return `<g transform="translate(${x},${y}) scale(${scale})" opacity="${opts.dim ? 0.45 : 1}" shape-rendering="crispEdges"><g class="c-${costume}${opts.isWalking ? ' run' : ''}">${body}${group('la')}${group('lb')}</g></g>`
448}
449
types/index.d.ts 111 lines
1export type AgentStatus = 'running' | 'done' | 'failed' | 'lost'
2
3/** One subagent of the session, keyed by the agentId the engine answered the spawn with. */
4export type AgentRun = {
5  id: string
6  type: string
7  description: string
8  model: string
9  effort?: string
10  status: AgentStatus
11  startedAt: number
12  endedAt?: number
13  /** The latest step's input side: input + cache read + cache write. */
14  contextTokens: number
15  /** Every step's four counts, summed. */
16  tokens: number
17  steps: number
18  /** What $.agent.list() says of a running row that is not working: parked on a prompt, a message, or not started. */
19  engineStatus?: 'pending' | 'waiting' | 'idle'
20  /** First reconcile that found no list row for it while running. */
21  missingSince?: number
22  /** The turn the row last finished in: a later step from another turn is a resume, one from this turn is late. */
23  turnId?: string
24  /**
25   * Usage steps seen per turn (the newest few turns): a completion of a turn with none seen
26   * takes its figures from its own usage, and the turn is then marked FELL_BACK (-1) so a
27   * repeated completion or a late step of it adds nothing.
28   */
29  turns?: Record<string, number>
30  /** Permission checks on the run's calls that asked or denied (an allow is not counted). */
31  checks?: { asked: number; denied: number }
32  /** The calls a check asked about that have not returned (and the run has not stepped or ended since). */
33  pending?: string[]
34  /** What the card opens to: the task, the newest tool uses, the answer's start, and whether it was cut off. */
35  detail?: AgentDetail
36  /** A subagent's spawn order, which picks its Clawd costume when its type has none of its own. */
37  variant?: number
38  /**
39   * Set on an agent a Bash call started outside the engine (a buddy seat, a Codex run):
40   * the engine never lists it and reports no tokens. A foreground one ends with its call;
41   * a backgrounded one when `doneFile` lands, on its task notification, at the main turn's
42   * end once `taskId` is no longer in flight, or as lost past `deadline`.
43   */
44  external?: { doneFile: string | null; deadline: number | null; taskId?: string }
45}
46
47/** A card's drill-down, clipped: the task (400), up to three tool lines (80 each), the answer's start (400). */
48export type AgentDetail = {
49  task?: string
50  tools?: string[]
51  answer?: string
52  /** A step stopped at the output limit (stop reason max_tokens). */
53  isCutOff?: true
54}
55
56export type Panel = {
57  isCompact: boolean
58  isDoneCollapsed: boolean
59  hasAutoOpened: boolean
60  /** The cards opened to their details, by agent id. */
61  open?: string[]
62}
63
64/** A model request's four token counts and model, as a step or completion reports them. */
65export type EarlyUsage = {
66  input_tokens?: number
67  output_tokens?: number
68  cache_read_input_tokens?: number
69  cache_creation_input_tokens?: number
70  model?: string
71}
72
73/** What arrived for a subagent before its spawn card opened: folded into the card when it does. */
74export type EarlyStep = {
75  tokens: number
76  context: number
77  steps: number
78  model: string
79  /** The turn the held steps ran in (the newest). */
80  turnId?: string
81  /** Held usage steps per turn, folded into the card's own per-turn counts. */
82  turns?: Record<string, number>
83  /** Permission checks that came before the card: counted, and asks still pending. */
84  checks?: { asked: number; denied: number }
85  pending?: string[]
86  /** The held steps' newest tool lines (three at most), and whether one stopped at the output limit. */
87  tools?: string[]
88  isCutOff?: true
89  /** A completion that also came first, applied right after the card opens. */
90  ended?: { reason: string; at: number; turnId: string; usage: EarlyUsage | null; answer?: string }
91}
92
93/** The main loop's model and its context window, written together. */
94export type Session = {
95  model: string
96  window: number | null
97}
98
99declare module 'claude-code' {
100  interface PluginState {
101    'agents-panel': {
102      agents: AgentRun[]
103      panel: Panel
104      now: number
105      session: Session | null
106      lastReconcileAt: number
107      early: Record<string, EarlyStep>
108    }
109  }
110}
111