SLOPSHOPPER

agent-arcade

Playful live view of Claude and its subagents: an animated dashboard with usage, model and effort per agent, a stats band, an activity feed and toasts

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-16timoranjes/agent-arcade
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-arcade
│ ┃ 📊 Dashboard ✕ › fix the failing auth test and add an a╭──────────────────────────╮ │ ┃ ▣ client module ./stage.tsx │ agent-arcade │ │ ┃ ⏺ Read(src/auth.ts) │ ⚠️ Bash failed: bun test │ │ ┃ last turn 0s 🔧 9 98.9k tokens 3 fil… ⎿ Read 6 lines ╰──────────────────────────╯ │ ┃ ⏺ Update(src/auth.ts) ╭──────────────────────────────────────╮ │ ┃ ctx 49% │ 5h 31% │ $0.42 ⎿ Added 2 lines, removed │ agent-arcade │ │ ⏺ Bash(bun test) │ 🏁 Turn done · 0s · 9 tools · 3 │ │ ⎿ 3 pass, 1 fail │ files │ │ ╰──────────────────────────────────────╯ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /arcade │ ⎿ agent-arcade: 🕹️ Arcade panes open. │ │ 🕹️ arcade │ opus-5-5 │ ctx ▓▓▓▓░░░░ 49% (97.4k) │ 💰 $0.42 │ 🏃 0 agents │ ⏱ 0s ✔ │ 🔧 9 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
🕹️ arcade │ opus-5-5 │ ctx ▓▓▓▓░░░░ 49% (97.4k) │ 💰 $0.42 │ 🏃 0 agents │ ⏱ 0s ✔ │ 🔧 9
Pane · 📊 Dashboard
▣ client module ./stage.tsx last turn 0s 🔧 9 98.9k tokens 3 files ctx 49% │ 5h 31% │ $0.42
Pane · 📜 Activity
✔ 🧠 Bash cat .env 20ms ✔ 🧠 Bash rm -rf build && git push --force origin… 20ms ✔ 🧠 Bash git status --porcelain 20ms ✘ 🧠 Bash bun test 20ms ✔ 🧠 Write cache.ts 20ms ✔ 🧠 Write audit.ts 20ms ✔ 🧠 Edit auth.ts 20ms ✔ 🧠 Grep "refresh\(" 20ms ✔ 🧠 Read auth.ts 20ms
README

🕹️ agent-arcade

A Claude Code mod that gives you a playful, live view of Claude and its subagents while they work.

What you get

  • 📊 Dashboard pane: an animated stage with one lane per agent, showing each agent's model, effort and token usage (including cache reads).
  • 📜 Activity pane: a running feed of tool calls, tagged by agent, with their targets and whether they succeeded.
  • Stats band above the prompt: model and effort, a context bar (green, amber or red), session cost, running agents, turn timer and tool count.
  • Toasts for:
  • a finished turn (time, tools, files and agents)
  • subagents finishing or stopping
  • denied or failed tools
  • context reaching 80%
  • a rate limit reaching 90%

Install

Clone the repo:

git clone https://github.com/timoranjes/agent-arcade.git

Then point Claude Code at the folder. You can do this for one session:

claude --plugin-dir /path/to/agent-arcade

Or set it permanently in ~/.claude/settings.json:

{
  "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/agent-arcade" }
}

The panes open on their own when a session starts.

Commands

CommandWhat it does
/arcadeShow or hide the Dashboard and Activity panes
/arcade resetClear the feed and finished agents (agents still running are kept)

Layout

