SLOPSHOPPER

clawd

Clawd, the Claude Code logo, runs around in the band above the prompt and reacts to the session.

newbandrowsguardcommandtoast
v0.1.7MITupdated 2026-10-13Probst1nator/clawd/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd
› fix the failing auth test and add an audit log call ● clawd: Clawd is on · /clawd help explains it · /clawd off hides it (remembered) · remove the mod: claude plugin disable clawd ⏺ 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 › /clawd ⎿ clawd: Clawd on: not on screen. ⎿ clawd: Minis: none (one comes per running subagent); 0 this session. ⎿ clawd: No autopick yet. ⎿ clawd: The autopicker is off, so Clawd plays random acts; /clawd autopick on lets a model choose (uses your Claude us ⎿ clawd: Skin: none, Clawd's own body. ⎿ clawd: /clawd help explains acts, emotes, skins and minis and lists the commands. ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩
README

clawd

A mod for Claude Code: Clawd, the small creature from the Claude Code banner, runs around in a strip above your prompt and reacts to the session.

The strip above the prompt during a session

There is also a version in which Clawd is the MatSci octopus: AutomatedAlchemy/clawd-matsci.

  • It hops when you send a prompt.
  • It holds up a scroll while Claude only reads, and stacks a brick for every other tool call. The next prompt kicks the pile over.
  • It trips when a tool call fails and celebrates when a turn ends.
  • Each subagent gets a small Clawd of its own, which runs off when the subagent is done.
  • Between events it wanders, jumps and chases sparks. After 3 idle minutes it falls asleep.

This is a fan project. Anthropic did not make it and does not endorse it.

Install

claude plugin marketplace add Probst1nator/clawd
claude plugin install clawd@clawd

Start a new session. /clawd help explains the rest. /clawd off hides Clawd (it stays hidden in later sessions until /clawd on), and claude plugin disable clawd@clawd turns the mod off.

Tested with Claude Code 2.1.292. The mod uses plugin hooks that draw into the terminal, so older versions may not load it.

Commands

CommandWhat it does
/clawdwhat Clawd is doing now
/clawd helpevery command; help uml draws how Clawd behaves
/clawd jump 3play an act now, up to 5 times in a row
/clawd a1 wavea subagent's small Clawd plays an act
/clawd act create, change, delete, previewwrite, rewrite, remove or picture an act
/clawd list actsthe acts; also made, emotes, skins, minis
/clawd emote <emote>for a while, Clawd turns into an emote's look or holds its thing
/clawd emote create, change, delete, previewmake, redraw, remove or picture an emote
/clawd skin <skin>, none, autothe body Clawd wears all the time, for this session
/clawd skin create, change, delete, previewmake, redraw, remove or picture a skin
/clawd on, /clawd offshow or hide Clawd
/clawd autopickswitch a model choosing what Clawd plays on or off (see below); now chooses once; haiku, sonnet or opus sets the model
/clawd debugswitch showing each autopick's request and whole reply in the transcript on or off
/clawd traceswitch writing each autopick to a file on or off

While you type /clawd , a list above the prompt shows what fits, and a space writes out a word that fits one name.

Model calls

By default the mod makes no model calls. Clawd picks random acts by itself.

/clawd autopick hands that choice to a model (Haiku). It reads what happens in the session and picks what Clawd and the small Clawds play: after each prompt and turn, and every 10 to 60 seconds. In a busy session that is about 100 calls an hour of about 1,500 tokens each, and it may have Opus write up to 5 new acts, emotes or skins a day. A skin it picks stays on for the rest of the session, unless you chose one with /clawd skin. All of it runs through your own Claude Code login and counts against your usage. /clawd autopick again stops it. /clawd autopick sonnet or opus has that model pick instead, at a higher cost per pick, and /clawd autopick haiku goes back. Both settings are remembered.

While Remote Control is on, Clawd wears an antenna and the autopicker pauses, so Clawd plays random acts. When Remote Control ends, the autopicker goes on as it was set.

/clawd emote create <name> [what it looks like] has Opus draw a new emote: a look Clawd turns into for a while, or a thing it holds or sets down beside it while it keeps its body. /clawd skin create <name> [what it looks like] draws a skin, a body Clawd wears all the time. /clawd act create <name> [what it does] has Opus write a new act. The name alone is enough: /clawd skin create claude_with_glasses draws what the name says. For a sentence such as /clawd skin create claude with glasses, Haiku picks the name. /clawd act change <act> <what to change>, /clawd emote change and /clawd skin change change one; with nothing after the name, they make it again from scratch. Each runs only when you type it.

Where it keeps things

New acts, emotes, skins, their preview images and the autopick traces (/clawd trace) go to ~/.claude/clawd/, or $CLAUDE_CONFIG_DIR/clawd/ when that is set. A plugin update leaves that folder alone. Writing preview images needs python3 on the PATH.

Development

plugin/ is the mod: hooks/register.tsx holds the hooks, hooks/clawd-sim.ts the world, the animation and the drawing. Check it with claude plugin validate plugin and claude plugin test plugin.

This repository gets release snapshots from a private working copy. Issues are welcome.

License

MIT, see LICENSE.

Source 6 files
hooks/register.tsx 2240 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import {
5  ACTS,
6  ACT_PREVIEW_EVERY_S,
7  BAND_ROWS,
8  FLOOR_TEXT,
9  MAX_DELAY_S,
10  MAX_MINIS,
11  MAX_STEPS,
12  MINI_STALE_S,
13  PICKABLE,
14  PREVIEW_PER_LINE,
15  PREVIEW_POSES,
16  PREVIEW_PROP_POSES,
17  READ_HOLD_S,
18  SHEET_POSES,
19  SLEEP_AFTER_S,
20  TICK_MS,
21  actPreview,
22  bandRows,
23  bodyOf,
24  callActs,
25  callLabel,
26  clean,
27  createWorld,
28  emoteFrom,
29  emoteJson,
30  emotePreview,
31  emoteSheet,
32  endMini,
33  frameCells,
34  isAsleep,
35  isClawdCopy,
36  linkRoutine,
37  onPrompt,
38  onTool,
39  onTurnEnd,
40  perform,
41  resize,
42  routineFrom,
43  seeded,
44  setEdge,
45  setSkin,
46  skinAsEmote,
47  skinFrom,
48  skinJson,
49  skinPreview,
50  skinSheet,
51  spawnMini,
52  step,
53} from './clawd-sim'
54import type { ActKind, Call, Emote, Playable, Routine, Skin, Step, World } from './clawd-sim'
55import { contextParts, contextRows } from './clawd-context'
56import { ACT_COMMANDS, ALIASES, COMMANDS, EMOTE_COMMANDS, LIST_KINDS, MAX_REPEAT, SKIN_COMMANDS, isClawdDraft, marksFor, menuFor, menuLayout, namesFit, orderOf, writeOut } from './clawd-words'
57import type { ListKind, MadeKind, Menu, Names } from './clawd-words'
58import { UML_LEGEND, UML_WIDTH, clawdUml, umlKinds } from './clawd-uml'
59import type { UmlKind } from './clawd-uml'
60
61// Clawd, the logo, runs in the band above the prompt: one full-width Raster
62// that a timer repaints with $.ui.blit. Session events steer it (the
63// behaviours are in clawd-sim.ts). Logo-style
64// quadrant blocks, Clawd standing on a floor line at the band's bottom, two
65// sub-pixels of headroom above him (BAND_ROWS), and a row more (TALL_ROWS)
66// during big jumps; two more (ANTENNA_ROWS) while he wears the Remote Control
67// antenna on a tall look. With only 3 rows to spare he bounces in place. The
68// band shows at any size the engine gives it, cut off where it is too small.
69const MENU_MIN_COLUMNS = 20
70const CLAWD_MAX_COLUMNS = 512 // the Raster limit
71// While /clawd is typed, a framed list hangs below Clawd's runway, whose floor
72// is the frame's top edge. At most this many names show.
73const MENU_ROWS = 7
74// The picker model picks what Clawd plays next (Haiku, Sonnet
75// from 2026-10-06, Haiku again from 2026-10-07; since 2026-10-08 Haiku unless
76// `/clawd autopick sonnet` or `opus` chose another): when a prompt is sent, when a turn ends, and
77// otherwise after a random 10 to 60 s. It reads the messages and tool calls
78// since its last pick, its rolling summary of the session, the situation
79// (time, idle time, what Clawd is doing) and the newest act a model made. It
80// may ask for a new act, emote or skin; Opus then writes the act's steps or draws the look, kept in data/ for
81// every session and host. A skin it calls or has drawn is Clawd's body for the
82// rest of the session, below the person's own /clawd skin choice. The top
83// right of the band shows the pick and, for a new act, its title.
84// Each running subagent gets a mini Clawd, which the
85// picker addresses on its own: one pick is a list of calls, like parallel tool
86// calls, each naming a body (or all of them), an act and a delay of 0 to 5 s.
87// The picker is off until `/clawd autopick on`; the choice is kept in $.store, as
88// is the picker model (`pickModel`).
89const PICK_MODELS = ['haiku', 'sonnet', 'opus'] as const
90type PickModel = (typeof PICK_MODELS)[number]
91const DEFAULT_PICK_MODEL: PickModel = 'haiku'
92const MAKE_MODEL = 'opus'
93const NAME_MODEL = 'haiku' // names what a sentence after create asks for
94const PICK_MIN_S = 10
95const PICK_MAX_S = 60
96const NEW_PER_DAY = 5 // new acts, emotes and skins the picker may ask for in any 24 hours, over all sessions and hosts
97const FEED_MAX = 40 // lines kept for the next pick
98const DAY_MS = 24 * 3600_000
99const TRACE_MAX = 50 // picks kept per session in picks/
100// Emotes: looks Clawd takes for a while. emotes/ holds the
101// ones drawn by hand, data/emotes/ the ones a model drew. A model draws one in
102// a headless `claude -p` run, because $.model.complete takes no image: the run
103// may read the reference image and the preview of its draft and nothing else,
104// and the mod writes the files. A new emote starts as EMOTE_DRAFTS drafts in
105// one reply; the next round sees them side by side and picks one, and each later round sees the preview of its draft.
106const EMOTE_ROUNDS = 3
107const EMOTE_DRAFTS = 3
108const EMOTE_ROUND_MS = 300_000
109const IMAGE = /\.(png|jpe?g|gif|webp)$/i
110// `/clawd` words no act, emote or skin may be named
111const RESERVED = [
112  ...Object.keys(COMMANDS),
113  ...Object.keys(ACT_COMMANDS),
114  ...Object.keys(EMOTE_COMMANDS),
115  ...Object.keys(SKIN_COMMANDS),
116  ...Object.keys(LIST_KINDS),
117  ...Object.keys(ALIASES),
118  'pick',
119]
120// What the built-in acts look like, for /clawd list and the menu while /clawd is typed.
121const ACT_WHAT: Partial<Record<ActKind, string>> = {
122  ...PICKABLE,
123  sleep: 'sits down and falls asleep',
124  wake: 'wakes up',
125  carry: 'fetches a brick for the pile',
126  kick: 'kicks the brick pile over',
127  read: 'holds up a scroll and looks down at it',
128}
129
130const isClawdOn = atom({ plugin: 'clawd', key: 'isClawdOn' } as const, true)
131const clawdRows = atom({ plugin: 'clawd', key: 'clawdRows' } as const, BAND_ROWS)
132const isTracing = atom({ plugin: 'clawd', key: 'isTracing' } as const, false)
133const isDebugging = atom({ plugin: 'clawd', key: 'isDebugging' } as const, false)
134const clawdMenu = atom({ plugin: 'clawd', key: 'clawdMenu' } as const, null)
135const clawdSkin = atom({ plugin: 'clawd', key: 'clawdSkin' } as const, null)
136const clawdPickedSkin = atom({ plugin: 'clawd', key: 'clawdPickedSkin' } as const, null)
137
138// Off in headless sessions and with CLAUDE_CLAWD_OFF=1. Tests set
139// CLAUDE_CLAWD_SEED so the band's random acts repeat.
140// `names` is what a `/clawd` word may name, read once per `/clawd` draft.
141// `dataDir` keeps what the mod makes (acts, emotes a model drew, previews,
142// traces): data/ beside the plugin where a checkout has one, as tools/clawd
143// does, else clawd/ in the Claude config folder, which a plugin update leaves
144// alone. `emoteDir` and `skinDir` hold the emotes and skins that ship with
145// the mod. `skin` is the skin Clawd wears now, as last read (`skinNow`):
146// `/clawd skin` chooses one for the session, else the autopicker picked one
147// for the session, else the plugin's clawd.json names one
148// (`fileSkin`), else
149// `ROOT_SKINS` gives one for the project root (`root`).
150const mod = {
151  isOff: false,
152  rand: Math.random,
153  dataDir: '',
154  emoteDir: '',
155  skinDir: '',
156  fileSkin: '',
157  root: '',
158  skin: null as MadeSkin | null,
159  project: '',
160  startedAt: 0,
161  names: undefined as Names | undefined,
162  isRemote: false, // Remote Control is on: Clawd wears an antenna and the autopicker pauses
163}
164
165/** An act a model made, as kept in data/acts/<name>.json. */
166type MadeAct = Routine & { made: string; project: string; why: string }
167
168/** What the file of a skin or an emote keeps besides the look: when, where and why it was made, its reference image, the file. */
169type Dated = { made: string; project: string; why: string; image: string; file: string }
170
171/** An emote from emotes/ (`made` empty) or data/emotes/. */
172type MadeEmote = Emote & Dated
173
174/** A skin from skins/ (`made` empty) or data/skins/. */
175type MadeSkin = Skin & Dated
176
177/** Emotes and skins are drawn, changed, kept and removed the same way, each in its own folders. */
178type LookKind = Exclude<MadeKind, 'act'>
179
180/** How a kind of look is read, written and drawn. */
181type Kit<T extends { name: string; title: string }> = {
182  kind: LookKind
183  parse: (raw: unknown) => T | string
184  json: (look: T) => Record<string, unknown>
185  preview: (look: T) => Uint8Array
186  sheet: (looks: T[]) => Uint8Array
187  check: (look: T) => string // why a newly drawn look is refused, or ''
188  what: (look: T) => string // one line on the look, for the overview a new drawing gets
189}
190// An emote is previewed on the skin Clawd wears now; with a clawd.json skin, on no other body.
191const EMOTE_KIT: Kit<Emote> = {
192  kind: 'emote',
193  parse: emoteFrom,
194  json: emoteJson,
195  preview: look => emotePreview(look, mod.skin, mod.fileSkin !== ''),
196  sheet: looks => emoteSheet(looks, mod.skin, mod.fileSkin !== ''),
197  check: look =>
198    isClawdCopy(look) ? "its shapes are Clawd's own body; leave out shapes and the other body fields, so the look keeps the body Clawd has" : '',
199  what: emoteWhat,
200}
201const SKIN_KIT: Kit<Skin> = {
202  kind: 'skin',
203  parse: skinFrom,
204  json: skinJson,
205  preview: skinPreview,
206  sheet: skinSheet,
207  check: () => '',
208  what: look => look.title,
209}
210
211/** One call as the picker made it, for the label and `/clawd`. */
212type Called = { who: string; name: string; delay: number }
213
214type Pick = { trigger: string; calls: Called[]; why: string; at: number; made?: MadeAct }
215
216/** One pick as it went: what the picker read, what it answered, what came of it. */
217type Trace = { at: string; trigger: string; system: string; prompt: string; reply: string; outcome: string }
218
219// The picker's working state; a hot reload starts it over.
220const brain = {
221  feed: [] as { n: number; text: string }[], // what happened since the last pick
222  seq: 0, // lines ever fed
223  seen: 0, // lines the last pick read
224  summary: '',
225  recent: [] as string[], // picks, newest first
226  last: null as Pick | null,
227  timer: null as Timer | null,
228  dueAt: 0, // when the timer fires; 0 while none is set
229  isPicking: false,
230  again: null as string | null, // a trigger that came in during a pick
231  making: '', // the act a model writes or the look it draws now, from a /clawd command or a picker's look
232  makeError: '', // why the last act, emote or skin a model made failed
233  shown: new Map<string, string>(), // each part of the picker's request as /clawd debug last showed it
234}
235
236// Clawd's working state; a hot reload starts Clawd over.
237const clawd = {
238  world: null as World | null,
239  requestId: null as string | null,
240  last: '',
241  rows: null as number | null, // the band's height as last written; null: not yet written this load
242}
243
244function clawdTick($: EngineInterface): void {
245  const w = clawd.world
246  const requestId = clawd.requestId
247  if (!w || !requestId) return // paused while the band is not mounted
248  step(w)
249  const rows = bandRows(w)
250  if (rows !== clawd.rows) {
251    clawd.rows = rows
252    void update($, clawdRows, () => rows) // the band redraws at its new height
253  }
254  const cells = frameCells(w)
255  if (cells === clawd.last) return
256  clawd.last = cells
257  void $.ui.blit({ requestId, key: 'clawd', cells }).then(r => {
258    if (r.deny === undefined || clawd.requestId !== requestId) return
259    // Unmounted, or a frame of the old height that reached the band after it
260    // grew or shrank (the band keeps one requestId). Nothing else redraws the
261    // band until its props change, so ask for the render that mounts it again;
262    // a band no longer shown draws no Raster, and the tick stays paused.
263    $.ui.log(`clawd: blit refused: ${r.deny}`, { to: 'debug' })
264    clawd.requestId = null
265    $.ui.invalidate('ui.render')
266  })
267}
268
269// While Remote Control is on, Clawd wears an antenna. No
270// hook event says when it goes on or off, but the engine keeps
271// CLAUDE_CODE_BRIDGE_SESSION_ID in its own environment while the session has a
272// Remote Control link (2.1.294), so the mod reads it every REMOTE_POLL_MS.
273// While it is on, the autopicker pauses and Clawd plays random acts; the stored
274// setting is left as it is and applies again when Remote Control ends.
275const REMOTE_POLL_MS = 1000
276
277async function pollRemote($: EngineInterface): Promise<void> {
278  const isRemote = Boolean(await $.env.get('CLAUDE_CODE_BRIDGE_SESSION_ID'))
279  if (isRemote === mod.isRemote) return
280  mod.isRemote = isRemote
281  if (clawd.world) clawd.world.antenna = isRemote
282  if (isRemote) {
283    brain.timer?.cancel()
284    brain.timer = null
285    brain.dueAt = 0
286  } else if (await isAutopicking($)) schedulePick($, await $.clock.now())
287  $.ui.log(`clawd: Remote Control ${isRemote ? 'on, antenna up, autopicker paused' : 'off, antenna down'}`, { to: 'debug' })
288}
289
290// --- the picker ----------------------------------------------------------------
291
292const PICK_SYSTEM =
293  'You direct Clawd, the Claude Code logo drawn as a small creature that runs around in a band ' +
294  'above the prompt of a Claude Code session, and a mini Clawd for each subagent the session runs. ' +
295  'The person sees Clawd while they work and sometimes talks to it or about it. When they give it a ' +
296  'name or compare it to something, they want to see it become that thing, which is what new acts, emotes and skins are for. ' +
297  'You pick what they play next so it fits what is going on, and you keep a short summary of the ' +
298  'session. Reply with one JSON object and nothing else.'
299
300const MAKE_SYSTEM =
301  'You write short choreographies for Clawd, the Claude Code logo drawn as a small creature, orange ' +
302  "in its own body, in a terminal band 4 rows high and as wide as the terminal. Reply with one JSON object and nothing else."
303
304function oneLine(s: string, n: number, end = false): string {
305  const t = s.replace(/\s+/g, ' ').trim()
306  if (t.length <= n) return t
307  return end ? `...${t.slice(-n)}` : `${t.slice(0, n)}...`
308}
309
310function feed(text: string): void {
311  brain.seq += 1
312  brain.feed.push({ n: brain.seq, text })
313  if (brain.feed.length > FEED_MAX) brain.feed.shift()
314}
315
316/** A subagent's mini id, or 'subagent' when it has none. */
317function miniName(agentId: string): string {
318  return clawd.world?.minis.find(m => m.agentId === agentId)?.id ?? 'subagent'
319}
320
321function toolLine(e: Record<string, unknown>, isError: boolean): string {
322  const keys = ['description', 'command', 'file_path', 'pattern', 'url', 'query', 'skill', 'prompt']
323  const what = keys.map(k => e[k]).find((v): v is string => typeof v === 'string' && v !== '')
324  const who = typeof e.agentId === 'string' ? ` (${miniName(e.agentId)})` : ''
325  return `tool ${String(e.tool)}${who} ${isError ? 'failed' : 'ok'}${what ? `: ${oneLine(what, 100)}` : ''}`
326}
327
328function callText(c: Called): string {
329  return `${c.who}: ${c.name}${c.delay > 0 ? ` +${c.delay} s` : ''}`
330}
331
332function parseJson(text: string): Record<string, unknown> | null {
333  const match = text.match(/\{[\s\S]*\}/)
334  if (!match) return null
335  try {
336    const value: unknown = JSON.parse(match[0])
337    return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
338  } catch {
339    return null
340  }
341}
342
343function ago(ms: number): string {
344  const s = Math.max(0, Math.round(ms / 1000))
345  return s < 120 ? `${s} s` : s < 7200 ? `${Math.round(s / 60)} min` : `${Math.round(s / 3600)} h`
346}
347
348/**
349 * The acts models made, newest first, from data/acts/, with their emote and
350 * play steps linked to `emotes` (read here when not given) and to each other.
351 * An emote step may also name a skin, which Clawd then wears for a while.
352 */
353async function loadMade($: EngineInterface, emotes?: Emote[]): Promise<MadeAct[]> {
354  const dir = `${mod.dataDir}/acts`
355  let entries: { name: string; kind: string }[] = []
356  try {
357    entries = await $.fs.list(dir)
358  } catch {
359    return [] // no act made yet
360  }
361  const made: MadeAct[] = []
362  for (const entry of entries) {
363    if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
364    try {
365      const raw = JSON.parse(await $.fs.read(`${dir}/${entry.name}`)) as Record<string, unknown>
366      const routine = routineFrom(raw)
367      if (typeof routine === 'string') continue
368      const text = (v: unknown) => (typeof v === 'string' ? v : '')
369      made.push({ ...routine, made: text(raw.made), project: text(raw.project), why: text(raw.why) })
370    } catch {
371      // half-written or broken; skip it
372    }
373  }
374  const find = finder(made, emotes ?? (await loadEmotes($)), await loadSkins($))
375  return made.sort((a, b) => b.made.localeCompare(a.made)).map(r => linkRoutine(r, find))
376}
377
378/** What a name in an act's emote or play step stands for: a made act, an emote, or a skin worn for a while. */
379function finder(made: readonly Routine[], emotes: readonly Emote[], skins: readonly Skin[]): (name: string) => Playable | undefined {
380  return name => {
381    const skin = skins.find(x => x.name === name)
382    return made.find(r => r.name === name) ?? emotes.find(x => x.name === name) ?? (skin && skinAsEmote(skin))
383  }
384}
385
386/**
387 * The emotes or skins, read fresh on each use so a new file plays without a
388 * reload: the mod's own folder (emotes/, skins/) first, then the data folder's
389 * newest first. A model's never replaces a hand-drawn one of the same name.
390 */
391async function loadLooks<T extends { name: string; title: string }>($: EngineInterface, kit: Kit<T>): Promise<(T & Dated)[]> {
392  const found: (T & Dated)[] = []
393  for (const dir of [kit.kind === 'emote' ? mod.emoteDir : mod.skinDir, `${mod.dataDir}/${kit.kind}s`]) {
394    let entries: { name: string; kind: string }[] = []
395    try {
396      entries = await $.fs.list(dir)
397    } catch {
398      continue // none there yet
399    }
400    const here: typeof found = []
401    for (const entry of entries) {
402      if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
403      try {
404        const raw = JSON.parse(await $.fs.read(`${dir}/${entry.name}`)) as Record<string, unknown>
405        const look = kit.parse(raw)
406        if (typeof look === 'string' || found.some(e => e.name === look.name)) continue
407        const text = (v: unknown) => (typeof v === 'string' ? v : '')
408        const file = `${dir}/${entry.name}`
409        here.push({ ...look, made: text(raw.made), project: text(raw.project), why: text(raw.why), image: text(raw.image), file })
410      } catch {
411        // half-written or broken; skip it
412      }
413    }
414    found.push(...here.sort((a, b) => b.made.localeCompare(a.made)))
415  }
416  return found
417}
418
419/** The emotes: emotes/, then data/emotes/. */
420async function loadEmotes($: EngineInterface): Promise<MadeEmote[]> {
421  return loadLooks($, EMOTE_KIT)
422}
423
424/** The skins: skins/, then data/skins/. */
425async function loadSkins($: EngineInterface): Promise<MadeSkin[]> {
426  return loadLooks($, SKIN_KIT)
427}
428
429/** data/ beside the plugin where a checkout has one, else clawd/ in the Claude config folder. */
430async function dataDirOf($: EngineInterface, root: string): Promise<string> {
431  const beside = `${root.replace(/\/[^/]*$/, '')}/data`
432  try {
433    if ((await $.fs.stat(beside)).kind === 'dir') return beside
434  } catch {
435    // no such folder
436  }
437  const config = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) ?? ''}/.claude`
438  return `${config}/clawd`
439}
440
441/** The skin the plugin's clawd.json names (`skin`, or `look` as until 2026-10-08), or ''. */
442async function fileSkinOf($: EngineInterface, root: string): Promise<string> {
443  try {
444    const raw = JSON.parse(await $.fs.read(`${root}/clawd.json`)) as { skin?: unknown; look?: unknown }
445    const name = raw.skin ?? raw.look
446    return typeof name === 'string' ? name : ''
447  } catch {
448    return '' // no clawd.json: Clawd is Clawd
449  }
450}
451
452// Without a clawd.json skin, a session whose project root has `part` in its
453// path, in any case, wears `skin`. The root moves with /cd and worktree moves but not with a
454// shell cd, so an agent passing through a MatSci folder does not switch it.
455const ROOT_SKINS = [{ part: 'matsci', skin: 'matsci_octopus' }]
456const ROOT_POLL_MS = 1000
457
458/**
459 * The skin for now and where it comes from: /clawd skin's choice, the
460 * autopicker's, clawd.json's, the project root's; '' for Clawd's own body.
461 */
462async function skinNow($: EngineInterface): Promise<{ name: string; from: string }> {
463  const chosen = await read($, clawdSkin)
464  if (chosen !== null) return { name: chosen, from: 'chosen with /clawd skin for this session' }
465  const picked = await read($, clawdPickedSkin)
466  if (picked !== null) return { name: picked, from: 'picked by the autopicker for this session; /clawd skin auto goes back' }
467  if (mod.fileSkin) return { name: mod.fileSkin, from: "named in the mod's clawd.json" }
468  const path = mod.root.toLowerCase()
469  const rule = ROOT_SKINS.find(r => path.includes(r.part))
470  return rule ? { name: rule.skin, from: `the project folder has "${rule.part}" in its path` } : { name: '', from: '' }
471}
472
473/** Reads the skin for now and gives it to Clawd: with a puff when it changes and `isChange`, else quietly. */
474async function applySkin($: EngineInterface, isChange: boolean): Promise<void> {
475  const { name } = await skinNow($)
476  mod.skin = name ? ((await loadSkins($)).find(x => x.name === name) ?? null) : null
477  if (clawd.world) setSkin(clawd.world, mod.skin, !isChange)
478}
479
480/** Reads the project root every ROOT_POLL_MS and changes the skin when the root asks for another. */
481async function pollRoot($: EngineInterface): Promise<void> {
482  let root: string
483  try {
484    root = await $.session.root()
485  } catch {
486    return
487  }
488  if (!root || root === mod.root) return
489  mod.root = root
490  const before = mod.skin?.name ?? ''
491  await applySkin($, true)
492  const after = mod.skin?.name ?? ''
493  if (after !== before) $.ui.log(`clawd: project root ${root}, skin ${after || 'none'}`, { to: 'debug' })
494}
495
496/** Whether the autopicker runs by itself (`/clawd autopick on`); off until then. */
497async function isAutopicking($: EngineInterface): Promise<boolean> {
498  return (await $.store.get('isAutopickOn')) === true
499}
500
501/** Whether the autopicker picks by itself now: switched on, and Remote Control off. */
502async function isPickingAlone($: EngineInterface): Promise<boolean> {
503  return !mod.isRemote && (await isAutopicking($))
504}
505
506/** The model the autopicker asks (`/clawd autopick haiku|sonnet|opus`); Haiku until another is chosen. */
507async function autopickModel($: EngineInterface): Promise<PickModel> {
508  const model = await $.store.get('pickModel')
509  return PICK_MODELS.find(m => m === model) ?? DEFAULT_PICK_MODEL
510}
511
512/** A local image's real path for an emote's reference, or '' when it is none. */
513async function imagePath($: EngineInterface, raw: string): Promise<string> {
514  const home = (await $.env.get('HOME')) ?? ''
515  const path = raw.startsWith('~/') ? `${home}/${raw.slice(2)}` : raw
516  if (!path.startsWith('/') || !IMAGE.test(path)) return ''
517  try {
518    const stat = await $.fs.stat(path, { resolve: true })
519    return stat.kind === 'file' && stat.realPath && IMAGE.test(stat.realPath) ? stat.realPath : ''
520  } catch {
521    return ''
522  }
523}
524
525/** Writes bytes to a file; $.fs.write takes text only. */
526async function writeBytes($: EngineInterface, path: string, bytes: Uint8Array): Promise<void> {
527  const code = 'import base64,os,sys;os.makedirs(os.path.dirname(sys.argv[1]),exist_ok=True);open(sys.argv[1],"wb").write(base64.b64decode(sys.stdin.read()))'
528  const base64 = (bytes as Uint8Array & { toBase64(): string }).toBase64()
529  const r = await $.process.run(['python3', '-c', code, path], { stdin: base64, timeoutMs: 20_000 })
530  if (r.exitCode !== 0) throw new Error(`python3 exited ${r.exitCode}: ${oneLine(r.stderr, 200)}`)
531}
532
533/** Draws an emote's or a skin's preview sheet to data/<kind>s/previews/<file>.png; the path. */
534async function previewLook<T extends { name: string; title: string }>($: EngineInterface, kit: Kit<T>, look: T, file = look.name): Promise<string> {
535  const path = `${mod.dataDir}/${kit.kind}s/previews/${file}.png`
536  await writeBytes($, path, kit.preview(look))
537  return path
538}
539
540// What an emote may have that a skin has not: a time, a prop and a tool.
541const EMOTE_EXTRAS = [
542  '- dur: seconds Clawd keeps the look, 3 to 20.',
543  "- accent (optional): the colour of '+' pixels, and of the prop and the tool where they give none.",
544  '- prop (optional): an object beside Clawd that is not part of its body: something that moves on its',
545  '  own, such as a rocket that lifts off, or something Clawd works on, such as a block it mines. Draw',
546  '  such a thing as a prop, not into the shapes, or it walks along with Clawd.',
547  '  {"rows": [...], "color": "#rrggbb", "x": -12, "path": [{"t": 0, "y": 0}, ...], "trail": false}.',
548  '  rows: # the prop colour, + the accent, . empty; at most 8 wide and 6 high (8 with',
549  '  "tall"); an even width keeps its pixels paired in cells. color: optional, the accent by default.',
550  "  x: sub-pixels from Clawd's centre to the prop's centre as the look starts, negative behind Clawd;",
551  '  from then on the prop keeps to its path wherever Clawd goes. path: up to 8 keys in time order, t',
552  '  seconds since the look started (0 to dur), y the height of its bottom above the floor (0 to 24),',
553  '  x an optional sideways shift; straight lines between keys, then the last key holds. Past y 10 the',
554  '  prop has left the band. trail: true for sparks below it while it rises, like a flame.',
555  '  The path runs on its own clock and does not follow Clawd: after a wave and two hops Clawd goes on',
556  '  as always and may walk off, trip or sit down and sleep. Give a moving path only to a thing that',
557  '  moves by itself, such as a rocket or a ball rolling away. A thing Clawd works on stays on the',
558  '  floor: one key {"t": 0, "y": 0}. When the look ends, a prop on the floor breaks into its pixels.',
559  '- tool (optional): a thing Clawd holds in its front hand, such as a pointer or a pickaxe.',
560  '  {"rows": [...], "color": "#rrggbb"}. rows: # the tool colour, + the accent, . empty; at most 8 wide',
561  "  and 4 high. color: optional, the accent by default. The first pixel of the bottom row sits one",
562  "  pixel past the front arm's tip, and the tool moves with that tip from pose to pose without",
563  '  turning, so a wave between up and raised moves it too; a body without arms holds it against its',
564  "  front edge. Give it a colour other than the body's (Clawd's own orange or its skin's), or it reads",
565  '  as part of the arm.',
566]
567
568/**
569 * What a new emote is asked to be: Clawd becomes the thing ('become'), or
570 * keeps its body and holds, uses or sets down the thing ('hold'); '' leaves
571 * it to the drawing model.
572 */
573type Form = 'become' | 'hold' | ''
574
575/**
576 * The prompt of one drawing round of an emote or a skin. With `change`, an
577 * existing look is changed: `isBefore` marks the first round, whose draft is
578 * the look as it is now. `others` lists the other looks of the kind, one line
579 * each, for the round that draws the drafts.
580 */
581function emotePrompt(
582  kind: LookKind,
583  name: string,
584  title: string,
585  why: string,
586  image: string,
587  draft: string,
588  preview: string,
589  refused: string,
590  change = '',
591  isBefore = false,
592  drafts: string[] = [],
593  form: Form = '',
594  others: string[] = [],
595): string {
596  const poses = PREVIEW_POSES.map((p, i) => `${i + 1}. ${p}`).join('; ')
597  const propPoses = PREVIEW_PROP_POSES.map((p, i) => `${PREVIEW_POSES.length + i + 1}. ${p}`).join('; ')
598  const isEmote = kind === 'emote'
599  // A skin, and an emote that turns Clawd into the thing, has a body; an emote that holds the thing has none.
600  const hasBody = !isEmote || form !== 'hold'
601  const mayHold = isEmote && form !== 'become'
602  const propLines = isEmote ? [`A look with a prop gets one more line of ${PREVIEW_PROP_POSES.length} panels: ${propPoses}.`] : []
603  // A look that keeps the body is drawn on the skin and on Clawd's own body (emotePreview, emoteSheet), unless clawd.json names the skin.
604  const isTwice = isEmote && mayHold && mod.skin !== null && !mod.fileSkin
605  const twiceLines = isTwice
606    ? ["A look that keeps the body is shown on the skin and then, from a new line, in the same panels on Clawd's own body. It must work on both."]
607    : []
608  // A new look's first round draws several drafts; the next one picks from them.
609  const isDrafting = !change && !draft && drafts.length === 0
610  const skin = mod.skin ? `the skin ${mod.skin.name} ("${oneLine(mod.skin.title, 60)}", ${String(skinJson(mod.skin).color)})` : 'its own orange body'
611  const differ = !hasBody
612    ? 'the shape, size and colour of the tool and the prop, and where the prop stands'
613    : mayHold
614    ? 'which kind it is, silhouette, proportions, eyes, legs, arms, tool or prop'
615    : 'silhouette, proportions, eyes, legs or arms'
616  return [
617    `You ${change ? 'change a' : 'draw a new'} ${isEmote ? 'look' : 'skin'} for Clawd, the Claude Code logo, which runs around as a small creature in a`,
618    isEmote
619      ? 'terminal band above the prompt. For a while Clawd wears this look, and'
620      : 'terminal band above the prompt. As a skin, Clawd is drawn in this look instead of its own body all the time, and',
621    'moves as always: it walks, hops, looks around, blinks, trips, waves, and sits down to sleep.',
622    `The look: ${name}, "${title}".`,
623    `Why it was asked for: ${why || '(no reason given)'}`,
624    ...(change ? [`The change asked for now: "${change}". Make that change and keep what it does not touch.`] : []),
625    image
626      ? `The reference image is ${image}. Read it first and draw the look after it: its silhouette, colour and the features that make it recognisable at this tiny size.`
627      : 'There is no reference image; draw it from the title.',
628    ...(!isEmote
629      ? []
630      : !mayHold
631      ? ['This look turns Clawd into the thing: it has shapes and the body fields, and Clawd is drawn in them instead of its body.']
632      : !hasBody
633      ? ['This look keeps the body Clawd has and adds the thing: a tool in its hand, a prop beside it, or both.', 'It has no shapes and none of the body fields.']
634      : [
635          'A look is one of two kinds. One turns Clawd into the thing: it has shapes and the body fields, and',
636          'Clawd is drawn in them instead of its body. The other keeps the body Clawd has and adds the thing: a',
637          'tool in its hand, a prop beside it, or both; it has no shapes and none of the body fields. Take the',
638          'second when the thing is something Clawd holds, uses, makes or works on, and the first when Clawd',
639          'should become it: a creature, a character, a costume.',
640        ]),
641    ...(mayHold ? [`Clawd's body now is ${skin}. A look that keeps the body is drawn on it, and later on whatever body Clawd has then.`] : []),
642    '',
643    'How it is drawn: on a grid of sub-pixels, 2 x 2 per terminal cell, with quadrant block characters.',
644    'A terminal cell is about twice as high as wide, so a sub-pixel is too. A cell shows at most two',
645    'colours, so keep to one body colour: eyes are holes that show the dark terminal background. The',
646    'band is 4 rows (8 sub-pixels) high; with "tall": true it grows to 5 rows (10 sub-pixels) while Clawd',
647    "wears the look. Clawd's own body is 12 x 4 sub-pixels with four legs of 1 below it.",
648    '',
649    ...(isDrafting && others.length > 0
650      ? [`Clawd has these other ${isEmote ? 'emotes' : 'skins'}. Draw the new one so that it cannot be taken for any of them:`, ...others, '']
651      : []),
652    ...(isDrafting
653      ? [
654          `Draw ${EMOTE_DRAFTS} drafts that differ in how they show it: ${differ}. The`,
655          'next round sees them side by side and keeps the best. Reply with one JSON object and nothing else,',
656          `{"drafts": [${Array.from({ length: EMOTE_DRAFTS }, (_, i) => `draft ${i + 1}`).join(', ')}]}, each draft an object like this:`,
657        ]
658      : ['Reply with one JSON object and nothing else:']),
659    ...(hasBody
660      ? [
661          ...(mayHold ? ['for a look that turns Clawd into the thing,'] : []),
662          `{"name": "${name}", "title": "...", "color": "#rrggbb", "shapes": {...}, "legs": [...], "legLength": 2,`,
663          ` "wiggle": false, "armRow": 2, "arms": {...}, "tall": false,${isEmote ? ' "dur": 8,' : ''} "done": false}`,
664        ]
665      : []),
666    ...(mayHold
667      ? [
668          ...(hasBody ? ['and for one that keeps the body Clawd has,'] : []),
669          `{"name": "${name}", "title": "...", "accent": "#rrggbb", "tall": false, "dur": 8, "tool": {...}, "prop": {...}, "done": false}`,
670          'with a tool, a prop or both; leave out the one it does not use.',
671        ]
672      : []),
673    ...(hasBody
674      ? [
675          ...(mayHold ? ['The body fields, for a look that turns Clawd into the thing:'] : []),
676          '- color: the body colour; one that stands out on a dark background. "accent": "#rrggbb" is',
677          "  optional, for '+' pixels; a '+' only shows where all 8 of its neighbours are body pixels.",
678          '- shapes: rows of characters, one string per row, at most 20 wide. # body, o an eye (a hole while',
679          '  open; it moves when Clawd looks around), + accent, . empty. normal: standing. squash: landing or',
680          '  crouching, wider and lower. stretch: rising in a jump, narrower and higher. flat: lying flat after',
681          '  a trip, 1 or 2 rows. Any shape left out is the normal one.',
682          '- legs: columns of the normal shape where legs hang below its last row, up to 8. legLength 0 to 3.',
683          "  wiggle true: the leg tips sway like tentacles and crawl in a ripple; false: they step like Clawd's.",
684          "- armRow: the normal shape's row the arms grow from, or null for none. arms (optional): the arm",
685          "  pixels as [dx, dy] from the body's edge on that row, dx outward and dy down, up to 4 per pose",
686          "  and within 3. Clawd's are out [[1,0],[2,0]], up [[1,-1],[2,-2]], down [[1,1]], raised [[1,-1],[1,-2]].",
687          "  Each pose is one line: the first pixel touches the body's edge, each next one the one before, no",
688          "  branches; a pose that is not keeps Clawd's. Draw nothing held into the arms: at this size it reads",
689          `  as horns or a tail.${isEmote ? ' A held thing is the tool.' : ''}`,
690          '- Height limit: each shape, with legLength for normal and stretch, at most 6 sub-pixels, or 8 with',
691          '  "tall": true.',
692        ]
693      : []),
694    ...(isEmote ? [...(hasBody ? ['The fields of every look:'] : []), ...EMOTE_EXTRAS] : []),
695    '- done: true once you have seen the preview of this exact JSON and it looks right.',
696    ...(refused ? ['', `Your last draft was refused: ${refused}. Fix that.`] : []),
697    ...(drafts.length > 0 && preview
698      ? [
699          '',
700          `Your ${drafts.length} drafts:`,
701          ...drafts.map((d, i) => `${i + 1}. ${d}`),
702          `Their sheet is ${preview}. Read it. Line k shows draft k, its number at the left, as the terminal`,
703          `shows the band, in ${SHEET_POSES.length} poses: ${SHEET_POSES.join('; ')}.`,
704          ...(isTwice
705            ? [`A draft that keeps the body shows them on the skin and then the same ${SHEET_POSES.length} on Clawd's own body. It must work on both.`]
706            : []),
707          'Compare them with the reference image, or with the title when there is none, and pick the one',
708          'that is easiest to recognise at this size. Fix what the sheet shows is off in it; a feature of',
709          'another draft may be taken over. Reply with the fixed JSON, "pick": its number, and "done": false.',
710        ]
711      : draft && isBefore
712      ? [
713          '',
714          `The look as it is now: ${draft}`,
715          ...(preview
716            ? [
717                `Its preview is ${preview}. Read it. It is a sheet of ${PREVIEW_POSES.length} panels, ${PREVIEW_PER_LINE} per line, each the`,
718                `band as the terminal shows it with Clawd in this look, in these poses: ${poses}.`,
719                ...propLines,
720                ...twiceLines,
721              ]
722            : []),
723          'Make the change asked for and reply with the changed JSON and "done": false.',
724        ]
725      : draft && preview
726      ? [
727          '',
728          `Your last draft: ${draft}`,
729          `Its preview is ${preview}. Read it. It is a sheet of ${PREVIEW_POSES.length} panels, ${PREVIEW_PER_LINE} per line, each the`,
730          `band as the terminal shows it with Clawd in your look, in these poses: ${poses}.`,
731          ...propLines,
732          ...twiceLines,
733          ...(change ? ['Check first that it carries the change asked for.'] : []),
734          'Compare it with the reference image, or with the title when there is none, and work out what',
735          'makes it harder to recognise at this size: silhouette, eyes, legs, arms, proportions. If any of',
736          'that can be fixed within the limits, reply with the corrected JSON and "done": false. Reply with',
737          'the same JSON and "done": true only when no change would make it clearly more recognisable.',
738        ]
739      : []),
740  ].join('\n')
741}
742
743/** A look a model drew, under the name asked for, with its own title or else the one asked for. */
744function lookFrom<T extends { name: string; title: string }>(kit: Kit<T>, raw: unknown, name: string, title: string): T | string {
745  const r = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
746  const look = kit.parse({ ...r, name, title: typeof r.title === 'string' && r.title.trim() ? r.title : title })
747  return typeof look === 'string' ? look : kit.check(look) || look
748}
749
750/** An emote or a skin to change, and the change asked for (/clawd emote change, /clawd skin change). */
751type Change<T> = { look: T & Dated; text: string }
752
753/**
754 * A model draws a new emote or skin: EMOTE_DRAFTS drafts, then a round in
755 * which it sees them side by side, picks one and fixes it, then a round in
756 * which it sees the pick's preview and corrects it. With `start`, the first
757 * draft is the look as it is now, and the model makes the change asked for.
758 * With `replace`, the look is drawn again from scratch and goes into that
759 * look's file. The look, or why there is none.
760 */
761async function makeEmote<T extends { name: string; title: string }>(
762  $: EngineInterface,
763  kit: Kit<T>,
764  name: string,
765  title: string,
766  why: string,
767  image: string,
768  start?: Change<T>,
769  form: Form = '',
770  replace?: T & Dated,
771): Promise<(T & Dated) | string> {
772  // The drafts are told the other looks of the kind; the one drawn again is left out.
773  const others = start ? [] : (await loadLooks($, kit)).filter(l => l.name !== name).map(l => `- ${l.name}: ${oneLine(kit.what(l), 100)}`)
774  let best: T | null = null
775  let draft = start ? JSON.stringify(kit.json(start.look)) : ''
776  let preview = start ? await previewLook($, kit, start.look, `${name}.before`) : ''
777  let drafts: string[] = [] // the drafts to pick from, as JSON
778  let refused = ''
779  for (let round = 1; round <= EMOTE_ROUNDS; round++) {
780    // The run may read the reference image and the newest preview, nothing else.
781    const reads = [image, preview].filter(Boolean).map(p => `Read(/${p})`)
782    const argv = [
783      'claude', '-p', '--model', MAKE_MODEL, '--effort', 'medium', '--safe-mode', '--setting-sources', '',
784      '--no-session-persistence', '--tools', 'Read', '--permission-mode', 'dontAsk',
785      ...(reads.length > 0 ? ['--allowedTools', ...reads] : []),
786    ]
787    const r = await $.process.run(argv, {
788      cwd: mod.dataDir,
789      stdin: emotePrompt(kit.kind, name, title, why, image, draft, preview, refused, start?.text, start !== undefined && round === 1, drafts, form, others),
790      timeoutMs: EMOTE_ROUND_MS,
791    })
792    if (r.exitCode !== 0) return `claude exited ${r.exitCode} in round ${round}: ${oneLine(r.stderr || r.stdout, 200)}`
793    const raw = parseJson(r.stdout)
794    if (!start && !draft && drafts.length === 0) {
795      // The drafts go on one sheet for the next round to pick from; a single one is a draft as usual.
796      const list = raw?.drafts
797      const looks = (Array.isArray(list) ? list : [raw]).slice(0, EMOTE_DRAFTS).map(d => lookFrom(kit, d, name, title))
798      const good = looks.filter((l): l is T => typeof l !== 'string')
799      refused = good.length > 0 ? '' : looks.join('; ') || 'no drafts'
800      best = good[0] ?? best
801      if (good.length > 1) {
802        drafts = good.map(l => JSON.stringify(kit.json(l)))
803        preview = `${mod.dataDir}/${kit.kind}s/previews/${name}.drafts.png`
804        await writeBytes($, preview, kit.sheet(good))
805      } else if (good[0]) {
806        draft = JSON.stringify(kit.json(good[0]))
807        preview = await previewLook($, kit, good[0], `${name}.draft${round}`)
808      }
809      continue
810    }
811    const look = lookFrom(kit, raw, name, title)
812    if (typeof look === 'string') {
813      refused = look
814      continue
815    }
816    refused = ''
817    // Done counts only for a draft whose preview the model has seen; for a
818    // change, not before the model has seen its own changed draft.
819    const json = JSON.stringify(kit.json(look))
820    best = look
821    if (raw?.done === true && json === draft && !(start && round === 1)) break
822    draft = json
823    drafts = []
824    preview = await previewLook($, kit, look, `${name}.draft${round}`)
825  }
826  if (!best) return `no usable draft in ${EMOTE_ROUNDS} rounds: ${refused}`
827  const made = new Date(await $.clock.now()).toISOString()
828  // A changed or redrawn look goes back where it was; the old one is kept in data/<kind>s/old/.
829  const was = start?.look ?? replace
830  const file = was?.file ?? `${mod.dataDir}/${kit.kind}s/${name}.json`
831  if (was) await $.fs.write(await oldPath($, kit.kind, name), await $.fs.read(file))
832  const changed = start ? { change: start.text } : {}
833  await $.fs.write(file, `${JSON.stringify({ ...kit.json(best), made, project: mod.project, why, image, ...changed }, null, 2)}\n`)
834  await previewLook($, kit, best)
835  return { ...best, made, project: mod.project, why, image, file }
836}
837
838/** Where an act's, an emote's or a skin's old file goes when it is changed or deleted: data/<kind>s/old/<name>.<time>.json. */
839async function oldPath($: EngineInterface, kind: MadeKind, name: string): Promise<string> {
840  const stamp = new Date(await $.clock.now()).toISOString().replace(/[:.]/g, '-')
841  return `${mod.dataDir}/${kind}s/old/${name}.${stamp}.json`
842}
843
844/**
845 * Starts drawing an emote in the background; it plays on `who` when done. It
846 * runs from a timer, so it outlives the hook that asked for it. With
847 * `replace`, that emote is drawn again from scratch.
848 */
849function startEmote(
850  $: EngineInterface,
851  name: string,
852  title: string,
853  why: string,
854  image: string,
855  who: string,
856  start?: Change<Emote>,
857  form: Form = '',
858  replace?: MadeEmote,
859): void {
860  brain.making = name
861  const w = clawd.world
862  const label = start ? ` · changing ${name}: "${start.text}"` : replace ? ` · drawing ${name} again` : ` · new emote coming: "${title}"`
863  if (w) callLabel(w, [], label)
864  $.clock.after(1, () => void drawEmote($, name, title, why, image, who, start, form, replace))
865}
866
867async function drawEmote(
868  $: EngineInterface,
869  name: string,
870  title: string,
871  why: string,
872  image: string,
873  who: string,
874  start?: Change<Emote>,
875  form: Form = '',
876  replace?: MadeEmote,
877): Promise<void> {
878  const result = await drawn($, EMOTE_KIT, name, title, why, image, start, form, replace)
879  if (typeof result === 'string') return
880  const w = clawd.world
881  if (w) {
882    callActs(w, [{ who, what: result, delay: 0 }])
883    callLabel(w, [{ who, name: `${name} (${start ? 'changed' : replace ? 'drawn again' : 'new emote'}) "${result.title}"` }])
884  }
885  brain.recent = [`${who}: ${name}`, ...brain.recent].slice(0, 8)
886  $.ui.toast(start ? `Clawd's emote ${name} is changed` : replace ? `Clawd's emote ${name} is drawn again` : `Clawd has a new emote: ${name}`)
887}
888
889/**
890 * Who wears a new skin for the rest of the session: the person, as with
891 * /clawd skin <skin>, or the autopicker, whose skin a /clawd skin choice outranks.
892 */
893type SkinSlot = 'chosen' | 'picked'
894
895/**
896 * Starts drawing a skin in the background, as startEmote does an emote. A
897 * new skin is worn for the rest of the session, in `slot`; a changed or
898 * redrawn one comes on when it is the skin worn now.
899 */
900function startSkin(
901  $: EngineInterface,
902  name: string,
903  title: string,
904  why: string,
905  image: string,
906  start?: Change<Skin>,
907  replace?: MadeSkin,
908  slot: SkinSlot = 'chosen',
909): void {
910  brain.making = name
911  const w = clawd.world
912  const label = start ? ` · changing the skin ${name}: "${start.text}"` : replace ? ` · drawing the skin ${name} again` : ` · new skin coming: "${title}"`
913  if (w) callLabel(w, [], label)
914  $.clock.after(1, () => void drawSkin($, name, title, why, image, start, replace, slot))
915}
916
917async function drawSkin(
918  $: EngineInterface,
919  name: string,
920  title: string,
921  why: string,
922  image: string,
923  start: Change<Skin> | undefined,
924  replace: MadeSkin | undefined,
925  slot: SkinSlot,
926): Promise<void> {
927  const result = await drawn($, SKIN_KIT, name, title, why, image, start, '', replace)
928  if (typeof result === 'string') return
929  if (!start && !replace && slot === 'chosen') await update($, clawdSkin, () => name)
930  if (!start && !replace && slot === 'picked') await update($, clawdPickedSkin, () => name)
931  await applySkin($, true)
932  const w = clawd.world
933  if (w) callLabel(w, [], ` · skin ${name} (${start ? 'changed' : replace ? 'drawn again' : 'new'}) "${result.title}"`)
934  $.ui.toast(
935    start ? `Clawd's skin ${name} is changed` : replace ? `Clawd's skin ${name} is drawn again` : `Clawd wears its new skin ${name} for this session`,
936  )
937}
938
939/** Draws the look; on failure, says so in the band, a toast and /clawd, and answers why. */
940async function drawn<T extends { name: string; title: string }>(
941  $: EngineInterface,
942  kit: Kit<T>,
943  name: string,
944  title: string,
945  why: string,
946  image: string,
947  start?: Change<T>,
948  form: Form = '',
949  replace?: T & Dated,
950): Promise<(T & Dated) | string> {
951  let result: (T & Dated) | string
952  try {
953    result = await makeEmote($, kit, name, title, why, image, start, form, replace)
954  } catch (err) {
955    result = String(err)
956  } finally {
957    brain.making = ''
958  }
959  if (typeof result !== 'string') return result
960  failed($, start ? `${kit.kind} change` : replace ? `${kit.kind} redraw` : `new ${kit.kind}`, name, result)
961  return result
962}
963
964/** Says in the band, a toast and /clawd that making an act, an emote or a skin failed, and why. */
965function failed($: EngineInterface, what: string, name: string, why: string): void {
966  const w = clawd.world
967  if (w) callLabel(w, [], ` · ${what} failed`)
968  $.ui.log(`clawd: ${what} ${name} failed: ${why}`, { to: 'debug' })
969  $.ui.toast(`Clawd's ${what} ${name} failed; /clawd says why`)
970  brain.makeError = `${name}: ${why}`
971}
972
973/** With `/clawd trace`, keeps the pick in data/picks/<session id>.jsonl, the last TRACE_MAX. */
974async function tracePick($: EngineInterface, t: Trace): Promise<void> {
975  if (!(await read($, isTracing))) return
976  const path = `${mod.dataDir}/picks/${await $.session.id()}.jsonl`
977  let lines: string[] = []
978  try {
979    lines = (await $.fs.read(path)).split('\n').filter(Boolean)
980  } catch {
981    // the first pick traced in this session
982  }
983  lines = [...lines, JSON.stringify(t)].slice(-TRACE_MAX)
984  try {
985    await $.fs.write(path, `${lines.join('\n')}\n`)
986  } catch (err) {
987    $.ui.log(`clawd: trace not written: ${String(err)}`, { to: 'debug' })
988  }
989}
990
991/**
992 * With `/clawd debug`, shows the pick in the transcript, in
993 * rows the model never sees: the trigger, the model and its tokens; the request
994 * it was sent, one row per line, its parts left out while they are as last
995 * shown (clawd-context.ts); the whole reply; what came of it.
996 * A row shows no line breaks (it draws a glyph for each; tested live
997 * 2026-10-07), so the reply's whitespace is collapsed. `$.model.complete`
998 * returns the reply's text only, so the model's thinking cannot be shown.
999 */
1000async function debugPick($: EngineInterface, t: Trace, model: string, tokens: string): Promise<void> {
1001  if (!(await read($, isDebugging))) return
1002  $.ui.log(`Clawd debug · autopick, ${t.trigger} · ${model}${tokens}`)
1003  for (const row of contextRows(contextParts(t.system, t.prompt), brain.shown)) $.ui.log(row)
1004  $.ui.log(`reply: ${t.reply.replace(/\s+/g, ' ').trim() || '(none)'}`)
1005  $.ui.log(`→ ${t.outcome}`)
1006}
1007
1008async function localTime($: EngineInterface): Promise<string> {
1009  try {
1010    const r = await $.process.run(['date', '+%a %H:%M'], { timeoutMs: 3000 })
1011    return r.stdout.trim() || 'unknown'
1012  } catch {
1013    return 'unknown'
1014  }
1015}
1016
1017/**
1018 * What an emote does to Clawd, for the picker and the act writer. The title
1019 * of one that keeps the body often starts with "Clawd", so it is not put
1020 * after "turns into".
1021 */
1022function emoteWhat(e: Emote): string {
1023  if (e.body) return `Clawd turns into ${e.title}`
1024  const keys = e.prop?.path ?? []
1025  const [first, last] = [keys[0], keys.at(-1)]
1026  // Past y 10 a prop has left the band.
1027  const how = (last?.y ?? 0) >= 10 ? ' that flies off' : keys.some(k => k.x !== first?.x || k.y !== first?.y) ? ' that moves' : ''
1028  const parts = [e.tool && 'a tool in its hand', e.prop && `a thing beside it${how}`]
1029  return `Clawd keeps the body it has, with ${parts.filter(Boolean).join(' and ')}: ${e.title}`
1030}
1031
1032/**
1033 * The picker's prompt. With `mayWear` (no /clawd skin choice this session) it
1034 * lists the skins as calls and may ask for a new one; both are worn for the
1035 * rest of the session.
1036 */
1037function pickPrompt(
1038  w: World,
1039  trigger: string,
1040  made: MadeAct[],
1041  emotes: MadeEmote[],
1042  skins: MadeSkin[],
1043  mayWear: boolean,
1044  madeToday: number,
1045  now: number,
1046  time: string,
1047): string {
1048  const lines = brain.feed.map(l => l.text)
1049  const first = brain.feed[0]
1050  if (first && first.n > brain.seen + 1) lines.unshift(`(${first.n - brain.seen - 1} earlier lines dropped)`)
1051  const newest = made[0]
1052  const doing = (a: World['act']) => (a ? (a.routine ?? a.kind) : 'between acts')
1053  const minis = w.minis.filter(m => !m.isLeaving)
1054  const canMake = madeToday < NEW_PER_DAY
1055  const wearable = mayWear ? skins.filter(x => x.name !== mod.skin?.name) : []
1056  const body = mod.skin ? `wears the skin ${mod.skin.name} ("${oneLine(mod.skin.title, 60)}") this session` : 'in its own body'
1057  return [
1058    `<session_summary>${brain.summary || '(nothing yet)'}</session_summary>`,
1059    '<new_since_your_last_pick>',
1060    ...(lines.length > 0 ? lines : ['(nothing new)']),
1061    '</new_since_your_last_pick>',
1062    '<situation>',
1063    `trigger: ${trigger}`,
1064    `project: ${mod.project}`,
1065    `local time: ${time}`,
1066    `session running for ${ago(now - mod.startedAt)}; last session event ${ago((w.t - w.lastEventAt) * 1000)} ago`,
1067    `the session is ${w.mode === 'work' ? 'working' : 'idle'}; ${w.pile} bricks on the pile (one per tool call that does more than read)`,
1068    `your recent calls, newest first: ${brain.recent.join(', ') || 'none'}`,
1069    '</situation>',
1070    '<entities>',
1071    `clawd: Clawd itself, for the main session; ${body}; doing ${doing(w.act)}`,
1072    ...minis.map(
1073      m =>
1074        `${m.id}: a mini Clawd for the subagent "${oneLine(m.title, 60)}" (${m.kind}); running ` +
1075        `${ago((w.t - m.since) * 1000)}, ${m.tools} tool calls; doing ${doing(m.act)}`,
1076    ),
1077    '</entities>',
1078    `<newest_made_act>${
1079      newest
1080        ? `${newest.name}: "${newest.title}", made ${ago(now - Date.parse(newest.made))} ago in ${newest.project || '?'} because: ${newest.why || '?'}`
1081        : 'none yet'
1082    }</newest_made_act>`,
1083    '<acts>',
1084    ...Object.entries(PICKABLE).map(([name, what]) => `${name}: ${what}`),
1085    ...made.map(r => `${r.name}: ${r.title}`),
1086    ...emotes.map(e => `${e.name}: an emote, clawd only, ${e.dur} s: ${emoteWhat(e)}`),
1087    ...wearable.map(x => `${x.name}: a skin, clawd only, for the rest of the session: Clawd's body becomes ${x.title}`),
1088    '</acts>',
1089    '',
1090    'Return {"summary": "...", "calls": [{"who": "clawd", "play": "...", "delay": 0}], "why": "...", "new": null}.',
1091    '- summary: at most three sentences. Update the session summary with what is new: what the',
1092    '  session is about, how it is going, the mood. Keep it as it is when nothing is new.',
1093    '- calls: what to play, like parallel tool calls: one call, or several for several entities.',
1094    '  who: an id from <entities>, or "all". play: a name from <acts>. delay: seconds from now,',
1095    `  0 to ${MAX_DELAY_S}. Calls with the same delay start together; different delays stagger them,`,
1096    '  e.g. a wave that runs from clawd to a1 to a2. Several calls for one entity play one after',
1097    '  another; after an emote, the further calls for clawd play while it wears the look, so an emote',
1098    '  with a tool and then a wave waves the tool. A call cuts in on what the entity is doing, except',
1099    '  a reaction to a session event, which it waits for. Leave out entities that have no reason to',
1100    '  react. Vary the acts: avoid your recent calls unless one clearly fits best. Quiet moments suit',
1101    '  calm acts.',
1102    ...(wearable.length > 0 ? ['  A skin stays on for the rest of the session: call one only when the person asks Clawd to stay that.'] : []),
1103    '- why: a few words.',
1104    // Tuned with replays through Haiku (2026-10-03): the earlier "null, unless no
1105    // act fits" wording answered "hi snowman" with a wave, 0 of 18 nicknames.
1106    canMake
1107      ? '- new: first read what the person just wrote. When they call Clawd by a name, compare it to\n' +
1108        '  something or ask it for something ("hey pirate", "he looks like a teapot", "do a cartwheel"),\n' +
1109        '  they want to see Clawd become that thing or do that trick. If no act in <acts> shows exactly\n' +
1110        '  that, ask for one. Also ask when the session reaches a moment worth its own act. Asking is\n' +
1111        '  {"name": "snake_case_name", "title": "what it shows and when it fits, under 80 characters",\n' +
1112        '  "for": "<who plays it first>"}; otherwise new is null. Add "look": "become" when Clawd should\n' +
1113        '  look like that thing (a pirate, a teapot), or "look": "hold" when it should hold, use or show a\n' +
1114        '  thing (a guitar, a telescope, a cake), rather than do a trick: then it is drawn as a new emote.\n' +
1115        (mayWear
1116          ? '  Add "look": "skin" instead when they ask Clawd to stay that thing from now on ("be a cat from\n' +
1117            '  now on", "stay a pirate for the rest of the day"): then it is drawn as a new skin, which Clawd\n' +
1118            '  wears for the rest of the session.\n'
1119          : '') +
1120        '  A thing to hold is drawn on whatever body Clawd has. Ask no new look for what its skin shows.\n' +
1121        '  Add "image": "<path>" when the person wrote the path of a local image of that thing. calls\n' +
1122        '  still name acts from <acts>: play the closest one while the new act is written.\n' +
1123        `  ${madeToday} of at most ${NEW_PER_DAY} new acts, emotes and skins were made in the last 24 hours.`
1124      : `- new: null. The ${NEW_PER_DAY} new acts, emotes and skins for the last 24 hours are made.`,
1125  ].join('\n')
1126}
1127
1128/** A made act to change, and the change asked for (/clawd act change). */
1129type ActChange = { act: MadeAct; text: string }
1130
1131/** An act's steps as its file keeps them: the names of its emote and play steps, not what they were linked to. */
1132function plainSteps(r: Routine): Step[] {
1133  return r.steps.map(({ worn: _worn, inner: _inner, ...rest }) => rest)
1134}
1135
1136function makePrompt(name: string, title: string, why: string, made: Routine[], emotes: Emote[], skins: Skin[], change?: ActChange): string {
1137  const names = (items: { name: string; title: string }[]) => items.map(x => `${x.name} ("${oneLine(x.title, 60)}")`).join(', ') || 'none yet'
1138  return [
1139    change ? 'Change the steps of an act.' : 'Write the steps for a new act.',
1140    `name: ${name}`,
1141    `title: ${title}`,
1142    `why it was asked for: ${why}`,
1143    `the session so far: ${brain.summary || '(no summary)'}`,
1144    ...(change
1145      ? [
1146          `The act as it is now: ${JSON.stringify({ steps: plainSteps(change.act) })}`,
1147          `The change asked for now: "${change.text}". Make that change and keep what it does not touch.`,
1148        ]
1149      : []),
1150    '',
1151    "Clawd's own body is 6 cells wide and stands on a floor line; above its head is one row of room",
1152    '(two when it jumps). A skin may make it wider or taller. A mini Clawd, half its size, may play',
1153    'the act too. Steps play one after another;',
1154    'the act should last 3 to 12 seconds. A step is an',
1155    'object with "do" and optional fields:',
1156    '- idle {dur}: stands and looks around. wave {dur}. look {dur}: looks left and right.',
1157    '  think {dur}: looks up with a question mark.',
1158    '- wander {to}, run {to}: walks or runs to a spot, to from 0 (left edge) to 1 (right edge).',
1159    '  run {wrap true, dir -1|1, to}: a lap: runs off the edge in dir and straight back in from the',
1160    '  other edge, on to `to` (default: where it started).',
1161    '- move {to, speed, ease, wrap, dir}: goes to `to` at `speed`: walk, run, or a number of walking',
1162    '  speeds from 0.25 to 4 (run is 3). ease true speeds up from a stand and slows to a stop, about',
1163    '  0.4 s each way. wrap and dir work as for run. wander and run also take speed and ease.',
1164    '- hop {n 1-3, height 1-6, sparks true|false}: hops in place.',
1165    '- jump {dir -1|1, height 1-6}: a big jump sideways.',
1166    '- celebrate: two hops with sparks, then a wave. trip: trips, lies flat with dizzy stars, gets',
1167    '  up. chase: chases a sparkle and jumps to catch it. peek: runs off one edge and comes back',
1168    '  from the other. alert: an exclamation mark, eyes wide.',
1169    '- pose {dur 0.2-4, shape, sit}: holds still. shape: normal, squash, stretch or flat. sit: true',
1170    '  sits.',
1171    '- Any step may also take arms, eyes, look and up, which hold for that whole step, so',
1172    '  {"do": "run", "arms": "wave"} runs and waves. arms: out, up, down, wave, flail or none. eyes:',
1173    '  open, closed or wide. look: -1 back, 0 ahead, 1 forward. up: true looks up.',
1174    '- mark {glyph}: a glyph over its head for a moment, one of ! ? * + ~ z o ^ #.',
1175    '- sparks {n 1-8}: gold sparks around it.',
1176    '- emote {name, dur 1-20}: puffs into a look by name; the steps after it play in that look for',
1177    "  dur seconds (default: the look's own time). A mini skips it. A look either turns Clawd into",
1178    '  something else for that time, or keeps the body Clawd has and adds a thing: a tool in its front',
1179    '  hand, which moves with the arm (wave and arms up swing it), or a prop that stands where Clawd',
1180    '  was or moves off on its own. After a look that adds a thing, let the steps use it: wave or raise',
1181    '  the tool, hop beside the prop, step back and look at it. Looks:',
1182    ...(emotes.length > 0 ? emotes.map(e => `  ${e.name}: ${emoteWhat(e)}`) : ['  none yet']),
1183    `  Skins, which turn Clawd into them: ${names(skins)}.`,
1184    "- play {name}: plays a made act's steps here. Inside it, that act's own play steps are skipped.",
1185    `  Made acts: ${names(made)}.`,
1186    'At most 12 steps. Return {"steps": [...]}.',
1187  ].join('\n')
1188}
1189
1190/**
1191 * Opus writes the steps of a new act. With `change` it changes a made act's
1192 * steps; with `replace` it writes that act again from scratch. A changed or
1193 * rewritten act goes back into its file, the old file into data/acts/old/.
1194 * The act, or why there is none.
1195 */
1196async function makeAct(
1197  $: EngineInterface,
1198  name: string,
1199  title: string,
1200  why: string,
hooks/clawd-sim.ts 2548 lines
1// Clawd, the Claude Code logo, as a small creature living in the band above
2// the prompt, and a mini Clawd for each running subagent. This file is the
3// pure part: the world, the behaviours, and the drawing into Raster cells. It
4// never touches `$`; register.tsx drives it.
5//
6// Units: the band is a grid of sub-pixels, 2 x 2 per terminal cell, drawn
7// with the quadrant blocks the logo itself uses (▐▛███▜▌). x grows to the
8// right and `row` downwards. A body's height above the ground, `y`, grows
9// upwards. Nothing is a stored frame: every pose comes from the current
10// motion (speed, distance walked, vertical speed, time since landing).
11
12export const TICK_MS = 50
13const DT = TICK_MS / 1000
14const GRAVITY = 120 // sub-pixels per second squared
15export const WALK = 12 // sub-pixels per second
16const RUN = 36
17// A wiggly look's ripple: one stride per this many sub-pixels walked, twice as
18// many running, so a sprint's wave stays slower than the frames.
19const STRIDE = 8
20const EASE_S = 0.4 // an eased move takes this long to reach full speed, and to stop from it
21export const SLEEP_AFTER_S = 180 // idle seconds without any session event
22export const MINI_STALE_S = 900 // a mini whose subagent was silent this long leaves
23export const MAX_MINIS = 6 // minis on screen at once; more subagents get none
24export const MINI_GAP = 6 // sub-pixels a mini's stop keeps clear of Clawd's edge (besideClawd)
25export const MAX_DELAY_S = 5 // how far ahead a model's call may start
26const LAYERS = [4, 3, 2, 1] // bricks per pile layer, bottom up, as far as the band is tall
27/** The band's height in rows, and its height while a body does a big jump. */
28export const BAND_ROWS = 4
29export const TALL_ROWS = 5
30/** The band's height while Clawd wears a tall look and the Remote Control antenna. */
31export const ANTENNA_ROWS = 6
32const TALL_HOLD_S = 0.6 // the band stays tall this long after a big jump, so a second one does not resize it
33export const READ_HOLD_S = 1.2 // the scroll stays up this long after the last reading call
34
35const ORANGE = 0xd77757 // the logo's colour
36// One colour per mini, in turn, so the label can say which mini is which.
37const MINI_COLORS = [0x6fb3d2, 0x8fc96f, 0xc89be0, 0xe0c060, 0x5fc7b0, 0xe08fa8]
38const BODY_COLORS = new Set([ORANGE, ...MINI_COLORS])
39const BRICK = 0x8696a8
40const BRICK_DARK = 0x67788d
41const DUST = 0x8a8a9a
42const GOLD = 0xffc857
43const SNOOZE = 0xa8b4ff
44const WHITE = 0xf0f0f0
45const PAPER = 0xe8dcb8 // the scroll a reading body holds
46const ROLL = 0x8b5e34 // its rolled ends
47const SWEAT = 0x7fd4ff
48const ANTENNA = 0xa8a8a8 // the antenna Clawd wears while Remote Control is on
49const SIGNAL = 0x5fd47a // its tip
50const SIGNAL_DIM = 0x2f6b3d // its tip as it blinks
51const FLOOR = 0x7a7a7a // the floor line, grey like the prompt border
52export const FLOOR_TEXT = '#7a7a7a' // FLOOR as a Text color, for the menu's frame below the band
53const LABEL = 0x9a9aa8 // the line at the top right: what was picked
54const LABEL_SPARE = 4 // cells right of the label: the engine's `[-]` and a gap
55const DEFAULT = 0x01000000 // Raster: the terminal's own colour
56const EMPTY = -1
57
58// Quadrant glyph by mask: upper-left 1, upper-right 2, lower-left 4, lower-right 8.
59const QUAD = [
60  0x20, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b, 0x2597, 0x259a, 0x2590, 0x259c,
61  0x2584, 0x2599, 0x259f, 0x2588,
62]
63
64type Shape = 'normal' | 'squash' | 'stretch' | 'flat'
65type Arms = 'out' | 'up' | 'down' | 'wave' | 'flail' | 'none'
66type Eyes = 'open' | 'closed' | 'wide' | 'down' // down: reading, at the scroll; no routine or emote sets it
67type Size = { w: number; h: number; eyes: [number, number]; eyeRow: number; armRow: number }
68
69// Body sizes in sub-pixels. `normal` is the logo: 12 x 4 with eye holes at
70// x 2 and 9 on row 1, arms on row 2 and four one-pixel legs below.
71const SHAPES: Record<Shape, Size> = {
72  normal: { w: 12, h: 4, eyes: [2, 9], eyeRow: 1, armRow: 2 },
73  squash: { w: 14, h: 3, eyes: [3, 10], eyeRow: 1, armRow: 1 },
74  stretch: { w: 10, h: 5, eyes: [2, 7], eyeRow: 1, armRow: 2 },
75  flat: { w: 16, h: 2, eyes: [3, 12], eyeRow: 0, armRow: 1 },
76}
77// A mini is half the logo: 6 x 3 with eye holes at x 1 and 4 on row 1, one
78// pixel of arm a side and two legs.
79const MINI_SHAPES: Record<Shape, Size> = {
80  normal: { w: 6, h: 3, eyes: [1, 4], eyeRow: 1, armRow: 2 },
81  squash: { w: 8, h: 2, eyes: [2, 5], eyeRow: 0, armRow: 1 },
82  stretch: { w: 6, h: 4, eyes: [1, 4], eyeRow: 1, armRow: 2 },
83  flat: { w: 9, h: 1, eyes: [2, 6], eyeRow: 0, armRow: 0 },
84}
85
86export const ACTS = [
87  'idle', 'wander', 'run', 'hop', 'jump', 'celebrate', 'trip', 'sleep', 'wake', 'wave', 'look',
88  'think', 'chase', 'peek', 'carry', 'alert', 'kick', 'read',
89] as const
90// Acts only a routine plays: hold a pose, put a glyph over the head, throw
91// sparks, move at a chosen speed, play another made act.
92const STEP_ONLY = ['pose', 'mark', 'sparks', 'move', 'play'] as const
93// `leave`: a mini whose subagent finished runs off the band. `emote`: Clawd
94// takes an emote's look.
95export type ActKind = (typeof ACTS)[number] | (typeof STEP_ONLY)[number] | 'routine' | 'leave' | 'emote'
96
97/** The acts a model may pick, with what each looks like. */
98export const PICKABLE: Partial<Record<ActKind, string>> = {
99  idle: 'stands and looks around',
100  wander: 'walks somewhere nearby',
101  run: 'runs across the band',
102  hop: 'a small hop',
103  jump: 'a big jump sideways',
104  celebrate: 'two hops with sparks, then a wave',
105  trip: 'trips, lies flat with dizzy stars, gets up',
106  wave: 'waves',
107  look: 'looks left and right',
108  think: 'looks up with a question mark',
109  chase: 'chases a sparkle and jumps to catch it',
110  peek: 'runs off one edge and comes back from the other',
111  alert: 'an exclamation mark, eyes wide',
112}
113
114// What a routine step may be: the pickable acts, the step-only ones, and an emote by name.
115const STEP_KINDS = [...Object.keys(PICKABLE), ...STEP_ONLY, 'emote'] as ActKind[]
116const ARMS: Arms[] = ['out', 'up', 'down', 'wave', 'flail', 'none']
117const EYES: Eyes[] = ['open', 'closed', 'wide']
118const SHAPE_NAMES = ['normal', 'squash', 'stretch', 'flat'] as const
119// ASCII only: a glyph a terminal draws two cells wide would shift the row.
120const MARKS: Record<string, number> = { '!': 0xf0f0f0, '?': 0xf0f0f0, '*': 0xffc857, '+': 0xffc857, '~': 0x7fd4ff, z: 0xa8b4ff, o: 0x7fd4ff, '^': 0xf0f0f0, '#': 0xd77757 }
121export const MAX_STEPS = 12
122
123/** One step of a routine; `routineFrom` checks it and fills in nothing. */
124export type Step = {
125  do: ActKind
126  to?: number // wander, run, move: where to, 0 left edge to 1 right edge
127  dir?: number // jump, a run or move with wrap: -1 left, 1 right
128  n?: number // hop: how many; sparks: how many
129  height?: number // hop, jump: sub-pixels
130  dur?: number // idle, wave, look, think, pose: seconds; emote: how long the look stays
131  // arms, eyes, look and up apply on top of any step, so a run can wave
132  arms?: Arms
133  eyes?: Eyes
134  shape?: Shape // pose
135  look?: number // -1 back, 0 centre, 1 forward
136  up?: boolean // looks up
137  sit?: boolean // pose
138  glyph?: string // mark
139  sparks?: boolean // hop
140  wrap?: boolean // run, move: off the edge in `dir`, straight back in from the other one, on to `to`
141  speed?: 'walk' | 'run' | number // wander, run, move: a number counts walking speeds, run is 3
142  ease?: boolean // wander, run, move: speeds up from a stand and slows to a stop
143  name?: string // emote: the look to put on; play: the made act to play
144  worn?: Emote // emote: the look `name` found, filled in by `linkRoutine`
145  inner?: Step[] // play: the steps of the act `name` found, filled in by `linkRoutine`
146}
147
148/** A named act made of steps, written by a model and kept in data/acts/. */
149export type Routine = { name: string; title: string; steps: Step[] }
150
151/**
152 * A body drawn and animated the way Clawd is, from a JSON file. A shape is
153 * rows of characters: `#` body, `o` an eye (a hole while open), `+` the
154 * accent colour, anything else empty. As a skin, Clawd wears it all the time instead of its own body:
155 * from skins/ (drawn by hand) or data/skins/ (drawn by a model), chosen by
156 * clawd.json, the project folder or `/clawd skin`. Emotes and acts play on it.
157 */
158export type Skin = {
159  name: string
160  title: string
161  color: number
162  accent: number
163  shapes: Record<Shape, string[]> // a shape the file leaves out is the normal one
164  legs: number[] // leg columns, counted in the normal shape
165  legLength: number
166  isWiggly: boolean // the legs sway like tentacles and crawl in a ripple instead of stepping
167  armRow: number | null // the normal shape's row the arms grow from; null: no arms
168  armPoses: Record<ArmPose, [number, number][]> // [dx, dy] from the body's edge, as Clawd's
169  isTall: boolean // the band grows a row while Clawd wears it, as for a big jump
170}
171
172/**
173 * A look Clawd takes for a while, from a JSON file in
174 * emotes/ (drawn by hand) or data/emotes/ (drawn by a model). It turns Clawd
175 * into another body, or keeps the body Clawd has, its skin or its own, and
176 * adds a prop or a tool; it may do both.
177 */
178export type Emote = {
179  name: string
180  title: string
181  accent: number // the colour of `+` pixels in the body, the prop and the tool
182  isTall: boolean // the band grows a row while Clawd wears it, as for a big jump
183  body: Skin | null // the body it gives Clawd for its time; null: Clawd keeps the one it has
184  dur: number // seconds it stays on when played
185  prop: Prop | null
186  tool: Tool | null
187}
188
189/**
190 * A thing Clawd holds in its front hand while it wears the look. It has its own colours, so it
191 * does not read as a horn or a tail the way a tool drawn into the arms does.
192 * The first pixel of its bottom row sits one pixel past the front arm's tip,
193 * so it follows the arm's pose without turning.
194 */
195export type Tool = {
196  rows: string[] // `#` the tool's colour, `+` the emote's accent, anything else empty
197  color: number
198}
199
200/**
201 * An object beside Clawd that is not part of its body: it
202 * starts `x` sub-pixels from Clawd's centre, forward as Clawd faces when the
203 * look starts, and from then on keeps to its path, wherever Clawd goes.
204 */
205export type Prop = {
206  rows: string[] // `#` the prop's colour, `+` the emote's accent, anything else empty
207  color: number
208  x: number
209  path: PropKey[] // in time order; straight lines between keys, then the last key holds
210  hasTrail: boolean // sparks below it while it rises, a rocket's flame
211}
212
213/** `t` seconds after the look starts the prop is `x` forward and its bottom `y` above the floor. */
214type PropKey = { t: number; x: number; y: number }
215
216/** The arm positions an emote may draw its own way; the other arm acts are made of them. */
217type ArmPose = 'out' | 'up' | 'down' | 'raised'
218
219export type Act = {
220  kind: ActKind
221  t0: number // world time the current stage started
222  stage: number
223  dur?: number
224  to?: number
225  speed?: number
226  dir?: number // a run or move with wrap: -1 left, 1 right
227  wrap?: boolean // run, move: a lap through the edges
228  ease?: boolean // wander, run, move: an arrive, EASE_S each way
229  n?: number
230  height?: number
231  look?: number
232  next?: number
233  gaze?: number // a routine step's look, on top of what the act sets; idle uses `look` for its glances
234  arms?: Arms
235  sparks?: boolean
236  eyes?: Eyes
237  shape?: Shape
238  lookUp?: boolean
239  sit?: boolean
240  glyph?: string
241  steps?: Step[] // routine
242  routine?: string // the routine this act is, or is a step of
243  step?: number // the routine's step this act comes from, counted from 1
244  via?: string // the made act a play step plays, for its acts
245  emote?: Emote // emote
246  isPick?: boolean // a model's call
247  isReflex?: boolean // a reaction to a session event; a model's call waits for it
248  isRandom?: boolean // rolled from the idle tables; a read cuts it short
249}
250
251export type Particle = {
252  x: number
253  row: number
254  vx: number
255  vrow: number
256  fall: number // added to vrow per second
257  age: number
258  life: number
259  glyph: string
260  color: number
261}
262
263/** One creature: Clawd itself, or a mini Clawd for a subagent. */
264export type Body = {
265  id: string // 'clawd', or a mini's: a1, a2, ...
266  color: number
267  isMini: boolean
268  x: number // centre, sub-pixels
269  y: number // feet above the ground
270  vx: number
271  vy: number
272  facing: 1 | -1
273  walked: number // distance travelled; drives the gait
274  strides: number // a wiggly look's strides taken; drives its ripple
275  dustAt: number
276  landedAt: number
277  blinkAt: number
278  act: Act | null
279  queue: Act[]
280  // this tick's intent, set by the act
281  arms: Arms
282  eyes: Eyes
283  look: number // -1 back, 0 centre, 1 forward
284  lookUp: boolean
285  shape: Shape | null
286  sitting: boolean
287  reading: boolean // holds a scroll
288  carrying: boolean
289  wearing: Emote | null // the emote whose look it has
290  wornAt: number
291  wornUntil: number
292  propStart: { left: number; dir: 1 | -1 } | null // the prop's left column and the way the body faced, as the look started
293}
294
295/** A mini Clawd: it comes when its subagent starts and leaves when it ends. */
296export type Mini = Body & {
297  agentId: string
298  title: string // the Agent tool's description
299  kind: string // the subagent type
300  since: number // world time it came
301  tools: number // its subagent's tool calls
302  lastEventAt: number
303  isLeaving: boolean
304  isGone: boolean
305}
306
307/** What a body can be told to play: a built-in act, a routine, or an emote. */
308export type Playable = ActKind | Routine | Emote
309
310/** A model's call: `who` (a body's id, or 'all') plays `what` after `delay` seconds. */
311export type Call = { who: string; what: Playable; delay: number }
312
313/** A part of the label, in its own colour. */
314export type LabelPart = { text: string; color: number }
315
316/** The world is Clawd itself plus everything around it. */
317export type World = Body & {
318  cols: number
319  rows: number
320  t: number
321  rand: () => number
322  mode: 'idle' | 'work'
323  lastEventAt: number
324  tallUntil: number // the band stays tall until this time
325  pile: number
326  pileAt: number // left edge of the pile, an even sub-pixel x
327  spark: { x: number; row: number; vx: number; age: number; isFleeing: boolean } | null
328  particles: Particle[]
329  label: LabelPart[] // drawn at the top right of the band
330  edge: LabelPart[] | null // while /clawd is typed: the floor is the menu's top edge, with this title
331  minis: Mini[]
332  minisMade: number // for the minis' ids and colours
333  due: { at: number; who: string; acts: Act[] }[] // model calls waiting for their time
334  skin: Skin | null // the body Clawd has whenever no emote gives it another; null: its own
335  antenna: boolean // Remote Control is on: Clawd wears an antenna on whatever look it has
336}
337
338// --- setup -------------------------------------------------------------------
339
340function newBody(id: string, color: number, isMini: boolean, x: number): Body {
341  return {
342    id,
343    color,
344    isMini,
345    x,
346    y: 0,
347    vx: 0,
348    vy: 0,
349    facing: 1,
350    walked: 0,
351    strides: 0,
352    dustAt: 0,
353    landedAt: -1,
354    blinkAt: 2,
355    act: null,
356    queue: [],
357    arms: 'out',
358    eyes: 'open',
359    look: 0,
360    lookUp: false,
361    shape: null,
362    sitting: false,
363    reading: false,
364    carrying: false,
365    wearing: null,
366    wornAt: 0,
367    wornUntil: 0,
368    propStart: null,
369  }
370}
371
372export function createWorld(cols: number, rows: number, rand: () => number = Math.random): World {
373  const w: World = {
374    ...newBody('clawd', ORANGE, false, -12),
375    cols,
376    rows,
377    t: 0,
378    rand,
379    mode: 'idle',
380    lastEventAt: 0,
381    tallUntil: 0,
382    pile: 0,
383    pileAt: 0,
384    spark: null,
385    particles: [],
386    label: [],
387    edge: null,
388    minis: [],
389    minisMade: 0,
390    due: [],
391    skin: null,
392    antenna: false,
393  }
394  resize(w, cols, rows)
395  // Clawd walks in from the left and says hello.
396  w.queue.push(make(w, 'wander', { to: Math.round(cols * 0.5) }), make(w, 'wave', { dur: 1.2 }))
397  return w
398}
399
400export function resize(w: World, cols: number, rows: number): void {
401  // Rows count from the top, so when the band grows upwards the particles
402  // move down with the floor.
403  const shift = 2 * (rows - w.rows)
404  for (const p of w.particles) p.row += shift
405  if (w.spark) w.spark.row += shift
406  w.cols = cols
407  w.rows = rows
408  const W = cols * 2
409  // Even, about 80% across, with room on its right for Clawd to drop bricks.
410  w.pileAt = Math.max(0, 2 * Math.round(Math.min(W * 0.8, W - 34) / 2))
411  for (const c of bodies(w)) if (c.x > W + 16) c.x = W + 16
412}
413
414/** A deterministic random source for tests. */
415export function seeded(seed: number): () => number {
416  let s = seed >>> 0
417  return () => {
418    s = (s + 0x6d2b79f5) >>> 0
419    let r = Math.imul(s ^ (s >>> 15), 1 | s)
420    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r
421    return ((r ^ (r >>> 14)) >>> 0) / 4294967296
422  }
423}
424
425/** Clawd first, then the minis. */
426export function bodies(w: World): Body[] {
427  return [w, ...w.minis]
428}
429
430/** The body a model's call names: 'clawd' or a mini's id; none for a mini on its way out. */
431export function bodyOf(w: World, who: string): Body | undefined {
432  if (who === 'clawd') return w
433  return w.minis.find(m => m.id === who && !m.isLeaving)
434}
435
436function make(w: World, kind: ActKind, extra: Partial<Act> = {}): Act {
437  return { kind, t0: w.t, stage: 0, ...extra }
438}
439
440function reflex(w: World, kind: ActKind, extra: Partial<Act> = {}): Act {
441  return make(w, kind, { ...extra, isReflex: true })
442}
443
444function between(w: World, lo: number, hi: number): number {
445  return lo + w.rand() * (hi - lo)
446}
447
448function width(w: World): number {
449  return w.cols * 2
450}
451
452// The feet stand on the upper half of the band's last row, as in the logo
453// (`▘▘ ▝▝`), and the rest of that row is a floor line (`─`) for them to stand
454// on. Claude Code keeps a blank row above the prompt box that no mod reaches,
455// so the floor is as close to the prompt as Clawd gets.
456function ground(w: World): number {
457  return w.rows * 2 - 2
458}
459
460/** Sub-pixel rows above standing Clawd's head; jumps stay within them. */
461function headroom(w: World): number {
462  return Math.max(0, w.rows * 2 - 6)
463}
464
465/** How far a standing body's top is above its feet, in sub-pixels. */
466function bodyHeight(w: World, c: Body): number {
467  const look = shownLook(w, c)
468  if (look) return look.shapes.normal.length + look.legLength - 1
469  return c.isMini ? 3 : 4
470}
471
472/** Sub-pixel rows above a standing body's head; its jumps stay within them. */
473function roomAbove(w: World, c: Body): number {
474  return shownLook(w, c) ? Math.max(0, ground(w) - bodyHeight(w, c)) : headroom(w)
475}
476
477/**
478 * The body a body has now: its emote's, else Clawd's skin; null: its own. A
479 * tall skin shows in any band; in a terminal too short for the tall band its
480 * head is cut off at the top.
481 */
482function shownLook(w: World, c: Body): Skin | null {
483  return c.wearing?.body ?? (c === w ? w.skin : null)
484}
485
486function pileCapacity(w: World): number {
487  let n = 0
488  while (brickSpot(w, n)) n += 1
489  return n
490}
491
492function isAirborne(c: Body): boolean {
493  return c.y > 0 || c.vy > 0
494}
495
496// --- session events (called by register.tsx) ---------------------------------------
497
498/** Replaces what `c` is doing with `acts`; a carried brick falls off. */
499export function interrupt(w: World, c: Body, acts: Act[]): void {
500  if (c.carrying && acts[0]?.kind !== 'trip') dropBrick(w, c)
501  c.act = null
502  c.queue = [...acts, ...c.queue.filter(a => a.kind === 'carry')]
503}
504
505export function onPrompt(w: World): void {
506  const wasAsleep = w.act?.kind === 'sleep'
507  w.lastEventAt = w.t
508  w.mode = 'work'
509  const plan = wasAsleep ? [reflex(w, 'wake')] : [reflex(w, 'alert'), reflex(w, 'hop', { n: 1, height: 2 })]
510  if (w.pile > 0) plan.push(reflex(w, 'kick'))
511  interrupt(w, w, plan)
512}
513
514/**
515 * A tool call: a brick for the pile, a read when the call only read
516 * (`isReading`), or a trip when it failed. A subagent's call goes to its mini;
517 * without one, to Clawd.
518 */
519export function onTool(w: World, isError: boolean, agentId?: string, isReading = false): void {
520  const mini = agentId === undefined ? undefined : w.minis.find(m => m.agentId === agentId && !m.isLeaving)
521  const c: Body = mini ?? w
522  if (mini) {
523    mini.lastEventAt = w.t
524    mini.tools += 1
525  } else {
526    w.lastEventAt = w.t
527  }
528  if (isError) {
529    interrupt(w, c, [reflex(w, 'trip')])
530    return
531  }
532  if (!mini && w.mode !== 'work') return
533  if (isReading) {
534    read(w, c)
535    return
536  }
537  const pending = (b: Body) => b.queue.filter(a => a.kind === 'carry').length + (b.act?.kind === 'carry' ? 1 : 0)
538  const all = bodies(w).reduce((sum, b) => sum + pending(b), 0)
539  if (pending(c) < 2 && w.pile + all < pileCapacity(w)) c.queue.push(reflex(w, 'carry'))
540}
541
542// Reads in a row make one read that ends READ_HOLD_S after the last. A read
543// cuts an act from the idle tables short (not a peek, which may be off the
544// band) and waits behind everything else.
545function read(w: World, c: Body): void {
546  if (c.act?.kind === 'read') {
547    c.act.dur = Math.max(c.act.dur ?? 0, w.t - c.act.t0 + READ_HOLD_S)
548    return
549  }
550  if (c.queue.some(a => a.kind === 'read')) return
551  const act = reflex(w, 'read', { dur: READ_HOLD_S })
552  if (c.act?.isRandom && c.act.kind !== 'peek') {
553    c.act = null
554    c.queue.unshift(act)
555  } else {
556    c.queue.push(act)
557  }
558}
559
560export function onTurnEnd(w: World, reason: string): void {
561  w.lastEventAt = w.t
562  w.mode = 'idle'
563  w.queue = w.queue.filter(a => a.kind !== 'carry')
564  if (reason === 'error') interrupt(w, w, [reflex(w, 'trip')])
565  else if (reason === 'aborted') interrupt(w, w, [reflex(w, 'look', { dur: 1.5 }), reflex(w, 'think', { dur: 1.5 })])
566  else interrupt(w, w, [reflex(w, 'celebrate')])
567}
568
569/** A subagent started: its mini runs in from an edge. None past MAX_MINIS. */
570export function spawnMini(w: World, agentId: string, title: string, kind: string): Mini | null {
571  if (w.minis.filter(m => !m.isLeaving).length >= MAX_MINIS) return null
572  const n = w.minisMade
573  w.minisMade += 1
574  const W = width(w)
575  const fromLeft = w.rand() < 0.5
576  const mini: Mini = {
577    ...newBody(`a${n + 1}`, MINI_COLORS[n % MINI_COLORS.length] ?? ORANGE, true, fromLeft ? -8 : W + 8),
578    agentId,
579    title,
580    kind,
581    since: w.t,
582    tools: 0,
583    lastEventAt: w.t,
584    isLeaving: false,
585    isGone: false,
586  }
587  mini.queue.push(reflex(w, 'run', { to: besideClawd(w, between(w, 0.1, 0.7) * W) }), reflex(w, 'alert'))
588  w.minis.push(mini)
589  return mini
590}
591
592/**
593 * Where a mini headed for x stops instead, so it stands beside Clawd rather
594 * than behind it: x moved out of Clawd's width plus MINI_GAP, to the nearer
595 * side, or to the other one when the nearer side is off the band.
596 */
597export function besideClawd(w: World, x: number): number {
598  const W = width(w)
599  const reach = clawdWidth(w) / 2 + MINI_GAP
600  if (Math.abs(x - w.x) >= reach) return x
601  const left = w.x - reach
602  const right = w.x + reach
603  const sides = x < w.x ? [left, right] : [right, left]
604  return sides.find(to => to >= 8 && to <= W - 8) ?? x
605}
606
607/** Clawd's width in sub-pixels, of its look when it wears one. */
608function clawdWidth(w: World): number {
609  const rows = shownLook(w, w)?.shapes.normal
610  return rows ? Math.max(...rows.map(r => r.length)) : SHAPES.normal.w
611}
612
613/** A subagent ended: its mini celebrates (or trips) and runs off the band. */
614export function endMini(w: World, agentId: string, reason: string): Mini | undefined {
615  const mini = w.minis.find(m => m.agentId === agentId && !m.isLeaving)
616  if (mini) leave(w, mini, reason)
617  return mini
618}
619
620function leave(w: World, mini: Mini, reason: string): void {
621  mini.isLeaving = true
622  mini.queue = []
623  w.due = w.due.filter(d => d.who !== mini.id)
624  interrupt(w, mini, [reflex(w, reason === 'answer' ? 'celebrate' : 'trip'), reflex(w, 'leave')])
625}
626
627/** True while a body wants the tall band: during a big jump and shortly after. */
628export function wantsTall(w: World): boolean {
629  return w.t < w.tallUntil
630}
631
632/** The band's height in rows that the world wants now: a row more while tall, two with the antenna on a tall look. */
633export function bandRows(w: World): number {
634  if (!wantsTall(w)) return BAND_ROWS
635  return w.antenna && shownLook(w, w)?.isTall ? ANTENNA_ROWS : TALL_ROWS
636}
637
638/** `/clawd [who] <act> [n]`: do it now, `n` times in a row; false when `who` is not there. */
639export function perform(w: World, what: Playable, n = 1, who = 'clawd'): boolean {
640  const c = bodyOf(w, who)
641  if (!c) return false
642  if (c === w) w.lastEventAt = w.t
643  interrupt(w, c, Array.from({ length: n }, () => actFor(w, what)))
644  return true
645}
646
647/**
648 * A model's calls, like parallel tool calls: each named body (or 'all') plays
649 * its calls in the order given, starting at the smallest `delay` (0 to 5 s)
650 * among them. At its time a call cuts in on what the body does, except a
651 * reaction to a session event, which it waits for. Calls replace a body's
652 * earlier calls still waiting. Answers the ids that got calls.
653 */
654export function callActs(w: World, calls: Call[]): string[] {
655  const slots = new Map<string, { at: number; acts: Act[] }>()
656  for (const call of calls) {
657    const targets = call.who === 'all' ? bodies(w).filter(b => bodyOf(w, b.id)) : [bodyOf(w, call.who)]
658    const at = w.t + Math.min(MAX_DELAY_S, Math.max(0, Number.isFinite(call.delay) ? call.delay : 0))
659    for (const b of targets) {
660      if (!b) continue
661      const slot = slots.get(b.id) ?? { at, acts: [] }
662      slot.at = Math.min(slot.at, at)
663      slot.acts.push(actFor(w, call.what, { isPick: true }))
664      slots.set(b.id, slot)
665    }
666  }
667  w.due = w.due.filter(d => !slots.has(d.who))
668  for (const [who, slot] of slots) {
669    const b = bodyOf(w, who)
670    if (b) b.queue = b.queue.filter(a => !a.isPick)
671    w.due.push({ at: slot.at, who, acts: slot.acts })
672  }
673  startDue(w)
674  return [...slots.keys()]
675}
676
677// Starts the calls whose time has come.
678function startDue(w: World): void {
679  if (!w.due.some(d => d.at <= w.t + 1e-9)) return
680  const now = w.due.filter(d => d.at <= w.t + 1e-9)
681  w.due = w.due.filter(d => d.at > w.t + 1e-9)
682  for (const d of now) {
683    const b = bodyOf(w, d.who)
684    if (!b) continue
685    // Reactions to session events go first, bricks to fetch after the call.
686    const ahead = b.queue.filter(a => a.isReflex && a.kind !== 'carry')
687    const after = b.queue.filter(a => !a.isPick && !(a.isReflex && a.kind !== 'carry'))
688    b.queue = [...ahead, ...d.acts, ...after]
689    if (!b.act?.isReflex && ahead.length === 0) b.act = null
690  }
691}
692
693export function isAsleep(w: World): boolean {
694  return w.act?.kind === 'sleep'
695}
696
697/** The text with only characters one cell wide (Latin-1 and Latin Extended). */
698export function clean(text: string): string {
699  return text.replace(/[^\x20-\x7e -ɏ]/g, '')
700}
701
702/** The line at the top right of the band; only characters one cell wide. */
703export function setLabel(w: World, label: string | LabelPart[]): void {
704  const parts = typeof label === 'string' ? [{ text: label, color: LABEL }] : label
705  w.label = parts.map(p => ({ text: clean(p.text), color: p.color })).filter(p => p.text)
706  const first = w.label[0]
707  if (first) first.text = first.text.trimStart()
708  const last = w.label[w.label.length - 1]
709  if (last) last.text = last.text.trimEnd()
710}
711
712/** The floor as the top edge of the `/clawd` menu: the title in the terminal's colour, the note dim; null for the plain floor. */
713export function setEdge(w: World, title: string | null, note = ''): void {
714  if (title === null) w.edge = null
715  else w.edge = [{ text: clean(title), color: DEFAULT }, { text: note ? clean(` · ${note}`) : '', color: LABEL }].filter(p => p.text)
716}
717
718/**
719 * The label for a model's calls: `clawd: wave · a1: hop+peek`, each part in
720 * its body's colour, then `note` in grey.
721 */
722export function callLabel(w: World, calls: { who: string; name: string }[], note = ''): void {
723  const byWho = new Map<string, string[]>()
724  for (const c of calls) byWho.set(c.who, [...(byWho.get(c.who) ?? []), c.name])
725  const parts: LabelPart[] = []
726  for (const [who, names] of byWho) {
727    if (parts.length > 0) parts.push({ text: ' · ', color: LABEL })
728    const b = who === 'all' ? undefined : w.minis.find(m => m.id === who) ?? (who === 'clawd' ? w : undefined)
729    parts.push({ text: `${who}: ${names.join('+')}`, color: b?.color ?? LABEL })
730  }
731  // Without calls, a note starts the label, so it loses its leading ` · `.
732  if (note) parts.push({ text: parts.length > 0 ? note : note.replace(/^ · /, ''), color: LABEL })
733  setLabel(w, parts)
734}
735
736function actFor(w: World, what: Playable, extra: Partial<Act> = {}): Act {
737  if (typeof what === 'string') return make(w, what, extra)
738  if ('steps' in what) return make(w, 'routine', { routine: what.name, steps: what.steps, ...extra })
739  return make(w, 'emote', { emote: what, ...extra })
740}
741
742/**
743 * Checks a routine a model wrote: a snake_case name, a title, and 1 to 12
744 * steps of known acts. Numbers are clamped and unknown fields dropped; the
745 * answer is the routine, or what is wrong with it.
746 */
747export function routineFrom(raw: unknown): Routine | string {
748  if (typeof raw !== 'object' || raw === null) return 'not an object'
749  const r = raw as Record<string, unknown>
750  const name = typeof r.name === 'string' ? r.name : ''
751  if (!/^[a-z][a-z0-9_]{1,30}$/.test(name)) return `bad name ${JSON.stringify(r.name)}`
752  if ((ACTS as readonly string[]).includes(name) || STEP_KINDS.includes(name as ActKind)) return `${name} is a built-in act`
753  const title = typeof r.title === 'string' ? r.title.trim().slice(0, 80) : ''
754  if (!title) return 'no title'
755  if (!Array.isArray(r.steps) || r.steps.length === 0) return 'no steps'
756  const steps: Step[] = []
757  for (const raw of r.steps.slice(0, MAX_STEPS)) {
758    const s = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>
759    const kind = s.do as ActKind
760    if (!STEP_KINDS.includes(kind)) return `unknown step ${JSON.stringify(s.do)}`
761    const num = (v: unknown, lo: number, hi: number) =>
762      typeof v === 'number' && Number.isFinite(v) ? Math.min(hi, Math.max(lo, v)) : undefined
763    const one = <T>(v: unknown, from: readonly T[]) => (from.includes(v as T) ? (v as T) : undefined)
764    const step: Step = { do: kind }
765    const set = <K extends keyof Step>(key: K, value: Step[K] | undefined) => {
766      if (value !== undefined) step[key] = value
767    }
768    set('to', num(s.to, 0, 1))
769    set('dir', s.dir === -1 || s.dir === 1 ? s.dir : undefined)
770    set('n', num(s.n, 1, kind === 'sparks' ? 8 : 3))
771    set('height', num(s.height, 1, 6))
772    set('dur', kind === 'emote' ? num(s.dur, 1, 20) : num(s.dur, 0.2, 4))
773    set('arms', one(s.arms, ARMS))
774    set('eyes', one(s.eyes, EYES))
775    set('shape', one(s.shape, SHAPE_NAMES))
776    set('look', s.look === -1 || s.look === 0 || s.look === 1 ? s.look : undefined)
777    set('up', typeof s.up === 'boolean' ? s.up : undefined)
778    set('sit', typeof s.sit === 'boolean' ? s.sit : undefined)
779    set('sparks', typeof s.sparks === 'boolean' ? s.sparks : undefined)
780    set('wrap', typeof s.wrap === 'boolean' ? s.wrap : undefined)
781    set('speed', s.speed === 'walk' || s.speed === 'run' ? s.speed : num(s.speed, 0.25, 4))
782    set('ease', typeof s.ease === 'boolean' ? s.ease : undefined)
783    if (kind === 'mark') {
784      if (typeof s.glyph !== 'string' || !(s.glyph in MARKS)) return `bad glyph ${JSON.stringify(s.glyph)}`
785      step.glyph = s.glyph
786    }
787    if (kind === 'emote' || kind === 'play') {
788      if (typeof s.name !== 'string' || !/^[a-z][a-z0-9_]{1,30}$/.test(s.name)) return `bad ${kind} name ${JSON.stringify(s.name)}`
789      if (kind === 'play' && s.name === name) return `${name} plays itself`
790      step.name = s.name
791    }
792    steps.push(step)
793  }
794  return { name, title, steps }
795}
796
797const EMOTE_MAX_W = 20 // sub-pixels, the arms come on top
798// Clawd's arms: [dx, dy] from the body's edge on the arm row.
799const CLAWD_ARMS: Record<ArmPose, [number, number][]> = {
800  out: [[1, 0], [2, 0]],
801  up: [[1, -1], [2, -2]],
802  down: [[1, 1]],
803  raised: [[1, -1], [1, -2]],
804}
805// High with the legs, so a sub-pixel stays free above the head: in the 4-row
806// band, and in the 5-row band for a tall emote.
807const EMOTE_MAX_H = 6
808const TALL_EMOTE_MAX_H = 8
809const PROP_MAX_W = 8
810const PROP_MAX_KEYS = 8
811const PROP_MAX_Y = 24 // sub-pixels; past about 10 the prop has left the band
812const TOOL_MAX_W = 8
813const TOOL_MAX_H = 4
814
815const colorOf = (v: unknown) => (typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v) ? Number.parseInt(v.slice(1), 16) : undefined)
816
817// Rows of pixel characters: only the `marks` count, the rest become `.`, and
818// empty rows above and below go.
819function pixelRows(v: unknown, marks: string): string[] | undefined {
820  if (!Array.isArray(v) || !v.every(line => typeof line === 'string')) return undefined
821  const other = new RegExp(`[^${marks}]`, 'g')
822  const lines = (v as string[]).map(line => line.replace(other, '.'))
823  const isEmpty = (line: string | undefined) => line !== undefined && !/[^.]/.test(line)
824  while (isEmpty(lines[0])) lines.shift()
825  while (isEmpty(lines[lines.length - 1])) lines.pop()
826  return lines.length > 0 ? lines : undefined
827}
828
829/** Checks an emote's prop; the prop, or what is wrong with it. */
830function propOf(raw: unknown, isTall: boolean, dur: number, accent: number): Prop | string {
831  if (typeof raw !== 'object' || raw === null) return 'not an object'
832  const r = raw as Record<string, unknown>
833  const rows = pixelRows(r.rows, '#+')
834  if (!rows) return 'no rows'
835  const wide = Math.max(...rows.map(line => line.length))
836  if (wide > PROP_MAX_W) return `rows are ${wide} wide, at most ${PROP_MAX_W}`
837  const maxH = isTall ? TALL_EMOTE_MAX_H : EMOTE_MAX_H
838  if (rows.length > maxH) return `rows are ${rows.length} high, at most ${maxH}`
839  const color = r.color === undefined ? accent : colorOf(r.color)
840  if (color === undefined) return `bad color ${JSON.stringify(r.color)}, want #rrggbb`
841  const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
842  const keys = Array.isArray(r.path) ? r.path : []
843  if (keys.length === 0) return 'no path'
844  if (keys.length > PROP_MAX_KEYS) return `path has ${keys.length} keys, at most ${PROP_MAX_KEYS}`
845  const path: PropKey[] = []
846  for (const key of keys) {
847    const k = (typeof key === 'object' && key !== null ? key : {}) as Record<string, unknown>
848    const t = num(k.t)
849    const y = num(k.y)
850    if (t === undefined || y === undefined) return 'a path key needs t and y'
851    if (t < 0 || t > dur) return `path t ${t} is outside 0 to dur (${dur})`
852    if (t < (path[path.length - 1]?.t ?? 0)) return 'path keys are not in time order'
853    path.push({ t, x: Math.min(40, Math.max(-40, num(k.x) ?? 0)), y: Math.min(PROP_MAX_Y, Math.max(0, y)) })
854  }
855  const x = Math.min(30, Math.max(-30, Math.round(num(r.x) ?? -12)))
856  return { rows, color, x, path, hasTrail: r.trail === true }
857}
858
859/** Checks an emote's tool; the tool, or what is wrong with it. */
860function toolOf(raw: unknown, accent: number): Tool | string {
861  if (typeof raw !== 'object' || raw === null) return 'not an object'
862  const r = raw as Record<string, unknown>
863  const rows = pixelRows(r.rows, '#+')
864  if (!rows) return 'no rows'
865  const wide = Math.max(...rows.map(line => line.length))
866  if (wide > TOOL_MAX_W) return `rows are ${wide} wide, at most ${TOOL_MAX_W}`
867  if (rows.length > TOOL_MAX_H) return `rows are ${rows.length} high, at most ${TOOL_MAX_H}`
868  const color = r.color === undefined ? accent : colorOf(r.color)
869  if (color === undefined) return `bad color ${JSON.stringify(r.color)}, want #rrggbb`
870  return { rows, color }
871}
872
873/** A look's snake_case name that is no built-in act, and its title; or what is wrong with them. */
874function nameOf(r: Record<string, unknown>): { name: string; title: string } | string {
875  const name = typeof r.name === 'string' ? r.name : ''
876  if (!/^[a-z][a-z0-9_]{1,30}$/.test(name)) return `bad name ${JSON.stringify(r.name)}`
877  if ((ACTS as readonly string[]).includes(name) || STEP_KINDS.includes(name as ActKind)) return `${name} is a built-in act`
878  const title = typeof r.title === 'string' ? r.title.trim().slice(0, 80) : ''
879  if (!title) return 'no title'
880  return { name, title }
881}
882
883/**
884 * Checks a skin from a file or a model: a snake_case name that is no
885 * built-in act, a title, a colour, and a normal shape that fits the band.
886 * Shapes it leaves out are the normal one; numbers are clamped. The answer is
887 * the skin, or what is wrong with it.
888 */
889export function skinFrom(raw: unknown): Skin | string {
890  if (typeof raw !== 'object' || raw === null) return 'not an object'
891  const r = raw as Record<string, unknown>
892  const named = nameOf(r)
893  if (typeof named === 'string') return named
894  const { name, title } = named
895  const color = colorOf(r.color)
896  if (color === undefined) return `bad color ${JSON.stringify(r.color)}, want #rrggbb`
897  const raws = (typeof r.shapes === 'object' && r.shapes !== null ? r.shapes : {}) as Record<string, unknown>
898  // Only body, eye and accent pixels count.
899  const rowsOf = (v: unknown) => pixelRows(v, '#o+')
900  const normal = rowsOf(raws.normal)
901  if (!normal) return 'no normal shape'
902  const legLength = typeof r.legLength === 'number' && Number.isFinite(r.legLength) ? Math.min(3, Math.max(0, Math.round(r.legLength))) : 1
903  const isTall = r.tall === true
904  const maxH = isTall ? TALL_EMOTE_MAX_H : EMOTE_MAX_H
905  const shapes = { normal } as Record<Shape, string[]>
906  for (const shape of SHAPE_NAMES) {
907    const lines = shape === 'normal' ? normal : (rowsOf(raws[shape]) ?? normal)
908    const wide = Math.max(...lines.map(line => line.length))
909    if (wide > EMOTE_MAX_W) return `${shape} is ${wide} wide, at most ${EMOTE_MAX_W}`
910    const high = lines.length + (shape === 'normal' || shape === 'stretch' ? legLength : 0)
911    if (high > maxH) return `${shape} is ${high} high with its legs, at most ${maxH}${isTall ? '' : ' (8 with "tall": true)'}`
912    shapes[shape] = lines
913  }
914  const nw = Math.max(...normal.map(line => line.length))
915  const legs = (Array.isArray(r.legs) ? r.legs : [])
916    .filter((x): x is number => typeof x === 'number' && Number.isInteger(x) && x >= 0 && x < nw)
917    .slice(0, 8)
918  const armRow = typeof r.armRow === 'number' && Number.isInteger(r.armRow) && r.armRow >= 0 && r.armRow < normal.length ? r.armRow : null
919  // Arm poses the file gives replace Clawd's: up to 4 pixels each, within 3 of the edge, in one
920  // line: the first touches the edge, each next one the one before. Any other pose keeps Clawd's,
921  // since a fork drawn as a tool reads as horns or a tail.
922  const arms = (typeof r.arms === 'object' && r.arms !== null ? r.arms : {}) as Record<string, unknown>
923  const armPoses = { ...CLAWD_ARMS }
924  for (const pose of Object.keys(CLAWD_ARMS) as ArmPose[]) {
925    const v = arms[pose]
926    if (!Array.isArray(v)) continue
927    const pixels = v
928      .filter(
929        (p): p is [number, number] =>
930          Array.isArray(p) && p.length === 2 && p.every(n => Number.isInteger(n) && Math.abs(n) <= 3),
931      )
932      .slice(0, 4)
933    const isLine = pixels.every(([dx, dy], i) => {
934      const [px, py] = pixels[i - 1] ?? [0, 0]
935      return Math.abs(dx - px) <= 1 && Math.abs(dy - py) <= 1
936    })
937    if (pixels.length > 0 && isLine) armPoses[pose] = pixels
938  }
939  const accent = colorOf(r.accent) ?? WHITE
940  return { name, title, color, accent, shapes, legs, legLength, isWiggly: r.wiggle === true, armRow, armPoses, isTall }
941}
942
943/**
944 * Checks an emote from a file or a model: a time, and a body as a skin has
945 * (with `shapes`), a prop, a tool, or several of them. Without shapes Clawd
946 * keeps the body it has. The answer is the emote, or what is wrong with it.
947 */
948export function emoteFrom(raw: unknown): Emote | string {
949  if (typeof raw !== 'object' || raw === null) return 'not an object'
950  const r = raw as Record<string, unknown>
951  const named = nameOf(r)
952  if (typeof named === 'string') return named
953  const body = r.shapes === undefined || r.shapes === null ? null : skinFrom(r)
954  if (typeof body === 'string') return body
955  const accent = body?.accent ?? colorOf(r.accent) ?? WHITE
956  const isTall = r.tall === true
957  const dur = typeof r.dur === 'number' && Number.isFinite(r.dur) ? Math.min(20, Math.max(3, r.dur)) : 8
958  const prop = r.prop === undefined || r.prop === null ? null : propOf(r.prop, isTall, dur, accent)
959  if (typeof prop === 'string') return `prop: ${prop}`
960  const tool = r.tool === undefined || r.tool === null ? null : toolOf(r.tool, accent)
961  if (typeof tool === 'string') return `tool: ${tool}`
962  if (!body && !prop && !tool) return 'no shapes, prop or tool: an emote changes the body, adds a thing, or both'
963  return { ...named, accent, isTall, body, dur, prop, tool }
964}
965
966/** A skin worn for a while, as an act's emote step may ask: an emote with the skin's body and nothing else. */
967export function skinAsEmote(skin: Skin): Emote {
968  return { name: skin.name, title: skin.title, accent: skin.accent, isTall: skin.isTall, body: skin, dur: 8, prop: null, tool: null }
969}
970
971/**
972 * Whether an emote's body is a copy of Clawd's own: the logo's normal shape
973 * in about its orange. A model that draws a thing to hold tends to copy the
974 * body along with it, and the copy then hides any skin (three emotes had one
975 * until 2026-10-08). A recoloured Clawd is not a copy.
976 */
977export function isClawdCopy(look: Emote): boolean {
978  if (!look.body) return false
979  const { w, h, eyes, eyeRow } = SHAPES.normal
980  const own = Array.from({ length: h }, (_, r) => Array.from({ length: w }, (_, x) => (r === eyeRow && eyes.includes(x) ? 'o' : '#')).join(''))
981  const near = (a: number, b: number) => [16, 8, 0].every(k => Math.abs(((a >> k) & 255) - ((b >> k) & 255)) <= 24)
982  return JSON.stringify(look.body.shapes.normal) === JSON.stringify(own) && near(look.body.color, ORANGE)
983}
984
985/** The colour of an emote's puff: its body's, else its tool's or prop's. */
986function emoteColor(look: Emote): number {
987  return look.body?.color ?? look.tool?.color ?? look.prop?.color ?? look.accent
988}
989
990const hex = (n: number) => `#${n.toString(16).padStart(6, '0')}`
991
992/** A skin as JSON, the way skinFrom reads it. */
993export function skinJson(look: Skin): Record<string, unknown> {
994  return {
995    name: look.name,
996    title: look.title,
997    color: hex(look.color),
998    accent: hex(look.accent),
999    shapes: look.shapes,
1000    legs: look.legs,
1001    legLength: look.legLength,
1002    wiggle: look.isWiggly,
1003    armRow: look.armRow,
1004    arms: look.armPoses,
1005    tall: look.isTall,
1006  }
1007}
1008
1009/** An emote as JSON, the way emoteFrom reads it. */
1010export function emoteJson(look: Emote): Record<string, unknown> {
1011  const head = look.body ? skinJson(look.body) : { name: look.name, title: look.title, accent: hex(look.accent), tall: look.isTall }
1012  return {
1013    ...head,
1014    dur: look.dur,
1015    ...(look.prop
1016      ? { prop: { rows: look.prop.rows, color: hex(look.prop.color), x: look.prop.x, path: look.prop.path, trail: look.prop.hasTrail } }
1017      : {}),
1018    ...(look.tool ? { tool: { rows: look.tool.rows, color: hex(look.tool.color) } } : {}),
1019  }
1020}
1021
1022/**
1023 * Fills in what a routine's emote and play steps name; `find` answers a made
1024 * act or an emote by name. The played act's own play steps are dropped, so
1025 * acts play each other one level deep. A step whose name finds nothing, or
1026 * the wrong kind, stays empty and plays nothing.
1027 */
1028export function linkRoutine<R extends Routine>(r: R, find: (name: string) => Playable | undefined, isInner = false): R {
1029  const steps = r.steps.map(s => {
1030    if (s.do !== 'emote' && s.do !== 'play') return s
1031    const step: Step = { ...s }
1032    delete step.worn
1033    delete step.inner
1034    const found = s.name ? find(s.name) : undefined
1035    if (typeof found !== 'object') return step
1036    if (s.do === 'emote' && !('steps' in found)) step.worn = found
1037    if (s.do === 'play' && !isInner && 'steps' in found) step.inner = linkRoutine(found, find, true).steps
1038    return step
1039  })
1040  return { ...r, steps }
1041}
1042
1043/** A routine's steps as acts, in the units the acts use; a play step becomes the acts of the steps it plays. */
1044function stepActs(w: World, a: Act, steps = a.steps ?? [], outer?: { step: number; via: string }): Act[] {
1045  return steps.flatMap((s, i) => {
1046    if (s.do === 'play') return outer || !s.inner ? [] : stepActs(w, a, s.inner, { step: i + 1, via: s.name ?? '' })
1047    if (s.do === 'emote' && !s.worn) return []
1048    const act = make(w, s.do, { routine: a.routine, step: outer?.step ?? i + 1, isPick: a.isPick, isReflex: a.isReflex })
1049    if (outer) act.via = outer.via
1050    if (s.worn) act.emote = s.worn
1051    const isMove = s.do === 'wander' || s.do === 'run' || s.do === 'move'
1052    if (s.to !== undefined && isMove) act.to = s.to * width(w)
1053    if (s.dir !== undefined && s.do === 'jump') act.to = s.dir
1054    if (s.wrap && (s.do === 'run' || s.do === 'move')) {
1055      act.wrap = true
1056      if (s.dir !== undefined) act.dir = s.dir
1057    }
1058    if (s.speed !== undefined && isMove) act.speed = s.speed === 'walk' ? WALK : s.speed === 'run' ? RUN : s.speed * WALK
1059    if (s.ease && isMove) act.ease = true
1060    if (s.n !== undefined) act.n = Math.round(s.n)
1061    if (s.height !== undefined) act.height = s.height
1062    if (s.dur !== undefined) act.dur = s.dur
1063    if (s.arms) act.arms = s.arms
1064    if (s.eyes) act.eyes = s.eyes
1065    if (s.shape) act.shape = s.shape
1066    if (s.look !== undefined) act.gaze = s.look
1067    if (s.up) act.lookUp = true
1068    if (s.sit) act.sit = true
1069    if (s.sparks) act.sparks = true
1070    if (s.glyph) act.glyph = s.glyph
1071    return [act]
1072  })
1073}
1074
1075// --- the tick --------------------------------------------------------------------
1076
1077export function step(w: World): void {
1078  w.t += DT
1079  startDue(w)
1080  for (const c of bodies(w)) {
1081    c.arms = 'out'
1082    c.eyes = 'open'
1083    c.look = 0
1084    c.lookUp = false
1085    c.shape = null
1086    c.sitting = false
1087    c.reading = false
1088    if (!isAirborne(c)) c.vx = 0
1089    const act = (c.act ??= nextAct(w, c))
1090    if (isBigJump(act)) w.tallUntil = w.t + TALL_HOLD_S
1091    if (runAct(w, c, act)) c.act = null
1092    overlay(c, act)
1093    physics(w, c)
1094    if (c.wearing?.prop) moveProp(w, c, c.wearing.prop)
1095    if (c.wearing && w.t >= c.wornUntil && !isAirborne(c)) {
1096      puff(w, c, emoteColor(c.wearing))
1097      const prop = c.wearing.prop
1098      if (prop) {
1099        // A prop left on the floor breaks apart like a mined block; one still in the air goes in a puff.
1100        const box = propBox(w, c, prop)
1101        if (box.bottom >= ground(w)) breakProp(w, c, c.wearing, prop)
1102        else if (box.bottom >= 0) puffAt(w, box.left + box.width / 2, box.bottom - box.height + 1, box.height, box.width / 2 + 2, prop.color)
1103      }
1104      c.wearing = null
1105    }
1106    // A tall look keeps the band tall; a tall skin all the time, so the prompt does not move between looks.
1107    if (c.wearing?.isTall || shownLook(w, c)?.isTall) w.tallUntil = w.t + TALL_HOLD_S
1108  }
1109  if (w.antenna) w.tallUntil = w.t + TALL_HOLD_S // the antenna's tip needs the second row of headroom
1110  moveParticles(w)
1111  moveSpark(w)
1112  for (const c of bodies(w)) {
1113    if (w.t > c.blinkAt + 0.12) c.blinkAt = w.t + between(w, 2, 6)
1114  }
1115  for (const m of w.minis) {
1116    if (!m.isLeaving && w.t - m.lastEventAt > MINI_STALE_S) leave(w, m, 'stale')
1117  }
1118  w.minis = w.minis.filter(m => !m.isGone)
1119}
1120
1121const WORK_TABLE: [ActKind, number][] = [['wander', 22], ['run', 18], ['think', 18], ['look', 10], ['idle', 10], ['hop', 6], ['jump', 6], ['chase', 4], ['peek', 3], ['wave', 3]]
1122const IDLE_TABLE: [ActKind, number][] = [['wander', 28], ['idle', 26], ['look', 10], ['wave', 5], ['hop', 6], ['jump', 5], ['run', 7], ['chase', 6], ['peek', 4], ['think', 3]]
1123const MINI_TABLE: [ActKind, number][] = [['wander', 28], ['think', 18], ['idle', 16], ['look', 12], ['run', 10], ['hop', 8], ['jump', 4], ['wave', 4]]
1124
1125function nextAct(w: World, c: Body): Act {
1126  const queued = c.queue.shift()
1127  if (queued) return { ...queued, t0: w.t, stage: 0 }
1128  if (!c.isMini && w.mode === 'idle' && w.t - w.lastEventAt > SLEEP_AFTER_S) return make(w, 'sleep')
1129  const table = c.isMini ? MINI_TABLE : w.mode === 'work' ? WORK_TABLE : IDLE_TABLE
1130  let roll = w.rand() * table.reduce((sum, [, weight]) => sum + weight, 0)
1131  for (const [kind, weight] of table) {
1132    roll -= weight
1133    if (roll <= 0) return make(w, kind, { isRandom: true })
1134  }
1135  return make(w, 'idle', { isRandom: true })
1136}
1137
1138// The jump act, the sparkle chase and the turn-end hops get the tall band.
1139function isBigJump(a: Act): boolean {
1140  return a.kind === 'jump' || a.kind === 'chase' || (a.kind === 'hop' && a.sparks === true)
1141}
1142
1143/**
1144 * A routine step's arms, eyes, look and up win over what its act set: the
1145 * upper-body layer of a layered animation (ANIMATION.md, S1).
1146 */
1147function overlay(c: Body, a: Act): void {
1148  if (a.arms) c.arms = a.arms
1149  if (a.eyes) c.eyes = a.eyes
1150  if (a.gaze !== undefined) c.look = a.gaze
1151  if (a.lookUp) c.lookUp = true
1152}
1153
1154/**
1155 * Reynolds' arrive: from a stand the speed grows to full in EASE_S, and near
1156 * the target it falls so that a constant braking stops it there in EASE_S.
1157 * `e` is the time since the move started.
1158 */
1159function eased(speed: number, e: number, gap: number, isStart: boolean, isEnd: boolean): number {
1160  const rate = speed / EASE_S
1161  let v = speed
1162  if (isStart) v = Math.min(v, rate * (e + DT))
1163  if (isEnd) v = Math.min(v, Math.sqrt(2 * rate * Math.abs(gap)))
1164  return v
1165}
1166
1167/** Moves towards `to`; true once there. */
1168function moveTo(c: Body, to: number, speed: number): boolean {
1169  const gap = to - c.x
1170  if (Math.abs(gap) <= speed * DT) {
1171    c.x = to
1172    return true
1173  }
1174  c.vx = Math.sign(gap) * speed
1175  c.look = 1
1176  return false
1177}
1178
1179function jumpUp(w: World, c: Body, height: number): void {
1180  const room = roomAbove(w, c)
1181  // The half step makes up for what the stepwise integration loses, so the
1182  // apex reaches `height` instead of half a sub-pixel below it.
1183  if (room > 0) c.vy = Math.sqrt(2 * GRAVITY * Math.max(0.5, Math.min(height, room))) + (GRAVITY * DT) / 2
1184}
1185
1186function nextStage(w: World, a: Act): void {
1187  a.stage += 1
1188  a.t0 = w.t
1189}
1190
1191/** Runs one tick of `c`'s act; true when it is done. */
1192function runAct(w: World, c: Body, a: Act): boolean {
1193  const e = w.t - a.t0
1194  const W = width(w)
1195  switch (a.kind) {
1196    case 'idle': {
1197      a.dur ??= between(w, 2, 5)
1198      if (e >= (a.next ?? 0)) {
1199        a.look = [-1, 0, 0, 1][Math.floor(w.rand() * 4)] ?? 0
1200        a.next = e + between(w, 0.8, 2.3)
hooks/clawd-context.ts 77 lines
1/**
2 * What `/clawd debug` shows of an autopick's request. Pure, no `$`.
3 *
4 * A `$.ui.log` row takes no colour, so a label leads each line instead. A part
5 * of the request is shown in full the first time and whenever it changed; after
6 * that it is left out and named. This diff is against what was shown, not the
7 * API's prompt cache: the prompt starts with the session summary, which changes
8 * at every pick, so that cache can serve the system prompt at most. The header
9 * row gives the real cache counts.
10 */
11
12/** One part of a picker request: the system prompt, a `<tag>` block of the prompt, or the text around them. */
13export type Part = { name: string; text: string }
14
15/**
16 * Splits a request into `system`, each `<tag>...</tag>` block of the prompt
17 * (named by its tag), and `instructions` for each run of lines outside a block.
18 * A name that comes twice gets a number, so every part has its own.
19 */
20export function contextParts(system: string, prompt: string): Part[] {
21  const parts: Part[] = [{ name: 'system', text: system }]
22  const lines = prompt.split('\n')
23  let rest: string[] = []
24  const add = (name: string, text: string) => {
25    const n = parts.filter(p => p.name === name || p.name.startsWith(`${name} `)).length
26    parts.push({ name: n ? `${name} ${n + 1}` : name, text })
27  }
28  for (let i = 0; i < lines.length; i++) {
29    const tag = /^<([a-z_]+)>/.exec(lines[i] ?? '')?.[1]
30    const end = tag ? lines.findIndex((l, j) => j >= i && l.includes(`</${tag}>`)) : -1
31    if (!tag || end < 0) {
32      rest.push(lines[i] ?? '')
33      continue
34    }
35    if (rest.length) add('instructions', rest.join('\n'))
36    rest = []
37    add(tag, lines.slice(i, end + 1).join('\n'))
38    i = end
39  }
40  if (rest.length) add('instructions', rest.join('\n'))
41  return parts
42}
43
44function chars(n: number): string {
45  return n < 1000 ? `${n} chars` : `${(n / 1000).toFixed(1)}k chars`
46}
47
48/**
49 * The rows for one pick. First a line naming the parts shown and the parts left
50 * out because they are as last shown, with their size. Then every line of each
51 * part shown, led by `new` or, for a line the part already had when it was last
52 * shown, `same`. `shown` holds the text last shown per part and is updated.
53 */
54export function contextRows(parts: Part[], shown: Map<string, string>): string[] {
55  const fresh = parts.filter(p => shown.get(p.name) !== p.text)
56  const kept = parts.filter(p => shown.get(p.name) === p.text)
57  const head = ['context']
58  if (fresh.length === parts.length) head.push(`all ${parts.length} parts`)
59  else head.push(fresh.length ? `shown: ${fresh.map(p => p.name).join(', ')}` : 'nothing new')
60  if (kept.length) {
61    const size = kept.reduce((n, p) => n + p.text.length, 0)
62    head.push(`left out, as shown before: ${kept.map(p => p.name).join(', ')} (${chars(size)})`)
63  }
64  if (fresh.some(p => p.name === 'system')) head.push("the CLI's identity block sent before the system prompt is not visible to a mod")
65  const rows = [head.join(' · ')]
66  for (const p of fresh) {
67    const before = new Set(shown.get(p.name)?.split('\n') ?? [])
68    const lines = p.text.split('\n')
69    lines.forEach((line, i) => {
70      const text = p.name === 'system' && i === 0 ? `system: ${line}` : line
71      rows.push(`${before.has(line) ? 'same' : 'new '} │ ${text}`)
72    })
73    shown.set(p.name, p.text)
74  }
75  return rows
76}
77
hooks/clawd-words.ts 460 lines
1// `/clawd` as typed. A word may be cut short while it fits one name
2// (`/clawd cel` plays celebrate). While the person types, a framed list
3// above the prompt shows the names the word at the cursor fits (`menuFor`),
4// the words are painted green (fits one name) or red (fits none), and a space
5// after a word that fits one name writes it out. Tab never reaches a plugin, so
6// the space does its job. Pure, no `$`: register.tsx passes the names in and
7// draws what comes back.
8
9export const MAX_REPEAT = 5 // `/clawd jump 3`: at most this many in a row
10
11/** The words `/clawd` takes besides acts, emotes and minis, with what each does. */
12export const COMMANDS: Readonly<Record<string, string>> = {
13  help: 'what Clawd plays and every command; help uml draws it',
14  act: 'Clawd plays an act: a move such as jump or wave; act create, change, delete, preview',
15  emote: "Clawd takes an emote's look for a while; emote create, change, delete, preview",
16  skin: "Clawd's body for this session: a skin, auto or none; skin create, change, delete, preview",
17  list: 'the names of one kind: acts, made, emotes, skins or minis',
18  on: 'show Clawd (remembered)',
19  off: 'hide Clawd (remembered)',
20  autopick: 'switch the model picker on or off; now picks once; haiku (default), sonnet or opus sets its model',
21  trace: 'switch writing each autopick to a file on or off',
22  debug: "switch showing each autopick's request and whole reply here on or off",
23}
24/**
25 * Acts, emotes and skins take the same four commands, create, change, delete
26 * and preview; a skin has auto and none besides. No act, emote or
27 * skin may take one of these names.
28 */
29export type MadeKind = 'act' | 'emote' | 'skin'
30/** What `/clawd act` takes besides an act's name. change and delete take a made act only: a built-in act is code. */
31export const ACT_COMMANDS: Readonly<Record<string, string>> = {
32  create: 'a model writes a new act (uses your Claude usage)',
33  change: "a model changes a made act's steps, or with no words writes it again from scratch (uses your Claude usage); edit and modify work too",
34  delete: 'remove a made act; the file goes to acts/old/',
35  preview: 'a PNG of an act, the band every half second while it plays',
36}
37/**
38 * What `/clawd emote` takes besides an emote's name.
39 */
40export const EMOTE_COMMANDS: Readonly<Record<string, string>> = {
41  create: 'a model draws a new emote (uses your Claude usage)',
42  change: "a model changes an emote's look, or with no words draws it again from scratch (uses your Claude usage); edit and modify work too",
43  delete: 'remove an emote; the file goes to emotes/old/',
44  preview: 'a PNG of an emote in every pose',
45}
46/** What `/clawd skin` takes besides a skin's name. */
47export const SKIN_COMMANDS: Readonly<Record<string, string>> = {
48  auto: 'the skin clawd.json or the project folder gives; the default',
49  none: "Clawd's own body for this session",
50  create: 'a model draws a new skin (uses your Claude usage)',
51  change: "a model changes a skin's look, or with no words draws it again from scratch (uses your Claude usage); edit and modify work too",
52  delete: 'remove a skin; the file goes to skins/old/',
53  preview: 'a PNG of a skin in every pose',
54}
55/** What `/clawd list` takes. */
56export const LIST_KINDS = {
57  acts: 'the built-in acts',
58  made: 'acts a model wrote',
59  emotes: 'looks Clawd takes for a while',
60  skins: 'bodies Clawd wears all the time instead of its own',
61  minis: 'small Clawds, one per running subagent',
62} as const
63export type ListKind = keyof typeof LIST_KINDS
64/** Other words for commands, taken only in full and never offered. */
65export const ALIASES: Readonly<Record<string, string>> = { '?': 'help', ls: 'list', edit: 'change', modify: 'change' }
66const A: Record<MadeKind, string> = { act: 'an act', emote: 'an emote', skin: 'a skin' }
67/** Commands that start a model run or remove something run only when typed in full, with why. */
68function inFull(verb: string, kind: MadeKind): string {
69  return verb === 'create' || verb === 'change' ? 'it starts a model run' : verb === 'delete' ? `it removes ${A[kind]}` : ''
70}
71
72export type Kind = 'command' | 'mini' | 'emote' | 'skin' | 'made' | 'act'
73/** The names a word may take now, each with a short description, in the order offered. */
74export type Names = {
75  minis: Readonly<Record<string, string>>
76  emotes: Readonly<Record<string, string>>
77  skins: Readonly<Record<string, string>>
78  made: Readonly<Record<string, string>>
79  acts: Readonly<Record<string, string>>
80  isSkinOnly?: boolean // clawd.json names a skin, so Clawd has no body of its own: /clawd skin has no none
81}
82export type Fit = { kind: Kind; name: string; what: string }
83
84const TABLES = { mini: 'minis', emote: 'emotes', skin: 'skins', made: 'made', act: 'acts' } as const
85/** What a menu row says before the description when names of several kinds are listed. */
86const KIND_WORD: Record<Kind, string> = { command: '', mini: '', emote: 'emote', skin: 'skin', made: 'made act', act: 'act' }
87const FIRST: Kind[] = ['command', 'mini', 'emote', 'made', 'act']
88const PLAYS: Kind[] = ['act', 'made']
89const rowsOf = (rows: Readonly<Record<string, string>>): Fit[] => Object.entries(rows).map(([name, what]) => ({ kind: 'command', name, what }))
90const LIST_ROWS: Fit[] = rowsOf(LIST_KINDS)
91const TRACE_ROWS: Fit[] = [
92  { kind: 'command', name: 'on', what: 'write each autopick to a file' },
93  { kind: 'command', name: 'off', what: 'stop writing the autopicks' },
94]
95const DEBUG_ROWS: Fit[] = [
96  { kind: 'command', name: 'on', what: "each autopick's request, reply, tokens and outcome in the transcript, not sent to the model" },
97  { kind: 'command', name: 'off', what: 'stop showing them' },
98]
99const AUTOPICK_ROWS: Fit[] = [
100  { kind: 'command', name: 'now', what: 'a model chooses what plays next, once (uses your Claude usage)' },
101  { kind: 'command', name: 'on', what: 'a model chooses every 10 to 60 s (remembered; uses your Claude usage)' },
102  { kind: 'command', name: 'off', what: 'random acts only, no model calls (remembered)' },
103  { kind: 'command', name: 'haiku', what: 'Haiku picks: the default and the cheapest (remembered)' },
104  { kind: 'command', name: 'sonnet', what: 'Sonnet picks: more of your Claude usage per pick (remembered)' },
105  { kind: 'command', name: 'opus', what: 'Opus picks: the most of your Claude usage per pick (remembered)' },
106]
107const HELP_ROWS: Fit[] = [{ kind: 'command', name: 'uml', what: 'how Clawd behaves and how new acts are made, as UML' }]
108
109function table(kinds: readonly Kind[], names: Names): Fit[] {
110  const out: Fit[] = []
111  for (const kind of kinds) {
112    const rows = kind === 'command' ? COMMANDS : names[TABLES[kind]]
113    for (const [name, what] of Object.entries(rows)) if (!out.some(f => f.name === name)) out.push({ kind, name, what })
114  }
115  return out
116}
117
118/** The names a typed word fits: the name itself, else the names it starts, else those it is part of. */
119export function fitsOf(word: string, rows: readonly Fit[]): Fit[] {
120  const w = word.toLowerCase()
121  const alias = rows.find(f => f.kind === 'command' && f.name === ALIASES[w])
122  if (alias) return [{ kind: 'command', name: w, what: alias.what }]
123  const exact = rows.filter(f => f.name === w)
124  if (exact.length > 0) return exact
125  const starts = rows.filter(f => f.name.startsWith(w))
126  return starts.length > 0 ? starts : rows.filter(f => f.name.includes(w))
127}
128
129/** The names of these kinds a typed word fits. */
130export function namesFit(word: string, kinds: readonly Kind[], names: Names): Fit[] {
131  return fitsOf(word, table(kinds, names))
132}
133
134/** How `/clawd act`, `/clawd emote` and `/clawd skin` read the words after them. */
135type KindWords = {
136  verbs: Fit[] // the commands, offered before the names
137  names: readonly Kind[] // what `/clawd <kind> <name>` and preview take
138  owned: Kind // what change and delete take: the ones a file holds
139  ownedOf: string // those, as the menu names them
140  ownedList: ListKind // the `/clawd list` kind that names them
141  create: string // what follows create's new name
142  change: string // what follows change's name
143}
144const KIND_WORDS: Record<MadeKind, KindWords> = {
145  act: {
146    verbs: rowsOf(ACT_COMMANDS),
147    names: PLAYS,
148    owned: 'made',
149    ownedOf: 'made acts',
150    ownedList: 'made',
151    create: '[what it does; else the name says it]',
152    change: '[what to change; nothing writes it again]',
153  },
154  emote: {
155    verbs: rowsOf(EMOTE_COMMANDS),
156    names: ['emote'],
157    owned: 'emote',
158    ownedOf: 'emotes',
159    ownedList: 'emotes',
160    create: '[image path] [what it looks like; else the name says it]',
161    change: '[image path] [what to change; nothing draws it again]',
162  },
163  skin: {
164    verbs: rowsOf(SKIN_COMMANDS),
165    names: ['skin'],
166    owned: 'skin',
167    ownedOf: 'skins',
168    ownedList: 'skins',
169    create: '[image path] [what it looks like; else the name says it]',
170    change: '[image path] [what to change; nothing draws it again]',
171  },
172}
173const LISTED: Record<MadeKind, ListKind> = { act: 'acts', emote: 'emotes', skin: 'skins' }
174
175/** After `/clawd act`, `emote` or `skin`: its commands (a skin's none only without isSkinOnly), then the names. */
176function kindRows(kind: MadeKind, names: Names): Fit[] {
177  const words = KIND_WORDS[kind]
178  return [...words.verbs.filter(row => !(row.name === 'none' && names.isSkinOnly)), ...table(words.names, names)]
179}
180
181function onlyFit(word: string, rows: readonly Fit[]): Fit | undefined {
182  const fits = fitsOf(word, rows)
183  return fits.length === 1 ? fits[0] : undefined
184}
185
186/** What the next word may be, after the words before it; `of` names the rows' kind. */
187type Place = { rows: Fit[]; of: string } | { free: string } | { count: true } | { end: true }
188const END: Place = { end: true }
189const COUNT: Place = { count: true }
190
191function placeAfter(before: readonly string[], names: Names): Place {
192  const [first, ...rest] = before
193  if (first === undefined) return { rows: table(FIRST, names), of: 'names' }
194  const fit = onlyFit(first, table(FIRST, names))
195  if (!fit) return END
196  const name = ALIASES[fit.name] ?? fit.name
197  // After a mini: an act, then how many times. A mini takes no commands.
198  if (fit.kind === 'mini') {
199    const [act] = rest
200    if (act === undefined) return { rows: table(PLAYS, names), of: 'acts' }
201    return rest.length === 1 && onlyFit(act, table(PLAYS, names)) ? COUNT : END
202  }
203  if (fit.kind === 'act' || fit.kind === 'made') return rest.length === 0 ? COUNT : END
204  if (fit.kind !== 'command') return END
205  if (name === 'act' || name === 'emote' || name === 'skin') return kindPlace(name, rest, names)
206  if (rest.length > 0) return END
207  if (name === 'list') return { rows: LIST_ROWS, of: 'kinds' }
208  if (name === 'trace') return { rows: TRACE_ROWS, of: 'words' }
209  if (name === 'debug') return { rows: DEBUG_ROWS, of: 'words' }
210  if (name === 'autopick') return { rows: AUTOPICK_ROWS, of: 'words' }
211  if (name === 'help') return { rows: HELP_ROWS, of: 'words' }
212  return END
213}
214
215/**
216 * What comes after `/clawd act`, `emote` or `skin` and these words: a command
217 * or a name; after a command its words, after an act's name how many times.
218 */
219function kindPlace(kind: MadeKind, rest: readonly string[], names: Names): Place {
220  const words = KIND_WORDS[kind]
221  const [first, ...more] = rest
222  if (first === undefined) return { rows: kindRows(kind, names), of: 'names' }
223  const fit = onlyFit(first, kindRows(kind, names))
224  if (!fit) return END
225  if (fit.kind !== 'command') return kind === 'act' && more.length === 0 ? COUNT : END
226  const verb = ALIASES[fit.name] ?? fit.name
227  if (verb === 'auto' || verb === 'none') return END
228  if (verb === 'create') return more.length === 0 ? { free: '<new_name> in snake_case' } : { free: words.create }
229  if (more.length === 0) {
230    return verb === 'preview' ? { rows: table(words.names, names), of: LISTED[kind] } : { rows: table([words.owned], names), of: words.ownedOf }
231  }
232  return verb === 'change' ? { free: words.change } : END
233}
234
235type Word = { text: string; start: number; end: number }
236
237/** The words after `/clawd `, with their offsets; undefined for any other draft. */
238function wordsOf(draft: string): Word[] | undefined {
239  const head = /^\s*\/clawd(?=\s)/.exec(draft)
240  if (!head) return undefined
241  const out: Word[] = []
242  const re = /\S+/g
243  re.lastIndex = head[0].length
244  for (let m = re.exec(draft); m; m = re.exec(draft)) out.push({ text: m[0], start: m.index, end: m.index + m[0].length })
245  return out
246}
247
248/** True for a draft that is a `/clawd` command with arguments to come. */
249export function isClawdDraft(draft: string): boolean {
250  return wordsOf(draft) !== undefined
251}
252
253/** A row of the `/clawd` menu: a name, what it is, and the part of the name the typed word matches (from = to: none). */
254export type MenuRow = { name: string; what: string; from: number; to: number }
255
256/** The framed list above the prompt while `/clawd` is typed. */
257export type Menu = {
258  title: string // `/clawd` and the words before the one at the cursor: `/clawd act`
259  note: string // after the title: how to use the list, or what goes here
260  rows: MenuRow[] // every name the word at the cursor fits, in the order offered
261  isOne: boolean // the typed word fits one name; its row is marked
262  count: string // `2 of 22 fit` while the typed word fits several names
263  next: string // what follows the fitting names: `then [1-5] times in a row`
264}
265
266// Skins are not in FIRST: they sort after the commands, as emotes and acts do.
267const rank = (f: Fit): number => (FIRST.includes(f.kind) ? FIRST.indexOf(f.kind) : FIRST.length)
268
269/** What comes after these words, when it is short to say: a count or a free text. */
270function nextAfter(before: readonly string[], names: Names): string {
271  const place = placeAfter(before, names)
272  if ('count' in place) return `then [1-${MAX_REPEAT}] times in a row`
273  if ('free' in place) return `then ${place.free}`
274  return ''
275}
276
277/** The menu for the word at the cursor; undefined for any other draft, or when nothing goes there. */
278export function menuFor(draft: string, cursor: number, names: Names): Menu | undefined {
279  const before = draft.slice(0, cursor)
280  const words = wordsOf(before)
281  if (!words) return undefined
282  const typing = /\s$/.test(before) ? '' : (words.pop()?.text ?? '')
283  const done = words.map(w => w.text)
284  const place = placeAfter(done, names)
285  if ('end' in place) return undefined
286  // At most two words: an emote's name and create's or change's description stay out.
287  const menu: Menu = { title: ['/clawd', ...done.slice(0, 2)].join(' '), note: '', rows: [], isOne: false, count: '', next: '' }
288  if ('free' in place) return { ...menu, note: place.free }
289  if ('count' in place) return { ...menu, note: `[1-${MAX_REPEAT}] times in a row` }
290  const isTop = done.length === 0
291  if (!typing) {
292    // At the top, the commands and minis; acts and emotes come by typing, or after act and emote.
293    const all = isTop ? place.rows.filter(f => f.kind === 'command' || f.kind === 'mini') : place.rows
294    const rows = [...all].sort((a, b) => rank(a) - rank(b)).map(f => ({ name: f.name, what: f.what, from: 0, to: 0 }))
295    return { ...menu, note: isTop ? 'type to narrow, space writes a single fit out' : `${rows.length} ${place.of}`, rows }
296  }
297  const fits = [...fitsOf(typing, place.rows)].sort((a, b) => rank(a) - rank(b))
298  const [only] = fits
299  if (!only) return { ...menu, note: `nothing is called "${typing}"`, next: '/clawd help shows what there is' }
300  const typed = typing.toLowerCase()
301  const rows = fits.map(f => {
302    const at = f.name.indexOf(typed)
303    const kind = isTop ? KIND_WORD[f.kind] : ''
304    return { name: f.name, what: kind ? `${kind}: ${f.what}` : f.what, from: Math.max(0, at), to: at < 0 ? 0 : at + typed.length }
305  })
306  const nexts = [...new Set(fits.map(f => nextAfter([...done, f.name], names)))]
307  const next = nexts.length === 1 ? (nexts[0] ?? '') : ''
308  if (fits.length === 1) return { ...menu, note: typing === only.name ? '' : 'space writes it out', rows, isOne: true, next }
309  return { ...menu, note: `${place.of} that fit "${typing}"`, rows, count: `${fits.length} of ${place.rows.length} fit`, next }
310}
311
312/** The rows that fit in `room` rows, and the bottom edge's text: the count, what comes next, the names left out. */
313export function menuLayout(menu: Menu, room: number): { rows: MenuRow[]; foot: string } {
314  const rows = menu.rows.slice(0, Math.max(0, room))
315  const left = menu.rows.slice(rows.length).map(r => r.name)
316  const more = left.length > 0 ? `+${left.length} more: ${left.join(' ')}` : ''
317  return { rows, foot: [menu.count, menu.next, more].filter(Boolean).join(' · ') }
318}
319
320/** Each word that can be checked, and whether it fits one name (true) or none (false). */
321export function marksFor(draft: string, names: Names): { start: number; end: number; isFit: boolean }[] {
322  const words = wordsOf(draft) ?? []
323  const marks: { start: number; end: number; isFit: boolean }[] = []
324  for (const [i, word] of words.entries()) {
325    const place = placeAfter(
326      words.slice(0, i).map(w => w.text),
327      names,
328    )
329    if (!('rows' in place)) break
330    const n = fitsOf(word.text, place.rows).length
331    if (n === 1) marks.push({ start: word.start, end: word.end, isFit: true })
332    else if (n === 0) marks.push({ start: word.start, end: word.end, isFit: false })
333  }
334  return marks
335}
336
337/**
338 * After a space typed at `cursor`: the draft with the word before that space
339 * written out in full, when it fits exactly one name and is short of it.
340 */
341export function writeOut(draft: string, cursor: number, names: Names): { text: string; cursor: number } | undefined {
342  const before = draft.slice(0, cursor - 1)
343  const words = wordsOf(before)
344  const last = words?.pop()
345  if (!words || !last || last.end !== before.length) return undefined
346  const place = placeAfter(
347    words.map(w => w.text),
348    names,
349  )
350  if (!('rows' in place)) return undefined
351  const fits = fitsOf(last.text, place.rows)
352  const [fit] = fits
353  if (fits.length !== 1 || !fit || fit.name === last.text || ALIASES[last.text]) return undefined
354  return { text: draft.slice(0, last.start) + fit.name + draft.slice(last.end), cursor: cursor + fit.name.length - last.text.length }
355}
356
357/** What `/clawd <args>` asks for. */
358export type Order =
359  | { kind: 'status' }
360  | { kind: 'command'; name: string; rest: string[] }
361  | { kind: 'list'; of: ListKind }
362  | { kind: 'play'; who: string; fit: Fit; count: number }
363  | { kind: 'unclear'; text: string }
364
365/** The one name a word fits, or why there is none; `where` names the command that lists them. */
366function oneFit(word: string, rows: readonly Fit[], where: string): Fit | string {
367  const fits = fitsOf(word, rows)
368  const [fit] = fits
369  if (fits.length === 1 && fit) return fit
370  if (!fit) return `Clawd: nothing is called "${word}". ${where} shows what there is.`
371  const shown = fits.slice(0, 8).map(f => f.name)
372  return `Clawd: "${word}" fits ${fits.length} names: ${shown.join(', ')}${fits.length > shown.length ? ', ...' : ''}. Type more of it.`
373}
374
375function countOf(word: string | undefined): number {
376  return Math.min(MAX_REPEAT, Math.max(1, Number.parseInt(word ?? '1', 10) || 1))
377}
378
379function unclear(text: string): Order {
380  return { kind: 'unclear', text }
381}
382
383/** A mini plays the act named by rest[0], rest[1] times. */
384function miniOrder(who: string, rest: readonly string[], names: Names): Order {
385  const [act, n] = rest
386  if (act === undefined) return unclear(`Clawd: which act? E.g. /clawd ${who} wave. /clawd list acts shows them.`)
387  const what = oneFit(act, table(PLAYS, names), '/clawd list acts')
388  if (typeof what !== 'string') return { kind: 'play', who, fit: what, count: countOf(n) }
389  return unclear(onlyFit(act, table(['emote'], names)) ? `Clawd: emotes are for Clawd only, not for ${who}.` : what)
390}
391
392/**
393 * `/clawd act <act> [n]` plays an act, `/clawd emote <emote>` an emote, and
394 * `/clawd skin <skin>|auto|none` chooses Clawd's body for the session.
395 * `/clawd <kind> <command> ...` creates, changes, deletes or previews one of
396 * them; the order is the kind with the command's full name first. change and
397 * delete take the full name of one a file holds, so an act's must be a made one.
398 */
399function kindOrder(kind: MadeKind, rest: readonly string[], names: Names): Order {
400  const words = KIND_WORDS[kind]
401  const [first, ...more] = rest
402  if (first === undefined) {
403    if (kind === 'skin') return { kind: 'command', name: 'skin', rest: [] }
404    return unclear(`Clawd: which ${kind}? /clawd ${kind} <${kind}> plays one; ${kind} create, change, delete and preview work on them. /clawd list ${LISTED[kind]} shows them.`)
405  }
406  const fit = oneFit(first, kindRows(kind, names), `/clawd list ${LISTED[kind]}`)
407  if (typeof fit === 'string') {
408    const look = kind === 'act' ? onlyFit(first, table(['emote'], names)) : undefined
409    return unclear(look ? `Clawd: ${look.name} is an emote; /clawd emote ${look.name} plays it.` : fit)
410  }
411  if (fit.kind === 'skin') return { kind: 'command', name: 'skin', rest: [fit.name] }
412  if (fit.kind !== 'command') return { kind: 'play', who: 'clawd', fit, count: kind === 'act' ? countOf(more[0]) : 1 }
413  const verb = ALIASES[fit.name] ?? fit.name
414  const reason = inFull(verb, kind)
415  if (reason && first !== fit.name) return unclear(`Clawd: type ${verb} in full; ${reason}.`)
416  if (verb === 'change' || verb === 'delete') {
417    const [name] = more
418    if (name === undefined) return unclear(`Clawd: which ${kind}? /clawd ${kind} ${verb} <${kind}>${verb === 'change' ? ' [what to change]' : ''}.`)
419    const owned = table([words.owned], names)
420    if (!owned.some(f => f.name === name)) {
421      if (kind === 'act' && Object.hasOwn(names.acts, name)) {
422        return unclear(`Clawd: ${name} is built in; only made acts can be ${verb === 'change' ? 'changed' : 'deleted'}. /clawd list made shows them.`)
423      }
424      const one = onlyFit(name, owned)
425      return unclear(
426        one ? `Clawd: type the ${kind}'s full name: /clawd ${kind} ${verb} ${one.name}.` : `Clawd: there is no ${kind} ${name}. /clawd list ${words.ownedList} shows them.`,
427      )
428    }
429  }
430  return { kind: 'command', name: kind, rest: [verb, ...more] }
431}
432
433/** The words after `/clawd`, read as one order. `clawd` as the first word names Clawd, as a mini id names that mini. */
434export function orderOf(args: readonly string[], names: Names): Order {
435  const words = args[0] === 'clawd' && args.length > 1 ? args.slice(1) : [...args]
436  const [first, ...rest] = words
437  if (first === undefined) return { kind: 'status' }
438  const fit = oneFit(first, table(FIRST, names), '/clawd help')
439  if (typeof fit === 'string') {
440    // These were top-level emote commands until 2026-10-07; acts, emotes and skins take them now.
441    const verb = ALIASES[first] ?? first
442    if (Object.hasOwn(ACT_COMMANDS, verb)) return unclear(`Clawd: ${verb} what? /clawd act ${verb}, /clawd emote ${verb} or /clawd skin ${verb}.`)
443    // A skin is no act: it is worn all session, so it takes the skin command.
444    const skin = fitsOf(first, table(FIRST, names)).length === 0 ? onlyFit(first, table(['skin'], names)) : undefined
445    return unclear(skin ? `Clawd: ${skin.name} is a skin; /clawd skin ${skin.name} puts it on for this session.` : fit)
446  }
447  if (fit.kind === 'act' || fit.kind === 'made') return { kind: 'play', who: 'clawd', fit, count: countOf(rest[0]) }
448  if (fit.kind === 'emote') return { kind: 'play', who: 'clawd', fit, count: 1 }
449  if (fit.kind === 'mini') return miniOrder(fit.name, rest, names)
450  const name = ALIASES[fit.name] ?? fit.name
451  if (name === 'act' || name === 'emote' || name === 'skin') return kindOrder(name, rest, names)
452  if (name === 'list') {
453    const [of] = rest
454    if (of === undefined) return unclear('Clawd: list what? /clawd list acts | made | emotes | skins | minis.')
455    const what = oneFit(of, LIST_ROWS, '/clawd help')
456    return typeof what === 'string' ? unclear(what) : { kind: 'list', of: what.name as ListKind }
457  }
458  return { kind: 'command', name, rest }
459}
460
hooks/clawd-uml.ts 273 lines
1// `/clawd help uml`. A command's answer is transcript text, so the
2// diagrams are box-drawing characters, at most UML_WIDTH columns wide. A
3// CommandOutput render hook in register.tsx colours them by umlKinds; the stored row stays
4// plain text. Pure, no `$`: register.tsx passes the numbers in, so a changed
5// constant shows here too.
6
7/** The numbers and model names the diagrams name. */
8export type UmlFacts = {
9  sleepAfterS: number // idle seconds before Clawd falls asleep
10  readHoldS: number // the scroll stays up this long after the last reading call
11  miniStaleS: number // a mini whose subagent was silent this long leaves
12  maxMinis: number
13  pickMinS: number
14  pickMaxS: number
15  maxDelayS: number // a pick's calls start up to this far apart
16  newPerDay: number // new acts, emotes and skins the autopicker may ask for in 24 h
17  maxSteps: number // steps in a made act
18  emoteDrafts: number
19  emoteRounds: number
20  pickModel: string
21  makeModel: string
22}
23
24export const UML_WIDTH = 78
25
26const cells = (s: string) => [...s].length
27const pad = (s: string, n: number) => s + ' '.repeat(Math.max(0, n - cells(s)))
28
29/** A UML state or action: a rounded box `inner` cells wide; a state's name gets a compartment of its own. */
30function node(inner: number, lines: readonly string[], name?: string): string[] {
31  const rule = '─'.repeat(inner + 2)
32  return [
33    `╭${rule}╮`,
34    ...(name === undefined ? [] : [`│ ${pad(name, inner)} │`, `├${rule}┤`]),
35    ...lines.map(l => `│ ${pad(l, inner)} │`),
36    `╰${rule}╯`,
37  ]
38}
39
40/** A UML note: a square box `inner` cells wide. */
41function note(inner: number, lines: readonly string[]): string[] {
42  const rule = '─'.repeat(inner + 2)
43  return [`┌${rule}┐`, ...lines.map(l => `│ ${pad(l, inner)} │`), `└${rule}┘`]
44}
45
46/** Blocks side by side, row by row, each padded to its width. */
47function beside(...blocks: [width: number, rows: readonly string[]][]): string[] {
48  const height = Math.max(...blocks.map(([, rows]) => rows.length))
49  return Array.from({ length: height }, (_, i) => blocks.map(([w, rows]) => pad(rows[i] ?? '', w)).join('').trimEnd())
50}
51
52/** One row, each text starting at its column. */
53function at(...parts: [col: number, text: string][]): string {
54  let row = ''
55  for (const [col, text] of parts) row = pad(row, col) + text
56  return row
57}
58
59/** Clawd itself: idle, work and asleep, and what moves it between them. */
60function clawdStates(f: UmlFacts): string[] {
61  const up = 59 // the column of the line from Asleep up to Work
62  return [
63    '1. Clawd (UML state machine)',
64    '',
65    '   ●  session start / Clawd walks in and waves',
66    '   │',
67    '   ▼',
68    ...beside(
69      [26, node(22, ['do / random acts from', '     the idle table', '', '', ''], 'Idle')],
70      [22, ['', '  prompt sent', '  / alert, hop,', '    kick [1]', `${'─'.repeat(21)}▶`, '', `◀${'─'.repeat(21)}`, '  turn ends / [4]']],
71      [28, node(24, ['do / random acts from', '     the work table', 'read call / scroll [2]', 'other call / carry [3]', ''], 'Work')],
72    ),
73    at([3, '│'], [17, '▲'], [up, '▲']),
74    at([3, '│'], [5, `after ${f.sleepAfterS} s`], [17, '│'], [19, 'failed call / trip,'], [up, '│'], [up + 2, 'prompt sent']),
75    at([3, '│'], [5, 'without an'], [17, '│'], [19, '/clawd <act>'], [up, '│'], [up + 2, '/ wake, kick [1]']),
76    at([3, '│'], [5, 'event'], [17, '│'], [up, '│']),
77    at([3, '▼'], [17, '│'], [up, '│']),
78    ...beside([26, node(22, ['do / sleep'], 'Asleep')], [up - 26 + 1, ['', '', '', `${'─'.repeat(up - 26)}┘`, '']]).map((row, i) =>
79      i < 3 ? at([0, row], [up, '│']) : row,
80    ),
81    '',
82    ...note(72, [
83      'In every state',
84      'failed tool call / trip',
85      '/clawd <act> / the act plays at once and cuts in on anything',
86      `autopick / ${f.pickModel} calls acts for Clawd and the minis, up to ${f.maxDelayS} s apart.`,
87      `   After /clawd autopick on, it picks every ${f.pickMinS} to ${f.pickMaxS} s and right after a`,
88      '   prompt, a turn end and a subagent starting or ending. A call cuts in',
89      '   on a random act and waits while Clawd reacts to a session event.',
90      '   Asleep with no mini, the timer picks nothing; a pick that does play',
91      '   ends in sleep again.',
92    ]),
93    '[1] kick: only while bricks lie on the pile; Clawd kicks it over.',
94    `[2] scroll: held up until ${f.readHoldS} s after the last call that only reads (Read, or`,
95    '    a Bash call the engine judges read-only). It cuts a random act short.',
96    '[3] carry: Clawd fetches a brick for the pile, until the pile is full.',
97    '    The turn end drops the bricks still waiting.',
98    '[4] celebrate; trip after an error; look and think when you stopped it.',
99  ]
100}
101
102/** A mini Clawd, from its subagent's start until it has run off. */
103function miniStates(f: UmlFacts): string[] {
104  const back = 60 // the column of the line from Mini round to Leaving
105  const leaving = node(13, ['do / run off', '     the band'], 'Leaving')
106  return [
107    `2. A mini Clawd (UML state machine): one per running subagent, at most ${f.maxMinis}.`,
108    '',
109    '   ●  subagent starts / a mini runs in from an edge, alert',
110    '   │',
111    '   ▼',
112    ...beside(
113      [28, node(24, ['do / random acts from', '     the mini table', 'its read call / scroll', 'its other call / carry', 'its failed call / trip'], 'Mini (a1, a2, ...)')],
114      [24, ['', '  subagent ends', '  / celebrate, or trip', '  without an answer', `${'─'.repeat(23)}▶`]],
115      [26, [...leaving.map((row, i) => (i === 4 ? `${row} ──▶ ◉` : row)), at([back - 52, '▲']), at([back - 52, '│']), at([back - 52, '│'])]],
116    ),
117    at([3, '│'], [5, `${Math.round(f.miniStaleS / 60)} min without an event / trip`], [back, '│']),
118    at([3, `└${'─'.repeat(back - 4)}┘`]),
119  ]
120}
121
122/** Two blocks of the activity diagram side by side, the shorter one's flow line drawn on to the same height. */
123function pair(left: readonly string[], right: readonly string[]): string[] {
124  const height = Math.max(left.length, right.length)
125  const fill = (rows: readonly string[]) => [...rows, ...Array<string>(height - rows.length).fill('   │')]
126  return beside([40, fill(left)], [37, fill(right)])
127}
128
129/** How a new act, emote or skin comes about, and how Clawd finds it afterwards. */
130function making(f: UmlFacts): string[] {
131  const right = 43 // the column of the look flow
132  const ask = '◇──[new.look: become, hold or skin]'
133  return [
134    '3. A new act, emote or skin (UML activity diagram)',
135    '',
136    at([3, '●  autopick'], [right, '●  /clawd emote or skin']),
137    at([3, '▼'], [right, '│  create or change']),
138    ...beside(
139      [right, node(36, [`${f.pickModel} reads what happened and every`, 'name; it answers with calls, why and', 'new: an act, emote or skin it wants'])],
140      [1, ['│', '│', '│', '│', '│']],
141    ),
142    at([3, '│ plays the calls'], [right, '│']),
143    at([3, `◇──[no new, or ${f.newPerDay} made in 24 h]──▶ ◉`], [right, '│']),
144    at([3, '◇──[it exists]──▶ play or wear it ──▶ ◉'], [right, '│']),
145    at([3, `${ask}${'─'.repeat(right - 3 - cells(ask))}┤`]),
146    at([3, '│ [a move]'], [right, '│ [nothing being made]']),
147    at([3, '│◀──● /clawd act create or change'], [right, '▼']),
148    at([3, '▼']),
149    ...pair(
150      node(34, [
151        `${f.makeModel} writes 1 to ${f.maxSteps} steps from the`,
152        'pickable acts and pose, mark and',
153        'sparks. A change starts from the',
154        'steps as they are; one with no',
155        'words writes it again. (makeAct)',
156      ]),
157      node(33, [
158        `${f.makeModel} draws it in up to ${f.emoteRounds} rounds:`,
159        `${f.emoteDrafts} drafts on one sheet, told the`,
160        'others of its kind; it picks one,',
161        'fixes it and checks its preview.',
162        'A change starts from the look as',
163        'it is; one with no words draws it',
164        'again from scratch.',
165        '(makeEmote; claude -p per round)',
166      ]),
167    ),
168    at([3, '◇──[routineFrom refuses]──▶ ◉'], [right, '◇──[no usable draft]──▶ ◉']),
169    at([3, '▼'], [right, '▼']),
170    ...pair(
171      node(34, ['write acts/<name>.json in the', 'data folder; a change or rewrite', 'moves the old file to acts/old/']),
172      node(33, ['write <kind>s/<name>.json and a', 'preview PNG in the data folder; a', 'change or redraw moves the old', 'file to <kind>s/old/ there']),
173    ),
174    at([3, '▼'], [right, '▼']),
175    ...pair(node(34, ['play it on new.for, or on Clawd']), node(33, ['Clawd plays an emote, or wears a', 'skin for the session'])),
176    at([3, '▼'], [right, '▼']),
177    at([3, '◉'], [right, '◉']),
178    '',
179    ...note(72, [
180      'Registered: there is no list to add a name to. Each autopick, each',
181      '/clawd and the menu while you type read acts/, emotes/ and skins/ in',
182      "the data folder and the plugin's emotes/ and skins/ afresh. The next",
183      `pick offers the new name to ${f.pickModel}, and /clawd <name> and /clawd list`,
184      'take it at once, without a reload. A file that fails the check is',
185      "skipped. A hand-drawn look in the plugin wins over a model's of the",
186      'same name. /clawd act, emote or skin delete moves the file to old/.',
187      'The data folder is clawd/ in the Claude config folder (~/.claude), or',
188      'data/ beside the plugin in a checkout. A skin the autopicker makes or',
189      'calls stays on for the session. A skin chosen with /clawd skin wins,',
190      'and the autopicker then gets no skins.',
191    ]),
192    'A built-in act is code: its name in ACTS and a case in runAct (clawd-sim.ts),',
193    'a line in PICKABLE so models and made acts may use it, a weight in a random',
194    'table if it should play by itself, then /reload-plugins.',
195  ]
196}
197
198/** The first line of the diagrams; the render hook finds the answer by it. */
199export const UML_LEGEND = 'Read as UML: ╭╮ a state or an action, ● start, ◉ end, ◇ a decision, [a guard],'
200
201/** The three diagrams, one line per row. */
202export function clawdUml(f: UmlFacts): string[] {
203  return [
204    UML_LEGEND,
205    'event / what Clawd does.',
206    '',
207    ...clawdStates(f),
208    '',
209    ...miniStates(f),
210    '',
211    ...making(f),
212  ]
213}
214
215/** What a run of cells in the diagrams is; register.tsx gives each kind its colour. */
216export type UmlKind = 'text' | 'heading' | 'state' | 'name' | 'note' | 'flow' | 'end' | 'guard' | 'footnote'
217
218const BOXES = [
219  { kind: 'state', corners: '╭╮╰╯' },
220  { kind: 'note', corners: '┌┐└┘' },
221] as const
222const FLOW = new Set([...'─│┤├┘└┐┌▶◀▲▼'])
223
224/**
225 * Each line cut into runs of one kind. A box's frame is found by walking its
226 * edges from the top left corner, and the row above a `├` divider is the
227 * state's name. The rest goes by character, brackets and the heading's number.
228 */
229export function umlKinds(lines: readonly string[]): { text: string; kind: UmlKind }[][] {
230  const grid = lines.map(l => [...l])
231  const ch = (r: number, c: number) => grid[r]?.[c] ?? ''
232  const kinds = grid.map(row => row.map((): UmlKind | undefined => undefined))
233  const mark = (r: number, c: number, kind: UmlKind) => {
234    const row = kinds[r]
235    if (row && c < row.length) row[c] = kind
236  }
237  grid.forEach((row, r) =>
238    row.forEach((corner, c) => {
239      for (const { kind, corners } of BOXES) {
240        if (corner !== corners[0]) continue
241        let right = c + 1
242        while (ch(r, right) === '─') right++
243        let bottom = r + 1
244        while (ch(bottom, c) === '│' || ch(bottom, c) === '├') bottom++
245        if (right === c + 1 || ch(r, right) !== corners[1] || ch(bottom, c) !== corners[2] || ch(bottom, right) !== corners[3]) continue
246        for (let y = r; y <= bottom; y++) {
247          const isRule = y === r || y === bottom || ch(y, c) === '├'
248          for (let x = c; x <= right; x++) if (isRule || x === c || x === right) mark(y, x, kind)
249          if (ch(y, c) === '├') for (let x = c + 1; x < right; x++) mark(y - 1, x, 'name')
250        }
251      }
252    }),
253  )
254  return grid.map((row, r) => {
255    const line = lines[r] ?? ''
256    const marks = kinds[r] ?? []
257    for (const m of line.matchAll(/\[[^\]]+\]/g)) {
258      const from = cells(line.slice(0, m.index))
259      for (let x = from; x < from + cells(m[0]); x++) marks[x] ??= /^\[\d\]$/.test(m[0]) ? 'footnote' : 'guard'
260    }
261    const runs: { text: string; kind: UmlKind }[] = []
262    row.forEach((cell, c) => {
263      const kind =
264        marks[c] ??
265        ('●◉'.includes(cell) ? 'end' : cell === '◇' ? 'guard' : '╭╮╰╯'.includes(cell) ? 'state' : FLOW.has(cell) ? 'flow' : /^\d\. /.test(line) ? 'heading' : 'text')
266      const last = runs[runs.length - 1]
267      if (last?.kind === kind) last.text += cell
268      else runs.push({ text: cell, kind })
269    })
270    return runs
271  })
272}
273
types/index.d.ts 46 lines
1/** Clawd's band state, as kept in $.state. */
2export type ClawdState = {
3  isClawdOn: boolean
4  clawdRows: number
5  isTracing: boolean
6  isDebugging: boolean
7  clawdSkin: string | null
8  clawdPickedSkin: string | null
9  clawdMenu: {
10    title: string
11    note: string
12    rows: { name: string; what: string; from: number; to: number }[]
13    isOne: boolean
14    count: string
15    next: string
16  } | null
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    'clawd': {
22      /** Clawd, the logo, running in the band above the prompt (/clawd on|off). */
23      isClawdOn: boolean
24      /** The band's height in rows: 4, 5 during a big jump or a tall look or skin, 6 with the Remote Control antenna on a tall look or skin. */
25      clawdRows: number
26      /** Each pick goes to picks/<session id>.jsonl in the data folder (/clawd trace [on|off]). */
27      isTracing: boolean
28      /** Each pick's whole reply, tokens and outcome go to the transcript (/clawd debug [on|off]). */
29      isDebugging: boolean
30      /** The skin /clawd skin chose for this session: a skin's name, '' for Clawd's own body; null: none chosen, clawd.json or the project folder decides. */
31      clawdSkin: string | null
32      /** The skin the autopicker called or had drawn for this session: a skin's name; null: none. A /clawd skin choice wins over it; /clawd skin auto clears both. */
33      clawdPickedSkin: string | null
34      /** The framed list above the prompt while a /clawd command is typed (clawd-words.ts `Menu`); null otherwise. */
35      clawdMenu: {
36        title: string
37        note: string
38        rows: { name: string; what: string; from: number; to: number }[]
39        isOne: boolean
40        count: string
41        next: string
42      } | null
43    }
44  }
45}
46