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…

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.

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.
/warehouse opens it any time.FAILED needs no check).install.sh works on macOS and Linux.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)
hooks/register.tsx 511 lines1import { 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}
511hooks/art.ts 377 lines1import 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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}
377hooks/plan.ts 180 lines1import 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}
180hooks/verdict.ts 29 lines1// 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}
29types/index.d.ts 44 lines1// 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