SLOPSHOPPER

cw-crew

Orchestrate lean headless Claude workers (Haiku scouts, Sonnet coders) with the cw CLI, and watch them live in a pixel-art side pane

newpanecommandtoaststatustimer
v0.8.0MITupdated 2026-10-14memoksin/cw-crew
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cw-crew
│ ┃ Crew ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ CREW ▐○ idle▌ │ ┃ ──────────────────────────────────────────── ⏺ 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 │ │ › /crew │ ⎿ cw-crew: Crew pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Crew
◆ CREW ▐○ idle▌ ▣ sc ────────────────────────────────────────────────────────
README

cw-crew

Claude Code plugin. The main session (Opus) leads; cheap headless workers do the reading and the coding.

  • orchestrate skill — the playbook: scout → spec → coder → verify, fleets, permissions, cost rules.
  • cw CLI (bin/, on the Bash PATH) — starts one claude -p worker with a ~3k-token cold start.
  • scout = Haiku, read-only (+ web with -w). coder = Sonnet, edits and runs tests (auto mode, risky calls come back as requests).
  • Fleet tools: cwgo (background start), cwwait (notify on each finish), cwls, cwopen, cwwt (one git worktree per writer), cwkill.
  • Crew pane — pixel-art side pane: each worker as a creature, live state, questions with a raised hand. /crew opens it; it also opens itself when a worker starts. Click a card's details › for its task, latest notes, recent steps and report; ‹ back (or b) returns.

Install

/plugin install cw-crew --marketplace memoksin/cw-crew

Answer y to add the marketplace, then pick a scope.

Windows

  1. In PowerShell, install everything with winget:
   winget install -e --id Git.Git
   winget install -e --id jqlang.jq
   winget install -e --id Anthropic.ClaudeCode
   winget install -e --id BurntSushi.ripgrep.MSVC   # optional, faster scout searches
   winget install -e --id sharkdp.fd                # optional

