SLOPSHOPPER

agent-warehouse

A vector warehouse where the session's agents are little robots: they walk in when spawned, go to the web, files, desk, bench or mail station as they work…

newpanebandguardcommandmodel
v0.5.0Apache-2.0updated 2026-10-09shaheershoaib/agent-warehouse
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-warehouse
│ ┃ Agent Warehouse ✕ › fix the failing auth test and add an audit log call │ ┃ Agent warehouse │ ┃ 0 working · 0 done ⏺ Read(src/auth.ts) │ ┃ No agents yet. They appear here when Claude ⎿ Read 6 lines │ ┃ starts subagents. ⏺ 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 │ │ › /warehouse │ ⎿ agent-warehouse: Agent warehouse opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent Warehouse
Agent warehouse 0 working · 0 done No agents yet. They appear here when Claude starts subagents.
README

agent-warehouse

A Claude Code mod (Desktop and terminal) by Shaheer Shoaib: your session's agents as little robots in a warehouse. They walk in when they start, work at the station their tools need, celebrate and walk out when they finish.

agent-warehouse with three robots at work

Install

Paste this in a terminal:

curl -fsSL https://raw.githubusercontent.com/shaheershoaib/agent-warehouse/main/install.sh | bash

Then open a new Claude Code session. That's it. Run the same line again any time to update.

It downloads the mod to ~/.claude/mods/agent-warehouse and adds it to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (your other settings are kept, and a backup is made first). Needs python3 or node, which most machines have.

To remove it:

curl -fsSL https://raw.githubusercontent.com/shaheershoaib/agent-warehouse/main/uninstall.sh | bash

In Claude Code:

/plugin marketplace add shaheershoaib/agent-warehouse
/plugin install agent-warehouse@agent-warehouse

Claude Code is still rolling out mods for marketplace plugins. If nothing appears after a new session, use the install line above instead.

