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

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
+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-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:
| Part | Meaning |
|---|---|
| Clawd costume | the 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% · 82k | the 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 |
196k | every request's four token counts, summed |
0:19 | elapsed since the spawn, to the end for a finished agent; h:mm:ss past an hour |
· waiting · cut off · 2 asked · 1 denied · awaiting approval | after 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 bar | desktop: 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 |
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:
completed is done, anything else failed);<dir>/meta-<model>.txt, whose EXIT: decides done or failed; relay's <out-dir>/result.json);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;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.
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).
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.waiting, idle or pending ends its stats with that word; the next step clears it./clear, /resume, /branch drop finished cards (running ones stay) and let the pane open itself again for the next conversation's first subagent.▸ 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.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.max_tokens) ends its card's stats with · cut off, without opening the 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.hooks/register.tsx 911 lines1import 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}
911hooks/agents.ts 523 lines1import 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}
523hooks/draw.ts 196 lines1import 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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 }
196hooks/launches.ts 214 lines1/**
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}
214hooks/sprite.ts 449 lines1/**
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}
449types/index.d.ts 111 lines1export 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