Git for Windows brings Git Bash (Claude Code's Bash tool), perl and git.

  1. Open a new terminal so the new PATH applies, then run claude and the /plugin install line above.
  2. If Claude Code does not find Git Bash, add to %USERPROFILE%\.claude\settings.json:
   { "env": { "CLAUDE_CODE_GIT_BASH_PATH": "C:\\Program Files\\Git\\bin\\bash.exe" } }
  1. Check: ask Claude to run cw scout <<< "Reply ok. No tools.". The last line must contain rc=0.

Update later with winget upgrade --all and claude plugin update cw-crew@cw-crew.

Requirements

  • macOS, Linux or Windows, bash. On Windows: Git for Windows and jq, see Windows.
  • claude, jq, perl, git on PATH. rg and fd help scouts but are optional.
  • Right-side pane: fullscreen layout (CLAUDE_CODE_NO_FLICKER=1, outside tmux) and ≥110 columns. Otherwise it sits above the prompt. Sprites need a terminal with an image protocol; others get half-blocks.
  • Claude desktop app (Code tab): the same pane and PNG sprites, drawn as one SVG; a details › button per worker sits under it. Install the plugin from a terminal at user scope; the desktop session loads it.

Use

Ask Claude to delegate ("use workers", /orchestrate). By hand:

cw scout -f src/app.ts <<< "List every caller of parseConfig with file:line."
cwgo api coder -W api -T 'npm test' -- specs/common.md specs/api.md
cwwait   # run in the background; fires when a worker finishes

Settings (env)

VarDefaultWhat
CW_SCOUT_MODEL / CW_CODER_MODELclaude-haiku-5-5 / claude-sonnet-5-5worker models
CW_MAX_TURNS80coder turn limit
CW_TIMEOUT3600 coder / 1200 scoutseconds
CW_LOG_DIR~/.claude/cw-runsrun logs (the pane reads them too)
CW_FLEET$CW_LOG_DIR/fleet/<repo>fleet dir
CW_WT_ROOT../<repo>-wtworktree root
CW_JEV01 = Jev (OpenRouter) picks worker effort per task. Sends the first 20k characters of each task to OpenRouter. Off: role default (scout low, coder medium)
OPENROUTER_API_KEY / CW_JEV_KEYCHAIN—key for CW_JEV=1; on macOS a keychain service name works instead of the env key
CW_KILL_ALLOW—colon-separated command prefixes cwkill may also stop

Set them in ~/.claude/settings.json under env.

Develop

claude --plugin-dir .          # run from this folder
claude plugin validate .
claude plugin test .
tools/gen_frames.sh            # rebuild frames/ after changing hooks/creatures.ts (needs bun + Pillow)

License

MIT

Source 4 files
hooks/register.tsx 321 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { FRAME_MS, frameFile, imageSource, lookKey } from './creatures'
5import {
6  buildWorkers, clock, DETAILS, drawScene, encode, isActive, isVisible, LABEL, msysToWin, parseEvents, sceneRows, sceneSvg, SPRITE_COLS, SPRITE_ROWS,
7  type Meta, type Run, type State, type Worker,
8} from './scene'
9
10const PANE = 'cw-crew'
11const DAY = 86_400_000
12const slots = atom({ plugin: 'cw-crew', key: 'slots' } as const, 0)
13const selected = atom({ plugin: 'cw-crew', key: 'selected' } as const, '')
14const NOTES = 4, STEPS = 10, REPORT_CHARS = 2400
15type Tracked = Run & { size: number; final: boolean }
16
17// Module state: a reload starts it over and the next poll rebuilds it from the run files.
18let dir = ''
19let session = ''
20let metas = new Map<string, Meta | null>()
21let runs = new Map<string, Tracked>()
22let workers: Worker[] = []
23let mounted: { cols: number; rows: number; maxRows: number; images: boolean; keys: Set<string> } | undefined
24// Sprites are Images until the terminal shows it draws their alt instead (no image protocol,
25// tmux): a blit of a mounted Image is then denied, and the pane falls back to half-blocks.
26let images = true
27let refused = 0
28let shown = new Map<string, string>()
29let seen = new Map<string, State>()
30let status: string | undefined
31let opened = false
32let polling = false
33let lastKick = 0
34// Surfaces with no Raster (desktop, editor, mobile) get the scene as an Svg, redrawn by poll() while shown.
35let svgAt = 0
36let svgKick = 0
37// PNG frames as base64, read once each; a session sees a few dozen of the 650.
38const pngs = new Map<string, string>()
39// Detail view: the open worker's live notes (its assistant text) and final report, refreshed by poll().
40let selKey = ''
41let detail: { key: string; size: number; notes: string[]; report?: string } = { key: '', size: -1, notes: [] }
42
43function readText($: EngineInterface, name: string) {
44  return $.fs.read(`${dir}/${name}`).catch(() => undefined)
45}
46
47async function poll($: EngineInterface) {
48  if (polling) return
49  polling = true
50  try {
51    const sid = await $.session.id()
52    if (sid !== session) {
53      session = sid
54      metas = new Map(); runs = new Map(); seen = new Map()
55    }
56    const now = await $.clock.now()
57    const entries = await $.fs.list(dir).catch(() => [])
58    const byName = new Map(entries.map(en => [en.name, en]))
59
60    for (const en of entries) {
61      if (!en.name.endsWith('.meta')) continue
62      const id = en.name.slice(0, -'.meta'.length)
63      if (!metas.has(id)) {
64        if (now - en.mtimeMs > DAY) { metas.set(id, null); continue }
65        const text = await readText($, en.name)
66        let meta: Meta | undefined
67        try { meta = text ? (JSON.parse(text) as Meta) : undefined } catch { meta = undefined }
68        if (!meta) continue
69        // A resumed/restarted lead gets a new session id: keep showing the workers still running from before.
70        const live = !byName.has(`${id}.done`) && now / 1000 - meta.start < (meta.tmo ?? 3600) + 2400   // + cw's acceptance run
71        metas.set(id, meta.session === session || live ? meta : null)
72      }
73      const meta = metas.get(id)
74      if (!meta) continue
75      const run = runs.get(id) ?? { id, meta, events: [], size: -1, final: false }
76      if (run.final) {
77        // The lead closed the request without a resume (cwopen -x): lower the hand.
78        if (run.question !== undefined && !byName.has(`${id}.ask`)) runs = new Map(runs).set(id, { ...run, question: undefined })
79        continue
80      }
81      const ev = byName.get(`${id}.events`)
82      const done = byName.get(`${id}.done`)
83      let next: Tracked = run
84      // The events file is complete once .done exists, so read it one last time then.
85      if (ev && (ev.size !== run.size || done)) {
86        next = { ...next, events: parseEvents((await readText($, ev.name)) ?? ''), size: ev.size }
87      }
88      if (done) {
89        const rc = Number.parseInt((await readText($, done.name)) ?? '', 10)
90        const question = byName.has(`${id}.ask`) ? (await readText($, `${id}.ask`))?.trim() : undefined
91        next = { ...next, rc: Number.isNaN(rc) ? 1 : rc, doneAt: done.mtimeMs, question, final: true }
92      }
93      if (next !== run || !runs.has(id)) runs = new Map(runs).set(id, next)
94    }
95
96    const all = buildWorkers([...runs.values()], now)
97    announce($, all)
98    const visible = all.filter(w => isVisible(w, now))
99    const grew = visible.length !== workers.length
100    workers = visible
101    if (grew) await update($, slots, () => visible.length)
102
103    const active = all.filter(isActive).length
104    const asking = all.filter(w => w.state === 'ask').length
105    const text = active ? `⚒ ${active} working${asking ? ` · ${asking} asking` : ''}`
106      : asking ? `? ${asking} waiting for an answer` : undefined
107    if (text !== status) { status = text; $.ui.status(text) }
108
109    if (active && !opened) {
110      opened = true
111      const res = await $.ui.open(paneArgs())
112      if (!res.isPlaced) $.ui.toast('cw-crew: workers running, open the pane with /crew')
113    }
114    // A pane hidden behind a tab drops its Raster; ask for a redraw now and then so the animation resumes.
115    if (!mounted && opened && now - lastKick > 2000) { lastKick = now; $.ui.invalidate('ui.render') }
116    // ponytail: whole-Svg redraw per poll, no per-frame blit there; fine for a handful of workers.
117    else if (!selKey && now - svgAt < 5000 && now - svgKick >= (active ? 400 : 2000)) { svgKick = now; $.ui.invalidate('ui.render') }
118    if (selKey) await refreshDetail($, all, byName)
119  } finally {
120    polling = false
121  }
122}
123
124// The open worker's newest run: its stream (.jsonl) holds the notes, its result (.json) the report.
125async function refreshDetail($: EngineInterface, all: Worker[], byName: Map<string, { size: number }>) {
126  const w = all.find(x => x.key === selKey)
127  const id = w?.runIds.at(-1)
128  if (!w || !id) return
129  const size = byName.get(`${id}.jsonl`)?.size ?? 0
130  if (detail.key === w.key && detail.size === size && (w.end === undefined || detail.report !== undefined)) return
131  const notes: string[] = []
132  for (const line of ((await readText($, `${id}.jsonl`)) ?? '').split('\n')) {
133    if (!line.includes('"type":"assistant"')) continue
134    try {
135      const m = JSON.parse(line) as { message?: { content?: { type: string; text?: string }[] } }
136      for (const c of m.message?.content ?? []) if (c.type === 'text' && c.text?.trim()) notes.push(c.text.trim())
137    } catch { /* a line still being written */ }
138  }
139  let report: string | undefined
140  if (w.end !== undefined) {
141    try { report = ((JSON.parse((await readText($, `${id}.json`)) ?? '{}') as { result?: string }).result ?? '').trim() } catch { report = '' }
142  }
143  detail = { key: w.key, size, notes: notes.slice(-NOTES), report }
144  $.ui.invalidate('ui.render')
145}
146
147function openDetail($: EngineInterface, key: string) {
148  selKey = key
149  detail = { key: '', size: -1, notes: [] }
150  mounted = undefined
151  void update($, selected, () => key)
152}
153
154// Ask for the rows the scene needs (room for two workers once any run), not the default third of the screen.
155const paneArgs = () => ({ id: PANE, title: 'Crew', rows: sceneRows(workers.length && Math.max(2, workers.length), 60), columns: 60 })
156
157function announce($: EngineInterface, all: Worker[]) {
158  // One state per key (the last run wins): two entries with one key would flip-flop and toast every poll.
159  for (const w of new Map(all.map(w => [w.key, w])).values()) {
160    const before = seen.get(w.key)
161    seen.set(w.key, w.state)
162    if (before === w.state || (before === undefined && w.end !== undefined)) continue
163    if (w.state === 'ask') $.ui.toast(`? ${w.name} asks: ${w.question ?? ''}`, { timeoutMs: 8000 })
164    else if (w.state === 'blocked') $.ui.toast(`! ${w.name} blocked: ${w.arg}`)
165    else if (w.state === 'done') $.ui.toast(`✓ ${w.name} (${w.role}) done`)
166    else if (w.state === 'failed') $.ui.toast(`✗ ${w.name} (${w.role}) failed`)
167  }
168}
169
170async function paint($: EngineInterface) {
171  const at = mounted
172  const root = $.plugin.root
173  if (!at || sceneRows(workers.length, at.cols, at.maxRows) !== at.rows) return
174  const { words, spots } = drawScene(workers, at.cols, await $.clock.now(), at.maxRows, at.images)
175  const res = await $.ui.blit({ requestId: PANE, key: 'crew', cells: encode(words) })
176  if (res.deny) { mounted = undefined; return }
177  // The cells went in, so the pane is mounted: an Image refused now is one drawing its alt.
178  for (const s of spots) {
179    const key = lookKey(s.look)
180    if (!at.keys.has(s.key) || shown.get(s.key) === key) continue
181    const img = await $.ui.blit({ requestId: PANE, key: s.key, source: imageSource(s.look, root) })
182    if (img.deny) {
183      if (/alt|placeholder|unsupported/i.test(img.deny) || ++refused >= 6) {
184        images = false
185        mounted = undefined
186        $.ui.log(`cw-crew: no images here (${img.deny}); half-block sprites`)
187        $.ui.invalidate('ui.render')
188      }
189      return
190    }
191    refused = 0
192    shown.set(s.key, key)
193  }
194}
195
196export const register: Register = on => {
197  on('session.start', async ($, e, next) => {
198    session = await $.session.id()
199    const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
200    dir = msysToWin((await $.env.get('CW_LOG_DIR')) || `${home}/.claude/cw-runs`).replace(/\\/g, '/')
201    await $.command.register({ name: 'crew', description: 'Open the cw worker pane (Haiku scouts / Sonnet coders)' })
202    $.clock.every(400, () => void poll($).catch(err => $.ui.log(`cw-crew poll: ${String(err)}`)))
203    $.clock.every(FRAME_MS, () => void paint($).catch(() => { mounted = undefined }))
204    return next(e)
205  })
206
207  // The engine docks a pane on the right only in the fullscreen layout from 110 columns; else it sits above the prompt.
208  on('command.run', { command: 'crew' }, async ($, e) => {
209    opened = true
210    // A reload with the pane open can refuse the first blits before the pane mounts; /crew retries PNG sprites.
211    images = true
212    refused = 0
213    mounted = undefined
214    await $.ui.open(paneArgs())
215    const { isFullscreen, columns } = e.presentation
216    // The layout hints are the terminal's; the desktop places the pane itself.
217    const where = (await $.env.get('CLAUDE_CODE_ENTRYPOINT')) === 'claude-desktop' ? ''
218      : !isFullscreen
219      ? ' It sits above the prompt: the right-side pane needs the fullscreen layout (CLAUDE_CODE_NO_FLICKER=1, outside tmux).'
220      : columns < 110 ? ` It sits above the prompt: the right-side pane needs a terminal of at least 110 columns (now ${columns}).` : ''
221    return { text: `Crew pane opened.${where}` }
222  })
223
224  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
225    await read($, slots)
226    const sel = await read($, selected)
227    const open = sel ? workers.find(w => w.key === sel) : undefined
228    if (open) {
229      mounted = undefined   // paint() must not blit the scene over the detail view
230      const { Box, Text, Button } = $.ui.resolve(e)
231      const now = await $.clock.now()
232      const steps = (open.runIds.flatMap(id => runs.get(id)?.events ?? [])).filter(ev => ev.tool).slice(-STEPS)
233      const ago = (t: number) => clock(Math.max(0, now - t))
234      return (
235        <Box flexDirection="column" paddingX={1}>
236          <Box flexDirection="row" gap={1}>
237            <Button key="back" hotkey="b" onPress={() => { selKey = ''; mounted = undefined; void update($, selected, () => '') }}>‹ back</Button>
238            <Text bold>{open.name}</Text>
239            <Text dimColor>{open.model} · {LABEL[open.state]} · {clock((open.end ?? now) - open.start)} · {open.turns} turns</Text>
240          </Box>
241          {open.state === 'ask' && open.question && <Text color="warning" wrap="wrap">? {open.question}</Text>}
242          <Text bold>Task</Text>
243          <Text dimColor wrap="wrap">{open.task.slice(0, 600)}</Text>
244          <Text bold>Latest notes</Text>
245          {detail.key !== open.key ? <Text dimColor>reading…</Text>
246            : detail.notes.length === 0 ? <Text dimColor>no notes yet</Text>
247            : detail.notes.map(n => <Text wrap="wrap">· {n.slice(0, 400)}</Text>)}
248          <Text bold>Recent steps</Text>
249          {steps.length === 0 ? <Text dimColor>none yet</Text>
250            : steps.map(ev => <Text wrap="truncate-end"><Text dimColor>{ago(ev.t)} ago </Text>{ev.tool} {ev.arg ?? ''}</Text>)}
251          {detail.key === open.key && detail.report !== undefined && <Text bold>Report</Text>}
252          {detail.key === open.key && detail.report !== undefined &&
253            <Text wrap="wrap">{detail.report ? detail.report.slice(0, REPORT_CHARS) : 'no report (timed out or killed)'}</Text>}
254        </Box>
255      )
256    }
257    if (sel) { selKey = ''; void update($, selected, () => '') }   // the worker left the crew
258    if (e.surface !== 'terminal') {
259      const { Box, Text, Button, Svg } = $.ui.resolve(e)
260      const now = await $.clock.now()
261      svgAt = now
262      const cols = Math.max(46, Math.min(138, e.props.bodyColumns))
263      let { rows, words, spots } = drawScene(workers, cols, now, 256, true)
264      const sprites = []
265      for (const s of spots) {
266        const file = frameFile(s.look)
267        let base64 = pngs.get(file)
268        if (base64 === undefined) {
269          base64 = await $.fs.read(`${$.plugin.root}/frames/${file}`, { as: 'bytes' }).then(r => r.base64, () => '')
270          pngs.set(file, base64)
271        }
272        if (base64) sprites.push({ x: s.x, y: s.y, base64 })
273      }
274      let svg = sceneSvg(words, cols, rows, sprites)
275      // A frame that would not read, or too many sprites for one Svg: half-block pixels instead.
276      if (sprites.length < spots.length || svg.length > 131_072) { ({ rows, words } = drawScene(workers, cols, now, 256, false)); svg = sceneSvg(words, cols, rows) }
277      const alt = workers.length ? workers.map(w => `${w.name} ${w.role} ${LABEL[w.state]}`).join(', ') : 'no workers'
278      return (
279        <Box flexDirection="column" gap={1}>
280          {svg.length <= 131_072 ? <Svg key="crew" source={svg} alt={alt} />
281            : workers.map(w => <Text>{w.name} · {w.role} · {LABEL[w.state]}</Text>)}
282          {workers.length > 0 && (
283            <Box flexDirection="row" gap={1} flexWrap="wrap">
284              {workers.map(w => <Button key={`open-${w.key}`} onPress={() => openDetail($, w.key)}>{w.name} {DETAILS}</Button>)}
285            </Box>
286          )}
287        </Box>
288      )
289    }
290    const { Box, Button, Image, Raster } = $.ui.resolve(e)
291    const cols = Math.max(24, Math.min(512, e.props.bodyColumns))
292    // Draw every card, even past the pane's height: the engine scrolls a body taller than its window.
293    // Only the 256-row hard cap still folds workers into one-line rows.
294    const maxRows = 256
295    const { rows, words, spots, hits } = drawScene(workers, cols, await $.clock.now(), maxRows, images)
296    mounted = { cols, rows, maxRows, images, keys: new Set(spots.map(s => s.key)) }
297    const raster = <Raster key="crew" columns={cols} rows={rows} cells={encode(words)} />
298    // A "details ›" Button on each card's bottom edge opens its detail view.
299    // Not `h`: JSX compiles to the global h(), which a parameter of that name would shadow.
300    const buttons = hits.map(hit => (
301      <Box position="absolute" top={hit.y} left={hit.x}>
302        <Button key={`open-${hit.key}`} plain dimColor onPress={() => openDetail($, hit.key)}>{DETAILS}</Button>
303      </Box>
304    ))
305    if (!images) return <Box width={cols} height={rows}>{raster}{buttons}</Box>
306    // Each sprite an Image over the blank cells the scene left for it; paint() swaps its frames.
307    shown = new Map(spots.map(s => [s.key, lookKey(s.look)]))
308    return (
309      <Box width={cols} height={rows}>
310        {raster}
311        {spots.map(s => (
312          <Box position="absolute" top={s.y} left={s.x}>
313            <Image key={s.key} source={imageSource(s.look, $.plugin.root)} columns={SPRITE_COLS} rows={SPRITE_ROWS} alt={s.alt} />
314          </Box>
315        ))}
316        {buttons}
317      </Box>
318    )
319  })
320}
321
hooks/creatures.ts 954 lines
1// The crew's creatures: a round owl per scout, a baby dragon per coder (purple and crowned on Opus).
2// Drawn in code at 60x32 pixels, 4x the old 15x8 half-block sprites each way, for terminals that
3// show images; the same frame downsampled to 15x8 half-blocks for the ones that draw cells only.
4// Frames step every FRAME_MS; motion is eased over long loops so nothing jumps more than a pixel or two.
5
6import type { Role, State } from './scene'
7
8export type Pose = State | 'sleep'
9export const CLEAR = 0x02000000
10export const BIG_W = 60, BIG_H = 32
11// Hi-res pixels per logical pixel in the image frames.
12export const BIG_S = 2
13export const SMALL_W = 15, SMALL_H = 8
14export const FRAME_MS = 125
15// A see-through black (gen_frames.py writes it with alpha) so the shadow suits any terminal background.
16export const SHADOW = 0x03000000
17
18const OUT = 0x2a2140
19const EYE = 0x1f1833, IRIS = 0x4a4fa8, SHINE = 0xffffff, BLUSH = 0xff9cc2, TONGUE = 0xff6f91
20const TEAR = 0x8fd3ff
21const CONFETTI = [0xff6b6b, 0xffd166, 0x06d6a0, 0x4dabf7, 0xcc5de8, 0xffffff]
22
23// Drawn in logical pixels; `s` hi-res pixels per logical one, so curves and outlines come out finer
24// while every shape keeps its logical size and place.
25export class Canvas {
26  px: Uint32Array
27  readonly pw: number; readonly ph: number
28  constructor(readonly w: number, readonly h: number, readonly s = 1) {
29    this.pw = w * s; this.ph = h * s
30    this.px = new Uint32Array(this.pw * this.ph).fill(CLEAR)
31  }
32  at(x: number, y: number) {
33    const i = Math.floor(x * this.s + 1e-6), j = Math.floor(y * this.s + 1e-6)
34    return i >= 0 && j >= 0 && i < this.pw && j < this.ph ? this.px[j * this.pw + i] ?? CLEAR : CLEAR
35  }
36  dot(i: number, j: number, c: number, on?: number) {
37    if (i < 0 || j < 0 || i >= this.pw || j >= this.ph) return
38    if (on !== undefined && this.px[j * this.pw + i] !== on) return
39    this.px[j * this.pw + i] = c
40  }
41  // One logical pixel: an s x s block, placed to the hi-res pixel.
42  put(x: number, y: number, c: number, on?: number) {
43    const i0 = Math.floor(x * this.s + 1e-6), j0 = Math.floor(y * this.s + 1e-6)
44    for (let j = 0; j < this.s; j++) for (let i = 0; i < this.s; i++) this.dot(i0 + i, j0 + j, c, on)
45  }
46  rect(x: number, y: number, w: number, h: number, c: number) {
47    const i0 = Math.floor(x * this.s + 1e-6), j0 = Math.floor(y * this.s + 1e-6)
48    for (let j = 0; j < Math.round(h * this.s); j++) for (let i = 0; i < Math.round(w * this.s); i++) this.dot(i0 + i, j0 + j, c)
49  }
50  // Every hi-res pixel whose centre lies in the ellipse; `keep` (in logical units) limits it to some of them.
51  oval(cx: number, cy: number, rx: number, ry: number, c: number, keep?: (x: number, y: number) => boolean) {
52    const s = this.s
53    for (let j = Math.floor((cy - ry) * s); j <= Math.ceil((cy + ry) * s); j++) {
54      for (let i = Math.floor((cx - rx) * s); i <= Math.ceil((cx + rx) * s); i++) {
55        const dx = ((i + 0.5) / s - cx) / rx, dy = ((j + 0.5) / s - cy) / ry
56        if (dx * dx + dy * dy <= 1 && (!keep || keep(i / s, j / s))) this.dot(i, j, c)
57      }
58    }
59  }
60  // An oval with a dark rim of its own, so it reads over a body of a near colour.
61  rim(cx: number, cy: number, rx: number, ry: number, c: number) {
62    this.oval(cx, cy, rx + 1, ry + 1, OUT)
63    this.oval(cx, cy, rx, ry, c)
64  }
65  tri(ax: number, ay: number, bx: number, by: number, cx: number, cy: number, c: number) {
66    const side = (px: number, py: number, x0: number, y0: number, x1: number, y1: number) =>
67      (px - x1) * (y0 - y1) - (x0 - x1) * (py - y1)
68    const s = this.s
69    for (let j = Math.floor(Math.min(ay, by, cy) * s); j <= Math.ceil(Math.max(ay, by, cy) * s); j++) {
70      for (let i = Math.floor(Math.min(ax, bx, cx) * s); i <= Math.ceil(Math.max(ax, bx, cx) * s); i++) {
71        const px = (i + 0.5) / s, py = (j + 0.5) / s
72        const d1 = side(px, py, ax, ay, bx, by), d2 = side(px, py, bx, by, cx, cy), d3 = side(px, py, cx, cy, ax, ay)
73        if (!((d1 < 0 || d2 < 0 || d3 < 0) && (d1 > 0 || d2 > 0 || d3 > 0))) this.dot(i, j, c)
74      }
75    }
76  }
77  line(x0: number, y0: number, x1: number, y1: number, c: number, thick = 1) {
78    const n = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0)) * this.s))
79    for (let k = 0; k <= n; k++) {
80      const x = x0 + ((x1 - x0) * k) / n, y = y0 + ((y1 - y0) * k) / n
81      this.rect(x, y, thick, thick, c)
82    }
83  }
84  stamp(rows: string[], pal: Record<string, number>, ox: number, oy: number, k = 1) {
85    rows.forEach((row, y) => {
86      for (let x = 0; x < row.length; x++) {
87        const c = pal[row[x] ?? '.']
88        if (c !== undefined) this.rect(ox + x * k, oy + y * k, k, k, c)
89      }
90    })
91  }
92  // A one hi-res pixel dark edge around everything drawn so far: the sticker look.
93  outline(c: number) {
94    const src = this.px.slice(), W = this.pw, H = this.ph
95    const at = (x: number, y: number) => (x < 0 || y < 0 || x >= W || y >= H ? CLEAR : src[y * W + x])
96    for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
97      if (src[y * W + x] !== CLEAR) continue
98      if (at(x - 1, y) !== CLEAR || at(x + 1, y) !== CLEAR || at(x, y - 1) !== CLEAR || at(x, y + 1) !== CLEAR) {
99        this.px[y * W + x] = c
100      }
101    }
102  }
103  over(o: Canvas) {
104    for (let i = 0; i < this.px.length; i++) if ((o.px[i] ?? CLEAR) !== CLEAR) this.px[i] = o.px[i] ?? CLEAR
105  }
106}
107
108const mix = (a: number, b: number, k: number) => {
109  const ch = (s: number) => Math.round(((a >> s) & 255) * (1 - k) + ((b >> s) & 255) * k)
110  return (ch(16) << 16) | (ch(8) << 8) | ch(0)
111}
112// One smooth cycle over `n` frames: 0 at frame 0, 1 halfway, back to 0.
113const cyc = (f: number, n: number) => 0.5 - 0.5 * Math.cos((2 * Math.PI * f) / n)
114const ease = (t: number) => (t <= 0 ? 0 : t >= 1 ? 1 : t * t * (3 - 2 * t))
115const dim = (c: number) => mix(c, 0x000000, 0.3)
116const rnd = (a: number, b: number) => {
117  const x = Math.sin(a * 12.9898 + b * 78.233) * 43758.5453
118  return x - Math.floor(x)
119}
120
121type Pal = {
122  body: number; shade: number; light: number; belly: number; mark: number
123  wing: number; face: number; beak: number; horn: number
124}
125const OWL: Pal = {
126  body: 0x63c9cf, shade: 0x3d9cad, light: 0xb4f0ea, belly: 0xfff3da, mark: 0xe8cf9f,
127  wing: 0x4aa3bd, face: 0xeafaf6, beak: 0xffa938, horn: 0x3d9cad,
128}
129const DRAGON: Pal = {
130  body: 0xffab52, shade: 0xe27c2f, light: 0xffd79c, belly: 0xfff0c8, mark: 0xf0c084,
131  wing: 0xff6f5e, face: 0xffab52, beak: 0, horn: 0xfff4dc,
132}
133const DRAGON_OPUS: Pal = {
134  body: 0xb59cff, shade: 0x8a6fe3, light: 0xe0d6ff, belly: 0xfff0fa, mark: 0xe6c6ef,
135  wing: 0xff8fcf, face: 0xb59cff, beak: 0, horn: 0xfff4dc,
136}
137
138type Arm = 'down' | 'up' | 'wave' | 'hold' | 'type' | 'chin' | 'point'
139type Eyes = 'open' | 'wide' | 'closed' | 'happy' | 'dizzy' | 'squeeze' | 'sad'
140type Mouth = 'smile' | 'open' | 'o' | 'frown' | 'flat'
141type Body = {
142  x: number; y: number; squash: number; look: [number, number]
143  eyes: Eyes; mouth: Mouth; left: Arm; right: Arm; phase: number
144  hat?: 'crown' | 'nightcap' | 'shell'; glasses?: boolean; flap: number
145}
146type Frame = { cx: number; cy: number; top: number; bottom: number; rx: number; ry: number; eyY: number }
147
148function frameOf(b: Body): Frame {
149  const top = 8 + b.y + b.squash, bottom = 30.5 + b.y
150  const cy = (top + bottom) / 2, ry = (bottom - top) / 2
151  return { cx: 16 + b.x, cy, top, bottom, rx: 12.5 + b.squash * 0.5, ry, eyY: cy - 3.5 }
152}
153
154function eye(c: Canvas, x: number, y: number, kind: Eyes, look: [number, number], phase: number, side: number, lid: number) {
155  const ix = Math.floor(x), iy = Math.floor(y)
156  switch (kind) {
157    case 'open':
158    case 'wide':
159    case 'sad': {
160      const r = kind === 'wide' ? 3.5 : 3.1
161      const lx = x + look[0], ly = y + look[1]
162      c.oval(lx, ly, r, r + 0.6, EYE)
163      c.oval(lx, ly + 1.8, r - 0.9, 1.6, IRIS, (px, py) => c.at(px, py) === EYE)
164      c.rect(Math.floor(lx - 1.7), Math.floor(ly - 2.3), 2, 2, SHINE)
165      c.put(lx + 1.1, ly + 1.2, SHINE)
166      if (kind === 'wide') c.put(lx + 1.4, ly - 2.2, SHINE)
167      if (kind === 'sad') {
168        // a heavy lid slanting down to the outside
169        for (let yy = iy - 5; yy <= iy; yy++) for (let xx = ix - 4; xx <= ix + 4; xx++) {
170          const cut = iy - 1 + (xx - ix) * side * 0.4
171          if (yy < cut) for (const on of [EYE, IRIS, SHINE]) c.put(xx, yy, lid, on)
172        }
173        c.line(ix - 3, iy - 1 - 3 * side * 0.4, ix + 3, iy - 1 + 3 * side * 0.4, EYE)
174      }
175      break
176    }
177    case 'closed':
178      for (const [dx, dy] of [[-2, 0], [-1, 1], [0, 1], [1, 1], [2, 0]] as const) c.put(ix + dx, iy + dy, EYE)
179      break
180    case 'happy':
181      for (const [dx, dy] of [[-2, 1], [-2, 2], [-1, 0], [0, -1], [1, 0], [2, 1], [2, 2]] as const) c.put(ix + dx, iy + dy, EYE)
182      break
183    case 'squeeze':
184      for (const [dx, dy] of [[-2, -2], [-1, -1], [0, 0], [-1, 1], [-2, 2], [-1, -2], [0, -1], [1, 0], [0, 1], [-1, 2]] as const) {
185        c.put(ix + dx * -side, iy + dy, EYE)
186      }
187      break
188    case 'dizzy':
189      for (let t = 0; t < 4 * Math.PI; t += 0.12) {
190        const r = 0.3 + t * 0.26
191        c.put(x + r * Math.cos(t * side + phase), y + 0.3 + r * Math.sin(t * side + phase), EYE)
192      }
193      break
194  }
195}
196
197function body(c: Canvas, role: Role, pal: Pal, b: Body) {
198  const { cx, cy, top, bottom, rx, ry, eyY } = frameOf(b)
199  if (role === 'coder') {
200    // the tail curls out behind, to the right
201    const wag = Math.sin((b.phase * Math.PI) / 8) * 0.8
202    for (let t = 0; t <= 1.0001; t += 0.05) {
203      const r = 3 - 1.7 * t
204      c.oval(cx + 8 + 8.5 * t, bottom - 3 - 9 * t * t + wag * t, r, r, pal.body)
205    }
206    c.tri(cx + 14.5, bottom - 13 + wag, cx + 18.5, bottom - 15 + wag, cx + 17, bottom - 10 + wag, pal.wing)
207    // bat wings behind the shoulders
208    for (const s of [-1, 1]) {
209      c.tri(cx + s * 7, cy - 6, cx + s * 15.6, cy - 12 - b.flap, cx + s * 10, cy + 1, pal.wing)
210      c.tri(cx + s * 11, cy - 4, cx + s * 15.6, cy - 12 - b.flap, cx + s * 15, cy - 1, pal.wing)
211    }
212  } else {
213    for (const s of [-1, 1]) c.tri(cx + s * 4.5, top + 3, cx + s * 10.5, top - 4.5, cx + s * 11.5, top + 6, pal.horn)
214  }
215
216  c.oval(cx, cy, rx, ry, pal.shade)
217  c.oval(cx - 0.6, cy - 0.9, rx - 1.1, ry - 0.9, pal.body)
218  c.oval(cx - 6, top + 3.6, 3.6, 1.7, pal.light)
219  c.oval(cx, bottom - 5.8, 7.6, 5.4, pal.belly)
220  if (role === 'scout') {
221    for (const [mx, my] of [[-4, -5], [1, -5], [-2, -2.5], [3, -2.5]] as const) {
222      const x = Math.floor(cx + mx), y = Math.floor(bottom + my)
223      c.put(x, y, pal.mark); c.put(x + 1, y + 1, pal.mark); c.put(x + 2, y, pal.mark)
224    }
225    for (const s of [-1, 1]) c.oval(cx + s * 5.5, eyY + 0.4, 5.9, 5.3, pal.face)
226  } else {
227    for (const dy of [-6.5, -3.5]) c.line(cx - 6, bottom + dy, cx + 5, bottom + dy, pal.mark)
228    for (const y of [bottom - 6.5, bottom - 3.5]) for (let x = cx - 8; x <= cx + 8; x++) {
229      if (c.at(x, y) === pal.mark && (c.at(x, y - 1) !== pal.belly || c.at(x, y + 1) !== pal.belly)) c.put(x, y, pal.belly)
230    }
231    for (const s of [-1, 1]) c.tri(cx + s * 4.5, top + 2.2, cx + s * 8.5, top - 5, cx + s * 8.6, top + 2.8, pal.horn)
232  }
233
234  // feet
235  for (const s of [-1, 1]) {
236    if (role === 'scout') c.oval(cx + s * 5, bottom, 2.6, 1.3, pal.beak)
237    else c.oval(cx + s * 6, bottom - 0.3, 3, 1.6, pal.shade)
238  }
239
240  for (const s of [-1, 1]) c.oval(cx + s * 8.6, eyY + 4.2, 1.8, 1, BLUSH)
241  for (const [s, x] of [[-1, cx - 5.5], [1, cx + 5.5]] as const) eye(c, x, eyY, b.eyes, b.look, (b.phase * Math.PI) / 8, s, pal.face)
242  if (b.glasses) {
243    for (const s of [-1, 1]) {
244      const gx = cx + s * 5.5
245      c.oval(gx, eyY, 4.9, 4.9, OUT, (x, y) => {
246        const dx = x + 0.5 - gx, dy = y + 0.5 - eyY
247        return dx * dx + dy * dy > 3.9 * 3.9
248      })
249    }
250    c.line(cx - 1, eyY - 1, cx, eyY - 1, OUT)
251  }
252
253  const my = Math.floor(eyY + 3)
254  const mx = Math.floor(cx)
255  if (role === 'scout') {
256    if (b.mouth === 'open' || b.mouth === 'o') {
257      c.rect(mx - 2, my, 4, 1, pal.beak); c.rect(mx - 2, my + 1, 4, 2, EYE)
258      c.put(mx - 2, my + 1, pal.beak); c.put(mx + 1, my + 1, pal.beak); c.rect(mx - 1, my + 3, 2, 1, pal.beak)
259      if (b.mouth === 'open') c.rect(mx - 1, my + 2, 2, 1, TONGUE)
260    } else {
261      c.rect(mx - 2, my, 4, 1, pal.beak); c.rect(mx - 1, my + 1, 2, 1, pal.beak)
262      if (b.mouth === 'frown') c.rect(mx - 1, my + 2, 2, 1, mix(pal.beak, EYE, 0.3))
263    }
264  } else {
265    c.put(mx - 2, my - 1, pal.shade); c.put(mx + 1, my - 1, pal.shade)
266    const y = my + 1
267    if (b.mouth === 'smile') {
268      for (const [dx, dy] of [[-3, 0], [-2, 1], [-1, 0], [0, 0], [1, 1], [2, 0]] as const) c.put(mx + dx, y + dy, EYE)
269    } else if (b.mouth === 'open') {
270      c.oval(cx, y + 1.2, 2.4, 1.9, EYE)
271      c.oval(cx, y + 2.4, 1.6, 0.8, TONGUE, (px, py) => c.at(px, py) === EYE)
272      c.put(mx - 2, y, SHINE)
273    } else if (b.mouth === 'o') {
274      c.oval(cx, y + 0.8, 1.3, 1.4, EYE)
275    } else if (b.mouth === 'frown') {
276      for (const [dx, dy] of [[-2, 1], [-1, 0], [0, 0], [1, 1]] as const) c.put(mx + dx, y + dy, EYE)
277    } else {
278      c.rect(mx - 1, y, 2, 1, EYE)
279    }
280  }
281
282  if (b.hat === 'crown') {
283    c.stamp([
284      'Y...YY...Y',
285      'YY.YYYY.YY',
286      'YYYYPPYYYY',
287      'yyyyyyyyyy',
288    ], { Y: 0xffd447, y: 0xe0a422, P: 0xff5fa2 }, mx - 5, Math.floor(top) - 4)
289  } else if (b.hat === 'nightcap') {
290    const t = Math.floor(top)
291    c.tri(mx - 8, t + 2, mx + 8, t + 2, mx + 11, t - 6, 0x6c7bff)
292    c.tri(mx - 2, t - 1, mx + 6, t - 1, mx + 11, t - 6, 0x8f9bff)
293    c.rect(mx - 8, t + 1, 17, 2, 0xf4f1ff)
294    c.oval(mx + 12, t - 6.5, 2.2, 2.2, 0xf4f1ff)
295  } else if (b.hat === 'shell') {
296    const t = Math.floor(top)
297    // the top of the eggshell, its broken edge jagged
298    c.oval(cx, t + 2, 7, 5, 0xfff6e6, (x, y) => y < t + 1 || (y === t + 1 && x % 2 === 0))
299    c.oval(cx - 3, t - 1, 1.2, 0.8, pal.body)
300  }
301}
302
303function arm(c: Canvas, role: Role, pal: Pal, b: Body, side: number, kind: Arm) {
304  const { cx, cy, eyY } = frameOf(b)
305  // owls have wings, dragons stubby paws: smaller, in the body's shade
306  const col = role === 'scout' ? pal.wing : pal.shade
307  const k = role === 'scout' ? 1 : 0.75
308  const s = side
309  const limb = (x: number, y: number, rx: number, ry: number) => c.rim(x, y, rx * k, ry * k, col)
310  switch (kind) {
311    case 'down': limb(cx + s * (role === 'scout' ? 11.2 : 10), cy + 3, 2.5, 4.8); break
312    case 'up': limb(cx + s * 12.4, cy - 8.5, 2.3, 4.5); break
313    case 'wave':
314      if (b.phase % 8 < 4) limb(cx + s * 12.4, cy - 8.5, 2.3, 4.5)
315      else limb(cx + s * 13.8, cy - 7, 2.3, 4.2)
316      break
317    case 'hold': limb(cx + s * 12.6, cy + 3, 4.6, 2.3); break
318    case 'type': limb(cx + s * 12.2, cy + 4.5 - (Math.floor(b.phase / 2) % 2 ? 1.5 : 0), 4.2, 2.2); break
319    case 'chin': c.rim(cx + s * 4.5, eyY + 7, 2.2, 1.6, col); break
320    case 'point':
321      limb(cx + s * 11.5, cy - 2, 2.4, 3.2)
322      limb(cx + s * 13.8, cy - 6.5, 2.2, 2.4)
323      c.oval(cx + s * 11.5, cy - 2, 2.4 * k, 3.2 * k, col)
324      break
325  }
326}
327
328function sparkle(c: Canvas, x: number, y: number, size: number, col = 0xffffff) {
329  if (size <= 0) return
330  c.put(x, y, col)
331  for (let k = 1; k <= size; k++) {
332    const t = k === size ? mix(col, 0x8888aa, 0.35) : col
333    c.put(x + k, y, t); c.put(x - k, y, t); c.put(x, y + k, t); c.put(x, y - k, t)
334  }
335}
336
337function star(c: Canvas, x: number, y: number) {
338  c.stamp(['..y..', '.yyy.', 'yyWyy', '.yyy.', '.y.y.'], { y: 0xffe066, W: 0xfffbe0 }, Math.floor(x) - 2, Math.floor(y) - 2)
339}
340
341const Z5 = ['#####', '...#.', '..#..', '.#...', '#####']
342const Z4 = ['####', '..#.', '.#..', '####']
343const QUESTION = ['.###.', '#...#', '...#.', '..#..', '..#..', '.....', '..#..']
344
345// Which frame a state shows at `now`: `seed` puts each worker's loop at its own phase.
346// Counts are frames of FRAME_MS. An intro plays once from the state's start; then the loop runs,
347// or, with no loop, the intro's last frame holds.
348export const LOOP: Record<Pose, number> = {
349  boot: 0, resumed: 0, think: 32, read: 48, write: 48, bash: 32, web: 32, test: 32,
350  blocked: 16, error: 32, ask: 32, done: 1, failed: 1, sleep: 32,
351}
352export const INTRO: Partial<Record<Pose, number>> = { boot: 12, resumed: 12, blocked: 8, done: 16, failed: 8 }
353export const BLINKS: Pose[] = ['think', 'read', 'write', 'bash', 'web', 'test', 'ask']
354// Working creatures breathe on a 2 s cycle; every loop above is a multiple of it.
355const BREATHE: Pose[] = ['think', 'read', 'write', 'bash', 'web', 'test', 'ask', 'error', 'blocked']
356export type Look = { role: Role; opus: boolean; state: Pose; frame: number; blink: boolean }
357
358export function lookAt(role: Role, model: string, state: Pose, now: number, stateAt: number, seed: number): Look {
359  const opus = /opus/i.test(model)
360  const tick = Math.floor(now / FRAME_MS) + seed
361  const intro = INTRO[state] ?? 0, loop = LOOP[state]
362  const step = Math.floor(Math.max(0, now - stateAt) / FRAME_MS)
363  const frame = step < intro ? step : loop ? intro + (loop > 1 ? tick % loop : 0) : intro - 1
364  const blink = BLINKS.includes(state) && tick % 31 === 0
365  return { role, opus, state, frame, blink }
366}
367export const lookKey = (l: Look) => `${l.role}${l.opus ? '+' : ''}:${l.state}:${l.frame}${l.blink ? 'b' : ''}`
368export const frameCount = (state: Pose) => (INTRO[state] ?? 0) + LOOP[state]
369
370export function drawLook({ role, opus, state, frame: f, blink }: Look): Canvas {
371  const pal = role === 'scout' ? OWL : opus ? DRAGON_OPUS : DRAGON
372  const c = new Canvas(BIG_W, BIG_H, BIG_S)
373  const fx = new Canvas(BIG_W, BIG_H, BIG_S)
374  const b: Body = {
375    x: 0, y: 0, squash: 0, look: [0, 0], eyes: 'open', mouth: 'smile', left: 'down', right: 'down',
376    phase: f, hat: opus ? 'crown' : undefined, flap: 0,
377  }
378  let creature = true
379  let mid: () => void = () => {}
380  let front: () => void = () => {}
381
382  switch (state) {
383    case 'boot': {
384      if (f <= 6) {
385        creature = false
386        const ox = [0, 0, -1, 0, 1, 0, -1][f] ?? 0
387        c.oval(16 + ox, 19.5, 10.5, 12.5, 0xfff6e6)
388        c.oval(17.5 + ox, 21, 9, 11, 0xf1e2c8, (x, y) => (x - 16 - ox) * 0.6 + (y - 19.5) > 6)
389        for (const [x, y, rx, ry] of [[11.5, 14, 2.2, 1.6], [20.5, 23, 2.8, 2.1], [12.5, 26, 1.6, 1.3], [21.5, 12, 1.4, 1.1]] as const) {
390          c.oval(x + ox, y, rx, ry, pal.body)
391        }
392        c.oval(12.5 + ox, 11, 1.4, 2.6, 0xffffff)
393        const crack: [number, number][] = [[6, 17], [8, 20], [10, 16], [13, 21], [15, 17], [18, 21], [21, 16], [24, 20], [26, 17]]
394        const n = Math.max(0, Math.floor((f - 1) * 1.6))
395        for (let i = 0; i < Math.min(n, crack.length - 1); i++) {
396          const p = crack[i], q = crack[i + 1]
397          if (p && q) c.line(p[0] + ox, p[1], q[0] + ox, q[1], OUT)
398        }
399        if (f >= 3) for (let i = 0; i < 3; i++) sparkle(fx, 36 + rnd(i, f) * 20, 4 + rnd(f, i) * 22, 1, i % 2 ? pal.body : 0xffffff)
400      } else {
401        const out = f >= 10
402        b.eyes = out ? 'happy' : 'wide'
403        b.mouth = out ? 'open' : 'o'
404        b.left = out ? 'up' : 'down'; b.right = out ? 'up' : 'down'
405        b.y = f === 10 ? -1.5 : f === 11 ? -0.5 : 0
406        if (out) b.hat = 'shell'
407        front = () => {
408          if (!out) {
409            // the lower half of the shell still around the belly, the cap flying off up and to the right
410            c.oval(16, 23, 12.5, 9, 0xfff6e6, (x, y) => y >= 26 + (x % 2))
411            for (let x = 4; x <= 28; x++) c.put(x, 25 + (x % 2), OUT)
412            const t = ease((f - 6) / 3)
413            const tx = 30 + 16 * t, ty = 9 - 7 * t
414            c.oval(tx, ty + 3, 6, 4, 0xfff6e6, (_, y) => y < ty + 4)
415            c.oval(tx - 2, ty + 1.5, 1.5, 1, pal.body)
416          } else {
417            c.oval(40, 29.5, 4, 2.5, 0xfff6e6, (_, y) => y >= 29)
418            c.oval(50, 30, 3, 2, 0xfff6e6, (_, y) => y >= 30)
419          }
420          for (let i = 0; i < 5; i++) sparkle(fx, 34 + rnd(i, f) * 24, 3 + rnd(f, i) * 24, 1 + ((i + f) % 2), [0xffffff, 0xffe066, pal.body][i % 3])
421        }
422      }
423      break
424    }
425
426    case 'think': {
427      b.look = [0.6 + 0.5 * Math.sin((f * Math.PI) / 16), -1]; b.right = 'chin'; b.mouth = 'flat'
428      mid = () => {
429        const dy = Math.round(cyc(f, 32))
430        for (const [x, y, rx, ry] of [[41.5, 8.5, 5, 4.5], [48, 6.5, 6, 5.2], [54.5, 8.5, 4.6, 4.2], [45, 12, 5.5, 3.2], [51.5, 12, 5.5, 3.2]] as const) {
431          c.oval(x, y + dy, rx, ry, 0xf4f1ff)
432        }
433        c.oval(34.5, 18.5, 1.6, 1.6, 0xf4f1ff)
434        c.oval(37.5, 15 + dy * 0.5, 2.2, 2.1, 0xf4f1ff)
435        // the dots light one a second, each fading in
436        for (let i = 0; i < 3; i++) c.rect(42 + i * 5, 8 + dy, 3, 3, mix(0xd5d0ea, 0x6a5acd, ease((f - 8 * i - 5) / 3)))
437      }
438      break
439    }
440
441    case 'read': {
442      // the eyes follow the highlighted line down the left page, then the right, then the page turns
443      const line = Math.min(7, Math.floor(f / 5))
444      b.look = [line < 4 ? 0.2 : 1.4, 0.4 + (line % 4) * 0.35]; b.glasses = true; b.right = 'hold'; b.mouth = 'flat'
445      mid = () => {
446        c.rect(31, 16, 27, 14, OUT)
447        c.rect(32, 27, 25, 2, 0xc4664d)
448        c.rect(32, 17, 12, 10, 0xfffaf0); c.rect(45, 17, 11, 10, 0xfffaf0)
449        c.rect(44, 17, 1, 10, 0xd9cbb2); c.rect(43, 17, 1, 10, 0xece2cf)
450        for (let i = 0; i < 4; i++) {
451          for (const [x0, w0, k] of [[33, 9, 0], [46, 9, 1]] as const) {
452            const len = 5 + Math.floor(rnd(i + k * 4, 0) * (w0 - 4))
453            c.rect(x0, 18 + i * 2 + 1, len, 1, f < 40 && line === i + k * 4 ? 0x4d9bf0 : 0xa7adbf)
454          }
455        }
456        const turn = f >= 41 && f <= 46 ? Math.floor((f - 41) / 2) : -1
457        if (turn >= 0) {
458          const x = [46, 43, 35][turn] ?? 46, w = turn === 1 ? 2 : 9, y = turn === 1 ? 10 : 14
459          c.rect(x - 1, y - 1, w + 2, 27 - y + 1, OUT)
460          c.rect(x, y, w, 27 - y, 0xfff3dd)
461        }
462      }
463      break
464    }
465
466    case 'write':
467    case 'bash': {
468      const code: [number, number, number][][] = [
469        [[0, 3, 0xff7aa2], [4, 5, 0x7cc7ff], [10, 2, 0xc9c9e6]],
470        [[2, 4, 0x7ee0a1], [7, 6, 0xffd166]],
471        [[2, 3, 0xff7aa2], [6, 7, 0x7cc7ff]],
472        [[4, 8, 0xc9c9e6], [13, 2, 0xffd166]],
473        [[0, 1, 0xc9c9e6]],
474      ]
475      if (state === 'write') {
476        // types the snippet out over ~4 s, then rests with the cursor blinking
477        const budget0 = Math.floor(f * 1.25)
478        const typing = budget0 < 41
479        b.right = typing ? 'type' : 'hold'
480        mid = () => {
481          c.rect(34, 6, 22, 18, OUT)
482          c.rect(35, 7, 20, 16, 0x3b4252)
483          c.rect(36, 8, 18, 14, 0x1e2230)
484          let budget = budget0
485          code.forEach((segs, row) => {
486            for (const [x, w, col] of segs) {
487              const shown = Math.max(0, Math.min(w, budget))
488              budget -= w
489              c.rect(37 + x, 10 + row * 2, shown, 1, col)
490              if (shown > 0 && shown < w) c.rect(37 + x + shown, 9 + row * 2, 1, 2, 0xffffff)
491            }
492          })
493          if (!typing && f % 8 < 4) c.rect(39, 17, 1, 2, 0xffffff)
494          c.rect(31, 24, 28, 5, OUT)
495          c.rect(32, 25, 26, 3, 0xc5ccd8)
496          for (let x = 33; x < 57; x += 2) c.put(x, 26, 0x8f99aa)
497          c.rect(36, 24, 18, 1, 0x8f99aa)
498          if (typing) for (let i = 0; i < 3; i++) {
499            const ph = (f + i * 5) % 16
500            fx.rect(38 + i * 7, 5 - ph * 0.35, 2, 1, [0xff7aa2, 0x7ee0a1, 0x7cc7ff][i] ?? 0xffffff)
501          }
502        }
503        b.look = [1, 0.2 + Math.min(4, Math.floor(budget0 / 9)) * 0.25]
504      } else {
505        // a line every second scrolls up; the prompt types between them
506        const s = Math.floor(f / 8), inLine = f % 8
507        const typed = Math.min(3, Math.floor(inLine / 2))
508        b.look = [1, 0.6]; b.right = inLine < 6 ? 'type' : 'hold'
509        mid = () => {
510          c.rect(33, 2, 26, 22, OUT)
511          c.rect(34, 3, 24, 3, 0x4c566a)
512          c.put(36, 4, 0xff5f56); c.put(38, 4, 0xffbd2e); c.put(40, 4, 0x27c93f)
513          c.rect(34, 6, 24, 17, 0x0d1117)
514          for (let row = 0; row < 4; row++) {
515            const k = (row + s) % 4
516            const len = 6 + Math.floor(rnd(k, 3) * 14)
517            c.rect(36, 8 + row * 3, len, 1, k % 3 === 0 ? 0x81c784 : 0x8b949e)
518          }
519          c.stamp(['#..', '.#.', '#..'], { '#': 0x81c784 }, 36, 19)
520          c.rect(40, 20, typed * 2, 1, 0xe6edf3)
521          if (f % 8 < 4) c.rect(40 + typed * 2, 19, 2, 3, 0xe6edf3)
522          c.rect(31, 25, 24, 5, OUT)
523          c.rect(32, 26, 22, 3, 0x9aa5b1)
524          for (let x = 33; x < 53; x += 2) c.put(x, 27, 0x6b7584)
525        }
526      }
527      break
528    }
529
530    case 'web': {
531      b.look = [1, -1]; b.right = 'point'; b.mouth = 'o'
532      mid = () => {
533        const gx = 47, gy = 13, r = 8.5
534        c.oval(gx, gy, r + 1, r + 1, OUT)
535        c.oval(gx, gy, r, r, 0x3a86ff, (x, y) => {
536          const nx = (x + 0.5 - gx) / r, ny = (y + 0.5 - gy) / r
537          const lon = Math.asin(Math.max(-1, Math.min(1, nx / Math.sqrt(Math.max(0.0001, 1 - ny * ny))))) + (f * Math.PI) / 16
538          const lat = Math.asin(ny)
539          const land = Math.sin(lon * 2 + 1) * Math.cos(lat * 2.5) + Math.sin(lon * 3 + lat * 3) * 0.6 > 0.45
540          if (land) c.put(x, y, nx > 0.55 ? 0x3a9e5c : 0x52d17c)
541          return !land
542        })
543        c.oval(gx, gy, r, r, 0x2a64c8, (x, y) => c.at(x, y) === 0x3a86ff && (x + 0.5 - gx) / r > 0.55)
544        c.rect(gx - 4, gy - 6, 2, 2, 0xd8ecff)
545        c.rect(gx - 1, gy + 10, 2, 3, 0x9aa5b1)
546        c.rect(gx - 5, gy + 13, 10, 2, 0x9aa5b1)
547        const arcs = 1 + Math.min(2, Math.floor((f % 16) / 5))
548        for (let k = 0; k < arcs; k++) {
549          const rr = 3 + k * 2.5
550          const col = k === arcs - 1 ? dim(0x4dd0e1) : 0x4dd0e1
551          for (let a = -0.2; a <= Math.PI / 2 + 0.2; a += 0.08) fx.put(gx + 4 + rr * Math.cos(a - Math.PI / 2), gy - 6 + rr * Math.sin(a - Math.PI / 2), col)
552        }
553      }
554      break
555    }
556
557    case 'test': {
558      // a clipboard of checks ticking green one by one; the row being run pulses yellow
559      const row = Math.min(4, Math.floor((f + 1) / 7))
560      b.look = [1, -0.4 + Math.min(3, row) * 0.45]; b.glasses = true; b.right = 'hold'; b.mouth = row >= 4 ? 'smile' : 'flat'
561      mid = () => {
562        c.rect(34, 3, 23, 28, OUT)
563        c.rect(35, 4, 21, 26, 0xc68a4f)
564        c.rect(37, 7, 17, 21, 0xfffaf0)
565        c.rect(41, 1, 9, 4, OUT)
566        c.rect(42, 2, 7, 2, 0x9aa5b1)
567        for (let i = 0; i < 4; i++) {
568          const y = 8 + i * 5
569          const done = i < row, now = i === row
570          c.rect(38, y, 5, 5, OUT)
571          c.rect(39, y + 1, 3, 3, done ? 0xd7f5df : now ? mix(0xfff3c4, 0xffd166, cyc(f, 4)) : 0xffffff)
572          if (done) c.stamp(['..#', '#.#', '.#.'], { '#': 0x2e9e4f }, 39, y + 1)
573          c.rect(44, y + 2, 5 + Math.floor(rnd(i, 1) * 5), 1, done ? 0x6b7584 : 0xa7adbf)
574        }
575        if (row >= 4) for (let k = 0; k < 3; k++) sparkle(fx, 36 + rnd(k, 9) * 20, 2 + rnd(9, k) * 6, (f + k) % 3 ? 1 : 2, k % 2 ? 0x7bd88f : 0xffffff)
576      }
577      break
578    }
579
580    case 'resumed': {
581      // the answer lands: two little hops under a lighting bulb
582      b.eyes = 'wide'; b.mouth = 'open'; b.left = 'up'; b.right = 'up'
583      b.y = -2.5 * Math.sin((Math.PI * (f % 6)) / 6)
584      b.flap = f % 4 < 2 ? 1 : 0
585      mid = () => {
586        const gx = 47, gy = 11
587        const lit = ease(f / 4)
588        c.oval(gx, gy, 7.2, 7.4, mix(0xd8dce6, 0xffe066, lit))
589        c.oval(gx + 1.5, gy + 1.5, 5.5, 5.5, mix(0xb8bdca, 0xffd23f, lit), (x, y) => (x - gx) + (y - gy) > 3)
590        c.oval(gx - 2.5, gy - 3, 1.4, 2.2, 0xfffbe0)
591        c.line(gx - 2, gy + 2, gx - 1, gy + 4, 0xff9f1c); c.line(gx - 1, gy + 4, gx, gy + 2, 0xff9f1c)
592        c.line(gx, gy + 2, gx + 1, gy + 4, 0xff9f1c)
593        c.rect(gx - 4, gy + 7, 8, 6, OUT)
594        c.rect(gx - 3, gy + 7, 6, 5, 0xb0b8c4)
595        c.rect(gx - 3, gy + 8, 6, 1, 0x7f889a); c.rect(gx - 3, gy + 10, 6, 1, 0x7f889a)
596        const len = f < 3 ? 0 : 1 + Math.min(2, Math.floor((f - 3) / 2))
597        for (let k = 0; k < 8; k++) {
598          if (k === 2) continue
599          const a = (k * Math.PI) / 4 - Math.PI / 2
600          for (let d = 0; d < len; d++) fx.put(gx + Math.cos(a) * (10 + d), gy + Math.sin(a) * (10 + d), 0xfff3b0)
601        }
602      }
603      break
604    }
605
606    case 'ask': {
607      b.eyes = 'wide'; b.look = [1, -1]; b.mouth = 'open'; b.right = 'wave'
608      mid = () => {
609        const dy = Math.round(cyc(f, 32))
610        c.oval(47, 11 + dy, 10, 9.5, 0xfffaf0)
611        c.tri(36.5, 13 + dy, 40, 17 + dy, 33, 21 + dy, 0xfffaf0)
612        c.stamp(QUESTION, { '#': 0xff9f1c }, 42, 4 + dy, 2)
613        const tw = f % 16
614        if (tw >= 2 && tw <= 5) sparkle(fx, 57, 2, tw === 3 || tw === 4 ? 2 : 1, 0xffd166)
615        if (tw >= 10 && tw <= 12) sparkle(fx, 36, 3, 1, 0xffd166)
616      }
617      break
618    }
619
620    case 'blocked': {
621      b.eyes = 'squeeze'; b.mouth = 'frown'
622      if (f < 8) b.x = [-1, 1, -1, 1, -0.6, 0.6, -0.3, 0][f] ?? 0
623      const g = Math.max(0, f - 8)
624      mid = () => {
625        const gx = 47, gy = 13
626        c.oval(gx, gy, 9, 9, OUT)
627        c.oval(gx, gy, 8, 8, f < 8 ? 0xff4d5e : mix(0xd93a4b, 0xff4d5e, cyc(g, 16)))
628        c.rect(gx - 6, gy - 1, 12, 3, 0xffffff)
629        const fr = frameOf(b)
630        const ph = (g + 10) % 16
631        fx.oval(fr.cx + 10, fr.top + 3 + ph * 0.35, 1.2, 1.7, TEAR)
632        fx.put(fr.cx + 9.6, fr.top + 2 + ph * 0.35, TEAR)
633      }
634      break
635    }
636
637    case 'error': {
638      // a slow dizzy spin: half the usual phase rate
639      b.eyes = 'dizzy'; b.mouth = 'flat'; b.phase = f / 2
640      b.x = 0.8 * Math.sin((2 * Math.PI * f) / 32)
641      mid = () => {
642        c.tri(47, 3, 36, 22.5, 58, 22.5, OUT)
643        c.tri(47, 5.5, 38, 21.5, 56, 21.5, mix(0xff9f43, 0xffc078, cyc(f, 32)))
644        c.rect(46, 10, 2, 6, OUT); c.rect(46, 17, 2, 2, OUT)
645        const fr = frameOf(b)
646        for (let k = 0; k < 3; k++) {
647          const a = (f * Math.PI) / 16 + (k * 2 * Math.PI) / 3
648          star(fx, fr.cx + 11 * Math.cos(a), fr.top - 0.5 + 2.2 * Math.sin(a))
649        }
650      }
651      break
652    }
653
654    case 'done': {
655      if (f < 16) {
656        // two hops, a squash on each landing, confetti drifting down
657        const h = f % 8
658        b.eyes = 'happy'; b.mouth = 'open'; b.left = 'up'; b.right = 'wave'
659        b.y = -3 * Math.sin((Math.PI * h) / 8)
660        b.squash = h === 0 ? 1 : 0
661        b.flap = f % 4 < 2 ? 2 : 0
662        front = () => {
663          for (let i = 0; i < 16; i++) {
664            const x = rnd(i, 7) * 60 + Math.sin((f + i) * 0.6) * 1.2, y = (rnd(i, 3) * 32 + f) % 32
665            const col = CONFETTI[i % CONFETTI.length] ?? 0xffffff
666            if ((i + Math.floor(f / 2)) % 2) fx.rect(x, y, 2, 1, col)
667            else fx.rect(x, y, 1, 2, col)
668          }
669        }
670      } else {
671        b.eyes = 'happy'; b.mouth = 'smile'
672        mid = () => {
673          const gx = 46, gy = 15
674          c.oval(gx, gy, 8, 8, 0x5cd67a)
675          c.oval(gx + 1, gy + 1, 6.5, 6.5, 0x43bd62, (x, y) => (x - gx) + (y - gy) > 4)
676          c.line(41, 15, 44, 18, 0xffffff, 2); c.line(44, 18, 50, 11, 0xffffff, 2)
677          sparkle(fx, 56, 4, 2, 0xffffff); sparkle(fx, 36, 5, 1, 0xfff3b0)
678        }
679      }
680      break
681    }
682
683    case 'failed': {
684      // sinks down over a second, then holds
685      b.eyes = 'sad'; b.mouth = 'frown'; b.squash = ease(Math.min(f, 7) / 7)
686      mid = () => {
687        const gx = 46, gy = 15
688        c.oval(gx, gy, 8, 8, 0xff5c6c)
689        c.oval(gx + 1, gy + 1, 6.5, 6.5, 0xe5485a, (x, y) => (x - gx) + (y - gy) > 4)
690        c.line(42, 11, 49, 18, 0xffffff, 2); c.line(49, 11, 42, 18, 0xffffff, 2)
691        const fr = frameOf(b)
692        for (const [s, off] of [[-1, 0], [1, 3]] as const) {
693          const ph = Math.min(f, 7 + off) - off
694          if (ph >= 0) fx.rect(fr.cx + s * 5.5 + (s < 0 ? -3 : 2), fr.eyY + 3 + Math.min(ph, 4) * 0.8, 1, 2, TEAR)
695        }
696      }
697      break
698    }
699
700    case 'sleep': {
701      b.eyes = 'closed'; b.mouth = 'o'; b.hat = 'nightcap'
702      b.squash = 0.2 + 0.9 * cyc(f, 32)
703      front = () => {
704        const fr = frameOf(b)
705        const r = 0.9 + 2.2 * cyc(f, 32)
706        fx.oval(fr.cx + 4.5, fr.eyY + 6.5 - r * 0.3, r, r, 0xbfe9ff)
707        fx.put(fr.cx + 4 - r * 0.4, fr.eyY + 5.5 - r * 0.6, 0xffffff)
708        for (let k = 0; k < 3; k++) {
709          const p = ((f + k * 11) % 32) / 32
710          const col = mix(0xc6b3ff, 0x2a2d3d, p * 0.9)
711          fx.stamp(k === 0 ? Z4 : Z5, { '#': col }, Math.floor(35 + k * 7 + p * 4), Math.floor(21 - k * 7 - p * 5))
712        }
713      }
714      break
715    }
716  }
717
718  if (BREATHE.includes(state)) b.squash += 0.7 * cyc(f, 16)
719  if (blink && (b.eyes === 'open' || b.eyes === 'wide')) b.eyes = 'closed'
720  if (creature) {
721    body(c, role, pal, b)
722    mid()
723    arm(c, role, pal, b, -1, b.left)
724    arm(c, role, pal, b, 1, b.right)
725    front()
726  }
727  c.outline(OUT)
728  c.over(fx)
729  // A soft shadow that shrinks as the creature leaves the ground.
730  const out = new Canvas(BIG_W, BIG_H, BIG_S)
731  const lift = Math.max(0, -b.y)
732  out.oval(creature ? frameOf(b).cx : 16, 31.2, 11.5 - lift * 1.4, 1.6, SHADOW)
733  out.over(c)
734  return out
735}
736
737// ---------- the half-block fallback: 15x8, a small hand-made creature and the props as icons ----------
738
739const SMALL_BODY: Record<Role, string[]> = {
740  scout: [
741    '.h....h.',
742    '.hBBBBh.',
743    'BFFBBFFB',
744    'BFFBBFFB',
745    'BBByyBBB',
746    'WBccccBW',
747    'WBccccBW',
748    '..o..o..',
749  ],
750  coder: [
751    'H......H',
752    'wHBBBBHw',
753    'wBBBBBBw',
754    '.BBBBBB.',
755    '.BBBBBB.',
756    '.BccccBt',
757    '.BccccBt',
758    '..d..d..',
759  ],
760}
761
762const ICON = {
763  ask: ['.##.', '#..#', '..#.', '.#..', '....', '.#..'],
764  bang: ['#', '#', '#', '.', '#'],
765  check: ['....#', '...#.', '#.#..', '.#...'],
766  cross: ['#...#', '.#.#.', '..#..', '.#.#.', '#...#'],
767  bulb: ['.###.', '#####', '#####', '.###.', '.###.'],
768  globe: ['.###.', '#####', '#####', '#####', '.###.'],
769  bubble: ['.#####.', '#######', '.#####.'],
770  stop: ['.###.', '#####', '#...#', '#####', '.###.'],
771  warn: ['..#..', '.###.', '.#.#.', '#####'],
772  z: ['###', '..#', '.#.', '###'],
773}
774const LAND = ['01100011', '11110011', '01110001', '00110110', '00011100']
775
776export function drawSmall({ role, opus, state, frame: big, blink }: Look): Canvas {
777  // The icons keep their own short cycles at half the big frames' rate.
778  const f = state === 'boot' ? (big < 7 ? Math.floor((big * 5) / 7) : big < 10 ? 5 : 7)
779    : state === 'done' ? (big < 16 ? big >> 1 : 8)
780    : big >> 1
781  const p = new Canvas(SMALL_W, SMALL_H)
782  const pal = role === 'scout' ? OWL : opus ? DRAGON_OPUS : DRAGON
783  const theme = role === 'scout' ? 0x4fd1c5 : opus ? 0xb59cff : 0xffb347
784  const colors: Record<string, number> = {
785    h: pal.horn, H: pal.horn, B: pal.body, F: pal.face, y: pal.beak, c: pal.belly, o: pal.beak,
786    w: pal.wing, t: pal.body, d: pal.shade,
787  }
788  const celebrating = state === 'done' && f < 8
789  const ox = state === 'blocked' && f < 2 ? f : 0
790  const oy = celebrating && f % 4 >= 1 && f % 4 <= 3 ? -1 : 0
791
792  if (state === 'boot' && f < 5) {
793    // the egg, cracking
794    p.stamp(['..##..', '.####.', '######', '######', '######', '.####.'], { '#': 0xfff6e6 }, 1 + (f === 1 ? -1 : f === 3 ? 1 : 0), 1)
795    p.put(2, 3, pal.body); p.put(5, 5, pal.body)
796    if (f >= 2) for (let x = 1; x < 1 + (f - 1) * 2 && x < 7; x++) p.put(x, 3 + (x % 2), OUT)
797    if (f >= 2) p.put(9 + (f * 3) % 5, (f * 5) % 7, 0xffffff)
798    return p
799  }
800
801  p.stamp(SMALL_BODY[role], colors, ox, oy)
802  if (opus) { p.put(ox + 3, oy, 0xffd447); p.put(ox + 4, oy, 0xffd447) }
803  if (state === 'sleep') { p.put(ox + 2, oy, 0x6c7bff); p.put(ox + 3, oy, 0x6c7bff); p.put(ox + 4, oy, 0x6c7bff); p.put(ox + 5, oy, 0xf4f1ff) }
804  if (state === 'boot') { p.put(ox + 2, oy, 0xfff6e6); p.put(ox + 3, oy, 0xfff6e6); p.put(ox + 4, oy, 0xfff6e6); p.put(ox + 5, oy, 0xfff6e6) }
805
806  // eyes: one dark pixel each, a lid line when shut
807  const eyeRow = oy + 3
808  const shut = blink || state === 'sleep' || state === 'blocked' || (state === 'done') || (state === 'boot' && f === 7)
809  const up = state === 'think' || state === 'ask' || state === 'web' || state === 'resumed'
810  const dx = state === 'read' || state === 'write' || state === 'bash' || state === 'test' ? 1 : 0
811  for (const x of [2, 5]) {
812    if (state === 'error') p.put(ox + x, eyeRow, f % 2 ? 0xff8a65 : EYE)
813    else if (shut) { p.put(ox + x - 1, eyeRow, EYE); p.put(ox + x, eyeRow, EYE) }
814    else p.put(ox + x + (x === 2 ? dx : 0) - (x === 5 && dx ? 0 : 0), eyeRow - (up ? 1 : 0), EYE)
815    if (state === 'failed') p.put(ox + x, eyeRow + 1 + (f % 2), TEAR)
816  }
817  if (state !== 'failed' && role === 'coder') { p.put(ox + 1, oy + 4, BLUSH); p.put(ox + 6, oy + 4, BLUSH) }
818  if (role === 'coder' && (state === 'done' || state === 'ask' || state === 'resumed')) { p.put(ox + 3, oy + 4, EYE); p.put(ox + 4, oy + 4, EYE) }
819
820  const limb = role === 'scout' ? pal.wing : pal.shade
821  const armUp = (x: number) => { p.put(ox + x, oy + 2, limb); p.put(ox + x, oy + 3, limb) }
822  const armDown = (x: number) => { p.put(ox + x, oy + 5, limb); p.put(ox + x, oy + 6, limb) }
823  armDown(0)
824
825  switch (state) {
826    case 'write':
827    case 'bash': {
828      p.put(7, 5 - (f % 2), limb)
829      if (state === 'bash') {
830        for (let y = 0; y < 6; y++) for (let x = 9; x < 15; x++) p.put(x, y, y === 0 ? 0x4c566a : 0x0d1117)
831        p.put(10, 2, 0x81c784); p.put(11, 3, 0x81c784); p.put(10, 4, 0x81c784)
832        if (f % 2 === 0) p.put(13, 4, 0xe6edf3)
833        for (let x = 9; x < 14; x++) p.put(x, 7, 0x9aa5b1)
834      } else {
835        for (let y = 1; y < 6; y++) for (let x = 9; x < 15; x++) p.put(x, y, y === 1 || x === 9 || x === 14 ? 0x3b4252 : 0x1e2230)
836        for (let i = 0; i < 3; i++) p.put(10 + i, 2 + i, [0xff7aa2, 0x7ee0a1, 0x7cc7ff][(i + f) % 3] ?? theme)
837        for (let x = 8; x < 15; x++) p.put(x, 6, 0xc5ccd8)
838        p.put(10 + (f % 4), 0, theme)
839      }
840      break
841    }
842    case 'read':
843      p.put(7, 5, limb)
844      for (let y = 3; y <= 7; y++) for (let x = 8; x <= 14; x++) p.put(x, y, x === 11 ? 0xd9cbb2 : 0xfffaf0)
845      for (const y of [4, 6]) {
846        const hot = Math.floor(f / 4) % 2
847        p.put(9, y, hot && y === 4 ? 0x4d9bf0 : 0xa7adbf); p.put(12, y, 0xa7adbf); p.put(13, y, y === 6 && !hot ? 0x4d9bf0 : 0xa7adbf)
848      }
849      break
850    case 'test':
851      p.put(7, 5, limb)
852      for (let y = 1; y < 8; y++) for (let x = 9; x < 15; x++) p.put(x, y, x === 9 || x === 14 || y === 1 || y === 7 ? 0xc68a4f : 0xfffaf0)
853      p.put(11, 0, 0x9aa5b1); p.put(12, 0, 0x9aa5b1)
854      for (let i = 0; i < 3; i++) {
855        const y = 2 + i * 2
856        p.put(10, y, f % 4 > i ? 0x43bd62 : f % 4 === i ? 0xffd166 : 0xa7adbf)
857        p.put(11, y, 0xa7adbf); p.put(12, y, 0xa7adbf)
858      }
859      break
860    case 'web':
861      armUp(7)
862      ICON.globe.forEach((row, y) => {
863        for (let x = 0; x < row.length; x++) if (row[x] === '#') {
864          const land = LAND[y]?.[(x + f) % 8] === '1'
865          p.put(9 + x, 1 + y, land ? 0x52d17c : 0x3a86ff)
866        }
867      })
868      p.put(f % 4 < 2 ? 14 : 13, 0, 0x4dd0e1)
869      break
870    case 'ask':
871      if (f % 4 < 2) armUp(7); else { p.put(7, 1, limb); p.put(7, 2, limb) }
872      p.stamp(ICON.ask, { '#': 0xffd166 }, 10, f % 4 < 2 ? 0 : 1)
873      break
874    case 'resumed':
875      armUp(7); armUp(0)
876      p.stamp(ICON.bulb.slice(0, 3), { '#': 0xffe066 }, 10, 0)
877      p.stamp(ICON.bulb.slice(3), { '#': 0x9aa5b1 }, 10, 3)
878      if (f % 2) { p.put(9, 0, 0xfff3b0); p.put(14, 1, 0xfff3b0); p.put(9, 2, 0xfff3b0) }
879      break
880    case 'blocked':
881      armDown(7)
882      p.stamp(ICON.stop, { '#': f < 2 || f % 4 < 2 ? 0xff4d5e : 0xd93a4b }, 10, 1)
883      p.put(12, 3, 0xffffff); p.put(11, 3, 0xffffff); p.put(13, 3, 0xffffff)
884      p.put(ox + 7, 1 + (f % 3), TEAR)
885      break
886    case 'error':
887      armDown(7)
888      p.stamp(ICON.warn, { '#': 0xff9f43 }, 10, 2)
889      p.put(12, 3, OUT)
890      p.put(ox + 1 + (f % 6), 0, 0xffe066)
891      break
892    case 'done':
893      if (celebrating) {
894        armUp(0); armUp(7)
895        for (let i = 0; i < 10; i++) {
896          const x = Math.floor(rnd(i, 7) * 15), y = Math.floor((rnd(i, 3) * 8 + f) % 8)
897          if (p.at(x, y) === CLEAR) p.put(x, y, CONFETTI[i % CONFETTI.length] ?? 0xffffff)
898        }
899      } else {
900        armDown(7)
901        p.stamp(ICON.check, { '#': 0x7bd88f }, 10, 2)
902        if (f % 16 < 2) p.put(14, 0, 0xffffff)
903      }
904      break
905    case 'failed':
906      armDown(7)
907      p.stamp(ICON.cross, { '#': 0xff5c6c }, 10, 1)
908      break
909    case 'sleep': {
910      armDown(7)
911      p.stamp(ICON.z, { '#': 0xb39ddb }, 9, 4)
912      if (f % 8 >= 3) p.stamp(ICON.z, { '#': 0x8f7fbf }, 12, 0)
913      break
914    }
915    case 'boot':
916      armUp(0); armUp(7)
917      p.put(10, 6, 0xfff6e6); p.put(11, 7, 0xfff6e6); p.put(12, 7, 0xfff6e6)
918      p.put(9 + (f % 3) * 2, 1, 0xffffff)
919      break
920    default: {
921      // think: paw at the chin, a thought bubble filling dot by dot
922      p.put(6, 4, limb)
923      p.put(8, 3, 0xe6e6f0)
924      p.stamp(ICON.bubble, { '#': 0xf4f1ff }, 8, 0)
925      const lit = Math.floor(f / 4)
926      for (let i = 0; i < 3; i++) p.put(9 + i * 2, 1, i < lit ? 0x6a5acd : 0xc8c8d8)
927    }
928  }
929  return p
930}
931
932// Frames are pure in their Look, so each is drawn once; a long session sees a few hundred.
933const BIG = new Map<string, Canvas>()
934const SMALL = new Map<string, Canvas>()
935const cached = <T>(m: Map<string, T>, key: string, make: () => T): T => {
936  const hit = m.get(key)
937  if (hit) return hit
938  if (m.size > 800) m.clear()
939  const v = make()
940  m.set(key, v)
941  return v
942}
943export const bigSprite = (l: Look) => cached(BIG, lookKey(l), () => drawLook(l))
944export const smallSprite = (l: Look) => cached(SMALL, lookKey(l), () => drawSmall(l))
945
946// Every distinct frame is a small PNG under frames/ (made by tools/gen_frames.sh); the terminal reads the
947// file itself and a source equal to the last one sends nothing, so a blit carries only a path.
948export const frameFile = (l: Look) => `${l.role}${l.opus ? '_opus' : ''}-${l.state}-${l.frame}${l.blink ? 'b' : ''}.png`
949export const imageSource = (l: Look, root: string) => ({ file: `${root}/frames/${frameFile(l)}`, format: 'png' as const })
950
951export const ALL_POSES: Pose[] = [
952  'boot', 'resumed', 'think', 'read', 'write', 'bash', 'web', 'test', 'blocked', 'error', 'ask', 'done', 'failed', 'sleep',
953]
954
hooks/scene.ts 608 lines
1// Pure drawing: cw run files -> worker states -> one Raster of text and half-block pixel art,
2// or of text alone with each sprite listed for an Image laid over it.
3
4import { CLEAR, FRAME_MS, SMALL_H, SMALL_W, lookAt, smallSprite, type Canvas, type Look } from './creatures'
5
6export type Role = 'scout' | 'coder'
7export type Kind = 'read' | 'write' | 'bash' | 'web'
8export type State =
9  | 'boot' | 'resumed' | 'think' | Kind | 'test' | 'blocked' | 'error' | 'ask' | 'done' | 'failed'
10
11export type Ev = {
12  e: string; t: number; tool?: string; arg?: string
13  ok?: boolean; sub?: string; sid?: string; turns?: number; denied?: number; out?: number
14}
15export type Meta = {
16  role: Role; model: string; session: string; resume: string; task: string
17  start: number; tmo: number; web: boolean; pid: number; effort?: string; effort_src?: string
18  name?: string   // fleet name from cwgo (CW_NAME): shown instead of a generated one
19}
20export type Run = { id: string; meta: Meta; events: Ev[]; rc?: number; doneAt?: number; question?: string }
21
22export type Worker = {
23  key: string; role: Role; model: string; name: string; task: string; effort: string
24  start: number; runStart: number; resumed: boolean
25  state: State; stateAt: number; arg: string; trail: Kind[]; times: number[]; lastAt: number; tmo: number
26  blocked: number; question?: string; end?: number; turns: number; out: number
27  runIds: string[]   // the chain's runs, oldest first: the detail view reads their files
28}
29
30const NAMES: Record<Role, string[]> = {
31  scout: ['KIRI', 'SORA', 'MOCHI', 'YUZU', 'HOSHI', 'NORI', 'TOFU', 'MISO', 'KOJI', 'UME'],
32  coder: ['BOLT', 'RIVET', 'PIXEL', 'FORGE', 'GIZMO', 'BYTE', 'FLUX', 'SPARK', 'COG', 'WELD'],
33}
34
35const READ_CMD = /^\s*(rg|fd|eza|ls|wc|head|tail|cat|bat|grep|find|sed -n|git (log|show|diff|status|blame))\b/
36
37export function kindOf(tool: string, arg: string): Kind {
38  if (tool === 'Read' || tool === 'Grep' || tool === 'Glob') return 'read'
39  if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit' || tool === 'NotebookEdit') return 'write'
40  if (tool === 'WebFetch' || tool === 'WebSearch') return 'web'
41  return READ_CMD.test(arg) ? 'read' : 'bash'
42}
43
44export function parseEvents(text: string): Ev[] {
45  const out: Ev[] = []
46  for (const line of text.split('\n')) {
47    if (!line.trim()) continue
48    try { out.push(JSON.parse(line)) } catch { /* a half-written last line; read again next poll */ }
49  }
50  return out
51}
52
53type RunView = Pick<Worker, 'state' | 'stateAt' | 'arg' | 'blocked' | 'end' | 'turns' | 'out' | 'lastAt'> & { trail: Kind[]; times: number[] }
54
55const ACCEPT_MS = 1_830_000   // cw's CW_ACCEPT_TMO default (1800 s) plus slack
56
57export function runState(run: Run, now: number): RunView {
58  const start = run.meta.start * 1000
59  const trail: Kind[] = [], times: number[] = []
60  let arg = '', lastTool: Ev | undefined, lastDenied: Ev | undefined, lastError: Ev | undefined
61  let result: Ev | undefined, accept: Ev | undefined, accepted: Ev | undefined, blocked = 0
62  for (const ev of run.events) {
63    if (ev.e === 'tool') {
64      lastTool = ev
65      arg = ev.arg ?? ''
66      trail.push(kindOf(ev.tool ?? '', arg))
67      times.push(ev.t)
68    } else if (ev.e === 'denied') { lastDenied = ev; blocked++ }
69    else if (ev.e === 'error') lastError = ev
70    else if (ev.e === 'result') result = ev
71    else if (ev.e === 'accept') accept = ev
72    else if (ev.e === 'accept_end') accepted = ev
73  }
74  const lastAt = run.events[run.events.length - 1]?.t ?? start
75  const base = { arg, trail, times, blocked, lastAt, turns: result?.turns ?? 0, out: result?.out ?? 0 }
76  // cw auto-continues a turn-limited run without writing .done: that result is not the end
77  const continuing = result?.sub === 'error_max_turns' && run.rc === undefined
78  // cw runs the acceptance command after the result and writes .done only when it ends
79  if (accept && !accepted && run.rc === undefined && now - accept.t < ACCEPT_MS) {
80    return { ...base, state: 'test', stateAt: accept.t }
81  }
82  if ((result && !continuing) || run.rc !== undefined) {
83    const end = accepted?.t ?? result?.t ?? run.doneAt ?? now
84    const ok = !!result?.ok && (run.rc ?? 0) === 0 && accepted?.ok !== false
85    const state: State = run.question && ok ? 'ask' : ok ? 'done' : 'failed'
86    return { ...base, state, stateAt: end, end }
87  }
88  if (now > start + (run.meta.tmo + 30) * 1000) return { ...base, state: 'failed', stateAt: now, end: now }
89  const last = run.events[run.events.length - 1]
90  if (run.meta.resume && now - start < 1600) return { ...base, state: 'resumed', stateAt: start }
91  if (!last || now - start < 1500) return { ...base, state: 'boot', stateAt: start }
92  if (lastDenied && now - lastDenied.t < 4000) return { ...base, state: 'blocked', stateAt: lastDenied.t }
93  if (lastError && now - lastError.t < 2500) return { ...base, state: 'error', stateAt: lastError.t }
94  if (lastTool && (last === lastTool || now - lastTool.t < 1500)) {
95    return { ...base, state: trail[trail.length - 1] ?? 'bash', stateAt: lastTool.t }
96  }
97  return { ...base, state: 'think', stateAt: last.t }
98}
99
100// One worker per chain of runs: a run started with `-r <sid>` continues the worker whose run ended with that sid.
101export function buildWorkers(runs: Run[], now: number): Worker[] {
102  const sorted = [...runs].sort((a, b) => a.meta.start - b.meta.start || a.id.localeCompare(b.id))
103  const bySid = new Map<string, Worker>()
104  const workers: Worker[] = []
105  const used: Record<Role, number> = { scout: 0, coder: 0 }
106  for (const run of sorted) {
107    const view = runState(run, now)
108    const prev = run.meta.resume ? bySid.get(run.meta.resume) : undefined
109    const role = run.meta.role === 'coder' ? 'coder' : 'scout'
110    // A resume started under its own fleet name (cwgo NAME -r sid) is a new job on the same creature:
111    // show that name, task and clock, as cwls does.
112    const renamed = !!prev && !!run.meta.name && run.meta.name !== prev.name
113    const w: Worker = {
114      key: prev?.key ?? run.id,
115      role,
116      model: run.meta.model,
117      effort: run.meta.effort ?? '',
118      name: renamed ? run.meta.name! : prev?.name ?? (run.meta.name || NAMES[role][used[role]++ % NAMES[role].length] + (used[role] > 10 ? String(Math.ceil(used[role] / 10)) : '')),
119      task: renamed ? run.meta.task : prev?.task ?? run.meta.task,
120      start: renamed ? run.meta.start * 1000 : prev?.start ?? run.meta.start * 1000,
121      runStart: run.meta.start * 1000,
122      tmo: run.meta.tmo,
123      resumed: !!prev,
124      ...view,
125      trail: [...(prev?.trail ?? []), ...view.trail],
126      times: [...(prev?.times ?? []), ...view.times],
127      blocked: (prev?.blocked ?? 0) + view.blocked,
128      turns: (prev?.turns ?? 0) + view.turns,
129      out: (prev?.out ?? 0) + view.out,
130      question: view.state === 'ask' ? run.question : undefined,
131      runIds: [...(prev?.runIds ?? []), run.id],
132    }
133    const at = prev ? workers.indexOf(prev) : -1
134    if (at >= 0) workers[at] = w
135    else workers.push(w)
136    // A run that dies before its result (killed, timeout) never maps its sid, so the next resume
137    // of the same sid still finds `prev`; point it at the newest run or two workers share a key.
138    if (prev) for (const [k, v] of bySid) if (v === prev) bySid.set(k, w)
139    // init carries the sid too, so a run that timed out before its result still chains its resume.
140    const sid = [...run.events].reverse().find(ev => ev.sid)?.sid
141    if (sid) bySid.set(sid, w)
142  }
143  return workers
144}
145
146export const KEEP_MS = 120_000
147export const isVisible = (w: Worker, now: number) =>
148  w.end === undefined || w.state === 'ask' || now - w.end < KEEP_MS
149export const isActive = (w: Worker) => w.end === undefined
150
151// ---------- pixels ----------
152
153const DEF = 0x01000000
154// A sprite fills MW x MH cells: one 60x32 Image where the terminal shows pictures, else
155// 15x8 half-block pixels (one pixel = one half-block, no scaling). Creature left, props right.
156const MW = SMALL_W, MH = SMALL_H / 2
157
158export const THEME: Record<Role, number> = { scout: 0x4fd1c5, coder: 0xffb347 }
159const OPUS_THEME = 0xb59cff
160
161const hash = (s: string) => {
162  let h = 7
163  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0
164  return h % 997
165}
166const dim = (c: number, k: number) =>
167  (Math.round(((c >> 16) & 255) * k) << 16) | (Math.round(((c >> 8) & 255) * k) << 8) | Math.round((c & 255) * k)
168const mix = (a: number, b: number, k: number) => {
169  const ch = (s: number) => Math.round(((a >> s) & 255) * (1 - k) + ((b >> s) & 255) * k)
170  return (ch(16) << 16) | (ch(8) << 8) | ch(0)
171}
172// 0..1..0 once every `ms`: colours breathe instead of blinking.
173const pulse = (now: number, ms: number) => 0.5 - 0.5 * Math.cos((2 * Math.PI * (now % ms)) / ms)
174
175// Where a sprite goes in the scene: `key` names its Image, `look` the frame it shows.
176export type Spot = { key: string; x: number; y: number; look: Look; alt: string }
177export const SPRITE_COLS = MW, SPRITE_ROWS = MH
178
179// ---------- cells ----------
180
181export class Grid {
182  glyph: Uint32Array; fg: Uint32Array; bg: Uint32Array
183  constructor(public w: number, public h: number) {
184    this.glyph = new Uint32Array(w * h).fill(0x20)
185    this.fg = new Uint32Array(w * h).fill(DEF)
186    this.bg = new Uint32Array(w * h).fill(DEF)
187  }
188  // No `bg`: the cell keeps the one it has, so text and pixels sit on a filled panel.
189  put(x: number, y: number, ch: string, fg: number, bg?: number) {
190    if (x < 0 || y < 0 || x >= this.w || y >= this.h) return
191    const i = y * this.w + x
192    this.glyph[i] = ch.codePointAt(0) ?? 0x20
193    this.fg[i] = fg
194    if (bg !== undefined) this.bg[i] = bg
195  }
196  fill(x: number, y: number, w: number, h: number, bg: number) {
197    for (let j = y; j < y + h; j++) for (let i = x; i < x + w; i++) this.put(i, j, ' ', DEF, bg)
198  }
199  text(x: number, y: number, s: string, fg: number, max = this.w - x, bg?: number) {
200    const chars = [...s]
201    for (let i = 0; i < Math.min(chars.length, max); i++) this.put(x + i, y, chars[i] ?? ' ', fg, bg)
202    return Math.max(0, Math.min(chars.length, max))
203  }
204  // A rounded chip: half-circle caps around the text, the text on the chip's colour.
205  pill(x: number, y: number, s: string, fg: number, bg: number, max = this.w - x) {
206    if (max < 3) return 0
207    this.put(x, y, '▐', bg)
208    const n = this.text(x + 1, y, s, fg, max - 2, bg)
209    this.put(x + 1 + n, y, '▌', bg)
210    return n + 2
211  }
212  pixels(p: Canvas, x0: number, y0: number) {
213    for (let r = 0; r < p.h / 2; r++) for (let x = 0; x < p.w; x++) {
214      const top = p.px[r * 2 * p.w + x] ?? CLEAR, bot = p.px[(r * 2 + 1) * p.w + x] ?? CLEAR
215      if (top === CLEAR && bot === CLEAR) continue
216      if (top === CLEAR) this.put(x0 + x, y0 + r, '▄', bot)
217      else if (bot === CLEAR) this.put(x0 + x, y0 + r, '▀', top)
218      else if (top === bot) this.put(x0 + x, y0 + r, '█', top)
219      else this.put(x0 + x, y0 + r, '▀', top, bot)
220    }
221  }
222  words(): Uint32Array {
223    const out = new Uint32Array(this.w * this.h * 3)
224    for (let i = 0; i < this.w * this.h; i++) {
225      out[i * 3] = this.glyph[i] ?? 0x20; out[i * 3 + 1] = this.fg[i] ?? DEF; out[i * 3 + 2] = this.bg[i] ?? DEF
226    }
227    return out
228  }
229}
230
231export const encode = (words: Uint32Array): string =>
232  (new Uint8Array(words.buffer) as Uint8Array & { toBase64(): string }).toBase64()
233
234export const LABEL: Record<State, string> = {
235  boot: 'waking up', resumed: 'answered', think: 'thinking', read: 'reading', write: 'writing',
236  bash: 'command', web: 'on the web', test: 'testing', blocked: 'blocked', error: 'error', ask: 'asking',
237  done: 'done', failed: 'failed',
238}
239export const STATE_COLOR: Record<State, number> = {
240  boot: 0xffffff, resumed: 0xffe082, think: 0xb39ddb, read: 0x64b5f6, write: 0xffb347, bash: 0x81c784,
241  web: 0x4dd0e1, test: 0x9be7ff, blocked: 0xff6b6b, error: 0xff8a65, ask: 0xffd166, done: 0x7bd88f, failed: 0xff5c5c,
242}
243const KIND_COLOR: Record<Kind, number> = { read: 0x64b5f6, write: 0xffb347, bash: 0x81c784, web: 0x4dd0e1 }
244const EFFORT: Record<string, [number, number]> = { low: [1, 0x81c784], medium: [2, 0xffd166], high: [3, 0xff8a65] }
245const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
246const TEXT = 0xe6e6f0, DIM = 0x8a8fa3, FAINT = 0x4a4e5c
247// Everything sits on the terminal's own background (an Image's cells always show it); this is only
248// what chips and fades mix toward, a neutral dark grey close to most dark themes.
249const BASE = 0x262626
250const EDGE = 0x3c3f4c
251const BUCKET_MS = 4000
252const QUIET_MS = 45_000
253
254export const CARD_H = 6
255const HEADER_H = 2
256const EMPTY_H = 8
257
258export const perRow = (cols: number) => Math.max(1, Math.min(3, Math.floor(cols / 46)))
259
260// The engine scrolls a pane body taller than its window; past 256 rows the scene folds:
261// as many full cards as fit, the rest as one-line rows, then a "+N" row.
262export type Layout = { rows: number; cards: number; lines: number; more: number }
263export function layout(n: number, cols: number, maxRows = 256): Layout {
264  const cap = Math.max(1, Math.min(256, maxRows))
265  const per = perRow(cols)
266  const full = HEADER_H + (n === 0 ? EMPTY_H : Math.ceil(n / per) * CARD_H)
267  if (full <= cap || n === 0) return { rows: Math.min(full, cap), cards: n, lines: 0, more: 0 }
268  for (let k = Math.ceil(n / per) - 1; k >= 0; k--) {
269    const cards = Math.min(n, k * per)
270    const lines = Math.ceil((n - cards) / per)
271    const rows = HEADER_H + k * CARD_H + lines
272    if (rows <= cap) return { rows, cards, lines, more: 0 }
273  }
274  const lines = Math.max(0, cap - HEADER_H - 1)
275  return { rows: cap, cards: 0, lines, more: n - lines * per }
276}
277export const sceneRows = (n: number, cols: number, maxRows?: number) => layout(n, cols, maxRows).rows
278
279const RANK = (w: Worker) => (w.state === 'ask' ? 0 : !w.end ? 1 : w.state === 'failed' ? 2 : 3)
280
281export const clock = (ms: number) => {
282  const s = Math.max(0, Math.floor(ms / 1000))
283  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
284}
285const kilo = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` : String(n))
286const shorten = (s: string, n: number) => {
287  const one = s.replace(/\s+/g, ' ').trim()
288  return [...one].length <= n ? one : '…' + [...one].slice(-(n - 1)).join('')
289}
290function wrap(s: string, width: number, lines: number): string[] {
291  const out: string[] = []
292  let cur = ''
293  for (const word of s.replace(/^[#>*\s\d.]*(goal:)?\s*/i, '').split(/\s+/)) {
294    if (!word) continue
295    const next = cur ? `${cur} ${word}` : word
296    if ([...next].length <= width) { cur = next; continue }
297    if (cur) out.push(cur)
298    cur = [...word].slice(0, width).join('')
299    if (out.length === lines) break
300  }
301  if (cur && out.length < lines) out.push(cur)
302  if (out.length === lines && [...out.join(' ')].length < [...s].length - 2) {
303    const last = [...(out[lines - 1] ?? '')]
304    out[lines - 1] = last.slice(0, Math.max(0, width - 1)).join('') + '…'
305  }
306  return out.slice(0, lines)
307}
308
309// MSYS path (/c/Users/x) -> C:/Users/x so $.fs can read it; other paths pass through.
310export const msysToWin = (p: string) => p.replace(/^\/([a-zA-Z])(?=\/|$)/, (_, d: string) => `${d.toUpperCase()}:`)
311
312// A path reads by its file name: the folders dim, the name bright. A command shows whole.
313function target(g: Grid, x: number, y: number, arg: string, max: number, bright: number, faint: number) {
314  if (max < 2 || !arg) return 0
315  const s = shorten(arg, max)
316  const cut = /\s/.test(arg.trim()) ? 0 : Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\')) + 1
317  const n = g.text(x, y, s.slice(0, cut), faint, max)
318  return n + g.text(x + n, y, s.slice(cut), bright, max - n)
319}
320
321const BARS = '▂▃▅▆▇'
322// Tools per BUCKET_MS bucket, newest on the right, each bar in the colour of its last tool's kind:
323// the worker's rhythm at a glance, and a flat line when it has gone quiet.
324function activity(g: Grid, w: Worker, x: number, y: number, n: number, now: number, fade: number) {
325  if (n <= 0) return
326  const anchor = Math.ceil((w.end ?? now) / BUCKET_MS) * BUCKET_MS
327  const from = anchor - n * BUCKET_MS
328  const count = new Array<number>(n).fill(0), kind = new Array<Kind | undefined>(n)
329  w.times.forEach((t, i) => {
330    const b = Math.floor((t - from) / BUCKET_MS)
331    if (b < 0 || b >= n) return
332    count[b] = (count[b] ?? 0) + 1
333    kind[b] = w.trail[i]
334  })
335  for (let i = 0; i < n; i++) {
336    const t0 = from + i * BUCKET_MS
337    if (t0 + BUCKET_MS < w.start) continue
338    const k = count[i] ?? 0, kd = kind[i]
339    if (!k || !kd) g.put(x + i, y, '▁', mix(FAINT, BASE, 0.35))
340    else g.put(x + i, y, BARS[Math.min(BARS.length - 1, k - 1)] ?? '▇', mix(KIND_COLOR[kd], BASE, fade))
341  }
342}
343
344type Place = (look: Look, x: number, y: number, alt: string) => void
345
346function card(g: Grid, w: Worker, x0: number, y0: number, W: number, now: number, place: Place) {
347  const f = Math.floor(now / FRAME_MS)
348  const opus = /opus/i.test(w.model)
349  const theme = opus ? OPUS_THEME : THEME[w.role]
350  const sc = STATE_COLOR[w.state]
351  const over = w.end !== undefined && w.state !== 'ask'
352  // A finished card steps back: its words sink toward the panel.
353  const fade = over ? 0.4 : 0
354  const ink = (c: number) => mix(c, BASE, fade)
355  const accent =
356    w.state === 'ask' ? mix(dim(sc, 0.5), sc, pulse(now, 1600))
357    : w.state === 'blocked' || w.state === 'error' ? mix(dim(sc, 0.45), sc, pulse(now, 700))
358    : over ? dim(sc, 0.7)
359    : mix(dim(theme, 0.75), theme, pulse(now + hash(w.key) * 37, 2400))
360
361  // A rounded frame on the terminal's background; its left side in the state's colour.
362  const R = x0 + W - 1, B = y0 + CARD_H - 1
363  const edge = over ? mix(EDGE, BASE, 0.4) : EDGE
364  for (let x = x0 + 1; x < R; x++) g.put(x, y0, '─', edge)
365  for (let y = y0 + 1; y < B; y++) { g.put(x0, y, '┃', accent); g.put(R, y, '│', edge) }
366  g.put(x0, y0, '┎', accent); g.put(R, y0, '╮', edge)
367  g.put(x0, B, '┖', accent); g.put(R, B, '╯', edge)
368
369  // The bottom edge is a bar: the run's time budget while it works (amber, then red, as it runs
370  // out), the countdown until a finished card leaves, full and breathing while a question waits.
371  const inner = W - 2
372  let frac = 0, bar = theme
373  if (w.state === 'ask') { frac = 1; bar = mix(dim(sc, 0.55), sc, pulse(now, 1600)) }
374  else if (over) { frac = 1 - (now - (w.end ?? now)) / KEEP_MS; bar = dim(sc, 0.6) }
375  else {
376    frac = (now - w.runStart) / Math.max(1, w.tmo * 1000)
377    bar = frac > 0.9 ? 0xff5c5c : frac > 0.75 ? 0xffb347 : dim(theme, 0.8)
378  }
379  const fill = Math.round(Math.max(0, Math.min(1, frac)) * inner)
380  for (let i = 0; i < inner; i++) g.put(x0 + 1 + i, B, i < fill ? '━' : '─', i < fill ? bar : edge)
381
382  place(lookAt(w.role, w.model, w.state, now, w.stateAt, hash(w.key)), x0 + 2, y0 + 1, `${w.name} ${LABEL[w.state]}`)
383
384  const X = x0 + MW + 3, TW = R - X - 1
385  if (TW < 8) return
386  const row = (i: number) => y0 + 1 + i
387
388  // 1: who — name, model chip, effort pips; the clock on the right
389  const elapsed = clock((w.end ?? now) - w.start)
390  const clockX = R - 1 - elapsed.length
391  g.text(clockX, row(0), elapsed, over ? ink(DIM) : TEXT)
392  let x = X + g.text(X, row(0), w.name, ink(theme), clockX - X - 1)
393  const room = () => clockX - x - 2
394  if (room() > 4) x += 1 + g.pill(x + 1, row(0), w.model.toUpperCase(), ink(theme), mix(BASE, theme, over ? 0.12 : 0.22), room())
395  const eff = EFFORT[w.effort]
396  if (eff && room() > 4) {
397    x += 1
398    for (let i = 0; i < 3; i++) g.put(x + i, row(0), i < eff[0] ? '●' : '○', i < eff[0] ? ink(eff[1]) : mix(FAINT, BASE, 0.3))
399    x += 3
400  }
401  if (w.resumed && room() > 2) g.put(x + 1, row(0), '↻', ink(0xffe082))
402
403  // 2: what it is doing now, and on what
404  const icon =
405    w.state === 'done' ? '✓' : w.state === 'failed' ? '✗' : w.state === 'ask' ? '?'
406    : w.state === 'blocked' || w.state === 'error' ? '!' : SPIN[f % SPIN.length] ?? ' '
407  g.put(X, row(1), icon, ink(sc))
408  let lx = X + 2 + g.text(X + 2, row(1), LABEL[w.state], ink(sc), TW - 2)
409  const quiet = !w.end ? now - w.lastAt : 0
410  if (quiet > QUIET_MS && w.state === 'think') {
411    lx += g.text(lx, row(1), ` · ${clock(quiet)} sessiz`, mix(0xffb347, BASE, 0.25 * pulse(now, 2000)), X + TW - lx)
412  } else if (w.arg && w.state !== 'ask' && X + TW - lx > 6) {
413    lx += 1 + g.text(lx + 1, row(1), '·', FAINT)
414    target(g, lx + 1, row(1), w.arg, X + TW - lx - 1, ink(0xc9d6e6), ink(0x6d7891))
415  }
416
417  // 3–4: the question while one waits; else the task and the activity strip
418  if (w.state === 'ask' && w.question) {
419    wrap(w.question, TW, 2).forEach((line, i) => g.text(X, row(2 + i), line, 0xffe8a3))
420    return
421  }
422  wrap(w.task, TW, 1).forEach(line => g.text(X, row(2), line, ink(DIM)))
423  const stats = over ? `${w.turns} turns · ${kilo(w.out)} tok`
424    : `${w.trail.length} tools${w.blocked ? ` · ${w.blocked} blocked` : ''}`
425  const sw = [...stats].length
426  const strip = TW - sw - 2
427  if (strip >= 6) activity(g, w, X, row(3), strip, now, fade)
428  g.text(X + TW - sw, row(3), stats, w.blocked && !w.end ? 0xff9e9e : ink(DIM))
429}
430
431function line(g: Grid, w: Worker, x0: number, y: number, W: number, now: number) {
432  const f = Math.floor(now / FRAME_MS)
433  const sc = STATE_COLOR[w.state]
434  const icon =
435    w.state === 'done' ? '✓' : w.state === 'failed' ? '✗' : w.state === 'ask' ? '?'
436    : w.state === 'blocked' || w.state === 'error' ? '!' : SPIN[f % SPIN.length] ?? ' '
437  const R = x0 + W - 1
438  const elapsed = clock((w.end ?? now) - w.start)
439  g.text(R - elapsed.length, y, elapsed, w.end ? DIM : TEXT)
440  const end = R - elapsed.length - 1
441  let x = x0 + 1
442  g.put(x, y, icon, sc); x += 2
443  x += g.text(x, y, w.name, /opus/i.test(w.model) ? OPUS_THEME : THEME[w.role], end - x)
444  x += 1 + g.text(x + 1, y, LABEL[w.state], sc, end - x - 1)
445  const rest = w.state === 'ask' ? (w.question ?? '') : w.arg || w.task
446  if (end - x > 4) g.text(x + 1, y, shorten(rest, end - x - 2), DIM)
447}
448
449function header(g: Grid, ws: Worker[], now: number) {
450  // the crew's colours drift through the title's diamond and the rule's travelling glow
451  const hues = [0x4fd1c5, 0x64b5f6, 0xb39ddb, 0xffb347, 0xffd166]
452  const t = (now / 1600) % hues.length
453  const hue = mix(hues[Math.floor(t)] ?? 0x4fd1c5, hues[(Math.floor(t) + 1) % hues.length] ?? 0x4fd1c5, t % 1)
454  g.put(1, 0, '◆', hue)
455  let x = 3 + g.text(3, 0, 'CREW', TEXT)
456  const active = ws.filter(w => !w.end).length
457  const asking = ws.filter(w => w.state === 'ask').length
458  const done = ws.filter(w => w.state === 'done').length
459  const failed = ws.filter(w => w.state === 'failed').length
460  const chips: [string, number, boolean][] = [
461    active ? [`● ${active} working`, 0x81c784, true] : ['○ idle', DIM, true], [`? ${asking} asking`, 0xffd166, asking > 0],
462    [`✓ ${done}`, 0x7bd88f, done > 0], [`✗ ${failed}`, 0xff5c5c, failed > 0],
463  ]
464  // the activity of the whole crew over the last two minutes, and its rate
465  const SPAN = 12, B = 10_000
466  const from = Math.ceil(now / B) * B - SPAN * B
467  const per = new Array<number>(SPAN).fill(0)
468  let minute = 0
469  for (const w of ws) for (const tt of w.times) {
470    const b = Math.floor((tt - from) / B)
471    if (b >= 0 && b < SPAN) per[b] = (per[b] ?? 0) + 1
472    if (now - tt < 60_000) minute++
473  }
474  const rate = `${minute}/dk`
475  const sparkW = SPAN + 1 + rate.length
476  const right = g.w - 1 - sparkW
477  for (const [s, c, on] of chips) {
478    if (!on) continue
479    const wdt = [...s].length + 2
480    if (x + 1 + wdt >= right - 1) break
481    x += 1 + g.pill(x + 1, 0, s, c === 0xffd166 ? mix(c, 0xffffff, 0.2 * pulse(now, 1600)) : c, mix(BASE, c, 0.18))
482  }
483  if (right > x + 1 && ws.length) {
484    const top = Math.max(1, ...per)
485    per.forEach((k, i) => g.put(right + i, 0, k ? (BARS[Math.min(BARS.length - 1, Math.ceil((k / top) * BARS.length) - 1)] ?? '▇') : '▁',
486      k ? mix(0x4fd1c5, 0xb39ddb, i / SPAN) : FAINT))
487    g.text(right + SPAN + 1, 0, rate, minute ? DIM : FAINT)
488  }
489  const head = ((now / 18) % (g.w + 30)) - 15
490  for (let i = 0; i < g.w; i++) {
491    const d = Math.abs(i - head)
492    g.put(i, 1, '─', d < 10 ? mix(0x2c2f3a, hue, (1 - d / 10) ** 2) : 0x2c2f3a)
493  }
494}
495
496// `images`: leave each sprite's cells blank and list it in `spots`, for an Image laid over them.
497// `hits`: where each card's "details" button goes (its bottom edge, right side), for a Button laid over it.
498export const DETAILS = 'details ›'
499export type Hit = { key: string; x: number; y: number }
500export function drawScene(
501  ws: Worker[], cols: number, now: number, maxRows?: number, images = false,
502): { rows: number; words: Uint32Array; spots: Spot[]; hits: Hit[] } {
503  const fit = layout(ws.length, cols, maxRows)
504  const rows = fit.rows
505  const g = new Grid(cols, rows)
506  const spots: Spot[] = []
507  const hits: Hit[] = []
508  const place: Place = (look, x, y, alt) => {
509    if (images) spots.push({ key: `s${spots.length}`, x, y, look, alt })
510    else g.pixels(smallSprite(look), x, y)
511  }
512  header(g, ws, now)
513  if (ws.length === 0) {
514    const half = Math.floor(cols / 2)
515    for (const [i, role] of (['scout', 'coder'] as Role[]).entries()) {
516      const cx = Math.max(0, (i ? half : 0) + Math.floor((half - MW) / 2))
517      place(lookAt(role, role === 'scout' ? 'haiku' : 'sonnet', 'sleep', now, 0, i * 5), cx, HEADER_H + 1, `${role} uyuyor`)
518      const label = role === 'scout' ? 'scout · haiku' : 'coder · sonnet'
519      g.text(Math.max(0, (i ? half : 0) + Math.floor((half - label.length) / 2)), HEADER_H + 2 + MH, label, dim(THEME[role], 0.8))
520    }
521    const msg = 'no workers · /orchestrate wakes them'
522    g.text(Math.max(0, Math.floor((cols - [...msg].length) / 2)), HEADER_H + MH + 3, msg, FAINT)
523    return { rows, words: g.words(), spots, hits }
524  }
525  const per = perRow(cols)
526  const W = Math.floor(cols / per)
527  const cw = per > 1 ? W - 1 : W
528  // Overflowing: questions and live workers keep the cards, finished ones drop to lines first.
529  const list = fit.cards < ws.length ? [...ws].sort((a, b) => RANK(a) - RANK(b)) : ws
530  list.slice(0, fit.cards).forEach((w, i) => {
531    const x0 = (i % per) * W, y0 = HEADER_H + Math.floor(i / per) * CARD_H
532    card(g, w, x0, y0, cw, now, place)
533    hits.push({ key: w.key, x: Math.max(x0 + 2, x0 + cw - DETAILS.length - 3), y: y0 + CARD_H - 1 })
534  })
535  const top = HEADER_H + Math.ceil(fit.cards / per) * CARD_H
536  list.slice(fit.cards, fit.cards + fit.lines * per)
537    .forEach((w, i) => line(g, w, (i % per) * W, top + Math.floor(i / per), W, now))
538  if (fit.more > 0) {
539    const msg = `+${fit.more} more · enlarge the pane`
540    g.text(Math.max(0, Math.floor((cols - [...msg].length) / 2)), rows - 1, msg, DIM)
541  }
542  return { rows, words: g.words(), spots, hits }
543}
544
545// ---------- svg (surfaces with no Raster: desktop, editor, mobile) ----------
546
547// One cell is CW x CH; each half-block pixel (CW x CH/2) comes out square. Every cell splits into
548// 2x2 quarters so ▀▄█▌▐ map to rects, merged into runs and one path per colour; other glyphs are text.
549const CW = 6, CH = 12
550const hex = (c: number) => `#${(c & 0xffffff).toString(16).padStart(6, '0')}`
551const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
552const QUARTERS: Record<number, number> = { 0x2580: 0b1100, 0x2584: 0b0011, 0x2588: 0b1111, 0x258c: 0b1010, 0x2590: 0b0101 }
553
554// `sprites`: PNG frames laid over the blank cells drawScene(images = true) left, as data URIs.
555export type SvgSprite = { x: number; y: number; base64: string }
556export function sceneSvg(words: Uint32Array, cols: number, rows: number, sprites: SvgSprite[] = [], scale = 1.5): string {
557  const q = new Int32Array(cols * 2 * rows * 2).fill(-1)
558  const texts: string[] = []
559  for (let y = 0; y < rows; y++) {
560    // Runs end at spaces: a surface may collapse them, and textLength would then stretch the rest.
561    let run = '', runX = 0, runFg = -1
562    const flush = () => {
563      if (run) {
564        const n = [...run].length
565        texts.push(`<text x="${runX * CW}" y="${y * CH + 9.5}" fill="${hex(runFg)}" textLength="${n * CW}" lengthAdjust="spacingAndGlyphs">${esc(run)}</text>`)
566      }
567      run = ''; runFg = -1
568    }
569    for (let x = 0; x < cols; x++) {
570      const i = (y * cols + x) * 3
571      const g = words[i] ?? 0x20, fg = words[i + 1] ?? DEF, bg = words[i + 2] ?? DEF
572      const fill = (mask: number, c: number) => {
573        if (c === DEF) return
574        for (let k = 0; k < 4; k++) if (mask & (8 >> k)) q[(y * 2 + (k >> 1)) * cols * 2 + x * 2 + (k & 1)] = c & 0xffffff
575      }
576      fill(0b1111, bg)
577      const mask = QUARTERS[g]
578      if (mask !== undefined) { fill(mask, fg === DEF ? TEXT : fg); flush(); continue }
579      const c = fg === DEF ? TEXT : fg & 0xffffff
580      if (g === 0x20) { flush(); continue }
581      if (run && c !== runFg) flush()
582      if (!run) { runX = x; runFg = c }
583      run += String.fromCodePoint(g)
584    }
585    flush()
586  }
587  const paths = new Map<number, string>()
588  const W = cols * 2
589  for (let j = 0; j < rows * 2; j++) {
590    for (let i = 0; i < W;) {
591      const c = q[j * W + i] ?? -1
592      let k = i + 1
593      while (k < W && q[j * W + k] === c) k++
594      if (c >= 0) {
595        const w = ((k - i) * CW) / 2
596        paths.set(c, `${paths.get(c) ?? ''}M${(i * CW) / 2} ${(j * CH) / 2}h${w}v${CH / 2}h-${w}z`)
597      }
598      i = k
599    }
600  }
601  const vw = cols * CW, vh = rows * CH
602  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${vw} ${vh}" width="${vw * scale}" height="${vh * scale}">` +
603    `<rect width="${vw}" height="${vh}" rx="6" fill="${hex(0x1b1c22)}"/>` +
604    [...paths].map(([c, d]) => `<path fill="${hex(c)}" d="${d}"/>`).join('') +
605    sprites.map(s => `<image x="${s.x * CW}" y="${s.y * CH}" width="${MW * CW}" height="${MH * CH}" style="image-rendering:pixelated" href="data:image/png;base64,${s.base64}"/>`).join('') +
606    `<g font-family="ui-monospace,Menlo,Consolas,monospace" font-size="10">${texts.join('')}</g></svg>`
607}
608
types/index.d.ts 10 lines
1export type Slots = number
2/** The worker key whose detail view is open; '' = the crew scene. */
3export type Selected = string
4
5declare module 'claude-code' {
6  interface PluginState {
7    'cw-crew': { slots: Slots; selected: Selected }
8  }
9}
10