One animated pixel buddy per subagent: see who runs, what they do, and open their output. Terminal, Desktop and VS Code.

A Claude Code mod that shows your subagents. The Agent den is a pane with one animated pixel buddy for each subagent. You see at a glance which agents run, wait, nap or are done, and which tool each one runs now. Click a buddy to open its output in a second pane. The mod works in the terminal, in Claude Code Desktop and in VS Code.
<img src="../../docs/images/agent-den-panel.svg" alt="The Agent den pane: a card for each subagent with its buddy, task, agent type, current tool, status and elapsed time" width="880">
Spec and design: docs/agent-den-PRD.md.
● 2 busy ◐ 1 waiting ✓ 3 done.zs (napping), a check badge (done), a rain cloud (oops) and a grey ghost (stopped). Each status change plays a one-shot: the buddy hatches, jumps with confetti, shakes its head, or fades out before a finished card leaves. Desktop and VS Code draw SVG; the terminal draws three-row ASCII frames.output › on a card (or press its number while the den has the keyboard) to see the agent's tool calls, the first lines of each result and the text it wrote. full transcript opens the agent's own transcript in the main view.hide button, Esc, or /agent-den hide. A hidden den stays hidden until you open it again.🐾 2 busy · 1 napping · 3 done.<img src="../../docs/images/agent-den.svg" alt="All ten buddies busy, and one buddy for each status: hatching, busy, waiting, napping, done, oops and stopped" width="880">
The look follows cache-thermo: calm pastels on a dark panel, round dim borders, pixel art on the desktop and one-cell ASCII in the terminal:
_/=o=\_ /\/\/\/\ .-(~~)-. (o)-(o)
(。◕‿◕。) ( ◕_◕ ) (。-‿-。) z (。ᵔ‿ᵔ。)
o(_ _)o o(_ _)o o(_ _)o o(___)o
busy waiting napping done
At the prompt of a terminal session:
/plugin install agent-den --marketplace stilero/mods-marketplace
From a shell: claude plugin install agent-den --marketplace stilero/mods-marketplace. In Claude Code Desktop: + next to the prompt → Plugins → Add marketplace → Add plugin.
| Command | Effect |
|---|---|
/agent-den | Show the den, or hide it when it shows |
/agent-den show / hide | Show or hide the den |
/agent-den log <n> | Open the output of the agent at position n in the den (1 is the first card) |
/agent-den log <name> | The same, by the agent's name, the buddy's name, or a word of the description |
/agent-den clear | Remove the finished buddies and the demo buddies now |
/agent-den demo | Show seven demo buddies, one for each status |
Keys in the den: 1-9 open the output of a card, x hides the den, d starts the demo in an empty den. Keys in the output pane: b goes back to the den, t opens the full transcript, x closes the pane.
/config rows)| Field | Default | Purpose |
|---|---|---|
| Open automatically | on | Open the den when the first subagent of a conversation starts |
| Keep finished buddies (minutes) | 10 | A finished buddy stays this long, then waves goodbye (0.5 to 240) |
| Animations | full | calm drops the bounce and the confetti and slows the loops; off is static |
| Buddy map | empty | Pin agent types to buddies, for example Explore=digby, my-agent=moss |
| Max log lines | 300 | Lines kept for each agent's output (20 to 5000) |
$.agent.list() each second for the status, the type and the description of each agent. Ended agents drop out of that list some seconds later, so the mod keeps its own record in $.state.turn.start, tool.call and turn.complete with an agentId give the live changes: busy, the current tool, a log line for each tool call, and the end of a turn.tool.call hook leaves out Agent, Task, Workflow and Skill. A hook that wraps the call that starts an agent does not hear that agent's own events.$.session.messages({ agentId }) for the output pane: the selected agent each second, the other live agents each 3 seconds, at most two reads a tick.workflow and have no full transcript button. A workflow agent gets its buddy from the brief at the start of its turn. It ends on turn.complete, or when no event reached it for keepFinishedMinutes and no tool call of it runs./clear starts a new conversation with new state, so a den that you hid can open again for the next subagent./agent-den shows the den at any width.Skill call gets no live log line. The next read of its messages adds it.claude plugin validate plugins/agent-den
claude plugin test plugins/agent-den
node plugins/agent-den/scripts/build-preview.mjs # writes scripts/preview-art.html: every buddy, status and transition
Type-check with a tsconfig that extends .claude-plugin/types/tsconfig.json (the engine lays that folder when a session loads the mod): npx -y -p typescript@5.6 tsc -p plugins/agent-den.
Run claude --plugin-dir plugins/agent-den to try it in one terminal session, then ask Claude to start a few Explore agents in parallel. /agent-den demo shows every status at once.
hooks/register.tsx 1001 lines1// agent-den: one animated pixel buddy per subagent, in a pane beside the chat, and a second pane with
2// the output of the buddy you click. The rules that shape this file (docs/agent-den-PRD.md, 3 and 11):
3// - Never hook agent.spawn or the main loop's turn.step, and keep the spawn tools out of the tool.call
4// matcher: a hook whose frame an agent's spawn ran in does not hear that agent's events.
5// - Render hooks only read atoms. Every write is in a hook, the ticker or a press handler.
6// - Every read-merge-write of agents/order/logs runs in serial(). No next(e) and no slow $ op inside it.
7// - State values are never undefined: tracker.ts leaves optional fields out instead.
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import type { AgentRow, LogLine } from '../types'
12import { ASCII_W, EMPTY_DEN_ASCII, SPINNER, buddyAscii, buddySvg, emptyDenSvg, transitionFor } from './art'
13import { parseBuddyMap, pickBuddy, resolveBuddy } from './buddies'
14import type { ResolvedBuddy } from './buddies'
15import { readOptions } from './options'
16import { forgetAgent, resetRuntime, rt, serial } from './runtime'
17import { CARD_HOVER_BG, STATUS_DOT, STATUS_LABEL, STATUS_ORDER, THEME, statusColor } from './theme'
18import {
19 DEMO_PREFIX,
20 animKey,
21 asciiFrameFor,
22 cleanText,
23 closeEffect,
24 currentFromMessages,
25 demoState,
26 dropEnded,
27 elapsedOf,
28 ensureRow,
29 expire,
30 firstLine,
31 formatElapsed,
32 isEnded,
33 isLive,
34 messagesToLog,
35 onToolStart,
36 onTurnComplete,
37 onTurnStart,
38 resultLines,
39 sameLog,
40 summary,
41 toolLabel,
42 treeOrder,
43 upsertListed,
44} from './tracker'
45import type { ListedAgent, PickFn, RowHint } from './tracker'
46
47const BUILD = '0.1.0'
48const PANE = 'agent-den'
49const LOG_PANE = 'agent-den-log'
50const COMMAND = 'agent-den'
51const USAGE = 'Usage: /agent-den [show|hide|log <n|name>|clear|demo]'
52
53const agentsAtom = atom({ plugin: 'agent-den', key: 'agents' } as const, {})
54const orderAtom = atom({ plugin: 'agent-den', key: 'order' } as const, [])
55const logsAtom = atom({ plugin: 'agent-den', key: 'logs' } as const, {})
56const selectedIdAtom = atom({ plugin: 'agent-den', key: 'selectedId' } as const, null)
57const isDenHiddenAtom = atom({ plugin: 'agent-den', key: 'isDenHidden' } as const, false)
58const hasAutoOpenedAtom = atom({ plugin: 'agent-den', key: 'hasAutoOpened' } as const, false)
59const frameAtom = atom({ plugin: 'agent-den', key: 'frame' } as const, 0)
60const nowAtom = atom({ plugin: 'agent-den', key: 'now' } as const, 0)
61
62const debug = ($: EngineInterface, text: string): void => $.ui.log(`agent-den: ${text}`, { to: 'debug' })
63
64const keepMs = (): number => rt.opts.keepFinishedMinutes * 60_000
65
66/** The fields of a list entry that a new row takes, with no undefined values. */
67function listedHint(a: ListedAgent): RowHint {
68 return {
69 type: a.type,
70 description: a.description,
71 ...(a.name !== undefined ? { name: a.name } : {}),
72 ...(a.parentId !== undefined ? { parentId: a.parentId } : {}),
73 }
74}
75
76/** What a hook knows of an agent id: a row of ours, a list entry, or neither. */
77type Lookup = { kind: 'row' } | { kind: 'listed'; hint: RowHint } | { kind: 'unlisted' }
78
79/**
80 * Looks up an id that a hook event carries. An id the mod does not know yet is looked up in
81 * $.agent.list() first (outside serial): the subagent's first event usually comes before the next poll.
82 * An id that no list names is a workflow agent or one of the engine's own forks (compaction, memory).
83 */
84async function lookUp($: EngineInterface, id: string): Promise<Lookup> {
85 if ((await read($, agentsAtom))[id] !== undefined) return { kind: 'row' }
86 try {
87 const listed = (await $.agent.list()).find(a => a.id === id)
88 if (listed !== undefined) return { kind: 'listed', hint: listedHint(listed) }
89 } catch (err) {
90 debug($, `agent.list failed: ${String(err)}`)
91 }
92 return { kind: 'unlisted' }
93}
94
95/**
96 * The last hook event (turn.start, tool.call, turn.complete) of each row, and the tool calls each row has
97 * in flight. Module state like rt: after a hot reload a row counts from statusSince again.
98 */
99const heardAt: Record<string, number> = {}
100const toolsInFlight: Record<string, number> = {}
101
102/** Forgets a removed row: its log reads (forgetAgent) and its last event. */
103function forgetRow(id: string): void {
104 forgetAgent(id)
105 delete heardAt[id]
106}
107
108/**
109 * The live unlisted rows (not demo rows) that no hook event reached for keepFinishedMinutes and that have no
110 * tool call in flight. No list ends such a row, so a missed turn.complete would keep it busy for good.
111 */
112function staleUnlistedIds(agents: Readonly<Record<string, AgentRow>>, now: number): string[] {
113 return Object.values(agents)
114 .filter(r => !r.isListed && isLive(r.status) && !r.id.startsWith(DEMO_PREFIX) && (toolsInFlight[r.id] ?? 0) === 0)
115 .filter(r => now - Math.max(heardAt[r.id] ?? -Infinity, r.statusSince) >= keepMs())
116 .map(r => r.id)
117}
118
119/** A stale unlisted row ends as completed, with no tool on its card. */
120function endStale(row: AgentRow, now: number): AgentRow {
121 const { currentTool: _gone, ...rest } = row
122 return { ...rest, status: 'completed', statusSince: now, endedAt: now }
123}
124
125/** Appends lines to one agent's log, capped at maxLogLines. Call it inside serial(). */
126async function appendLog($: EngineInterface, id: string, lines: readonly LogLine[]): Promise<void> {
127 if (lines.length === 0) return
128 await update($, logsAtom, logs => ({ ...logs, [id]: [...(logs[id] ?? []), ...lines].slice(-rt.opts.maxLogLines) }))
129}
130
131/** The pane title of an agent's log: no control characters, no tabs (a title with one is refused). */
132function logTitle(row: AgentRow): string {
133 const name = resolveBuddy(row.buddy).displayName
134 const desc = firstLine(row.description, 40)
135 return cleanText(desc === '' ? name : `${name} · ${desc}`).replace(/\s+/g, ' ')
136}
137
138/** Every open of the den goes through here: each open sets the options anew, so all of them pass closeOnEscape and rows. */
139async function openDen($: EngineInterface): Promise<void> {
140 try {
141 const count = (await read($, orderAtom)).length
142 const opened = await $.ui.open({ id: PANE, title: 'Agent den', closeOnEscape: true, rows: Math.min(4 + 4 * Math.max(1, count), 22) })
143 // An open the person did not ask for waits undrawn on a narrow terminal; /agent-den shows it.
144 if (!opened.isPlaced) debug($, `the den waits undrawn: ${opened.reason}`)
145 } catch (err) {
146 debug($, `opening the den failed: ${String(err)}`)
147 }
148}
149
150/** Selects an agent and opens its log pane. Only a press or a command calls this, so the pane takes the keyboard. */
151async function openLog($: EngineInterface, id: string): Promise<void> {
152 await update($, selectedIdAtom, () => id)
153 const row = (await read($, agentsAtom))[id]
154 try {
155 const opened = await $.ui.open({
156 id: LOG_PANE,
157 title: row === undefined ? 'Agent log' : logTitle(row),
158 focus: true,
159 closeOnEscape: true,
160 rows: 18,
161 })
162 if (!opened.isPlaced) debug($, `the log pane waits undrawn: ${opened.reason}`)
163 } catch (err) {
164 debug($, `opening the log pane failed: ${String(err)}`)
165 }
166}
167
168/** Closes a pane of ours; a missing pane or a refusal is only logged. */
169async function closePane($: EngineInterface, id: string): Promise<void> {
170 try {
171 await $.ui.close({ id })
172 } catch (err) {
173 debug($, `closing ${id} failed: ${String(err)}`)
174 }
175}
176
177/** Opens the den one time per conversation, on the first agent, unless the person hid it or turned autoOpen off. */
178async function autoOpen($: EngineInterface): Promise<void> {
179 if (!rt.opts.autoOpen) return
180 // Check and set in serial(): the poll and a hook can find a new agent at the same time.
181 // Set before the open: an open that waits undrawn still counts, the engine draws it when the terminal grows.
182 const isFirst = await serial(async () => {
183 if ((await read($, isDenHiddenAtom)) || (await read($, hasAutoOpenedAtom))) return false
184 await update($, hasAutoOpenedAtom, () => true)
185 return true
186 })
187 if (isFirst) await openDen($)
188}
189
190/** The rows and order without `ids`. */
191function withoutIds(
192 agents: Readonly<Record<string, AgentRow>>,
193 order: readonly string[],
194 ids: readonly string[],
195): { agents: Record<string, AgentRow>; order: string[] } {
196 return {
197 agents: Object.fromEntries(Object.entries(agents).filter(([id]) => !ids.includes(id))),
198 order: order.filter(id => !ids.includes(id)),
199 }
200}
201
202/** The ids of the demo rows (/agent-den demo). */
203const demoIds = (agents: Readonly<Record<string, AgentRow>>): string[] => Object.keys(agents).filter(id => id.startsWith(DEMO_PREFIX))
204
205/**
206 * The live demo rows that are keepFinishedMinutes old, except `keepId` (the selected row). A live demo row
207 * has no list entry and no turn.complete, so without this it would hold its buddy and the status line for good.
208 */
209function staleDemoIds(agents: Readonly<Record<string, AgentRow>>, now: number, keepId: string | null): string[] {
210 return demoIds(agents).filter(id => {
211 const row = agents[id]
212 return row !== undefined && isLive(row.status) && id !== keepId && now - row.statusSince >= keepMs()
213 })
214}
215
216/** Drops the ended rows, every demo row and their logs (`/agent-den clear`, a /clear or a resume). Gives the count. */
217async function dropEndedRows($: EngineInterface): Promise<number> {
218 return serial(async () => {
219 const agents = await read($, agentsAtom)
220 const demo = demoIds(agents)
221 const kept = withoutIds(agents, await read($, orderAtom), demo)
222 const r = dropEnded(kept.agents, kept.order, await read($, logsAtom))
223 const removed = [...demo, ...r.removed]
224 if (removed.length === 0) return 0
225 await update($, agentsAtom, () => r.agents)
226 await update($, orderAtom, () => r.order)
227 await update($, logsAtom, () => Object.fromEntries(Object.entries(r.logs).filter(([id]) => !demo.includes(id))))
228 for (const id of removed) forgetRow(id)
229 const selected = await read($, selectedIdAtom)
230 if (selected !== null && removed.includes(selected)) await update($, selectedIdAtom, () => null)
231 return removed.length
232 })
233}
234
235/** Picks a buddy for a new row with the person's buddyMap (rt.buddyMap is set in register). */
236const pick: PickFn = (type, taken) => pickBuddy(type, taken, rt.buddyMap)
237
238/** Makes the row when it is new (ensureRow), changes it with `change` and writes it. Call it inside serial(). Gives true for a new row. */
239async function changeRow(
240 $: EngineInterface,
241 id: string,
242 now: number,
243 hint: RowHint,
244 change: (row: AgentRow) => AgentRow,
245): Promise<boolean> {
246 const made = ensureRow(await read($, agentsAtom), await read($, orderAtom), id, now, pick, hint)
247 const row = made.agents[id]
248 if (row === undefined) return false
249 // An ended agent can wake up under the same id: read its log again from the start.
250 if (isEnded(row.status)) forgetAgent(id)
251 const changed = change(row)
252 await update($, agentsAtom, () => ({ ...made.agents, [id]: changed }))
253 if (made.added) await update($, orderAtom, () => made.order)
254 return made.added
255}
256
257/** One $.agent.list() read: new rows, status merges, dropped rows, expiry, and the auto-open. */
258async function poll($: EngineInterface): Promise<void> {
259 let list: readonly ListedAgent[] | null = null
260 try {
261 list = await $.agent.list()
262 } catch (err) {
263 // Keep the rows as they are; expiry still runs.
264 debug($, `agent.list failed: ${String(err)}`)
265 }
266 const now = await $.clock.now()
267 const added = await serial(async () => {
268 const agents = await read($, agentsAtom)
269 const order = await read($, orderAtom)
270 const up = list === null ? { agents, order, changed: false, added: [] as string[] } : upsertListed(agents, order, list, now, pick)
271 const selectedId = await read($, selectedIdAtom)
272 const ended = expire(up.agents, up.order, now, keepMs(), selectedId)
273 // A live demo row goes after keepFinishedMinutes, like the demo's ended rows.
274 const demo = staleDemoIds(ended.agents, now, selectedId)
275 const ex = demo.length === 0 ? ended : { ...withoutIds(ended.agents, ended.order, demo), removed: [...ended.removed, ...demo] }
276 // A workflow row that missed its turn.complete ends here; expiry removes it keepFinishedMinutes later.
277 const stale = staleUnlistedIds(ex.agents, now)
278 const rows: Record<string, AgentRow> = stale.length === 0 ? ex.agents : { ...ex.agents }
279 for (const id of stale) {
280 const row = rows[id]
281 if (row !== undefined) rows[id] = endStale(row, now)
282 }
283 for (const [id, row] of Object.entries(rows)) {
284 const was = agents[id]
285 if (was !== undefined && isEnded(was.status) && isLive(row.status)) forgetAgent(id)
286 }
287 if (up.changed || ex.removed.length > 0 || stale.length > 0) await update($, agentsAtom, () => rows)
288 if (up.added.length > 0 || ex.removed.length > 0) await update($, orderAtom, () => ex.order)
289 if (ex.removed.length > 0) {
290 const removed = ex.removed
291 await update($, logsAtom, logs => Object.fromEntries(Object.entries(logs).filter(([id]) => !removed.includes(id))))
292 for (const id of removed) forgetRow(id)
293 }
294 return up.added
295 })
296 if (added.length > 0) await autoOpen($)
297}
298
299/** One read of an agent's messages. A deny or an empty answer keeps the log the mod has. */
300async function readLog($: EngineInterface, id: string, now: number): Promise<void> {
301 rt.logReadAt[id] = now
302 // A tool.call that starts while the read is out is newer than the transcript the read gives.
303 const toolsBefore = (await read($, agentsAtom))[id]?.toolCount ?? 0
304 const msgs = await $.session.messages({ agentId: id }).catch((err: unknown) => {
305 debug($, `session.messages failed for ${id}: ${String(err)}`)
306 return null
307 })
308 if (msgs === null) return
309 if (!Array.isArray(msgs)) {
310 rt.deniedUntil[id] = now + 10_000
311 return
312 }
313 if (msgs.length === 0) return
314 const lines = messagesToLog(msgs, rt.opts.maxLogLines, now)
315 const cur = currentFromMessages(msgs)
316 await serial(async () => {
317 if (!sameLog((await read($, logsAtom))[id] ?? [], lines)) await update($, logsAtom, logs => ({ ...logs, [id]: lines }))
318 const row = (await read($, agentsAtom))[id]
319 if (row === undefined) return
320 const toolCount = Math.max(row.toolCount, cur.toolCount)
321 // The transcript says which tool is in flight: none means the agent thinks or writes text.
322 const currentTool = row.toolCount > toolsBefore ? row.currentTool : cur.currentTool
323 const lastText = cur.lastText ?? row.lastText
324 if (toolCount === row.toolCount && currentTool === row.currentTool && lastText === row.lastText) return
325 const { currentTool: _old, ...rest } = row
326 const merged: AgentRow = {
327 ...rest,
328 toolCount,
329 ...(currentTool !== undefined ? { currentTool } : {}),
330 ...(lastText !== undefined ? { lastText } : {}),
331 }
332 await update($, agentsAtom, agents => ({ ...agents, [id]: merged }))
333 })
334}
335
336/**
337 * At most 2 session.messages calls a tick: the selected live agent (each 1 s), the ended agents one
338 * time after they end, then the other live agents round-robin (each 3 s). Demo and workflow rows are
339 * never read: a workflow agent's messages are always denied.
340 */
341async function refreshLogs($: EngineInterface, now: number): Promise<void> {
342 const agents = await read($, agentsAtom)
343 const order = await read($, orderAtom)
344 const selectedId = await read($, selectedIdAtom)
345 const canRead = (id: string): boolean => {
346 const row = agents[id]
347 return row !== undefined && !id.startsWith(DEMO_PREFIX) && row.type !== 'workflow' && !((rt.deniedUntil[id] ?? 0) > now)
348 }
349 const due: string[] = []
350 const lastRead = (id: string): number => rt.logReadAt[id] ?? -Infinity
351 if (selectedId !== null && canRead(selectedId)) {
352 const row = agents[selectedId]
353 if (row !== undefined && isLive(row.status) && now - lastRead(selectedId) >= 1000) due.push(selectedId)
354 }
355 for (const id of order) {
356 if (due.length >= 2) break
357 const row = agents[id]
358 if (row !== undefined && isEnded(row.status) && rt.finalRead[id] !== true && canRead(id) && !due.includes(id)) due.push(id)
359 }
360 const others = order.filter(id => id !== selectedId && canRead(id) && isLive(agents[id]?.status ?? 'completed'))
361 for (let i = 0; i < others.length && due.length < 2; i++) {
362 const at = (rt.logCursor + i) % others.length
363 const id = others[at]
364 if (id === undefined || due.includes(id) || now - lastRead(id) < 3000) continue
365 due.push(id)
366 rt.logCursor = at + 1
367 }
368 for (const id of due) {
369 const row = agents[id]
370 await readLog($, id, now)
371 // One read after the end is enough, whatever it answered: a finished agent can deny for good.
372 if (row !== undefined && isEnded(row.status)) rt.finalRead[id] = true
373 }
374}
375
376/** Every 500 ms: poll every second tick, refresh the logs, the status line, and now/frame only when the drawing would change. */
377async function tick($: EngineInterface): Promise<void> {
378 if (rt.isTicking) return
379 rt.isTicking = true
380 try {
381 rt.ticks += 1
382 if (rt.ticks % 2 === 1) await poll($)
383 const now = await $.clock.now()
384 await refreshLogs($, now)
385 const agents = await read($, agentsAtom)
386 const text = summary(agents)
387 if (text !== rt.lastStatusText) {
388 rt.lastStatusText = text
389 $.ui.status(text)
390 }
391 const panes = await $.ui.panes()
392 if (!panes.some(p => p.id === PANE || p.id === LOG_PANE)) return
393 // Idle costs nothing: write only when the next frame or the clock changes what a pane draws.
394 const frame = await read($, frameAtom)
395 const o = { animations: rt.opts.animations, keepFinishedMs: keepMs() }
396 const still = animKey(agents, now, frame, o)
397 const moved = animKey(agents, now, frame + 1, o)
398 // `calm` draws a new ASCII frame every second frame: look two frames ahead, or the frame never leaves 0.
399 const isMoving = moved !== still || (rt.opts.animations === 'calm' && animKey(agents, now, frame + 2, o) !== still)
400 if (isMoving) {
401 await update($, frameAtom, () => frame + 1)
402 await update($, nowAtom, () => now)
403 rt.lastKey = moved
404 } else if (still !== rt.lastKey) {
405 await update($, nowAtom, () => now)
406 rt.lastKey = still
407 }
408 } finally {
409 rt.isTicking = false
410 }
411}
412
413/** Puts seven demo buddies in the den, one for each status, and shows it. */
414async function runDemo($: EngineInterface): Promise<void> {
415 const demo = demoState(await $.clock.now())
416 await serial(async () => {
417 await update($, agentsAtom, agents => ({ ...agents, ...demo.agents }))
418 await update($, orderAtom, order => [...order, ...demo.order.filter(id => !order.includes(id))])
419 await update($, logsAtom, logs => ({ ...logs, ...demo.logs }))
420 })
421 await update($, isDenHiddenAtom, () => false)
422 await openDen($)
423}
424
425export const register: Register = (on, options) => {
426 rt.opts = readOptions(options)
427 rt.buddyMap = parseBuddyMap(rt.opts.buddyMap)
428
429 on('session.start', async ($, e, next) => {
430 // Also runs after a hot reload: the state survives, the timers do not.
431 resetRuntime()
432 try {
433 await $.command.register({
434 name: COMMAND,
435 description: 'Agent den: show, hide, log <n>, clear, demo',
436 argumentHint: '[show|hide|log <n>|clear|demo]',
437 })
438 } catch (err) {
439 debug($, `registering /${COMMAND} failed: ${String(err)}`)
440 }
441 rt.timer?.cancel()
442 rt.timer = $.clock.every(500, () => {
443 void tick($).catch(err => debug($, `tick failed: ${String(err)}`))
444 })
445 try {
446 await poll($)
447 } catch (err) {
448 debug($, `the first poll failed: ${String(err)}`)
449 }
450 $.ui.log(`agent-den v${BUILD} ready`, { to: 'debug' })
451 return next(e)
452 }).catch(($, e, next) => next(e))
453
454 on('turn.start', async ($, e, next) => {
455 const id = e.agentId
456 if (id === undefined) return next(e)
457 const r = await next(e)
458 try {
459 const now = await $.clock.now()
460 const found = await lookUp($, id)
461 // In a subagent, e.text is the brief: the description of a workflow row and the first log line.
462 // An unlisted id with no brief makes no row: a workflow agent's run starts with one, and the engine's
463 // forks (compaction, memory) are no subagents. No event tells a fork with a prompt from a workflow
464 // agent; if such a row misses its turn.complete, poll() ends it (staleUnlistedIds).
465 const hint: RowHint | null =
466 found.kind === 'listed' ? found.hint : found.kind === 'row' || e.text.trim() !== '' ? { description: firstLine(e.text, 60) } : null
467 const added = await serial(async () => {
468 if (hint === null && (await read($, agentsAtom))[id] === undefined) return false
469 const isNew = await changeRow($, id, now, hint ?? {}, row => onTurnStart(row, now))
470 heardAt[id] = now
471 if (((await read($, logsAtom))[id] ?? []).length === 0 && e.text.trim() !== '') {
472 await appendLog($, id, [{ at: now, kind: 'status', text: `brief: ${firstLine(e.text, 200)}` }])
473 }
474 return isNew
475 })
476 if (added) await autoOpen($)
477 } catch (err) {
478 debug($, `turn.start for ${id} failed: ${String(err)}`)
479 }
480 return r
481 }).catch(($, e, next) => next(e))
482
483 // Agent, Task, Workflow and Skill can start agents: a hook that wraps them would not hear those agents.
484 on('tool.call', { tool: /^(?!(Agent|Task|Workflow|Skill)$)/ }, async ($, e, next) => {
485 const id = e.agentId
486 if (id === undefined) return next(e)
487 // Set only when the row took this call: the end of the call removes it from the card again.
488 let started: string | undefined
489 try {
490 const now = await $.clock.now()
491 // The tool's arguments sit on e itself; toolLabel reads only named keys for each tool.
492 const label = toolLabel(String(e.tool), e as unknown as Readonly<Record<string, unknown>>)
493 // Only a known or listed id gets a row here. An unlisted id is a workflow agent, whose turn.start
494 // makes its row from the brief, or an engine fork (compaction, memory), which is no subagent.
495 const found = await lookUp($, id)
496 const hint = found.kind === 'listed' ? found.hint : found.kind === 'row' ? {} : null
497 const added = await serial(async () => {
498 if (hint === null && (await read($, agentsAtom))[id] === undefined) return null
499 const isNew = await changeRow($, id, now, hint ?? {}, row => onToolStart(row, label, now))
500 heardAt[id] = now
501 await appendLog($, id, [{ at: now, kind: 'tool', text: `▸ ${label}` }])
502 return isNew
503 })
504 if (added !== null) {
505 started = label
506 // A stale-row check never ends a row while one of its calls runs (a long Bash, a held permission).
507 toolsInFlight[id] = (toolsInFlight[id] ?? 0) + 1
508 }
509 if (added === true) await autoOpen($)
510 } catch (err) {
511 debug($, `tool.call for ${id} failed: ${String(err)}`)
512 }
513 // Outside serial: a held tool call must never block the other writers.
514 let r: Awaited<ReturnType<typeof next>>
515 try {
516 r = await next(e)
517 } finally {
518 if (started !== undefined) {
519 const n = (toolsInFlight[id] ?? 1) - 1
520 if (n > 0) toolsInFlight[id] = n
521 else delete toolsInFlight[id]
522 }
523 }
524 // A call that no row took has no log.
525 if (started === undefined) return r
526 try {
527 const now = await $.clock.now()
528 // The same lines messagesToLog makes, so a later refresh gives no jump.
529 const lines =
530 r.deny !== undefined ? resultLines(r.deny, 'error', now) : resultLines(r.text ?? '', r.isError === true ? 'error' : 'result', now)
531 await serial(async () => {
532 // The row can be gone (clear, expiry): then its log is gone too.
533 const row = (await read($, agentsAtom))[id]
534 if (row === undefined) return
535 heardAt[id] = now
536 await appendLog($, id, lines)
537 // The tool is done: the card stops showing it, unless a later call of the agent took its place.
538 if (row.currentTool !== started) return
539 const { currentTool: _done, ...rest } = row
540 await update($, agentsAtom, agents => ({ ...agents, [id]: rest }))
541 })
542 } catch (err) {
543 debug($, `logging a result for ${id} failed: ${String(err)}`)
544 }
545 return r
546 }).catch(($, e, next) => next(e))
547
548 on('turn.complete', async ($, e, next) => {
549 const id = e.agentId
550 if (id === undefined) return next(e)
551 try {
552 const now = await $.clock.now()
553 // Cleaned like every engine string a pane draws: a control character makes a desktop refuse the drawing.
554 const model = firstLine(e.usage?.model ?? '', 60)
555 await serial(async () => {
556 const row = (await read($, agentsAtom))[id]
557 if (row === undefined) return
558 heardAt[id] = now
559 const done = onTurnComplete(row, e.reason, e.answer, now)
560 const ended = model !== '' ? { ...done, model } : done
561 await update($, agentsAtom, agents => ({ ...agents, [id]: ended }))
562 })
563 } catch (err) {
564 debug($, `turn.complete for ${id} failed: ${String(err)}`)
565 }
566 // Never a text of our own: it would show under the answer.
567 return next(e)
568 }).catch(($, e, next) => next(e))
569
570 // Esc or the engine closing a pane. A Button's own $.ui.close does not reach this hook, so each press writes its own state.
571 on('ui.close', async ($, e, next) => {
572 const effect = closeEffect(e.id, e.origin.kind, { den: PANE, log: LOG_PANE })
573 if (effect.hideDen === true) await update($, isDenHiddenAtom, () => true)
574 if (effect.clearSelected === true) await update($, selectedIdAtom, () => null)
575 return next(e)
576 }).catch(($, e, next) => next(e))
577
578 on('session.end', async ($, e, next) => {
579 // A /clear or a resume goes on in this process under another conversation.
580 if (e.reason === 'clear' || e.reason === 'resume') {
581 try {
582 await dropEndedRows($)
583 } catch (err) {
584 debug($, `dropping the ended rows failed: ${String(err)}`)
585 }
586 }
587 return next(e)
588 }).catch(($, e, next) => next(e))
589
590 on('command.run', async ($, e, next) => {
591 const name = String(e.command)
592 if (name !== COMMAND && !name.endsWith(`:${COMMAND}`)) return next(e)
593 // Keep the case of the rest: `log <name>` matches names as typed.
594 const [head = '', ...rest] = String(e.args ?? '').trim().split(/\s+/)
595 switch (head.toLowerCase()) {
596 case '': {
597 // Close only a den the person can see: one that waits undrawn is opened (and so drawn) instead.
598 const isShown = (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)
599 if (isShown) {
600 await update($, isDenHiddenAtom, () => true)
601 await closePane($, PANE)
602 return { text: 'Agent den hidden. /agent-den shows it again.' }
603 }
604 await update($, isDenHiddenAtom, () => false)
605 await openDen($)
606 return { text: 'Agent den shown.' }
607 }
608 case 'show':
609 await update($, isDenHiddenAtom, () => false)
610 await openDen($)
611 return { text: 'Agent den shown.' }
612 case 'hide':
613 await update($, isDenHiddenAtom, () => true)
614 await closePane($, PANE)
615 return { text: 'Agent den hidden. /agent-den show brings it back.' }
616 case 'log': {
617 const target = rest.join(' ')
618 if (target === '') return { text: 'Usage: /agent-den log <n|name>' }
619 const agents = await read($, agentsAtom)
620 const tree = treeOrder(agents, await read($, orderAtom))
621 let id: string | undefined
622 if (/^\d+$/.test(target)) {
623 id = tree[Number(target) - 1]?.id
624 } else {
625 const t = target.toLowerCase()
626 const named = (row: AgentRow): boolean => row.name?.toLowerCase() === t || resolveBuddy(row.buddy).displayName.toLowerCase() === t
627 id =
628 tree.find(n => agents[n.id] !== undefined && named(agents[n.id] as AgentRow))?.id ??
629 tree.find(n => agents[n.id]?.description.toLowerCase().includes(t) === true)?.id
630 }
631 const row = id === undefined ? undefined : agents[id]
632 if (id === undefined || row === undefined) return { text: `agent-den: no buddy matches "${target}".` }
633 await openLog($, id)
634 return { text: `Showing the output of ${resolveBuddy(row.buddy).displayName}.` }
635 }
636 case 'clear': {
637 const n = await dropEndedRows($)
638 return { text: n === 0 ? 'No finished or demo buddies to clear.' : `Cleared ${n} ${n === 1 ? 'buddy' : 'buddies'}.` }
639 }
640 case 'demo':
641 await runDemo($)
642 return { text: 'Seven demo buddies, one for each status. /agent-den clear removes them.' }
643 default:
644 return { text: USAGE }
645 }
646 }).catch(($, e, next) => (next.called ? next(e) : { text: `agent-den: the command failed: ${next.error.message ?? next.error.kind}` }))
647
648 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
649 const agents = await read($, agentsAtom)
650 const order = await read($, orderAtom)
651 const frame = await read($, frameAtom)
652 const now = (await read($, nowAtom)) || (await $.clock.now())
653 const animations = rt.opts.animations
654 const keep = keepMs()
655 const cards = treeOrder(agents, order).flatMap(({ id, depth }) => {
656 const row = agents[id]
657 return row === undefined ? [] : [{ id, depth, row, b: resolveBuddy(row.buddy) }]
658 })
659 const counts = STATUS_ORDER.map(s => ({ s, n: cards.filter(c => c.row.status === s).length })).filter(c => c.n > 0)
660
661 const onHide = async (): Promise<void> => {
662 // Our own $.ui.close does not run our ui.close hook: write the state here.
663 await update($, isDenHiddenAtom, () => true)
664 await closePane($, PANE)
665 }
666 const statusLine = (row: AgentRow): string =>
667 `${STATUS_DOT[row.status]} ${STATUS_LABEL[row.status]} · ${formatElapsed(elapsedOf(row, now))} · ${row.type}`
668 const activity = (row: AgentRow): string =>
669 row.status === 'running'
670 ? `${SPINNER[frame % SPINNER.length] ?? ''} ${row.currentTool ?? 'thinking…'}`
671 : (row.lastText ?? '')
672 const spriteColor = (row: AgentRow, b: ResolvedBuddy): string =>
673 row.status === 'failed' || row.status === 'killed' ? statusColor(row.status) : b.accent
674
675 const els = $.ui.resolve(e)
676 const { Box, Text, Button } = els
677 const header = (
678 <Box gap={2} justifyContent="space-between">
679 <Box gap={2} flexWrap="wrap" flexShrink={1}>
680 <Text weight="bold" color={THEME.accent}>
681 Agent den
682 </Text>
683 {counts.map(c => (
684 <Text color={statusColor(c.s)}>{`${STATUS_DOT[c.s]} ${c.n} ${STATUS_LABEL[c.s]}`}</Text>
685 ))}
686 </Box>
687 <Button key="hide" hotkey="x" role="dismiss" plain dimColor onPress={() => void onHide()}>
688 hide
689 </Button>
690 </Box>
691 )
692 const emptyText = 'No buddies out yet. They show up when Claude starts a subagent.'
693 const demoButton = (
694 <Button key="demo" hotkey="d" plain dimColor onPress={() => void runDemo($)}>
695 demo
696 </Button>
697 )
698 const hotkey = (i: number): string | undefined => (i < 9 ? String(i + 1) : undefined)
699 // About 34 columns a card, never wider than the pane leaves (paddingX and the child indent).
700 const cardWidth = (depth: number): number => Math.max(20, Math.min(34, e.props.bodyColumns - 2 - 2 * depth))
701
702 if (e.surface !== 'terminal' && 'Svg' in els) {
703 const { Svg } = els
704 if (cards.length === 0) {
705 return (
706 <Box flexDirection="column" paddingX={1} gap={1}>
707 {header}
708 <Box gap={2} alignItems="center">
709 <Svg source={emptyDenSvg(96)} alt="An empty den: the buddies are asleep" width={96} height={96} isInteractive />
710 <Box flexDirection="column" flexShrink={1} gap={1}>
711 <Text color={THEME.dim}>{emptyText}</Text>
712 <Box>{demoButton}</Box>
713 </Box>
714 </Box>
715 </Box>
716 )
717 }
718 return (
719 <Box flexDirection="column" paddingX={1} gap={1}>
720 {header}
721 <Box flexWrap="wrap" gap={1}>
722 {cards.map(({ id, depth, row, b }, i) => {
723 const transition = transitionFor(row, now, keep)
724 const isLeaving = transition === 'leave'
725 return (
726 <Box
727 key={`card-${id}`}
728 width={cardWidth(depth)}
729 marginLeft={2 * depth}
730 borderStyle="round"
731 borderColor={b.accent}
732 borderDimColor
733 hover={{ borderColor: statusColor(row.status), backgroundColor: CARD_HOVER_BG }}
734 paddingX={1}
735 gap={1}
736 alignItems="center"
737 >
738 {/* Only stable inputs go into the SVG: a changed source reloads it and restarts the loops. */}
739 <Svg
740 source={buddySvg(b, row.status, {
741 sizePx: 48,
742 transition,
743 animations,
744 bubble: row.status === 'running' ? 'thinking' : undefined,
745 })}
746 alt={`${b.displayName} the ${b.species}, ${STATUS_LABEL[row.status]}`}
747 width={48}
748 height={48}
749 isInteractive
750 />
751 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
752 <Text weight="bold" color={b.accent} dimColor={isLeaving} wrap="truncate-end">
753 {b.displayName}
754 </Text>
755 <Text color={THEME.ink} dimColor={isLeaving} wrap="truncate-end">
756 {row.description}
757 </Text>
758 <Text color={statusColor(row.status)} dimColor={isLeaving} tabular wrap="truncate-end">
759 {statusLine(row)}
760 </Text>
761 <Text color={THEME.dim} wrap="truncate-end">
762 {activity(row)}
763 </Text>
764 <Box>
765 <Button key={`open-${id}`} hotkey={hotkey(i)} dimColor={isLeaving} onPress={() => void openLog($, id)}>
766 output ›
767 </Button>
768 </Box>
769 </Box>
770 </Box>
771 )
772 })}
773 </Box>
774 </Box>
775 )
776 }
777
778 if (cards.length === 0) {
779 return (
780 <Box flexDirection="column" paddingX={1}>
781 {header}
782 <Box gap={1}>
783 <Box flexDirection="column" width={ASCII_W} flexShrink={0}>
784 {EMPTY_DEN_ASCII.map(line => (
785 <Text color={THEME.ghost}>{line}</Text>
786 ))}
787 </Box>
788 <Box flexDirection="column" flexShrink={1}>
789 <Text color={THEME.dim}>{emptyText}</Text>
790 <Box>{demoButton}</Box>
791 </Box>
792 </Box>
793 </Box>
794 )
795 }
796 return (
797 <Box flexDirection="column" paddingX={1}>
798 {header}
799 {cards.map(({ id, depth, row, b }, i) => {
800 const isLeaving = transitionFor(row, now, keep) === 'leave'
801 const color = spriteColor(row, b)
802 const sprite = buddyAscii(b, row.status, asciiFrameFor(row, now, frame, animations), animations)
803 // The row Button holds one line: name and description, cut to what the pane leaves beside the sprite.
804 const room = e.props.bodyColumns - ASCII_W - 4 - 2 * depth - [...b.displayName].length - 5
805 const desc = room > 1 ? firstLine(row.description, room) : ''
806 const act = activity(row)
807 return (
808 <Box key={`row-${id}`} gap={1} paddingLeft={2 * depth}>
809 <Box flexDirection="column" width={ASCII_W} flexShrink={0}>
810 {sprite.map(line => (
811 <Text color={color} dimColor={isLeaving}>
812 {line}
813 </Text>
814 ))}
815 </Box>
816 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
817 <Box>
818 <Button key={`open-${id}`} plain hotkey={hotkey(i)} dimColor={isLeaving} onPress={() => void openLog($, id)}>
819 {b.displayName} <Text dimColor>{desc}</Text>
820 </Button>
821 </Box>
822 <Text color={statusColor(row.status)} dimColor wrap="truncate-end">
823 {act === '' ? statusLine(row) : `${statusLine(row)} · ${act}`}
824 </Text>
825 </Box>
826 </Box>
827 )
828 })}
829 </Box>
830 )
831 }).catch(($, e, next) => next(e))
832
833 on('ui.render', { component: 'Pane', requestId: LOG_PANE }, async ($, e) => {
834 const selectedId = await read($, selectedIdAtom)
835 const row = selectedId === null ? undefined : (await read($, agentsAtom))[selectedId]
836 const els = $.ui.resolve(e)
837 const { Box, Text, Button } = els
838 if (selectedId === null || row === undefined) {
839 return (
840 <Box paddingX={1}>
841 <Text color={THEME.dim}>Pick a buddy in the den</Text>
842 </Box>
843 )
844 }
845 const id = selectedId
846 const frame = await read($, frameAtom)
847 const now = (await read($, nowAtom)) || (await $.clock.now())
848 const animations = rt.opts.animations
849 const b = resolveBuddy(row.buddy)
850 const lines = (await read($, logsAtom))[id] ?? []
851 const shown = lines.slice(-Math.max(5, (e.props.scroll.bodyRows || 30) - 8))
852 const transition = transitionFor(row, now, keepMs())
853 const accent = row.status === 'failed' || row.status === 'killed' ? statusColor(row.status) : b.accent
854
855 const onBack = async (): Promise<void> => {
856 await update($, selectedIdAtom, () => null)
857 // The person asked for the den: it is no longer hidden.
858 await update($, isDenHiddenAtom, () => false)
859 await openDen($)
860 await closePane($, LOG_PANE)
861 }
862 const onClose = async (): Promise<void> => {
863 // Our own $.ui.close does not run our ui.close hook: clear the selection here.
864 await update($, selectedIdAtom, () => null)
865 await closePane($, LOG_PANE)
866 }
867 const onView = async (): Promise<void> => {
868 if (id.startsWith(DEMO_PREFIX)) {
869 $.ui.toast('This is a demo buddy.')
870 return
871 }
872 try {
873 const r = await $.ui.view({ agentId: id })
874 if (r.deny !== undefined) $.ui.toast(`agent-den: no transcript to show: ${firstLine(r.deny, 200)}`)
875 } catch (err) {
876 $.ui.toast(`agent-den: no transcript to show: ${firstLine(String(err), 200)}`)
877 }
878 }
879
880 const facts = [
881 `${STATUS_DOT[row.status]} ${STATUS_LABEL[row.status]} · ${formatElapsed(elapsedOf(row, now))}`,
882 row.model === undefined ? row.type : `${row.type} · ${row.model}`,
883 `${row.toolCount} ${row.toolCount === 1 ? 'tool' : 'tools'} · ${row.turnCount} ${row.turnCount === 1 ? 'turn' : 'turns'}`,
884 ]
885 const buttons = (
886 <Box gap={2}>
887 <Button key="back" hotkey="b" plain dimColor onPress={() => void onBack()}>
888 ‹ back
889 </Button>
890 {row.type === 'workflow' ? null : (
891 <Button key="view" hotkey="t" plain onPress={() => void onView()}>
892 full transcript
893 </Button>
894 )}
895 <Button key="close" hotkey="x" role="dismiss" plain dimColor onPress={() => void onClose()}>
896 close
897 </Button>
898 </Box>
899 )
900 const waiting = `Waiting for ${b.displayName} to say something…`
901
902 if (e.surface !== 'terminal' && 'Svg' in els) {
903 const { Svg, Code } = els
904 const sprite = (status: AgentRow['status'], sizePx: number, t: typeof transition) => (
905 <Svg
906 source={buddySvg(b, status, { sizePx, transition: t, animations, bubble: status === 'running' ? 'thinking' : undefined })}
907 alt={`${b.displayName} the ${b.species}, ${STATUS_LABEL[status]}`}
908 width={sizePx}
909 height={sizePx}
910 isInteractive
911 />
912 )
913 // Code takes at most 100,000 characters: keep the newest lines that fit in 90,000.
914 let source = ''
915 for (let i = shown.length - 1; i >= 0; i--) {
916 const text = shown[i]?.text ?? ''
917 const nextSource = source === '' ? text : `${text}\n${source}`
918 if (nextSource.length > 90_000) break
919 source = nextSource
920 }
921 return (
922 <Box flexDirection="column" paddingX={1} gap={1}>
923 <Box gap={2} alignItems="center">
924 {sprite(row.status, 72, transition)}
925 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
926 <Text weight="bold" color={accent} wrap="truncate-end">
927 {b.displayName}
928 </Text>
929 <Text color={THEME.ink} wrap="truncate-end">
930 {row.description}
931 </Text>
932 {facts.map(f => (
933 <Text color={THEME.dim} tabular wrap="truncate-end">
934 {f}
935 </Text>
936 ))}
937 </Box>
938 </Box>
939 {buttons}
940 {shown.length === 0 ? (
941 <Box gap={2} alignItems="center">
942 {sprite('running', 48, null)}
943 <Text color={THEME.dim}>{waiting}</Text>
944 </Box>
945 ) : (
946 <Code language="text" wrap="wrap" source={source} />
947 )}
948 </Box>
949 )
950 }
951
952 const asciiOf = (status: AgentRow['status']): string[] =>
953 buddyAscii(b, status, asciiFrameFor({ ...row, status }, now, frame, animations), animations)
954 const lineColor = (kind: LogLine['kind']): string =>
955 kind === 'tool' ? b.accent : kind === 'error' ? THEME.cold : kind === 'status' ? THEME.ghost : THEME.ink
956 return (
957 <Box flexDirection="column" paddingX={1}>
958 <Box gap={1}>
959 <Box flexDirection="column" width={ASCII_W} flexShrink={0}>
960 {asciiOf(row.status).map(line => (
961 <Text color={accent}>{line}</Text>
962 ))}
963 </Box>
964 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
965 <Box gap={1}>
966 <Text weight="bold" color={accent}>
967 {b.displayName}
968 </Text>
969 <Box flexShrink={1}>
970 <Text dimColor wrap="truncate-end">
971 {firstLine(row.description, 200)}
972 </Text>
973 </Box>
974 </Box>
975 <Text color={THEME.dim} wrap="truncate-end">
976 {facts.join(' · ')}
977 </Text>
978 {buttons}
979 </Box>
980 </Box>
981 {shown.length === 0 ? (
982 <Box gap={1}>
983 <Box flexDirection="column" width={ASCII_W} flexShrink={0}>
984 {asciiOf('running').map(line => (
985 <Text color={b.accent}>{line}</Text>
986 ))}
987 </Box>
988 <Text color={THEME.dim}>{waiting}</Text>
989 </Box>
990 ) : (
991 shown.map(l => (
992 <Text color={lineColor(l.kind)} dimColor={l.kind === 'result'} wrap="truncate-end">
993 {l.text}
994 </Text>
995 ))
996 )}
997 </Box>
998 )
999 }).catch(($, e, next) => next(e))
1000}
1001hooks/art.ts 676 lines1// agent-den art: the buddy SVGs (one loop per status, one-shot transitions), the empty den, the ASCII frames.
2//
3// Every SVG string is a complete document on a 24×24 grid: pixel art is `<rect>` runs with crisp edges, animation
4// is SMIL only (no scripts, no event attributes, no external references, `url(#…)` only to its own ids). The
5// buddy's 16×16 body sits at (4, 7); row 23 holds the shadow; rows 0–6 and the side columns hold the effects
6// (bubble, z's, rain cloud, badge, sparks, hourglass). Faces come from templates: art paints each body's eye and
7// mouth boxes with the skin around them, then draws the mood of the status on top.
8//
9// The source is stable: the same inputs give the same string (the id prefix is a hash of the inputs, never a
10// counter or the time), so a re-render does not reload the frame and restart the loops. The agent's own text
11// never goes into an SVG; `bubble` only adds the 3-dot bubble and a word in the escaped `<title>`.
12//
13// The preview builder loads this file without a bundler: write each import on one line, value-import only from
14// ./buddies, and use only `export const` / `export function`.
15
16import type { AgentRow, DenStatus } from '../types'
17import type { ResolvedBuddy } from './buddies'
18
19export type Animations = 'full' | 'calm' | 'off'
20export type Transition = 'spawn' | 'complete' | 'fail' | 'leave' | null
21
22/** How long a one-shot transition plays, in ms. */
23export const TRANSITION_MS = 1500
24
25const isEndedStatus = (s: DenStatus): boolean => s === 'completed' || s === 'failed' || s === 'killed'
26
27/** Which one-shot plays now (null: none). */
28export function transitionFor(
29 row: Pick<AgentRow, 'status' | 'statusSince' | 'startedAt' | 'endedAt'>,
30 now: number,
31 keepFinishedMs: number,
32): Transition {
33 const ended = isEndedStatus(row.status)
34 if (ended && row.endedAt !== undefined && now >= row.endedAt + keepFinishedMs - TRANSITION_MS) return 'leave'
35 // startedAt, not statusSince: a pending → running change in the first second must not cut the pop short.
36 if (!ended && now - row.startedAt < TRANSITION_MS) return 'spawn'
37 if (now - row.statusSince < TRANSITION_MS) {
38 if (row.status === 'completed') return 'complete'
39 if (row.status === 'failed') return 'fail'
40 }
41 return null
42}
43
44/** Width of every terminal sprite row, in cells. */
45export const ASCII_W = 13
46
47/** One-cell braille spinner frames. */
48export const SPINNER: readonly string[] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
49
50// ---------------------------------------------------------------------------------------------------------------
51// Helpers (runs, pixels, flicker, disc and pillRects come from cache-thermo; each mod is self-contained).
52
53type Pal = Readonly<Record<string, string>>
54type Point = { x: number; y: number }
55
56const XMLNS = 'xmlns="http://www.w3.org/2000/svg"'
57const CRISP = 'shape-rendering="crispEdges"'
58const LOOP = 'repeatCount="indefinite"'
59const ONCE = 'begin="0s" fill="freeze"'
60const BX = 4 // body origin on the 24×24 canvas
61const BY = 7
62
63const INK = '#2b2f3a'
64const GLINT = '#ffffff'
65const PINK = '#f6a6b8'
66const PANEL = '#0f1420'
67const MINT = '#6ee7b7'
68const FROST = '#93c5fd'
69const GHOST = '#9aa3b5'
70const LAVENDER = '#c4b5fd'
71const CONFETTI = ['#6ee7b7', '#fcd34d', '#fb7185', '#93c5fd', '#c4b5fd', '#ffb27a']
72
73const esc = (s: string): string =>
74 s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
75
76/** FNV-1a (32 bit) as 8 hex digits: a stable id prefix from the inputs. */
77function fnv1a(text: string): string {
78 let h = 0x811c9dc5
79 for (let i = 0; i < text.length; i++) {
80 h ^= text.charCodeAt(i)
81 h = Math.imul(h, 0x01000193) >>> 0
82 }
83 return h.toString(16).padStart(8, '0')
84}
85
86/** Seconds as an SMIL clock value, scaled by the calm factor. */
87const sec = (n: number, k = 1): string => `${Math.round(n * k * 100) / 100}s`
88
89/** Pixel map → rect strings, one per horizontal run of a colour. Letters not in `pal` are transparent. */
90function runs(rows: readonly string[], pal: Pal, ox: number, oy: number): Record<string, string[]> {
91 const out: Record<string, string[]> = {}
92 rows.forEach((row, y) => {
93 let x = 0
94 while (x < row.length) {
95 const ch = row.charAt(x)
96 let end = x + 1
97 while (end < row.length && row.charAt(end) === ch) end++
98 const color = Object.prototype.hasOwnProperty.call(pal, ch) ? pal[ch] : undefined
99 if (color !== undefined) {
100 ;(out[color] ??= []).push(`<rect x="${x + ox}" y="${y + oy}" width="${end - x}" height="1"/>`)
101 }
102 x = end
103 }
104 })
105 return out
106}
107
108/** Pixel map → rects grouped in a `<g fill>` per colour. */
109function pixels(rows: readonly string[], pal: Pal, ox = 0, oy = 0): string {
110 return Object.entries(runs(rows, pal, ox, oy))
111 .map(([color, rects]) => `<g fill="${color}">${rects.join('')}</g>`)
112 .join('')
113}
114
115/** Two drawings shown alternately (a hard 2-frame swap, like a sprite sheet). */
116function flicker(a: string, b: string, dur: string): string {
117 const swap = (values: string) =>
118 `<animate attributeName="opacity" values="${values}" keyTimes="0;0.5" calcMode="discrete" dur="${dur}" ${LOOP}/>`
119 return `<g>${a}${swap('1;0')}</g><g opacity="0">${b}${swap('0;1')}</g>`
120}
121
122/** A pixel disc of diameter `d` as a map of '#'. */
123function disc(d: number): string[] {
124 const r = d / 2
125 const rows: string[] = []
126 for (let y = 0; y < d; y++) {
127 const dy = y + 0.5 - r
128 const half = Math.sqrt(Math.max(0, r * r - dy * dy))
129 let row = ''
130 for (let x = 0; x < d; x++) row += Math.abs(x + 0.5 - r) <= half ? '#' : '.'
131 rows.push(row)
132 }
133 return rows
134}
135
136/** A pixel pill (rounded ends) as rect strings, one per row. */
137function pillRects(x: number, y: number, w: number, h: number): string[] {
138 const r = Math.floor(h / 2)
139 const out: string[] = []
140 for (let row = 0; row < h; row++) {
141 const inset = Math.max(0, r - 1 - Math.min(row, h - 1 - row))
142 out.push(`<rect x="${x + inset}" y="${y + row}" width="${Math.max(1, w - 2 * inset)}" height="1"/>`)
143 }
144 return out
145}
146
147/** Wraps `inner` so that `anim` (a scale or rotate animateTransform) acts about the point (cx, cy). */
148function about(cx: number, cy: number, anim: string, inner: string): string {
149 return `<g transform="translate(${cx} ${cy})"><g>${anim}<g transform="translate(${-cx} ${-cy})">${inner}</g></g></g>`
150}
151
152/** A looping animateTransform; `discrete` takes the keyTimes. */
153function loopT(type: string, values: string, dur: string, keyTimes?: string): string {
154 const timing = keyTimes === undefined ? '' : ` keyTimes="${keyTimes}" calcMode="discrete"`
155 return `<animateTransform attributeName="transform" type="${type}" values="${values}" dur="${dur}"${timing} ${LOOP}/>`
156}
157
158/** A looping animate of one attribute. `discrete` keyTimes swap hard; plain keyTimes interpolate. */
159function loopA(attr: string, values: string, dur: string, o: { keyTimes?: string; discrete?: true; begin?: string } = {}): string {
160 const times = o.keyTimes === undefined ? '' : ` keyTimes="${o.keyTimes}"${o.discrete === true ? ' calcMode="discrete"' : ''}`
161 const start = o.begin === undefined ? '' : ` begin="${o.begin}"`
162 return `<animate attributeName="${attr}" values="${values}" dur="${dur}"${times}${start} ${LOOP}/>`
163}
164
165/** #rrggbb → its luminance grey (the killed buddy is drawn in greys; no filter needed). */
166function grey(hex: string): string {
167 const m = /^#([0-9a-f]{6})$/i.exec(hex)
168 if (m === null || m[1] === undefined) return GHOST
169 const n = parseInt(m[1], 16)
170 const lum = 0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)
171 const v = Math.round(Math.min(255, Math.max(0, lum))).toString(16).padStart(2, '0')
172 return `#${v}${v}${v}`
173}
174
175// ---------------------------------------------------------------------------------------------------------------
176// Faces. K ink, W glint, P pink, s skin (not drawn: the box is painted with skin first). The right eye mirrors.
177
178const EYES = {
179 open: ['WKK', 'KKK', 'KKK'],
180 closed: ['sss', 'KKK', 'sss'],
181 sleep: ['sss', 'KsK', 'sKs'],
182 happy: ['sKs', 'KsK', 'sss'],
183 cross: ['KsK', 'sKs', 'KsK'],
184 hollow: ['KKK', 'KsK', 'KKK'],
185} as const
186
187const MOUTHS = {
188 smile: ['KssK', 'sKKs'],
189 big: ['KKKK', 'sPPs'],
190 small: ['ssss', 'sKKs'],
191 flat: ['ssss', 'KKKK'],
192 frown: ['sKKs', 'KssK'],
193 none: ['ssss', 'ssss'],
194} as const
195
196type EyeName = keyof typeof EYES
197type MouthName = keyof typeof MOUTHS
198
199const MOOD: Record<DenStatus, { eyes: EyeName; mouth: MouthName }> = {
200 pending: { eyes: 'sleep', mouth: 'none' },
201 running: { eyes: 'open', mouth: 'smile' },
202 waiting: { eyes: 'open', mouth: 'flat' },
203 idle: { eyes: 'closed', mouth: 'small' },
204 completed: { eyes: 'happy', mouth: 'big' },
205 failed: { eyes: 'cross', mouth: 'frown' },
206 killed: { eyes: 'hollow', mouth: 'flat' },
207}
208
209const WORD: Record<DenStatus, string> = {
210 pending: 'hatching',
211 running: 'busy',
212 waiting: 'waiting',
213 idle: 'napping',
214 completed: 'done',
215 failed: 'oops',
216 killed: 'stopped',
217}
218
219/** Finds the two 3×3 eye boxes ('E') and the 4×2 mouth box ('M') of a body; null when malformed. */
220export function faceBoxes(
221 body: readonly string[],
222): { eyes: [{ x: number; y: number }, { x: number; y: number }]; mouth: { x: number; y: number } } | null {
223 const eyes: Point[] = []
224 const mouth: Point[] = []
225 body.forEach((row, y) => {
226 for (let x = 0; x < row.length; x++) {
227 const c = row.charAt(x)
228 if (c === 'E') eyes.push({ x, y })
229 else if (c === 'M') mouth.push({ x, y })
230 }
231 })
232 if (eyes.length !== 18 || mouth.length !== 8) return null
233 const at = (x: number, y: number): string => body[y]?.charAt(x) ?? ''
234 const ey = Math.min(...eyes.map(p => p.y))
235 const lx = Math.min(...eyes.map(p => p.x))
236 const rx = Math.max(...eyes.map(p => p.x)) - 2
237 // Two separate boxes: at least one cell between them, so each eye has skin on both sides.
238 if (rx < lx + 4) return null
239 for (let dy = 0; dy < 3; dy++) {
240 for (let dx = 0; dx < 3; dx++) if (at(lx + dx, ey + dy) !== 'E' || at(rx + dx, ey + dy) !== 'E') return null
241 }
242 const mx = Math.min(...mouth.map(p => p.x))
243 const my = Math.min(...mouth.map(p => p.y))
244 for (let dy = 0; dy < 2; dy++) for (let dx = 0; dx < 4; dx++) if (at(mx + dx, my + dy) !== 'M') return null
245 return { eyes: [{ x: lx, y: ey }, { x: rx, y: ey }], mouth: { x: mx, y: my } }
246}
247
248/** The skin under a box: the palette letter seen most in the ring of cells around it (ties: first in char order). */
249function ringSkin(body: readonly string[], pal: Pal, x: number, y: number, w: number, h: number): string | undefined {
250 const counts: Record<string, number> = {}
251 for (let yy = y - 1; yy <= y + h; yy++) {
252 for (let xx = x - 1; xx <= x + w; xx++) {
253 if (yy >= y && yy < y + h && xx >= x && xx < x + w) continue
254 const c = body[yy]?.charAt(xx) ?? ''
255 if (c === '' || c === '.' || c === 'E' || c === 'M' || !Object.prototype.hasOwnProperty.call(pal, c)) continue
256 counts[c] = (counts[c] ?? 0) + 1
257 }
258 }
259 let best: string | undefined
260 for (const c of Object.keys(counts).sort()) if (best === undefined || (counts[c] ?? 0) > (counts[best] ?? 0)) best = c
261 return best
262}
263
264/** The body with its eye and mouth boxes painted in the skin around them. */
265function skinnedBody(body: readonly string[], pal: Pal, face: NonNullable<ReturnType<typeof faceBoxes>>): string[] {
266 const rows = body.map(r => [...r])
267 const fill = (x: number, y: number, w: number, h: number) => {
268 const skin = ringSkin(body, pal, x, y, w, h) ?? '.'
269 for (let dy = 0; dy < h; dy++) for (let dx = 0; dx < w; dx++) {
270 const row = rows[y + dy]
271 if (row !== undefined) row[x + dx] = skin
272 }
273 }
274 fill(face.eyes[0].x, face.eyes[0].y, 3, 3)
275 fill(face.eyes[1].x, face.eyes[1].y, 3, 3)
276 fill(face.mouth.x, face.mouth.y, 4, 2)
277 return rows.map(r => r.join(''))
278}
279
280const mirror = (rows: readonly string[]): string[] => rows.map(r => [...r].reverse().join(''))
281
282// ---------------------------------------------------------------------------------------------------------------
283// Effects, drawn on the canvas around the buddy.
284
285/** Campfire-style spark pixels that rise and fade at the head sides (running, full only). */
286function sparks(accent: string): string {
287 const spark = (x: number, y: number, color: string, dur: string, begin: string) =>
288 `<rect x="${x}" y="${y}" width="1" height="1" fill="${color}" opacity="0">` +
289 loopA('opacity', '0;0.9;0.9;0', dur, { keyTimes: '0;0.15;0.6;1', begin }) +
290 loopA('y', `${y};${y - 4}`, dur, { begin }) +
291 `</rect>`
292 return spark(21, 13, accent, '1.6s', '0s') + spark(2, 12, '#fff8e6', '1.9s', '0.7s') + spark(22, 16, '#ffe3a3', '1.7s', '1.2s')
293}
294
295/** A frost sweat drop that slides down beside the head. */
296function sweat(k: number): string {
297 return (
298 `<g opacity="0">` +
299 loopA('opacity', '0;1;1;0', sec(2.4, k), { keyTimes: '0;0.1;0.7;1' }) +
300 loopT('translate', '0 0;0 4', sec(2.4, k)) +
301 `<rect x="3" y="10" width="1" height="2" fill="${FROST}"/></g>`
302 )
303}
304
305/** The speech bubble with three typing dots, top right. */
306function speechBubble(live: boolean, k: number): string {
307 const shape = pixels(['.#####.', '#######', '#######', '#######', '.#####.', '.#.....'], { '#': '#f4f7fb' }, 16, 0)
308 const dot = (x: number, begin: number) =>
309 live
310 ? `<rect x="${x}" y="2" width="1" height="1" fill="#5a6478" opacity="0.3">${loopA('opacity', '0.3;1;0.3', sec(1.2, k), { begin: sec(begin, k) })}</rect>`
311 : `<rect x="${x}" y="2" width="1" height="1" fill="#5a6478"/>`
312 return shape + dot(17, 0) + dot(19, 0.2) + dot(21, 0.4)
313}
314
315/** A small hourglass beside the buddy: the top sand drains, a grain falls, the pile grows (waiting). */
316function hourglass(live: boolean, k: number): string {
317 const frame = pixels(['FFF', '...', '...', '...', 'FFF'], { F: '#c9a77c' }, 20, 14)
318 const glass = `<g fill="#dfe7ef" opacity="0.25"><rect x="20" y="15" width="3" height="1"/><rect x="20" y="17" width="3" height="1"/></g>`
319 if (!live) return frame + glass + `<rect x="20" y="15" width="3" height="1" fill="#fcd34d"/><rect x="21" y="17" width="1" height="1" fill="#fcd34d"/>`
320 const d = sec(2.4, k)
321 return (
322 frame +
323 glass +
324 `<rect x="20" y="15" width="3" height="1" fill="#fcd34d">${loopA('opacity', '1;0', d)}</rect>` +
325 `<rect x="21" y="16" width="1" height="1" fill="#fcd34d">${loopA('opacity', '1;0', sec(0.4, k), { keyTimes: '0;0.5', discrete: true })}</rect>` +
326 `<rect x="20" y="17" width="3" height="1" fill="#fcd34d">${loopA('opacity', '0;1', d)}</rect>`
327 )
328}
329
330/** Two pixel z's that float up and fade (idle). */
331function snores(live: boolean, k: number): string {
332 const z = (ox: number, oy: number, color: string) => pixels(['ZZZ', '.Z.', 'ZZZ'], { Z: color }, ox, oy)
333 if (!live) return z(18, 1, LAVENDER) + `<g opacity="0.6">${z(15, 4, FROST)}</g>`
334 const copy = (color: string, begin: string) =>
335 `<g opacity="0">` +
336 loopA('opacity', '0;0.9;0.9;0', sec(2.8, k), { keyTimes: '0;0.2;0.6;1', begin }) +
337 `<animateTransform attributeName="transform" type="translate" values="0 0;5 -5" dur="${sec(2.8, k)}" begin="${begin}" ${LOOP}/>` +
338 z(15, 5, color) +
339 `</g>`
340 return copy(LAVENDER, '0s') + copy(FROST, sec(1.4, k))
341}
342
343/** The ✓ badge: a mint disc with a check in the panel colour (completed). */
344function badge(): string {
345 return pixels(disc(5), { '#': MINT }, 18, 1) + pixels(['...#.', '#.#..', '.#...'], { '#': PANEL }, 18, 2)
346}
347
348/** A small rain cloud with falling frost drops (failed). */
349function rainCloud(live: boolean, k: number): string {
350 const cloud = pixels(['..GGG...', '.GGGGGG.', 'GGGGGGGG'], { G: GHOST }, 8, 0)
351 const drop = (x: number, begin: number) =>
352 live
353 ? `<rect x="${x}" y="3" width="1" height="1" fill="${FROST}" opacity="0">` +
354 loopA('opacity', '0;1;0', sec(1.2, k), { begin: sec(begin, k) }) +
355 loopA('y', '3;6', sec(1.2, k), { begin: sec(begin, k) }) +
356 `</rect>`
357 : `<rect x="${x}" y="${3 + (x % 2)}" width="1" height="1" fill="${FROST}"/>`
358 return cloud + drop(9, 0) + drop(12, 0.4) + drop(14, 0.8)
359}
360
361/** The front half of an egg shell over the body, with a jagged crack edge (pending). */
362function eggShell(accent: string): string {
363 const EGG = [
364 '.c..c....c..c...',
365 'cCccCc..cCccCc.c',
366 'cCCCCCccCCCCCCcC',
367 'cCCxCCCCCCCxCCCc',
368 'cCCCCCCxCCCCCCCc',
369 '.cCxCCCCCCCCxCc.',
370 '..cccccccccccc..',
371 ]
372 return pixels(EGG, { C: '#fdf6e3', c: '#d8c8a8', x: accent }, BX, 16)
373}
374
375/** A shell cap on the head (pending), tilting a little. */
376function eggCap(top: number, live: boolean, k: number): string {
377 const cap = pixels(['..cccc..', '.cCCCCc.', 'cCCxCCCc', 'c.cC.Cc.'], { C: '#fdf6e3', c: '#d8c8a8', x: '#f6c9a8' }, 8, top - 3)
378 if (!live) return cap
379 return about(12, top, loopT('rotate', '-8;0;8;0', sec(1.6, k), '0;0.25;0.5;0.75'), cap)
380}
381
382// ---------------------------------------------------------------------------------------------------------------
383// The buddy SVG.
384
385/** The buddy drawn for a status, as a complete SVG document (`sizePx` square; 48, 72 and 96 are pixel-crisp). */
386export function buddySvg(
387 b: ResolvedBuddy,
388 status: DenStatus,
389 opts: { sizePx: number; transition: Transition; animations: Animations; bubble?: string },
390): string {
391 const size = Math.max(8, Math.round(Number.isFinite(opts.sizePx) ? opts.sizePx : 48))
392 const animations: Animations = opts.animations === 'calm' || opts.animations === 'off' ? opts.animations : 'full'
393 const live = animations !== 'off'
394 const full = animations === 'full'
395 const k = animations === 'calm' ? 2 : 1
396 const transition = live ? opts.transition : null
397 const bubble = typeof opts.bubble === 'string' && opts.bubble.trim() !== '' ? opts.bubble.trim() : undefined
398 const id = `ad${fnv1a([b.key, b.variant, status, String(opts.transition), animations, size, bubble ?? ''].join('|'))}`
399
400 const ghost = status === 'killed'
401 const pal: Record<string, string> = {}
402 for (const [letter, color] of Object.entries(b.palette)) pal[letter] = ghost ? grey(color) : color
403 const tone = (c: string): string => (ghost ? grey(c) : c)
404 const facePal: Pal = { K: tone(b.palette.K ?? INK), W: tone(b.palette.W ?? GLINT), P: tone(b.palette.P ?? PINK) }
405
406 // Body rows with the face boxes painted as skin.
407 const face = faceBoxes(b.body)
408 const rows = face === null ? b.body.map(r => r.replace(/[EM]/g, '.')) : skinnedBody(b.body, pal, face)
409 const top = Math.max(0, rows.findIndex(r => r.replace(/\./g, '').length >= 6)) + BY
410
411 // Body layer. A ghost's bottom row is a hem that waves (every other cell, swapped). Waiting taps the left
412 // foot: the body is drawn twice, clipped, and only the foot window moves.
413 let body = pixels(rows, pal, BX, BY)
414 let defs = ''
415 if (ghost) {
416 const last = rows.map(r => /[^.]/.test(r)).lastIndexOf(true)
417 const hem = (odd: number) =>
418 pixels(rows.map((r, y) => (y === last ? [...r].map((c, x) => (x % 2 === odd ? '.' : c)).join('') : r)), pal, BX, BY)
419 body = live ? flicker(hem(1), hem(0), sec(0.8, k)) : hem(1)
420 } else if (status === 'waiting' && live) {
421 defs =
422 `<clipPath id="${id}a"><rect x="0" y="0" width="24" height="21"/><rect x="0" y="21" width="4" height="3"/><rect x="10" y="21" width="14" height="3"/><rect x="4" y="23" width="6" height="1"/></clipPath>` +
423 `<clipPath id="${id}b"><rect x="4" y="21" width="6" height="2"/></clipPath>`
424 body =
425 `<g clip-path="url(#${id}a)">${body}</g>` +
426 `<g clip-path="url(#${id}b)"><g>${loopT('translate', '0 0;0 -1', sec(0.6, k), '0;0.5')}${body}</g></g>`
427 }
428 // 'A' cells are lights (Bolt's antenna, Digby's lamp): they pulse white over the body and prop while running.
429 const glow = pixels(rows, { A: '#ffffff' }, BX, BY) + pixels(b.prop ?? [], { A: '#ffffff' }, BX, BY)
430 const lights = status === 'running' && live && glow !== '' ? `<g opacity="0">${loopA('opacity', '0;0.8;0', sec(1, k))}${glow}</g>` : ''
431
432 // Face layer.
433 const mood = MOOD[status]
434 const eyesOf = (name: EyeName): string =>
435 face === null
436 ? ''
437 : pixels(EYES[name], facePal, BX + face.eyes[0].x, BY + face.eyes[0].y) +
438 pixels(mirror(EYES[name]), facePal, BX + face.eyes[1].x, BY + face.eyes[1].y)
439 const mouth = face === null ? '' : pixels(MOUTHS[mood.mouth], facePal, BX + face.mouth.x, BY + face.mouth.y)
440 let eyes = eyesOf(mood.eyes)
441 if (live && status === 'running') {
442 // Blink: closed for 6 % of a 3 s cycle, a discrete swap as Frostbit's.
443 eyes =
444 `<g>${loopA('opacity', '1;0;1', sec(3, k), { keyTimes: '0;0.9;0.96', discrete: true })}${eyes}</g>` +
445 `<g opacity="0">${loopA('opacity', '0;1;0', sec(3, k), { keyTimes: '0;0.9;0.96', discrete: true })}${eyesOf('closed')}</g>`
446 } else if (live && status === 'completed') {
447 eyes =
448 `<g>${loopA('opacity', '1;0;1', sec(4, k), { keyTimes: '0;0.9;0.94', discrete: true })}${eyes}</g>` +
449 `<g opacity="0">${loopA('opacity', '0;1;0', sec(4, k), { keyTimes: '0;0.9;0.94', discrete: true })}${eyesOf('closed')}</g>`
450 } else if (live && status === 'waiting') {
451 eyes = `<g>${loopT('translate', '0 0;-1 0;0 0;1 0', sec(2.4, k), '0;0.25;0.5;0.75')}${eyes}</g>`
452 }
453 let faceLayer = eyes + mouth
454 if (live && status === 'failed') faceLayer = `<g>${loopT('translate', '0 0;0 1', sec(3, k), '0;0.5')}${faceLayer}</g>`
455
456 const prop = b.prop === undefined ? '' : pixels(b.prop, pal, BX, BY)
457 let buddy = body + faceLayer + prop + lights
458 if (status === 'pending') buddy += eggShell(tone(b.accent)) + eggCap(top, live, k)
459
460 // Status loop, about the feet.
461 if (live) {
462 if (status === 'running' && full) {
463 const squash = about(12, 23, loopT('scale', '1.06 0.94;1 1', sec(0.5), '0;0.2'), buddy)
464 buddy = `<g>${loopT('translate', '0 0;0 -1', sec(0.5), '0;0.5')}${squash}</g>`
465 } else if (status === 'idle') {
466 buddy = about(12, 23, `<animateTransform attributeName="transform" type="scale" values="1 1;1.04 1.04;1 1" dur="${sec(3, k)}" calcMode="spline" keySplines="0.4 0 0.6 1;0.4 0 0.6 1" ${LOOP}/>`, buddy)
467 } else if (status === 'completed' && full) {
468 buddy = about(12, 23, `<animateTransform attributeName="transform" type="scale" values="1 1;1.03 1.03;1 1" dur="4s" calcMode="spline" keySplines="0.4 0 0.6 1;0.4 0 0.6 1" ${LOOP}/>`, buddy)
469 } else if (status === 'pending') {
470 buddy = `<g>${loopT('rotate', '0 12 22;-4 12 22;0 12 22;4 12 22', sec(1.6, k), '0;0.25;0.5;0.75')}${buddy}</g>`
471 } else if (status === 'killed') {
472 buddy = `<g>${loopT('translate', '0 0;1 0;0 0;-1 0', sec(3, k), '0;0.25;0.5;0.75')}<g>${loopT('translate', '0 0;0 -1', sec(1.5, k), '0;0.5')}${buddy}</g></g>`
473 }
474 }
475 if (status === 'failed') buddy = `<g transform="translate(0 1)">${buddy}</g>`
476 if (ghost) buddy = `<g opacity="0.55">${buddy}</g>`
477
478 // Shadow on row 23: it narrows while a running buddy is up in the air.
479 const shadowRects = `<g fill="#000000" opacity="${ghost ? 0.12 : 0.25}">${pillRects(7, 23, 10, 1).join('')}</g>`
480 const shadow =
481 status === 'running' && full ? about(12, 23, loopT('scale', '1 1;0.8 1', sec(0.5), '0;0.5'), shadowRects) : shadowRects
482
483 // Effects around the buddy.
484 let effects = ''
485 if (status === 'running') {
486 if (full) effects += sparks(tone(b.accent))
487 if (live) effects += sweat(k)
488 }
489 if (status === 'waiting') effects += hourglass(live, k)
490 if (status === 'idle') effects += snores(live, k)
491 if (status === 'completed') effects += badge()
492 if (status === 'failed') effects += rainCloud(live, k)
493 if (bubble !== undefined && status !== 'killed') effects += speechBubble(live, k)
494
495 // One-shot transition wrapper (fill="freeze"); confetti and shell chips fly outside it.
496 let scene = shadow + buddy + effects
497 let extras = ''
498 const once = sec(TRANSITION_MS / 1000)
499 if (transition === 'spawn') {
500 const values = full ? '0 0;1.15 1.15;1 1' : '0 0;1 1'
501 const timing = full ? 'keyTimes="0;0.6;1" keySplines="0.2 0.8 0.3 1;0.4 0 0.6 1"' : 'keyTimes="0;1" keySplines="0.2 0.8 0.3 1"'
502 scene = about(12, 22, `<animateTransform attributeName="transform" type="scale" values="${values}" ${timing} calcMode="spline" dur="${once}" ${ONCE}/>`, scene)
503 const chip = (x: number, dx: number) =>
504 `<g><animateTransform attributeName="transform" type="translate" values="0 0;${dx} -5" dur="1s" ${ONCE}/><animate attributeName="opacity" values="1;0" dur="1s" ${ONCE}/>` +
505 pixels(['cC', 'Cc'], { C: '#fdf6e3', c: '#d8c8a8' }, x, 18) +
506 `</g>`
507 extras = chip(8, -7) + chip(14, 7)
508 } else if (transition === 'complete') {
509 scene = `<g><animateTransform attributeName="transform" type="translate" values="0 0;0 ${full ? -3 : -1};0 0;0 0" keyTimes="0;0.35;0.7;1" dur="${once}" ${ONCE}/>${scene}</g>`
510 if (full) {
511 const ends: [number, number][] = [[-8, -6], [-5, -9], [-1, -10], [3, -10], [6, -8], [9, -5]]
512 extras = ends
513 .map(
514 ([dx, dy], i) =>
515 `<rect x="12" y="11" width="1" height="1" fill="${CONFETTI[i] ?? MINT}">` +
516 `<animateTransform attributeName="transform" type="translate" values="0 0;${dx} ${dy}" dur="1.2s" ${ONCE}/>` +
517 `<animate attributeName="opacity" values="1;1;0" keyTimes="0;0.6;1" dur="1.2s" ${ONCE}/></rect>`,
518 )
519 .join('')
520 }
521 } else if (transition === 'fail') {
522 scene = `<g><animateTransform attributeName="transform" type="translate" values="0 0;-1 0;1 0;-1 0;1 0;0 0" keyTimes="0;0.17;0.33;0.5;0.67;0.83" calcMode="discrete" dur="0.8s" ${ONCE}/>${scene}</g>`
523 } else if (transition === 'leave') {
524 scene = `<g><animate attributeName="opacity" values="1;0" dur="${once}" ${ONCE}/><animateTransform attributeName="transform" type="translate" values="0 0;8 0" dur="${once}" ${ONCE}/>${scene}</g>`
525 }
526
527 // Hover: the buddy hops up one pixel under the pointer (a CSS :hover, so it needs no script).
528 const hover = live ? `<style>svg:hover .${id}h{transform:translate(0px,-1px)}</style>` : ''
529 const word = WORD[status] + (bubble === undefined ? '' : `, ${bubble}`)
530 const title = esc(`${b.displayName} the ${b.species}: ${word}`)
531 const defsTag = defs === '' ? '' : `<defs>${defs}</defs>`
532 return `<svg ${XMLNS} viewBox="0 0 24 24" width="${size}" height="${size}" ${CRISP}><title>${title}</title>${hover}${defsTag}<g class="${id}h">${scene}${extras}</g></svg>`
533}
534
535// ---------------------------------------------------------------------------------------------------------------
536// The empty den: a burrow in a grassy mound, a sleepy pair of eyes in the dark, z's drifting out.
537
538const DEN = [
539 '........................',
540 '........................',
541 '........................',
542 '........................',
543 '........................',
544 '........................',
545 '........................',
546 '........................',
547 '........................',
548 '.........dddddd.........',
549 '.......ddDDDDDDdd.......',
550 '.....ddDDDDDDDDDDdd.....',
551 '....dDDDDDhhhhDDDDDd....',
552 '...dDDDDDhhhhhhDDDDDd...',
553 '...dDDDDhhhhhhhhDDDDd...',
554 '..dDDDDhhhhhhhhhhDDDDd..',
555 '..dDDDDhhhhhhhhhhDDDDd..',
556 '.dDDDDDhhhhhhhhhhDDDDDd.',
557 '.dDDDDDhhhhhhhhhhDDDDDd.',
558 'gdDDDDDhhhhhhhhhhDDDDDdg',
559 'GgDDDDDhhhhhhhhhhDDDDgGG',
560 'GGgggggggggggggggggggGGG',
561 'GGGGGGGGGGGGGGGGGGGGGGGG',
562 '........................',
563]
564
565/** A sleeping burrow for the empty state, as a complete SVG document. */
566export function emptyDenSvg(sizePx: number): string {
567 const size = Math.max(8, Math.round(Number.isFinite(sizePx) ? sizePx : 96))
568 const id = `ad${fnv1a(`den|${size}`)}`
569 const mound = pixels(DEN, { d: '#5e4f4a', D: '#7a675c', h: '#1a1420', g: '#5fae75', G: '#86efac' })
570 const pebbles = `<g fill="#9c8479"><rect x="5" y="15" width="1" height="1"/><rect x="18" y="13" width="1" height="1"/><rect x="19" y="17" width="1" height="1"/></g>`
571 const tufts = pixels(['.G.....', 'G.G....'], { G: '#86efac' }, 2, 17) + pixels(['....G.', '...G.G'], { G: '#86efac' }, 17, 17)
572 const eyes = `<g fill="#dfe7ef" opacity="0.7"><rect x="10" y="17" width="2" height="1"/><rect x="13" y="17" width="2" height="1"/></g>`
573 const z = (ox: number, oy: number, color: string, begin: string) =>
574 `<g opacity="0">` +
575 `<animate attributeName="opacity" values="0;0.85;0.85;0" keyTimes="0;0.2;0.6;1" dur="3.2s" begin="${begin}" ${LOOP}/>` +
576 `<animateTransform attributeName="transform" type="translate" values="0 0;3 -6" dur="3.2s" begin="${begin}" ${LOOP}/>` +
577 pixels(['ZZZ', '.Z.', 'ZZZ'], { Z: color }, ox, oy) +
578 `</g>`
579 const blink = `<g>${loopA('opacity', '0.7;0.25;0.7', '4s')}${eyes}</g>`
580 return `<svg ${XMLNS} viewBox="0 0 24 24" width="${size}" height="${size}" ${CRISP}><title>The den is empty: no buddies out yet</title><g class="${id}">${mound}${pebbles}${tufts}${blink}${z(13, 9, LAVENDER, '0s')}${z(10, 10, FROST, '1.6s')}</g></svg>`
581}
582
583// ---------------------------------------------------------------------------------------------------------------
584// Terminal art: 3 rows (hat, face, arms and feet), each exactly ASCII_W cells. Every glyph is one cell wide in
585// every terminal: printable ASCII, 。 ◕ ‿ ᵔ ᶻ and the braille block. Never emoji or East Asian ambiguous-width
586// marks (• ⌒ ˘ ° ✓ ✗ ●), which some terminals draw two cells wide and shear the sprite.
587
588const SAFE_EXTRA = new Set(['。', '◕', '‿', 'ᵔ', 'ᶻ'])
589
590/** The glyph rule of the terminal sprites: printable ASCII, 。 ◕ ‿ ᵔ ᶻ and braille U+2800–U+28FF. */
591export function isSafeGlyphs(text: string): boolean {
592 for (const ch of text) {
593 const cp = ch.codePointAt(0) ?? 0
594 if (cp >= 0x20 && cp <= 0x7e) continue
595 if (cp >= 0x2800 && cp <= 0x28ff) continue
596 if (SAFE_EXTRA.has(ch)) continue
597 return false
598 }
599 return true
600}
601
602/** Places `text` at cell `left` of a blank row of `ASCII_W` cells (cut at the right edge). */
603function place(row: string, text: string, left: number): string {
604 const cells = [...row.padEnd(ASCII_W)].slice(0, ASCII_W)
605 while (cells.length < ASCII_W) cells.push(' ')
606 ;[...text].forEach((ch, i) => {
607 const x = left + i
608 if (x >= 0 && x < ASCII_W && ch !== ' ') cells[x] = ch
609 })
610 return cells.join('')
611}
612
613const BLANK = ' '.repeat(ASCII_W)
614const FACE_X = 3 // the 7-cell face sits in cells 3–9
615const MID = 6 // the centre cell of the face
616
617/** A part centred over the face, shifted by `dx` cells. */
618const centred = (text: string, dx = 0): string => place(BLANK, text, MID - Math.floor(([...text].length - 1) / 2) + dx)
619
620/** Three rows, each one exactly ASCII_W cells. */
621const sprite = (r0: string, r1: string, r2: string): string[] => [r0, r1, r2].map(r => place(r, '', 0))
622
623/** The buddy's terminal sprite: exactly 3 rows of ASCII_W cells. `frame` comes from tracker's asciiFrameFor. */
624export function buddyAscii(b: ResolvedBuddy, status: DenStatus, frame: number, _animations: Animations): string[] {
625 const f = Number.isFinite(frame) ? Math.max(0, Math.floor(frame)) : 0
626 const hat = [...(b.ascii.hat ?? '')].slice(0, 9).join('')
627 const [armL, armR] = b.ascii.arms ?? ['o', 'o']
628 const feet = (l: string, r: string, mid = '(_ _)') => centred(`${l}${mid}${r}`)
629 const faceRow = (face: string) => place(BLANK, face, FACE_X)
630 switch (status) {
631 case 'running': {
632 const n = f % 4
633 const up = n === 1 || n === 3
634 const dx = n === 1 ? 1 : n === 3 ? -1 : 0
635 return sprite(centred(hat, dx), faceRow('(。◕‿◕。)'), up ? feet('\\', '/') : feet(armL, armR))
636 }
637 case 'waiting': {
638 const n = f % 4
639 const faces = ['( ◕_◕ )', '(◕_◕ )', '( ◕_◕ )', '( ◕_◕)']
640 const hatRow = n === 1 || n === 2 ? place(centred(hat), '?', 11) : centred(hat)
641 return sprite(hatRow, faceRow(faces[n] ?? '( ◕_◕ )'), feet(armL, armR, n % 2 === 1 ? '(_ -)' : '(_ _)'))
642 }
643 case 'idle': {
644 const n = f % 4
645 const z0 = ['', '', 'ᶻ', 'ᶻz'][n] ?? ''
646 const z1 = ['z', 'zᶻ', 'z', ''][n] ?? ''
647 return sprite(place(centred(hat), z0, 11), place(faceRow('(。-‿-。)'), z1, 11), feet(armL, armR))
648 }
649 case 'completed': {
650 const n = Math.min(f, 3)
651 const face = '(。ᵔ‿ᵔ。)'
652 if (n === 0) return sprite(centred(hat), faceRow(face), feet(armL, armR, '(___)'))
653 if (n === 1) return sprite(place(faceRow(face), '*', 1), feet('\\', '/'), place(BLANK, '*', 11))
654 if (n === 2) return sprite(place(centred(hat), '*', 11), faceRow(face), feet('\\', '/'))
655 return sprite(centred(hat), faceRow(face), feet(armL, armR))
656 }
657 case 'failed': {
658 const n = f % 2
659 const r1 = faceRow('(。x_x。)')
660 const r2 = feet(armL, armR)
661 return sprite(centred(hat), n === 0 ? place(r1, ',', 10) : r1, n === 1 ? place(r2, ',', 10) : r2)
662 }
663 case 'killed': {
664 const n = f % 2
665 return sprite(centred('.---.'), faceRow('( o o )'), centred(n === 0 ? '\\/\\/\\/\\' : '/\\/\\/\\/'))
666 }
667 case 'pending': {
668 const dx = f % 2 === 0 ? 0 : 1
669 return sprite(centred('.-^-^-.', dx), place(BLANK, '( -.- )', FACE_X + dx), centred("'-----'", dx))
670 }
671 }
672}
673
674/** The empty den in the terminal: a small burrow, 3 rows of ASCII_W cells. */
675export const EMPTY_DEN_ASCII: readonly string[] = [' .-~~~-. ', ' / ᶻz \\ ', ".-'-------'-."]
676hooks/buddies.ts 546 lines1// agent-den buddies: the roster of ten pixel mascots, the recoloured variants, and the buddy picker.
2//
3// Pure data and pure functions, no value imports, so the preview builder can load this file on its own.
4// A body is 16 rows × 16 chars: '.' is transparent, 'E' marks the two 3×3 eye boxes, 'M' the 4×2 mouth box,
5// 'A' a light that blinks while the buddy runs, and every other letter is a palette colour. art.ts paints the
6// eye and mouth boxes with the skin around them and draws the mood faces on top, so one body serves every status.
7// Write each import on one line and use only `export const` / `export function` (the preview strips the types).
8
9import type { BuddyRef } from '../types'
10
11export type Buddy = {
12 /** Roster key, e.g. 'digby'. */
13 key: string
14 /** Display name, e.g. 'Digby'. */
15 name: string
16 /** Short species for titles and alt text, e.g. 'mole'. */
17 species: string
18 /** 16 rows × 16 chars; see the file header for the letters. */
19 body: string[]
20 /** Letter → '#rrggbb'. `K` (face ink) and `W` (eye glint) are optional overrides. */
21 palette: Record<string, string>
22 /** Card border and name colour (pastel on the #0f1420 panel). */
23 accent: string
24 /** Optional 16×16 accessory layer, drawn over the face with the same palette rules. */
25 prop?: string[]
26 /** Terminal sprite parts: a hat row of at most 9 one-cell glyphs, and the two arm glyphs. */
27 ascii: { hat: string; arms?: [string, string] }
28 /** Agent types this buddy is the default for (lowercase). */
29 types?: string[]
30 /** `types` match as substrings of the agent type, not as whole names. */
31 matchSubstring?: true
32}
33
34export type ResolvedBuddy = Buddy & { displayName: string; variant: number }
35
36// ---------------------------------------------------------------------------------------------------------------
37// The roster, in pick order. Shared letters: O outline, B body, L light, P cheeks, D shade, A light that blinks.
38
39export const ROSTER: readonly Buddy[] = [
40 {
41 key: 'digby',
42 name: 'Digby',
43 species: 'mole',
44 body: [
45 '................',
46 '................',
47 '................',
48 '.....OOOOOO.....',
49 '...OOBBBBBBOO...',
50 '..OBBBBBBBBBBO..',
51 '.OBBEEEBBEEEBBO.',
52 '.OBBEEEBBEEEBBO.',
53 'OBBBEEEBBEEEBBBO',
54 'OBPPBBBNNBBBPPBO',
55 'OBBBBBMMMMBBBBBO',
56 'OBBBBBMMMMBBBBBO',
57 'OBBBBBBBBBBBBBBO',
58 '.OBBBLLLLLLBBBO.',
59 '..OBBLLLLLLBBO..',
60 '..OHHOOOOOOHHO..',
61 ],
62 prop: [
63 '.......AA.......',
64 '.....YYAAYY.....',
65 '....YYYYYYYY....',
66 '...YYYYYYYYYY...',
67 '..yyyyyyyyyyyy..',
68 '................',
69 '................',
70 '................',
71 '................',
72 '................',
73 '................',
74 '................',
75 '................',
76 '................',
77 '................',
78 '................',
79 ],
80 palette: {
81 O: '#5e4f4a',
82 B: '#9c8479',
83 L: '#c9b4a6',
84 N: '#f4a3b4',
85 H: '#f6b8c4',
86 P: '#f6a6b8',
87 Y: '#fcd34d',
88 y: '#d9a93a',
89 A: '#fff6c2',
90 },
91 accent: '#fcd34d',
92 ascii: { hat: '_/=o=\\_' },
93 types: ['explore'],
94 },
95 {
96 key: 'bricks',
97 name: 'Bricks',
98 species: 'beaver',
99 body: [
100 '................',
101 '................',
102 '...OO......OO...',
103 '..OIBOOOOOOBIO..',
104 '..OBBBBBBBBBBO..',
105 '.OBBBBBBBBBBBBO.',
106 '.OBBEEEBBEEEBBO.',
107 '.OBBEEEBBEEEBBO.',
108 '.OBBEEEBBEEEBBO.',
109 '.OBPPLLLLLLPPBO.',
110 '.OBBLLMMMMLLBBO.',
111 '.OBBLLMMMMLLBBO.',
112 '.OBBBLLTTLLBBBOD',
113 '..OBBBBTTBBBBODD',
114 '...OOBBBBBBOODDd',
115 '....OOO..OOODDd.',
116 ],
117 prop: [
118 '................',
119 '................',
120 '................',
121 '................',
122 'RR..............',
123 'RR..............',
124 'SS..............',
125 'YY..............',
126 'Yy..............',
127 'Yy..............',
128 'Yy..............',
129 'tt..............',
130 'tt..............',
131 'k...............',
132 '................',
133 '................',
134 ],
135 palette: {
136 O: '#6e4b33',
137 B: '#c08a5b',
138 L: '#ecc9a0',
139 I: '#e8a98a',
140 P: '#f6a6b8',
141 T: '#fffaf0',
142 D: '#5a3d2a',
143 d: '#7a5440',
144 R: '#f6a6b8',
145 S: '#cbd5e1',
146 Y: '#fcd34d',
147 y: '#e0a93a',
148 t: '#f3d9b1',
149 k: '#4b4f5c',
150 },
151 accent: '#ffb27a',
152 ascii: { hat: 'n n', arms: ['o', '/'] },
153 types: ['plan'],
154 },
155 {
156 key: 'bolt',
157 name: 'Bolt',
158 species: 'robot',
159 body: [
160 '.......AA.......',
161 '.......AA.......',
162 '.......RR.......',
163 '...OOOOOOOOOO...',
164 '..OLLLBBBBBBBO..',
165 '.OLRBBBBBBBBRBO.',
166 '.OBSSSSSSSSSSBO.',
167 'ROBSEEESSEEESBOR',
168 'ROBSEEESSEEESBOR',
169 'ROBSEEESSEEESBOR',
170 '.OBPSSSSSSSSPBO.',
171 '.OBSSSMMMMSSSBO.',
172 '.OBSSSMMMMSSSBO.',
173 '.OBSSSSSSSSSSBO.',
174 '.OBBBBBBBBBBBBO.',
175 '..ORRO....ORRO..',
176 ],
177 palette: {
178 O: '#4d5b73',
179 B: '#aebfd6',
180 L: '#dbe6f4',
181 S: '#1f2a3d',
182 R: '#7d8aa3',
183 A: '#fcd34d',
184 P: '#f9a8d4',
185 // The face is a dark screen, so the face ink is a light mint.
186 K: '#6ee7b7',
187 W: '#d1fae5',
188 },
189 accent: '#93c5fd',
190 ascii: { hat: '-=[o]=-', arms: ['[', ']'] },
191 types: ['general-purpose', 'claude'],
192 },
193 {
194 key: 'pip',
195 name: 'Pip',
196 species: 'bookworm',
197 body: [
198 '................',
199 '...O........O...',
200 '....O......O....',
201 '....OOOOOOOO....',
202 '..OOBBBBBBBBOO..',
203 '.OBBBBBBBBBBBBO.',
204 '.OBBEEEBBEEEBBO.',
205 '.OBBEEEBBEEEBBO.',
206 '.OBBEEEBBEEEBBO.',
207 '.OBBBBBBBBBBBBO.',
208 '.OPPBBMMMMBBPPO.',
209 '..OBBBMMMMBBBO..',
210 '...OOBBBBBBOO...',
211 '.OOBBDBBDBBDBOO.',
212 'OBBLLDLLDLLDLBBO',
213 '.OOOOOOOOOOOOOO.',
214 ],
215 prop: [
216 '................',
217 '................',
218 '................',
219 '................',
220 '................',
221 '...GGGGGGGGGG...',
222 '...G...GG...G...',
223 '..GG...GG...GG..',
224 '...G...GG...G...',
225 '...GGGGGGGGGG...',
226 '................',
227 '................',
228 '................',
229 '................',
230 '................',
231 '................',
232 ],
233 palette: {
234 O: '#3d7a4e',
235 B: '#9ee6ad',
236 L: '#d4f7da',
237 D: '#74c98a',
238 P: '#f6a6b8',
239 G: '#6b5b8a',
240 },
241 accent: '#86efac',
242 ascii: { hat: '(o)-(o)' },
243 types: ['claude-code-guide'],
244 },
245 {
246 key: 'bristle',
247 name: 'Bristle',
248 species: 'hedgehog',
249 body: [
250 '...T..T..T..T...',
251 '..TS.TS..ST.ST..',
252 '.TSSSSSSSSSSSST.',
253 '.SSSSSSSSSSSSSS.',
254 'TSSSFFFFFFFFSSST',
255 '.SSFFFFFFFFFFSS.',
256 'TSFFEEEFFEEEFFST',
257 '.SFFEEEFFEEEFFS.',
258 'TSFFEEEFFEEEFFST',
259 '.SFPPFFNNFFPPFS.',
260 'TSFFFFMMMMFFFFST',
261 '.SSFFFMMMMFFFSS.',
262 '.TSSFFFFFFFFSST.',
263 '..SSSSSSSSSSSS..',
264 '...OSSSSSSSSO...',
265 '...HHO....OHH...',
266 ],
267 palette: {
268 O: '#3f3550',
269 S: '#7d6b94',
270 T: '#a996c4',
271 F: '#f6e7d4',
272 N: '#3b3040',
273 P: '#f6a6b8',
274 H: '#e9cfb4',
275 },
276 accent: '#c4b5fd',
277 ascii: { hat: '/\\/\\/\\/\\' },
278 types: ['review', 'security', 'audit'],
279 // A reviewer has many names (security-review, code-reviewer, audit-bot): match any type that holds one.
280 matchSubstring: true,
281 },
282 {
283 key: 'mochi',
284 name: 'Mochi',
285 species: 'slime',
286 body: [
287 '................',
288 '................',
289 '................',
290 '......OOOO......',
291 '....OOBBBBOO....',
292 '...OBLLBBBBBO...',
293 '..OBLLBBBBBBBO..',
294 '.OBBEEEBBEEEBBO.',
295 '.OBBEEEBBEEEBBO.',
296 'OBBBEEEBBEEEBBBO',
297 'OBPPBBBBBBBBPPBO',
298 'OBBBBBMMMMBBBBBO',
299 'OBBBBBMMMMBBBBBO',
300 'OBBBBBBBBBBBBBBO',
301 'ODBBBDDBBDDBBBDO',
302 '.OOOOOOOOOOOOOO.',
303 ],
304 palette: {
305 O: '#d77aa5',
306 B: '#fcd5e5',
307 L: '#fff4f9',
308 D: '#f3a9c9',
309 P: '#f69bb8',
310 },
311 accent: '#f9a8d4',
312 ascii: { hat: '.-~~~-.' },
313 types: ['workflow'],
314 },
315 {
316 key: 'nib',
317 name: 'Nib',
318 species: 'fox kit',
319 body: [
320 '.O............O.',
321 '.OO..........OO.',
322 '.OIO........OIO.',
323 '.OIBO......OBIO.',
324 '.OBBBOOOOOOBBBO.',
325 '.OBBBBBBBBBBBBO.',
326 'OBBBBBBBBBBBBBBO',
327 'OBBBEEEBBEEEBBBO',
328 'OBBBEEEBBEEEBBBO',
329 'OBBBEEEBBEEEBBBO',
330 'OLLLBBBNNBBBLLLO',
331 '.OLLLLMMMMLLLLO.',
332 '..OLLLMMMMLLLO..',
333 '...OOLLLLLLOO...',
334 '....OBBBBBBO....',
335 '....OO....OO....',
336 ],
337 prop: [
338 '................',
339 '................',
340 '................',
341 '................',
342 '................',
343 '................',
344 '................',
345 '................',
346 '................',
347 '................',
348 '..............O.',
349 '.............OLO',
350 '.............OBO',
351 '............OBBO',
352 '...........OBBO.',
353 '...........OOO..',
354 ],
355 palette: {
356 O: '#a4502f',
357 B: '#ffa56b',
358 L: '#fff4ea',
359 I: '#ffc9d6',
360 N: '#3b2a2a',
361 P: '#ff9aa8',
362 },
363 accent: '#fca5a5',
364 ascii: { hat: '/\\ /\\', arms: ['o', '~'] },
365 },
366 {
367 key: 'moss',
368 name: 'Moss',
369 species: 'frog',
370 body: [
371 '................',
372 '.OOOOO....OOOOO.',
373 'OBBBBBO..OBBBBBO',
374 'OBEEEBOOOOBEEEBO',
375 'OBEEEBBBBBBEEEBO',
376 'OBEEEBBBBBBEEEBO',
377 'OBBBBBBBBBBBBBBO',
378 'OBPPBBBBBBBBPPBO',
379 'OBBBBBMMMMBBBBBO',
380 'OBBBBBMMMMBBBBBO',
381 '.OBBBBBBBBBBBBO.',
382 '.OBBLLLLLLLLBBO.',
383 '..OBLLLLLLLLBO..',
384 '..OBBLLLLLLBBO..',
385 '.OOBBOOOOOOBBOO.',
386 'OBBBO......OBBBO',
387 ],
388 palette: {
389 O: '#3d6b2c',
390 B: '#a6d96a',
391 L: '#e6f7c9',
392 P: '#f6a6b8',
393 },
394 accent: '#bef264',
395 ascii: { hat: '.o. .o.', arms: ['v', 'v'] },
396 },
397 {
398 key: 'puff',
399 name: 'Puff',
400 species: 'cloud',
401 body: [
402 '................',
403 '................',
404 '......OOOO......',
405 '..OOOOBBBBOOOO..',
406 '.OBBBOBBBBOBBBO.',
407 '.OBBBBBBBBBBBBO.',
408 'OBBBBBBBBBBBBBBO',
409 'OBBBEEEBBEEEBBBO',
410 'OBBBEEEBBEEEBBBO',
411 'OBBBEEEBBEEEBBBO',
412 'OBPPBBBBBBBBPPBO',
413 'OBBBBBMMMMBBBBBO',
414 'OBBBBBMMMMBBBBBO',
415 'ODDBBBBBBBBBBDDO',
416 '.ODDDDDDDDDDDDO.',
417 '..OOOOOOOOOOOO..',
418 ],
419 palette: {
420 O: '#7ea6d1',
421 B: '#f0f7ff',
422 D: '#cfe2f7',
423 P: '#fbb1c4',
424 },
425 accent: '#bae6fd',
426 ascii: { hat: '.-(~~)-.' },
427 },
428 {
429 key: 'tansy',
430 name: 'Tansy',
431 species: 'bunny',
432 body: [
433 '...OOO....OOO...',
434 '..OBIBO..OBIBO..',
435 '..OBIBO..OBIBO..',
436 '..OBIBO..OBIBO..',
437 '..OBIBO..OBIBO..',
438 '..OBBBOOOOBBBO..',
439 '.OBBBBBBBBBBBBO.',
440 'OBBBBBBBBBBBBBBO',
441 'OBBBEEEBBEEEBBBO',
442 'OBBBEEEBBEEEBBBO',
443 'OBBBEEEBBEEEBBBO',
444 'OBPPBBBBBBBBPPBO',
445 'OBBBBBMMMMBBBBBO',
446 '.OBBBBMMMMBBBBO.',
447 '..OOBBBBBBBBOO..',
448 '...OOO....OOO...',
449 ],
450 palette: {
451 O: '#9b84bd',
452 B: '#f5efff',
453 I: '#fbcfe8',
454 P: '#f9a8c8',
455 },
456 accent: '#fbcfe8',
457 ascii: { hat: '(\\ /)' },
458 },
459]
460
461// ---------------------------------------------------------------------------------------------------------------
462// Variants: past the roster, a buddy comes back with its hues turned and a numbered name.
463
464const ROMAN = ['', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X']
465
466/** 'Digby', 1 → 'Digby II'; 2 → 'Digby III'; up to 'Digby X' (variant 9), then 'Digby 11', 'Digby 12', … */
467export function variantName(name: string, variant: number): string {
468 const v = Number.isFinite(variant) ? Math.max(0, Math.floor(variant)) : 0
469 if (v === 0) return name
470 const roman = ROMAN[v]
471 return `${name} ${roman ?? String(v + 1)}`
472}
473
474const hex2 = (n: number): string => Math.round(Math.min(255, Math.max(0, n))).toString(16).padStart(2, '0')
475
476/** Hue rotation of a #rrggbb colour; greys (saturation < 0.15) and malformed input stay as they are. */
477export function rotateHue(hex: string, degrees: number): string {
478 const m = /^#([0-9a-f]{6})$/i.exec(hex)
479 if (m === null || m[1] === undefined || !Number.isFinite(degrees)) return hex
480 const n = parseInt(m[1], 16)
481 const r = ((n >> 16) & 255) / 255
482 const g = ((n >> 8) & 255) / 255
483 const b = (n & 255) / 255
484 const max = Math.max(r, g, b)
485 const min = Math.min(r, g, b)
486 const l = (max + min) / 2
487 const d = max - min
488 const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1))
489 if (s < 0.15) return hex
490 let h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4
491 h = (((h * 60 + degrees) % 360) + 360) % 360
492 const c = (1 - Math.abs(2 * l - 1)) * s
493 const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
494 const o = l - c / 2
495 const [r1, g1, b1] =
496 h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x] : h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x]
497 return `#${hex2((r1 + o) * 255)}${hex2((g1 + o) * 255)}${hex2((b1 + o) * 255)}`
498}
499
500/** A roster buddy ready to draw; variant > 0 turns every hue by variant × 47° and numbers the name. Never throws. */
501export function resolveBuddy(ref: BuddyRef): ResolvedBuddy {
502 const base = ROSTER.find(b => b.key === ref.key) ?? (ROSTER[0] as Buddy)
503 const variant = Number.isFinite(ref.variant) ? Math.max(0, Math.floor(ref.variant)) : 0
504 if (variant === 0) return { ...base, displayName: base.name, variant: 0 }
505 const turn = (variant * 47) % 360
506 const palette: Record<string, string> = {}
507 for (const [letter, color] of Object.entries(base.palette)) palette[letter] = rotateHue(color, turn)
508 return { ...base, palette, accent: rotateHue(base.accent, turn), displayName: variantName(base.name, variant), variant }
509}
510
511// ---------------------------------------------------------------------------------------------------------------
512// Picking: by agent type first, then by roster order; two live agents never share a buddy.
513
514/** The roster buddy whose default types name this (lowercased) agent type. */
515function defaultFor(type: string): Buddy | undefined {
516 return ROSTER.find(b => (b.types ?? []).some(t => (b.matchSubstring === true ? type.includes(t) : type === t)))
517}
518
519/** The buddy for a new agent. `taken` holds the buddies of live agents; `map` pins types to roster keys. Pure. */
520export function pickBuddy(type: string, taken: readonly BuddyRef[], map: Readonly<Record<string, string>>): BuddyRef {
521 const t = type.toLowerCase()
522 const isRosterKey = (k: string | undefined): k is string => k !== undefined && ROSTER.some(b => b.key === k)
523 const pinned = Object.prototype.hasOwnProperty.call(map, t) ? map[t] : undefined
524 const own = isRosterKey(pinned) ? pinned : (defaultFor(t) ?? (ROSTER[0] as Buddy)).key
525 const isFree = (key: string, variant: number) => !taken.some(r => r.key === key && r.variant === variant)
526 if (isFree(own, 0)) return { key: own, variant: 0 }
527 const next = ROSTER.find(b => isFree(b.key, 0))
528 if (next !== undefined) return { key: next.key, variant: 0 }
529 let n = 1
530 while (!isFree(own, n)) n++
531 return { key: own, variant: n }
532}
533
534/** "Explore=digby, my-agent=moss" → { explore: 'digby', 'my-agent': 'moss' }; splits on , ; and newlines. */
535export function parseBuddyMap(text: string): Record<string, string> {
536 const out: Record<string, string> = {}
537 for (const part of String(text).split(/[,;\n\r]+/)) {
538 const eq = part.indexOf('=')
539 if (eq < 0) continue
540 const type = part.slice(0, eq).trim().toLowerCase()
541 const key = part.slice(eq + 1).trim().toLowerCase()
542 if (type !== '' && key !== '') out[type] = key
543 }
544 return out
545}
546hooks/options.ts 35 lines1// userConfig → Opts. Values can come in as strings (settings UI) or be missing, so every field is read,
2// coerced and clamped here, and the rest of the mod trusts Opts.
3import type { PluginOptions } from 'claude-code'
4
5import type { Animations } from './art'
6
7export type Opts = {
8 autoOpen: boolean
9 keepFinishedMinutes: number
10 animations: Animations
11 buddyMap: string
12 maxLogLines: number
13}
14
15const num = (v: unknown, fallback: number): number => {
16 const n = typeof v === 'number' ? v : Number(v)
17 return Number.isFinite(n) ? n : fallback
18}
19
20const clamp = (n: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, n))
21
22const ANIMATIONS: readonly Animations[] = ['full', 'calm', 'off']
23
24/** Reads the mod's userConfig with defaults true, 10, 'full', '', 300. */
25export function readOptions(options: PluginOptions): Opts {
26 const anim = String(options.animations ?? 'full')
27 return {
28 autoOpen: options.autoOpen !== false && options.autoOpen !== 'false',
29 keepFinishedMinutes: clamp(num(options.keepFinishedMinutes, 10), 0.5, 240),
30 animations: ANIMATIONS.find(a => a === anim) ?? 'full',
31 buddyMap: String(options.buddyMap ?? ''),
32 maxLogLines: clamp(Math.round(num(options.maxLogLines, 300)), 20, 5000),
33 }
34}
35hooks/runtime.ts 72 lines1// Module-level runtime the hooks share. Nothing here must survive a hot reload:
2// everything a drawing reads lives in $.state (see types/index.d.ts). These are guards, cursors and caches.
3import type { Opts } from './options'
4
5export const rt: {
6 opts: Opts
7 /** parseBuddyMap(opts.buddyMap), set in register(). */
8 buddyMap: Record<string, string>
9 /** The one $.clock.every ticker; session.start cancels it before it starts a new one. */
10 timer: { cancel(): void } | null
11 /** Tick counter since session.start (odd ticks poll the agent list). */
12 ticks: number
13 /** Guard: a tick never overlaps the one before. */
14 isTicking: boolean
15 /** The last text sent to $.ui.status; null = never sent. */
16 lastStatusText: string | undefined | null
17 /** animKey of the last now/frame write. */
18 lastKey: string
19 /** Last session.messages read per agent id. */
20 logReadAt: Record<string, number>
21 /** Round-robin index over the live agents that are not selected. */
22 logCursor: number
23 /** Ended agents whose last log is read (one read after the end). */
24 finalRead: Record<string, true>
25 /** Back-off after a { deny }: no read before this time. */
26 deniedUntil: Record<string, number>
27} = {
28 opts: { autoOpen: true, keepFinishedMinutes: 10, animations: 'full', buddyMap: '', maxLogLines: 300 },
29 buddyMap: {},
30 timer: null,
31 ticks: 0,
32 isTicking: false,
33 lastStatusText: null,
34 lastKey: '',
35 logReadAt: {},
36 logCursor: 0,
37 finalRead: {},
38 deniedUntil: {},
39}
40
41/** Resets everything but opts, buddyMap and timer (session.start, hot reload). */
42export function resetRuntime(): void {
43 // After a hot reload, lastStatusText = null sends the status again and finalRead = {} reads each
44 // ended agent one more time. This is intended: both are cheap and make the drawing match the state.
45 rt.ticks = 0
46 rt.isTicking = false
47 rt.lastStatusText = null
48 rt.lastKey = ''
49 rt.logReadAt = {}
50 rt.logCursor = 0
51 rt.finalRead = {}
52 rt.deniedUntil = {}
53}
54
55/** Removes an id from logReadAt, finalRead and deniedUntil (a row expired, dropped or came back to life). */
56export function forgetAgent(id: string): void {
57 delete rt.logReadAt[id]
58 delete rt.finalRead[id]
59 delete rt.deniedUntil[id]
60}
61
62let queue: Promise<unknown> = Promise.resolve()
63
64/** Runs fn after every earlier serial() call settles: one writer at a time for agents/order/logs. */
65export function serial<T>(fn: () => Promise<T>): Promise<T> {
66 // Never call next(e) or a slow $ op (one that waits on the person) inside fn: a held tool call
67 // would block every later writer. A failure rejects this call only; the queue goes on.
68 const p = queue.then(() => fn())
69 queue = p.catch(() => undefined)
70 return p
71}
72hooks/theme.ts 64 lines1// The den's own chrome colors and copy: cute and calm, not loud.
2// Sprite colors come from buddies.ts (each buddy's palette and accent); these are for text, dots and borders.
3import type { DenStatus } from '../types'
4
5export const THEME = {
6 hot: '#6ee7b7',
7 warm: '#fcd34d',
8 cold: '#fb7185',
9 ink: '#dfe7ef',
10 dim: '#8a93a6',
11 panel: '#0f1420',
12 ember: '#ffb27a',
13 frost: '#93c5fd',
14 accent: '#c4b5fd',
15 ghost: '#9aa3b5',
16}
17
18/** Friendly words for each status, used in counts, cards and the status line. */
19export const STATUS_LABEL: Record<DenStatus, string> = {
20 pending: 'hatching',
21 running: 'busy',
22 waiting: 'waiting',
23 idle: 'napping',
24 completed: 'done',
25 failed: 'oops',
26 killed: 'stopped',
27}
28
29/** One-cell dot for each status (header counts and card status lines). */
30export const STATUS_DOT: Record<DenStatus, string> = {
31 running: '●',
32 waiting: '◐',
33 idle: '○',
34 pending: '✦',
35 completed: '✓',
36 failed: '✗',
37 killed: '·',
38}
39
40/** The color of a status dot, a hover border and a failed or killed sprite. */
41export const statusColor = (s: DenStatus): string => {
42 switch (s) {
43 case 'running':
44 case 'completed':
45 return THEME.hot
46 case 'waiting':
47 return THEME.warm
48 case 'idle':
49 return THEME.frost
50 case 'failed':
51 return THEME.cold
52 case 'killed':
53 return THEME.dim
54 case 'pending':
55 return THEME.ember
56 }
57}
58
59/** Display order for the header counts and the summary: live first, then ended. */
60export const STATUS_ORDER: readonly DenStatus[] = ['running', 'waiting', 'idle', 'pending', 'completed', 'failed', 'killed']
61
62/** Card hover background on GUI surfaces. */
63export const CARD_HOVER_BG = '#151c2c'
64hooks/tracker.ts 659 lines1// agent-den tracker: pure functions over AgentRow and LogLine. No $, no clock, no I/O.
2//
3// register.tsx feeds these the agent list, hook events and session messages, and writes what they return.
4// Rules: never mutate an input; return the same object when nothing changed and new objects for changed
5// rows only; never put `undefined` in a row (the host can refuse it), so optional fields are left out with
6// a rest pattern or `delete` on a copy. Text from agents goes through cleanText before it reaches a line.
7import type { AgentRow, BuddyRef, DenStatus, LogLine } from '../types'
8import { transitionFor } from './art'
9import type { Animations } from './art'
10import { STATUS_LABEL, STATUS_ORDER } from './theme'
11
12/** One entry of $.agent.list(), as the tracker reads it. */
13export type ListedAgent = { id: string; description: string; type: string; status: DenStatus; parentId?: string; name?: string }
14/** Picks a buddy for a new row; `taken` holds the refs of live rows. */
15export type PickFn = (type: string, taken: BuddyRef[]) => BuddyRef
16/** What register knows about an id that no list names yet. */
17export type RowHint = { type?: string; description?: string; model?: string; name?: string; parentId?: string }
18
19type Rows = Record<string, AgentRow>
20type Reason = AgentRow['lastReason']
21type ToolUseLike = { readonly tool: string; readonly input: Readonly<Record<string, unknown>>; readonly text?: string; readonly isError?: true }
22type MessageLike = { readonly role: string; readonly text: string; readonly toolUses: readonly ToolUseLike[] }
23
24/** Ids of the demo rows (/agent-den demo). They are never read from session.messages. */
25export const DEMO_PREFIX = 'demo-'
26
27// A log line longer than this is cut: one minified file must not fill the pane.
28const MAX_LINE = 300
29
30/** pending, running, waiting and idle. */
31export function isLive(s: DenStatus): boolean {
32 return s === 'pending' || s === 'running' || s === 'waiting' || s === 'idle'
33}
34
35/** completed, failed and killed. */
36export function isEnded(s: DenStatus): boolean {
37 return s === 'completed' || s === 'failed' || s === 'killed'
38}
39
40/** Removes control characters (ESC included) and turns a tab into 2 spaces; keeps newlines. */
41export function cleanText(text: string): string {
42 // A pane title with a control character is refused, and ESC would let agent output style the terminal.
43 // eslint-disable-next-line no-control-regex
44 return text.replace(/\t/g, ' ').replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g, '')
45}
46
47// Cuts by code points, so an emoji is never split into U+FFFD; the result has at most `max` code points.
48const cut = (s: string, max: number): string => {
49 const cps = [...s]
50 if (cps.length <= max) return s
51 return max <= 0 ? '' : cps.slice(0, max - 1).join('') + '…'
52}
53
54const collapse = (s: string): string => s.replace(/\s+/g, ' ').trim()
55
56// An engine string a card draws (description, type, name): one clean line. A desktop refuses a whole
57// drawing that has a control character in a Text, so the den would show nothing.
58const plain = (s: string, max: number): string => cut(collapse(cleanText(s)), max)
59const DESC_MAX = 200
60const NAME_MAX = 60
61
62/** The first non-empty line of a text, cleaned, trimmed and collapsed, cut to `max` code points with '…'. */
63export function firstLine(text: string, max: number): string {
64 for (const line of cleanText(text).split('\n')) {
65 const t = collapse(line)
66 if (t !== '') return cut(t, max)
67 }
68 return ''
69}
70
71// The non-empty lines of a text, cleaned, with trailing spaces removed.
72const nonEmptyLines = (text: string): string[] =>
73 cleanText(text)
74 .split('\n')
75 .map(l => l.trimEnd())
76 .filter(l => l.trim() !== '')
77
78/** The ended status for a turn that stopped for `reason`: answer (or none) → completed, error|refusal → failed, aborted → killed. */
79function endedStatusFor(reason: Reason): DenStatus {
80 if (reason === 'error' || reason === 'refusal') return 'failed'
81 if (reason === 'aborted') return 'killed'
82 return 'completed'
83}
84
85/** The buddy refs of live rows: a new agent never gets one of these. Demo rows do not hold a buddy. */
86export function liveBuddies(agents: Readonly<Rows>): BuddyRef[] {
87 return Object.values(agents)
88 .filter(r => isLive(r.status) && !r.id.startsWith(DEMO_PREFIX))
89 .map(r => r.buddy)
90}
91
92const sameBuddy = (a: BuddyRef, b: BuddyRef): boolean => a.key === b.key && a.variant === b.variant
93
94// Shallow row equality (buddy by value), so upsertListed reports `changed` only for a real change.
95function sameRow(a: AgentRow, b: AgentRow): boolean {
96 const ka = Object.keys(a) as (keyof AgentRow)[]
97 if (ka.length !== Object.keys(b).length) return false
98 for (const k of ka) {
99 if (k === 'buddy') {
100 if (!sameBuddy(a.buddy, b.buddy)) return false
101 } else if (a[k] !== b[k]) return false
102 }
103 return true
104}
105
106// Sets or removes an optional string field on a fresh copy.
107function setOpt<K extends 'name' | 'parentId' | 'model'>(row: AgentRow, key: K, value: string | undefined): void {
108 if (value === undefined || value === '') delete row[key]
109 else row[key] = value
110}
111
112// Moves a copy to a new status: statusSince on a change, endedAt kept or set when ended, removed when live.
113function withStatus(row: AgentRow, status: DenStatus, now: number): AgentRow {
114 const next: AgentRow = { ...row, status }
115 if (status !== row.status) next.statusSince = now
116 if (isEnded(status)) next.endedAt = row.endedAt ?? now
117 else delete next.endedAt
118 return next
119}
120
121// A list entry with its strings made safe to draw; an empty type becomes 'agent'.
122function cleanListed(a: ListedAgent): ListedAgent {
123 const out: ListedAgent = { ...a, description: plain(a.description, DESC_MAX), type: plain(a.type, NAME_MAX) || 'agent' }
124 if (a.name === undefined) delete out.name
125 else out.name = plain(a.name, NAME_MAX)
126 return out
127}
128
129/** Merges one $.agent.list() result into the rows: new live ids, status changes, and listed rows that dropped out. `added` holds only live rows. */
130export function upsertListed(
131 agents: Readonly<Rows>,
132 order: readonly string[],
133 list: readonly ListedAgent[],
134 now: number,
135 pick: PickFn,
136): { agents: Rows; order: string[]; changed: boolean; added: string[] } {
137 const out: Rows = { ...agents }
138 const nextOrder = [...order]
139 const added: string[] = []
140 const addedBuddies: BuddyRef[] = []
141 const listedIds = new Set<string>()
142 let changed = false
143
144 for (const raw of list) {
145 if (listedIds.has(raw.id)) continue
146 listedIds.add(raw.id)
147 const a = cleanListed(raw)
148 const old = agents[a.id]
149 if (old === undefined) {
150 // An ended agent with no row is never added: the list keeps a subagent some time after it ends, so
151 // a row that clear or expiry removed would come back on the next poll (and auto-open the den after
152 // a /clear). turn.start and tool.call make the row of every run the mod hears.
153 if (isEnded(a.status)) continue
154 const buddy = pick(a.type, [...liveBuddies(agents), ...addedBuddies])
155 const row: AgentRow = {
156 id: a.id,
157 description: a.description,
158 type: a.type,
159 buddy,
160 status: a.status,
161 statusSince: now,
162 startedAt: now,
163 toolCount: 0,
164 turnCount: 0,
165 isListed: true,
166 }
167 setOpt(row, 'name', a.name)
168 setOpt(row, 'parentId', a.parentId)
169 out[a.id] = row
170 if (!nextOrder.includes(a.id)) nextOrder.push(a.id)
171 added.push(a.id)
172 addedBuddies.push(buddy)
173 changed = true
174 continue
175 }
176 let row = withStatus({ ...old, description: a.description, type: a.type, isListed: true }, a.status, now)
177 setOpt(row, 'name', a.name)
178 setOpt(row, 'parentId', a.parentId)
179 // A workflow row (an id no list named) that a list names now: its real type gets its own buddy, one time.
180 if (old.isListed === false && old.type === 'workflow' && a.type !== 'workflow') {
181 const others = Object.values(out).filter(r => r.id !== a.id && isLive(r.status)).map(r => r.buddy)
182 row = { ...row, buddy: pick(a.type, [...others, ...addedBuddies]) }
183 }
184 if (!sameRow(old, row)) {
185 out[a.id] = row
186 changed = true
187 }
188 }
189
190 for (const id of Object.keys(agents)) {
191 const row = agents[id]
192 if (row === undefined || listedIds.has(id) || row.isListed !== true) continue
193 // Demo and workflow rows (isListed false) never reach here: they end only by turn.complete, clear or expiry.
194 if (isLive(row.status)) {
195 out[id] = { ...withStatus(row, endedStatusFor(row.lastReason), now), statusSince: now, endedAt: now, isListed: false }
196 } else {
197 out[id] = { ...row, isListed: false }
198 }
199 changed = true
200 }
201
202 if (!changed) return { agents: agents as Rows, order: order as string[], changed, added }
203 return { agents: out, order: nextOrder, changed, added }
204}
205
206/** Makes a pending, unlisted row for an id that no row has yet (a hook event came first, or a workflow agent). */
207export function ensureRow(
208 agents: Readonly<Rows>,
209 order: readonly string[],
210 id: string,
211 now: number,
212 pick: PickFn,
213 hint?: RowHint,
214): { agents: Rows; order: string[]; added: boolean } {
215 if (agents[id] !== undefined) return { agents: agents as Rows, order: order as string[], added: false }
216 const type = hint?.type === undefined ? 'workflow' : plain(hint.type, NAME_MAX) || 'agent'
217 const row: AgentRow = {
218 id,
219 description: plain(hint?.description ?? '', DESC_MAX),
220 type,
221 buddy: pick(type, liveBuddies(agents)),
222 status: 'pending',
223 statusSince: now,
224 startedAt: now,
225 toolCount: 0,
226 turnCount: 0,
227 isListed: false,
228 }
229 setOpt(row, 'model', hint?.model === undefined ? undefined : plain(hint.model, NAME_MAX))
230 setOpt(row, 'name', hint?.name === undefined ? undefined : plain(hint.name, NAME_MAX))
231 setOpt(row, 'parentId', hint?.parentId)
232 return { agents: { ...agents, [id]: row }, order: order.includes(id) ? [...order] : [...order, id], added: true }
233}
234
235/** A turn of the agent started: running, one more turn, no endedAt. */
236export function onTurnStart(row: AgentRow, now: number): AgentRow {
237 const next = withStatus(row, 'running', now)
238 next.turnCount = row.turnCount + 1
239 return next
240}
241
242/** A turn of the agent ended: no current tool, the answer's first line, and the reason; an unlisted row ends by the reason. */
243export function onTurnComplete(row: AgentRow, reason: Reason, answer: string, now: number): AgentRow {
244 let next: AgentRow = { ...row }
245 delete next.currentTool
246 delete next.lastReason
247 if (reason !== undefined) next.lastReason = reason
248 const line = firstLine(answer, 80)
249 if (line !== '') next.lastText = line
250 // A listed row keeps its status: the list says when it ends. An unlisted one (workflow) has no list.
251 if (!row.isListed) {
252 const status = endedStatusFor(reason)
253 next = isLive(row.status) ? { ...next, status, statusSince: now, endedAt: now } : withStatus(next, status, now)
254 }
255 return next
256}
257
258/** A tool call of the agent started: the current tool, one more tool, running. */
259export function onToolStart(row: AgentRow, label: string, now: number): AgentRow {
260 const next = withStatus(row, 'running', now)
261 next.currentTool = label
262 next.toolCount = row.toolCount + 1
263 return next
264}
265
266const basename = (p: string): string => p.split(/[\\/]/).filter(s => s !== '').pop() ?? ''
267
268/** A short label for a tool call: "Read a.ts", "Bash npm test", "Grep TODO"; at most 48 code points. */
269export function toolLabel(tool: string, input: Readonly<Record<string, unknown>>): string {
270 // Only named keys are read, so reserved keys (tool_use_id, agentId, requestMeta, consent) never reach a label.
271 const str = (key: string): string => {
272 const v = input[key]
273 return typeof v === 'string' ? v : ''
274 }
275 let name = tool
276 let arg = ''
277 switch (tool) {
278 case 'Read':
279 case 'Edit':
280 case 'Write':
281 case 'NotebookEdit':
282 arg = basename(str('file_path') || str('notebook_path'))
283 break
284 case 'Bash':
285 arg = firstLine(str('command'), 200)
286 break
287 case 'Grep':
288 case 'Glob':
289 arg = str('pattern')
290 break
291 case 'WebFetch':
292 try {
293 arg = new URL(str('url')).host
294 } catch {
295 arg = ''
296 }
297 break
298 case 'WebSearch':
299 arg = str('query')
300 break
301 case 'Agent':
302 case 'Task':
303 arg = str('description')
304 break
305 case 'Skill':
306 arg = str('skill')
307 break
308 default:
309 if (tool.startsWith('mcp__')) {
310 const [srv = '', ...rest] = tool.slice(5).split('__')
311 if (srv !== '' && rest.length > 0) name = `${srv}·${rest.join('__')}`
312 }
313 }
314 const text = collapse(cleanText(arg === '' ? name : `${name} ${arg}`))
315 return cut(text, 48)
316}
317
318/** The result or error lines of a tool: up to 2 non-empty lines, each with 2 spaces in front. */
319export function resultLines(text: string, kind: 'result' | 'error', now: number): LogLine[] {
320 return nonEmptyLines(text)
321 .slice(0, 2)
322 .map(l => ({ at: now, kind, text: cut(' ' + l, MAX_LINE) }))
323}
324
325/** An agent's transcript as log lines: the brief, messages, text, tools and their results; the last `max` lines. */
326export function messagesToLog(msgs: readonly MessageLike[], max: number, now: number): LogLine[] {
327 const lines: LogLine[] = []
328 let hasBrief = false
329 for (const m of msgs) {
330 if (m.role === 'user') {
331 // A user message with empty text holds only tool results: those show under their tool line.
332 const t = firstLine(m.text, 200)
333 if (t !== '') {
334 lines.push({ at: now, kind: 'status', text: (hasBrief ? 'message: ' : 'brief: ') + t })
335 hasBrief = true
336 }
337 } else {
338 const text = nonEmptyLines(m.text)
339 for (const l of text.slice(0, 6)) lines.push({ at: now, kind: 'text', text: cut(l, MAX_LINE) })
340 if (text.length > 6) lines.push({ at: now, kind: 'text', text: '…' })
341 }
342 for (const use of m.toolUses) {
343 lines.push({ at: now, kind: 'tool', text: '▸ ' + toolLabel(use.tool, use.input) })
344 if (use.text !== undefined) lines.push(...resultLines(use.text, use.isError === true ? 'error' : 'result', now))
345 }
346 }
347 return max > 0 ? lines.slice(-max) : []
348}
349
350/** What the card shows from a transcript: the tool in flight, the last text line, and the tool count. */
351export function currentFromMessages(msgs: readonly MessageLike[]): { currentTool?: string; lastText?: string; toolCount: number } {
352 const out: { currentTool?: string; lastText?: string; toolCount: number } = { toolCount: 0 }
353 for (const m of msgs) out.toolCount += m.toolUses.length
354 const assistant = msgs.filter(m => m.role === 'assistant')
355 const last = assistant[assistant.length - 1]
356 if (last !== undefined) {
357 const inFlight = last.toolUses.filter(u => u.text === undefined)
358 const use = inFlight[inFlight.length - 1]
359 if (use !== undefined) out.currentTool = toolLabel(use.tool, use.input)
360 }
361 for (let i = assistant.length - 1; i >= 0; i--) {
362 const lines = nonEmptyLines(assistant[i]?.text ?? '')
363 const line = lines[lines.length - 1]
364 if (line !== undefined) {
365 out.lastText = firstLine(line, 80)
366 break
367 }
368 }
369 return out
370}
371
372// Removes ids from rows and order.
373function removeIds(agents: Readonly<Rows>, order: readonly string[], gone: readonly string[]) {
374 const set = new Set(gone)
375 const nextAgents: Rows = {}
376 for (const [id, row] of Object.entries(agents)) if (!set.has(id)) nextAgents[id] = row
377 return { agents: nextAgents, order: order.filter(id => !set.has(id)), set }
378}
379
380/** Removes ended rows that ended `keepMs` ago or more; never removes `keepId` (the selected row). */
381export function expire(
382 agents: Readonly<Rows>,
383 order: readonly string[],
384 now: number,
385 keepMs: number,
386 keepId?: string | null,
387): { agents: Rows; order: string[]; removed: string[] } {
388 // A row with no endedAt counts from statusSince, so it can never stay forever.
389 const removed = Object.values(agents)
390 .filter(r => isEnded(r.status) && r.id !== keepId && now - (r.endedAt ?? r.statusSince) >= keepMs)
391 .map(r => r.id)
392 if (removed.length === 0) return { agents: agents as Rows, order: order as string[], removed }
393 const r = removeIds(agents, order, removed)
394 return { agents: r.agents, order: r.order, removed }
395}
396
397/** Removes every ended row and its log (/agent-den clear, session.end). */
398export function dropEnded(
399 agents: Readonly<Rows>,
400 order: readonly string[],
401 logs: Readonly<Record<string, LogLine[]>>,
402): { agents: Rows; order: string[]; logs: Record<string, LogLine[]>; removed: string[] } {
403 const removed = Object.values(agents)
404 .filter(r => isEnded(r.status))
405 .map(r => r.id)
406 if (removed.length === 0) return { agents: agents as Rows, order: order as string[], logs: logs as Record<string, LogLine[]>, removed }
407 const r = removeIds(agents, order, removed)
408 const nextLogs: Record<string, LogLine[]> = {}
409 for (const [id, lines] of Object.entries(logs)) if (!r.set.has(id)) nextLogs[id] = lines
410 return { agents: r.agents, order: r.order, logs: nextLogs, removed }
411}
412
413/** The status-line text: "🐾 2 busy · 1 napping · 3 done"; undefined with no rows. */
414export function summary(agents: Readonly<Rows>): string | undefined {
415 const rows = Object.values(agents)
416 if (rows.length === 0) return undefined
417 const parts: string[] = []
418 for (const s of STATUS_ORDER) {
419 const n = rows.filter(r => r.status === s).length
420 if (n > 0) parts.push(`${n} ${STATUS_LABEL[s]}`)
421 }
422 return '🐾 ' + parts.join(' · ')
423}
424
425const pad2 = (n: number): string => String(n).padStart(2, '0')
426
427/** "8s", "2m 05s", "1h 03m"; negative or not finite gives "0s". */
428export function formatElapsed(ms: number): string {
429 if (!Number.isFinite(ms) || ms < 0) return '0s'
430 const s = Math.floor(ms / 1000)
431 if (s < 60) return `${s}s`
432 const m = Math.floor(s / 60)
433 if (m < 60) return `${m}m ${pad2(s % 60)}s`
434 return `${Math.floor(m / 60)}h ${pad2(m % 60)}m`
435}
436
437/** How long the agent ran: up to its end, or up to now while live. Never negative. */
438export function elapsedOf(row: AgentRow, now: number): number {
439 return Math.max(0, (row.endedAt ?? now) - row.startedAt)
440}
441
442/** The ASCII frame to draw: `calm` runs at half speed, `off` stays on frame 0, completed hops once and rests on 3. */
443export function asciiFrameFor(row: AgentRow, now: number, frame: number, animations: Animations): number {
444 if (animations === 'off') return 0
445 if (row.status === 'completed') return Math.min(3, Math.floor(Math.max(0, now - row.statusSince) / 500))
446 const f = animations === 'calm' ? Math.floor(frame / 2) : frame
447 return row.status === 'killed' ? f % 2 : f
448}
449
450/** What the drawing shows at (now, frame): the ticker writes frame or now only when this changes. */
451export function animKey(
452 agents: Readonly<Rows>,
453 now: number,
454 frame: number,
455 opts?: { animations?: Animations; keepFinishedMs?: number },
456): string {
457 const animations = opts?.animations ?? 'full'
458 const keepMs = opts?.keepFinishedMs ?? 600_000
459 return Object.values(agents)
460 .map(row => {
461 // % 4: the key changes on every frame while a row animates, and stays put for a row at rest.
462 const f = asciiFrameFor(row, now, frame, animations) % 4
463 const t = transitionFor(row, now, keepMs) ?? '-'
464 return `${row.id}:${row.status}:${f}:${t}:${formatElapsed(elapsedOf(row, now))}`
465 })
466 .join('|')
467}
468
469/** The draw order: `order`, with each child right after its parent (and the parent's earlier children), depth up to 3. */
470export function treeOrder(agents: Readonly<Rows>, order: readonly string[]): { id: string; depth: number }[] {
471 const ids = [...new Set(order)].filter(id => agents[id] !== undefined)
472 const known = new Set(ids)
473 const children = new Map<string, string[]>()
474 const roots: string[] = []
475 for (const id of ids) {
476 const parent = agents[id]?.parentId
477 if (parent !== undefined && parent !== id && known.has(parent)) {
478 const list = children.get(parent) ?? []
479 list.push(id)
480 children.set(parent, list)
481 } else roots.push(id)
482 }
483 const out: { id: string; depth: number }[] = []
484 const seen = new Set<string>()
485 const walk = (id: string, depth: number): void => {
486 if (seen.has(id)) return
487 seen.add(id)
488 out.push({ id, depth })
489 for (const child of children.get(id) ?? []) walk(child, Math.min(3, depth + 1))
490 }
491 for (const id of roots) walk(id, 0)
492 // A parent cycle (a → b → a) has no root: draw what is left at depth 0, so every id shows one time.
493 for (const id of ids) walk(id, 0)
494 return out
495}
496
497/** True when two logs show the same lines (kind and text; `at` is ignored). */
498export function sameLog(a: readonly LogLine[], b: readonly LogLine[]): boolean {
499 if (a.length !== b.length) return false
500 return a.every((l, i) => l.kind === b[i]?.kind && l.text === b[i]?.text)
501}
502
503/** What a ui.close means for the state: the person closed the den → hide it; the log closed → no selection. */
504export function closeEffect(
505 id: string,
506 kind: 'plugin' | 'person' | 'unload',
507 panes: { den: string; log: string },
508): { hideDen?: true; clearSelected?: true } {
509 if (id === panes.den && kind === 'person') return { hideDen: true }
510 if (id === panes.log) return { clearSelected: true }
511 return {}
512}
513
514type DemoSpec = {
515 status: DenStatus
516 key: string
517 type: string
518 description: string
519 agoMs: number
520 extra: Partial<Pick<AgentRow, 'currentTool' | 'lastText' | 'lastReason' | 'model'>>
521 lines: [LogLine['kind'], string][]
522}
523
524const DEMO: readonly DemoSpec[] = [
525 {
526 status: 'pending',
527 key: 'digby',
528 type: 'Explore',
529 description: 'Map the auth module',
530 agoMs: 3_000,
531 extra: {},
532 lines: [
533 ['status', 'brief: Find every place that reads the session token'],
534 ['tool', '▸ Glob src/auth/**'],
535 ['result', ' src/auth/session.ts'],
536 ],
537 },
538 {
539 status: 'running',
540 key: 'bricks',
541 type: 'Plan',
542 description: 'Plan the cache rewrite',
543 agoMs: 95_000,
544 extra: { currentTool: 'Grep TODO', model: 'claude-opus-5-5' },
545 lines: [
546 ['status', 'brief: Plan a rewrite of the cache layer'],
547 ['tool', '▸ Read cache.ts'],
548 ['result', ' export class Cache {'],
549 ['text', 'The cache has two stores. I check where each one is read.'],
550 ['tool', '▸ Grep TODO'],
551 ],
552 },
553 {
554 status: 'waiting',
555 key: 'bolt',
556 type: 'general-purpose',
557 description: 'Run the test suite',
558 agoMs: 140_000,
559 extra: { lastText: 'Waiting for the permission to run npm test.' },
560 lines: [
561 ['status', 'brief: Run the tests and report the failures'],
562 ['tool', '▸ Read package.json'],
563 ['result', ' "test": "vitest run"'],
564 ['tool', '▸ Bash npm test'],
565 ['text', 'Waiting for the permission to run npm test.'],
566 ],
567 },
568 {
569 status: 'idle',
570 key: 'pip',
571 type: 'claude-code-guide',
572 description: 'Answer a hooks question',
573 agoMs: 200_000,
574 extra: { lastText: 'Hooks run in the order they were added.' },
575 lines: [
576 ['status', 'brief: How do hooks order their calls?'],
577 ['tool', '▸ WebFetch docs.claude.com'],
578 ['result', ' Hooks run in the order they were added.'],
579 ['text', 'Hooks run in the order they were added.'],
580 ],
581 },
582 {
583 status: 'completed',
584 key: 'bristle',
585 type: 'security-review',
586 description: 'Review the login form',
587 agoMs: 260_000,
588 extra: { lastText: 'No problems found in the login form.', lastReason: 'answer' },
589 lines: [
590 ['status', 'brief: Review the login form for injection risks'],
591 ['tool', '▸ Read login.tsx'],
592 ['result', ' export function Login() {'],
593 ['tool', '▸ Grep dangerouslySetInnerHTML'],
594 ['result', ' No matches found'],
595 ['text', 'No problems found in the login form.'],
596 ],
597 },
598 {
599 status: 'failed',
600 key: 'mochi',
601 type: 'workflow',
602 description: 'Migrate the database',
603 agoMs: 180_000,
604 extra: { lastText: 'The migration failed.', lastReason: 'error' },
605 lines: [
606 ['status', 'brief: Run the database migration'],
607 ['tool', '▸ Bash npm run migrate'],
608 ['error', ' Error: relation "users" already exists'],
609 ['tool', '▸ Read 0042_users.sql'],
610 ['result', ' CREATE TABLE users ('],
611 ['text', 'The migration failed.'],
612 ],
613 },
614 {
615 status: 'killed',
616 key: 'nib',
617 type: 'teammate',
618 description: 'Watch the build',
619 agoMs: 120_000,
620 extra: { lastText: 'Stopped by the lead.', lastReason: 'aborted' },
621 lines: [
622 ['status', 'brief: Watch the build and report when it is green'],
623 ['tool', '▸ Bash npm run build'],
624 ['result', ' build started'],
625 ['text', 'Stopped by the lead.'],
626 ],
627 },
628]
629
630/** Seven demo rows, one for each status, with fake logs (/agent-den demo). Unlisted, so the poll never ends them. */
631export function demoState(now: number): { agents: Rows; order: string[]; logs: Record<string, LogLine[]> } {
632 const agents: Rows = {}
633 const order: string[] = []
634 const logs: Record<string, LogLine[]> = {}
635 for (const d of DEMO) {
636 const id = DEMO_PREFIX + d.status
637 const tools = d.lines.filter(([kind]) => kind === 'tool').length
638 // statusSince = now, so each one-shot transition plays one time when the demo opens.
639 const row: AgentRow = {
640 id,
641 description: d.description,
642 type: d.type,
643 buddy: { key: d.key, variant: 0 },
644 status: d.status,
645 statusSince: now,
646 startedAt: now - d.agoMs,
647 toolCount: tools,
648 turnCount: d.status === 'pending' ? 0 : 1,
649 isListed: false,
650 ...d.extra,
651 }
652 if (isEnded(d.status)) row.endedAt = now
653 agents[id] = row
654 order.push(id)
655 logs[id] = d.lines.map(([kind, text]) => ({ at: now, kind, text }))
656 }
657 return { agents, order, logs }
658}
659types/index.d.ts 40 lines1// agent-den state contract. Every $.state key the mod reads or writes is declared here.
2// Types only: no imports, no values. A row value is never `undefined`; optional fields are left out instead.
3
4export type DenStatus = 'pending' | 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed'
5export type BuddyRef = { key: string; variant: number } // variant 0 = the roster original
6export type AgentRow = {
7 id: string
8 description: string
9 type: string // AgentInfo.type, or 'workflow' / 'agent' when unlisted
10 name?: string // AgentInfo.name (SendMessage address)
11 model?: string
12 parentId?: string
13 buddy: BuddyRef
14 status: DenStatus
15 statusSince: number // ms; start of the one-shot transition
16 startedAt: number
17 endedAt?: number
18 currentTool?: string // short label, e.g. "Read src/a.ts"
19 toolCount: number
20 turnCount: number
21 lastText?: string // last assistant text line (speech bubble)
22 lastReason?: 'answer' | 'aborted' | 'refusal' | 'error'
23 isListed: boolean // seen in $.agent.list() on the last poll
24}
25export type LogLine = { at: number; kind: 'tool' | 'result' | 'text' | 'error' | 'status'; text: string }
26declare module 'claude-code' {
27 interface PluginState {
28 'agent-den': {
29 agents: Record<string, AgentRow>
30 order: string[] // first-seen order
31 logs: Record<string, LogLine[]> // last read log per agent, capped at maxLogLines
32 selectedId: string | null
33 isDenHidden: boolean // the person hid the den: no auto-open again this session
34 hasAutoOpened: boolean
35 frame: number // terminal animation frame counter
36 now: number // last tick time (drives elapsed + transitions)
37 }
38 }
39}
40