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

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.

There is also a version in which Clawd is the MatSci octopus: AutomatedAlchemy/clawd-matsci.
This is a fan project. Anthropic did not make it and does not endorse it.
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.
| Command | What it does |
|---|---|
/clawd | what Clawd is doing now |
/clawd help | every command; help uml draws how Clawd behaves |
/clawd jump 3 | play an act now, up to 5 times in a row |
/clawd a1 wave | a subagent's small Clawd plays an act |
/clawd act create, change, delete, preview | write, rewrite, remove or picture an act |
/clawd list acts | the 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, preview | make, redraw, remove or picture an emote |
/clawd skin <skin>, none, auto | the body Clawd wears all the time, for this session |
/clawd skin create, change, delete, preview | make, redraw, remove or picture a skin |
/clawd on, /clawd off | show or hide Clawd |
/clawd autopick | switch a model choosing what Clawd plays on or off (see below); now chooses once; haiku, sonnet or opus sets the model |
/clawd debug | switch showing each autopick's request and whole reply in the transcript on or off |
/clawd trace | switch 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.
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.
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.
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.
MIT, see LICENSE.
hooks/register.tsx 2240 lines1import { 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 lines1// 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 lines1/**
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}
77hooks/clawd-words.ts 460 lines1// `/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}
460hooks/clawd-uml.ts 273 lines1// `/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}
273types/index.d.ts 46 lines1/** 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