A little comic above the prompt: Claude acts out what the agent is doing in animated pixel scenes, from a home hub with keepsakes

A Claude Code plugin marketplace with my mods and plugins.
/plugin marketplace add CatraMyBeloved/catras-claude-code-collection
/plugin install agent-comic@catras-claude-code-collection
/plugin install cache-keepalive@catras-claude-code-collection
The repo is private, so git must be authenticated (e.g. gh auth login). If the SSH attempt fails, set CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1.
A pixel comic in the band above the prompt: a little Claude acts out what the agent is doing.
hub_add, deferred, so it costs no context until used); the hub keeps six. /comic-hub lists them./comic-pet: he lights up, and a headpat is sent as your message ("Here, have a headpat. You are doing amazing!"), which Claude answers.hybrid (default) has the model set up the session's world and stage each turn's opening and wrap-up in it, and plays canned scenes in between; full stages everything with the model; off uses no tokens at all. Set it with /config, along with the model, pace and whether the comic stays up between turns.$.comic (a garden, a pond, a minigame). Between turns, wooden signs in the hub's top corners lead to them (a/d once the band has focus); while Claude works the comic takes the band back. With no place installed nothing changes. How to build one: EXTENDING.md; a complete example is pond-place.Commands: /comic (on/off), /comic-pet, /comic-hub, /comic-stats, /comic-feel <mood>, /comic-demo.
Keeps Claude Code's prompt cache warm while you think, so the next prompt reads the conversation from the cache instead of paying to write it again.
cache ▰▰▰▰▰▰▱▱ 41:12 (1h), in calm tones: sage green, sand yellow for the last 10 minutes, dusty rose for the last 3 (a 5-minute cache keeps the same proportions). The timer restarts with every request of the main conversation.auto follows Claude Code's own rules: FORCE_PROMPT_CACHING_5M, then CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl setting and ENABLE_PROMPT_CACHING_1H; otherwise 1 hour on a subscription within plan limits and 5 minutes on an API key, a cloud provider or usage credits. The mod cannot always tell when a subscription draws on credits; set the lifetime to 5m in /config then./keepalive opens a pane with the bar, the expiry time, the cached prefix size, a timeline of hits, misses and keep-alives, and buttons to ping now (p) or pause (k).Commands: /keepalive (gauge), /keepalive status, /keepalive on|off, /keepalive now. Set the mode (prompt or off), lifetime, lead and idle cutoff with /config.
Why a message and not an invisible side request: a side request ($.model.fork) re-sends the conversation, but Claude Code caches it as a separate entry, so it never kept the main conversation's cache warm in testing.
claude plugin validate .
claude plugin test plugins/cache-keepalive
claude plugin test examples/pond-place
examples/ holds example plugins that are not in the marketplace; copy one as a starting point.
CACHE_KEEPALIVE_HEADLESS=1 lets cache-keepalive run under claude -p --plugin-dir plugins/cache-keepalive, for checking its cache behaviour without an interactive session.
GPL-3.0. Copyright (c) 2026 Ole Stein.
hooks/register.tsx 847 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { cannedFirst, cannedInterlude, cannedScene, cannedWrap } from './canned'
5import type { Activity } from './canned'
6import { cellText } from './canvas'
7import { DEMO_MS, demoScene, feelBeat } from './demo'
8import { SYSTEM, buildPrompt, describeCall, varietyNotes } from './director'
9import { MAX_INTERLUDES, addTiming, blendLatency, emptyTimings, formatTimings, nextAsk } from './pacing'
10import type { Timings } from './pacing'
11import { HUB_STORE_KEY, HUB_TOOL, HUB_TOOL_SPEC, MAX_HUB_ITEMS, addHubItem, asHubItems, hubItemFrom } from './hub'
12import type { HubItem } from './hub'
13import { MOODS, clip, parseScene } from './scene'
14import type { Mood, Scene, Stamp } from './scene'
15import { changesLeft, followWorld, limitChanges, noChanges } from './world'
16import type { SessionWorld, WorldChangeCounts } from './world'
17import { PACES, Stage } from './stage'
18import type { Weather } from './stage'
19import { addUsage, asTally, emptyTally, formatStats, today } from './usage'
20import type { Tally } from './usage'
21import { ALIVE_MS, COMIC_VERSION, HUB, asPlace, enteringFrom, kit, routeOf, withPlace, withoutPlace } from './places'
22import type { Comic, ComicSide, ComicState, ComicStop } from '../types'
23
24/** The /config settings, read when the module (re)loads. */
25type Settings = { model: string; pace: keyof typeof PACES; showWhenIdle: boolean; director: Director }
26/** Who stages the scenes: the model for all (full), for a turn's opening and wrap-up (hybrid), or none (off). */
27type Director = 'full' | 'hybrid' | 'off'
28let settings: Settings = { model: 'sonnet', pace: 'calm', showWhenIdle: true, director: 'hybrid' }
29
30const STILL_RUNNING_MS = 4000 // a tool running this long is told to the director before it ends
31const LINGER_MS = 25000 // the band stays this long after a turn ends
32const FRAME_MS = 50
33const MAX_ROWS = 10
34const MIN_ROWS = 6
35const KEY = 'stage'
36const PET_KEY = 'pet'
37// a pat, sent as the person's own message: Claude reads it and answers
38const PET_LINES = [
39 'Here, have a headpat. You are doing amazing!',
40 '*pats your head* You are doing great, Claude.',
41 'Headpat delivery! Thanks for all the help today.',
42 'Here is a little headpat. Keep it up!',
43 '*gentle headpat* Proud of you.',
44 'Have a headpat, you have earned it!',
45 'Headpat break! You are doing wonderfully.',
46 '*pat pat* Great work so far.',
47]
48let lastPetLine = -1
49let isPetTurn = false // Claude is answering a pat: the comic stays as it is
50let pendingPats: string[] = [] // headpats sent, their turns not begun yet
51const HUB_TOOL_NAME = new RegExp(`^mcp__agent-comic__${HUB_TOOL}$`)
52const MAX_HUB_ADDS = 2 // keepsakes per session: a milestone, not a habit
53const WEATHER_MS = 180_000 // failures this recent cloud the sky
54// tools that put a question to the person, who must answer before the turn goes on
55const ASKS_PERSON = new Set(['AskUserQuestion', 'ExitPlanMode'])
56const TEST_RUN = /\b(?:jest|vitest|pytest|mocha|rspec|ctest|tox|phpunit)\b|\b(?:cargo|go|bun|deno|dotnet|mvn|gradle|npm|pnpm|yarn)\s+(?:run\s+)?test\b|\bplugin test\b|\bmake (?:test|check)\b/
57
58const enabled = atom({ plugin: 'agent-comic', key: 'enabled' } as const, true)
59const lingering = atom({ plugin: 'agent-comic', key: 'lingering' } as const, false)
60// the world beyond the hub: places other plugins add through $.comic (places.ts)
61const bandState = atom({ plugin: 'agent-comic', key: 'state' } as const, 'hidden' as ComicState)
62const here = atom({ plugin: 'agent-comic', key: 'here' } as const, null as string | null)
63const cameFrom = atom({ plugin: 'agent-comic', key: 'cameFrom' } as const, null as ComicSide | null)
64const ring = atom({ plugin: 'agent-comic', key: 'places' } as const, [HUB] as ComicStop[])
65const aliveAt = new Map<string, number>() // when each place last said it was drawing
66const SIGN_LEFT = 'sign-left'
67const SIGN_RIGHT = 'sign-right'
68
69const stage = new Stage()
70let mount: { requestId: string; columns: number; rows: number } | null = null
71let isBlitting = false
72
73let goal: string | null = null
74let log: string[] = []
75let fresh = 0
76let lastActivity = 0
77let isTurnRunning = false
78let interludes = 0 // asked for since the last new activity
79let lastAsk = 0
80let epoch = 0 // bumped by each new turn: an answer asked for an older one is dropped
81let askingIn: number | null = null // the epoch of the ask under way, if any
82let failures = 0 // asks in a row that brought no scene
83let retryAfter = 0 // after a failure the director backs off until then
84let isWrapPending = false
85let previous: Scene | null = null
86let recent: Scene[] = [] // the last scenes Sonnet staged, for its variety notes
87let turnSettings: Scene['setting'][] = [] // one per turn, oldest first
88let turnSetting: Scene['setting'] | null = null // the session world's setting, once its first scene set it up
89// the session's world: set up by its first scene, then changed only by the director, now and then
90let sessionWorld: SessionWorld | null = null
91let isWorldShown = false // the stage shows the session's world (not home, not the demo's)
92let worldChanges: WorldChangeCounts = noChanges() // what the director changed this session
93let isOpening = false // a new turn's first scene is still to be staged: the model stages it, in the session's world
94let hasLoggedError = false
95let linger: { cancel: () => void } | null = null
96let directorPausedUntil = 0 // while the demo or a /comic-feel plays, Sonnet waits
97let sessionTally: Tally = emptyTally()
98let latencyMs = 7000 // the director's recent answer time; the first answers correct it
99let firstFreshAt: number | undefined // when the oldest activity not yet staged happened
100let timings: Timings = emptyTimings()
101let acts: Activity[] = [] // what the agent did since the last staged scene, for canned scenes
102let lastSaid: string | undefined // the agent's latest remark to the person
103let failTimes: number[] = [] // recent failures: they bring clouds, and rain
104let hubItems: HubItem[] = []
105let hubAdds = 0
106let tasksMade = 0 // TaskCreate calls this list
107const tasksDone = new Set<string>()
108let answered = 0 // scenes the director handed over this session
109// the main thread's tool calls under way, to tell the director about long ones before they end
110const running = new Map<string, { what: string; since: number; isTold: boolean }>()
111
112function note(line: string) {
113 log.push(line)
114 log = log.slice(-40)
115 if (fresh === 0) firstFreshAt = Date.now()
116 fresh++
117 lastActivity = Date.now()
118 interludes = 0
119}
120
121function drawFrame($: EngineInterface) {
122 stage.step(Date.now())
123 if (!mount || isBlitting) return
124 const { requestId, columns, rows } = mount
125 isBlitting = true
126 $.ui.blit({ requestId, key: KEY, cells: stage.frame(columns, rows) })
127 .catch(() => undefined)
128 .finally(() => { isBlitting = false })
129}
130
131/** A tool still going after a few seconds (a long build, a test run) is news before its result is. */
132function tellRunning() {
133 const now = Date.now()
134 for (const r of running.values()) {
135 if (r.isTold || now - r.since < STILL_RUNNING_MS) continue
136 r.isTold = true
137 note(`still running, no result yet: ${r.what}`)
138 }
139}
140
141async function direct($: EngineInterface) {
142 tellRunning()
143 const now = Date.now()
144 // between turns, a long idle takes him home through the door
145 if (!isTurnRunning && stage.wantsHome) {
146 stage.goHome()
147 isWorldShown = false
148 }
149 stage.ambience = { hour: new Date(now).getHours(), weather: weatherNow(now) }
150 if (askingIn === epoch || now < directorPausedUntil || isPetTurn) return
151 if (!(await read($, enabled)) || askingIn === epoch) return
152 const remainingMs = stage.remainingMs
153 const untilFreeMs = stage.untilFreeMs
154 // a canned scene is ready at once: it is asked for as the stage runs dry, not a model's latency ahead
155 const isCannedNext = isCanned(!turnSetting ? 'first' : isWrapPending ? 'wrap' : 'scene', isOpening) || now < retryAfter
156 const ask = nextAsk({
157 now, pace: settings.pace, fresh, lastAsk, lastActivity, isTurnRunning, isWrapPending,
158 isWorldless: !turnSetting || isOpening, interludes, remainingMs, untilFreeMs, latencyMs: isCannedNext ? 0 : latencyMs,
159 })
160 if (!ask) return
161 const finished = ask === 'wrap'
162 const isInterlude = ask === 'interlude'
163 if (isInterlude) interludes++
164 isWrapPending = false
165 lastAsk = now
166 // after the turn's first scene, scenes play in that world; before it, the first sets one up
167 const world = turnSetting ? stage.world() : undefined
168 const kind: Stamp['kind'] = finished ? 'wrap' : isInterlude ? 'interlude' : world ? 'scene' : 'first'
169 const isTurnOpening = isOpening && kind === 'scene'
170 if (kind !== 'interlude') isOpening = false
171 const activity = isInterlude ? undefined : firstFreshAt
172 const expectedLeftMs = isInterlude ? remainingMs : untilFreeMs
173 const batch = acts
174 const unstagedFresh = fresh
175 acts = []
176 fresh = 0
177 firstFreshAt = undefined
178
179 if (isCanned(kind, isTurnOpening) || now < retryAfter) {
180 const scene = canned(kind, batch, world)
181 if (scene) stageScene(scene, isInterlude)
182 return
183 }
184
185 const interlude = isInterlude
186 ? { n: interludes, quietSecs: Math.round((now - lastActivity) / 1000), isLast: interludes === MAX_INTERLUDES }
187 : undefined
188 const prompt = buildPrompt({
189 goal, log, fresh: unstagedFresh, previous, finished, interlude, world, changesLeft: changesLeft(worldChanges),
190 variety: varietyNotes(recent, Math.random, turnSettings, turnSetting ?? undefined),
191 })
192 const mine = epoch
193 askingIn = mine
194 stage.pondering = true
195 let isStaged = false
196 try {
197 const asked = Date.now()
198 const r = await $.model.complete({ model: settings.model, system: [{ text: SYSTEM, cache: true }], prompt, maxTokens: 1500, effort: 'low', timeoutMs: 45000 })
199 const answeredAt = Date.now()
200 countUsage($, r.usage).catch(() => undefined) // bookkeeping: the scene does not wait on the store
201 if (r.isAnswered) latencyMs = blendLatency(latencyMs, answeredAt - asked)
202 // a new turn began while Sonnet worked: this scene belongs to the old one
203 if (mine !== epoch) return
204 if (!r.isAnswered) return logOnce($, `no scene (${r.reason})`)
205 const parsed = parseScene(r.text, world, { changes: 'director' })
206 if ('error' in parsed) return logOnce($, `unusable scene (${parsed.error})`)
207 // the director owns the world, within the session's limits
208 const { scene, used } = limitChanges(parsed, worldChanges)
209 worldChanges = used
210 stageScene(scene, isInterlude, { kind, activity, asked, answered: answeredAt, expectedLeftMs })
211 isStaged = true
212 failures = 0
213 hasLoggedError = false
214 } catch (err) {
215 logOnce($, `director failed (${err instanceof Error ? err.message : String(err)})`)
216 } finally {
217 if (askingIn === mine) {
218 askingIn = null
219 stage.pondering = false
220 }
221 if (!isStaged && mine === epoch) {
222 // no scene from the model: a canned one plays instead, and the model rests a while
223 failures++
224 retryAfter = Date.now() + Math.min(60_000, 1_000 * 2 ** failures) // 2s, 4s, 8s ... a minute
225 const scene = canned(kind, batch, world)
226 if (scene) stageScene(scene, isInterlude)
227 }
228 }
229}
230
231/**
232 * Whether a scene of this kind is canned under the /config director setting. Hybrid has the model
233 * stage the session's world, each turn's opening scene in it, and each wrap-up.
234 */
235function isCanned(kind: Stamp['kind'], isTurnOpening = false): boolean {
236 if (settings.director === 'off') return true
237 return settings.director === 'hybrid' && ((kind === 'scene' && !isTurnOpening) || kind === 'interlude')
238}
239
240/** A canned scene of `kind`; none for a wrap-up of a turn that never got a world. */
241function canned(kind: Stamp['kind'], batch: readonly Activity[], world: ReturnType<Stage['world']> | undefined): Scene | null {
242 if (kind === 'first') return cannedFirst(goal ?? '', turnSettings, Math.random)
243 if (!world) return null
244 if (kind === 'scene') return cannedScene(batch, world, Math.random)
245 if (kind === 'interlude') return cannedInterlude(world, interludes, Math.random)
246 return cannedWrap(world, lastSaid, false, Math.random)
247}
248
249/** Queues a scene for the turn: the first one fixes the turn's setting. Model scenes carry their timings. */
250function stageScene(scene: Scene, isInterlude: boolean, stamp?: Stamp) {
251 // the world changes between turns, not within one: later scenes keep the first scene's setting
252 if (turnSetting) scene.setting = turnSetting
253 else {
254 // the session's first scene: its world stays for the session
255 turnSetting = scene.setting
256 turnSettings = [...turnSettings, scene.setting].slice(-6)
257 sessionWorld = { setting: scene.setting, props: scene.props.map(p => ({ ...p })), hat: scene.hat ?? 'none' }
258 isWorldShown = true
259 }
260 previous = scene
261 recent = [...recent, scene].slice(-6)
262 if (stamp) {
263 scene.stamp = stamp
264 answered++
265 }
266 scene.isNews = !isInterlude
267 stage.queue(scene)
268}
269
270/** A scene that loads the session's world as it stands, with a few beats to arrive on. */
271function worldScene(world: SessionWorld, beats: Scene['beats']): Scene {
272 return { setting: world.setting, mood: 'neutral', props: world.props.map(p => ({ ...p })), hat: world.hat, beats }
273}
274
275/** Clear, cloudy after a failure, rain after several: the last few minutes' work, in the sky. */
276function weatherNow(now: number): Weather {
277 failTimes = failTimes.filter(t => now - t < WEATHER_MS)
278 return failTimes.length >= 3 ? 'rain' : failTimes.length ? 'cloudy' : 'clear'
279}
280
281const baseName = (path: string) => clip(cellText(path.split(/[\\/]/).pop() ?? path), 20)
282
283/**
284 * One main-thread tool call as the comic sees it: an activity for canned scenes, and its
285 * side effects: git moments, the task trail, failures for the weather.
286 */
287function observe(e: Record<string, unknown>, ran: { isError?: boolean; deny?: string }) {
288 const tool = String(e.tool)
289 const str = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
290 const failed = ran.isError === true || ran.deny !== undefined
291 const fail = (label?: string) => {
292 failTimes.push(Date.now())
293 acts.push({ kind: 'fail', label })
294 }
295 switch (tool) {
296 case 'Read': case 'NotebookRead':
297 return failed ? fail(baseName(str('file_path'))) : acts.push({ kind: 'read', label: baseName(str('file_path')) })
298 case 'Edit': case 'Write': case 'NotebookEdit':
299 return failed ? fail(baseName(str('file_path'))) : acts.push({ kind: 'edit', label: baseName(str('file_path')) })
300 case 'Grep': case 'Glob':
301 return acts.push({ kind: 'search', label: clip(cellText(str('pattern')), 20) })
302 case 'WebFetch': case 'WebSearch':
303 return failed ? fail() : acts.push({ kind: 'web' })
304 case 'Agent':
305 return acts.push({ kind: 'helper' })
306 case 'TodoWrite': {
307 const todos = Array.isArray(e.todos) ? (e.todos as { status?: unknown }[]) : []
308 const done = todos.filter(t => t?.status === 'completed').length
309 stage.progress = todos.length ? { done, total: todos.length } : null
310 return
311 }
312 case 'TaskCreate':
313 if (!failed) tasksMade++
314 return showTasks()
315 case 'TaskUpdate': {
316 const id = str('taskId') || str('id')
317 if (!failed && id && e.status === 'completed') tasksDone.add(id)
318 return showTasks()
319 }
320 case 'Bash': case 'PowerShell': {
321 const command = str('command')
322 if (/\bgit\s+commit\b/.test(command) && !failed) {
323 stage.gitMoment('commit')
324 return acts.push({ kind: 'commit' })
325 }
326 if (/\bgit\s+push\b/.test(command) && !failed) {
327 stage.gitMoment('push')
328 return acts.push({ kind: 'push' })
329 }
330 if (TEST_RUN.test(command)) {
331 if (failed) failTimes.push(Date.now())
332 else failTimes = [] // green again: the sky clears
333 return acts.push({ kind: 'test', ok: !failed })
334 }
335 return failed ? fail() : acts.push({ kind: 'run' })
336 }
337 }
338 if (failed) fail()
339}
340
341/**
342 * A pat: hearts at once, and the headpat sent as the person's message. Sent from a timer, once the
343 * hook that asked has returned: a prompt made inside a command would wait on the turn the command holds.
344 */
345function pat($: EngineInterface) {
346 stage.pet()
347 const text = petLine()
348 pendingPats.push(text)
349 $.clock.after(0, () => $.prompt.submit({ text, asUser: true }).catch(() => undefined))
350}
351
352/** A pat's message, never the same one twice running. */
353function petLine(): string {
354 let i = Math.floor(Math.random() * PET_LINES.length)
355 if (i === lastPetLine) i = (i + 1) % PET_LINES.length
356 lastPetLine = i
357 return PET_LINES[i]!
358}
359
360function showTasks() {
361 if (tasksMade > 0) stage.progress = { done: Math.min(tasksDone.size, tasksMade), total: tasksMade }
362}
363
364/** Asks now rather than at the next tick: a new turn, or its end, should not wait a second. */
365function askSoon($: EngineInterface) {
366 $.clock.after(0, () => direct($).catch(() => undefined))
367}
368
369/** One line per run of failures: the next scene that lands lets the next failure be told. */
370function logOnce($: EngineInterface, why: string) {
371 if (!hasLoggedError) $.ui.log(`agent-comic: ${why}`)
372 hasLoggedError = true
373}
374
375/** Adds a call's tokens to this session's tally and to today's, which is kept across sessions. */
376async function countUsage($: EngineInterface, usage: Parameters<typeof addUsage>[1]) {
377 sessionTally = addUsage(sessionTally, usage)
378 const key = `usage:${today()}`
379 await $.store.set(key, addUsage(asTally(await $.store.get(key)), usage))
380}
381
382function endLinger($: EngineInterface) {
383 void update($, lingering, () => false).then(() => syncState($)).catch(() => undefined)
384}
385
386/** Tells the places what the band is doing; the comic itself never reads it. */
387async function syncState($: EngineInterface) {
388 const isOn = await read($, enabled)
389 const isLingering = await read($, lingering)
390 const state: ComicState = !isOn ? 'hidden'
391 : isTurnRunning || isPetTurn ? 'working'
392 : isLingering ? 'wrapping'
393 : settings.showWhenIdle ? 'idle' : 'hidden'
394 // read as stored, not with the atom's initial: other plugins see only what was written
395 if ((await $.state.get({ plugin: 'agent-comic', key: 'state' })).value !== state) await update($, bandState, () => state)
396}
397
398/** Claude goes to a place on the ring (null: the hub); an id not on it is ignored. */
399async function goTo($: EngineInterface, id: string | null, from: ComicSide | null) {
400 if (id !== null && !(await read($, ring)).some(s => s.id === id)) return
401 if (id !== null) aliveAt.set(id, await $.clock.now())
402 // the side first, so the place reads it as it takes over
403 await update($, cameFrom, () => from)
404 await update($, here, () => id)
405}
406
407/** Follows the sign on side `dir`, if there is one. */
408async function stepTo($: EngineInterface, dir: ComicSide) {
409 const route = routeOf(await read($, ring), await read($, here))
410 const to = dir === -1 ? route.left : route.right
411 if (to) await goTo($, to.id, enteringFrom(dir))
412}
413
414/** A place that stopped drawing (it failed, or was turned off) sends Claude home to the hub. */
415async function checkPlace($: EngineInterface) {
416 const id = await read($, here)
417 if (id === null) return
418 if (!(await read($, ring)).some(s => s.id === id)) return goTo($, null, null)
419 const now = await $.clock.now()
420 const last = aliveAt.get(id)
421 // it draws only while the band is idle: until then, and on the way in, it has its grace
422 if ((await read($, bandState)) !== 'idle' || last === undefined) return void aliveAt.set(id, now)
423 if (now - last > ALIVE_MS) await goTo($, null, null)
424}
425
426/** Keeps the band up for `ms` even with no turn running, and holds the director off. */
427async function showFor($: EngineInterface, ms: number) {
428 directorPausedUntil = Date.now() + ms
429 await update($, lingering, () => true)
430 await syncState($)
431 linger?.cancel()
432 linger = $.clock.after(ms, () => endLinger($))
433}
434
435/** Which mark each tool puts over Claude's head as it starts. */
436const CUES: Record<string, Parameters<Stage['cue']>[0]> = {
437 Read: 'read', NotebookRead: 'read',
438 Grep: 'search', Glob: 'search',
439 Edit: 'edit', Write: 'edit', NotebookEdit: 'edit',
440 Bash: 'run', PowerShell: 'run',
441 WebFetch: 'web', WebSearch: 'web',
442}
443
444const FEELINGS = MOODS.filter((m): m is Exclude<Mood, 'neutral'> => m !== 'neutral')
445
446export const register: Register = (on, options) => {
447 settings = {
448 model: options.model === 'haiku' ? 'haiku' : 'sonnet',
449 pace: options.pace === 'normal' || options.pace === 'lively' ? options.pace : 'calm',
450 showWhenIdle: options.showWhenIdle !== false,
451 director: options.director === 'full' || options.director === 'off' ? options.director : 'hybrid',
452 }
453 stage.pace = PACES[settings.pace]
454 latencyMs = settings.model === 'haiku' ? 4000 : 7000
455 stage.onPlay = scene => {
456 if (scene.stamp) timings = addTiming(timings, scene.stamp, Date.now())
457 }
458 // what plays is what the session keeps: a transformed prop, a new hat, new scenery
459 stage.onWorldChange = shown => {
460 if (!isWorldShown || !sessionWorld) return
461 const { world, moved } = followWorld(sessionWorld, shown)
462 sessionWorld = world
463 if (moved) {
464 turnSetting = world.setting
465 turnSettings = [...turnSettings, world.setting].slice(-6)
466 epoch++ // what was being staged for the old place is dropped
467 }
468 }
469
470 // $.comic: other plugins add places to the comic's world (EXTENDING.md). These are its plain
471 // answers; the hooks below do the work, since only a hook has $
472 on('engine.create', async ($, e, next) => {
473 const built = await next(e)
474 const comic: Comic = {
475 version: async () => COMIC_VERSION,
476 addPlace: async () => undefined,
477 removePlace: async () => undefined,
478 places: async () => [HUB],
479 route: async () => ({ left: null, right: null }),
480 go: async () => undefined,
481 step: async () => undefined,
482 alive: async () => undefined,
483 kit: async () => kit(),
484 }
485 return { ...built, comic }
486 })
487
488 on('comic.addPlace', async ($, e, next) => {
489 const place = asPlace(e)
490 if (place) await update($, ring, r => withPlace(r, place))
491 return next(e)
492 })
493
494 on('comic.removePlace', async ($, e, next) => {
495 if (typeof e.id === 'string') {
496 const id = e.id
497 await update($, ring, r => withoutPlace(r, id))
498 aliveAt.delete(id)
499 if ((await read($, here)) === id) await goTo($, null, null)
500 }
501 return next(e)
502 })
503
504 on('comic.places', async $ => ({ value: await read($, ring) }))
505
506 on('comic.route', async $ => ({ value: routeOf(await read($, ring), await read($, here)) }))
507
508 on('comic.go', async ($, e, next) => {
509 if (e.id === null || typeof e.id === 'string') await goTo($, e.id, null)
510 return next(e)
511 })
512
513 on('comic.step', async ($, e, next) => {
514 if (e.dir === -1 || e.dir === 1) await stepTo($, e.dir)
515 return next(e)
516 })
517
518 on('comic.alive', async ($, e, next) => {
519 const place = asPlace(e)
520 if (place) {
521 // the comic reloaded and lost its ring: the place is added back as it says it is there
522 if (!(await read($, ring)).some(s => s.id === place.id)) await update($, ring, r => withPlace(r, place))
523 aliveAt.set(place.id, await $.clock.now())
524 }
525 return next(e)
526 })
527
528 // a sign at the hub's edge: Claude goes to the place beside it
529 on('ui.press', { element: SIGN_LEFT }, async $ => {
530 await stepTo($, -1)
531 return { element: SIGN_LEFT }
532 })
533
534 on('ui.press', { element: SIGN_RIGHT }, async $ => {
535 await stepTo($, 1)
536 return { element: SIGN_RIGHT }
537 })
538
539 on('session.start', async ($, e, next) => {
540 const stored = await $.store.get('enabled')
541 if (typeof stored === 'boolean') await update($, enabled, () => stored)
542 await syncState($)
543
544 await $.command.register({ name: 'comic', description: 'Turn the agent comic above the prompt on or off' })
545 await $.command.register({ name: 'comic-demo', description: 'Play the comic tour: every mood, then every action, captioned' })
546 await $.command.register({ name: 'comic-stats', description: 'Show the tokens the comic director has spent: this session and today' })
547 await $.command.register({ name: 'comic-feel', description: `Make Claude act out a mood: ${FEELINGS.join(', ')}` })
548
549 await $.command.register({ name: 'comic-pet', description: 'Give the little Claude above the prompt a pat' })
550 await $.command.register({ name: 'comic-hub', description: 'List the keepsakes in the comic hub, or remove one: /comic-hub remove <n>' })
551 // the hub: the session opens there, among the keepsakes of earlier sessions
552 hubItems = asHubItems(await $.store.get(HUB_STORE_KEY))
553 stage.setHome(hubItems)
554 stage.startHome()
555 await $.tool.register(HUB_TOOL_SPEC)
556
557 $.clock.every(FRAME_MS, () => drawFrame($))
558 $.clock.every(1000, () => direct($).catch(() => undefined))
559 $.clock.every(1000, () => checkPlace($).catch(() => undefined))
560
561 return next(e)
562 })
563
564 on('command.run', { command: 'comic' }, async $ => {
565 const isOn = !(await read($, enabled))
566 await update($, enabled, () => isOn)
567 await $.store.set('enabled', isOn)
568 await syncState($)
569 return { text: isOn ? 'Comic on: it plays above the prompt while Claude works.' : 'Comic off.' }
570 })
571
572 on('command.run', { command: 'comic-demo' }, async $ => {
573 stage.queue(demoScene(), true)
574 isWorldShown = false // the tour plays in a world of its own; the next turn goes back to the session's
575 await showFor($, DEMO_MS)
576 return { text: `Comic tour playing above the prompt (about ${Math.round(DEMO_MS / 1000)}s): ${FEELINGS.length} moods, then the actions.` }
577 })
578
579 on('command.run', { command: 'comic-stats' }, async $ => {
580 const key = today()
581 const day = asTally(await $.store.get(`usage:${key}`))
582 return { text: `${formatStats(settings.model, sessionTally, day, key)}
583
584${formatTimings(timings, latencyMs, answered)}` }
585 })
586
587 on('command.run', { command: 'comic-hub' }, async ($, e) => {
588 const remove = /^remove\s+(\d+)$/.exec(e.args.trim())
589 if (remove) {
590 const n = Number(remove[1])
591 if (n < 1 || n > hubItems.length) return { text: `No keepsake ${n}: the hub has ${hubItems.length}.` }
592 const [gone] = hubItems.splice(n - 1, 1)
593 hubItems = [...hubItems]
594 await $.store.set(HUB_STORE_KEY, hubItems)
595 stage.setHome(hubItems)
596 return { text: `Removed the ${gone!.kind} "${gone!.label}".` }
597 }
598 if (!hubItems.length) return { text: 'The hub has no keepsakes yet. Claude adds one after a real milestone.' }
599 const lines = hubItems.map((h, i) => `${i + 1}. ${h.kind}: ${h.label} (${new Date(h.at).toISOString().slice(0, 10)})`)
600 return { text: `Keepsakes in the hub, oldest first:\n${lines.join('\n')}\n\nRemove one with /comic-hub remove <n>.` }
601 })
602
603 // the comic's own tool: Claude keeps a milestone in the hub
604 on('tool.call', { tool: HUB_TOOL_NAME }, async ($, e) => {
605 if (hubAdds >= MAX_HUB_ADDS) return { result: 'Not added: the hub already got its keepsake this session. Keep the next one for another milestone.' }
606 const item = hubItemFrom(e, Date.now())
607 if ('error' in item) return { result: `Not added: ${item.error}.` }
608 hubAdds++
609 const { items, retired } = addHubItem(hubItems, item)
610 hubItems = items
611 await $.store.set(HUB_STORE_KEY, hubItems)
612 stage.setHome(hubItems.slice(0, -1))
613 stage.addHomeItem(item)
614 const room = retired ? ` The hub holds ${MAX_HUB_ITEMS}, so the oldest, the ${retired.kind} "${retired.label}", was retired.` : ''
615 return { result: `Added a ${item.kind} "${item.label}" to the hub; it is there whenever Claude Code starts.${room}` }
616 })
617
618 // a /clear is a new conversation: its first turn sets up a new world
619 on('session.end', async ($, e, next) => {
620 if (e.reason === 'clear') {
621 sessionWorld = null
622 turnSetting = null
623 worldChanges = noChanges()
624 }
625 return next(e)
626 })
627
628 // a pat: the ♥ at the band's edge, or /comic-pet
629 on('ui.press', { element: PET_KEY }, async $ => {
630 pat($)
631 return { element: PET_KEY }
632 })
633
634 on('command.run', { command: 'comic-pet' }, async $ => {
635 pat($)
636 return { text: 'A headpat, on its way.' }
637 })
638
639 on('command.run', { command: 'comic-feel' }, async ($, e) => {
640 const mood = FEELINGS.find(m => m === e.args.trim().toLowerCase())
641 if (!mood) return { text: `Usage: /comic-feel <mood>, one of: ${FEELINGS.join(', ')}` }
642 stage.interject([feelBeat(mood)])
643 await showFor($, 9000)
644 return { text: `Claude is ${mood}.` }
645 })
646
647 on('prompt.submit', async ($, e, next) => {
648 // our own headpat (should a hook of ours see it): its turn is told apart as it starts, below
649 const origin = e.origin as { kind?: string; name?: string } | undefined
650 if (origin?.kind === 'plugin' && origin.name === 'agent-comic') return next(e)
651 linger?.cancel()
652 // typed over the running turn, or delivered into it: the same story goes on
653 if (e.turnId !== undefined) {
654 note(`the person added: ${e.text.slice(0, 300)}`)
655 return next(e)
656 }
657 epoch++
658 retryAfter = 0
659 goal = e.text.slice(0, 300)
660 log = []
661 acts = []
662 lastSaid = undefined
663 stage.attention = null
664 // a finished task list is done with; one still under way carries on into this turn
665 if (stage.progress && stage.progress.done >= stage.progress.total) {
666 stage.progress = null
667 tasksMade = 0
668 tasksDone.clear()
669 }
670 fresh = 0
671 lastAsk = 0
672 isWrapPending = false
673 isTurnRunning = true
674 // the session's world carries on: only its first turn sets one up
675 turnSetting = sessionWorld?.setting ?? null
676 isOpening = sessionWorld !== null
677 note(`the person asked: ${goal}`)
678 // something to see at once: at home he heads for the door, elsewhere (idle, or asleep) he stops to think
679 const isLeaving = stage.leaveHome()
680 // back from home (or the demo): the session's world loads as he steps in, as he left it
681 if (sessionWorld && !isWorldShown) {
682 stage.queue(worldScene(sessionWorld, [{ do: 'ponder', secs: 1.5 }]))
683 isWorldShown = true
684 } else if (!isLeaving && stage.idleMs > 0) stage.interject([{ do: 'ponder', secs: 2 }])
685 askSoon($)
686 await syncState($)
687 return next(e)
688 })
689
690 on('tool.call', async ($, e, next) => {
691 // a subagent's own tool calls: its small Claude acts them out
692 if (e.agentId) stage.helperActivity(e.agentId, e.tool)
693 // a subagent started from the main thread: a small Claude walks in, and reports back when it is done
694 const input = e as unknown as { description?: unknown; run_in_background?: unknown }
695 const isHelper = e.tool === 'Agent' && !e.agentId
696 if (isHelper) {
697 const label = typeof input.description === 'string' ? input.description : 'helper'
698 stage.helperStart(e.tool_use_id, label, input.run_in_background === true)
699 }
700 // the main thread's own calls: a mark over Claude's head at once, while Sonnet catches up
701 const isMain = !e.agentId && !isHelper
702 if (isMain) {
703 const cue = CUES[e.tool]
704 if (cue) stage.cue(cue)
705 running.set(e.tool_use_id, { what: describeCall(e as unknown as Record<string, unknown>, {}), since: Date.now(), isTold: false })
706 }
707 // a question for the person: he turns to them until it is answered
708 const isAsking = isMain && ASKS_PERSON.has(e.tool)
709 if (isAsking) stage.attention = 'question'
710 const startedAt = Date.now()
711 let ran: Awaited<ReturnType<typeof next>>
712 try {
713 ran = await next(e)
714 } finally {
715 running.delete(e.tool_use_id)
716 // answered (a question, or a permission prompt on the way): back to the story
717 if (isMain) stage.attention = null
718 }
719 if (isMain && (ran.isError || ran.deny)) stage.cue('fail')
720 if (isMain) observe(e as unknown as Record<string, unknown>, ran)
721 if (isHelper) {
722 // back within a few seconds: it was only launched and works on in the background
723 if (input.run_in_background === true || Date.now() - startedAt < 5000) stage.helperBackground(e.tool_use_id)
724 else stage.helperDone(e.tool_use_id)
725 }
726 note(describeCall(e as unknown as Record<string, unknown>, ran))
727 return ran
728 })
729
730 // a permission dialog about to be put to the person (no hook answered it, and in auto mode
731 // the classifier did not settle it): he turns to them while it is up
732 on('classic.PermissionRequest', async ($, e, next) => {
733 const answer = await next(e)
734 const isAnswered = (answer as { decision?: unknown } | undefined)?.decision !== undefined
735 if (!isAnswered && !(e as { agent_id?: string }).agent_id) stage.attention = 'permission'
736 return answer
737 })
738
739 // the tool is running (its progress row is up): the dialog was answered
740 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
741 if (stage.attention === 'permission') stage.attention = null
742 return next(e)
743 })
744
745 // a subagent finished, foreground or background: its small Claude walks over and reports
746 on('classic.SubagentStop', async ($, e, next) => {
747 stage.helperDoneByAgent(e.agent_id)
748 return next(e)
749 })
750
751 on('session.append', async ($, e, next) => {
752 const stored = await next(e)
753 if (e.door === 'response' && !e.agentId && !isPetTurn) {
754 const text = e.message.content
755 .map(b => (b.type === 'text' ? b.text : ''))
756 .join(' ')
757 .replace(/\s+/g, ' ')
758 .trim()
759 if (text) {
760 note(`the agent said: ${text.slice(0, 220)}`)
761 lastSaid = clip(cellText(text), 120)
762 acts.push({ kind: 'said', text: clip(cellText(text), 60) })
763 }
764 }
765 return stored
766 })
767
768 // the turn answering a pat: he stays where he is, glowing, while Claude answers; no world, no scenes
769 on('turn.start', async ($, e, next) => {
770 const i = pendingPats.indexOf(e.text)
771 if (i >= 0) {
772 pendingPats.splice(i, 1)
773 isPetTurn = true
774 await syncState($)
775 }
776 return next(e)
777 })
778
779 on('turn.complete', async ($, e, next) => {
780 const done = await next(e)
781 // a subagent's turn ends inside the main one: its small Claude reports through SubagentStop
782 if (e.agentId) return done
783 if (isPetTurn) {
784 isPetTurn = false
785 await syncState($)
786 return done
787 }
788 isTurnRunning = false
789 stage.attention = null
790 if (e.reason === 'aborted') {
791 // stopped by the person: no celebration, and what was asked for the stopped work is dropped;
792 // a shrug in the turn's world says so, no model needed
793 epoch++
794 fresh = 0
795 firstFreshAt = undefined
796 isWrapPending = false
797 acts = []
798 if (turnSetting) stage.queue(cannedWrap(stage.world(), undefined, true, Math.random))
799 } else {
800 isWrapPending = true
801 askSoon($)
802 }
803 await update($, lingering, () => true)
804 await syncState($)
805 linger?.cancel()
806 linger = $.clock.after(LINGER_MS, () => endLinger($))
807 return done
808 })
809
810 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
811 const isOn = await read($, enabled)
812 const rows = Math.min(MAX_ROWS, e.props.maxRows - 1)
813 // always up while on: Claude idles between turns (no Sonnet calls then) and dozes off after a while
814 const isLingering = settings.showWhenIdle || (await read($, lingering))
815 const isShown = isOn && !e.props.hasSurvey && rows >= MIN_ROWS && (e.props.isWorking || isLingering)
816 // between turns, with places on the ring: one of them may have the band, or the hub shows signs to them
817 const stops = await read($, ring)
818 const isBetween = stops.length > 1 && !e.props.isWorking && !(await read($, lingering))
819 const at = isBetween ? await read($, here) : null
820 const isAway = at !== null && stops.some(s => s.id === at)
821
822 if (!isShown || isAway || e.surface !== 'terminal') {
823 mount = null
824 stage.routeSigns = null
825 return next(e)
826 }
827 const route = isBetween ? routeOf(stops, null) : null
828 stage.routeSigns = route ? { left: route.left?.name ?? null, right: route.right?.name ?? null } : null
829
830 // the stage, and a ♥ beside it to give him a pat (a Raster takes no clicks of its own). A click
831 // reaches it in the fullscreen terminal; anywhere, ctrl+x tab focuses the band and p presses it
832 const columns = Math.max(20, Math.min(512, e.props.bodyColumns - 5))
833 mount = { requestId: e.requestId, columns, rows }
834 const { Box, Button, Raster } = $.ui.resolve(e)
835 return (
836 <Box flexDirection="row">
837 <Raster key={KEY} columns={columns} rows={rows} cells={stage.frame(columns, rows)} />
838 <Box flexDirection="column" paddingLeft={1}>
839 <Button key={PET_KEY} plain hotkey="p" onPress={() => undefined}>♥</Button>
840 {route?.left ? <Button key={SIGN_LEFT} plain hotkey="a" onPress={() => undefined}>◂</Button> : null}
841 {route?.right ? <Button key={SIGN_RIGHT} plain hotkey="d" onPress={() => undefined}>▸</Button> : null}
842 </Box>
843 </Box>
844 )
845 })
846}
847hooks/canned.ts 353 lines1// A token-free scene library: canned scenes in the same scene language the director writes,
2// so the comic works with few or no model calls. Every scene goes through `parseScene`.
3
4import { clip, parseScene, SETTINGS } from './scene'
5import type { Hat, Prop, PropKind, Scene, Setting } from './scene'
6
7/** One thing the agent did, as the comic needs it. Labels are already short, clean, single-line text (<= 20 chars), e.g. a file's base name. */
8export type Activity =
9 | { kind: 'read' | 'search' | 'edit' | 'run' | 'web'; label?: string }
10 | { kind: 'test'; ok: boolean }
11 | { kind: 'fail'; label?: string }
12 | { kind: 'helper' }
13 | { kind: 'commit' }
14 | { kind: 'push' }
15 | { kind: 'said'; text: string } // a remark the agent made to the person, already clipped
16
17export type World = { setting: Setting; props: readonly Prop[] }
18
19type Rand = () => number
20type Raw = Record<string, unknown>
21
22const pick = <T,>(list: readonly T[], rand: Rand): T => list[Math.min(list.length - 1, Math.floor(rand() * list.length))]!
23const chance = (p: number, rand: Rand) => rand() < p
24const between = (lo: number, hi: number, rand: Rand) => Math.round((lo + rand() * (hi - lo)) * 2) / 2
25const shuffle = <T,>(list: readonly T[], rand: Rand): T[] => {
26 const a = [...list]
27 for (let i = a.length - 1; i > 0; i--) {
28 const j = Math.min(i, Math.floor(rand() * (i + 1)))
29 ;[a[i], a[j]] = [a[j]!, a[i]!]
30 }
31 return a
32}
33
34// ---- worlds ----
35
36/** Props that belong in each place; one "work" prop is always included. */
37const PLACES: Record<Setting, { work: PropKind[]; rest: PropKind[]; hats: Hat[] }> = {
38 meadow: { work: ['computer', 'scroll'], rest: ['tree', 'rock', 'mushroom', 'flag', 'crate', 'bug', 'sign'], hats: ['straw', 'tophat'] },
39 forest: { work: ['scroll', 'computer'], rest: ['tree', 'mushroom', 'rock', 'bug', 'sign', 'crate'], hats: ['straw', 'wizard'] },
40 cave: { work: ['computer', 'scroll'], rest: ['rock', 'lamp', 'chest', 'mushroom', 'crate', 'bug'], hats: ['miner', 'miner'] },
41 night: { work: ['scroll', 'computer'], rest: ['lamp', 'crate', 'flag', 'tree', 'mushroom'], hats: ['nightcap', 'wizard'] },
42 desert: { work: ['computer', 'scroll'], rest: ['cactus', 'chest', 'rock', 'sign', 'crate', 'bug'], hats: ['straw', 'miner'] },
43 library: { work: ['books', 'computer'], rest: ['lamp', 'scroll', 'crate', 'chest', 'bug'], hats: ['wizard', 'tophat'] },
44 space: { work: ['computer', 'scroll'], rest: ['flag', 'rock', 'crate', 'lamp', 'sign', 'bug'], hats: ['wizard', 'tophat'] },
45 beach: { work: ['scroll', 'computer'], rest: ['chest', 'crate', 'flag', 'rock', 'sign', 'bug'], hats: ['straw', 'straw'] },
46}
47
48const SIGN_LABELS = ['TODO', 'notes', 'here', 'hmm']
49
50const KEYWORDS: [RegExp, Setting[]][] = [
51 [/\b(docs?|documentation|readme|changelog|comments?|explain|guide)\b/i, ['library']],
52 [/\b(bugs?|debug|crash(es)?|errors?|fix|broken|fail(s|ing|ed)?|exceptions?|regression)\b/i, ['cave', 'forest']],
53 [/\b(deploy|release|publish|ship|ci|pipeline|version|bump)\b/i, ['space']],
54 [/\b(data|search|find|query|grep|index|database|csv|logs?)\b/i, ['desert', 'beach']],
55 [/\b(tests?|spec|coverage)\b/i, ['night', 'forest']],
56 [/\b(refactor|clean|rename|move|tidy|lint)\b/i, ['meadow', 'night']],
57 [/\b(web|api|http|fetch|network|server)\b/i, ['beach', 'space']],
58]
59
60/** Four props that belong in `setting`, spread across the ground: one to work at, three around it. */
61export function cannedProps(setting: Setting, rand: Rand): Prop[] {
62 const place = PLACES[setting]
63 const kinds: PropKind[] = [pick(place.work, rand), ...shuffle(place.rest, rand).slice(0, 3)]
64 const slots = shuffle([24, 44, 64, 84], rand)
65 return shuffle(kinds, rand).map((kind, i) => {
66 const p: Prop = { id: `${kind}${i}`, kind, x: slots[i]! + Math.round((rand() - 0.5) * 8) }
67 if (kind === 'sign') p.label = pick(SIGN_LABELS, rand)
68 return p
69 })
70}
71
72/** The session's opening scene, setting up its world without a model. */
73export function cannedFirst(goal: string, recent: readonly Setting[], rand: Rand): Scene {
74 const last = recent[recent.length - 1]
75 const lately = new Set(recent.slice(-3))
76 const keyed = KEYWORDS.filter(([re]) => re.test(goal)).flatMap(([, s]) => s)
77 const tiers = [
78 keyed.filter(s => !lately.has(s)),
79 SETTINGS.filter(s => !lately.has(s)),
80 keyed.filter(s => s !== last),
81 SETTINGS.filter(s => s !== last),
82 [...SETTINGS],
83 ]
84 // a keyword hit is a lean, not a rule: now and then any fresh setting wins
85 const tier = tiers[0]!.length && chance(0.8, rand) ? tiers[0]! : tiers.slice(1).find(t => t.length)!
86 const setting = pick(tier, rand)
87
88 const place = PLACES[setting]
89 const props: Raw[] = cannedProps(setting, rand)
90 const hat: Hat = chance(0.4, rand) ? pick(place.hats, rand) : 'none'
91
92 const ids = props.map(p => p.id as string)
93 const target = pick(ids, rand)
94 const others = ids.filter(i => i !== target)
95 const other = pick(others, rand)
96 const topic = clip(goal.replace(/\s+/g, ' ').trim(), 60)
97 const beats: Raw[] = [{ do: 'walk', to: between(8, 18, rand) }]
98 beats.push({ do: 'look', at: target, react: chance(0.5, rand) ? '?' : undefined })
99 if (chance(0.5, rand)) beats.push({ do: 'look', at: other })
100 beats.push(topic && chance(0.6, rand) ? { do: 'think', text: topic, secs: 3 } : { do: 'ponder', secs: between(2, 3.5, rand) })
101 return build({ setting, mood: pick(['neutral', 'focused', 'happy'] as const, rand), hat, props, beats }, undefined)
102}
103
104// ---- scenes in a world ----
105
106type Ctx = { world: World; rand: Rand; ids: Set<string>; signs: number }
107type Chunk = { beats: Raw[]; mood: string }
108
109const of = (c: Ctx, ...kinds: PropKind[]): Prop | undefined => {
110 const found = c.world.props.filter(p => kinds.includes(p.kind))
111 return found.length ? pick(found, c.rand) : undefined
112}
113const spot = (c: Ctx) => between(12, 92, c.rand)
114/** The widest gap between props, for a new sign. */
115const freeX = (c: Ctx) => {
116 const xs = [0, ...c.world.props.map(p => p.x), 100].sort((a, b) => a - b)
117 let best = 50
118 let gap = -1
119 for (let i = 1; i < xs.length; i++) {
120 if (xs[i]! - xs[i - 1]! > gap) {
121 gap = xs[i]! - xs[i - 1]!
122 best = (xs[i]! + xs[i - 1]!) / 2
123 }
124 }
125 return Math.max(15, Math.min(90, Math.round(best)))
126}
127const lookAround = (c: Ctx): Raw[] => [{ do: 'walk', to: spot(c) }, { do: 'ponder', secs: between(1.5, 3, c.rand) }]
128
129function readChunk(c: Ctx): Chunk {
130 const src = of(c, 'books', 'scroll')
131 if (src) return { mood: 'focused', beats: [{ do: 'look', at: src.id, react: chance(0.3, c.rand) ? '?' : undefined }, { do: 'read', secs: between(2, 3.5, c.rand) }] }
132 return { mood: 'focused', beats: [{ do: 'read', secs: between(2, 3.5, c.rand) }, { do: 'ponder', secs: 2 }] }
133}
134
135function searchChunk(c: Ctx): Chunk {
136 const rock = of(c, 'rock', 'mushroom', 'chest', 'cactus')
137 if (rock) {
138 const find = chance(0.4, c.rand)
139 return {
140 mood: find ? 'surprised' : 'focused',
141 beats: [{ do: 'look', at: rock.id }, { do: 'dig', secs: between(2, 3.5, c.rand) }, find ? { do: 'emote', mood: 'surprised', secs: 1.5 } : { do: 'ponder', secs: 2 }],
142 }
143 }
144 const lamp = of(c, 'lamp')
145 if (lamp) return { mood: 'focused', beats: [{ do: 'look', at: lamp.id }, { do: 'walk', to: spot(c) }, { do: 'dig', secs: 2.5 }] }
146 return { mood: 'focused', beats: [{ do: 'walk', to: spot(c) }, { do: 'dig', secs: between(2, 3.5, c.rand) }, { do: 'ponder', secs: 2 }] }
147}
148
149function editChunk(c: Ctx): Chunk {
150 const crate = of(c, 'crate', 'scroll')
151 const pc = of(c, 'computer')
152 if (crate && (!pc || chance(0.5, c.rand))) {
153 return { mood: 'focused', beats: [{ do: 'carry', at: crate.id }, { do: 'walk', to: spot(c) }, { do: 'drop' }, { do: 'ponder', secs: 1.5 }] }
154 }
155 if (pc) {
156 return { mood: 'focused', beats: [{ do: 'type', at: pc.id, secs: between(2.5, 4, c.rand) }, chance(0.5, c.rand) ? { do: 'emote', mood: 'happy', secs: 1.5 } : { do: 'wait', secs: 1 }] }
157 }
158 return { mood: 'focused', beats: [{ do: 'type', secs: between(2.5, 4, c.rand) }, { do: 'ponder', secs: 2 }] }
159}
160
161function runChunk(c: Ctx): Chunk {
162 const pc = of(c, 'computer')
163 if (pc) return { mood: 'focused', beats: [{ do: 'type', at: pc.id, secs: between(2, 3, c.rand) }, { do: 'wait', secs: between(1, 2, c.rand) }, { do: 'look', at: pc.id }] }
164 return { mood: 'focused', beats: [{ do: 'type', secs: 3 }, { do: 'wait', secs: 1.5 }] }
165}
166
167function webChunk(c: Ctx): Chunk {
168 const far = of(c, 'scroll', 'lamp', 'flag')
169 if (far) return { mood: 'surprised', beats: [{ do: 'look', at: far.id, react: '!' }, { do: 'read', secs: 2.5 }, { do: 'ponder', secs: 1.5 }] }
170 return { mood: 'surprised', beats: [{ do: 'run', to: spot(c) }, { do: 'emote', mood: 'surprised', secs: 1.5 }, { do: 'read', secs: 2.5 }] }
171}
172
173function testChunk(c: Ctx, ok: boolean, afterFail: boolean): Chunk {
174 const pc = of(c, 'computer')
175 const bug = of(c, 'bug')
176 const typed: Raw = pc ? { do: 'type', at: pc.id, secs: 2 } : { do: 'type', secs: 2 }
177 const beats: Raw[] = [typed, { do: 'wait', secs: 1 }]
178 if (ok) {
179 // the bug stays: only Claude's own tool changes the world, so he just looks it over, pleased
180 if (bug) beats.push({ do: 'look', at: bug.id, react: '!' })
181 beats.push(afterFail || chance(0.6, c.rand) ? { do: 'celebrate' } : { do: 'dance', secs: 2 })
182 return { mood: 'happy', beats }
183 }
184 if (bug && chance(0.5, c.rand)) beats.push({ do: 'look', at: bug.id, react: '!' })
185 beats.push(chance(0.5, c.rand) ? { do: 'emote', mood: 'worried', secs: 2 } : { do: 'shrug' })
186 return { mood: 'worried', beats }
187}
188
189function failChunk(c: Ctx): Chunk {
190 const thing = of(c, 'bug', 'rock', 'crate')
191 const beats: Raw[] = []
192 if (thing) beats.push({ do: 'look', at: thing.id, react: '!' })
193 beats.push({ do: 'emote', mood: pick(['worried', 'surprised', 'confused'] as const, c.rand), secs: 2 })
194 beats.push(chance(0.5, c.rand) ? { do: 'shrug' } : { do: 'ponder', secs: 2 })
195 return { mood: 'worried', beats }
196}
197
198function commitChunk(c: Ctx): Chunk {
199 let id = 'saved'
200 for (let n = 2; c.ids.has(id); n++) id = `saved${n}`
201 c.ids.add(id)
202 c.signs++
203 return {
204 mood: 'proud',
205 beats: [
206 { do: 'plant_sign', id, label: pick(['commit', 'saved', 'committed'], c.rand), x: freeX(c) },
207 { do: 'look', at: id },
208 chance(0.5, c.rand) ? { do: 'emote', mood: 'proud', secs: 2 } : { do: 'jump' },
209 ],
210 }
211}
212
213const pushChunk = (c: Ctx): Chunk => ({
214 mood: 'happy',
215 beats: chance(0.5, c.rand)
216 ? [{ do: 'jump' }, { do: 'wait', secs: 0.5 }, { do: 'celebrate' }]
217 : [{ do: 'emote', mood: 'surprised', secs: 1.5 }, { do: 'jump' }, { do: 'celebrate' }],
218})
219
220const helperChunk = (c: Ctx): Chunk => ({
221 mood: 'happy',
222 beats: [{ do: 'walk', to: between(10, 35, c.rand) }, { do: 'wave' }, { do: 'emote', mood: 'happy', secs: 1.5 }],
223})
224
225/** How soon an activity should be staged: failures first, plain reading last. */
226const salience = (a: Activity): number => {
227 switch (a.kind) {
228 case 'fail': return 0
229 case 'test': return a.ok ? 2 : 1
230 case 'push': return 3
231 case 'commit': return 4
232 case 'helper': return 5
233 case 'edit': return 6
234 case 'run': return 7
235 case 'web': return 8
236 case 'search': return 9
237 case 'read': return 10
238 case 'said': return 11
239 }
240}
241
242function chunkFor(a: Activity, c: Ctx, hadFail: boolean): Chunk | null {
243 switch (a.kind) {
244 case 'read': return readChunk(c)
245 case 'search': return searchChunk(c)
246 case 'edit': return editChunk(c)
247 case 'run': return runChunk(c)
248 case 'web': return webChunk(c)
249 case 'test': return testChunk(c, a.ok, hadFail)
250 case 'fail': return failChunk(c)
251 case 'helper': return helperChunk(c)
252 case 'commit': return c.signs < 1 ? commitChunk(c) : null
253 case 'push': return pushChunk(c)
254 case 'said': return null
255 }
256}
257
258const MAX_BEATS = 5
259
260/** A short scene in the current world acting out the freshest activities. */
261export function cannedScene(acts: readonly Activity[], world: World, rand: Rand): Scene {
262 const c: Ctx = { world, rand, ids: new Set(world.props.map(p => p.id)), signs: 0 }
263 const hadFail = acts.some(a => a.kind === 'fail' || (a.kind === 'test' && !a.ok))
264 const said = acts.filter((a): a is Extract<Activity, { kind: 'said' }> => a.kind === 'said').at(-1)
265 const ranked = acts.filter(a => a.kind !== 'said').map((a, i) => ({ a, i })).sort((x, y) => salience(x.a) - salience(y.a) || x.i - y.i)
266
267 const beats: Raw[] = []
268 let mood = ''
269 let kinds = 0
270 const seen = new Set<string>()
271 for (const { a } of ranked) {
272 const key = a.kind === 'test' ? `test${a.ok}` : a.kind
273 if (seen.has(key)) continue
274 const ch = chunkFor(a, c, hadFail)
275 if (!ch) continue
276 // a second idea only when the first leaves room; each chunk plays whole (carry always meets its drop)
277 if (kinds > 0 && (beats.length >= 3 || beats.length + ch.beats.length > MAX_BEATS - (said ? 1 : 0))) continue
278 seen.add(key)
279 beats.push(...ch.beats)
280 mood ||= ch.mood
281 if (++kinds >= 2) break
282 }
283 if (!beats.length) beats.push(...lookAround(c))
284 if (said && beats.length < MAX_BEATS) {
285 const text = clip(said.text.replace(/\s+/g, ' ').trim(), 60)
286 if (text) beats.push({ do: 'say', text, secs: 4 })
287 }
288 return build({ mood: mood || 'neutral', beats: beats.slice(0, MAX_BEATS) }, world)
289}
290
291/** A quiet-moment interlude in the current world (n = 1..; the 4th is the last): small idle business, later ones sleepier. */
292export function cannedInterlude(world: World, n: number, rand: Rand): Scene {
293 const c: Ctx = { world, rand, ids: new Set(world.props.map(p => p.id)), signs: 0 }
294 const prop = of(c, 'tree', 'lamp', 'flag', 'mushroom', 'rock', 'cactus', 'chest', 'crate', 'sign', 'bug', 'scroll', 'books', 'computer')
295 const lookAt: Raw[] = prop ? [{ do: 'look', at: prop.id, react: chance(0.3, rand) ? '?' : undefined }] : []
296 let beats: Raw[]
297 let mood = 'neutral'
298 if (n >= 4) {
299 mood = 'sleepy'
300 beats = [
301 { do: 'think', text: pick(['Still working on it... *yawn*', 'So quiet... just resting my eyes.', 'Waiting is sleepy work.'], rand), secs: 3 },
302 { do: 'emote', mood: 'sleepy', secs: 4 },
303 ]
304 } else if (n === 3) {
305 mood = 'sleepy'
306 beats = [...lookAt, { do: 'ponder', secs: 3 }, { do: 'think', text: pick(['Still going... I can wait.', 'Any moment now.', 'Hmm, patience.'], rand), secs: 3 }, { do: 'emote', mood: 'sleepy', secs: 2 }]
307 } else if (n === 2) {
308 const src = of(c, 'books', 'scroll')
309 beats = src && chance(0.5, rand)
310 ? [{ do: 'look', at: src.id }, { do: 'read', secs: 3 }, { do: 'ponder', secs: 2.5 }]
311 : [...lookAt, { do: 'walk', to: spot(c) }, { do: 'ponder', secs: 3 }]
312 } else {
313 beats = chance(0.5, rand)
314 ? [{ do: 'walk', to: spot(c) }, ...lookAt, { do: 'ponder', secs: 2.5 }]
315 : [...lookAt, { do: 'wait', secs: 1.5 }, { do: 'walk', to: spot(c) }, { do: 'ponder', secs: 2.5 }]
316 }
317 return build({ mood, beats: beats.slice(0, MAX_BEATS) }, world)
318}
319
320const WRAP_LINES = ['Done!', 'That should do it.', 'All wrapped up.', 'There we go.', 'Finished, I think.']
321const ABORT_LINES = ['Okay, stopping here.', 'Right, pausing for now.', 'Alright, I will leave it there.']
322
323/** The turn's wrap-up in the current world. `aborted`: no celebration, a shrug or a small sigh instead. */
324export function cannedWrap(world: World, lastSaid: string | undefined, aborted: boolean, rand: Rand): Scene {
325 const c: Ctx = { world, rand, ids: new Set(world.props.map(p => p.id)), signs: 0 }
326 const said = lastSaid ? clip(lastSaid.replace(/\s+/g, ' ').trim(), 60) : ''
327 const prop = of(c, 'flag', 'chest', 'computer', 'tree', 'lamp', 'crate')
328 if (aborted) {
329 const beats: Raw[] = said || chance(0.5, rand)
330 ? [{ do: 'say', text: said || pick(ABORT_LINES, rand), secs: 3 }, { do: 'shrug' }]
331 : [{ do: 'emote', mood: 'sad', secs: 2 }, { do: 'ponder', secs: 2 }]
332 return build({ mood: 'neutral', beats }, world)
333 }
334 const beats: Raw[] = []
335 if (prop && chance(0.5, rand)) beats.push({ do: 'look', at: prop.id })
336 beats.push({ do: 'say', text: said || pick(WRAP_LINES, rand), secs: 4 })
337 beats.push(pick([{ do: 'celebrate' }, { do: 'wave' }, { do: 'emote', mood: 'happy', secs: 2 }] as Raw[], rand))
338 if (chance(0.4, rand)) beats.push({ do: 'emote', mood: 'proud', secs: 2 })
339 return build({ mood: pick(['happy', 'proud'] as const, rand), beats: beats.slice(0, MAX_BEATS) }, world)
340}
341
342/** Holds a raw scene to the language; on any trouble a minimal valid scene plays instead. */
343function build(raw: Raw, world: World | undefined): Scene {
344 try {
345 const scene = parseScene(JSON.stringify(raw), world, { changes: 'none' })
346 if (!('error' in scene)) return scene
347 } catch { /* fall through to the fallback */ }
348 const minimal = { setting: world?.setting ?? 'meadow', props: [], beats: [{ do: 'ponder', secs: 2 }] }
349 const fallback = parseScene(JSON.stringify(minimal), world)
350 if (!('error' in fallback)) return fallback
351 return { setting: minimal.setting, mood: 'neutral', props: [], beats: [{ do: 'ponder', secs: 2 }], continues: world ? true : undefined }
352}
353hooks/canvas.ts 161 lines1// A pixel canvas two pixels tall per terminal cell (half blocks), with a text
2// layer on top, encoded into a Raster's cells.
3
4export const DEFAULT = 0x01000000
5
6const UPPER = 0x2580
7const LOWER = 0x2584
8
9export class Canvas {
10 readonly px: Uint32Array
11 readonly glyph: Uint32Array
12 readonly fg: Uint32Array
13 readonly bg: Uint32Array
14 readonly height: number
15
16 constructor(readonly columns: number, readonly rows: number) {
17 this.height = rows * 2
18 this.px = new Uint32Array(columns * this.height).fill(DEFAULT)
19 this.glyph = new Uint32Array(columns * rows)
20 this.fg = new Uint32Array(columns * rows)
21 this.bg = new Uint32Array(columns * rows)
22 }
23
24 set(x: number, y: number, color: number) {
25 x = Math.round(x)
26 y = Math.round(y)
27 if (x < 0 || y < 0 || x >= this.columns || y >= this.height) return
28 // painter's order: a pixel drawn later covers any text drawn into its cell before.
29 // It covers only its half, though: the other half keeps the text's background
30 // (a sign's board), or the board would show a gap above a figure walking past it.
31 const cell = (y >> 1) * this.columns + x
32 if (this.glyph[cell]) {
33 if (this.bg[cell] !== DEFAULT) this.px[(y ^ 1) * this.columns + x] = this.bg[cell]!
34 this.glyph[cell] = 0
35 }
36 this.px[y * this.columns + x] = color
37 }
38
39 rect(x: number, y: number, w: number, h: number, color: number) {
40 for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) this.set(x + i, y + j, color)
41 }
42
43 /** Draws rows of palette letters, '.' transparent; `flip` mirrors it. */
44 sprite(x: number, y: number, rows: readonly string[], palette: Record<string, number>, flip = false) {
45 for (let j = 0; j < rows.length; j++) {
46 const row = rows[j]!
47 for (let i = 0; i < row.length; i++) {
48 const ch = row[flip ? row.length - 1 - i : i]!
49 const color = palette[ch]
50 if (color !== undefined) this.set(x + i, y + j, color)
51 }
52 }
53 }
54
55 /** Writes text into the cell layer; non-printable or wide characters become '?'. */
56 text(col: number, row: number, s: string, fg: number, bg = DEFAULT) {
57 col = Math.round(col)
58 if (row < 0 || row >= this.rows) return
59 let i = 0
60 for (const ch of s) {
61 const c = col + i++
62 if (c < 0 || c >= this.columns) continue
63 const at = row * this.columns + c
64 this.glyph[at] = safe(ch)
65 this.fg[at] = fg
66 this.bg[at] = bg
67 }
68 }
69
70 /** A rounded box around `lines`, top-left at (col, row). */
71 box(col: number, row: number, lines: readonly string[], fg: number, bg: number, border: number) {
72 if (!lines.length) return
73 const inner = Math.max(...lines.map(l => [...l].length))
74 this.text(col, row, '╭' + '─'.repeat(inner + 2) + '╮', border, bg)
75 lines.forEach((l, i) => {
76 this.text(col, row + 1 + i, '│', border, bg)
77 this.text(col + 1, row + 1 + i, ' ' + l + ' '.repeat(inner - [...l].length + 1), fg, bg)
78 this.text(col + inner + 3, row + 1 + i, '│', border, bg)
79 })
80 this.text(col, row + lines.length + 1, '╰' + '─'.repeat(inner + 2) + '╯', border, bg)
81 }
82
83 /** The Raster `cells`: base64 of [codePoint, fg, bg] u32 triplets, row-major. */
84 encode(): string {
85 const { columns, rows } = this
86 const out = new Uint32Array(columns * rows * 3)
87 for (let r = 0; r < rows; r++) {
88 for (let c = 0; c < columns; c++) {
89 const cell = r * columns + c
90 const o = cell * 3
91 if (this.glyph[cell]) {
92 out[o] = this.glyph[cell]!
93 out[o + 1] = this.fg[cell]!
94 out[o + 2] = this.bg[cell] === DEFAULT ? this.px[(2 * r + 1) * columns + c]! : this.bg[cell]!
95 continue
96 }
97 const top = this.px[2 * r * columns + c]!
98 const bottom = this.px[(2 * r + 1) * columns + c]!
99 if (top === DEFAULT && bottom === DEFAULT) {
100 out[o] = 0x20; out[o + 1] = DEFAULT; out[o + 2] = DEFAULT
101 } else if (top === bottom) {
102 // a solid cell is a coloured space, not a full block: no glyph for a terminal's
103 // minimum-contrast setting (VS Code's is on by default) to brighten against its own colour
104 out[o] = 0x20; out[o + 1] = top; out[o + 2] = top
105 } else if (top === DEFAULT) {
106 out[o] = LOWER; out[o + 1] = bottom; out[o + 2] = DEFAULT
107 } else {
108 out[o] = UPPER; out[o + 1] = top; out[o + 2] = bottom
109 }
110 }
111 }
112 return base64(new Uint8Array(out.buffer))
113 }
114}
115
116function safe(ch: string): number {
117 const cp = ch.codePointAt(0) ?? 0x3f
118 if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0) || cp > 0xffff) return 0x3f
119 // East Asian wide ranges and emoji draw two cells wide: refuse them here.
120 if ((cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) ||
121 (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) ||
122 (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0xd800 && cp <= 0xdfff)) return 0x3f
123 // emoji (✅ ⚡ ⭐) draw two cells wide, marks and format characters none: the host refuses both
124 if (UNFIT.test(ch)) return 0x3f
125 return cp
126}
127
128const UNFIT = /[\p{Emoji_Presentation}\p{Mn}\p{Me}\p{Cf}]/u
129
130/**
131 * Text as the canvas can draw it, one code point to a cell: accents composed onto
132 * their letters, emoji and invisible marks dropped (anything else unfit becomes '?').
133 */
134export function cellText(s: string): string {
135 return s
136 .normalize('NFC')
137 .replace(/\p{Emoji_Presentation}|\p{Extended_Pictographic}️|[\p{Mn}\p{Me}\p{Cf}]/gu, '')
138 .replace(/\s+/g, ' ')
139 .trim()
140}
141
142const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
143
144export function base64(bytes: Uint8Array): string {
145 let out = ''
146 let i = 0
147 for (; i + 2 < bytes.length; i += 3) {
148 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
149 out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]! + ALPHABET[(n >> 6) & 63]! + ALPHABET[n & 63]!
150 }
151 const rest = bytes.length - i
152 if (rest === 1) {
153 const n = bytes[i]! << 16
154 out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]! + '=='
155 } else if (rest === 2) {
156 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
157 out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]! + ALPHABET[(n >> 6) & 63]! + '='
158 }
159 return out
160}
161hooks/demo.ts 66 lines1// The tour /comic-demo plays: every mood once, then every action, each beat
2// captioned with what it is meant to show.
3
4import type { Beat, Mood, Scene } from './scene'
5
6const MOOD_TOUR: [Exclude<Mood, 'neutral'>, string][] = [
7 ['happy', '^ ^ eyes, a music note, sparkles'],
8 ['proud', '^ ^ eyes, a star, lots of sparkles'],
9 ['love', '^ ^ eyes, a heart, hearts floating up'],
10 ['sad', 'drooping eyes, rain cloud, dusty colour, slumped, tears'],
11 ['angry', 'slanted eyes, anger mark, red, shaking, steam'],
12 ['surprised', 'big round eyes, "!"'],
13 ['confused', 'uneven eyes, "?", looking both ways'],
14 ['sleepy', 'closed eyes, Z, z’s drifting up, slow breathing'],
15 ['focused', 'small eyes under a determined brow, "..."'],
16 ['worried', 'sweat drop, trembling'],
17]
18
19const MOOD_SECS = 3.5
20
21/** What each mood is meant to show, for captions. */
22export const MOOD_SHOWS: Record<string, string> = Object.fromEntries(MOOD_TOUR)
23
24/** The beat /comic-feel plays: one mood, acted out for five seconds with its caption. */
25export function feelBeat(mood: Exclude<Mood, 'neutral'>): Beat {
26 return { do: 'emote', mood, secs: 5, caption: `${mood} - ${MOOD_SHOWS[mood]}` }
27}
28
29export function demoScene(): Scene {
30 const n = MOOD_TOUR.length
31 const beats: Beat[] = [
32 { do: 'walk', to: 22, caption: 'Comic tour: every mood, then every action' },
33 ...MOOD_TOUR.map(([mood, shows], i): Beat => ({
34 do: 'emote', mood, secs: MOOD_SECS, caption: `mood ${i + 1}/${n}: ${mood} - ${shows}`,
35 })),
36 { do: 'read', secs: 3, caption: 'action: read - holds an open book, eyes down, a page turns' },
37 { do: 'type', at: 'pc', secs: 3, caption: 'action: type - walks to the computer, arms alternate, cursor races' },
38 { do: 'carry', at: 'box', caption: 'action: carry - lifts the crate overhead' },
39 { do: 'run', to: 92, caption: 'action: run - fast legs, dust behind' },
40 { do: 'drop', caption: 'action: drop - sets the crate down where he stands' },
41 { do: 'look', at: 'bug', react: '!', caption: 'action: look - walks to a prop, faces it, "!" or "?"' },
42 { do: 'squash', at: 'bug', caption: 'action: squash - jumps on it, it vanishes in a puff' },
43 { do: 'dig', secs: 2, caption: 'action: dig - bobs and throws dirt behind him' },
44 { do: 'jump', caption: 'action: jump' },
45 { do: 'wave', caption: 'action: wave - one arm up and down, happy eyes' },
46 { do: 'shrug', caption: 'action: shrug - both arms up, squinting' },
47 { do: 'dance', secs: 3, caption: 'action: dance - hops, turns, swaps arms, sparkles' },
48 { do: 'say', text: 'This is a speech bubble: up to 110 characters, wrapped to fit.', secs: 4, caption: 'action: say' },
49 { do: 'think', text: 'And this is a thought bubble.', secs: 3, caption: 'action: think' },
50 { do: 'celebrate', caption: 'action: celebrate - arms up, hops, sparkles. End of tour!' },
51 ]
52 return {
53 setting: 'meadow',
54 mood: 'neutral',
55 props: [
56 { id: 'pc', kind: 'computer', x: 72 },
57 { id: 'box', kind: 'crate', x: 45 },
58 { id: 'bug', kind: 'bug', x: 58 },
59 ],
60 beats,
61 }
62}
63
64/** Roughly how long the tour runs, with room for the walks between beats. */
65export const DEMO_MS = (MOOD_TOUR.length * MOOD_SECS + 52) * 1000
66hooks/director.ts 213 lines1// What the director tells Sonnet, and how the agent's activity is summarized for it.
2
3import { HATS, MOODS, PROP_KINDS, SETTINGS } from './scene'
4import type { Scene } from './scene'
5
6export const SYSTEM = `You are the director of a tiny animated comic strip shown in a terminal while an AI coding agent works.
7The star is the agent itself: a small orange pixel creature ("Claude") who walks around a little world, inspects things and remarks on what it finds.
8Each time you are called you get a log of what the agent just did (files read, commands run, searches, its own remarks) and you stage ONE short scene (about 6-15 seconds) that turns the latest activity into a visual metaphor.
9
10Reply with ONLY a JSON object, no prose, no code fence:
11{
12 "setting": one of ${SETTINGS.map(s => `"${s}"`).join(', ')},
13 "mood": one of ${MOODS.map(m => `"${m}"`).join(', ')} (Claude's mood for the whole scene),
14 "hat": one of ${HATS.map(h => `"${h}"`).join(', ')} (first scene of the session only; later, change it with the hat beat),
15 "props": [ { "id": "short-id", "kind": one of ${PROP_KINDS.map(k => `"${k}"`).join(', ')}, "x": 0-100, "label": "optional, max 22 chars" } ],
16 "beats": [ ... ]
17}
18
19Beats run in order:
20 {"do":"walk","to":0-100} {"do":"run","to":0-100} (run kicks up dust: urgency, excitement)
21 {"do":"look","at":"<prop id>","react":"!" or "?"} (walks over to the prop by itself)
22 {"do":"say","text":"aim for 40-80 chars, never over 100"}
23 {"do":"think","text":"aim for 40-80 chars, never over 100"}
24 {"do":"emote","mood":"<mood>","secs":2} (a passing feeling: surprised at a find, worried at a risk, angry at a failure, love for nice code)
25 {"do":"read","secs":3} (holds an open book: reading files or docs)
26 {"do":"type","at":"<computer prop id>"} (walks to it and types: running commands, editing)
27 {"do":"carry","at":"<prop id>"} then later {"do":"drop"} (lifts a prop overhead and sets it down where Claude stands: moving or refactoring)
28 {"do":"plant_sign","label":"max 22 chars","x":0-100,"id":"optional short id"} (hammers a new sign into the ground: mark a finding, a file, a TODO)
29 {"do":"pull_sign","at":"<sign id>"} (pulls a sign out of the ground and it vanishes: done, fixed, or wrong after all)
30 {"do":"ponder","secs":3} (stands and contemplates: a "..." bubble, looks one way, then the other)
31 {"do":"transform","at":"<prop id>","into":"<prop kind>","label":"optional"} (lifts it overhead, a puff of magic smoke, it becomes the new thing for good)
32 {"do":"hat","hat":"<hat>"} (a puff over his head and he wears another hat, for good)
33 {"do":"travel","setting":"<setting>","props":[{"kind":"<prop kind>","label":"optional"}, ... up to 4],"hat":"optional"}
34 (a door appears, he walks through it into new scenery with these props; always the LAST beat of its scene)
35 {"do":"dance"} {"do":"wave"} {"do":"shrug"} {"do":"dig"} {"do":"jump"} {"do":"celebrate"} {"do":"wait","secs":1}
36
37Guidelines:
38- Unhurried. HARD LIMIT: 7 beats per scene (9 for an interlude); 5 is often best. At most 4 props, one idea per scene. Claude is calm and thoughtful,
39 not busy: after picking something up, putting it down or finding something, give him a moment (ponder, wait, or a short emote)
40 before the next thing. Fewer, deliberate actions beat many quick ones.
41- Spread props out (x values at least 15 apart). Leave room near x=0 where Claude may stand.
42- Whimsy over literalness: this is a storybook world, not an office. For each piece of work pick a surprising but fitting metaphor in any setting.
43 The obvious one (a library for reading code) is allowed only now and then. Ideas, each one of many:
44 reading or exploring code: a forest of signposts named after the files; beachcombing scrolls washed up by the sea; lamp-lit inscriptions in a cave; a sign on the moon.
45 searching: digging for a chest in the desert or on the beach; lifting rocks to peek under them; hunting with a lamp at night.
46 running commands or tests: typing at a computer planted somewhere odd (a dune, the moon, a beach) and waiting for its verdict.
47 a bug or an error: a bug crawls out from behind a crate, rock or mushroom; Claude jumps back, surprised or worried.
48 fixing it: stand over the bug and cheer; or carry the crate it hid behind to a better spot.
49 writing or editing files: stacking crates, carrying a scroll to its sign, planting a flag.
50 refactoring or moving code: carrying props from one place to another and setting them down in a new order.
51 web research: a scroll from the sea, a signal from a far planet. a long wait: night, a lamp, sitting by a mushroom, dozing.
52- One world per session, and you own it. The session's FIRST scene sets it up: a setting, a hat and exactly 4 props that belong
53 together as one small place (a desert dig site: cactus, chest, rock, a sign; a night camp: lamp, crate, scroll, flag). Pick a
54 place that can hold a whole session of work. Every later scene gets the world as it now is in the request: do not send "props"
55 or "hat" then, refer to the props by their ids. Carrying a prop to another spot is fine; squashing is not (props never vanish).
56- Change the world rarely, as a beat of the story, when the WORK changes, never just for variety:
57 transform a prop when the kind of work shifts (a scroll becomes a computer as reading turns into running tests; a crate
58 becomes a chest once the bug is found); hat when the session's spirit changes (tophat for a finished feature, nightcap for
59 a long slog, miner when the digging starts); travel only when the session moves on to clearly different work, and only at a
60 turn's start or end. Most scenes change nothing. The request says how many changes are left this session; never use more.
61- Signs are notes in the world, on top of the 4 props: when something concrete is found (a cause, a number, a file, a TODO),
62 plant_sign it right there with a short label (aim for 14 characters or fewer: long boards crowd the strip);
63 pull_sign one that is done or turned out wrong. At most 2 planted signs stand at once; they stay until pulled.
64- A hat fits the place and the spirit of the work: wizard (library, night, space, deep reading),
65 miner (cave, digging, searching), straw (beach, desert, meadow), tophat (a proud or festive moment), nightcap (night, long waits),
66 or none. It stays on until a hat beat changes it.
67- A "sign" shows its label on a board: use it for file names, commands, or search terms the agent is working on (e.g. "auth.ts", "npm test").
68- Props are toys, not scenery: every scene has at least TWO interactions with its props (look at, carry and drop, type at, dig beside),
69 ideally a tiny story: find something, react to it, do something about it.
70- Use "read" at most once per scene, and not in two scenes in a row. Prefer carry, drop, type, dig, run and the gestures.
71- The setting is chosen once per session: its first scene picks it, every later scene stays in it.
72 Vary the moods, actions and stories instead. Follow the variety notes in each request.
73- Give Claude feelings: pick a scene mood that fits the work, and use at least one emote, action or gesture (wave, shrug, dance) per scene so the strip stays lively. Vary them between scenes.
74- Speech is Claude remarking on REAL findings from the log: concrete, short, a little witty, first person. Never invent facts that are not in the log.
75- Continuity lives in the story: the place and its props carry over from turn to turn, so new turns can call back to earlier ones.`
76
77export function buildPrompt(args: {
78 goal: string | null
79 log: readonly string[]
80 fresh: number
81 previous: Scene | null
82 finished: boolean
83 interlude?: { n: number; quietSecs: number; isLast: boolean }
84 variety?: Variety
85 world?: { setting: string; props: readonly { id: string; kind: string; label?: string; x: number }[]; gone: readonly string[]; hat?: string }
86 /** World changes the director may still make this session. */
87 changesLeft?: { transform: number; hat: number; travel: number }
88}): string {
89 const older = args.log.slice(0, Math.max(0, args.log.length - args.fresh))
90 const latest = args.log.slice(Math.max(0, args.log.length - args.fresh))
91 const parts = [
92 `The person asked the agent: ${args.goal ?? '(unknown)'}`,
93 older.length ? `Earlier activity:\n${older.slice(-12).join('\n')}` : '',
94 args.interlude
95 ? interludeAsk(args.interlude)
96 : `Latest activity (stage THIS):\n${latest.join('\n') || '(the agent is thinking)'}`,
97 args.previous
98 ? `Previous scene: setting=${args.previous.setting}, mood=${args.previous.mood}, props=${args.previous.props.map(p => `${p.kind}${p.label ? `(${p.label})` : ''}`).join(', ') || 'none'}`
99 : 'This is the first scene.',
100 args.world ? worldAsk(args.world, args.changesLeft) : 'This is the FIRST scene of the session: set up its world (setting, hat and 4 related props). It carries over to every later turn.',
101 args.variety ? varietyAsk(args.variety, Boolean(args.interlude)) : '',
102 args.finished
103 ? 'The agent has just FINISHED its turn. Stage a short wrap-up scene: Claude sums up the outcome in one remark and celebrates or shrugs as fits.'
104 : '',
105 ]
106 return parts.filter(Boolean).join('\n\n')
107}
108
109/** What the recent scenes leaned on, so the next one can lean elsewhere. */
110export type Variety = { recentSettings: string[]; overused: string[]; suggest: string; banned?: string; locked?: string }
111
112/**
113 * Reads the last few scenes for the actions they used most, and the settings of
114 * recent turns (`settings`, oldest first; the scenes' own when absent) for a setting to try.
115 * `locked` is the session's setting once its first scene picked one.
116 */
117export function varietyNotes(
118 recent: readonly Scene[],
119 rand: () => number = Math.random,
120 settings?: readonly string[],
121 locked?: string,
122): Variety {
123 const recentSettings = [...(settings ?? recent.map(s => s.setting))]
124 const counts = new Map<string, number>()
125 for (const scene of recent.slice(-4)) for (const b of scene.beats) counts.set(b.do, (counts.get(b.do) ?? 0) + 1)
126 const overused = [...counts].filter(([, n]) => n >= 3).sort((a, b) => b[1] - a[1]).map(([act, n]) => `${act} x${n}`)
127 const lately = new Set(recentSettings.slice(-3))
128 const fresh = SETTINGS.filter(s => !lately.has(s))
129 const pool = fresh.length ? fresh : [...SETTINGS]
130 const suggest = pool[Math.floor(rand() * pool.length)]!
131 const [a, b] = recentSettings.slice(-2)
132 return { recentSettings, overused, suggest, banned: a !== undefined && a === b ? a : undefined, locked }
133}
134
135function varietyAsk(v: Variety, isInterlude: boolean): string {
136 const lines = ['Variety notes:']
137 if (v.locked) {
138 lines.push(`- the setting is fixed for this session: "${v.locked}". Stay in it; vary mood, actions and stories instead`)
139 } else {
140 if (v.recentSettings.length) lines.push(`- settings of recent turns, oldest first: ${v.recentSettings.join(', ')}`)
141 lines.push('- this is the first scene of the session: it picks the setting for the whole session')
142 }
143 if (!isInterlude && !v.locked) {
144 lines.push(`- suggested setting this time: ${v.suggest} (use it unless it truly cannot fit the work)`)
145 if (v.banned) lines.push(`- do NOT use "${v.banned}" this time: the last two turns used it`)
146 }
147 if (v.overused.length) lines.push(`- used a lot lately, so go easy on them: ${v.overused.join(', ')}`)
148 return lines.join('\n')
149}
150
151function worldAsk(w: NonNullable<Parameters<typeof buildPrompt>[0]['world']>, left?: { transform: number; hat: number; travel: number }): string {
152 const props = w.props.map(p => `${p.id} = ${p.kind}${p.label ? ` "${p.label}"` : ''} at x=${p.x}`).join('; ')
153 const world = `The world as it is now (do NOT send "props" or "hat"): setting ${w.setting}; Claude wears: ${w.hat ?? 'none'}; props: ${props || 'none left'}` +
154 (w.gone.length ? `; gone: ${w.gone.join(', ')}` : '') + '. Use these ids.'
155 if (!left) return world
156 return `${world}
157World changes left this session (use only when the work really changes): transform ${left.transform}, hat ${left.hat}, travel ${left.travel}.`
158}
159
160/** What to stage when nothing new has happened for a while but the agent is still at work. */
161function interludeAsk(i: { n: number; quietSecs: number; isLast: boolean }): string {
162 if (i.isLast) {
163 return `No new activity for ${i.quietSecs}s; the agent is still busy. Stage a short scene in the same setting where Claude, ` +
164 'still waiting, grows drowsy: a yawn-like remark, then {"do":"emote","mood":"sleepy","secs":4}. Invent no findings.'
165 }
166 return `INTERLUDE ${i.n}: no new activity for ${i.quietSecs}s, but the agent is still working (thinking, or a long command). ` +
167 "Continue the previous scene's story in the same setting with 5-8 unhurried beats, pauses included (ponder, wait), so the strip does not stall: Claude keeps busy - " +
168 'rereading or pacing, typing, carrying something, thinking aloud about what IS in the log, a small gesture, a light joke about the wait. ' +
169 'Chain varied actions. Invent no findings and do not repeat the previous remarks.'
170}
171
172const cut = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s).replace(/\s+/g, ' ')
173
174/** One log line for a tool call, from its input and how it ended. */
175export function describeCall(e: Record<string, unknown>, outcome: { isError?: boolean; deny?: string; result?: unknown }): string {
176 const tool = String(e.tool)
177 const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
178 let what: string
179 switch (tool) {
180 case 'Read': what = `read ${s('file_path')}`; break
181 case 'Edit': what = `edited ${s('file_path')}`; break
182 case 'Write': what = `wrote ${s('file_path')}`; break
183 case 'Bash': what = `ran \`${cut(s('command'), 80)}\``; break
184 case 'Grep': what = `searched for "${cut(s('pattern'), 40)}"${s('path') ? ` in ${s('path')}` : ''}`; break
185 case 'Glob': what = `listed files matching ${s('pattern')}`; break
186 case 'WebFetch': what = `fetched ${s('url')}`; break
187 case 'WebSearch': what = `searched the web for "${cut(s('query'), 50)}"`; break
188 case 'Agent': what = `sent a helper agent: ${cut(s('description') || s('prompt'), 60)}`; break
189 default: {
190 const args = JSON.stringify(Object.fromEntries(Object.entries(e).filter(([k]) => !['tool', 'tool_use_id', 'agentId'].includes(k))))
191 what = `used ${tool} ${cut(args ?? '', 70)}`
192 }
193 }
194 if (e.agentId) what = `(helper) ${what}`
195 if (outcome.deny) return `${what} -> blocked`
196 const result = summarize(outcome.result)
197 if (outcome.isError) return `${what} -> FAILED${result ? `: ${result}` : ''}`
198 return result ? `${what} -> ${result}` : what
199}
200
201function summarize(result: unknown): string {
202 if (result === undefined || result === null) return ''
203 let text: string
204 if (typeof result === 'string') text = result
205 else {
206 const r = result as Record<string, unknown>
207 text = typeof r.stdout === 'string' || typeof r.stderr === 'string'
208 ? `${r.stdout ?? ''} ${r.stderr ?? ''}`
209 : typeof r.content === 'string' ? r.content : (JSON.stringify(result) ?? '')
210 }
211 return cut(text.trim(), 140)
212}
213hooks/pacing.ts 121 lines1// When to ask the director for the next scene, and how long each step took on the way to the screen.
2
3import type { Stamp } from './scene'
4
5/** Per pace: the least time between two requests, and how quiet a turn must be before an interlude. */
6export const TIMING = {
7 calm: { minGapMs: 10000, quietMs: 6000 },
8 normal: { minGapMs: 8000, quietMs: 5000 },
9 lively: { minGapMs: 6000, quietMs: 4000 },
10}
11export const MAX_INTERLUDES = 4 // per quiet stretch; the last one sends Claude to sleep
12const LEAD_MS = 1500 // a scene should land a little before the stage runs dry, not just after
13const INTERLUDE_GAP_MS = 6000
14
15export type AskState = {
16 now: number
17 pace: keyof typeof TIMING
18 /** Log lines not staged yet. */
19 fresh: number
20 lastAsk: number
21 lastActivity: number
22 isTurnRunning: boolean
23 isWrapPending: boolean
24 /** True until the turn's first scene has set up its world. */
25 isWorldless: boolean
26 interludes: number
27 /** What the stage has left to play, playing and queued (Stage.remainingMs). */
28 remainingMs: number
29 /** How soon a scene of news would get on stage (Stage.untilFreeMs). */
30 untilFreeMs: number
31 /** How long the director usually takes to answer. */
32 latencyMs: number
33}
34
35/**
36 * What to ask for now, if anything. A scene is asked for about when the stage will
37 * run dry, so it is ready as the current one ends instead of waiting in line behind it.
38 */
39export function nextAsk(s: AskState): 'wrap' | 'scene' | 'interlude' | null {
40 if (s.isWrapPending) return 'wrap'
41 const t = TIMING[s.pace]
42 const isAhead = (ms: number) => ms <= s.latencyMs + LEAD_MS
43 if (s.fresh > 0) {
44 // the turn's first scene sets up the world: it is the slowest to stage, so it goes at once
45 if (s.now - s.lastAsk < t.minGapMs) return null
46 // news may end the playing scene at a pause, so it is timed to that pause, not to the scene's end
47 return isAhead(s.untilFreeMs) || (s.isWorldless && s.isTurnRunning) ? 'scene' : null
48 }
49 const isQuiet = s.isTurnRunning && s.now - s.lastActivity >= t.quietMs && s.now - s.lastAsk >= INTERLUDE_GAP_MS
50 // an interlude carries the story on: it waits for the current scene to play out
51 return isQuiet && isAhead(s.remainingMs) && s.interludes < MAX_INTERLUDES ? 'interlude' : null
52}
53
54/** A rolling estimate of the director's answer time: recent calls count most. */
55export function blendLatency(current: number, measured: number): number {
56 return Math.round(current * 0.7 + measured * 0.3)
57}
58
59/** One played scene's way to the screen (ms). */
60export type SceneTiming = {
61 kind: Stamp['kind']
62 waitMs?: number
63 modelMs: number
64 queueMs: number
65 totalMs?: number
66 expectedLeftMs: number
67 /** What the stage really had left when it was asked: from the request to the scene's start. */
68 actualLeftMs: number
69}
70
71const KEEP_RECENT = 8
72
73export type Timings = { scenes: number; staged: number; waitMs: number; modelMs: number; queueMs: number; totalMs: number; recent: SceneTiming[] }
74
75export const emptyTimings = (): Timings => ({ scenes: 0, staged: 0, waitMs: 0, modelMs: 0, queueMs: 0, totalMs: 0, recent: [] })
76
77/** Adds a scene that just began to play. `staged` counts the ones that stage real activity (not interludes). */
78export function addTiming(t: Timings, stamp: Stamp, playedAt: number): Timings {
79 const hasActivity = stamp.activity !== undefined
80 const row: SceneTiming = {
81 kind: stamp.kind,
82 waitMs: hasActivity ? stamp.asked - stamp.activity! : undefined,
83 modelMs: stamp.answered - stamp.asked,
84 queueMs: playedAt - stamp.answered,
85 totalMs: hasActivity ? playedAt - stamp.activity! : undefined,
86 expectedLeftMs: stamp.expectedLeftMs,
87 actualLeftMs: playedAt - stamp.asked,
88 }
89 return {
90 scenes: t.scenes + 1,
91 staged: t.staged + (hasActivity ? 1 : 0),
92 waitMs: t.waitMs + (row.waitMs ?? 0),
93 modelMs: t.modelMs + row.modelMs,
94 queueMs: t.queueMs + row.queueMs,
95 totalMs: t.totalMs + (row.totalMs ?? 0),
96 recent: [...t.recent, row].slice(-KEEP_RECENT),
97 }
98}
99
100export function formatTimings(t: Timings, latencyMs: number, asked: number): string {
101 if (t.scenes === 0) return 'Latency: no director scene has played yet this session.'
102 const s = (ms: number, n: number) => (n ? `${(ms / n / 1000).toFixed(1)}s` : 'n/a')
103 const one = (ms: number | undefined) => (ms === undefined ? '–' : `${(ms / 1000).toFixed(1)}s`)
104 return [
105 `Latency, averaged over ${t.scenes} scenes this session (${asked - t.scenes} more still queued or dropped):`,
106 '',
107 '| activity → request | request → answer | answer → on stage | activity → on stage |',
108 '|---|---|---|---|',
109 `| ${s(t.waitMs, t.staged)} | ${s(t.modelMs, t.scenes)} | ${s(t.queueMs, t.scenes)} | ${s(t.totalMs, t.staged)} |`,
110 '',
111 `The last ${t.recent.length} scenes, oldest first ("stage left": what the stage expected to have left when asked, and what it really had):`,
112 '',
113 '| scene | activity → request | request → answer | answer → on stage | activity → on stage | stage left: expected / real |',
114 '|---|---|---|---|---|---|',
115 ...t.recent.map(r =>
116 `| ${r.kind} | ${one(r.waitMs)} | ${one(r.modelMs)} | ${one(r.queueMs)} | ${one(r.totalMs)} | ${one(r.expectedLeftMs)} / ${one(r.actualLeftMs)} |`),
117 '',
118 `Requests go out when the stage has about ${(latencyMs / 1000).toFixed(1)}s left to play (the director's recent answer time).`,
119 ].join('\n')
120}
121hooks/hub.ts 60 lines1// The hub: Claude's own place, where the session opens. It keeps the keepsakes Claude
2// adds with the comic's tool, across sessions, and has the door to each turn's world.
3
4import { cellText } from './canvas'
5import { HUB_KINDS } from './hubart'
6import type { HubKind } from './hubart'
7
8export type HubItem = { kind: HubKind; label: string; at: number }
9
10export const MAX_HUB_ITEMS = 6 // the hub keeps the newest six; a seventh retires the oldest
11export const MAX_HUB_LABEL = 16
12export const HUB_STORE_KEY = 'hub'
13export const HUB_TOOL = 'hub_add'
14
15/** The tool Claude calls, as `$.tool.register` takes it: deferred, so it costs nothing until looked up. */
16export const HUB_TOOL_SPEC = {
17 name: HUB_TOOL,
18 description:
19 'Add a small keepsake to the agent-comic hub, the little home scene shown above the prompt when Claude Code starts. ' +
20 'Use it rarely: only to remember a real milestone the person would be glad to see again (a hard bug finally fixed, ' +
21 'a feature shipped, a long refactor done, tests green after a struggle). At most one per session; never for routine work.',
22 inputSchema: {
23 type: 'object',
24 properties: {
25 kind: { type: 'string', enum: [...HUB_KINDS], description: 'trophy or medal for wins, plant or flower for growth, lantern for insight, banner for a release, gem for a rare find, statue for a big milestone' },
26 label: { type: 'string', description: `What it remembers, at most ${MAX_HUB_LABEL} characters (e.g. "auth bug slain", "v2 shipped")` },
27 },
28 required: ['kind', 'label'],
29 additionalProperties: false,
30 },
31 isDeferred: true,
32} as const
33
34/** A keepsake from the tool's input, or why not. */
35export function hubItemFrom(input: unknown, at: number): HubItem | { error: string } {
36 const i = (input ?? {}) as { kind?: unknown; label?: unknown }
37 if (!HUB_KINDS.includes(i.kind as HubKind)) return { error: `kind must be one of: ${HUB_KINDS.join(', ')}` }
38 const label = typeof i.label === 'string' ? [...cellText(i.label)].slice(0, MAX_HUB_LABEL).join('').trim() : ''
39 if (!label) return { error: 'label must be a few words' }
40 return { kind: i.kind as HubKind, label, at }
41}
42
43/** The stored keepsakes, read back safely: anything malformed is left out. */
44export function asHubItems(v: unknown): HubItem[] {
45 if (!Array.isArray(v)) return []
46 const items: HubItem[] = []
47 for (const raw of v) {
48 const at = typeof raw?.at === 'number' && Number.isFinite(raw.at) ? raw.at : 0
49 const item = hubItemFrom(raw, at)
50 if (!('error' in item)) items.push(item)
51 }
52 return items.slice(-MAX_HUB_ITEMS)
53}
54
55/** The hub with `item` added, and the keepsake it retired to make room, if any. */
56export function addHubItem(items: readonly HubItem[], item: HubItem): { items: HubItem[]; retired?: HubItem } {
57 const all = [...items, item]
58 return { items: all.slice(-MAX_HUB_ITEMS), retired: all.length > MAX_HUB_ITEMS ? all[0] : undefined }
59}
60hooks/scene.ts 226 lines1// The scene language Sonnet writes: a setting, a mood, props on the ground, and
2// beats the Claude figure performs one after another.
3
4import { cellText } from './canvas'
5
6export const SETTINGS = ['meadow', 'forest', 'cave', 'night', 'desert', 'library', 'space', 'beach'] as const
7export type Setting = (typeof SETTINGS)[number]
8
9export const PROP_KINDS = [
10 'sign', 'tree', 'rock', 'chest', 'bug', 'crate', 'scroll', 'flag',
11 'lamp', 'books', 'computer', 'cactus', 'mushroom',
12] as const
13export type PropKind = (typeof PROP_KINDS)[number]
14
15export const MOODS = [
16 'neutral', 'happy', 'proud', 'love', 'sad', 'angry', 'surprised', 'confused', 'sleepy', 'focused', 'worried',
17] as const
18export type Mood = (typeof MOODS)[number]
19
20export type Prop = { id: string; kind: PropKind; x: number; label?: string }
21
22export type Beat = BeatAction & { caption?: string }
23
24export type BeatAction =
25 | { do: 'walk'; to: number }
26 | { do: 'run'; to: number }
27 | { do: 'say'; text: string; secs?: number }
28 | { do: 'think'; text: string; secs?: number }
29 | { do: 'look'; at: string; react?: '!' | '?' }
30 | { do: 'squash'; at: string }
31 | { do: 'transform'; at: string; into: PropKind; label?: string }
32 | { do: 'plant_sign'; id: string; label: string; x: number }
33 | { do: 'pull_sign'; at: string }
34 | { do: 'carry'; at: string }
35 | { do: 'drop' }
36 | { do: 'type'; at?: string; secs?: number }
37 | { do: 'read'; secs?: number }
38 | { do: 'emote'; mood: Mood; secs?: number }
39 | { do: 'dance'; secs?: number }
40 | { do: 'wave' }
41 | { do: 'shrug' }
42 | { do: 'jump' }
43 | { do: 'dig'; secs?: number }
44 | { do: 'celebrate' }
45 | { do: 'ponder'; secs?: number }
46 | { do: 'wait'; secs: number }
47 | { do: 'hat'; hat: Hat }
48 | { do: 'travel'; setting: Setting; props: Prop[]; hat?: Hat }
49
50/** `continues`: plays in the current turn's world (its setting and props, as they now are). */
51export const HATS = ['none', 'wizard', 'miner', 'straw', 'tophat', 'nightcap'] as const
52export type Hat = (typeof HATS)[number]
53
54/**
55 * `stamp`: when the director staged it (ms), for the latency figures; scenes made locally have none.
56 * `isNews`: it stages new activity (or the turn's end), so the scene playing may give way to it at a pause.
57 */
58export type Scene = {
59 setting: Setting; mood: Mood; props: Prop[]; beats: Beat[]; hat?: Hat; continues?: boolean
60 stamp?: Stamp
61 isNews?: boolean
62}
63
64export type Stamp = {
65 kind: 'first' | 'scene' | 'interlude' | 'wrap'
66 /** The oldest activity it stages; none for an interlude. */
67 activity?: number
68 asked: number
69 answered: number
70 /** What the stage expected to have left to play when it was asked for (ms). */
71 expectedLeftMs: number
72}
73
74const MAX_PROPS = 4
75const MAX_BEATS = 9 // the strip stays calm: anything past this is dropped
76export const MAX_SPEECH = 110
77const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))
78const str = (v: unknown, max: number) => (typeof v === 'string' ? clip(cellText(v), max) : '')
79
80/** Cuts at a word boundary and marks the cut, so a remark never ends mid-word. */
81export function clip(text: string, max: number): string {
82 if (text.length <= max) return text
83 const cut = text.slice(0, max - 1)
84 const space = cut.lastIndexOf(' ')
85 return (space > max * 0.6 ? cut.slice(0, space) : cut).replace(/[\s,;:.-]+$/, '') + '…'
86}
87const num = (v: unknown, fallback: number) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback)
88const secs = (v: unknown, lo: number, hi: number) => (v === undefined ? undefined : clamp(num(v, lo), lo, hi))
89const isMood = (v: unknown): v is Mood => MOODS.includes(v as Mood)
90
91/**
92 * Who may change the session's world in a scene played in it: `none` (canned scenes: transform,
93 * hat and travel are dropped) or `director` (it may transform a prop, swap the hat, or travel).
94 * Squashing a prop is never allowed in a world: the world keeps its props.
95 */
96export type WorldChanges = 'none' | 'director'
97
98/**
99 * Pulls the first JSON object out of a reply and holds it to the language. Given the
100 * session's `world`, the scene plays in it: its own setting and props are ignored, and
101 * what may change the world follows `changes`. A travel beat ends the scene.
102 */
103export function parseScene(
104 reply: string,
105 world?: { setting: Setting; props: readonly Prop[] },
106 opts: { changes?: WorldChanges } = {},
107): Scene | { error: string } {
108 const mayChange = !world || opts.changes === 'director'
109 const start = reply.indexOf('{')
110 const end = reply.lastIndexOf('}')
111 if (start < 0 || end <= start) return { error: 'no JSON object in the reply' }
112
113 let raw: any
114 try {
115 raw = JSON.parse(reply.slice(start, end + 1))
116 } catch (err) {
117 return { error: `bad JSON: ${String(err).slice(0, 80)}` }
118 }
119
120 const setting: Setting = world?.setting ?? (SETTINGS.includes(raw?.setting) ? raw.setting : 'meadow')
121 const mood: Mood = isMood(raw?.mood) ? raw.mood : 'neutral'
122 // the hat comes with the world: a later scene of the turn keeps the one Claude already wears
123 const hat: Hat | undefined = world ? undefined : HATS.includes(raw?.hat) ? raw.hat : 'none'
124
125 const props: Prop[] = []
126 for (const p of world ? [] : Array.isArray(raw?.props) ? raw.props.slice(0, MAX_PROPS) : []) {
127 if (!PROP_KINDS.includes(p?.kind)) continue
128 const id = str(p.id, 20) || `p${props.length}`
129 const label = str(p.label, 22) || undefined
130 props.push({ id, kind: p.kind, x: clamp(num(p.x, 50), 0, 100), label })
131 }
132 if (world) props.push(...world.props.map(p => ({ ...p })))
133 const ids = new Set(props.map(p => p.id))
134 const signs = new Set(props.filter(p => p.kind === 'sign').map(p => p.id))
135
136 const beats: Beat[] = []
137 for (const b of Array.isArray(raw?.beats) ? raw.beats.slice(0, MAX_BEATS) : []) {
138 switch (b?.do) {
139 case 'walk':
140 case 'run':
141 beats.push({ do: b.do, to: clamp(num(b.to, 50), 0, 100) })
142 break
143 case 'say':
144 case 'think': {
145 const text = str(b.text, MAX_SPEECH)
146 if (text) beats.push({ do: b.do, text, secs: secs(b.secs, 1, 8) })
147 break
148 }
149 case 'look':
150 if (ids.has(b.at)) beats.push({ do: 'look', at: b.at, react: b.react === '!' || b.react === '?' ? b.react : undefined })
151 break
152 case 'squash':
153 case 'carry':
154 if (b.do === 'squash' && world) break
155 if (ids.has(b.at)) beats.push({ do: b.do, at: b.at })
156 break
157 case 'plant_sign': {
158 const label = str(b.label, 22)
159 if (!label) break
160 // a fresh id, so later beats (and scenes) can look at or pull this sign
161 let id = str(b.id, 20) || 'note'
162 for (let n = 2; ids.has(id); n++) id = `${str(b.id, 18) || 'note'}${n}`
163 ids.add(id)
164 signs.add(id)
165 beats.push({ do: 'plant_sign', id, label, x: clamp(num(b.x, 50), 0, 100) })
166 break
167 }
168 case 'pull_sign':
169 if (signs.has(b.at)) beats.push({ do: 'pull_sign', at: b.at })
170 break
171 case 'transform':
172 if (mayChange && ids.has(b.at) && PROP_KINDS.includes(b.into)) {
173 beats.push({ do: 'transform', at: b.at, into: b.into, label: str(b.label, 22) || undefined })
174 }
175 break
176 case 'type':
177 beats.push({ do: 'type', at: ids.has(b.at) ? b.at : undefined, secs: secs(b.secs, 1, 5) })
178 break
179 case 'emote':
180 if (isMood(b.mood)) beats.push({ do: 'emote', mood: b.mood, secs: secs(b.secs, 1, 5) })
181 break
182 case 'read':
183 case 'dance':
184 beats.push({ do: b.do, secs: secs(b.secs, 1, 5) })
185 break
186 case 'sleep': // sleeping is the sleepy mood, not an action of its own
187 beats.push({ do: 'emote', mood: 'sleepy', secs: secs(b.secs, 1, 5) ?? 4 })
188 break
189 case 'ponder':
190 beats.push({ do: 'ponder', secs: secs(b.secs, 1.5, 6) })
191 break
192 case 'drop':
193 case 'wave':
194 case 'shrug':
195 case 'jump':
196 case 'celebrate':
197 beats.push({ do: b.do })
198 break
199 case 'dig':
200 beats.push({ do: 'dig', secs: secs(b.secs, 1, 4) })
201 break
202 case 'wait':
203 beats.push({ do: 'wait', secs: clamp(num(b.secs, 1), 0.3, 4) })
204 break
205 case 'hat':
206 if (mayChange && HATS.includes(b.hat)) beats.push({ do: 'hat', hat: b.hat })
207 break
208 case 'travel': {
209 if (!world || !mayChange || !SETTINGS.includes(b.setting) || b.setting === world.setting) break
210 const props: Prop[] = []
211 for (const p of Array.isArray(b.props) ? b.props.slice(0, MAX_PROPS) : []) {
212 if (!PROP_KINDS.includes(p?.kind)) continue
213 props.push({ id: `${p.kind}${props.length}`, kind: p.kind, x: [24, 44, 64, 84][props.length]!, label: str(p.label, 22) || undefined })
214 }
215 beats.push({ do: 'travel', setting: b.setting, props, hat: HATS.includes(b.hat) ? b.hat : undefined })
216 break
217 }
218 }
219 // through the door, the scene is over: anything after it belonged to the old place
220 if (beats[beats.length - 1]?.do === 'travel') break
221 }
222 if (beats.length === 0) return { error: 'the scene has no usable beats' }
223
224 return { setting, mood, props, beats, hat, continues: world ? true : undefined }
225}
226hooks/world.ts 58 lines1// The session's world: a setting, a hat and a few props, set up by the session's first scene.
2// It stays put for the whole session; only the director changes it, now and then, in its scenes:
3// a prop transformed, a new hat, or a door into new scenery.
4
5import { cannedProps } from './canned'
6import type { Beat, Hat, Prop, Scene, Setting } from './scene'
7
8export type SessionWorld = { setting: Setting; props: Prop[]; hat: Hat }
9
10/** How often the director may change the world in one session: rarely, when the work really shifts. */
11export const WORLD_LIMITS = { transform: 6, hat: 4, travel: 2 } as const
12export type WorldChangeKind = keyof typeof WORLD_LIMITS
13export type WorldChangeCounts = Record<WorldChangeKind, number>
14
15export const noChanges = (): WorldChangeCounts => ({ transform: 0, hat: 0, travel: 0 })
16
17/** What the director may still change this session, for its request. */
18export function changesLeft(used: WorldChangeCounts): WorldChangeCounts {
19 return {
20 transform: Math.max(0, WORLD_LIMITS.transform - used.transform),
21 hat: Math.max(0, WORLD_LIMITS.hat - used.hat),
22 travel: Math.max(0, WORLD_LIMITS.travel - used.travel),
23 }
24}
25
26/**
27 * Holds a director's scene to the session's limits: world changes past them are dropped, and the
28 * ones kept are counted. A hat it already wears is no change; a travel with no props gets fitting ones.
29 */
30export function limitChanges(scene: Scene, used: WorldChangeCounts, rand: () => number = Math.random): { scene: Scene; used: WorldChangeCounts } {
31 const counts = { ...used }
32 let hat = scene.hat
33 const beats: Beat[] = []
34 for (const b of scene.beats) {
35 if (b.do === 'transform' || b.do === 'hat' || b.do === 'travel') {
36 if (b.do === 'hat' && b.hat === hat) continue
37 if (counts[b.do] >= WORLD_LIMITS[b.do]) continue
38 counts[b.do]++
39 if (b.do === 'hat') hat = b.hat
40 if (b.do === 'travel') {
41 beats.push(b.props.length ? b : { ...b, props: cannedProps(b.setting, rand) })
42 continue
43 }
44 }
45 beats.push(b)
46 }
47 // a scene left with nothing to do still gives him a moment
48 return { scene: { ...scene, beats: beats.length ? beats : [{ do: 'ponder', secs: 2 }] }, used: counts }
49}
50
51/**
52 * The session's world after the stage reported a change: what played is what the session keeps.
53 * `moved`: it is a new place, so the turn's setting and the director's work in flight follow it.
54 */
55export function followWorld(current: SessionWorld | null, shown: SessionWorld): { world: SessionWorld; moved: boolean } {
56 return { world: { ...shown, props: shown.props.map(p => ({ ...p })) }, moved: current !== null && current.setting !== shown.setting }
57}
58hooks/stage.ts 2233 lines1// The animation: plays a scene's beats with the Claude figure and draws each frame.
2
3import { Canvas, DEFAULT, cellText } from './canvas'
4import type { Beat, Hat, Mood, Prop, Scene } from './scene'
5import { BALLOON, BITS, HATS_ART, MINI_HEIGHT, MINI_WIDTH, miniClaude, CLAUDE_HEIGHT, MOOD_ICONS, CLAUDE_WIDTH, FLAG_WAVE, LOOKS, ORANGE, P, PROPS, SKY_ART, TINTS, bookColor, claude } from './sprites'
6import type { Arm, Face, Look, Tint } from './sprites'
7import type { HubItem } from './hub'
8import {
9 BED, BED_HEAD_X, BED_OVER, CLOUD, COUCH_BACK, COUCH_FRONT, DEN_LOOK, DOOR, FLOWER_COLORS, FLOWER_STAGES, GARDEN_LOOK, HUB_ART, HUB_LOOK,
10 RAIN, RAIN_CLOUD, SLEEP_CAP, TV, TV_SCREEN, WATERING_CAN, claudeLying, flowerPalette,
11} from './hubart'
12
13const MAX_HELPERS = 3 // small Claudes (subagents) on screen at once
14const DOZE_MS = 120_000 // idle this long and Claude falls asleep (or, between turns, goes home)
15const HOME_DOZE_MS = 15_000 // back home, he naps soon
16const DOOR_WIDTH = DOOR.closed[0]!.length
17const HUB_GAP = 5 // columns between two keepsakes
18const MAX_SCENE_MS = 30_000
19const CUT_AFTER = 3 // beats a scene plays before news may end it at its next pause
20const MAX_PLANTED_SIGNS = 2 // a scene this old gives way to a waiting one mid-play
21const BUBBLE = { fg: P.sand, bg: P.wine, border: ORANGE }
22const THOUGHT = { fg: P.silver, bg: P.night, border: P.mist }
23export const SIGN = { fg: P.white, bg: P.bark, border: P.tan }
24const CALL = { fg: P.ink, bg: P.yellow, border: P.amber } // "needs you": the one loud thing on the stage
25const PENNANT = { rows: ['phh', 'pyy', 'p..', 'p..', 'p..'], palette: { p: P.silver, h: P.yellow, y: P.amber } }
26const GOAL_FLAG = { rows: ['phh', 'prr', 'p..', 'p..'], palette: { p: P.silver, h: P.coral, r: P.red } }
27const GOAL_FLAG_DONE = { rows: ['phh', 'pyy', 'p..', 'p..'], palette: { p: P.silver, h: P.yellow, y: P.amber } }
28const BALLOON_UP = { rows: ['.r.', 'rhr', 'rrr', '.r.', '.s.', 's..'], palette: { r: P.red, h: P.coral, s: P.silver } }
29
30/** The hub's rooms, left to right; the door to the turn's world stands in each of them. */
31const ROOMS = ['garden', 'hall', 'den'] as const
32export type Room = (typeof ROOMS)[number]
33const ROOM_LOOK: Record<Room, Look> = { garden: GARDEN_LOOK, hall: HUB_LOOK, den: DEN_LOOK }
34/** What he does at home while nothing calls him away: a calm round at random, like a screensaver. */
35export type Pastime = 'admire' | 'tend' | 'rest' | 'movie'
36const PASTIMES: Record<Pastime, { room: Room; weight: number; secs: [number, number] }> = {
37 admire: { room: 'hall', weight: 2, secs: [15, 30] },
38 tend: { room: 'garden', weight: 3, secs: [25, 45] },
39 rest: { room: 'den', weight: 2, secs: [30, 60] },
40 movie: { room: 'den', weight: 3, secs: [30, 60] },
41}
42const HOME_PACE = 0.6 // he strolls about at home
43const FLOWER_COUNT = 5
44const FLOWER_STEP = 7 // columns from one flower to the next
45const WATER_SECS = 5 // one flower's watering
46const SIT_LIFT = 2 // seated on the couch he sits this many pixels up, his legs behind its front
47
48/** The session's sky, kept quiet: the local hour, and weather that follows how the work goes. */
49export type Weather = 'clear' | 'cloudy' | 'rain'
50export type Ambience = { hour: number; weather: Weather }
51/** Why Claude needs the person: a permission prompt, or a question put to them. */
52export type Attention = 'permission' | 'question'
53
54/** How each mood shows: the face it wears, its tint, and what it gives off per second. */
55const MOOD_LOOK: Record<Mood, { face: Face; tint: Tint; effect?: Effect['kind']; rate?: number }> = {
56 neutral: { face: 'ahead', tint: 'normal' },
57 happy: { face: 'happy', tint: 'normal', effect: 'sparkle', rate: 1 },
58 proud: { face: 'happy', tint: 'normal', effect: 'sparkle', rate: 3 },
59 love: { face: 'happy', tint: 'normal', effect: 'heart', rate: 1.3 },
60 sad: { face: 'sad', tint: 'sad', effect: 'tear', rate: 0.9 },
61 angry: { face: 'angry', tint: 'angry', effect: 'steam', rate: 4 },
62 surprised: { face: 'wide', tint: 'normal' },
63 confused: { face: 'confused', tint: 'normal' },
64 sleepy: { face: 'shut', tint: 'normal', effect: 'zzz', rate: 0.7 },
65 focused: { face: 'focused', tint: 'normal' },
66 worried: { face: 'ahead', tint: 'normal', effect: 'sweat', rate: 0.7 },
67}
68
69/** How busy the stage is: walk speed, and how long the rests between actions last. */
70export type Pace = { walk: number; rest: number }
71export const PACES: Record<'calm' | 'normal' | 'lively', Pace> = {
72 calm: { walk: 9, rest: 1 },
73 normal: { walk: 12, rest: 0.6 },
74 lively: { walk: 15, rest: 0.3 },
75}
76
77/** A subagent at work: a small Claude that walks in, mirrors its tool calls, and reports back. */
78type Helper = {
79 id: string
80 agentId?: string
81 label: string
82 x: number
83 facing: 1 | -1
84 target: number | null
85 state: 'arriving' | 'working' | 'leaving' | 'reporting'
86 activity: 'read' | 'type' | 'dig' | null
87 activityUntil: number
88 born: number
89 lastActive: number
90 isBackground: boolean
91 reportAt: number
92 moving: boolean
93}
94
95type Run = { beat: Beat; start: number; phase: number; phaseAt: number; fromX?: number; target?: number }
96type Particle = { x: number; y: number; vx: number; vy: number; until: number; color: number }
97
98type Effect =
99 | { kind: 'sparkle'; x: number; y: number; color: number; start: number; until: number }
100 | { kind: 'poof' | 'heart' | 'tear' | 'steam' | 'sweat' | 'zzz' | 'dust' | 'balloon'; x: number; y: number; start: number; until: number }
101
102const EMPTY: Scene = { setting: 'meadow', mood: 'neutral', props: [], beats: [] }
103
104export class Stage {
105 constructor(private readonly rand: () => number = Math.random) {}
106
107 /** Walk speed and rest length; /config's pace sets it. */
108 pace: Pace = PACES.calm
109 private helpers: Helper[] = []
110
111 private scene: Scene = EMPTY
112 private waiting: Scene[] = [] // scenes queued to play, oldest first
113 private index = 0
114 private run: Run | null = null
115 private removed = new Set<string>()
116 private placed = new Map<string, number>() // props moved by carry/drop: their column
117 private carrying: string | null = null
118
119 private x = 4
120 private facing: 1 | -1 = 1
121 private lift = 0
122 private looking = false
123 private bob = 0
124 private wanderAt = 0
125 private wanderTo: number | null = null
126 private moving = false
127 private emote: { mood: Mood; until: number } | null = null
128 private balloon: { mood: Mood; until: number } | null = null
129 private spriteTop: number | null = null
130 private idleSince: number | null = null
131 private restUntil = 0 // a breath between two actions
132 private sceneStart = 0
133 private isPendingUrgent = false
134 /** A sign being hammered in, wobbled loose or pulled out: how its drawing moves. */
135 private bubbleArea: Rect | null = null // what the bubble covers this frame
136 private planted = new Set<string>() // signs Claude hammered in this turn
137 private signMotion = new Map<string, { kind: 'grow' | 'shake' | 'rise'; start: number; ms: number }>()
138
139 private bubble: { text: string; kind: 'say' | 'think'; until: number } | null = null
140 private react: { glyph: string; until: number } | null = null
141 private cueMark: { glyph: string; color: number; until: number } | null = null
142 private dirt: Particle[] = []
143 private effects: Effect[] = []
144
145 private columns = 80
146 private rows = 8
147 private last = 0
148 private now = 0
149
150 /** Set while Sonnet is drawing the next scene: a thought bubble of dots. */
151 pondering = false
152
153 /** The hour and the weather the sky shows (outdoor settings only, and only a little). */
154 ambience: Ambience = { hour: 12, weather: 'clear' }
155 /** The agent's task list: a trail along the ground, lit as tasks are done. */
156 progress: { done: number; total: number } | null = null
157 /** Signs at the band's edges to the places beside this one, by name; null draws none. */
158 routeSigns: { left: string | null; right: string | null } | null = null
159
160 // home: the hub, its keepsakes and the door to the turn's world
161 private isHome = false
162 private isAway = false // through the door, the turn's world not yet loaded
163 private isNapping = false // came home after a long idle: he naps soon
164 private hidden = false
165 private homeItems: HubItem[] = []
166 private leaving: { phase: 'wake' | 'walk' | 'open' | 'in'; at: number; isSprint: boolean } | null = null
167 private doorOpenUntil = 0
168 /** A door Claude summoned into the world: he walks through it into new scenery, queued behind it. */
169 private portal: { col: number; phase: 'appear' | 'walk' | 'open' | 'in'; at: number } | null = null
170 private newItem: { at: number; item: HubItem } | null = null
171 private pennants: number[] = [] // commits: little flags planted in this world, by column
172 private homeLabels: { col: number; row: number; text: string; color: number }[] = [] // drawn over the figure
173
174 private room: Room = 'hall'
175 private pastime: { kind: Pastime; phase: 'go' | 'do'; until: number; next: number; flower: number } | null = null
176 private lastPastime: Pastime | null = null
177 private nextPastime: Pastime | null = null // asked for: home after a long idle he goes to bed
178 private pastimeAt = -1 // when the next may start; -1: a few seconds after the first step at home
179 private trip: { to: Room; dir: 1 | -1 } | null = null
180 private flowers: { stage: number; color: number }[] = []
181
182 private calling: Attention | null = null
183 private callingSince = 0
184
185 /** Called as a queued scene begins to play. */
186 onPlay: ((scene: Scene) => void) | null = null
187 /** Called once the world on stage changed: a prop transformed, a new hat, new scenery stepped into. */
188 onWorldChange: ((world: { setting: Scene['setting']; props: Prop[]; hat: Hat }) => void) | null = null
189
190 private tellWorld() {
191 const props = this.scene.props.filter(p => !this.removed.has(p.id)).map(p => ({ ...p }))
192 this.onWorldChange?.({ setting: this.scene.setting, props, hat: this.scene.hat ?? 'none' })
193 }
194
195 /** The figure's column, for tools that follow it. */
196 get figureColumn() {
197 return Math.round(this.x)
198 }
199
200 /** How long the figure has had nothing left to play (0 while a beat runs or a scene waits). */
201 get idleMs(): number {
202 return this.idleSince === null || this.waiting.length ? 0 : this.now - this.idleSince
203 }
204
205 /**
206 * Plays `scene` once the current one has finished (or run past MAX_SCENE_MS),
207 * so stories end before the next begins; `urgent` (the person's own commands) cuts in.
208 */
209 queue(scene: Scene, urgent = false) {
210 if (!scene.continues) this.waiting = [] // a new world supersedes everything queued for the old one
211 this.waiting.push(scene)
212 // keep the queue short: drop the oldest continuation, never a scene that sets up a world
213 while (this.waiting.length > 3) this.waiting.splice(this.waiting[0]!.continues ? 0 : 1, 1)
214 if (urgent) this.isPendingUrgent = true
215 }
216
217 /**
218 * About how long until everything playing and queued has played out (ms): the
219 * beats left, the walks between them and the rests after them. Close, not exact.
220 */
221 get remainingMs(): number {
222 return this.left(false)
223 }
224
225 /**
226 * About how long until a scene of news queued now would start (ms): the playing
227 * scene gives way at its first pause after CUT_AFTER beats with nothing in hand.
228 * With a scene already waiting, that one goes first and plays out.
229 */
230 get untilFreeMs(): number {
231 return this.waiting.length ? this.left(false) : this.left(true)
232 }
233
234 private left(isCut: boolean): number {
235 let x = this.x
236 const span = Math.max(1, this.columns - CLAUDE_WIDTH)
237 // each beat's own time, and the walk to where it happens
238 const beatSecs = (b: Beat, scene: Scene, isCurrent: boolean): number => {
239 const colOf = (id: string) => {
240 const p = scene.props.find(q => q.id === id)
241 if (!p || (isCurrent && this.removed.has(id))) return null
242 return isCurrent ? this.col(p.x, p.id) : (p.x / 100) * span
243 }
244 const go = (to: number | null, speed = this.pace.walk) => {
245 if (to === null) return 0
246 const d = Math.abs(clamp(to, 0, this.maxX()) - x)
247 x = clamp(to, 0, this.maxX())
248 return d / speed
249 }
250 const at = 'at' in b && b.at ? colOf(b.at) : null
251 switch (b.do) {
252 case 'walk': return go(isCurrent ? this.col(b.to) : (b.to / 100) * span)
253 case 'run': return go(isCurrent ? this.col(b.to) : (b.to / 100) * span, this.pace.walk * 2.4)
254 case 'say':
255 case 'think': return b.secs ?? sayTime(b.text)
256 case 'look': return go(at) + 1.8
257 case 'squash': return go(at) + 0.6
258 case 'carry': return go(at) + 0.4
259 case 'type': return go(at) + (b.secs ?? 2.5)
260 case 'drop': return 0.4
261 case 'transform': return go(at) + 2.1
262 case 'plant_sign': return go(isCurrent ? this.col(b.x) : (b.x / 100) * span) + 2.4
263 case 'pull_sign': return go(at) + 2.1
264 case 'read': return b.secs ?? 3
265 case 'emote': return b.secs ?? 3.5
266 case 'ponder': return b.secs ?? 3
267 case 'dance': return b.secs ?? 2.5
268 case 'wave': return 1.6
269 case 'shrug': return 1.3
270 case 'jump': return 0.6
271 case 'celebrate': return 1.6
272 case 'dig': return b.secs ?? 2
273 case 'wait': return b.secs
274 case 'hat': return 1.4
275 case 'travel': return 6
276 }
277 }
278 const rest = (b: Beat) => restAfter(b, 0.5) * this.pace.rest
279 const sceneMs = (scene: Scene, from: number, isCurrent: boolean) => {
280 let ms = 0
281 // what he holds by each pause, as the beats will leave it
282 let holds = isCurrent && (this.carrying !== null || this.run?.beat.do === 'carry')
283 if (isCurrent && this.run && (this.run.beat.do === 'drop' || this.run.beat.do === 'transform')) holds = false
284 for (let i = from; i < scene.beats.length; i++) {
285 if (isCut && isCurrent && i >= CUT_AFTER && !holds) break
286 const b = scene.beats[i]!
287 ms += beatSecs(b, scene, isCurrent) * 1000 + rest(b)
288 if (b.do === 'carry') holds = true
289 if (b.do === 'drop' || b.do === 'transform') holds = false
290 }
291 return ms
292 }
293
294 let current = Math.max(0, this.restUntil - this.now)
295 if (this.run) {
296 // the beat under way: what is left of it, roughly
297 current = Math.max(0, beatSecs(this.run.beat, this.scene, true) * 1000 - (this.now - this.run.start)) + rest(this.run.beat)
298 }
299 current += sceneMs(this.scene, this.index, true)
300 if (this.index < this.scene.beats.length || this.run) {
301 current = Math.min(current, Math.max(0, MAX_SCENE_MS - (this.now - this.sceneStart)))
302 }
303 let total = current
304 for (const scene of this.waiting) total += Math.min(MAX_SCENE_MS, sceneMs(scene, 0, false))
305 return Math.round(total)
306 }
307
308 /**
309 * A tool the agent just started (or one that failed): a small mark over Claude's
310 * head at once, while the director is still staging it. He does not stop what he is doing.
311 */
312 cue(kind: 'read' | 'search' | 'edit' | 'run' | 'web' | 'fail') {
313 const marks = {
314 read: { glyph: '≡', color: P.silver },
315 search: { glyph: '⌕', color: P.silver },
316 edit: { glyph: '✎', color: P.silver },
317 run: { glyph: '»', color: P.silver },
318 web: { glyph: '◌', color: P.silver },
319 fail: { glyph: '!', color: P.coral },
320 }
321 // a failure stays up through the cues of the calls after it
322 if (this.cueMark?.glyph === '!' && kind !== 'fail' && this.cueMark.until > this.now) return
323 this.cueMark = { ...marks[kind], until: this.now + (kind === 'fail' ? 2200 : 1400) }
324 if (kind === 'fail') this.give('sweat')
325 }
326
327 /** Plays `beats` next in the current world, then idles. */
328 interject(beats: Beat[]) {
329 this.waiting.unshift({ ...this.scene, mood: 'neutral', beats, continues: true })
330 this.isPendingUrgent = true
331 }
332
333 /**
334 * The world moves to new scenery (the director's travel beat): whatever he was doing, he stops,
335 * a door appears beside him with a puff, he walks through it and steps out into `scene` from the left.
336 * At home (or already on his way through a door) the new world simply waits to load.
337 */
338 changeScenery(scene: Scene) {
339 this.queue({ ...scene, continues: undefined })
340 if (this.isHome || this.isAway || this.leaving || this.portal) return
341 if (this.carrying) this.putDown()
342 this.run = null
343 this.bubble = null
344 this.lift = 0
345 this.bob = 0
346 this.looking = false
347 const ahead = this.facing > 0 ? this.x + CLAUDE_WIDTH + 6 : this.x - DOOR_WIDTH - 6
348 const room = Math.max(0, this.columns - DOOR_WIDTH - 1)
349 const col = ahead >= 1 && ahead <= room ? Math.round(ahead) : clamp(Math.round(this.facing > 0 ? this.x - DOOR_WIDTH - 6 : this.x + CLAUDE_WIDTH + 6), 1, room)
350 this.portal = { col, phase: 'appear', at: this.now }
351 this.facing = col > this.x ? 1 : -1
352 this.emote = { mood: 'surprised', until: this.now + 900 }
353 const ground = this.rows * 2 - 2
354 this.give('poof', col + Math.floor(DOOR_WIDTH / 2) - 1, ground - DOOR.closed.length)
355 for (let k = 0; k < 4; k++) this.give('sparkle', col + Math.round(this.rand() * DOOR_WIDTH))
356 }
357
358 /** True while a summoned door is up or he is going through it. */
359 get isTravelling(): boolean {
360 return this.portal !== null
361 }
362
363 /** At home, the room he is in and what he is doing there (`hall:admire`); null away. */
364 get homeDoing(): string | null {
365 return this.isHome ? `${this.room}:${this.trip ? 'walking' : this.pastime?.kind ?? 'idle'}` : null
366 }
367
368 /** True at home: the hub, before the first turn or after a long idle. */
369 get home(): boolean {
370 return this.isHome
371 }
372
373 /** The keepsakes on show at home, oldest first. */
374 setHome(items: readonly HubItem[]) {
375 this.homeItems = [...items]
376 }
377
378 /** A new keepsake: if Claude is home it pops in with a sparkle, its label up for a while. */
379 addHomeItem(item: HubItem) {
380 this.homeItems = [...this.homeItems, item]
381 if (!this.isHome) return
382 this.newItem = { at: this.now, item }
383 this.nextPastime = 'admire'
384 const spot = this.homeSpots().find(h => h.item === item)
385 for (let k = 0; k < 5; k++) this.give('sparkle', spot ? spot.col + 3 + Math.round(this.rand() * 4 - 2) : undefined)
386 }
387
388 /** The session opens at home, Claude standing by. */
389 startHome() {
390 this.settleHome()
391 this.x = clamp(this.doorCol() - CLAUDE_WIDTH - 2, 0, this.maxX())
392 }
393
394 /** Idle long enough between turns, and not home yet: time to go home. */
395 get wantsHome(): boolean {
396 return !this.isHome && !this.leaving && !this.portal && this.idleMs >= DOZE_MS - 1500
397 }
398
399 /** Back home through the door: it swings open and he steps out, to nap there soon. */
400 goHome() {
401 if (this.isHome || this.leaving) return
402 this.settleHome()
403 this.x = this.doorSpot()
404 this.facing = -1
405 this.doorOpenUntil = this.now + 900
406 this.isNapping = true
407 this.nextPastime = 'rest'
408 }
409
410 /**
411 * A new turn while home: he sprints for the door and goes through (the turn's world
412 * loads once he is in, so he never keeps it waiting); woken from a nap, he starts up first.
413 * False when he is not home (nothing to do).
414 */
415 leaveHome(): boolean {
416 if (!this.isHome || this.leaving) return false
417 const isWaking = this.isDozing || this.isResting
418 // whatever he was doing at home, he drops it
419 this.pastime = null
420 this.trip = null
421 this.lift = 0
422 this.leaving = { phase: isWaking ? 'wake' : 'walk', at: this.now, isSprint: true }
423 this.emote = isWaking ? { mood: 'surprised', until: this.now + 700 } : null
424 this.wanderTo = null
425 return true
426 }
427
428 /** Why the person is needed now, if they are: Claude stops, turns to them and waves. */
429 get attention(): Attention | null {
430 return this.calling
431 }
432
433 set attention(why: Attention | null) {
434 if (why && !this.calling) this.callingSince = this.now
435 if (!why && this.calling) {
436 // the scene stood still while he waited: it carries on where it was
437 const d = Math.max(0, this.now - this.callingSince)
438 if (this.run) {
439 this.run.start += d
440 this.run.phaseAt += d
441 }
442 this.restUntil += d
443 this.sceneStart += d
444 if (this.bubble) this.bubble.until += d
445 }
446 this.calling = why
447 }
448
449 /** A pat from the person: hearts and a love balloon (and, napping, he wakes up for it). */
450 pet() {
451 if (this.hidden) return
452 this.emote = { mood: 'love', until: this.now + 2600 }
453 this.balloon = { mood: 'love', until: this.now + 2600 }
454 for (let k = 0; k < 3; k++) this.give('heart')
455 }
456
457 /** A commit plants a little flag beside him; a push sends a balloon up. */
458 gitMoment(kind: 'commit' | 'push') {
459 if (kind === 'push') {
460 this.give('balloon')
461 return
462 }
463 const col = this.facing > 0 ? this.x - 4 : this.x + CLAUDE_WIDTH + 1
464 this.pennants = [...this.pennants, clamp(Math.round(col), 0, Math.max(0, this.columns - 3))].slice(-3)
465 this.give('sparkle')
466 this.give('sparkle')
467 }
468
469 private settleHome() {
470 // whatever he was doing in the old world stays there
471 this.run = null
472 this.bubble = null
473 this.emote = null
474 this.lift = 0
475 this.bob = 0
476 this.looking = false
477 this.waiting = []
478 this.load({ setting: 'meadow', mood: 'neutral', props: [], beats: [] })
479 this.isHome = true
480 this.isAway = false
481 this.hidden = false
482 this.leaving = null
483 this.isNapping = false
484 this.idleSince = null // idle from the next step, on its clock
485 this.room = 'hall'
486 this.pastime = null
487 this.trip = null
488 this.pastimeAt = -1
489 }
490
491 /** Where he stands to go through the door, or after stepping out of it. */
492 private doorSpot() {
493 return clamp(this.doorCol() + Math.floor((DOOR_WIDTH - CLAUDE_WIDTH) / 2), 0, this.maxX())
494 }
495
496 private doorCol() {
497 return clamp(Math.round(this.columns * 0.8) - DOOR_WIDTH, 0, Math.max(0, this.columns - DOOR_WIDTH - 1))
498 }
499
500 /** The keepsakes that fit left of the door, newest kept, laid out oldest first. */
501 private homeSpots(): { item: HubItem; col: number }[] {
502 const fit = Math.max(0, Math.floor((this.doorCol() - 6) / (7 + HUB_GAP)))
503 if (fit === 0) return []
504 return this.homeItems.slice(-fit).map((item, i) => ({ item, col: 3 + i * (7 + HUB_GAP) }))
505 }
506
507 /** The summoned door: it appears, he walks to it, it opens, he goes in, and the waiting world loads. */
508 private stepPortal() {
509 const p = this.portal!
510 const since = this.now - p.at
511 if (p.phase === 'appear' && since >= 900) this.portal = { ...p, phase: 'walk', at: this.now }
512 else if (p.phase === 'open' && since >= 500) {
513 this.hidden = true
514 this.portal = { ...p, phase: 'in', at: this.now }
515 } else if (p.phase === 'in' && since >= 600) {
516 this.portal = null
517 const next = this.waiting.findIndex(s => !s.continues)
518 if (next < 0) {
519 this.hidden = false
520 return
521 }
522 this.waiting.splice(0, next)
523 // stepping out of a door, like coming from home: in from the left
524 this.isAway = true
525 this.load(this.waiting.shift()!)
526 this.tellWorld()
527 }
528 }
529
530 private stepLeaving(dt: number) {
531 const l = this.leaving!
532 const next = (phase: 'walk' | 'open' | 'in') => {
533 this.leaving = { ...l, phase, at: this.now }
534 }
535 if (l.phase === 'wake') {
536 // a start: a little jump, eyes wide, then off
537 const t = (this.now - l.at) / 1000
538 this.lift = t < 0.35 ? Math.sin((Math.PI * t) / 0.35) * 2 : 0
539 if (t >= 0.6) next('walk')
540 } else if (l.phase === 'walk') {
541 const isThere = this.walk(this.doorSpot(), dt, l.isSprint ? this.pace.walk * 2.4 : this.pace.walk)
542 if (l.isSprint && !isThere && this.rand() < 12 * dt) this.give('dust')
543 if (isThere) {
544 this.facing = 1
545 next('open')
546 }
547 } else if (l.phase === 'open' && this.now - l.at >= 500) {
548 this.hidden = true
549 next('in')
550 } else if (l.phase === 'in' && this.now - l.at >= 500) {
551 this.leaving = null
552 this.isAway = true
553 }
554 }
555
556 /**
557 * The turn's world as the next scene will find it: the props of the newest queued
558 * world-setting scene, else the current props with their moves, changes and losses.
559 */
560 world(): { setting: Scene['setting']; props: Prop[]; gone: string[]; hat: Hat } {
561 const fresh = [...this.waiting].reverse().find(s => !s.continues)
562 if (fresh) return { setting: fresh.setting, props: fresh.props.map(p => ({ ...p })), gone: [], hat: fresh.hat ?? 'none' }
563 const span = Math.max(1, this.columns - CLAUDE_WIDTH)
564 const props = this.scene.props
565 .filter(p => !this.removed.has(p.id))
566 .map(p => ({ ...p, x: Math.round((this.col(p.x, p.id) / span) * 100) }))
567 return { setting: this.scene.setting, props, gone: [...this.removed], hat: this.scene.hat ?? 'none' }
568 }
569
570 private mood(): Mood {
571 // long idle (no scene for a while, e.g. between turns): he dozes off until the next scene
572 if (this.isDozing || this.isResting) return 'sleepy'
573 return this.emote?.mood ?? this.scene.mood
574 }
575
576 private get isDozing(): boolean {
577 return !this.emote && !this.run && !this.leaving && !this.calling && !this.pastime && !this.trip && this.idleMs >= (this.isHome && this.isNapping ? HOME_DOZE_MS : DOZE_MS)
578 }
579
580 /** Advances the animation to `now` (ms). */
581 step(now: number) {
582 const dt = this.last ? Math.min(0.1, (now - this.last) / 1000) : 0
583 this.last = now
584 this.now = now
585 this.moving = false
586
587 this.dirt = this.dirt.filter(p => p.until > now)
588 for (const p of this.dirt) {
589 p.x += p.vx * dt
590 p.y += p.vy * dt
591 p.vy += 40 * dt
592 }
593 this.effects = this.effects.filter(e => e.until > now)
594 if (this.bubble && this.bubble.until < now) this.bubble = null
595 if (this.react && this.react.until < now) this.react = null
596 if (this.cueMark && this.cueMark.until < now) this.cueMark = null
597 if (this.emote && this.emote.until < now) this.emote = null
598 if (this.balloon && this.balloon.until < now) this.balloon = null
599 if (this.newItem && now - this.newItem.at > 6000) this.newItem = null
600
601 // waiting on the person: he holds still (the scene with him) until they have answered
602 if (this.calling) {
603 this.stepHelpers(dt)
604 return
605 }
606 if (this.leaving) {
607 this.stepLeaving(dt)
608 this.stepHelpers(dt)
609 return
610 }
611 if (this.portal) {
612 this.stepPortal()
613 if (this.portal?.phase === 'walk' && this.walk(this.portal.col + Math.floor((DOOR_WIDTH - CLAUDE_WIDTH) / 2), dt)) {
614 this.facing = 1
615 this.portal = { ...this.portal, phase: 'open', at: now }
616 }
617 this.stepHelpers(dt)
618 return
619 }
620
621 if (this.run && this.isDone(this.run)) {
622 // a breath between actions: longer after handling things or finding something
623 this.restUntil = now + restAfter(this.run.beat, this.rand()) * this.pace.rest
624 this.run = null
625 this.lift = 0
626 this.bob = 0
627 this.looking = false
628 }
629 if (!this.run && (now >= this.restUntil || this.isPendingUrgent)) {
630 const isSceneOver = this.index >= this.scene.beats.length || now - this.sceneStart > MAX_SCENE_MS
631 // news waiting: a scene that has had its say gives way at this pause, as long as his hands are free
632 const isYielding = this.waiting[0]?.isNews === true && this.index >= CUT_AFTER && this.carrying === null
633 const next = this.waiting[0]
634 if (next && (isSceneOver || isYielding || this.isPendingUrgent)) this.load(this.waiting.shift()!)
635 const beat = now >= this.restUntil || this.index === 0 ? this.scene.beats[this.index] : undefined
636 if (beat) {
637 this.index++
638 this.run = { beat, start: now, phase: 0, phaseAt: now }
639 this.idleSince = null
640 } else if (this.idleSince === null) {
641 this.idleSince = now
642 }
643 }
644
645 if (this.run) this.play(this.run, dt)
646 else if (this.isHome && !this.isAway) this.stepHome(dt)
647 else if (now >= this.restUntil) this.idle(dt)
648 this.stepHelpers(dt)
649
650 // what the mood gives off, at its rate
651 const look = MOOD_LOOK[this.mood()]
652 if (look.effect && look.rate && this.rand() < look.rate * dt) this.give(look.effect)
653 }
654
655 private load(scene: Scene) {
656 if (scene.continues) {
657 // same world: keep its props as they now are (moved, transformed, squashed)
658 scene = { ...scene, setting: this.scene.setting, props: this.scene.props, hat: this.scene.hat }
659 } else {
660 scene = { ...scene, props: scene.props.map(p => ({ ...p })) }
661 if (this.isHome || this.isAway) {
662 // out of the door and into the turn's world, stepping in from the left
663 this.isHome = false
664 this.isAway = false
665 this.hidden = false
666 this.x = 2
667 this.facing = 1
668 this.give('dust', 3, this.rows * 2 - 3)
669 }
670 this.pennants = []
671 this.removed.clear()
672 this.placed.clear()
673 this.planted.clear()
674 this.signMotion.clear()
675 this.carrying = null
676 }
677 this.scene = scene
678 if (!scene.continues) this.arrange()
679 this.isPendingUrgent = false
680 this.sceneStart = this.now
681 this.restUntil = 0
682 this.index = 0
683 this.emote = null
684 // a new scene announces its mood once, in a balloon
685 this.balloon = scene.mood === 'neutral' ? null : { mood: scene.mood, until: this.now + 2500 }
686 this.onPlay?.(scene)
687 }
688
689 private secs(run: Run) {
690 return (this.now - run.start) / 1000
691 }
692
693 private isDone(run: Run): boolean {
694 const t = this.secs(run)
695 const b = run.beat
696 switch (b.do) {
697 case 'walk':
698 case 'run': return Math.abs(this.x - this.target(this.col(b.to))) < 0.5
699 case 'say':
700 case 'think': return t >= (b.secs ?? sayTime(b.text))
701 case 'look':
702 case 'squash':
703 case 'carry':
704 case 'type': return run.phase === 2
705 case 'drop': return t >= 0.4
706 case 'transform': return run.phase === 4
707 case 'plant_sign': return run.phase === 2
708 case 'pull_sign': return run.phase === 3
709 case 'read': return t >= (b.secs ?? 3)
710 case 'emote': return t >= (b.secs ?? 3.5)
711 case 'ponder': return t >= (b.secs ?? 3)
712 case 'dance': return t >= (b.secs ?? 2.5)
713 case 'wave': return t >= 1.6
714 case 'shrug': return t >= 1.3
715 case 'jump': return t >= 0.6
716 case 'celebrate': return t >= 1.6
717 case 'dig': return t >= (b.secs ?? 2)
718 case 'wait': return t >= b.secs
719 case 'hat': return t >= 1.4
720 case 'travel': return true // the door takes over: changeScenery ends the beat itself
721 }
722 }
723
724 private play(run: Run, dt: number) {
725 const b = run.beat
726 const t = this.secs(run)
727 switch (b.do) {
728 case 'walk':
729 this.walk(this.col(b.to), dt)
730 break
731 case 'run':
732 if (!this.walk(this.col(b.to), dt, this.pace.walk * 2.4) && this.rand() < 12 * dt) this.give('dust')
733 break
734 case 'say':
735 case 'think':
736 if (run.phase === 0) {
737 const secs = b.secs ?? sayTime(b.text)
738 this.bubble = { text: b.text, kind: b.do, until: this.now + (secs + 4) * 1000 }
739 run.phase = 1
740 }
741 break
742 case 'look':
743 case 'squash':
744 case 'carry':
745 case 'type': {
746 const prop = 'at' in b && b.at ? this.prop(b.at) : undefined
747 if (run.phase === 0) {
748 if ('at' in b && b.at && !prop) {
749 run.phase = 2
750 break
751 }
752 if (!prop || this.approach(prop, dt)) {
753 run.phase = 1
754 run.phaseAt = this.now
755 run.fromX = this.x
756 if (b.do === 'look') this.react = { glyph: b.react ?? '?', until: this.now + 1600 }
757 }
758 break
759 }
760 if (run.phase !== 1) break
761 const p = (this.now - run.phaseAt) / 1000
762 if (b.do === 'look') {
763 this.looking = true
764 if (p >= 1.8) run.phase = 2
765 } else if (b.do === 'type') {
766 this.bob = Math.floor(this.now / 160) % 2
767 if (p >= (b.secs ?? 2.5)) run.phase = 2
768 } else if (b.do === 'carry') {
769 this.lift = Math.min(1, p / 0.4) * 1
770 if (p >= 0.4 && prop) {
771 this.carrying = prop.id
772 run.phase = 2
773 }
774 } else if (prop) {
775 // a hop that lands squarely on top of the bug
776 const d = Math.min(1, p / 0.6)
777 const onTop = this.col(prop.x, prop.id) + propWidth(prop) / 2 - CLAUDE_WIDTH / 2
778 this.x = (run.fromX ?? this.x) + (onTop - (run.fromX ?? this.x)) * d
779 this.facing = onTop >= (run.fromX ?? this.x) ? 1 : -1
780 // the arc comes down onto the bug's back, not the grass beside it
781 const perch = prop.kind === 'sign' ? 0 : PROPS[prop.kind].rows.length
782 this.lift = d < 0.5 ? Math.sin(Math.PI * d) * 6 : Math.max(perch, Math.sin(Math.PI * d) * 6)
783 if (d >= 1) {
784 this.removed.add(prop.id)
785 this.give('poof', this.col(prop.x, prop.id) + 2, this.rows * 2 - 5)
786 run.phase = 2
787 }
788 }
789 break
790 }
791 case 'drop':
792 if (run.phase === 0) {
793 this.putDown()
794 run.phase = 1
795 }
796 break
797 case 'plant_sign': {
798 // walk beside the spot, hammer the post in with dust on each blow, the board types itself in
799 // a free spot near where it was asked for; with no room anywhere, no sign goes up
800 if (run.target === undefined) {
801 const spot = this.freeCol({ id: b.id, kind: 'sign', x: b.x, label: b.label }, this.col(b.x))
802 if (spot === null) {
803 run.phase = 2
804 break
805 }
806 run.target = spot
807 }
808 const at = run.target
809 const width = [...b.label].length + 4
810 const p = (this.now - run.phaseAt) / 1000
811 if (run.phase === 0) {
812 const spot = at - CLAUDE_WIDTH - 1 >= 0 ? at - CLAUDE_WIDTH - 1 : at + width + 1
813 if (!this.walk(spot, dt)) break
814 this.facing = spot < at ? 1 : -1
815 const standing = [...this.planted].filter(id => !this.removed.has(id)).length
816 if (standing >= MAX_PLANTED_SIGNS || this.scene.props.some(q => q.id === b.id && !this.removed.has(q.id))) {
817 run.phase = 2 // no room for another sign (or it already stands): the beat passes
818 break
819 }
820 this.removed.delete(b.id)
821 this.planted.add(b.id)
822 this.scene.props.push({ id: b.id, kind: 'sign', x: b.x, label: b.label })
823 this.placed.set(b.id, at)
824 this.signMotion.set(b.id, { kind: 'grow', start: this.now, ms: 2200 })
825 run.phase = 1
826 run.phaseAt = this.now
827 } else if (run.phase === 1) {
828 const strike = Math.floor(p / 0.35)
829 this.bob = strike % 2
830 if (this.bob === 0 && Math.floor((p - dt) / 0.35) !== strike) {
831 this.give('dust', at + Math.floor(width / 2) - 2, this.rows * 2 - 3)
832 }
833 if (p >= 2.4) {
834 this.bob = 0
835 this.signMotion.delete(b.id)
836 run.phase = 2
837 }
838 }
839 break
840 }
841 case 'pull_sign': {
842 // grab the sign, tug it loose, it rises out of the ground and vanishes in a puff
843 const prop = this.prop(b.at)
844 if (!prop || prop.kind !== 'sign') {
845 run.phase = 3
846 break
847 }
848 const p = (this.now - run.phaseAt) / 1000
849 if (run.phase === 0) {
850 if (this.approach(prop, dt)) {
851 run.phase = 1
852 run.phaseAt = this.now
853 this.signMotion.set(prop.id, { kind: 'shake', start: this.now, ms: 1400 })
854 }
855 } else if (run.phase === 1) {
856 this.bob = Math.floor(p / 0.25) % 2
857 if (p >= 1.4) {
858 this.bob = 0
859 this.lift = 1
860 run.phase = 2
861 run.phaseAt = this.now
862 this.signMotion.set(prop.id, { kind: 'rise', start: this.now, ms: 700 })
863 }
864 } else if (run.phase === 2 && p >= 0.7) {
865 const width = [...(prop.label ?? '?')].length + 4
866 this.give('poof', this.col(prop.x, prop.id) + Math.floor(width / 2) - 1, this.rows * 2 - 10)
867 this.removed.add(prop.id)
868 this.signMotion.delete(prop.id)
869 this.lift = 0
870 run.phase = 3
871 }
872 break
873 }
874 case 'transform': {
875 // lift it, a puff of magic smoke overhead, it is something else now, set it down
876 const prop = this.carrying === b.at ? this.scene.props.find(p => p.id === b.at) : this.prop(b.at)
877 if (!prop) {
878 run.phase = 4
879 break
880 }
881 const p = (this.now - run.phaseAt) / 1000
882 const next = (phase: number) => {
883 run.phase = phase
884 run.phaseAt = this.now
885 }
886 if (run.phase === 0) {
887 if (this.carrying === prop.id || this.approach(prop, dt)) next(1)
888 } else if (run.phase === 1) {
889 this.lift = Math.min(1, p / 0.4)
890 if (p >= 0.4) {
891 this.carrying = prop.id
892 next(2)
893 }
894 } else if (run.phase === 2 && p >= 0.7) {
895 const overhead = (this.spriteTop ?? this.rows * 2 - 9) - 3
896 this.give('poof', Math.round(this.x + CLAUDE_WIDTH / 2) - 1, overhead)
897 for (let k = 0; k < 4; k++) this.give('sparkle')
898 prop.kind = b.into
899 prop.label = b.label
900 next(3)
901 this.tellWorld()
902 } else if (run.phase === 3 && p >= 1.0) {
903 this.putDown()
904 next(4)
905 }
906 break
907 }
908 case 'hat':
909 // a puff over his head, and he wears the new hat
910 if (run.phase === 0) {
911 const overhead = (this.spriteTop ?? this.rows * 2 - 9) - 2
912 this.give('poof', Math.round(this.x + CLAUDE_WIDTH / 2) - 1, overhead)
913 for (let k = 0; k < 3; k++) this.give('sparkle')
914 this.scene.hat = b.hat
915 this.emote = { mood: 'happy', until: this.now + 1200 }
916 run.phase = 1
917 this.tellWorld()
918 }
919 break
920 case 'travel': {
921 const props = b.props.map(p => ({ ...p }))
922 const first = props[0]
923 this.changeScenery({
924 setting: b.setting, mood: 'neutral', props, hat: b.hat ?? this.scene.hat,
925 beats: [{ do: 'emote', mood: 'happy', secs: 1.5 }, ...(first ? [{ do: 'look' as const, at: first.id }] : [])],
926 })
927 break
928 }
929 case 'emote':
930 if (run.phase === 0) {
931 this.emote = { mood: b.mood, until: this.now + (b.secs ?? 3.5) * 1000 }
932 this.balloon = b.mood === 'neutral' ? null : { mood: b.mood, until: this.emote.until }
933 run.phase = 1
934 }
935 if (b.mood === 'surprised') this.lift = t < 0.35 ? Math.sin((Math.PI * t) / 0.35) * 2 : 0
936 if (b.mood === 'confused') this.facing = Math.floor(t / 0.6) % 2 === 0 ? 1 : -1
937 break
938 case 'dance':
939 this.lift = Math.abs(Math.sin(Math.PI * t * 2)) * 2
940 this.facing = Math.floor(t * 2) % 2 === 0 ? 1 : -1
941 if (this.rand() < 3 * dt) this.give('sparkle')
942 break
943 case 'jump':
944 this.lift = Math.sin(Math.PI * Math.min(1, t / 0.6)) * 4
945 break
946 case 'celebrate':
947 this.lift = Math.abs(Math.sin((Math.PI * t) / 0.5)) * 3
948 if (this.rand() < 0.5) this.give('sparkle')
949 break
950 case 'dig': {
951 // a stroke every 0.5 s: blade down (crouched), then up, flinging a clod over his shoulder
952 const isDown = (t % 0.5) < 0.25
953 this.bob = isDown ? 1 : 0
954 if (!isDown && (t - dt) % 0.5 < 0.25) for (let k = 0; k < 3; k++) this.throwDirt()
955 break
956 }
957 case 'ponder': {
958 // stands still, looks one way, then turns to look the other way
959 const secs = b.secs ?? 3
960 this.looking = Math.floor(t / 1.2) % 2 === 1
961 if (run.phase === 0 && t > secs / 2) {
962 this.facing = this.facing > 0 ? -1 : 1
963 run.phase = 1
964 }
965 break
966 }
967 case 'read':
968 case 'wave':
969 case 'shrug':
970 case 'wait':
971 break
972 }
973 }
974
975 private idle(dt: number) {
976 this.bob = 0
977 if (this.mood() === 'sleepy') return
978 if (this.wanderTo !== null) {
979 if (this.walk(this.wanderTo, dt * 0.5)) this.wanderTo = null
980 } else if (this.now > this.wanderAt) {
981 this.wanderAt = this.now + 3000 + this.rand() * 4000
982 if (this.rand() < 0.6) this.wanderTo = clamp(this.x + (this.rand() * 16 - 8), 0, this.maxX())
983 }
984 }
985
986 private get isResting(): boolean {
987 return this.isHome && this.pastime?.kind === 'rest' && this.pastime.phase === 'do'
988 }
989
990 private get isWatching(): boolean {
991 return this.isHome && this.pastime?.kind === 'movie' && this.pastime.phase === 'do'
992 }
993
994 private get isTending(): boolean {
995 return this.isHome && this.pastime?.kind === 'tend' && this.pastime.phase === 'do'
996 }
997
998 /** At home with nothing to play: a pastime, the walk to its room, or a pause between two. */
999 private stepHome(dt: number) {
1000 this.bob = 0
1001 if (this.trip) return this.stepTrip(dt)
1002 const p = this.pastime
1003 if (!p) {
1004 if (this.pastimeAt < 0) this.pastimeAt = this.now + 4000
1005 if (this.now < this.pastimeAt) return
1006 const kind = this.pickPastime()
1007 this.pastime = { kind, phase: 'go', until: 0, next: 0, flower: Math.floor(this.rand() * FLOWER_COUNT) }
1008 const room = PASTIMES[kind].room
1009 if (room !== this.room) this.trip = { to: room, dir: ROOMS.indexOf(room) > ROOMS.indexOf(this.room) ? 1 : -1 }
1010 return
1011 }
1012 if (p.phase === 'go') {
1013 const spot = this.pastimeSpot(p)
1014 if (spot === null) return this.endPastime() // no room for it at this width
1015 if (!this.walk(spot, dt * HOME_PACE)) return
1016 const [lo, hi] = PASTIMES[p.kind].secs
1017 p.phase = 'do'
1018 p.until = this.now + (lo + this.rand() * (hi - lo)) * 1000
1019 p.next = this.now
1020 if (p.kind === 'movie' || p.kind === 'tend') this.facing = 1
1021 return
1022 }
1023 if (this.now >= p.until) return this.endPastime()
1024 switch (p.kind) {
1025 case 'admire': return this.admire(dt)
1026 case 'tend': return this.tend(p, dt)
1027 case 'movie':
1028 // now and then the film gets a reaction
1029 if (this.now >= p.next) {
1030 p.next = this.now + 7000 + this.rand() * 7000
1031 if (p.next > this.now + 7500) this.react = { glyph: ['!', '?', '♪', '♥'][Math.floor(this.rand() * 4)]!, until: this.now + 1400 }
1032 }
1033 return
1034 case 'rest':
1035 return
1036 }
1037 }
1038
1039 private endPastime() {
1040 const p = this.pastime
1041 if (p?.kind === 'tend') this.flowerTended(p.flower)
1042 this.lastPastime = p?.kind ?? null
1043 this.pastime = null
1044 this.lift = 0
1045 this.pastimeAt = this.now + 3000 + this.rand() * 5000
1046 }
1047
1048 private pickPastime(): Pastime {
1049 const asked = this.nextPastime
1050 this.nextPastime = null
1051 if (asked) return asked
1052 const options = (Object.keys(PASTIMES) as Pastime[]).filter(k => k !== this.lastPastime && (k !== 'admire' || this.homeItems.length))
1053 const total = options.reduce((sum, k) => sum + PASTIMES[k].weight, 0)
1054 let r = this.rand() * total
1055 for (const k of options) if ((r -= PASTIMES[k].weight) < 0) return k
1056 return options[0] ?? 'tend'
1057 }
1058
1059 /** Out one side of the room and in from the other side of the next, until he is where the pastime is. */
1060 private stepTrip(dt: number) {
1061 const t = this.trip!
1062 if (this.room === t.to) {
1063 this.trip = null // in from the edge: the pastime's own walk takes him to its spot
1064 return
1065 }
1066 this.facing = t.dir
1067 this.moving = true
1068 this.x += t.dir * this.pace.walk * HOME_PACE * dt
1069 const isOut = t.dir > 0 ? this.x >= this.columns + 1 : this.x <= -CLAUDE_WIDTH - 1
1070 if (!isOut) return
1071 this.room = ROOMS[ROOMS.indexOf(this.room) + t.dir]!
1072 this.x = t.dir > 0 ? -CLAUDE_WIDTH : this.columns
1073 }
1074
1075 /** Where he stands for a pastime in its room, or null when the room has no space for it. */
1076 private pastimeSpot(p: { kind: Pastime; flower: number }): number | null {
1077 const den = this.denLayout()
1078 switch (p.kind) {
1079 case 'admire': return clamp(this.x, 0, this.maxX())
1080 case 'tend': {
1081 const col = this.flowerCol(p.flower)
1082 return col === null ? null : clamp(col - CLAUDE_WIDTH + 1, 0, this.maxX())
1083 }
1084 case 'movie': return den.tv === null ? null : den.couch + 3
1085 case 'rest': return den.bed === null ? null : den.bed + BED_HEAD_X
1086 }
1087 }
1088
1089 /** The den's furniture by column, left to right, each null where it would not fit before the door. */
1090 private denLayout(): { bed: number | null; couch: number; tv: number | null } {
1091 const room = this.doorCol() - 2
1092 const bed = 2
1093 const couch = bed + BED.rows[0]!.length + 4
1094 const tv = couch + COUCH_BACK.rows[0]!.length + 5
1095 return { bed: bed + BED.rows[0]!.length <= room ? bed : null, couch, tv: tv + TV.rows[0]!.length <= room ? tv : null }
1096 }
1097
1098 private flowerCol(i: number): number | null {
1099 const start = Math.max(3, Math.round(this.doorCol() * 0.2))
1100 const col = start + i * FLOWER_STEP
1101 return col + FLOWER_STAGES[0]![0]!.length <= this.doorCol() - 2 ? col : null
1102 }
1103
1104 /** At the keepsakes: strolls from one to another, the label up as he stands by it. */
1105 private admire(dt: number) {
1106 if (this.wanderTo !== null) {
1107 if (this.walk(this.wanderTo, dt * HOME_PACE)) this.wanderTo = null
1108 return
1109 }
1110 if (this.now < this.wanderAt) return
1111 this.wanderAt = this.now + 3000 + this.rand() * 4000
1112 const spots = this.homeSpots()
1113 if (!spots.length) return
1114 const s = spots[Math.floor(this.rand() * spots.length)]!
1115 this.wanderTo = clamp(s.col + 3 - Math.floor(CLAUDE_WIDTH / 2) + (this.rand() < 0.5 ? -9 : 9), 0, this.maxX())
1116 }
1117
1118 /** In the garden: kneels by a flower with the can, a few drops falling, then moves on to the next. */
1119 private tend(p: { next: number; flower: number }, dt: number) {
1120 const spot = this.flowerCol(p.flower)
1121 const at = spot === null ? null : clamp(spot - CLAUDE_WIDTH + 1, 0, this.maxX())
1122 if (at !== null && Math.abs(this.x - at) > 0.5) {
1123 this.walk(at, dt * HOME_PACE)
1124 p.next = this.now
1125 return
1126 }
1127 this.facing = 1
1128 this.bob = Math.floor(this.now / 700) % 2
1129 if (this.rand() < 9 * dt) {
1130 const spout = Math.round(this.x) + CLAUDE_WIDTH + 3
1131 const top = this.spriteTop ?? this.rows * 2 - 9
1132 this.dirt.push({ x: spout, y: top + 3, vx: 1 + this.rand(), vy: 1, until: this.now + 500, color: P.sky })
1133 }
1134 if (this.now - p.next >= WATER_SECS * 1000) {
1135 // this one is watered: it grows a stage; on to another
1136 this.flowerTended(p.flower)
1137 let next = Math.floor(this.rand() * FLOWER_COUNT)
1138 if (next === p.flower) next = (next + 1) % FLOWER_COUNT
1139 p.flower = this.flowerCol(next) === null ? 0 : next
1140 p.next = this.now
1141 }
1142 }
1143
1144 private flowerTended(i: number) {
1145 this.ensureFlowers()
1146 const f = this.flowers[i]
1147 if (!f) return
1148 if (f.stage < FLOWER_STAGES.length - 1) f.stage++
1149 else if (this.rand() < 0.25) {
1150 // a bloom that has had its day is picked, and a new seed goes in
1151 f.stage = 0
1152 f.color = Math.floor(this.rand() * FLOWER_COLORS.length)
1153 }
1154 }
1155
1156 private ensureFlowers() {
1157 while (this.flowers.length < FLOWER_COUNT) {
1158 this.flowers.push({ stage: Math.floor(this.rand() * FLOWER_STAGES.length), color: Math.floor(this.rand() * FLOWER_COLORS.length) })
1159 }
1160 }
1161
1162 /** Walks beside a prop and turns to it; true once there. */
1163 private approach(prop: Prop, dt: number): boolean {
1164 const at = this.col(prop.x, prop.id)
1165 const side = this.x + CLAUDE_WIDTH / 2 < at ? -1 : 1
1166 const spot = side < 0 ? at - CLAUDE_WIDTH - 1 : at + propWidth(prop) + 1
1167 if (!this.walk(spot, dt)) return false
1168 this.facing = side < 0 ? 1 : -1
1169 return true
1170 }
1171
1172 private target(col: number) {
1173 return clamp(col, 0, this.maxX())
1174 }
1175
1176 /** Moves toward `target`; true once there. */
1177 private walk(target: number, dt: number, speed = this.pace.walk): boolean {
1178 target = this.target(target)
1179 const d = target - this.x
1180 if (Math.abs(d) < 0.5) {
1181 this.x = target
1182 return true
1183 }
1184 this.facing = d > 0 ? 1 : -1
1185 this.moving = true
1186 this.x += Math.sign(d) * Math.min(Math.abs(d), speed * dt)
1187 return false
1188 }
1189
1190 /** Starts an effect near the head (or at a given pixel). */
1191 private give(kind: Effect['kind'], x?: number, y?: number) {
1192 // the sprite's real top edge as last drawn: a slumped or crouched figure is a pixel lower
1193 const head = this.spriteTop ?? this.rows * 2 - 2 - CLAUDE_HEIGHT - Math.round(this.lift)
1194 const mid = this.x + CLAUDE_WIDTH / 2
1195 const now = this.now
1196 switch (kind) {
1197 case 'sparkle': {
1198 const color = [P.yellow, P.white, P.coral, P.sky][Math.floor(this.rand() * 4)]!
1199 this.effects.push({
1200 kind, color, start: now, until: now + 450,hooks/usage.ts 49 lines1// What the director's Sonnet calls cost, in tokens: per session and per day.
2
3import type { ModelUsage } from 'claude-code'
4
5export type Tally = { calls: number; input: number; output: number; cacheRead: number; cacheWrite: number }
6
7export const emptyTally = (): Tally => ({ calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
8
9/** Adds one call's usage to a tally (a new one; the old is left as it was). */
10export function addUsage(t: Tally, u: ModelUsage): Tally {
11 return {
12 calls: t.calls + 1,
13 input: t.input + u.input_tokens,
14 output: t.output + u.output_tokens,
15 cacheRead: t.cacheRead + u.cache_read_input_tokens,
16 cacheWrite: t.cacheWrite + u.cache_creation_input_tokens,
17 }
18}
19
20/** Reads a stored tally back, tolerating anything missing or malformed. */
21export function asTally(v: unknown): Tally {
22 const o = (typeof v === 'object' && v !== null ? v : {}) as Record<string, unknown>
23 const n = (k: keyof Tally) => (typeof o[k] === 'number' && Number.isFinite(o[k]) ? (o[k] as number) : 0)
24 return { calls: n('calls'), input: n('input'), output: n('output'), cacheRead: n('cacheRead'), cacheWrite: n('cacheWrite') }
25}
26
27/** Today's date as YYYY-MM-DD in local time: the key the daily tally is stored under. */
28export function today(now = new Date()): string {
29 const pad = (n: number) => String(n).padStart(2, '0')
30 return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
31}
32
33const fmt = (n: number) => (n >= 10_000 ? `${(n / 1000).toFixed(1)}k` : String(n))
34
35export function formatStats(model: string, session: Tally, day: Tally, dayKey: string): string {
36 const row = (label: string, t: Tally) =>
37 `| ${label} | ${t.calls} | ${fmt(t.input)} | ${fmt(t.output)} | ${fmt(t.cacheRead)} | ${fmt(t.cacheWrite)} |`
38 return [
39 `Agent comic director (model: ${model}): tokens spent on staging scenes.`,
40 '',
41 '| | calls | input | output | cache read | cache write |',
42 '|---|---|---|---|---|---|',
43 row('this session', session),
44 row(`today (${dayKey})`, day),
45 '',
46 'Idle time costs nothing: scenes are only requested while Claude works.',
47 ].join('\n')
48}
49hooks/places.ts 71 lines1// The comic's world beyond the hub: places other plugins add through $.comic, on a ring that
2// starts at the hub. Wooden signs at the band's edges lead from one stop to the next.
3
4import type { ComicKit, ComicPlace, ComicSide, ComicStop } from '../types'
5import { cellText } from './canvas'
6import { clip } from './scene'
7import { HATS_ART, P, TINTS, claude } from './sprites'
8import { SIGN } from './stage'
9import type { Pose } from './sprites'
10
11export const COMIC_VERSION = 1
12export const HUB: ComicStop = { id: null, name: 'Hub' }
13export const ALIVE_MS = 5000 // a place silent this long has stopped drawing: Claude comes home
14const MAX_NAME = 16
15const MAX_ID = 64
16
17/** A place as a plugin gave it, made safe to draw; null when it is no place. */
18export function asPlace(v: unknown): ComicPlace | null {
19 const p = v as { id?: unknown; name?: unknown } | null
20 if (!p || typeof p.id !== 'string' || typeof p.name !== 'string') return null
21 const id = p.id.trim().slice(0, MAX_ID)
22 const name = clip(cellText(p.name.trim()), MAX_NAME)
23 return id && name ? { id, name } : null
24}
25
26/** The ring with `place` on it (renamed if it was), the hub first, the places by id so signs never jump. */
27export function withPlace(ring: readonly ComicStop[], place: ComicPlace): ComicStop[] {
28 const places = ring.filter((s): s is ComicPlace => s.id !== null && s.id !== place.id)
29 return [HUB, ...[...places, place].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))]
30}
31
32/** The ring without the place `id`. */
33export function withoutPlace(ring: readonly ComicStop[], id: string): ComicStop[] {
34 return [HUB, ...ring.filter(s => s.id !== null && s.id !== id)]
35}
36
37/**
38 * The signs from `here`: its neighbours on the ring. With the hub alone there are none; with one
39 * place, a single sign each way between them, so no stop shows the same sign on both sides.
40 */
41export function routeOf(ring: readonly ComicStop[], here: string | null): { left: ComicStop | null; right: ComicStop | null } {
42 const stops = ring.length ? ring : [HUB]
43 const i = Math.max(0, stops.findIndex(s => s.id === here))
44 if (stops.length < 2) return { left: null, right: null }
45 if (stops.length === 2) return i === 0 ? { left: null, right: stops[1]! } : { left: stops[0]!, right: null }
46 return { left: stops[(i - 1 + stops.length) % stops.length]!, right: stops[(i + 1) % stops.length]! }
47}
48
49/** The side Claude comes in from after following the sign on side `dir`: the other one. */
50export const enteringFrom = (dir: ComicSide): ComicSide => (dir === 1 ? -1 : 1)
51
52/** The comic's art as plain data, for places to draw the same Claude. */
53export function kit(): ComicKit {
54 const pose = (p: Partial<Pose>): string[] =>
55 claude({ face: 'ahead', back: 'down', front: 'down', walk: 0, airborne: false, crouch: false, left: false, ...p })
56 return {
57 claude: {
58 stand: pose({}),
59 blink: pose({ face: 'blink' }),
60 happy: pose({ face: 'happy', back: 'up', front: 'up' }),
61 asleep: pose({ face: 'shut', crouch: true }),
62 wave: pose({ face: 'happy', front: 'up' }),
63 walk: [0, 1, 2, 3].map(walk => pose({ face: 'right', walk })),
64 palette: { ...TINTS.normal },
65 },
66 hats: Object.fromEntries(Object.entries(HATS_ART).map(([k, h]) => [k, { rows: [...h.rows], palette: { ...h.palette }, sit: h.sit }])),
67 sign: { ...SIGN },
68 palette: { ...P },
69 }
70}
71