.claude-plugin/plugin.json   mod manifest
hooks/hooks.json             loads register.tsx
hooks/register.tsx           event handlers, the /arcade command, panes, toasts
hooks/model.ts               pure state and formatting logic
hooks/views.tsx              band, dashboard and feed rendering
hooks/stage.tsx              animated agent stage
hooks/*.test.ts(x)           tests
types/index.d.ts             shared types

License

MIT

Source 5 files
hooks/register.tsx 346 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { AgentNode, FeedEntry, LimitSample, TurnStats } from '../types'
5import {
6  MAIN,
7  PARK_MS,
8  addSample,
9  addToTurn,
10  addUsage,
11  crossedThresholds,
12  describeTarget,
13  duration,
14  finishFeed,
15  mainNode,
16  patchAgent,
17  pushFeed,
18  runningCount,
19  shortModel,
20  spawnAgent,
21  stageView,
22  totalTokens,
23} from './model'
24import { Band, DashPane, FeedPane, StaticStage } from './views'
25
26const DASH = 'arcade-dash'
27const FEED = 'arcade-feed'
28
29const agents = atom({ plugin: 'agent-arcade', key: 'agents' } as const, {} as Record<string, AgentNode>)
30const feed = atom({ plugin: 'agent-arcade', key: 'feed' } as const, [] as FeedEntry[])
31const turn = atom({ plugin: 'agent-arcade', key: 'turn' } as const, null as TurnStats | null)
32const tick = atom({ plugin: 'agent-arcade', key: 'tick' } as const, 0)
33const stageDown = atom({ plugin: 'agent-arcade', key: 'stageDown' } as const, false)
34const samples = atom({ plugin: 'agent-arcade', key: 'samples' } as const, {} as Record<string, LimitSample[]>)
35const warned = atom({ plugin: 'agent-arcade', key: 'warned' } as const, [] as string[])
36
37/** Bookkeeping must never break Claude's own work: swallow its failures. */
38async function quietly(work: () => Promise<unknown>): Promise<void> {
39  try {
40    await work()
41  } catch {
42    // the view is best-effort
43  }
44}
45
46async function openPanes($: EngineInterface) {
47  await $.ui.open({ id: DASH, title: '📊 Dashboard' })
48  await $.ui.open({ id: FEED, title: '📜 Activity' })
49}
50
51// Redraws spinners and timers while something runs; stopped once all is idle.
52let ticker: Timer | undefined
53let seq = 0
54
55function startTicker($: EngineInterface) {
56  ticker ??= $.clock.every(300, () => void update($, tick, n => n + 1))
57}
58
59async function stopTickerIfIdle($: EngineInterface) {
60  const list = await read($, agents)
61  const isMainBusy = list[MAIN]?.status === 'running'
62  if (!isMainBusy && runningCount(list) === 0) {
63    ticker?.cancel()
64    ticker = undefined
65  }
66}
67
68export const register: Register = on => {
69  ticker = undefined
70
71  on('session.start', async ($, e, next) => {
72    await quietly(async () => {
73      await $.command.register({
74        name: 'arcade',
75        description: 'Agent arcade: /arcade (toggle panes) or /arcade reset',
76      })
77      await update($, stageDown, () => false)
78      void openPanes($)
79    })
80
81    return next(e)
82  })
83
84  on('command.run', { command: 'arcade' }, async ($, e) => {
85    if (e.args.trim() === 'reset') {
86      await update($, feed, () => [])
87      await update($, agents, list =>
88        Object.fromEntries(Object.entries(list).filter(([, n]) => n.status === 'running')),
89      )
90      await update($, warned, () => [])
91      return { text: '🧹 Arcade reset.' }
92    }
93    const panes = await $.ui.panes()
94    const isOpen = panes.some(p => (p.id === DASH || p.id === FEED) && p.isShown)
95    if (isOpen) {
96      await $.ui.close({ id: DASH })
97      await $.ui.close({ id: FEED })
98      return { text: '🕹️ Arcade panes closed. /arcade brings them back.' }
99    }
100    await openPanes($)
101    return { text: '🕹️ Arcade panes open.' }
102  })
103
104  on('turn.start', async ($, e, next) => {
105    await quietly(async () => {
106      const now = await $.clock.now()
107      if (e.agentId === undefined) {
108        const prev = (await read($, agents))[MAIN]
109        await update($, turn, () => ({ startedAt: now, tools: 0, files: [], agents: 0, tokens: 0, cacheRead: 0 }))
110        // A new prompt: keep agents still working in the background, drop finished ones.
111        await update($, agents, list => ({
112          ...Object.fromEntries(Object.entries(list).filter(([id, n]) => id !== MAIN && n.status === 'running')),
113          [MAIN]: { ...mainNode(now), model: prev?.model, effort: prev?.effort },
114        }))
115      } else {
116        const id = e.agentId
117        await update($, agents, list => patchAgent(list, id, () => ({ status: 'running', endedAt: undefined })))
118      }
119      startTicker($)
120    })
121
122    return next(e)
123  })
124
125  // Each request names the model and effort it is really sent with: record them, change nothing.
126  on('turn.step', async function* ($, e, next) {
127    await quietly(async () => {
128      const who = e.agentId ?? MAIN
129      const effort = e.effort === undefined ? undefined : String(e.effort)
130      await update($, agents, list => {
131        const node = list[who]
132        if (node && node.model === e.model && node.effort === effort) return list
133        return patchAgent(list, who, () => ({ model: e.model, effort }))
134      })
135    })
136
137    return yield* next(e)
138  })
139
140  on('agent.spawn', async ($, e, next) => {
141    const spawned = await next(e)
142    await quietly(async () => {
143      if (spawned.deny !== undefined || !spawned.agentId) return
144      const id = spawned.agentId
145      const now = await $.clock.now()
146      await update($, agents, list =>
147        patchAgent(
148          spawnAgent(list, { id, type: e.subagentType, description: e.description, parentId: e.parentAgentId, fork: e.fork }, now),
149          id,
150          () => ({ model: spawned.model }),
151        ),
152      )
153      await update($, turn, t => (t ? { ...t, agents: t.agents + 1 } : t))
154      startTicker($)
155    })
156
157    return spawned
158  }).catch(($, e, next) => next(e)) // an observer: never block a spawn
159
160  // The engine is about to ask the person; the ask clears when that call ends.
161  on('classic.PermissionRequest', async ($, e, next) => {
162    await quietly(async () => {
163      const who = e.agent_id ?? MAIN
164      const target = describeTarget((e.tool_input ?? {}) as Record<string, unknown>)
165      const label = target ? `${e.tool_name} ${target}` : e.tool_name
166      await update($, agents, list => patchAgent(list, who, () => ({ waiting: label })))
167    })
168
169    return next(e)
170  }).catch(($, e, next) => next(e))
171
172  on('tool.call', async ($, e, next) => {
173    const who = e.agentId ?? MAIN
174    const args = e as unknown as Record<string, unknown>
175    const target = describeTarget(args)
176    const callId = `${e.tool_use_id ?? 'call'}-${(seq += 1)}`
177    let startedAt = 0
178
179    await quietly(async () => {
180      startedAt = await $.clock.now()
181      const list = await read($, agents)
182      const node = list[who] ?? list[MAIN]
183      const icon = node?.icon ?? '🧠'
184      const color = node?.color ?? 'claude'
185      const label = `${e.tool}${target ? ` ${target}` : ''}`
186      await update($, agents, all => patchAgent(all, who, () => ({ tool: label })))
187      await update($, feed, f => pushFeed(f, { id: callId, agentId: who, icon, color, tool: e.tool, target, ok: null }))
188      $.ui.status(`${icon} ${node?.type ?? 'Claude'} → ${label}`)
189    })
190
191    const ran = await next(e)
192
193    await quietly(async () => {
194      const ms = Math.round((await $.clock.now()) - startedAt)
195      const isOk = ran.deny === undefined && ran.isError !== true
196      const file = typeof args.file_path === 'string' ? args.file_path : undefined
197      await update($, feed, f => finishFeed(f, callId, isOk, ms))
198      await update($, agents, all =>
199        patchAgent(all, who, n => ({
200          tools: n.tools + 1,
201          failures: (n.failures ?? 0) + (isOk ? 0 : 1),
202          tool: undefined,
203          waiting: undefined,
204        })),
205      )
206      await update($, turn, t => addToTurn(t, e.tool, file))
207      if (ran.deny !== undefined) $.ui.toast(`🚫 ${e.tool} was denied`)
208      else if (!isOk) $.ui.toast(`⚠️ ${e.tool} failed${target ? `: ${target}` : ''}`)
209    })
210
211    return ran
212  }).catch(($, e, next) => next(e)) // an observer: never block a tool
213
214  on('turn.complete', async ($, e, next) => {
215    await quietly(async () => {
216      const now = await $.clock.now()
217      const isOk = e.reason === 'answer'
218      const who = e.agentId ?? MAIN
219      const used = e.usage
220      if (used) {
221        await update($, agents, list => patchAgent(list, who, n => addUsage(n, used)))
222        await update($, turn, t =>
223          t ? { ...t, tokens: (t.tokens ?? 0) + totalTokens(used), cacheRead: (t.cacheRead ?? 0) + used.cache_read_input_tokens } : t,
224        )
225      }
226      if (e.agentId !== undefined) {
227        const id = e.agentId
228        const node = (await read($, agents))[id]
229        await update($, agents, list =>
230          patchAgent(list, id, () => ({ status: isOk ? 'done' : 'failed', endedAt: now, tool: undefined, waiting: undefined })),
231        )
232        // Fold the parked lane into the summary once its time is up.
233        $.clock.after(PARK_MS + 500, () => void update($, tick, n => n + 1))
234        if (node) {
235          const took = duration(now - node.startedAt)
236          $.ui.toast(
237            isOk
238              ? `🎉 ${node.icon} ${node.type} finished · ${took} · ${node.tools} tools`
239              : `💥 ${node.icon} ${node.type} stopped (${e.reason}) after ${took}`,
240          )
241        }
242      } else {
243        await update($, agents, list =>
244          patchAgent(list, MAIN, () => ({ status: isOk ? 'done' : 'failed', endedAt: now, tool: undefined, waiting: undefined })),
245        )
246        const t = await read($, turn)
247        await update($, turn, s => (s ? { ...s, endedAt: now } : s))
248        $.ui.status(undefined)
249        if (t && t.tools > 0) {
250          const files = t.files.length
251          $.ui.toast(
252            `🏁 Turn done · ${duration(now - t.startedAt)} · ${t.tools} tools` +
253              (files ? ` · ${files} file${files === 1 ? '' : 's'}` : '') +
254              (t.agents ? ` · ${t.agents} agent${t.agents === 1 ? '' : 's'}` : ''),
255          )
256        }
257      }
258      await stopTickerIfIdle($)
259    })
260
261    return next(e)
262  })
263
264  // Usage pushed by the engine: keep rate-limit history for the burn rate, warn once past thresholds.
265  on('session.measure', async ($, e, next) => {
266    await quietly(async () => {
267      const now = await $.clock.now()
268      if (e.changed.includes('rateLimits')) {
269        await update($, samples, all => {
270          const out = { ...all }
271          for (const l of e.rateLimits) out[l.kind] = addSample(all[l.kind], { at: now, percent: l.percentUsed })
272          return out
273        })
274      }
275      const fresh = crossedThresholds(e.context.percent, e.rateLimits, await read($, warned))
276      if (fresh.length > 0) {
277        await update($, warned, list => [...list, ...fresh.map(f => f.key)])
278        for (const f of fresh) $.ui.toast(f.text)
279      }
280      await update($, tick, n => n + 1)
281    })
282
283    return next(e)
284  })
285
286  // A stage that failed to load or draw: fall back to the still one.
287  on('ui.fault', async ($, e, next) => {
288    await quietly(() => update($, stageDown, () => true))
289    return next(e)
290  })
291
292  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
293    if (e.props.hasSurvey) return next(e)
294    const elements = $.ui.resolve(e)
295    const list = await read($, agents)
296    const usage = await $.session.usage()
297    const isDashUp = (await $.ui.panes()).some(p => p.id === DASH && p.isShown)
298
299    return Band(elements, {
300      model: list[MAIN]?.model ?? (await $.session.model()),
301      effort: list[MAIN]?.effort,
302      isCompact: isDashUp,
303      percent: usage.context.percent,
304      contextTokens: usage.context.tokens,
305      usd: usage.cost?.usd,
306      running: runningCount(list),
307      turn: await read($, turn),
308      isBusy: list[MAIN]?.status === 'running' || runningCount(list) > 0,
309      now: await $.clock.now(),
310      tick: await read($, tick),
311    })
312  })
313
314  on('ui.render', { component: 'Pane', requestId: DASH }, async ($, e) => {
315    const elements = $.ui.resolve(e)
316    const { Text } = elements
317    const Client = 'Client' in elements ? elements.Client : undefined // terminal and desktop only
318    const list = await read($, agents)
319    const now = await $.clock.now()
320    await read($, tick) // redraw with the clock while busy
321    const rows = e.viewport?.rows ?? 30
322    const view = stageView(list, now, Math.max(1, Math.floor((rows - 12) / 2)))
323    const isBusy = list[MAIN]?.status === 'running' || runningCount(list) > 0
324    const main = view.lanes.find(l => l.id === MAIN)
325    if (main && !isBusy) {
326      main.stats = [shortModel(list[MAIN]?.model), list[MAIN]?.effort ?? '-', '  ready for your next prompt'].join(' ')
327    }
328
329    const canAnimate = Client !== undefined && !(await read($, stageDown))
330    const empty = <Text dimColor>No agents yet. Send a prompt to start the show ✨</Text>
331    const stage =
332      view.lanes.length === 0 ? empty
333      : canAnimate && Client ? <Client key="stage" module="./stage.tsx" width="100%" props={{ lanes: view.lanes }} />
334      : StaticStage(elements, view)
335    const usage = await $.session.usage()
336    const data = { view, usage, samples: await read($, samples), turn: await read($, turn), isBusy, now, columns: e.props.bodyColumns }
337
338    return DashPane(elements, data, stage)
339  })
340
341  on('ui.render', { component: 'Pane', requestId: FEED }, async ($, e) => {
342    const room = Math.max(1, (e.viewport?.rows ?? 24) - 2)
343    return FeedPane($.ui.resolve(e), await read($, feed), room, await read($, tick))
344  })
345}
346
hooks/model.ts 306 lines
1import type { AgentNode, FeedEntry, LimitSample, TurnStats } from '../types'
2
3export const MAIN = 'main'
4export const FEED_CAP = 50
5export const SPINNER = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷']
6
7const STYLES: Record<string, { icon: string; color: string }> = {
8  main: { icon: '🧠', color: 'claude' },
9  Explore: { icon: '🔭', color: 'cyan' },
10  Plan: { icon: '🗺️', color: 'magenta' },
11  'general-purpose': { icon: '🤖', color: 'blue' },
12  fork: { icon: '🧬', color: 'green' },
13  'claude-code-guide': { icon: '📚', color: 'yellow' },
14}
15const FALLBACK_ICONS = ['👾', '🦊', '🐙', '🦉', '🐝', '🚀']
16const FALLBACK_COLORS = ['yellow', 'green', 'cyan', 'magenta', 'blue']
17
18export function styleFor(type: string): { icon: string; color: string } {
19  const known = STYLES[type]
20  if (known) return known
21  let hash = 0
22  for (const ch of type) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
23  return {
24    icon: FALLBACK_ICONS[hash % FALLBACK_ICONS.length] ?? '👾',
25    color: FALLBACK_COLORS[hash % FALLBACK_COLORS.length] ?? 'yellow',
26  }
27}
28
29export function mainNode(now: number): AgentNode {
30  return {
31    id: MAIN,
32    type: 'Claude',
33    description: 'main loop',
34    icon: styleFor('main').icon,
35    color: styleFor('main').color,
36    startedAt: now,
37    status: 'running',
38    tools: 0,
39  }
40}
41
42export function spawnAgent(
43  agents: Record<string, AgentNode>,
44  spawn: { id: string; type: string; description: string; parentId?: string; fork?: boolean },
45  now: number,
46): Record<string, AgentNode> {
47  const style = styleFor(spawn.fork ? 'fork' : spawn.type)
48  return {
49    ...agents,
50    [spawn.id]: {
51      id: spawn.id,
52      type: spawn.fork ? 'fork' : spawn.type,
53      description: spawn.description,
54      parentId: spawn.parentId ?? MAIN,
55      icon: style.icon,
56      color: style.color,
57      startedAt: now,
58      status: 'running',
59      tools: 0,
60    },
61  }
62}
63
64export function patchAgent(
65  agents: Record<string, AgentNode>,
66  id: string,
67  patch: (node: AgentNode) => Partial<AgentNode>,
68): Record<string, AgentNode> {
69  const node = agents[id]
70  return node ? { ...agents, [id]: { ...node, ...patch(node) } } : agents
71}
72
73/** Agents in tree order, each with its depth below the main loop. */
74export function treeOrder(agents: Record<string, AgentNode>): { node: AgentNode; depth: number }[] {
75  const out: { node: AgentNode; depth: number }[] = []
76  const all = Object.values(agents)
77  const seen = new Set<string>()
78  const walk = (node: AgentNode, depth: number) => {
79    if (seen.has(node.id)) return
80    seen.add(node.id)
81    out.push({ node, depth })
82    all
83      .filter(child => child.parentId === node.id)
84      .sort((a, b) => a.startedAt - b.startedAt)
85      .forEach(child => walk(child, depth + 1))
86  }
87  const roots = all.filter(n => n.parentId === undefined || !agents[n.parentId])
88  roots.sort((a, b) => (a.id === MAIN ? -1 : b.id === MAIN ? 1 : a.startedAt - b.startedAt))
89  roots.forEach(root => walk(root, 0))
90  return out
91}
92
93export function runningCount(agents: Record<string, AgentNode>): number {
94  return Object.values(agents).filter(n => n.id !== MAIN && n.status === 'running').length
95}
96
97function short(text: string, max = 48): string {
98  const one = text.replace(/\s+/g, ' ').trim()
99  return one.length > max ? `${one.slice(0, max - 1)}…` : one
100}
101
102function baseName(path: string): string {
103  return path.split(/[\\/]/).filter(Boolean).pop() ?? path
104}
105
106/** A few words for what a tool call is aimed at, from its arguments. */
107export function describeTarget(args: Record<string, unknown>): string {
108  const str = (key: string) => (typeof args[key] === 'string' ? (args[key] as string) : undefined)
109  const file = str('file_path') ?? str('notebook_path')
110  if (file) return baseName(file)
111  const pattern = str('pattern')
112  if (pattern) return `"${short(pattern, 32)}"`
113  const command = str('command')
114  if (command) return short(command, 40)
115  const desc = str('description')
116  if (desc) return short(desc, 40)
117  const url = str('url') ?? str('query') ?? str('skill') ?? str('prompt')
118  return url ? short(url, 40) : ''
119}
120
121export function pushFeed(feed: FeedEntry[], entry: FeedEntry): FeedEntry[] {
122  return [...feed, entry].slice(-FEED_CAP)
123}
124
125export function finishFeed(feed: FeedEntry[], id: string, ok: boolean, ms: number): FeedEntry[] {
126  return feed.map(one => (one.id === id ? { ...one, ok, ms } : one))
127}
128
129export function addToTurn(turn: TurnStats | null, tool: string, file?: string): TurnStats | null {
130  if (!turn) return turn
131  const isWrite = tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit'
132  const files = isWrite && file && !turn.files.includes(file) ? [...turn.files, file] : turn.files
133  return { ...turn, tools: turn.tools + 1, files }
134}
135
136export function duration(ms: number): string {
137  const s = Math.max(0, Math.round(ms / 1000))
138  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
139}
140
141export function bar(percent: number, width = 8): string {
142  const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
143  return '▓'.repeat(filled) + '░'.repeat(width - filled)
144}
145
146export function tokens(n: number): string {
147  return n >= 1000 ? `${Math.round(n / 100) / 10}k` : String(n)
148}
149
150type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
151
152/** All the tokens a call was answered over and generated. */
153export function totalTokens(u: Usage): number {
154  return u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
155}
156
157/** Folds one turn's usage into an agent's running totals. */
158export function addUsage(node: AgentNode, u: Usage): Partial<AgentNode> {
159  return {
160    tokensIn: (node.tokensIn ?? 0) + u.input_tokens + u.cache_creation_input_tokens,
161    tokensOut: (node.tokensOut ?? 0) + u.output_tokens,
162    cacheRead: (node.cacheRead ?? 0) + u.cache_read_input_tokens,
163  }
164}
165
166export function agentTokens(node: AgentNode): number {
167  return (node.tokensIn ?? 0) + (node.tokensOut ?? 0) + (node.cacheRead ?? 0)
168}
169
170/** The share of input the prompt cache served, as a whole percent; undefined before any input. */
171export function cacheRate(cacheRead: number, input: number): number | undefined {
172  const all = cacheRead + input
173  return all > 0 ? Math.round((cacheRead / all) * 100) : undefined
174}
175
176/** `claude-opus-5-5` → `opus-5-5`; a dated suffix dropped. */
177export function shortModel(model: string | undefined): string {
178  if (!model) return '–'
179  return model.replace(/^claude-/, '').replace(/-\d{8}$/, '').replace(/\[1m\]$/, '')
180}
181
182const WINDOW_NAMES: Record<string, string> = { five_hour: '5-hour', seven_day: '7-day', spend_limit: 'spend' }
183const WINDOW_SHORT: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: '$lim' }
184
185export function windowName(kind: string, isShort = false): string {
186  return (isShort ? WINDOW_SHORT[kind] : WINDOW_NAMES[kind]) ?? kind.replace(/_/g, ' ')
187}
188
189function span(ms: number): string {
190  const mins = Math.max(1, Math.round(ms / 60_000))
191  if (mins < 60) return `${mins}m`
192  const hours = Math.floor(mins / 60)
193  if (hours < 24) return `${hours}h ${mins % 60}m`
194  return `${Math.floor(hours / 24)}d ${hours % 24}h`
195}
196
197/** "resets in 2h 10m" for a reset time ahead of now; empty when unknown or past. */
198export function resetsIn(resetsAt: string | undefined, now: number): string {
199  const at = resetsAt ? Date.parse(resetsAt) : NaN
200  return Number.isFinite(at) && at > now ? `resets in ${span(at - now)}` : ''
201}
202
203const SAMPLE_CAP = 30
204const MIN_SPAN_MS = 2 * 60_000
205
206/** Adds a reading; a drop in percent means the window reset, so the history starts over. */
207export function addSample(list: LimitSample[] | undefined, sample: LimitSample): LimitSample[] {
208  const prev = list ?? []
209  const last = prev[prev.length - 1]
210  if (last && sample.percent < last.percent) return [sample]
211  return [...prev, sample].slice(-SAMPLE_CAP)
212}
213
214/**
215 * When the window fills at the pace of its readings: "limit in ~1h 20m".
216 * Empty with too little history, no climb, or a reset that comes first.
217 */
218export function burnRate(list: LimitSample[] | undefined, now: number, resetsAt?: string): string {
219  const first = list?.[0]
220  const last = list?.[list.length - 1]
221  if (!first || !last || last.at - first.at < MIN_SPAN_MS || last.percent <= first.percent) return ''
222  if (last.percent >= 100) return 'limit reached'
223  const perMs = (last.percent - first.percent) / (last.at - first.at)
224  const eta = (100 - last.percent) / perMs - (now - last.at)
225  const reset = resetsAt ? Date.parse(resetsAt) : NaN
226  if (Number.isFinite(reset) && now + eta >= reset) return ''
227  return `limit in ~${span(Math.max(0, eta))}`
228}
229
230/** Thresholds that toast once each: context at 80%, a rate-limit window at 90%. */
231export function crossedThresholds(
232  context: number | undefined,
233  limits: readonly { kind: string; percentUsed: number }[],
234  warned: readonly string[],
235): { key: string; text: string }[] {
236  const out: { key: string; text: string }[] = []
237  if ((context ?? 0) >= 80 && !warned.includes('context')) {
238    out.push({ key: 'context', text: `🟥 Context is ${Math.round(context ?? 0)}% full: /compact soon` })
239  }
240  for (const l of limits) {
241    if (l.percentUsed >= 90 && !warned.includes(l.kind)) {
242      out.push({ key: l.kind, text: `🟥 ${windowName(l.kind)} limit is at ${Math.round(l.percentUsed)}%` })
243    }
244  }
245  return out
246}
247
248/** Bar color by fill: calm, then warning at 60%, error at 80%. */
249export function levelColor(percent: number): 'success' | 'warning' | 'error' {
250  return percent >= 80 ? 'error' : percent >= 60 ? 'warning' : 'success'
251}
252
253/** How long a finished agent stays parked on the stage before it folds into the summary. */
254export const PARK_MS = 30_000
255
256export type Lane = {
257  id: string
258  icon: string
259  color: string
260  name: string
261  status: AgentNode['status']
262  bubble: string
263  waiting: string
264  stats: string
265}
266
267export type StageView = { lanes: Lane[]; folded: { icon: string; name: string; took: string }[] }
268
269/** The stage: Claude first, then running agents, then the ones parked under PARK_MS; the rest folded. */
270export function stageView(agents: Record<string, AgentNode>, now: number, max: number): StageView {
271  const all = Object.values(agents)
272  const main = all.filter(n => n.id === MAIN)
273  const running = all.filter(n => n.id !== MAIN && n.status === 'running').sort((a, b) => a.startedAt - b.startedAt)
274  const ended = all
275    .filter(n => n.id !== MAIN && n.status !== 'running')
276    .sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
277  const parked = ended.filter(n => now - (n.endedAt ?? now) < PARK_MS)
278  const shown = [...main, ...running, ...parked].slice(0, Math.max(1, max))
279  const shownIds = new Set(shown.map(n => n.id))
280
281  return {
282    lanes: shown.map(n => ({
283      id: n.id,
284      icon: n.icon,
285      color: n.color,
286      name: n.type,
287      status: n.status,
288      bubble: n.status === 'running' ? (n.tool ?? '') : '',
289      waiting: n.status === 'running' ? (n.waiting ?? '') : '',
290      stats: laneStats(n, now),
291    })),
292    folded: ended
293      .filter(n => !shownIds.has(n.id))
294      .map(n => ({ icon: n.icon, name: n.type, took: duration((n.endedAt ?? now) - n.startedAt) })),
295  }
296}
297
298function laneStats(n: AgentNode, now: number): string {
299  const parts = [`${shortModel(n.model)} ${n.effort ?? '–'}`, duration((n.endedAt ?? now) - n.startedAt), `🔧 ${n.tools}${n.failures ? ` ✘${n.failures}` : ''}`]
300  const used = agentTokens(n)
301  if (used > 0) parts.push(tokens(used))
302  const rate = cacheRate(n.cacheRead ?? 0, n.tokensIn ?? 0)
303  if (rate !== undefined) parts.push(`cache ${rate}%`)
304  return parts.join('   ')
305}
306
hooks/views.tsx 208 lines
1import type { ElementTable, RenderElement, SessionUsage } from 'claude-code'
2
3import type { FeedEntry, LimitSample, TurnStats } from '../types'
4import type { StageView } from './model'
5import {
6  MAIN,
7  SPINNER,
8  bar,
9  burnRate,
10  cacheRate,
11  duration,
12  levelColor,
13  resetsIn,
14  shortModel,
15  tokens,
16  windowName,
17} from './model'
18
19type El = Pick<ElementTable, 'Box' | 'Text'>
20
21export type BandData = {
22  model: string
23  percent?: number
24  contextTokens?: number
25  usd?: number
26  running: number
27  turn: TurnStats | null
28  isBusy: boolean
29  now: number
30  tick: number
31  /** The main loop's latest effort, from turn.step. */
32  effort?: string
33  /** While the dashboard shows usage, the band keeps to model, time and agents. */
34  isCompact?: boolean
35}
36
37export function Band({ Box, Text }: El, d: BandData) {
38  const spin = SPINNER[d.tick % SPINNER.length]
39  const elapsed = d.turn ? duration((d.turn.endedAt ?? d.now) - d.turn.startedAt) : undefined
40  const pct = d.percent ?? 0
41  const ctxColor = pct >= 80 ? 'error' : pct >= 60 ? 'warning' : 'success'
42
43  return (
44    <Box flexDirection="row" gap={1}>
45      <Text color="claude" weight="bold">🕹️ arcade</Text>
46      <Text dimColor>│</Text>
47      <Text>{shortModel(d.model)}{d.effort ? ` ${d.effort}` : ''}</Text>
48      {d.isCompact ? null : <Text dimColor>│</Text>}
49      {d.isCompact ? null : <Text>
50        ctx <Text color={ctxColor}>{bar(pct)}</Text> {Math.round(pct)}%
51        {d.contextTokens !== undefined ? <Text dimColor> ({tokens(d.contextTokens)})</Text> : null}
52      </Text>}
53      {d.usd !== undefined && !d.isCompact ? <Text dimColor>│</Text> : null}
54      {d.usd !== undefined && !d.isCompact ? <Text>💰 ${d.usd.toFixed(2)}</Text> : null}
55      <Text dimColor>│</Text>
56      <Text color={d.running > 0 ? 'cyan' : undefined} dimColor={d.running === 0}>
57        🏃 {d.running} agent{d.running === 1 ? '' : 's'}
58      </Text>
59      {elapsed ? <Text dimColor>│</Text> : null}
60      {elapsed ? (
61        <Text>
62          ⏱ {elapsed} {d.isBusy ? <Text color="claude">{spin}</Text> : <Text color="success">✔</Text>}
63        </Text>
64      ) : null}
65      {d.turn && d.turn.tools > 0 ? <Text dimColor>│ 🔧 {d.turn.tools}</Text> : null}
66    </Box>
67  )
68}
69
70export function FeedPane({ Box, Text }: El, feed: FeedEntry[], room: number, tick: number) {
71  const spin = SPINNER[tick % SPINNER.length]
72  const shown = feed.slice(-room).reverse()
73
74  return (
75    <Box flexDirection="column">
76      {shown.length === 0 ? <Text dimColor>Quiet in here… tool calls will scroll by 🎮</Text> : null}
77      {shown.map(entry => (
78        <Text key={entry.id} wrap="truncate-end">
79          {entry.ok === null ? <Text color={entry.color}>{spin}</Text>
80            : entry.ok ? <Text color="success">✔</Text>
81            : <Text color="error">✘</Text>}{' '}
82          {entry.icon} <Text color={entry.color} weight="bold">{entry.tool}</Text>
83          {entry.target ? <Text> {entry.target}</Text> : null}
84          {entry.ms !== undefined ? <Text dimColor> {entry.ms < 1000 ? `${entry.ms}ms` : duration(entry.ms)}</Text> : null}
85        </Text>
86      ))}
87    </Box>
88  )
89}
90
91const NARROW = 60
92
93function baseName(path: string): string {
94  return path.split(/[\/]/).filter(Boolean).pop() ?? path
95}
96
97/** The stage drawn once, without motion: for surfaces with no Client, or after the stage failed. */
98export function StaticStage({ Box, Text }: El, view: StageView) {
99  return (
100    <Box flexDirection="column">
101      {view.lanes.map(lane => (
102        <Box key={lane.id} flexDirection="column">
103          <Text wrap="truncate-end">
104            {lane.status === 'failed' ? '💥' : lane.id === MAIN && lane.status !== 'running' ? '💤' : lane.icon}{' '}
105            <Text color={lane.color} weight="bold">{lane.name}</Text>
106            {lane.status === 'done' && lane.id !== MAIN ? <Text color="success"> ✔</Text> : null}
107            {lane.waiting ? <Text color="warning"> ⏸ asked you to approve {lane.waiting}</Text>
108              : lane.bubble ? <Text dimColor> ⟨{lane.bubble}⟩</Text> : null}
109          </Text>
110          <Text wrap="truncate-end" dimColor>   {lane.stats}</Text>
111        </Box>
112      ))}
113    </Box>
114  )
115}
116
117export type DashData = {
118  view: StageView
119  usage: SessionUsage
120  samples: Record<string, LimitSample[]>
121  turn: TurnStats | null
122  isBusy: boolean
123  now: number
124  columns: number
125}
126
127function Meter({ Text }: El, name: string, percent: number, detail: string) {
128  const pct = Math.round(percent)
129  return (
130    <Text key={name} wrap="truncate-end">
131      {name.padEnd(9)}<Text color={levelColor(percent)}>{bar(percent, 10)}</Text>  {String(pct).padStart(3)}%   <Text dimColor>{detail}</Text>
132    </Text>
133  )
134}
135
136/** The dashboard: the stage (handed in, animated or not), then usage, then what changed. */
137export function DashPane(el: El, d: DashData, stage: RenderElement) {
138  const { Box, Text } = el
139  const isNarrow = d.columns < NARROW
140  const ctx = d.usage.context
141  const pct = ctx.percent ?? 0
142  const limits = d.usage.rateLimits
143  const turn = d.turn
144  const turnCache = turn ? cacheRate(turn.cacheRead ?? 0, Math.max(0, (turn.tokens ?? 0) - (turn.cacheRead ?? 0))) : undefined
145  const burns = limits
146    .map(l => ({ kind: l.kind, text: burnRate(d.samples[l.kind], d.now, l.resetsAt) }))
147    .filter(b => b.text)
148  const files = turn?.files ?? []
149  const folded = d.view.folded
150
151  return (
152    <Box flexDirection="column">
153      {stage}
154      {folded.length > 0 ? (
155        <Text wrap="truncate-end" dimColor>
156          <Text color="success">✔</Text> {folded.length} finished
157          {isNarrow ? '' : `: ${folded.slice(0, 4).map(f => `${f.icon} ${f.name} ${f.took}`).join(', ')}${folded.length > 4 ? ', …' : ''}`}
158        </Text>
159      ) : null}
160      <Text> </Text>
161      {!d.isBusy && turn?.endedAt !== undefined ? (
162        <Box flexDirection="column">
163          <Text wrap="truncate-end" dimColor>
164            last turn  {duration(turn.endedAt - turn.startedAt)}   🔧 {turn.tools}
165            {turn.agents ? `   ${turn.agents} agent${turn.agents === 1 ? '' : 's'}` : ''}
166            {turn.tokens ? `   ${tokens(turn.tokens)} tokens` : ''}
167            {files.length ? `   ${files.length} file${files.length === 1 ? '' : 's'}` : ''}
168          </Text>
169          <Text> </Text>
170        </Box>
171      ) : null}
172      {isNarrow ? (
173        <Box flexDirection="column">
174          <Text wrap="truncate-end">
175            ctx <Text color={levelColor(pct)}>{Math.round(pct)}%</Text>
176            {limits.map(l => (
177              <Text key={l.kind}>
178                <Text dimColor> │ </Text>{windowName(l.kind, true)} <Text color={levelColor(l.percentUsed)}>{Math.round(l.percentUsed)}%</Text>
179              </Text>
180            ))}
181            {d.usage.cost ? <Text><Text dimColor> │ </Text>${d.usage.cost.usd.toFixed(2)}</Text> : null}
182          </Text>
183          {burns.map(b => <Text key={b.kind} dimColor wrap="truncate-end">{windowName(b.kind, true)} {b.text}</Text>)}
184        </Box>
185      ) : (
186        <Box flexDirection="column">
187          {Meter(el, 'context', pct, ctx.tokens !== undefined ? `${tokens(ctx.tokens)} of ${tokens(ctx.window)}` : `window ${tokens(ctx.window)}`)}
188          {limits.length === 0 ? <Text dimColor>{'limits'.padEnd(9)}no reading yet: they show after the first reply</Text> : null}
189          {limits.map(l => {
190            const parts = [resetsIn(l.resetsAt, d.now), burns.find(b => b.kind === l.kind)?.text ?? ''].filter(Boolean)
191            return Meter(el, windowName(l.kind), l.percentUsed, parts.join(', '))
192          })}
193          <Text wrap="truncate-end">
194            {'spent'.padEnd(9)}{d.usage.cost ? `$${d.usage.cost.usd.toFixed(2)}` : '–'}
195            {d.isBusy && turn?.tokens ? <Text dimColor>       this turn {tokens(turn.tokens)} tokens{turnCache !== undefined ? `, cache ${turnCache}%` : ''}</Text> : null}
196          </Text>
197          {files.length > 0 ? <Text> </Text> : null}
198          {files.length > 0 ? (
199            <Text wrap="truncate-end">
200              {'changed'.padEnd(9)}<Text dimColor>{files.map(baseName).join('  ')}</Text>
201            </Text>
202          ) : null}
203        </Box>
204      )}
205    </Box>
206  )
207}
208
hooks/stage.tsx 175 lines
1import type { ClientModule, JsonValue } from 'claude-code'
2
3import type { Lane } from './model'
4
5// The stage: one lane per agent, its sprite running while it works.
6// Runs on the surface's own frame clock, so it never waits on the hooks module.
7
8export type StageProps = { lanes: Lane[] }
9
10type Shared = { props: StageProps; end: number }
11type StageState = { box: Shared; pos: Record<string, number>; dir: Record<string, number>; frame: number }
12
13const FRAME_MS = 120
14const LABEL = 13
15const SPRITE = 2 // an emoji takes two cells
16
17function seed(id: string, end: number): number {
18  let hash = 0
19  for (const ch of id) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
20  return end > 0 ? hash % end : 0
21}
22
23function cells(text: string): number {
24  return [...text].length
25}
26
27function clip(text: string, room: number): string {
28  if (room <= 0) return ''
29  const chars = [...text]
30  return chars.length <= room ? text : `${chars.slice(0, Math.max(0, room - 1)).join('')}…`
31}
32
33function startAt(lane: Lane, end: number): number {
34  if (lane.status === 'running') return seed(lane.id, end)
35  return lane.id === 'main' ? 0 : end
36}
37
38/** One frame on: moves what moves; `moved` false when nothing did, so an idle stage costs no redraw. */
39export function step(state: StageState): { next: StageState; moved: boolean } {
40  const { lanes } = state.box.props
41  const end = state.box.end
42  const pos: Record<string, number> = {}
43  const dir: Record<string, number> = {}
44  let moved = Object.keys(state.pos).length !== lanes.length
45
46  for (const lane of lanes) {
47    const was = state.pos[lane.id] ?? startAt(lane, end)
48    let p = Math.min(was, end)
49    let d = state.dir[lane.id] ?? 1
50    const isMainAsleep = lane.id === 'main' && lane.status !== 'running'
51    if (lane.status === 'running' && !lane.waiting) {
52      // Each lane keeps its own pace: a slow one moves every other frame.
53      const isSlow = seed(lane.id, 2) === 1
54      if (!isSlow || state.frame % 2 === 0) {
55        if (p + d > end || p + d < 0) d = -d
56        p += d
57      }
58    } else if (isMainAsleep && p > 0) {
59      p = Math.max(0, p - 2)
60      d = -1
61    } else if (lane.status === 'done' && !isMainAsleep && p < end) {
62      p = Math.min(end, p + 2)
63      d = 1
64    }
65    pos[lane.id] = p
66    dir[lane.id] = d
67    if (p !== was || d !== state.dir[lane.id]) moved = true
68  }
69
70  return { next: { box: state.box, pos, dir, frame: state.frame + 1 }, moved }
71}
72
73type Seg = { at: number; text: string; width: number; color?: string; dim?: boolean }
74
75/** The track's segments left to right; the gaps between are spaces. */
76export function trackSegments(lane: Lane, p: number, d: number, track: number): Seg[] {
77  const isMoving = lane.status === 'running' && !lane.waiting
78  const isMainAsleep = lane.id === 'main' && lane.status !== 'running'
79  const sprite = lane.status === 'failed' ? '💥' : isMainAsleep ? '💤' : lane.icon
80  const segs: Seg[] = [{ at: p, text: sprite, width: SPRITE }]
81  let right = p + SPRITE
82  let left = p
83
84  if (isMoving && d > 0 && p >= 2) {
85    segs.unshift({ at: p - 2, text: '·∙', width: 2, dim: true })
86    left = p - 2
87  }
88  if (isMoving && d < 0 && right + 2 <= track) {
89    segs.push({ at: right, text: '∙·', width: 2, dim: true })
90    right += 2
91  }
92
93  const words = lane.waiting ? `⏸ asked you to approve ${lane.waiting}` : lane.bubble
94  if (words) {
95    const bubble = `⟨${words}⟩`
96    const color = lane.waiting ? 'warning' : undefined
97    const roomRight = track - right - 1
98    const roomLeft = left - 1
99    if (cells(bubble) <= roomRight || roomRight >= roomLeft) {
100      const text = clip(bubble, roomRight)
101      if (roomRight >= 6) segs.push({ at: right + 1, text, width: cells(text), color, dim: !color })
102    } else if (roomLeft >= 6) {
103      const text = clip(bubble, roomLeft)
104      segs.unshift({ at: left - 1 - cells(text), text, width: cells(text), color, dim: !color })
105    }
106  }
107
108  return segs
109}
110
111const Stage: ClientModule<JsonValue, StageState> = (raw, surface) => {
112  const { Box, Text } = surface.elements
113  const props = raw as unknown as StageProps
114  const width = Math.max(24, surface.columns || 60)
115  const track = Math.max(10, width - LABEL - 2)
116  const end = track - SPRITE
117
118  let now = surface.state
119  if (now === undefined) {
120    // First draw: one setState, and the one timer. The timer steps from its own
121    // copy (several ticks can pass between redraws) and hands each frame over
122    // as a new object, which is what makes the surface draw again.
123    let cur: StageState = { box: { props, end }, pos: {}, dir: {}, frame: 0 }
124    now = cur
125    surface.setState(cur)
126    surface.every(FRAME_MS, () => {
127      const { next, moved } = step(cur)
128      cur = next
129      if (moved) surface.setState(next)
130    })
131  }
132  // New props and width reach the running timer through the box every frame shares.
133  now.box.props = props
134  now.box.end = end
135  const state = now
136
137  return Box({
138    flexDirection: 'column',
139    children: props.lanes.map(lane => {
140      const p = Math.min(state.pos[lane.id] ?? startAt(lane, end), end)
141      const segs = trackSegments(lane, p, state.dir[lane.id] ?? 1, track)
142      const parts = []
143      let at = 0
144      for (const seg of segs) {
145        if (seg.at > at) parts.push(Text({ children: ' '.repeat(seg.at - at) }))
146        parts.push(Text({ children: seg.text, color: seg.color, dimColor: seg.dim }))
147        at = Math.max(at, seg.at) + seg.width
148      }
149      if (track > at) parts.push(Text({ children: ' '.repeat(track - at) }))
150      const isFinished = lane.status === 'done' && lane.id !== 'main'
151      const name = clip(lane.name, LABEL - 4).padEnd(LABEL - 4)
152
153      return Box({
154        key: lane.id,
155        flexDirection: 'column',
156        children: [
157          Text({
158            wrap: 'truncate-end',
159            children: [
160              Text({ children: `${lane.icon} ` }),
161              Text({ children: `${name} `, color: lane.color, weight: 'bold' }),
162              Text({ children: '┆', dimColor: true }),
163              ...parts,
164              isFinished ? Text({ children: '✔', color: 'success' }) : Text({ children: '┆', dimColor: true }),
165            ],
166          }),
167          Text({ wrap: 'truncate-end', dimColor: true, children: `${' '.repeat(LABEL + 1)}${lane.stats}` }),
168        ],
169      })
170    }),
171  })
172}
173
174export default Stage
175
types/index.d.ts 65 lines
1export type AgentStatus = 'running' | 'done' | 'failed'
2
3export type AgentNode = {
4  id: string
5  type: string
6  description: string
7  parentId?: string
8  icon: string
9  color: string
10  startedAt: number
11  endedAt?: number
12  status: AgentStatus
13  tool?: string
14  tools: number
15  /** Tokens its turns cost, summed from turn.complete usage. */
16  tokensIn?: number
17  tokensOut?: number
18  cacheRead?: number
19  /** The model and effort of its latest request, from turn.step. */
20  model?: string
21  effort?: string
22  /** Tool calls that were denied or errored. */
23  failures?: number
24  /** Set while a permission dialog it raised has not resolved its call. */
25  waiting?: string
26}
27
28/** One reading of a rate-limit window, for the burn rate. */
29export type LimitSample = { at: number; percent: number }
30
31export type FeedEntry = {
32  id: string
33  agentId: string
34  icon: string
35  color: string
36  tool: string
37  target: string
38  ok: boolean | null
39  ms?: number
40}
41
42export type TurnStats = {
43  startedAt: number
44  endedAt?: number
45  tools: number
46  files: string[]
47  agents: number
48  tokens?: number
49  cacheRead?: number
50}
51
52declare module 'claude-code' {
53  interface PluginState {
54    'agent-arcade': {
55      agents: Record<string, AgentNode>
56      feed: FeedEntry[]
57      turn: TurnStats | null
58      tick: number
59      stageDown: boolean
60      samples: Record<string, LimitSample[]>
61      warned: string[]
62    }
63  }
64}
65