SLOPSHOPPER

vivarium

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.

newbandguardcommandtoastagents
v0.1.0no licenseupdated 2026-10-06claytonmgravatt/claude-mods/plugins/vivarium
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vivarium
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /vivarium ⎿ vivarium: Subagent strip hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Claude Code mods by Clayton Gravatt.

/plugin marketplace add claytonmgravatt/claude-mods
/plugin install vivarium@claude-mods
ModWhat it does
vivariumEach subagent as a pixel Clawd above the prompt. Click one for its details. Terminal and desktop app.
Source 4 files
hooks/register.tsx 236 lines
1import 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}
236
hooks/svg.ts 337 lines
1// 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, '&amp;')
12    .replace(/</g, '&lt;')
13    .replace(/>/g, '&gt;')
14    .replace(/"/g, '&quot;')
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
337
hooks/strip.tsx 194 lines
1import 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
194
types/index.d.ts 41 lines
1export 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