SLOPSHOPPER

agent-den

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

newpaneguardcommandtoaststatus
★ 2v0.1.0MITupdated 2026-10-25stilero/mods-marketplace/plugins/agent-den
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-den
│ ┃ Agent den ✕ › fix the failing auth test and add an audit log call │ ┃ Agent den x: hide │ ┃ .-~~~-. No buddies out yet. They show ● agent-den: agent-den v0.1.0 ready │ ┃ / ᶻz \ Claude starts a subagent. ⏺ Read(src/auth.ts) │ ┃ .-'-------'-. d: demo ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /agent-den │ ⎿ agent-den: Agent den shown. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent den
Agent den x: hide .-~~~-. No buddies out yet. They show up when Claude / ᶻz \ subagent. .-'-------'-. d: demo
Pane · agent-den-log
Pick a buddy in the den
README

agent-den

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.

What you get

  • The den. One card for each subagent: the buddy, its name and the task description, the status, the elapsed time, the agent type, and the current tool (with a spinner) or the last line the agent wrote. Child agents show under their parent. The header counts the agents for each status: ● 2 busy ◐ 1 waiting ✓ 3 done.
  • Ten buddies. Digby the mole, Bricks the beaver, Bolt the robot, Pip the bookworm, Bristle the hedgehog, Mochi the slime, Nib the fox kit, Moss the frog, Puff the cloud and Tansy the bunny. The agent type picks the buddy first (Explore gets Digby, Plan gets Bricks, a review agent gets Bristle), then the order. Two agents that run at the same time never share a buddy. Past ten, a recoloured variant comes in ("Digby II").
  • Animation. Each status has its own loop: an egg that wobbles (hatching), a bounce with sparks (busy), an hourglass (waiting), floating 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.
  • The output pane. Click 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.
  • Easy to hide. The hide button, Esc, or /agent-den hide. A hidden den stays hidden until you open it again.
  • A status line, for example 🐾 2 busy · 1 napping · 3 done.

Meet the buddies

<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">

In the terminal

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

Install

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.

Commands

CommandEffect
/agent-denShow the den, or hide it when it shows
/agent-den show / hideShow 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 clearRemove the finished buddies and the demo buddies now
/agent-den demoShow 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.

Settings (/config rows)

FieldDefaultPurpose
Open automaticallyonOpen the den when the first subagent of a conversation starts
Keep finished buddies (minutes)10A finished buddy stays this long, then waves goodbye (0.5 to 240)
Animationsfullcalm drops the bounce and the confetti and slows the loops; off is static
Buddy mapemptyPin agent types to buddies, for example Explore=digby, my-agent=moss
Max log lines300Lines kept for each agent's output (20 to 5000)

How it works

  • The engine has no event for the life of a subagent. The mod polls $.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.
  • The 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.
  • A ticker (500 ms) reads $.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.
  • The ticker writes the clock and the animation frame to the state only when the drawing changes. A den at rest costs nothing.
  • The SVG of a buddy depends only on its buddy, status, transition and settings. New text never restarts its animation.

Limits

  • Workflow agents are not in the agent list. They show as Mochi with the type 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.
  • The engine's own helper agents (compaction, memory) are not in the agent list and have no brief, so they get no buddy.
  • The output of a workflow agent holds only the live lines: the brief, the tool calls and their results. The engine does not give their messages.
  • Hotkeys work only while the pane holds the keyboard. The output pane takes the keyboard when it opens. The den does not, because you type in the prompt.
  • A /clear starts a new conversation with new state, so a den that you hid can open again for the next subagent.
  • On a terminal narrower than 144 columns, the automatic open waits undrawn. /agent-den shows the den at any width.
  • A subagent's own Skill call gets no live log line. The next read of its messages adds it.

Development

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.

Source 8 files
hooks/register.tsx 1001 lines
1// 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}
1001
hooks/art.ts 676 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
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   \\  ', ".-'-------'-."]
676
hooks/buddies.ts 546 lines
1// 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}
546
hooks/options.ts 35 lines
1// 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}
35
hooks/runtime.ts 72 lines
1// 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}
72
hooks/theme.ts 64 lines
1// 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'
64
hooks/tracker.ts 659 lines
1// 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}
659
types/index.d.ts 40 lines
1// 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