What it does

  • Opens on its own when your first agent starts; /warehouse opens it any time.
  • Stations: WEB (web fetches and MCP tools), FILES (reading and searching), DESK (edits), BENCH (shell commands), MAIL (messages and new agents). Agents still thinking wait on the rug.
  • Every robot wears its own color, so you can tell them apart even when they are the same kind of agent.
  • Letters fly between robots when agents message each other. Finished robots celebrate and leave by the exit; failed ones cry under a rain cloud, and keep crying all the way out. An agent counts as failed when Claude Code reports an error, or when its final answer says it could not do the task (a quick check by Claude's small model reads the answer; one that starts with FAILED needs no check).
  • Point at or click a robot to see what it is doing right now and the task it was given. Click its name below the scene to keep those details open.

Good to know

  • Requires a Claude Code build with mods (2.1.286 or later). Tested on macOS; install.sh works on macOS and Linux.
  • Everything runs locally in Claude Code; nothing is sent anywhere.
  • Claude Desktop reloads a mod's pictures whenever its panel updates, so the warehouse can blink briefly when an agent starts, finishes or changes station.
  • Pairs with usage-meter, which shows your live usage above the prompt and requests more from your admins.

License

Apache 2.0: free to use, modify, and share, including commercially. Keep the NOTICE file with any redistribution (§4(d)), and don't market a fork under the agent-warehouse name (§6). The patent grant in §3 means adopting this doesn't expose you to a patent claim over it.

Required Notice: Copyright Shaheer Shoaib (https://github.com/shaheershoaib)

Source 5 files
hooks/register.tsx 511 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Agent, Ping, Strip } from '../types'
5import { backgroundSvg, robotSvg, tagsSvg } from './art'
6import { H, W, asDrawn, enter, freeColor, freeSlot, goTo, goneAt, leave, posAt, reanchor, stationX } from './plan'
7import { verdictOf } from './verdict'
8
9const PANE = 'agents'
10// The strip stays a while after the last agent finishes, then makes room again.
11const LINGER_MS = 30_000
12const TICK_MS = 2_500
13// Robots change station at most this often, all together: one redraw, however busy the agents.
14const MOVE_FLUSH_MS = 4_000
15const MAX_AGENTS = 40
16const MAX_PINGS = 12
17const MIN_COLUMNS = 36
18// About how long a redrawn picture takes to load and start; moving robots are drawn this far ahead.
19const LOAD_LEAD_MS = 150
20const GLYPHS = { running: '●', done: '✓', failed: '✗', stopped: '■' } as const
21
22const agents = atom({ plugin: 'agent-warehouse', key: 'agents' } as const, [])
23const pings = atom({ plugin: 'agent-warehouse', key: 'pings' } as const, [])
24const strip = atom({ plugin: 'agent-warehouse', key: 'strip' } as const, null)
25const selected = atom({ plugin: 'agent-warehouse', key: 'selected' } as const, null)
26
27let counter = 0
28let hasOpened = false
29// What each agent is doing is kept here, not in shared state: a tool call must never redraw the pane.
30const activity = new Map<string, { tool: string; doing: string }>()
31// Where each robot stands, mirrored so a tool call never has to read state.
32const seats = new Map<string, Agent['station']>()
33const wanted = new Map<string, Agent['station']>()
34let isFlushQueued = false
35// Agents whose answer is still being judged: the engine's own list must not settle them first.
36const judging = new Set<string>()
37// Spawns and moves are applied one after another, so each sees the one before it.
38let chain: Promise<unknown> = Promise.resolve()
39const serial = <T,>(work: () => Promise<T>) => {
40  const next = chain.then(work, work)
41  chain = next.catch(() => undefined)
42
43  return next
44}
45
46const clip = (text: string, size: number) => (text.length > size ? `${text.slice(0, size - 1)}…` : text)
47const labelOf = (list: Agent[], id: string) =>
48  id === 'main' ? 'Main' : clip(list.find(agent => agent.id === id)?.label ?? id, 22)
49const elapsed = (ms: number) => {
50  const seconds = Math.max(0, Math.round(ms / 1000))
51
52  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`
53}
54
55// Where a tool is done in the warehouse.
56const stationOf = (tool: string): Agent['station'] => {
57  if (/^(WebFetch|WebSearch)$/.test(tool) || tool.startsWith('mcp__')) {
58    return 'web'
59  }
60  if (/^(Read|Grep|Glob|LS|NotebookRead)$/.test(tool)) {
61    return 'files'
62  }
63  if (/^(Edit|MultiEdit|Write|NotebookEdit)$/.test(tool)) {
64    return 'desk'
65  }
66  if (/^(Bash|BashOutput|KillShell)$/.test(tool)) {
67    return 'bench'
68  }
69  if (/^(SendMessage|Agent|Task)$/.test(tool)) {
70    return 'mail'
71  }
72
73  return 'lounge'
74}
75
76const text = (value: unknown) => (typeof value === 'string' ? value : '')
77const nameOfPath = (path: string) => path.split('/').filter(Boolean).at(-1) ?? path
78const hostOf = (url: string) => {
79  try {
80    return new URL(url).hostname
81  } catch {
82    return clip(url, 30)
83  }
84}
85
86// A short plain-words line for what a tool call is doing.
87const doingOf = (tool: string, input: Record<string, unknown>) => {
88  switch (tool) {
89    case 'Read':
90      return `Reading ${nameOfPath(text(input.file_path))}`
91    case 'Grep':
92      return `Searching for “${clip(text(input.pattern), 30)}”`
93    case 'Glob':
94      return `Finding files: ${clip(text(input.pattern), 30)}`
95    case 'Bash':
96      return `Running ${clip(text(input.command), 44)}`
97    case 'Edit':
98    case 'MultiEdit':
99    case 'Write':
100      return `Editing ${nameOfPath(text(input.file_path))}`
101    case 'WebFetch':
102      return `Fetching ${hostOf(text(input.url))}`
103    case 'WebSearch':
104      return `Searching the web: ${clip(text(input.query), 36)}`
105    case 'SendMessage':
106      return `Messaging ${clip(text(input.to), 20)}`
107    case 'Agent':
108      return `Starting an agent: ${clip(text(input.description), 30)}`
109    default:
110      return tool.startsWith('mcp__') ? `Using ${tool.split('__').slice(1).join(' · ')}` : `Using ${tool}`
111  }
112}
113
114// Who a message is for: an agent by id or by the name it was given. A reply
115// from an agent to anyone else this map does not know goes to the session.
116const addressee = (list: Agent[], to: string, from: string) => {
117  const found = list.find(agent => agent.id === to || agent.name === to)
118  if (found) {
119    return found.id
120  }
121
122  return from === 'main' ? null : 'main'
123}
124
125// The strip speaks only when its words change, so it never redraws for nothing.
126const syncStrip = async ($: EngineInterface) => {
127  const list = await read($, agents)
128  const now = await $.clock.now()
129  const running = list.filter(agent => agent.status === 'running').length
130  const isRecent = list.some(agent => agent.endedAt !== undefined && now - agent.endedAt < LINGER_MS)
131  const plural = (count: number) => `${count} agent${count === 1 ? '' : 's'}`
132  const next: Strip =
133    running > 0 ? { title: `${plural(running)} working` } : isRecent ? { title: `${plural(list.length)} finished` } : null
134  if (JSON.stringify(await read($, strip)) !== JSON.stringify(next)) {
135    await update($, strip, () => next)
136  }
137}
138
139// An agent is done: it cheers (or sulks) where it stands, then walks out.
140const settle = (agent: Agent, status: Agent['status'], now: number): Agent => {
141  const { tool: _tool, ...rest } = leave(agent, status, now)
142  seats.delete(agent.id)
143  activity.delete(agent.id)
144
145  return { ...rest, status, endedAt: now, doing: status === 'done' ? 'Finished' : status === 'failed' ? 'Failed' : 'Stopped' }
146}
147
148// An agent that ended without its own turn.complete (stopped, or failed) is
149// settled from the engine's own list.
150const reconcile = async ($: EngineInterface) => {
151  const before = await read($, agents)
152  if (!before.some(agent => agent.status === 'running')) {
153    return
154  }
155  const live = await $.agent.list()
156  const now = await $.clock.now()
157  const gone = before.filter(agent => {
158    const info = agent.status === 'running' && !judging.has(agent.id) ? live.find(one => one.id === agent.id) : undefined
159
160    return info !== undefined && info.status !== 'running'
161  })
162  if (gone.length > 0) {
163    await update($, agents, list =>
164      list.map(agent => {
165        const info = gone.some(one => one.id === agent.id) ? live.find(one => one.id === agent.id) : undefined
166
167        return info
168          ? settle(agent, info.status === 'completed' ? 'done' : info.status === 'failed' ? 'failed' : 'stopped', now)
169          : agent
170      }),
171    )
172  }
173  await syncStrip($)
174}
175
176const finish = async ($: EngineInterface, id: string, status: Agent['status']) => {
177  const now = await $.clock.now()
178  await serial(() =>
179    update($, agents, list =>
180      list.map(agent => (agent.id === id && agent.status === 'running' ? settle(agent, status, now) : agent)),
181    ),
182  )
183  await syncStrip($)
184  $.clock.after(LINGER_MS + 200, () => void syncStrip($))
185}
186
187// All the stations robots asked for since the last flush, applied together.
188const flush = async ($: EngineInterface) => {
189  isFlushQueued = false
190  const asked = [...wanted]
191  wanted.clear()
192  if (asked.length === 0) {
193    return
194  }
195  const now = await $.clock.now()
196  await serial(() =>
197    update($, agents, list => {
198      // Each robot is seated against the list as the robots before it left it.
199      const next = [...list]
200      for (const [id, st] of asked) {
201        const at = next.findIndex(agent => agent.id === id)
202        const agent = next[at]
203        if (!agent || agent.status !== 'running' || agent.station === st) {
204          continue
205        }
206        seats.set(id, st)
207        next[at] = goTo(agent, st, freeSlot(next, st, id), now)
208      }
209
210      return next
211    }),
212  )
213}
214
215// Picks a robot (or lets go of it): one write, so one redraw.
216const select = async ($: EngineInterface, id: string) => {
217  const before = await read($, selected)
218  await update($, selected, () => (before === id ? null : id))
219}
220
221const openPane = ($: EngineInterface, focus: boolean) =>
222  $.ui.open({ id: PANE, title: 'Agent Warehouse', closeOnEscape: true, ...(focus ? { focus: true as const } : {}) })
223
224export const register: Register = on => {
225  on('session.start', async ($, e, next) => {
226    await $.command.register({
227      name: 'warehouse',
228      description: 'Open the live warehouse of this session’s agents',
229    })
230    $.clock.every(TICK_MS, () => void reconcile($))
231
232    return next(e)
233  })
234
235  on('command.run', { command: 'warehouse' }, async $ => {
236    await openPane($, true)
237
238    return { text: 'Agent warehouse opened.' }
239  })
240
241  on('agent.spawn', async ($, e, next) => {
242    const spawned = await next(e)
243    if (spawned.agentId) {
244      const now = await $.clock.now()
245      const id = spawned.agentId
246      await serial(() =>
247        update($, agents, list => {
248          const slot = freeSlot(list, 'lounge', id)
249          seats.set(id, 'lounge')
250          const born = enter(
251            {
252              id,
253              label: e.description || e.subagentType,
254              type: e.subagentType,
255              parent: e.parentAgentId ?? 'main',
256              ...(e.name ? { name: e.name } : {}),
257              status: 'running',
258              bornAt: now,
259              station: 'lounge',
260              doing: 'Getting started',
261              task: clip(e.prompt.replace(/\s+/g, ' ').trim(), 220),
262              slot,
263              planAt: now,
264              phases: [],
265              color: freeColor(list, now),
266            },
267            now,
268          )
269
270          return [...list, born].slice(-MAX_AGENTS)
271        }),
272      )
273      await syncStrip($)
274      // The first agent of a session opens the warehouse by itself, without taking focus.
275      if (!hasOpened) {
276        hasOpened = true
277        void openPane($, false)
278      }
279    }
280
281    return spawned
282  })
283
284  // A tool call only notes what the agent is up to. It never touches shared
285  // state: that would redraw the pane on every call.
286  on('tool.call', ($, e, next) => {
287    const id = e.agentId
288    if (id) {
289      activity.set(id, { tool: e.tool, doing: doingOf(e.tool, e as unknown as Record<string, unknown>) })
290      const station = stationOf(e.tool)
291      if (seats.has(id) && seats.get(id) !== station) {
292        wanted.set(id, station)
293        if (!isFlushQueued) {
294          isFlushQueued = true
295          $.clock.after(MOVE_FLUSH_MS, () => void flush($))
296        }
297      } else {
298        wanted.delete(id)
299      }
300    }
301
302    return next(e)
303  })
304
305  on('turn.complete', async ($, e, next) => {
306    const id = e.agentId
307    if (id && e.reason === 'answer') {
308      // An answer can still say the task failed. It is judged off the hook's
309      // path, so the model call never holds up the turn.
310      judging.add(id)
311      const task = (await read($, agents)).find(agent => agent.id === id)?.task ?? ''
312      void verdictOf(e.answer, task, (text, labels) => $.model.classify(text, labels))
313        .then(status => finish($, id, status))
314        .catch(() => undefined)
315        .finally(() => judging.delete(id))
316    } else if (id) {
317      await finish($, id, e.reason === 'aborted' ? 'stopped' : 'failed')
318    }
319
320    return next(e)
321  })
322
323  on('session.send', async ($, e, next) => {
324    const list = await read($, agents)
325    const from = e.agentId ?? 'main'
326    const to = addressee(list, e.to, from)
327    if (to) {
328      const now = await $.clock.now()
329      const entry: Ping = { id: (counter += 1), from, to, text: clip(e.text, 140), at: now }
330      void update($, pings, all => [...all, entry].slice(-MAX_PINGS))
331      // The letter flies in the sender's layer, or the receiver's when the session writes.
332      const spot = (id: string) => {
333        const found = list.find(agent => agent.id === id)
334        const at = found ? posAt(found.phases, now) : { x: stationX('mail'), y: 128 }
335
336        return { x: at.x, y: at.y - 34 }
337      }
338      const a = spot(from)
339      const b = spot(to)
340      const owner = from === 'main' ? to : from
341      void serial(() =>
342        update($, agents, all =>
343          all.map(agent =>
344            agent.id === owner && agent.status === 'running'
345              ? { ...reanchor(agent, now), letter: { x0: a.x, y0: a.y, x1: b.x, y1: b.y, at: now } }
346              : agent,
347          ),
348        ),
349      )
350    }
351
352    return next(e)
353  })
354
355  // The strip: a quiet line while agents work, and a way into the warehouse.
356  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
357    const below = await next(e)
358    const line = await read($, strip)
359    if (e.props.hasSurvey || !line) {
360      return below
361    }
362
363    const { Box, Button, Text } = $.ui.resolve(e)
364    const bar = (
365      <Box key="strip" borderStyle="round" borderDimColor paddingX={1} gap={2} alignItems="center" flexWrap="wrap">
366        <Text color="#22d3ee">●</Text>
367        <Box flexGrow={1}>
368          <Text bold>{line.title}</Text>
369        </Box>
370        <Button key="open" label="Open warehouse" onPress={() => openPane($, true)} />
371      </Box>
372    )
373
374    return below.type === 'engine' ? (
375      bar
376    ) : (
377      <Box flexDirection="column">
378        {bar}
379        {below}
380      </Box>
381    )
382  })
383
384  // The warehouse: a still floor, one transparent layer per robot, then who is who.
385  // A robot at rest is a pose that never changes, so redraws leave it alone.
386  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
387    const ui = $.ui.resolve(e)
388    const { Box, Button, Text } = ui
389    const list = await read($, agents)
390    const recent = await read($, pings)
391    const chosen = await read($, selected)
392    const now = await $.clock.now()
393    const columns = e.props.bodyColumns
394    const floor = list.filter(agent => {
395      const out = goneAt(agent)
396
397      return out === null || now < out + 800
398    })
399    const working = list.filter(agent => agent.status === 'running').length
400    const done = list.filter(agent => agent.status === 'done').length
401    const failed = list.filter(agent => agent.status === 'failed').length
402    const counts = `${working} working · ${done} done${failed ? ` · ${failed} failed` : ''}`
403    const talk = recent.slice(-2)
404    const pick = floor.find(agent => agent.id === chosen)
405    const doingNow = pick ? (activity.get(pick.id)?.doing ?? pick.doing) : ''
406
407    const notes = (
408      <Box flexDirection="column">
409        {pick ? (
410          <Box flexDirection="column">
411            <Box gap={1}>
412              <Text color={pick.color}>●</Text>
413              <Text bold>{clip(pick.label, 40)}</Text>
414              <Text dimColor>
415                {pick.type} · {pick.status} · {elapsed((pick.endedAt ?? now) - pick.bornAt)}
416              </Text>
417            </Box>
418            <Text dimColor>Doing</Text>
419            <Text>{clip(doingNow, 160)}</Text>
420            <Text dimColor>Task</Text>
421            <Text>{clip(pick.task || 'No task text.', 220)}</Text>
422          </Box>
423        ) : (
424          <Box flexDirection="column">
425            <Text bold>Agent warehouse</Text>
426            <Text dimColor>{counts}</Text>
427            {working > 0 && <Text dimColor>Point at or click a robot, or click its name, to see what it is doing.</Text>}
428            {list.length === 0 && <Text dimColor>No agents yet. They appear here when Claude starts subagents.</Text>}
429          </Box>
430        )}
431        {talk.map(ping => (
432          <Text key={`ping-${ping.id}`} dimColor>
433            {labelOf(list, ping.from)} → {labelOf(list, ping.to)}: {clip(ping.text, 60)}
434          </Text>
435        ))}
436      </Box>
437    )
438
439    if (!('Svg' in ui) || columns < MIN_COLUMNS) {
440      // A surface without pictures, or too narrow for them: a plain list.
441      return (
442        <Box flexDirection="column" gap={1}>
443          {list
444            .slice(-10)
445            .reverse()
446            .map(agent => (
447              <Box key={`row-${agent.id}`} gap={1}>
448                <Text color={agent.color}>{GLYPHS[agent.status]}</Text>
449                <Text bold>{clip(agent.label, 28)}</Text>
450                <Text dimColor>{clip(activity.get(agent.id)?.doing ?? agent.doing, 40)}</Text>
451              </Box>
452            ))}
453          {notes}
454        </Box>
455      )
456    }
457
458    const width = Math.min(640, Math.max(300, Math.round(columns * 9)))
459    const height = Math.round((width * H) / W)
460    const drawn = floor.map(agent => asDrawn(agent, now + LOAD_LEAD_MS))
461
462    return (
463      <Box flexDirection="column" gap={1}>
464        <Box position="relative">
465          <Box key="floor">
466            <ui.Svg source={backgroundSvg(width, height)} alt="The warehouse floor" width={width} height={height} isInteractive />
467          </Box>
468          {drawn.map(agent => (
469            <Box key={`bot-${agent.id}`} position="absolute" top={0} left={0}>
470              <ui.Svg
471                source={robotSvg(agent, { width, height, selected: agent.id === chosen })}
472                alt={`${agent.label}: ${activity.get(agent.id)?.doing ?? agent.doing}`}
473                width={width}
474                height={height}
475                isInteractive
476              />
477            </Box>
478          ))}
479          {drawn.length > 0 && (
480            <Box key="tags" position="absolute" top={0} left={0}>
481              <ui.Svg
482                source={tagsSvg(
483                  drawn.map(agent => ({ agent, doing: activity.get(agent.id)?.doing ?? agent.doing })),
484                  { width, height },
485                )}
486                alt="Point at or click a robot to see what it is doing"
487                width={width}
488                height={height}
489                isInteractive
490              />
491            </Box>
492          )}
493        </Box>
494        <Box gap={1} flexWrap="wrap">
495          {floor
496            .filter(agent => agent.status === 'running')
497            .map(agent => (
498              <Button
499                key={`pick-${agent.id}`}
500                variant={agent.id === chosen ? 'primary' : 'secondary'}
501                label={`${GLYPHS.running} ${clip(agent.label, 16)}`}
502                onPress={() => select($, agent.id)}
503              />
504            ))}
505        </Box>
506        {notes}
507      </Box>
508    )
509  })
510}
511
hooks/art.ts 377 lines
1import type { Agent, Phase } from '../types'
2import { H, LANE, STATIONS, W, WALL, stationX } from './plan'
3
4// Vector art for the warehouse: one still background, and one transparent layer
5// per robot. A robot's layer only changes when that robot's own plan does, so
6// the others keep walking undisturbed.
7const INK = '#14161c'
8const FONT = 'system-ui,-apple-system,Segoe UI,sans-serif'
9// Without a color scheme, Desktop's sandboxed frame paints an opaque white square.
10const SCHEME = '<style>:root{color-scheme:light dark}</style>'
11
12const part = (c: string, at: number) => parseInt(c.slice(at, at + 2), 16)
13const two = (n: number) => Math.round(Math.min(255, Math.max(0, n))).toString(16).padStart(2, '0')
14const shade = (c: string, k: number) => `#${two(part(c, 1) * k)}${two(part(c, 3) * k)}${two(part(c, 5) * k)}`
15const mix = (a: string, b: string, k: number) =>
16  `#${two(part(a, 1) * (1 - k) + part(b, 1) * k)}${two(part(a, 3) * (1 - k) + part(b, 3) * k)}${two(part(a, 5) * (1 - k) + part(b, 5) * k)}`
17const n = (v: number) => Math.round(v * 100) / 100
18const sec = (ms: number) => `${n(Math.max(0, ms) / 1000)}s`
19const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
20
21const open = (w: number, h: number) =>
22  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${w}" height="${h}">${SCHEME}`
23
24// ---- the room -------------------------------------------------------------
25
26const station = (id: (typeof STATIONS)[number]) => {
27  const x = stationX(id) - 28
28  const y = 24
29  let art = ''
30  if (id === 'web') {
31    art =
32      '<rect x="3" y="6" width="50" height="34" rx="4" fill="#cbd5e1"/><rect x="6" y="9" width="44" height="28" rx="2.5" fill="#0b1e3a"/>' +
33      '<circle cx="28" cy="23" r="11" fill="#0e3a66" stroke="#38bdf8" stroke-width="1.6"/>' +
34      '<ellipse cx="28" cy="23" rx="4.5" ry="11" fill="none" stroke="#38bdf8" stroke-width="1"><animate attributeName="rx" values="4.5;0.6;4.5" dur="3s" repeatCount="indefinite"/></ellipse>' +
35      '<path d="M17 23h22M19 17.5h18M19 28.5h18" stroke="#38bdf8" stroke-width="0.8" fill="none"/>' +
36      '<rect x="6" y="9" width="44" height="2" fill="#7dd3fc" fill-opacity="0.35"><animate attributeName="y" values="9;35;9" dur="3.4s" repeatCount="indefinite"/></rect>' +
37      '<rect x="24" y="40" width="8" height="6" fill="#94a3b8"/><rect x="14" y="46" width="28" height="4" rx="2" fill="#64748b"/>'
38  } else if (id === 'files') {
39    const hues = ['#ef4444', '#3b82f6', '#22c55e', '#f59e0b', '#a855f7', '#14b8a6', '#ec4899']
40    const books = [0, 1, 2].map(row =>
41      Array.from({ length: 7 }, (_, i) => {
42        const tall = 11 + ((i * 5 + row * 3) % 6)
43
44        return `<rect x="${6 + i * 6.2}" y="${15 + row * 13 - tall + 11}" width="5" height="${tall}" rx="1" fill="${hues[(i + row * 2) % hues.length]}"/>`
45      }).join(''),
46    ).join('')
47    art =
48      '<rect x="2" y="2" width="52" height="50" rx="3" fill="#4a3524"/>' +
49      [0, 1, 2].map(row => `<rect x="2" y="${15 + row * 13}" width="52" height="3" fill="#8b6a47"/>`).join('') +
50      books +
51      '<rect x="6" y="2" width="5" height="11" fill="#fff" fill-opacity="0"><animate attributeName="fill-opacity" values="0;0;0.7;0" dur="4.5s" repeatCount="indefinite"/></rect>'
52  } else if (id === 'desk') {
53    art =
54      '<rect x="8" y="4" width="40" height="29" rx="3" fill="#334155"/><rect x="11" y="7" width="34" height="23" rx="1.5" fill="#0f172a"/>' +
55      [['#4ade80', 10], ['#fbbf24', 18], ['#f472b6', 13], ['#38bdf8', 22]]
56        .map(([c, w], i) => `<rect x="14" y="${10 + i * 5}" width="${w}" height="2.4" rx="1" fill="${c}"><animate attributeName="width" values="${w};${Number(w) + 7};${w}" dur="${2 + i * 0.6}s" repeatCount="indefinite"/></rect>`)
57        .join('') +
58      '<rect x="24" y="33" width="8" height="4" fill="#64748b"/><rect x="0" y="38" width="56" height="9" rx="2" fill="#a16207"/><rect x="10" y="40" width="22" height="3" rx="1" fill="#e5e7eb"/>' +
59      '<rect x="4" y="47" width="4" height="9" fill="#78350f"/><rect x="48" y="47" width="4" height="9" fill="#78350f"/>'
60  } else if (id === 'bench') {
61    art =
62      '<rect x="2" y="34" width="52" height="9" rx="2" fill="#94a3b8"/><rect x="5" y="43" width="4" height="13" fill="#64748b"/><rect x="47" y="43" width="4" height="13" fill="#64748b"/>' +
63      '<g transform="translate(18 21)"><g><circle r="10" fill="none" stroke="#f59e0b" stroke-width="4" stroke-dasharray="5.2 4.4"/><circle r="7" fill="#b45309"/><circle r="2.5" fill="#fde68a"/>' +
64      '<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="5s" repeatCount="indefinite"/></g></g>' +
65      '<g transform="translate(37 22)"><g><circle r="6" fill="none" stroke="#fb923c" stroke-width="3" stroke-dasharray="3.2 2.8"/><circle r="3.4" fill="#9a3412"/>' +
66      '<animateTransform attributeName="transform" type="rotate" from="360" to="0" dur="3.4s" repeatCount="indefinite"/></g></g>' +
67      '<g fill="#fde047"><circle cx="46" cy="30" r="1.4"><animate attributeName="opacity" values="1;0;1" dur="0.5s" repeatCount="indefinite"/></circle>' +
68      '<circle cx="49" cy="27" r="1"><animate attributeName="opacity" values="0;1;0" dur="0.5s" repeatCount="indefinite"/></circle></g>'
69  } else {
70    art =
71      '<rect x="27" y="2" width="2.5" height="24" fill="#94a3b8"/><circle cx="28" cy="3" r="3" fill="#fde047"><animate attributeName="fill-opacity" values="1;0.2;1" dur="1.2s" repeatCount="indefinite"/></circle>' +
72      '<circle cx="28" cy="3" r="3" fill="none" stroke="#fde047" stroke-width="1"><animate attributeName="r" values="3;12" dur="2s" repeatCount="indefinite"/><animate attributeName="stroke-opacity" values="0.8;0" dur="2s" repeatCount="indefinite"/></circle>' +
73      '<rect x="8" y="26" width="40" height="28" rx="8" fill="#ef4444"/><rect x="14" y="34" width="28" height="4" rx="2" fill="#7f1d1d"/>' +
74      '<rect x="42" y="14" width="3" height="16" fill="#cbd5e1"/><path d="M45 14h9v7h-9z" fill="#fde047"><animateTransform attributeName="transform" type="skewY" values="0;-8;0" dur="2.4s" repeatCount="indefinite"/></path>'
75  }
76  const label = `<text x="28" y="-6" text-anchor="middle" font-family="${FONT}" font-size="8" letter-spacing="1.4" fill="#8b93a7">${id.toUpperCase()}</text>`
77
78  return `<g transform="translate(${x} ${y})">${label}${art}</g>`
79}
80
81export const backgroundSvg = (w: number, h: number) => {
82  const tiles = Array.from({ length: Math.ceil((H - WALL) / 20) }, (_, row) =>
83    Array.from({ length: Math.ceil(W / 20) }, (_, col) =>
84      (row + col) % 2 === 0 ? `<rect x="${col * 20}" y="${WALL + row * 20}" width="20" height="20" fill="#3b4150"/>` : '',
85    ).join(''),
86  ).join('')
87  const dashes = Array.from({ length: 14 }, (_, i) => `<rect x="${10 + i * 29}" y="${LANE + 11}" width="14" height="2.4" rx="1" fill="#8a7a2c"/>`).join('')
88  const windows = [83, 161, 239, 317]
89    .map(x => `<rect x="${x - 17}" y="8" width="34" height="18" rx="2" fill="#27405f"/><rect x="${x - 15}" y="10" width="30" height="14" rx="1" fill="#4a79ad" fill-opacity="0.55"/><path d="M${x} 10v14M${x - 15} 17h30" stroke="#27405f" stroke-width="1.2"/>`)
90    .join('')
91
92  return (
93    open(w, h) +
94    `<defs><linearGradient id="wall" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2f3544"/><stop offset="1" stop-color="#262b37"/></linearGradient>` +
95    `<linearGradient id="shade" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#000" stop-opacity="0.18"/><stop offset="0.35" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity="0.22"/></linearGradient></defs>` +
96    `<rect width="${W}" height="${H}" fill="#363c4a"/>${tiles}` +
97    `<rect y="${WALL - 2}" width="${W}" height="4" fill="#14161c" fill-opacity="0.55"/>` +
98    `<rect width="${W}" height="${WALL - 2}" fill="url(#wall)"/>${windows}` +
99    `<rect x="0" y="${WALL - 6}" width="${W}" height="4" fill="#1b1e27"/>` +
100    `<text x="${W / 2}" y="${WALL - 11}" text-anchor="middle" font-family="${FONT}" font-size="0" fill="#000">.</text>` +
101    `<rect x="132" y="190" width="136" height="32" rx="12" fill="#4b3f63"/><rect x="140" y="196" width="120" height="20" rx="8" fill="#5b4d77"/>` +
102    dashes +
103    `<rect x="0" y="${LANE - 34}" width="9" height="46" rx="2" fill="#0e1015"/><rect x="0" y="${LANE - 40}" width="22" height="9" rx="3" fill="#166534"/><text x="11" y="${LANE - 33}" text-anchor="middle" font-family="${FONT}" font-size="6.5" font-weight="700" fill="#bbf7d0">IN</text>` +
104    `<rect x="${W - 9}" y="${LANE - 34}" width="9" height="46" rx="2" fill="#0e1015"/><rect x="${W - 28}" y="${LANE - 40}" width="28" height="9" rx="3" fill="#991b1b"/><text x="${W - 14}" y="${LANE - 33}" text-anchor="middle" font-family="${FONT}" font-size="6.5" font-weight="700" fill="#fecaca">EXIT</text>` +
105    STATIONS.map(station).join('') +
106    `<rect width="${W}" height="${H}" fill="url(#shade)"/></svg>`
107  )
108}
109
110// ---- a robot --------------------------------------------------------------
111
112const ICONS: Record<Agent['station'], string> = {
113  web:
114    '<circle r="5.2" fill="none" stroke="#0284c7" stroke-width="1.4"/><ellipse rx="2.2" ry="5.2" fill="none" stroke="#0284c7" stroke-width="1"><animate attributeName="rx" values="2.2;0.4;2.2" dur="1.8s" repeatCount="indefinite"/></ellipse><path d="M-5.2 0h10.4" stroke="#0284c7" stroke-width="1"/>',
115  files:
116    '<g><circle cx="-1" cy="-1" r="3.6" fill="none" stroke="#7c3aed" stroke-width="1.5"/><path d="M1.8 1.8 5.4 5.4" stroke="#7c3aed" stroke-width="2" stroke-linecap="round"/><animateTransform attributeName="transform" type="translate" values="-2,0;2,0;-2,0" dur="1.4s" repeatCount="indefinite"/></g>',
117  desk:
118    '<g><path d="M-4 4 3 -3 5 -1 -2 6z" fill="#f59e0b"/><path d="M-4 4-5 7-2 6z" fill="#92400e"/><animateTransform attributeName="transform" type="translate" values="0,0;1.5,-1;0,0" dur="0.5s" repeatCount="indefinite"/></g>',
119  bench:
120    '<g><circle r="4.6" fill="none" stroke="#f59e0b" stroke-width="2" stroke-dasharray="2.4 2.4"/><circle r="2.2" fill="none" stroke="#f59e0b" stroke-width="1.4"/><animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="2.6s" repeatCount="indefinite"/></g>',
121  mail:
122    '<rect x="-5.5" y="-3.6" width="11" height="7.2" rx="1" fill="#fff" stroke="#ef4444" stroke-width="1.2"/><path d="M-5.5 -3.6 0 1 5.5 -3.6" fill="none" stroke="#ef4444" stroke-width="1.2"/>',
123  lounge:
124    '<circle cx="-4.5" r="1.5" fill="#64748b"><animate attributeName="opacity" values="0.2;1;0.2" dur="1.2s" repeatCount="indefinite"/></circle><circle r="1.5" fill="#64748b"><animate attributeName="opacity" values="0.2;1;0.2" dur="1.2s" begin="0.2s" repeatCount="indefinite"/></circle><circle cx="4.5" r="1.5" fill="#64748b"><animate attributeName="opacity" values="0.2;1;0.2" dur="1.2s" begin="0.4s" repeatCount="indefinite"/></circle>',
125}
126
127type Timeline = { start: Pt; moves: { dur: number; values: string; keyTimes: string } | null; walks: [number, number][]; work: number | null; cheer: [number, number] | null; sulk: [number, number] | null }
128type Pt = { x: number; y: number }
129
130// Reads a plan as seconds from its own anchor, so the same plan always gives the same drawing.
131const timeline = (phases: Phase[], anchor: number): Timeline => {
132  const rel = (t: number) => Math.max(0, (t - anchor) / 1000)
133  const first = phases[0]
134  const start = first ? first.from : { x: 0, y: 0 }
135  const pts: { t: number; p: Pt }[] = [{ t: 0, p: start }]
136  const walks: [number, number][] = []
137  let work: number | null = null
138  let cheer: [number, number] | null = null
139  let sulk: [number, number] | null = null
140  for (const ph of phases) {
141    const end = ph.t1 === null ? ph.t0 : ph.t1
142    if (rel(ph.t0) > (pts.at(-1)?.t ?? 0)) {
143      pts.push({ t: rel(ph.t0), p: pts.at(-1)?.p ?? start })
144    }
145    pts.push({ t: rel(end), p: ph.to })
146    if (ph.kind === 'walk') {
147      walks.push([rel(ph.t0), rel(end)])
148    } else if (ph.kind === 'work') {
149      work = rel(ph.t0)
150    } else if (ph.kind === 'cheer') {
151      cheer = [rel(ph.t0), rel(end)]
152    } else {
153      sulk = [rel(ph.t0), rel(end)]
154    }
155  }
156  const total = pts.at(-1)?.t ?? 0
157  if (total <= 0) {
158    return { start, moves: null, walks, work, cheer, sulk }
159  }
160  const kept = pts.filter((pt, i) => i === pts.length - 1 || (pts[i + 1]?.t ?? 0) > pt.t)
161  const values = kept.map(pt => `${n(pt.p.x)},${n(pt.p.y)}`).join(';')
162  const keyTimes = kept.map(pt => n(pt.t / total)).join(';')
163
164  return { start, moves: { dur: total, values, keyTimes }, walks, work, cheer, sulk }
165}
166
167// The walk a robot's layer and its target on the top layer both follow.
168const motion = (tl: Timeline) =>
169  tl.moves
170    ? `<animateTransform attributeName="transform" type="translate" values="${tl.moves.values}" keyTimes="${tl.moves.keyTimes}" dur="${n(tl.moves.dur)}s" begin="0s" fill="freeze"/>`
171    : ''
172
173const SAD_GREY = '#7b8190'
174const SCALE = 0.84
175
176export const robotSvg = (
177  a: Agent,
178  o: { width: number; height: number; selected: boolean },
179) => {
180  const tl = timeline(a.phases, a.planAt)
181  const body = a.color
182  const dark = shade(body, 0.66)
183  const light = shade(body, 1.25)
184  const ms = (t: number) => sec(t * 1000)
185  const window = (w: [number, number]) => `begin="${ms(w[0])}" dur="${ms(w[1] - w[0])}"`
186  const show = (w: [number, number] | number) =>
187    typeof w === 'number'
188      ? `<set attributeName="opacity" to="1" begin="${ms(w)}" fill="freeze"/>`
189      : `<set attributeName="opacity" to="1" ${window(w)}/>`
190  const hide = (w: [number, number] | null) => (w ? `<set attributeName="opacity" to="0" ${window(w)}/>` : '')
191  const cycle = (type: string, w: [number, number], period: string, values: string) =>
192    `<animateTransform attributeName="transform" type="${type}" values="${values}" dur="${period}" begin="${ms(w[0])}" repeatDur="${ms(w[1] - w[0])}"/>`
193  const swing = (side: 1 | -1) =>
194    tl.work === null
195      ? ''
196      : `<animateTransform attributeName="transform" type="rotate" values="${side * -26} ${side * 11} -22;${side * 22} ${side * 11} -22;${side * -26} ${side * 11} -22" dur="0.7s" begin="${ms(tl.work)}" repeatCount="indefinite"/>`
197
198  // A failed robot is sad from its sulk (or at once, redrawn after it) until it is out of sight.
199  const sad = tl.sulk ? tl.sulk[0] : a.status === 'failed' ? 0 : null
200  const bob =
201    tl.walks.map(w => cycle('translate', w, '0.42s', '0,0;0,-2.4;0,0')).join('') +
202    (tl.cheer ? cycle('translate', tl.cheer, '0.5s', '0,0;0,-16;0,0') : '') +
203    (tl.sulk ? cycle('translate', tl.sulk, '0.34s', '0,0;0,1.4;0,0') : '')
204  const legL = tl.walks.map(w => cycle('rotate', w, '0.42s', '-24 -5 -9;24 -5 -9;-24 -5 -9')).join('')
205  const legR = tl.walks.map(w => cycle('rotate', w, '0.42s', '24 5 -9;-24 5 -9;24 5 -9')).join('')
206  const tint = sad === null ? '' : `<animate attributeName="fill" to="${mix(body, SAD_GREY, 0.72)}" begin="${ms(sad)}" dur="0.4s" fill="freeze"/>`
207  const move = motion(tl)
208
209  const eye = (cx: number) =>
210    `<ellipse cx="${cx}" cy="-36" rx="3.7" ry="3.9" fill="#f8fafc"><animate attributeName="ry" values="3.9;3.9;0.5;3.9" keyTimes="0;0.93;0.965;1" dur="4.2s" begin="${cx > 0 ? 0.35 : 0}s" repeatCount="indefinite"/></ellipse>` +
211    `<circle cx="${cx + 0.7}" cy="-35.4" r="1.9" fill="#0f172a"/><circle cx="${cx + 1.3}" cy="-36.3" r="0.7" fill="#fff"/>`
212  const sadEye = (cx: number) =>
213    `<path d="M${cx - 3.4} -37q3.4 -3 6.8 0" fill="none" stroke="#f8fafc" stroke-width="1.6" stroke-linecap="round"/><circle cx="${cx}" cy="-32.6" r="1.2" fill="#93c5fd"/>`
214  const mouth = sad !== null
215    ? `<path d="M-3 -27.4q3 -2.8 6 0" fill="none" stroke="#f8fafc" stroke-width="1.4" stroke-linecap="round"/>`
216    : tl.cheer
217      ? `<path d="M-4.6 -30q4.6 6 9.2 0z" fill="#f8fafc"/>`
218      : `<path d="M-3.4 -30q3.4 3 6.8 0" fill="none" stroke="#f8fafc" stroke-width="1.4" stroke-linecap="round"/>`
219
220  const bubble =
221    tl.work === null
222      ? ''
223      : `<g opacity="0" transform="translate(0 -68)">${show(tl.work)}<g><animateTransform attributeName="transform" type="translate" values="0,0;0,-2.4;0,0" dur="1.6s" repeatCount="indefinite"/>` +
224        '<path d="M-14 -10h28a4.5 4.5 0 0 1 4.5 4.5v11a4.5 4.5 0 0 1-4.5 4.5h-9.5l-4.5 5.5-4.5-5.5h-9.5a4.5 4.5 0 0 1-4.5-4.5v-11a4.5 4.5 0 0 1 4.5-4.5z" fill="#f8fafc" stroke="#14161c" stroke-width="1.2"/>' +
225        `<g transform="translate(0 -2.5)">${ICONS[a.station]}</g></g></g>`
226  const stars = tl.cheer
227    ? `<g opacity="0">${show(tl.cheer)}` +
228      ([[-19, -52], [19, -50], [-13, -66], [15, -64]] as const)
229        .map(([x, y], i) => `<path d="M${x} ${y - 4}l1.3 2.7 3 .4-2.2 2 .6 3-2.7-1.5-2.7 1.5.6-3-2.2-2 3-.4z" fill="#fde047"><animate attributeName="opacity" values="1;0.2;1" dur="${n(0.5 + i * 0.1)}s" repeatCount="indefinite"/></path>`)
230        .join('') +
231      '</g>'
232    : ''
233  // Tears well at the outer corner of each eye and roll off the cheek, two at a time.
234  const tear = (cx: number, side: 1 | -1, delay: number) =>
235    `<path class="tear" d="M${n(cx + side * 2.6)} -35.2q2.1 2.9 1.7 4.2a1.7 1.7 0 0 1-3.4 0q-.4-1.3 1.7-4.2z" fill="#60a5fa" stroke="#e0f2fe" stroke-width="0.5">` +
236    `<animateTransform attributeName="transform" type="translate" values="0,0;${side * 1.6},6;${side * 4},17" keyTimes="0;0.35;1" dur="0.9s" begin="${ms((sad ?? 0) + delay)}" repeatCount="indefinite"/>` +
237    `<animate attributeName="opacity" values="1;1;0" keyTimes="0;0.6;1" dur="0.9s" begin="${ms((sad ?? 0) + delay)}" repeatCount="indefinite"/></path>`
238  const tears =
239    sad === null ? '' : `<g opacity="0">${show(sad)}${tear(-4.8, -1, 0)}${tear(-4.8, -1, 0.45)}${tear(4.8, 1, 0.2)}${tear(4.8, 1, 0.65)}</g>`
240  const cloud = sad !== null
241    ? `<g opacity="0" transform="translate(0 -64)">${show(sad)}<ellipse cx="-6" cy="0" rx="9" ry="5.5" fill="#64748b"/><ellipse cx="5" cy="-2" rx="10" ry="6.5" fill="#64748b"/><ellipse cx="0" cy="2" rx="14" ry="4.5" fill="#64748b"/>` +
242      [-6, 0, 6]
243        .map((x, i) => `<path d="M${x} 7v4" stroke="#60a5fa" stroke-width="1.6" stroke-linecap="round"><animateTransform attributeName="transform" type="translate" values="0,0;0,10" dur="0.7s" begin="${n(i * 0.2)}s" repeatCount="indefinite"/><animate attributeName="opacity" values="1;0" dur="0.7s" begin="${n(i * 0.2)}s" repeatCount="indefinite"/></path>`)
244        .join('') +
245      '</g>'
246    : ''
247  const ring = o.selected
248    ? '<ellipse cx="0" cy="0" rx="23" ry="6.5" fill="none" stroke="#fde047" stroke-width="1.8" stroke-dasharray="6 4"><animate attributeName="stroke-dashoffset" from="0" to="20" dur="1.2s" repeatCount="indefinite"/></ellipse>'
249    : ''
250  const letter = a.letter
251    ? (() => {
252        const { x0, y0, x1, y1 } = a.letter
253        const path = `M${n(x0)} ${n(y0)}Q${n((x0 + x1) / 2)} ${n(Math.min(y0, y1) - 46)} ${n(x1)} ${n(y1)}`
254
255        return (
256          `<g opacity="0"><set attributeName="opacity" to="1" begin="0s" dur="1.15s"/><animateMotion dur="1.1s" path="${path}" fill="freeze"/>` +
257          '<rect x="-7" y="-5" width="14" height="10" rx="1.6" fill="#f8fafc" stroke="#ef4444" stroke-width="1.2"/><path d="M-7 -5 0 1 7 -5" fill="none" stroke="#ef4444" stroke-width="1.2"/></g>'
258        )
259      })()
260    : ''
261
262  const art =
263    `<ellipse cx="0" cy="0.6" rx="13" ry="3.4" fill="#000" fill-opacity="0.3"/>${ring}` +
264    `<g>${bob}` +
265    `<g><rect x="-8" y="-9" width="6" height="9.6" rx="2.6" fill="${INK}"/>${legL}</g><g><rect x="2" y="-9" width="6" height="9.6" rx="2.6" fill="${INK}"/>${legR}</g>` +
266    `<rect x="-11" y="-26" width="22" height="19" rx="6" fill="${dark}" stroke="${INK}" stroke-width="1.4">${tint}</rect>` +
267    '<rect x="-6.5" y="-22" width="13" height="8" rx="2" fill="#0f172a"/>' +
268    '<circle cx="-2.6" cy="-18" r="1.7" fill="#4ade80"><animate attributeName="opacity" values="1;0.25;1" dur="1.3s" repeatCount="indefinite"/></circle><circle cx="2.6" cy="-18" r="1.7" fill="#38bdf8"><animate attributeName="opacity" values="0.25;1;0.25" dur="1.3s" repeatCount="indefinite"/></circle>' +
269    `<g>${hide(tl.cheer)}<g><rect x="-15.8" y="-24" width="4.6" height="12.5" rx="2.3" fill="${dark}" stroke="${INK}" stroke-width="1">${tint}</rect>${swing(1)}</g><g><rect x="11.2" y="-24" width="4.6" height="12.5" rx="2.3" fill="${dark}" stroke="${INK}" stroke-width="1">${tint}</rect>${swing(-1)}</g></g>` +
270    (tl.cheer
271      ? `<g opacity="0">${show(tl.cheer)}<rect x="-19" y="-38" width="4.6" height="13" rx="2.3" fill="${dark}" stroke="${INK}" stroke-width="1" transform="rotate(-22 -15 -25)"/><rect x="14.4" y="-38" width="4.6" height="13" rx="2.3" fill="${dark}" stroke="${INK}" stroke-width="1" transform="rotate(22 15 -25)"/></g>`
272      : '') +
273    `<rect x="-17.4" y="-40" width="3.6" height="8.6" rx="1.8" fill="${dark}" stroke="${INK}" stroke-width="1"/><rect x="13.8" y="-40" width="3.6" height="8.6" rx="1.8" fill="${dark}" stroke="${INK}" stroke-width="1"/>` +
274    `<rect x="-14" y="-47" width="28" height="22" rx="9" fill="${body}" stroke="${INK}" stroke-width="1.6">${tint}</rect>` +
275    `<path d="M-9.5 -44.4q9.5 -3.4 19 0" fill="none" stroke="${light}" stroke-width="1.8" stroke-linecap="round" stroke-opacity="0.8"/>` +
276    '<rect x="-10.6" y="-42.4" width="21.2" height="15.4" rx="6" fill="#0f172a"/>' +
277    '<circle cx="-8.2" cy="-30.6" r="2" fill="#fb7185" fill-opacity="0.5"/><circle cx="8.2" cy="-30.6" r="2" fill="#fb7185" fill-opacity="0.5"/>' +
278    `<g>${sad === null ? hide(tl.sulk) : `<set attributeName="opacity" to="0" begin="${ms(sad)}" fill="freeze"/>`}${eye(-4.8)}${eye(4.8)}</g>` +
279    (sad !== null ? `<g opacity="0">${show(sad)}${sadEye(-4.8)}${sadEye(4.8)}</g>${tears}` : '') +
280    mouth +
281    `<path d="M0 -47v-6" stroke="${INK}" stroke-width="1.6" stroke-linecap="round"/><circle cx="0" cy="-55" r="2.8" fill="${tl.work !== null ? '#fde047' : light}" stroke="${INK}" stroke-width="1"><animate attributeName="fill-opacity" values="1;0.35;1" dur="1.4s" repeatCount="indefinite"/></circle>` +
282    `${stars}${cloud}${bubble}</g>`
283
284  return (
285    open(o.width, o.height) +
286    `<g transform="translate(${n(tl.start.x)} ${n(tl.start.y)})">${move}<g transform="scale(${SCALE})">${art}</g></g>${letter}</svg>`
287  )
288}
289
290// ---- who is who -------------------------------------------------------------
291
292// Each robot is its own frame and only the top frame hears the pointer, so this
293// last layer carries an invisible target that walks with each robot and the card
294// pointing at it or clicking it shows. All of it is CSS inside the frame: no
295// message to the plugin, so no redraw.
296export type Tag = { agent: Agent; doing: string }
297
298// Up to `lines` lines of at most `size` characters, an ellipsis where it was cut.
299const wrap = (text: string, size: number, lines: number) => {
300  const out: string[] = []
301  let line = ''
302  for (const word of text.split(/\s+/).filter(Boolean)) {
303    const piece = word.length > size ? `${word.slice(0, size - 1)}…` : word
304    if (!line || `${line} ${piece}`.length <= size) {
305      line = line ? `${line} ${piece}` : piece
306      continue
307    }
308    out.push(line)
309    line = piece
310    if (out.length === lines) {
311      const last = out[lines - 1] ?? ''
312      out[lines - 1] = `${last.length < size ? last : last.slice(0, size - 1)}…`
313
314      return out
315    }
316  }
317
318  return line ? [...out, line] : out
319}
320
321export const tagsSvg = (tags: Tag[], o: { width: number; height: number }) => {
322  // Sized in screen pixels, whatever width the scene is drawn at.
323  const k = W / o.width
324  const body = n(12 * k)
325  const head = n(13 * k)
326  const gap = n(16 * k)
327  const pad = n(9 * k)
328  const left = 8
329  const wide = W - 2 * left
330  const size = Math.max(20, Math.floor((wide - 2 * pad - 10 * k) / (body * 0.56)))
331
332  const style =
333    '<style>:root{color-scheme:light dark}.h{cursor:pointer;outline:none}.r,.c{opacity:0;pointer-events:none}' +
334    '.h:hover .r,.h:focus .r{opacity:1}' +
335    tags.map((_, i) => `#h${i}:hover~#c${i}{opacity:1}`).join('') +
336    tags.map((_, i) => `svg:not(:has(.h:hover)) #h${i}:focus~#c${i}{opacity:1}`).join('') +
337    '</style>'
338
339  const targets = tags
340    .map(({ agent }, i) => {
341      const tl = timeline(agent.phases, agent.planAt)
342
343      return (
344        `<g id="h${i}" class="h" tabindex="0" transform="translate(${n(tl.start.x)} ${n(tl.start.y)})">${motion(tl)}` +
345        '<ellipse class="r" cx="0" cy="0.6" rx="21" ry="6" fill="none" stroke="#fde047" stroke-width="1.6" stroke-dasharray="5 3.5"/>' +
346        '<rect x="-18" y="-52" width="36" height="56" fill="#000" fill-opacity="0"/></g>'
347      )
348    })
349    .join('')
350
351  const cards = tags
352    .map(({ agent, doing }, i) => {
353      const meta = `${agent.type} · ${agent.status}`
354      const rows: { text: string; size: number; fill: string; weight?: string }[] = [
355        { text: wrap(agent.label, size - 4, 1)[0] ?? '', size: head, fill: '#f8fafc', weight: '700' },
356        { text: meta, size: body, fill: '#9aa3b5' },
357        { text: wrap(`Doing: ${doing || 'Thinking'}`, size, 1)[0] ?? '', size: body, fill: '#e2e8f0' },
358        ...wrap(`Task: ${agent.task || 'No task text.'}`, size, 2).map(text => ({ text, size: body, fill: '#cbd5e1' })),
359      ]
360      const tall = n(2 * pad + rows.length * gap - (gap - head) / 2)
361      const text = rows
362        .map(
363          (row, r) =>
364            `<text x="${n(left + pad + (r === 0 ? 10 * k : 0))}" y="${n(6 + pad + head * 0.8 + r * gap)}" font-family="${FONT}" font-size="${row.size}"${row.weight ? ` font-weight="${row.weight}"` : ''} fill="${row.fill}">${esc(row.text)}</text>`,
365        )
366        .join('')
367
368      return (
369        `<g id="c${i}" class="c"><rect x="${left}" y="6" width="${wide}" height="${tall}" rx="${n(7 * k)}" fill="#0f1117" fill-opacity="0.94" stroke="${agent.color}" stroke-width="${n(1.4 * k)}"/>` +
370        `<circle cx="${n(left + pad + 3.5 * k)}" cy="${n(6 + pad + head * 0.45)}" r="${n(3.5 * k)}" fill="${agent.color}"/>${text}</g>`
371      )
372    })
373    .join('')
374
375  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${o.width}" height="${o.height}">${style}${targets}${cards}</svg>`
376}
377
hooks/plan.ts 180 lines
1import type { Agent, Phase } from '../types'
2
3// The warehouse floor, in the units of the scene's viewBox. A robot's day is a
4// list of timed phases; nothing here depends on a clock but the times it is given.
5export const W = 400
6export const H = 250
7export const WALL = 80
8export const LANE = 226
9const SPEED = 80
10export const CHEER_MS = 1500
11export const SULK_MS = 4000
12// How long a letter is drawn after it is sent.
13export const LETTER_MS = 1_500
14
15export type Pt = { x: number; y: number }
16export type StationId = Agent['station']
17
18export const STATIONS = ['web', 'files', 'desk', 'bench', 'mail'] as const
19export const stationX = (id: (typeof STATIONS)[number]) => 44 + 78 * STATIONS.indexOf(id)
20const FRONT: [number, number][] = [[-17, 136], [17, 136], [-17, 170], [17, 170], [0, 202]]
21
22export const DOOR_IN: Pt = { x: -26, y: LANE }
23export const DOOR_OUT: Pt = { x: W + 26, y: LANE }
24
25export const slotPoint = (station: StationId, slot: number): Pt => {
26  if (station === 'lounge') {
27    return { x: 132 + (slot % 5) * 34, y: 212 }
28  }
29  const [dx, y] = FRONT[slot % FRONT.length] ?? [0, 136]
30
31  return { x: stationX(station) + dx, y }
32}
33
34export const freeSlot = (agents: Agent[], station: StationId, except: string) => {
35  const taken = new Set(
36    agents.filter(a => a.station === station && a.id !== except && a.status === 'running').map(a => a.slot),
37  )
38  let slot = 0
39  while (taken.has(slot)) {
40    slot += 1
41  }
42
43  return slot
44}
45
46// Bright enough for the dark floor, far enough apart to tell robots apart.
47export const PALETTE = ['#22d3ee', '#fbbf24', '#a78bfa', '#34d399', '#f472b6', '#60a5fa', '#fb923c', '#a3e635', '#f87171', '#e879f9']
48
49// The first color no robot on the floor is wearing, one walking out included.
50export const freeColor = (agents: Agent[], now: number) => {
51  const worn = new Set(
52    agents
53      .filter(a => {
54        const out = goneAt(a)
55
56        return out === null || now < out
57      })
58      .map(a => a.color),
59  )
60
61  return PALETTE.find(c => !worn.has(c)) ?? PALETTE[agents.length % PALETTE.length] ?? '#9ca3af'
62}
63
64const dist = (a: Pt, b: Pt) => Math.hypot(a.x - b.x, a.y - b.y)
65
66// Down to the lane, along it, then up to the place: a warehouse aisle.
67const aisle = (from: Pt, to: Pt): Pt[] => {
68  if (Math.abs(from.y - to.y) < 4) {
69    return [to]
70  }
71  const pts: Pt[] = []
72  if (from.y !== LANE) {
73    pts.push({ x: from.x, y: LANE })
74  }
75  if (to.x !== from.x) {
76    pts.push({ x: to.x, y: LANE })
77  }
78  pts.push(to)
79
80  return pts
81}
82
83const walk = (from: Pt, to: Pt, t0: number): Phase[] => {
84  const out: Phase[] = []
85  let at = from
86  let t = t0
87  for (const next of aisle(from, to)) {
88    const ms = Math.max(1, Math.round((dist(at, next) / SPEED) * 1000))
89    out.push({ kind: 'walk', t0: t, t1: t + ms, from: at, to: next })
90    at = next
91    t += ms
92  }
93
94  return out
95}
96
97const endOf = (phases: Phase[], fallback: number) => phases.at(-1)?.t1 ?? fallback
98
99export const posAt = (phases: Phase[], now: number): Pt => {
100  const first = phases[0]
101  if (!first || now < first.t0) {
102    return first?.from ?? DOOR_IN
103  }
104  for (const p of phases) {
105    if (p.t1 === null || now < p.t1) {
106      if (p.kind !== 'walk') {
107        return p.to
108      }
109      const k = (now - p.t0) / Math.max(1, p.t1 === null ? 1 : p.t1 - p.t0)
110
111      return { x: p.from.x + (p.to.x - p.from.x) * k, y: p.from.y + (p.to.y - p.from.y) * k }
112    }
113  }
114
115  return phases.at(-1)?.to ?? first.from
116}
117
118// Anchors the plan at `now`: what is past is dropped and the phase underway
119// starts from where the robot actually is.
120export const reanchor = (a: Agent, now: number): Agent => {
121  const { letter: _sent, ...kept } = a
122  const here = posAt(a.phases, now)
123  const rest = a.phases
124    .filter(p => p.t1 === null || p.t1 > now)
125    .map(p => (p.t0 < now ? { ...p, t0: now, from: p.kind === 'walk' ? here : p.from } : p))
126
127  return { ...kept, planAt: now, phases: rest.length > 0 ? rest : [{ kind: 'work', t0: now, t1: null, from: here, to: here }] }
128}
129
130export const enter = (a: Agent, now: number): Agent => {
131  const to = slotPoint(a.station, a.slot)
132
133  return {
134    ...a,
135    planAt: now,
136    phases: [...walk(DOOR_IN, to, now), { kind: 'work', t0: endOf(walk(DOOR_IN, to, now), now), t1: null, from: to, to }],
137  }
138}
139
140export const goTo = (a: Agent, station: StationId, slot: number, now: number): Agent => {
141  const { letter: _sent, ...kept } = a
142  const here = posAt(a.phases, now)
143  const to = slotPoint(station, slot)
144  const steps = walk(here, to, now)
145
146  return { ...kept, station, slot, planAt: now, phases: [...steps, { kind: 'work', t0: endOf(steps, now), t1: null, from: to, to }] }
147}
148
149// Cheer or sulk where it stands, then out through the exit door.
150export const leave = (a: Agent, status: Agent['status'], now: number): Agent => {
151  const { letter: _sent, ...kept } = a
152  const here = posAt(a.phases, now)
153  const hold = status === 'done' ? CHEER_MS : status === 'failed' ? SULK_MS : 0
154  const phases: Phase[] = []
155  if (hold > 0) {
156    phases.push({ kind: status === 'done' ? 'cheer' : 'sulk', t0: now, t1: now + hold, from: here, to: here })
157  }
158  phases.push(...walk(here, DOOR_OUT, now + hold))
159
160  return { ...kept, planAt: now, phases }
161}
162
163// When a robot has walked out of sight.
164export const goneAt = (a: Agent) => (a.status === 'running' ? null : endOf(a.phases, a.planAt))
165
166// The robot as it should be drawn at `now`. One that has stopped moving is a
167// still pose that never changes, so its drawing never needs redrawing; one on
168// the move is drawn from where it is right now, so a redrawn frame picks up
169// where it was instead of starting the walk again.
170export const asDrawn = (a: Agent, now: number): Agent => {
171  const isResting = a.status === 'running' && a.phases.every(p => p.t1 === null || p.t1 <= now)
172  const letter = a.letter && now - a.letter.at < LETTER_MS ? a.letter : undefined
173  const pose = posAt(a.phases, now)
174  const drawn: Agent = isResting
175    ? { ...reanchor(a, now), planAt: 0, phases: [{ kind: 'work', t0: 0, t1: null, from: pose, to: pose }] }
176    : reanchor(a, now)
177
178  return letter ? { ...drawn, letter } : drawn
179}
180
hooks/verdict.ts 29 lines
1// Whether an agent that answered actually did its task. The engine calls any
2// agent that answered "completed", even when its answer says it could not.
3export type Classify = (text: string, labels: readonly string[]) => Promise<string | undefined>
4
5// An answer that leads with it needs no model to read it.
6const SAYS_FAILED = /^\W*failed\b/i
7
8export const verdictOf = async (answer: string, task: string, classify: Classify): Promise<'done' | 'failed'> => {
9  const report = answer.trim()
10  if (!report) {
11    return 'done'
12  }
13  if (SAYS_FAILED.test(report)) {
14    return 'failed'
15  }
16  try {
17    const label = await classify(
18      `An AI agent was given this task:\n${task}\n\nIts final report:\n${report.slice(0, 2000)}\n\n` +
19        'Did the agent accomplish the task (succeeded), or report that it could not (failed)?',
20      ['succeeded', 'failed'],
21    )
22
23    return label === 'failed' ? 'failed' : 'done'
24  } catch {
25    // No verdict (no network, rate limited): what the engine says, as before.
26    return 'done'
27  }
28}
29
types/index.d.ts 44 lines
1// A step of a robot's day. Times are epoch milliseconds; `t1: null` runs on until the next event.
2export type Phase = {
3  kind: 'walk' | 'work' | 'cheer' | 'sulk'
4  t0: number
5  t1: number | null
6  from: { x: number; y: number }
7  to: { x: number; y: number }
8}
9// A letter in flight from one point of the scene to another, sent at `at`.
10export type Letter = { x0: number; y0: number; x1: number; y1: number; at: number }
11
12// `id` is the engine's agent id.
13export type Agent = {
14  id: string
15  label: string
16  type: string
17  parent: string
18  name?: string
19  status: 'running' | 'done' | 'failed' | 'stopped'
20  bornAt: number
21  endedAt?: number
22  // Where it works, what it is doing now, and what it was asked to do.
23  station: 'web' | 'files' | 'desk' | 'bench' | 'mail' | 'lounge'
24  tool?: string
25  doing: string
26  task: string
27  // Its route through the warehouse, planned from `planAt` (the moment of its last change).
28  slot: number
29  planAt: number
30  phases: Phase[]
31  color: string
32  letter?: Letter
33}
34// One message between two agents (from and to are agent ids, or 'main').
35export type Ping = { id: number; from: string; to: string; text: string; at: number }
36// What the strip above the prompt says, or null when it has nothing to say.
37export type Strip = { title: string } | null
38
39declare module 'claude-code' {
40  interface PluginState {
41    'agent-warehouse': { agents: Agent[]; pings: Ping[]; strip: Strip; selected: string | null }
42  }
43}
44