Shows each subagent as a pixel Clawd in a strip above the prompt: walking while it works, asleep when it finishes, sized by model. Click one for its details.

Claude Code mods by Clayton Gravatt.
/plugin marketplace add claytonmgravatt/claude-mods
/plugin install vivarium@claude-mods
| Mod | What it does |
|---|---|
| vivarium | Each subagent as a pixel Clawd above the prompt. Click one for its details. Terminal and desktop app. |
hooks/register.tsx 236 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Critter, Mood, StripProps } from '../types'
4import { drawStrip } from './svg'
5
6const OLD_PANE = 'vivarium'
7const CRITTERS = { plugin: 'vivarium', key: 'critters' } as const
8const IS_HIDDEN = { plugin: 'vivarium', key: 'isHidden' } as const
9const SELECTED = { plugin: 'vivarium', key: 'selected' } as const
10
11const MAX_KEPT = 40
12const ANSWER_CHARS = 300
13
14// ---------------------------------------------------------------- helpers
15
16function clip(text: string, max: number): string {
17 return text.length > max ? `${text.slice(0, max - 1)}…` : text
18}
19
20function oneLine(text: string): string {
21 return text.replace(/\s+/g, ' ').trim()
22}
23
24function duration(ms: number): string {
25 const s = Math.max(0, Math.round(ms / 1000))
26 if (s < 60) return `${s}s`
27 const m = Math.floor(s / 60)
28 if (m < 60) return `${m}m ${s % 60}s`
29 return `${Math.floor(m / 60)}h ${m % 60}m`
30}
31
32function summary(list: Critter[]): string {
33 const awake = list.filter(c => c.mood === 'awake').length
34 const failed = list.filter(c => c.mood === 'flopped').length
35 const parts = [`${awake} working`, `${list.length - awake - failed} finished`]
36 if (failed > 0) parts.push(`${failed} failed`)
37 return parts.join(' · ')
38}
39
40// claude-opus-5-5 -> Opus 5.5, claude-haiku-4-5-20251001 -> Haiku 4.5.
41function modelName(model: string | undefined): string | undefined {
42 if (model === undefined) return undefined
43 const m = /(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2}))?(?:-|\[|$)/i.exec(model)
44 if (m === null) return model
45 const family = m[1][0].toUpperCase() + m[1].slice(1).toLowerCase()
46 return m[3] !== undefined ? `${family} ${m[2]}.${m[3]}` : `${family} ${m[2]}`
47}
48
49function detail(c: Critter, now: number, tool: string | undefined): string {
50 const model = modelName(c.model)
51 const head = model !== undefined ? `${c.kind} · ${model} · ${c.description}` : `${c.kind} · ${c.description}`
52 if (c.mood === 'awake') {
53 return `${head} · running ${duration(now - c.hatchedAt)}${tool !== undefined ? ` · last tool: ${tool}` : ''}`
54 }
55 const took = duration((c.doneAt ?? now) - c.hatchedAt)
56 if (c.mood === 'asleep') return `${head} · finished in ${took}: ${c.answer ?? '(no text)'}`
57 return `${head} · ${c.stopReason ?? 'stopped'} after ${took}`
58}
59
60function stopReason(reason: string, explanation: string | null): string {
61 if (reason === 'aborted') return 'interrupted'
62 if (reason === 'refusal') return explanation ? `refused (${explanation})` : 'refused'
63 return 'stopped by an API error'
64}
65
66// ---------------------------------------------------------------- state
67
68async function getCritters($: EngineInterface): Promise<Critter[]> {
69 const { value = [] } = await $.state.get(CRITTERS)
70 return value
71}
72
73// Read, apply, write only if nobody wrote in between; try again otherwise.
74// Skips the write when nothing changed, so the strip is not redrawn for nothing.
75async function editCritters($: EngineInterface, fn: (list: Critter[]) => Critter[]): Promise<void> {
76 for (let attempt = 0; attempt < 8; attempt++) {
77 const held = await $.state.get(CRITTERS)
78 const before = held.value ?? []
79 const after = fn(before)
80 if (after === before) return
81 const written = await $.state.set(CRITTERS, after, { ifVersion: held.version })
82 if (written.isSet) return
83 }
84}
85
86function wake(c: Critter): Critter {
87 const { doneAt: _d, answer: _a, stopReason: _s, ...rest } = c
88 return { ...rest, mood: 'awake' }
89}
90
91// ---------------------------------------------------------------- hooks
92
93export const register: Register = on => {
94 // The last tool each running subagent called. Kept here, not in $.state,
95 // so a tool call never redraws the strip; read when a Clawd is clicked.
96 const lastTool = new Map<string, string>()
97
98 on('session.start', async ($, e, next) => {
99 await $.command.register({
100 name: 'vivarium',
101 description: 'Show or hide the subagent strip above the prompt',
102 })
103 // Earlier versions drew into a side pane.
104 void $.ui.close({ id: OLD_PANE })
105
106 return next(e)
107 })
108
109 on('command.run', { command: 'vivarium' }, async $ => {
110 const { value: isHidden = false } = await $.state.get(IS_HIDDEN)
111 await $.state.set(IS_HIDDEN, !isHidden)
112
113 return { text: isHidden ? 'Subagent strip shown.' : 'Subagent strip hidden.' }
114 })
115
116 on('agent.spawn', async ($, e, next) => {
117 const hatchedAt = await $.clock.now()
118 const spawned = await next(e)
119
120 if (spawned.deny !== undefined || spawned.agentId === undefined) return spawned
121
122 const critter: Critter = {
123 id: e.tool_use_id,
124 agentId: spawned.agentId,
125 kind: e.subagentType,
126 description: e.description,
127 mood: 'awake',
128 hatchedAt,
129 model: spawned.model,
130 }
131 await editCritters($, list => [...list.filter(c => c.id !== critter.id), critter].slice(-MAX_KEPT))
132 $.ui.toast(`✻ ${e.subagentType} hatched: ${clip(e.description, 50)}`)
133
134 return spawned
135 })
136
137 // A finished subagent that is given more work wakes up again. Writes only
138 // on that change, never per tool call.
139 on('tool.call', ($, e, next) => {
140 const agentId = e.agentId
141 if (agentId !== undefined) {
142 lastTool.set(agentId, e.tool)
143 void editCritters($, list =>
144 list.some(c => c.agentId === agentId && c.mood !== 'awake')
145 ? list.map(c => (c.agentId === agentId ? wake(c) : c))
146 : list,
147 )
148 }
149
150 return next(e)
151 })
152
153 on('turn.complete', async ($, e, next) => {
154 const agentId = e.agentId
155 if (agentId !== undefined) {
156 const now = await $.clock.now()
157 const mood: Mood = e.reason === 'answer' ? 'asleep' : 'flopped'
158 const answer = clip(oneLine(e.answer), ANSWER_CHARS)
159 const reason = stopReason(e.reason, e.reason === 'refusal' ? e.refusal.explanation : null)
160 await editCritters($, list =>
161 list.some(c => c.agentId === agentId)
162 ? list.map(c => {
163 if (c.agentId !== agentId) return c
164 return mood === 'asleep'
165 ? { ...c, mood, doneAt: now, answer }
166 : { ...c, mood, doneAt: now, stopReason: reason }
167 })
168 : list,
169 )
170 }
171
172 return next(e)
173 })
174
175 // A click in the strip: remember which Clawd is selected, so the strip
176 // draws its detail line.
177 on('ui.message', async ($, e, next) => {
178 if (e.element !== 'clawds') return next(e)
179 const data = e.data as { select?: unknown } | null
180 const id = typeof data?.select === 'string' ? data.select : null
181 await $.state.set(SELECTED, id)
182
183 return next(e)
184 })
185
186 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
187 const list = await getCritters($)
188 const { value: isHidden = false } = await $.state.get(IS_HIDDEN)
189 if (e.props.hasSurvey || isHidden || list.length === 0) return next(e)
190
191 const now = await $.clock.now()
192 const hasResting = list.some(c => c.mood !== 'awake')
193 const clear = async () => {
194 await editCritters($, all => all.filter(c => c.mood === 'awake'))
195 await $.state.set(SELECTED, null)
196 }
197
198 const toolOf = (c: Critter) => (c.agentId !== undefined ? lastTool.get(c.agentId) : undefined)
199
200 // The terminal draws the block-character strip, whose cells it lays out
201 // exactly; every other surface draws the pixel SVG.
202 if (e.surface === 'terminal') {
203 const { value: selectedId = null } = await $.state.get(SELECTED)
204 const chosen = list.find(c => c.id === selectedId)
205 const tool = chosen !== undefined ? toolOf(chosen) : undefined
206 const props: StripProps = {
207 critters: list.map(c => ({ id: c.id, kind: c.kind, label: c.description, mood: c.mood })),
208 selected: chosen?.id ?? null,
209 detail: chosen !== undefined ? detail(chosen, now, tool) : '',
210 summary: summary(list),
211 }
212 const { Box, Button, Client } = $.ui.resolve(e)
213 return (
214 <Box flexDirection="row" alignItems="flex-end">
215 <Client key="clawds" module="./strip.tsx" props={props} height={6} flexGrow={1} />
216 {hasResting && <Button key="clear" label="Clear finished" onPress={clear} />}
217 </Box>
218 )
219 }
220
221 const { Box, Svg, Button } = $.ui.resolve(e)
222 return (
223 <Box flexDirection="row" alignItems="flex-end">
224 <Svg
225 source={drawStrip(list, now, c => detail(c, now, toolOf(c)), summary(list))}
226 alt={`Subagents: ${summary(list)}`}
227 width={640}
228 height={62}
229 isInteractive
230 />
231 {hasResting && <Button key="clear" label="Clear finished" onPress={clear} />}
232 </Box>
233 )
234 })
235}
236hooks/svg.ts 337 lines1// The desktop drawing: a pixel-art strip as one SVG. Clicking a Clawd makes it
2// hop and shows its detail line, all in SMIL, since the SVG runs no script.
3import type { Critter, Mood } from '../types'
4
5const MAX_AWAKE_DRAWN = 8
6const MAX_RESTING_DRAWN = 10
7const DETAIL_CHARS = 100
8
9function esc(text: string): string {
10 return text
11 .replace(/&/g, '&')
12 .replace(/</g, '<')
13 .replace(/>/g, '>')
14 .replace(/"/g, '"')
15}
16
17function clip(text: string, max: number): string {
18 return text.length > max ? `${text.slice(0, max - 1)}…` : text
19}
20
21function rng(seed: string): () => number {
22 let h = 2166136261
23 for (let i = 0; i < seed.length; i++) {
24 h ^= seed.charCodeAt(i)
25 h = Math.imul(h, 16777619)
26 }
27 if (h === 0) h = 1
28 return () => {
29 h ^= h << 13
30 h ^= h >>> 17
31 h ^= h << 5
32 return ((h >>> 0) % 10000) / 10000
33 }
34}
35
36// ---------------------------------------------------------------- pixels
37
38const INK = '#141413'
39const CREAM = '#f0eee6'
40const CLAY = '#d97757'
41const SKY_BLUE = '#6a9bcc'
42const OLIVE = '#788c5d'
43// Readable on both the light and the dark theme.
44const MUTED = '#8f8b80'
45// Outlines for accessories, visible on both themes.
46const OUTLINE = '#6b675e'
47
48type Palette = Record<string, string>
49
50// Draws a sprite of one character per pixel ('.' is empty), merging runs of a
51// colour into one rect. (ox, oy) is the sprite's top-left, in pixels of size p.
52function sprite(rows: string[], palette: Palette, p: number, ox: number, oy: number): string {
53 let out = ''
54 rows.forEach((row, y) => {
55 let x = 0
56 while (x < row.length) {
57 const ch = row[x]
58 let run = 1
59 while (x + run < row.length && row[x + run] === ch) run++
60 const fill = palette[ch]
61 if (fill !== undefined) {
62 out += `<rect x="${ox + x * p}" y="${oy + y * p}" width="${run * p}" height="${p}" fill="${fill}"/>`
63 }
64 x += run
65 }
66 })
67 return out
68}
69
70// Clawd, 14 wide. Arms out on row 3, eyes as 1x2 slits, four legs.
71const BODY = [
72 '..BBBBBBBBBB..',
73 '..BBEBBBBEBB..',
74 '..BBEBBBBEBB..',
75 'BBBBBBBBBBBBBB',
76 '..BBBBBBBBBB..',
77 '..BBBBBBBBBB..',
78]
79const BODY_ASLEEP = [
80 '..BBBBBBBBBB..',
81 '..BBBBBBBBBB..',
82 '..BEEBBBBEEB..',
83 'BBBBBBBBBBBBBB',
84 '..BBBBBBBBBB..',
85 '..BBBBBBBBBB..',
86]
87const LEGS_A = ['...B.B..B.B...', '...B.B..B.B...']
88const LEGS_B = ['..B.B....B.B..', '...B.B..B.B...']
89const LEGS_TUCKED = ['..B.B....B.B..']
90const SHADOW = ['..SSSSSSSSSS..']
91
92const P = 2 // creature pixel size
93const W = 14 // sprite width in pixels
94
95type Accessory = { rows: string[]; dx: number; dy: number; palette: Palette }
96type Kind = { body: string; accessory?: Accessory }
97
98const MAGNIFIER: Accessory = {
99 rows: ['..KKK.', '.KGGGK', '.KGGGK', '..KKK.', '.K....', 'K.....'],
100 dx: 13,
101 dy: -2,
102 palette: { K: OUTLINE, G: '#d6e6f5' },
103}
104const CLIPBOARD: Accessory = {
105 rows: ['.KK.', 'PPPP', 'PKKP', 'PPPP', 'PKKP', 'PPPP'],
106 dx: -4,
107 dy: 0,
108 palette: { K: INK, P: CREAM },
109}
110const GRAD_CAP: Accessory = {
111 rows: ['..KKKKKKKKKK..', '....KKKKKK.Y..', '...........Y..'],
112 dx: 0,
113 dy: -3,
114 palette: { K: OUTLINE, Y: '#e8c468' },
115}
116const SPROUT: Accessory = {
117 rows: ['.LL.LL', '..LL..', '...L..'],
118 dx: 4,
119 dy: -3,
120 palette: { L: OLIVE },
121}
122
123function kindOf(kind: string): Kind {
124 const k = kind.toLowerCase()
125 if (k === 'explore') return { body: SKY_BLUE, accessory: MAGNIFIER }
126 if (k === 'plan') return { body: OLIVE, accessory: CLIPBOARD }
127 if (k === 'general-purpose' || k === 'claude') return { body: CLAY }
128 if (k === 'claude-code-guide') return { body: '#c46686', accessory: GRAD_CAP }
129 if (k === 'fork') return { body: CLAY, accessory: SPROUT }
130 return { body: `hsl(${Math.floor(rng(kind)() * 360)} 45% 62%)` }
131}
132
133// Opus Clawds wear this, over any hat their type gives them.
134const CROWN: Accessory = {
135 rows: ['.Y.Y.Y.', '.YYYYY.'],
136 dx: 3.5,
137 dy: -2,
138 palette: { Y: '#e8c468' },
139}
140
141// Size by model: Opus larger, Haiku smaller, anything else as Sonnet.
142function scaleFor(c: Critter): number {
143 const m = (c.model ?? '').toLowerCase()
144 if (m.includes('opus')) return 1.25
145 if (m.includes('haiku')) return 0.75
146 return 1
147}
148
149const isOpus = (c: Critter) => (c.model ?? '').toLowerCase().includes('opus')
150
151// One Clawd with its feet on y = 0, centred on x = 0.
152function clawd(kind: Kind, mood: Mood, legs: string[], crowned = false): string {
153 const grey = mood === 'flopped'
154 const palette: Palette = { B: grey ? '#9c9a92' : kind.body, E: INK }
155 const rows = [...(mood === 'awake' ? BODY : BODY_ASLEEP), ...legs]
156 const ox = (-W * P) / 2
157 const oy = -rows.length * P
158 let out = sprite(rows, palette, P, ox, oy)
159 const hat = crowned && !grey ? CROWN : mood === 'awake' ? kind.accessory : undefined
160 const extras = [hat]
161 if (crowned && mood === 'awake' && kind.accessory !== undefined && kind.accessory.dy >= 0) extras.push(kind.accessory)
162 for (const a of extras) {
163 if (a !== undefined) out += sprite(a.rows, a.palette, P, ox + a.dx * P, oy + a.dy * P)
164 }
165 return out
166}
167
168// Body-coloured pixels over the eye slits, shown for a moment every four
169// seconds: a blink, each Clawd on its own beat.
170function blink(body: string, beat: number): string {
171 const oy = -(BODY.length + LEGS_A.length) * P
172 const ox = (-W * P) / 2
173 const lids = [4, 9].map(col => `<rect x="${ox + col * P}" y="${oy + P}" width="${P}" height="${2 * P}" fill="${body}"/>`).join('')
174 return `<g opacity="0">${lids}<animate attributeName="opacity" values="0;1;0" keyTimes="0;0.96;0.99" calcMode="discrete" dur="4s" begin="-${(beat * 4).toFixed(2)}s" repeatCount="indefinite"/></g>`
175}
176
177function shadow(): string {
178 return sprite(SHADOW, { S: 'rgba(128,124,116,.35)' }, P, (-W * P) / 2, 0)
179}
180
181// Claude's spinner glyphs, one shown at a time.
182const SPIN = ['·', '✢', '✳', '✶', '✻', '✽']
183
184function spinner(x: number, y: number, period: number): string {
185 return SPIN.map((glyph, i) => {
186 const values = SPIN.map((_, j) => (j === i ? 1 : 0)).join(';')
187 return `<text x="${x}" y="${y}" class="spin" opacity="${i === 0 ? 1 : 0}">${glyph}<animate attributeName="opacity" values="${values}" calcMode="discrete" dur="${period}s" repeatCount="indefinite"/></text>`
188 }).join('')
189}
190
191// ---------------------------------------------------------------- the strip
192
193const STRIP_W = 640
194const STRIP_H = 62
195const GROUND_Y = 40 // feet line
196
197const snap = (n: number) => Math.round(n / P) * P
198
199function walker(c: Critter, n: number, x0: number, x1: number, maxChars: number, now: number): string {
200 const r = rng(c.id)
201 const kind = kindOf(c.kind)
202 const scale = scaleFor(c)
203 // Walk from a start point to one end, back to the other, and home again,
204 // one step every `stride` seconds.
205 const stepPx = 4
206 const span = Math.max(stepPx * 4, x1 - x0)
207 const start = snap(x0 + r() * span)
208 const xs: number[] = []
209 for (let x = start; x <= x0 + span; x += stepPx) xs.push(x)
210 const forward = xs.length
211 for (let x = x0 + span; x >= x0; x -= stepPx) xs.push(x)
212 for (let x = x0; x < start; x += stepPx) xs.push(x)
213 const stride = 0.22 + r() * 0.1
214 const dur = xs.length * stride
215 const age = (now - c.hatchedAt) / 1000
216 // A negative begin resumes the walk where it was, so a redraw never makes it jump.
217 const begin = (-(age % dur)).toFixed(2)
218 const flipAt = (forward / xs.length).toFixed(3)
219 const backAt = ((forward + Math.floor(span / stepPx) + 1) / xs.length).toFixed(3)
220 const label = esc(clip(c.description, maxChars))
221 // Above the head and any crown, at whatever size the model gives it.
222 const labelY = -Math.round(20 * scale + 6)
223 return `<g id="c${n}" class="clawd">
224 <animateTransform attributeName="transform" type="translate" calcMode="discrete" values="${xs.map(x => `${x} ${GROUND_Y}`).join(';')}" dur="${dur.toFixed(2)}s" begin="${begin}s" repeatCount="indefinite"/>
225 ${shadow()}
226 ${HIT}
227 <g>${hop(n)}<g transform="scale(${scale})">
228 <g>
229 <animateTransform attributeName="transform" type="scale" calcMode="discrete" values="1 1;-1 1;1 1" keyTimes="0;${flipAt};${backAt}" dur="${dur.toFixed(2)}s" begin="${begin}s" repeatCount="indefinite"/>
230 <g>
231 <animateTransform attributeName="transform" type="translate" calcMode="discrete" values="0 0;0 -${P}" dur="${(stride * 2).toFixed(2)}s" repeatCount="indefinite"/>
232 <g>${clawd(kind, 'awake', LEGS_A, isOpus(c))}<animate attributeName="opacity" values="1;0" calcMode="discrete" dur="${(stride * 2).toFixed(2)}s" repeatCount="indefinite"/></g>
233 <g opacity="0">${clawd(kind, 'awake', LEGS_B, isOpus(c))}<animate attributeName="opacity" values="0;1" calcMode="discrete" dur="${(stride * 2).toFixed(2)}s" repeatCount="indefinite"/></g>
234 ${blink(kind.body, r())}
235 </g>
236 </g>
237 </g></g>
238 ${spinner(-14, labelY, 1 + r() * 0.4)}
239 <text x="-3" y="${labelY}" class="label">${label}</text>
240 </g>`
241}
242
243function sleeper(c: Critter, n: number, x: number): string {
244 const kind = kindOf(c.kind)
245 const scale = scaleFor(c)
246 const h = (BODY_ASLEEP.length + LEGS_TUCKED.length) * P
247 if (c.mood === 'flopped') {
248 return `<g id="c${n}" class="clawd" transform="translate(${x} ${GROUND_Y})">
249 ${shadow()}
250 ${HIT}
251 <g>${hop(n)}<g transform="scale(${scale}) translate(0 ${-h}) rotate(180)">${clawd(kind, 'flopped', LEGS_TUCKED)}</g></g>
252 <text x="-8" y="${-h - 3}" class="star">✦<animate attributeName="opacity" values="1;0;1;0" calcMode="discrete" dur="1.2s" repeatCount="indefinite"/></text>
253 <text x="3" y="${-h - 7}" class="star" opacity="0">✦<animate attributeName="opacity" values="0;1;0;1" calcMode="discrete" dur="1.2s" repeatCount="indefinite"/></text>
254 </g>`
255 }
256 const offset = (rng(c.id)() * 3).toFixed(2)
257 return `<g id="c${n}" class="clawd" transform="translate(${x} ${GROUND_Y})">
258 ${shadow()}
259 ${HIT}
260 <g>${hop(n)}<g transform="scale(${scale})"><g>
261 <animateTransform attributeName="transform" type="translate" values="0 0;0 -${P}" calcMode="discrete" dur="2.4s" begin="-${offset}s" repeatCount="indefinite"/>
262 ${clawd(kind, 'asleep', LEGS_TUCKED, isOpus(c))}
263 </g></g></g>
264 <text x="10" y="-12" class="zz">z<animateTransform attributeName="transform" type="translate" values="0 0;2 -3;4 -6;6 -9" calcMode="discrete" dur="2.4s" begin="-${offset}s" repeatCount="indefinite"/><animate attributeName="opacity" values="1;1;1;0" calcMode="discrete" dur="2.4s" begin="-${offset}s" repeatCount="indefinite"/></text>
265 </g>`
266}
267
268// An invisible box over a Clawd, so a click anywhere on it counts.
269const HIT = `<rect x="-18" y="-30" width="36" height="34" fill="#000" fill-opacity="0"/>`
270
271function hop(n: number): string {
272 return `<animateTransform attributeName="transform" type="translate" values="0 0;0 -8;0 0;0 -3;0 0" dur=".5s" begin="c${n}.click"/>`
273}
274
275// One detail line per Clawd, hidden until it is clicked; it stays for eight
276// seconds, or until another Clawd is clicked.
277function detailLine(n: number, count: number, text: string): string {
278 const others = Array.from({ length: count }, (_, i) => i)
279 .filter(i => i !== n)
280 .map(i => `c${i}.click`)
281 const end = [`c${n}.click+8s`, ...others].join(';')
282 return `<text x="4" y="${STRIP_H - 4}" class="detail" visibility="hidden"><set attributeName="visibility" to="visible" begin="c${n}.click" end="${end}"/>${esc(clip(text, DETAIL_CHARS))}</text>`
283}
284
285function grass(): string {
286 let s = `<rect x="0" y="${GROUND_Y + 2}" width="${STRIP_W}" height="2" fill="${OLIVE}"/>`
287 for (let x = 12; x < STRIP_W; x += 37) {
288 s += `<rect x="${x}" y="${GROUND_Y}" width="2" height="2" fill="${OLIVE}"/><rect x="${x + 4}" y="${GROUND_Y}" width="2" height="2" fill="${OLIVE}"/>`
289 }
290 return s
291}
292
293export function drawStrip(list: Critter[], now: number, detailOf: (c: Critter) => string, summary: string): string {
294 const awake = list.filter(c => c.mood === 'awake').slice(-MAX_AWAKE_DRAWN)
295 const resting = list
296 .filter(c => c.mood !== 'awake')
297 .sort((a, b) => (a.doneAt ?? 0) - (b.doneAt ?? 0))
298 .slice(-MAX_RESTING_DRAWN)
299
300 // Sleepers line up on the left; walkers share the rest of the strip.
301 const restingSvg = resting.map((c, i) => sleeper(c, i, 18 + i * 32)).join('')
302 const yardStart = resting.length > 0 ? 18 + resting.length * 32 + 8 : 16
303 const yardEnd = STRIP_W - 140
304 const lane = awake.length > 0 ? (yardEnd - yardStart) / awake.length : 0
305 // A label is about 5.4px a character and starts 14px left of the Clawd's
306 // centre; keep it inside the walker's lane.
307 const maxChars = Math.max(4, Math.min(16, Math.floor((lane - 40) / 5.4)))
308 const awakeSvg = awake
309 .map((c, i) => walker(c, resting.length + i, yardStart + i * lane, yardStart + i * lane + Math.max(16, lane - 30 - maxChars * 5.4), maxChars, now))
310 .join('')
311
312 const shown = [...resting, ...awake]
313 const details = shown.map((c, i) => detailLine(i, shown.length, detailOf(c))).join('')
314
315 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${STRIP_W} ${STRIP_H}" width="${STRIP_W}" height="${STRIP_H}" shape-rendering="crispEdges" font-family="ui-monospace, 'Cascadia Mono', Consolas, monospace">
316 <style>
317 /* Matching the page's colour scheme keeps the sandboxed frame see-through
318 instead of giving it an opaque white backdrop on the dark theme. */
319 :root { color-scheme: light dark; background: transparent; }
320 .summary { font-size: 10px; fill: ${MUTED}; }
321 .label { font-size: 9px; fill: ${MUTED}; }
322 .zz { font-size: 8px; fill: ${MUTED}; font-weight: 700; }
323 .spin { font-size: 10px; fill: ${CLAY}; font-weight: 700; }
324 .star { font-size: 7px; fill: #e8c468; }
325 .detail { font-size: 10px; fill: ${MUTED}; }
326 .clawd { cursor: pointer; }
327 .clawd:hover { opacity: .8; }
328 </style>
329 <text x="${STRIP_W - 4}" y="12" text-anchor="end" class="summary">${esc(summary)}</text>
330 ${grass()}
331 ${restingSvg}
332 ${awakeSvg}
333 ${details}
334</svg>`
335}
336
337hooks/strip.tsx 194 lines1import type { ClientModule } from 'claude-code'
2
3import type { StripCritter, StripMessage, StripProps } from '../types'
4
5// Drawn in cells. Clawd is the start-up banner's: 9 columns by 3 rows of
6// quadrant blocks, the eyes being the two missing quadrants.
7const AWAKE_A = [' ▐▛███▜▌ ', '▝▜█████▛▘', ' ▘▘ ▝▝ ']
8const AWAKE_B = [' ▐▛███▜▌ ', '▝▜█████▛▘', ' ▘▘▝▝ ']
9const ASLEEP = [' ', ' ▐█████▌ ', '▝▜█████▛▘']
10const FLOPPED = [' ▖▖ ▗▗ ', '▗▟█████▙▖', ' ▐▙███▟▌ ']
11const SPRITE_W = 9
12
13const MIRROR: Record<string, string> = { '▘': '▝', '▝': '▘', '▖': '▗', '▗': '▖', '▛': '▜', '▜': '▛', '▙': '▟', '▟': '▙', '▌': '▐', '▐': '▌' }
14const mirror = (rows: string[]) => rows.map(row => [...row].reverse().map(ch => MIRROR[ch] ?? ch).join(''))
15
16const CLAY = '#d97757'
17const MUTED = '#8f8b80'
18const GRASS = '#788c5d'
19const GRASS_DARK = '#4f5e3c'
20const GREY = '#9c9a92'
21const STAR = '#e8c468'
22
23function bodyColour(kind: string): string {
24 const k = kind.toLowerCase()
25 if (k === 'explore') return '#6a9bcc'
26 if (k === 'plan') return '#788c5d'
27 if (k === 'claude-code-guide') return '#c46686'
28 if (k === 'fork') return '#e8a283'
29 if (k === 'general-purpose' || k === 'claude') return CLAY
30 let h = 0
31 for (let i = 0; i < kind.length; i++) h = (h * 31 + kind.charCodeAt(i)) >>> 0
32 return `hsl(${h % 360} 45% 62%)`
33}
34
35function phaseOf(id: string): number {
36 let h = 0
37 for (let i = 0; i < id.length; i++) h = (h * 33 + id.charCodeAt(i)) >>> 0
38 return h % 997
39}
40
41const SPIN = ['·', '✢', '✳', '✶', '✻', '✽']
42const ROWS = 6 // label, three of Clawd, grass, detail
43const TICK_MS = 120
44const MAX_RESTING = 8
45const REST_GAP = 10
46
47type Cell = { ch: string; color?: string; bold?: boolean }
48type State = { t: number; hop: { id: string; until: number } | null; picked: string | null }
49
50type Placed = { c: StripCritter; x: number; rows: string[]; colour: string; dir: number }
51
52function layout(critters: StripCritter[], columns: number, t: number): { placed: Placed[]; laneWidth: number } {
53 const resting = critters.filter(c => c.mood !== 'awake').slice(-MAX_RESTING)
54 const awake = critters.filter(c => c.mood === 'awake')
55 const placed: Placed[] = []
56
57 resting.forEach((c, i) => {
58 placed.push({
59 c,
60 x: 1 + i * REST_GAP,
61 rows: c.mood === 'asleep' ? ASLEEP : FLOPPED,
62 colour: c.mood === 'asleep' ? bodyColour(c.kind) : GREY,
63 dir: 1,
64 })
65 })
66
67 const yardStart = resting.length > 0 ? 1 + resting.length * REST_GAP + 2 : 1
68 const yardWidth = Math.max(0, columns - yardStart - 1)
69 const laneWidth = awake.length > 0 ? Math.floor(yardWidth / awake.length) : 0
70 awake.forEach((c, i) => {
71 const lane0 = yardStart + i * laneWidth
72 // Walk back and forth across the lane, one column every two ticks.
73 const span = Math.max(1, laneWidth - SPRITE_W - 1)
74 const p = (Math.floor(t / 2) + phaseOf(c.id)) % (2 * span)
75 const dir = p < span ? 1 : -1
76 const offset = p < span ? p : 2 * span - p
77 const step = (Math.floor(t / 2) + phaseOf(c.id)) % 2 === 0
78 const rows = step ? AWAKE_A : AWAKE_B
79 placed.push({ c, x: lane0 + offset, rows: dir < 0 ? mirror(rows) : rows, colour: bodyColour(c.kind), dir })
80 })
81
82 return { placed, laneWidth }
83}
84
85function put(grid: Cell[][], row: number, x: number, text: string, cell: Omit<Cell, 'ch'>) {
86 const line = grid[row]
87 if (line === undefined) return
88 ;[...text].forEach((ch, i) => {
89 const col = x + i
90 if (col >= 0 && col < line.length) line[col] = { ch, ...cell }
91 })
92}
93
94function sameStyle(a: Cell, b: Cell): boolean {
95 return a.color === b.color && a.bold === b.bold
96}
97
98const Strip: ClientModule<StripProps, State> = (props, surface) => {
99 const { Box, Text } = surface.elements
100 const state: State = surface.state ?? { t: 0, hop: null, picked: null }
101
102 if (surface.state === undefined) {
103 surface.setState(state)
104 surface.every(TICK_MS, () => {
105 const s = surface.state ?? state
106 surface.setState({ ...s, t: s.t + 1 })
107 })
108 }
109
110 const columns = surface.columns
111 if (columns <= 0) return <Text> </Text>
112
113 const t = state.t
114 const { placed, laneWidth } = layout(props.critters, columns, t)
115 const selected = state.picked ?? props.selected
116
117 surface.onPointer(event => {
118 if (event.type !== 'down') return
119 const s = surface.state ?? state
120 const hit = layout(props.critters, surface.columns, s.t).placed.find(
121 p => event.x >= p.x && event.x < p.x + SPRITE_W && event.y <= 4,
122 )
123 const id = hit === undefined || hit.c.id === (s.picked ?? props.selected) ? null : hit.c.id
124 surface.setState({ ...s, picked: id, hop: id === null ? null : { id, until: s.t + 4 } })
125 const message: StripMessage = { select: id }
126 surface.post(message)
127 })
128
129 const grid: Cell[][] = Array.from({ length: ROWS }, () => Array.from({ length: columns }, () => ({ ch: ' ' })))
130
131 // Grass, with a darker patch under each Clawd.
132 put(grid, 4, 0, '▀'.repeat(columns), { color: GRASS })
133 for (const p of placed) put(grid, 4, p.x + 1, '▀'.repeat(SPRITE_W - 2), { color: GRASS_DARK })
134
135 for (const p of placed) {
136 const ph = phaseOf(p.c.id)
137 const hopping = state.hop !== null && state.hop.id === p.c.id && t < state.hop.until
138 const top = hopping ? 0 : 1
139 p.rows.forEach((row, i) => {
140 // Spaces in a sprite are see-through.
141 ;[...row].forEach((ch, j) => {
142 if (ch !== ' ') put(grid, top + i, p.x + j, ch, { color: p.colour })
143 })
144 })
145
146 const isSelected = p.c.id === selected
147 if (p.c.mood === 'awake') {
148 if (!hopping) {
149 put(grid, 0, p.x, SPIN[(t + ph) % SPIN.length], { color: CLAY, bold: true })
150 const room = Math.max(0, laneWidth - 3)
151 const label = p.c.label.length > room ? `${p.c.label.slice(0, Math.max(0, room - 1))}…` : p.c.label
152 if (room > 1) put(grid, 0, p.x + 2, label, { color: isSelected ? CLAY : MUTED, bold: isSelected })
153 }
154 } else if (p.c.mood === 'asleep') {
155 const z = Math.floor((t + ph) / 6) % 3
156 if (z > 0) put(grid, z === 1 ? 1 : 0, p.x + 7 + (z - 1), z === 1 ? 'z' : 'Z', { color: MUTED })
157 if (isSelected) put(grid, 0, p.x + 3, '▾', { color: CLAY, bold: true })
158 } else {
159 if (Math.floor((t + ph) / 4) % 2 === 0) put(grid, 0, p.x + 2, '✦', { color: STAR })
160 else put(grid, 0, p.x + 6, '✦', { color: STAR })
161 if (isSelected) put(grid, 0, p.x + 4, '▾', { color: CLAY, bold: true })
162 }
163 }
164
165 const bottom = selected !== null && props.selected === selected ? props.detail : props.summary
166 put(grid, 5, 0, bottom.length > columns ? `${bottom.slice(0, columns - 1)}…` : bottom, {
167 color: selected !== null ? undefined : MUTED,
168 })
169
170 return (
171 <Box flexDirection="column">
172 {grid.map(line => {
173 const runs: Cell[] = []
174 for (const cell of line) {
175 const last = runs[runs.length - 1]
176 if (last !== undefined && sameStyle(last, cell)) last.ch += cell.ch
177 else runs.push({ ...cell })
178 }
179 return (
180 <Box flexDirection="row">
181 {runs.map(run => (
182 <Text color={run.color} bold={run.bold} wrap="truncate">
183 {run.ch}
184 </Text>
185 ))}
186 </Box>
187 )
188 })}
189 </Box>
190 )
191}
192
193export default Strip
194types/index.d.ts 41 lines1export type Mood = 'awake' | 'asleep' | 'flopped'
2
3export type Critter = {
4 /** The Agent tool call that hatched it. */
5 id: string
6 /** The subagent's loop id, once it started. */
7 agentId?: string
8 /** The agent type: Explore, Plan, general-purpose, ... */
9 kind: string
10 description: string
11 mood: Mood
12 hatchedAt: number
13 /** The model it runs on, as the engine resolved it. */
14 model?: string
15 doneAt?: number
16 /** The start of its final answer, once it finished. */
17 answer?: string
18 /** Why it stopped, when it failed. */
19 stopReason?: string
20}
21
22/** One Clawd as the strip module draws it. */
23export type StripCritter = { id: string; kind: string; label: string; mood: Mood }
24
25/** What the hooks module hands the strip module. */
26export type StripProps = {
27 critters: StripCritter[]
28 selected: string | null
29 detail: string
30 summary: string
31}
32
33/** What the strip module posts back when a Clawd is clicked. */
34export type StripMessage = { select: string | null }
35
36declare module 'claude-code' {
37 interface PluginState {
38 vivarium: { critters: Critter[]; isHidden: boolean; selected: string | null }
39 }
40}
41