A living 8-bit office of Claude crabs in a side panel: the main session, its subagents and your other sessions act out what they are really doing.

A Claude Code mod that turns a side panel into a living 8-bit office of Claude crabs. The crabs act out what is really happening:
Background crabs wander, chat, nap and squabble between the rooms, riding a two-crab elevator.
The plugin is named clawd-office, because Claude Code reserves names starting with claude-. The design is in docs/claude-office-design.md; open docs/mockup/crab-town-v5.html in a browser to see the approved look.
At the prompt of a Claude Code session:
/plugin install clawd-office --marketplace Djdhmf/clawd-office
Answer y to add the marketplace, then pick a scope. From a shell:
claude plugin install clawd-office --marketplace Djdhmf/clawd-office
Then run /reload-plugins, or start a new session.
/office toggles it. /office on and /office off set it, and the choice is kept across sessions./office says why.Each row is a real event, the way the mod sees it, and what happens in the office. The list in hooks/events.ts (CATALOG) is the source of truth.
| Group | Events |
|---|---|
| You | prompt sent, Enhance used (prompt-enhancer), typing, image pasted, @mention, slash command expanded, Esc |
| Turn | turn starts, thinking, parallel tools, turn done, turn failed |
| Tools | Read, Grep/Glob, Edit/Write, NotebookEdit, LSP, ToolSearch, tool failed |
| Shell | Bash, tests (pass / fail), build, install, background shell, Monitor |
| Git | commit, push, pull request, branch switch, worktree |
| Web | WebSearch, WebFetch |
| Agents | subagent starts, finishes or fails, Skill, team task created / done, teammate idle |
| Sessions | a message between two other sessions, a message to and from main, ListAgents, sessions opening, busy, idle, closing |
| MCP | MCP tool call, MCP asks for input |
| Permissions | needs permission, granted, denied, AskUserQuestion, plan mode |
| Tasks | todos, scheduling, notifications, remote trigger, artifact published |
| Context | context filling, compaction, CLAUDE.md loaded, cost milestone, rate limited |
| Settings | model switch, fast mode, settings changed, folder changed, file changed outside, remote control, /clear, resume, long idle, session end |
The design listed a few rows that Claude Code 2.1.301 gives a mod no signal for. They are left out rather than guessed:
config.set.~/.claude/claude-office/<session>.json.The shared files in ~/.claude/claude-office/ hold short labels only: event kind, tool name, file base name, agent type and session name. They never hold prompt, message or command text, and they stay on this machine.
MIT
hooks/register.ts 323 lines1import type { EngineInterface, Register } from 'claude-code'
2import { classifyTool, labelFor } from './events'
3import type { EventKind, OfficeEvent } from './events'
4import { createFeed } from './feed'
5import type { OfficeProps, Roster } from './office/client'
6import { claudeDir, lettersFrom, readOpenSessions, readPeerFiles, reconcileAgents, trimRoster, writeOwnFile } from './sessions'
7import type { Disk } from './sessions'
8
9const POLL_MS = 2000
10
11export const OFFICE = 'office'
12const TITLE = 'Clawd Office'
13const ENABLED_KEY = 'enabled'
14const COST_MILESTONES = [1, 2, 5, 10, 20, 50, 100]
15const IDLE_LONG_MS = 10 * 60 * 1000
16
17export const DOCK_HELP =
18 'Clawd Office only opens as a side panel docked beside the conversation: that needs Claude Code\'s fullscreen layout and a window at least 110 columns wide. Turn on the fullscreen layout (or widen the window), then run /office again.'
19
20// What the hooks module keeps for the office: a bounded feed of events, the
21// roster of real crabs, and what the pane last showed.
22const feed = createFeed()
23const roster: Roster = { agents: [], sessions: [] }
24let placement: 'dock' | 'inline' | undefined
25let isOpen = false
26let size = { columns: 110, rows: 40 }
27let lastTurnAt = Date.now()
28let idleReported = false
29let lastContext = 0
30let costShown = 0
31let lastTyping = 0
32let lastThinking = 0
33const askedTools = new Set<string>()
34
35const push = (kind: EventKind, extra: Omit<OfficeEvent, 'seq' | 'kind'> = {}) => feed.push({ kind, ...extra })
36const field = (e: unknown, key: string): unknown => (e && typeof e === 'object' ? (e as Record<string, unknown>)[key] : undefined)
37const text = (e: unknown, key: string): string | undefined => {
38 const v = field(e, key)
39
40 return typeof v === 'string' && v !== '' ? v : undefined
41}
42const base = (path: string | undefined) => path?.split(/[\\/]/).filter(Boolean).pop()?.slice(0, 40)
43
44const propsFor = (since: number): OfficeProps => ({
45 events: feed.since(since),
46 roster: trimRoster({ agents: [...roster.agents], sessions: [...roster.sessions] }),
47 columns: size.columns,
48 rows: size.rows,
49})
50
51// Opens the office and says how it went: the dock help when it cannot dock.
52async function open($: EngineInterface): Promise<string> {
53 const opened = await $.ui.open({ id: OFFICE, title: TITLE }).catch(() => undefined)
54 if (!opened || !opened.isPlaced) {
55 const why = opened && !opened.isPlaced && opened.reason ? ` (${opened.reason})` : ''
56
57 return `${DOCK_HELP}${why}`
58 }
59 isOpen = true
60 if (placement === 'inline') return DOCK_HELP
61
62 return 'Clawd Office is open.'
63}
64
65export const register: Register = on => {
66 on('session.start', async ($, e, next) => {
67 const started = await next(e)
68 await $.command.register({ name: 'office', description: 'Show or hide Clawd Office, the crab office side panel', argumentHint: '[on|off]' }).catch(() => undefined)
69 if ((await $.store.get(ENABLED_KEY)) !== false) void open($).catch(() => undefined)
70 // A long quiet spell: main naps.
71 $.clock.every(60_000, () => {
72 if (!idleReported && Date.now() - lastTurnAt > IDLE_LONG_MS) { idleReported = true; push('idleLong') }
73 })
74 void startSharing($).catch(() => undefined)
75
76 return started
77 })
78
79 on('command.run', { command: 'office' }, async ($, e) => {
80 const arg = e.args.trim().toLowerCase()
81 const enable = arg === 'on' ? true : arg === 'off' ? false : !isOpen
82 await $.store.set(ENABLED_KEY, enable)
83 if (!enable) {
84 isOpen = false
85 await $.ui.close({ id: OFFICE }).catch(() => undefined)
86
87 return { text: 'Clawd Office is off. /office on brings it back.' }
88 }
89
90 return { text: await open($) }
91 })
92
93 // Docked, the office draws; seated inline, it closes rather than take the prompt's strip.
94 on('ui.render', { component: 'Pane', requestId: OFFICE }, async ($, e) => {
95 placement = e.props.placement
96 if (e.props.placement !== 'dock' || e.surface !== 'terminal') {
97 isOpen = false
98 void $.ui.close({ id: OFFICE }).catch(() => undefined)
99
100 return $.ui.resolve(e).Box({ children: [] })
101 }
102 const { Client } = $.ui.resolve(e)
103 isOpen = true
104 size = { columns: e.props.bodyColumns, rows: e.props.scroll.bodyRows }
105
106 return Client({ key: OFFICE, module: './office/client.ts', props: propsFor(0), width: size.columns, height: size.rows })
107 })
108
109 // The office asks for what happened since the last event it saw.
110 on('ui.message', async ($, e, next) => {
111 const since = field(e.data, 'since')
112 if (e.element === OFFICE && typeof since === 'number') return { props: propsFor(since) }
113
114 return next(e)
115 })
116
117 // ---------- observers: each passes the event on unchanged ----------
118
119 on('tool.call', async ($, e, next) => {
120 if (e.agentId) return next(e)
121 const input = e as unknown as Record<string, unknown>
122 const kinds = classifyTool(e.tool, input)
123 const label = labelFor(e.tool, input)
124 if (askedTools.delete(e.tool)) push('permOk', { label: e.tool })
125 const isTest = kinds.includes('test')
126 for (const kind of kinds) {
127 if (kind === 'todo') {
128 const todos = field(input, 'todos')
129 push('todo', { value: Array.isArray(todos) ? todos.filter(t => field(t, 'status') === 'completed').length : 0 })
130 } else if (kind !== 'test') push(kind, label ? { label } : {})
131 }
132 const ran = await next(e)
133 const failed = 'isError' in ran && ran.isError === true
134 if (isTest) push('test', { ok: !failed })
135 else if (failed) push('toolFail', { label: e.tool })
136
137 return ran
138 })
139
140 on('turn.start', async ($, e, next) => {
141 if (!field(e, 'agentId')) { lastTurnAt = Date.now(); idleReported = false; push('turnStart') }
142
143 return next(e)
144 })
145 // turn.step streams: forward its chunks untouched.
146 on('turn.step', async function* ($, e, next) {
147 if (!field(e, 'agentId') && Date.now() - lastThinking > 20000) { lastThinking = Date.now(); push('thinking') }
148
149 return yield* next(e)
150 })
151 on('turn.complete', async ($, e, next) => {
152 if (!field(e, 'agentId')) {
153 lastTurnAt = Date.now()
154 const reason = field(e, 'reason')
155 push(reason === 'aborted' ? 'interrupt' : reason === 'error' ? 'turnFail' : 'turnDone')
156 }
157
158 return next(e)
159 })
160
161 on('prompt.submit', async ($, e, next) => {
162 push('prompt')
163
164 return next(e)
165 })
166 on('prompt.change', async ($, e, next) => {
167 if (Date.now() - lastTyping > 15000) { lastTyping = Date.now(); push('typing') }
168
169 return next(e)
170 })
171 on('prompt.attachment', async ($, e, next) => {
172 push('image')
173
174 return next(e)
175 })
176 on('prompt.mention', async ($, e, next) => {
177 const label = base(text(e, 'path') ?? text(e, 'text'))
178 push('mention', label ? { label } : {})
179
180 return next(e)
181 })
182 on('prompt.fill', async ($, e, next) => {
183 if (e.origin.kind === 'plugin' && e.origin.name === 'prompt-enhancer') push('enhance')
184
185 return next(e)
186 })
187
188 on('agent.spawn', async ($, e, next) => {
189 const spawned = await next(e)
190 const agentId = field(spawned, 'agentId')
191 if (typeof agentId === 'string') {
192 roster.agents = trimRoster({ ...roster, agents: [...roster.agents.filter(a => a.id !== agentId), { id: agentId, type: e.subagentType, status: 'running' }] }).agents
193 push('agentStart', { realId: agentId, agentType: e.subagentType })
194 }
195
196 return spawned
197 })
198
199 on('session.send', async ($, e, next) => {
200 push('msgOut', { session: e.to.slice(0, 40) })
201
202 return next(e)
203 })
204 on('session.receive', async ($, e, next) => {
205 const from = text(e.origin, 'name')?.slice(0, 40)
206 push('msgIn', from ? { session: from } : {})
207
208 return next(e)
209 })
210 on('session.measure', async ($, e, next) => {
211 const tokens = e.context.tokens
212 const used = tokens !== undefined && e.context.window > 0 ? tokens / e.context.window : undefined
213 if (used !== undefined && Math.abs(used - lastContext) >= 0.1) { lastContext = used; push('context', { value: Math.round(used * 100) / 100 }) }
214 const usd = e.cost?.usd
215 const milestone = usd === undefined ? undefined : [...COST_MILESTONES].reverse().find(m => usd >= m)
216 if (milestone !== undefined && milestone > costShown) { costShown = milestone; push('cost', { value: milestone }) }
217
218 return next(e)
219 })
220 on('session.end', async ($, e, next) => {
221 push('sessionEnd')
222
223 return next(e)
224 })
225 on('session.attach', async ($, e, next) => {
226 push('remoteControl')
227
228 return next(e)
229 })
230 on('config.set', async ($, e, next) => {
231 if (/fast/i.test(String(field(e, 'key') ?? ''))) push('fast', { ok: field(e, 'value') === true })
232
233 return next(e)
234 })
235
236 // Classic hook events: observed, never answered. The engine reads each
237 // event name and hook off the source, so every one is written out.
238 on('classic.PreCompact', async ($, e, next) => { push('compact'); return next(e) })
239 on('classic.PermissionRequest', async ($, e, next) => {
240 const tool = text(e, 'tool_name')
241 if (tool) askedTools.add(tool)
242 push('permAsk', labelOf(tool))
243
244 return next(e)
245 })
246 on('classic.PermissionDenied', async ($, e, next) => { askedTools.delete(text(e, 'tool_name') ?? ''); push('permDenied'); return next(e) })
247 on('classic.Notification', async ($, e, next) => { push('notify'); return next(e) })
248 on('classic.StopFailure', async ($, e, next) => { push(field(e, 'error') === 'rate_limit' ? 'rateLimit' : 'turnFail'); return next(e) })
249 on('classic.PostToolBatch', async ($, e, next) => { push('batch'); return next(e) })
250 on('classic.SubagentStop', async ($, e, next) => {
251 const id = text(e, 'agent_id')
252 if (id) {
253 roster.agents = roster.agents.map(a => (a.id === id ? { ...a, status: 'completed' as const } : a))
254 push('agentDone', { realId: id, ok: true })
255 }
256
257 return next(e)
258 })
259 on('classic.TaskCreated', async ($, e, next) => { push('taskCreated'); return next(e) })
260 on('classic.TaskCompleted', async ($, e, next) => { push('taskDone'); return next(e) })
261 on('classic.TeammateIdle', async ($, e, next) => { push('teammateIdle', labelOf(text(e, 'teammate_name'), 20)); return next(e) })
262 on('classic.InstructionsLoaded', async ($, e, next) => { push('instructions'); return next(e) })
263 on('classic.ConfigChange', async ($, e, next) => { push('config'); return next(e) })
264 on('classic.CwdChanged', async ($, e, next) => { push('cwd', labelOf(base(text(e, 'new_cwd') ?? text(e, 'cwd')))); return next(e) })
265 on('classic.DirectoryAdded', async ($, e, next) => { push('cwd', labelOf(base(text(e, 'directory') ?? text(e, 'path')))); return next(e) })
266 on('classic.FileChanged', async ($, e, next) => { push('fileChanged', labelOf(base(text(e, 'file_path') ?? text(e, 'path')))); return next(e) })
267 on('classic.WorktreeCreate', async ($, e, next) => { push('worktree'); return next(e) })
268 on('classic.PostModelSwitch', async ($, e, next) => { push('model', labelOf(text(e, 'model') ?? text(e, 'new_model'))); return next(e) })
269 on('classic.Elicitation', async ($, e, next) => { push('elicitation', labelOf(text(e, 'mcp_server_name'), 20)); return next(e) })
270 on('classic.UserPromptExpansion', async ($, e, next) => { push('expand'); return next(e) })
271 on('classic.SessionStart', async ($, e, next) => {
272 const source = field(e, 'source')
273 if (source === 'clear') push('clear')
274 if (source === 'resume') push('resume')
275
276 return next(e)
277 })
278}
279
280// Every 2 s: the other sessions into the roster, their messages to each
281// other as letters, subagents settled against the engine's list, and this
282// session's events written for the other offices to read.
283async function startSharing($: EngineInterface): Promise<void> {
284 const dir = await claudeDir($).catch(() => undefined)
285 if (!dir) return
286 const self = await $.session.id().catch(() => '')
287 const name = ((await $.session.cwd().catch(() => '')).split(/[\\/]/).filter(Boolean).pop() ?? 'session').slice(0, 24)
288 const disk: Disk = {
289 list: d => $.fs.list(d),
290 read: async p => String(await $.fs.read(p)),
291 write: (p, t) => $.fs.write(p, t),
292 }
293 const seen = new Map<string, number>()
294 let primed = false
295 let written = 0
296 let polling = false
297 const poll = async () => {
298 const open = await readOpenSessions(disk, dir, self)
299 roster.sessions = open.map(s => ({ id: s.sessionId, name: s.name, busy: s.busy }))
300 const letters = lettersFrom(await readPeerFiles(disk, dir, self), open, seen)
301 // The first read only learns where each peer is, so old messages are not replayed.
302 if (primed) for (const l of letters) push('peerLetter', { realId: l.from, session: l.to })
303 primed = true
304 if (roster.agents.some(a => a.status === 'running')) {
305 const list = await $.agent.list().catch(() => [])
306 roster.agents = reconcileAgents(roster, list.map(a => ({ id: a.id, status: a.status }))).agents
307 }
308 if (self && feed.last() > written) {
309 written = feed.last()
310 await writeOwnFile(disk, dir, self, name, feed.since(0))
311 }
312 }
313 $.clock.every(POLL_MS, () => {
314 if (polling) return
315 polling = true
316 void poll().catch(() => undefined).finally(() => { polling = false })
317 })
318}
319
320function labelOf(v: string | undefined, max = 30): { label?: string } {
321 return v ? { label: v.slice(0, max) } : {}
322}
323hooks/events.ts 180 lines1// Every real Claude Code event the office acts out, as a kind; what each
2// means and how the mod sees it; and the classifiers that turn a tool call
3// into kinds. Only events this build (2.1.301) actually raises have a kind:
4// see README.md, "Not shown, and why", for the rows the design dropped.
5
6export const KINDS = [
7 // you and the prompt box
8 'prompt', 'enhance', 'typing', 'image', 'mention', 'expand', 'interrupt',
9 // the turn
10 'turnStart', 'thinking', 'batch', 'turnDone', 'turnFail',
11 // files and search
12 'read', 'grep', 'edit', 'notebook', 'lsp', 'toolSearch', 'toolFail',
13 // the shell
14 'bash', 'test', 'build', 'install', 'background', 'monitor',
15 // git
16 'commit', 'push', 'pr', 'checkout', 'worktree',
17 // the web
18 'webSearch', 'webFetch',
19 // agents, skills, teams
20 'agentStart', 'agentDone', 'skill', 'taskCreated', 'taskDone', 'teammateIdle',
21 // sessions talking to each other
22 'peerLetter', 'msgIn', 'msgOut', 'listAgents', 'sessionOpen', 'sessionBusy', 'sessionIdle', 'sessionClose',
23 // MCP
24 'mcp', 'elicitation',
25 // permissions and modes
26 'permAsk', 'permOk', 'permDenied', 'ask', 'plan',
27 // tasks and time
28 'todo', 'cron', 'notify', 'remote', 'artifact',
29 // context and cost
30 'context', 'compact', 'instructions', 'cost', 'rateLimit',
31 // settings and the session
32 'model', 'fast', 'config', 'cwd', 'fileChanged', 'remoteControl', 'clear', 'resume', 'idleLong', 'sessionEnd',
33] as const
34
35export type EventKind = (typeof KINDS)[number]
36
37/** One thing that happened, as the office acts it out. */
38export type OfficeEvent = {
39 seq: number
40 kind: EventKind
41 /** A short label: a file's base name, an MCP server, a model; never prompt or command text. */
42 label?: string
43 /** The real crab it concerns: a subagent's id or a session's id. */
44 realId?: string
45 /** The other session in a message between sessions. */
46 session?: string
47 /** For a subagent, its type (Explore, Plan, ...). */
48 agentType?: string
49 /** Whether it went well, where that is known (tests, subagents, tools). */
50 ok?: boolean
51 /** A number some kinds carry: context used (0..1), dollars spent. */
52 value?: number
53}
54
55type Row = { cat: string; name: string; how: string; act: string }
56
57export const CATALOG: Record<EventKind, Row> = {
58 prompt: { cat: 'You', name: 'You send a prompt', how: 'prompt.submit', act: 'a letter drops into the inbox; main reads it' },
59 enhance: { cat: 'You', name: 'Enhance used', how: 'prompt.fill from prompt-enhancer', act: 'the librarian crab hurries up with a book' },
60 typing: { cat: 'You', name: 'You are typing', how: 'prompt.change', act: 'main looks up, listening' },
61 image: { cat: 'You', name: 'Image pasted', how: 'prompt.attachment', act: 'a picture frame appears on the wall' },
62 mention: { cat: 'You', name: '@file mentioned', how: 'prompt.mention', act: 'a sticky note goes on the whiteboard' },
63 expand: { cat: 'You', name: 'Slash command expanded', how: 'UserPromptExpansion', act: 'unrolls a scroll of instructions' },
64 interrupt: { cat: 'You', name: 'Interrupted (Esc)', how: 'turn.complete (aborted)', act: 'freezes and holds up a stop sign' },
65 turnStart: { cat: 'Turn', name: 'Turn starts', how: 'turn.start', act: 'hops up and gets to work' },
66 thinking: { cat: 'Turn', name: 'Thinking', how: 'turn.step', act: 'paces with a thought cloud' },
67 batch: { cat: 'Turn', name: 'Several tools at once', how: 'PostToolBatch', act: 'juggles' },
68 turnDone: { cat: 'Turn', name: 'Turn done, waiting for you', how: 'turn.complete', act: 'waves "your turn!"' },
69 turnFail: { cat: 'Turn', name: 'Turn failed', how: 'StopFailure / turn.complete (error)', act: 'red alarm, smoke, shakes' },
70 read: { cat: 'Tools', name: 'Read a file', how: 'tool.call Read', act: 'pulls a book from the shelf' },
71 grep: { cat: 'Tools', name: 'Grep / Glob', how: 'tool.call Grep, Glob', act: 'rifles the card catalogue with a magnifier' },
72 edit: { cat: 'Tools', name: 'Edit / Write a file', how: 'tool.call Edit, Write', act: 'types at the desk' },
73 notebook: { cat: 'Tools', name: 'Notebook edit', how: 'tool.call NotebookEdit', act: 'scribbles in a lab notebook' },
74 lsp: { cat: 'Tools', name: 'Code intelligence', how: 'tool.call LSP', act: 'checks the big dictionary' },
75 toolSearch: { cat: 'Tools', name: 'Tool search', how: 'tool.call ToolSearch', act: 'rummages in the toolbox' },
76 toolFail: { cat: 'Tools', name: 'Tool failed', how: 'tool.call result isError', act: 'smoke puff, shakes it off' },
77 bash: { cat: 'Shell', name: 'Bash command', how: 'tool.call Bash', act: 'works the console' },
78 test: { cat: 'Shell', name: 'Tests run', how: 'Bash running tests; pass or fail from its result', act: 'confetti when green, red alarm when not' },
79 build: { cat: 'Shell', name: 'Build', how: 'Bash running a build', act: 'hammers away' },
80 install: { cat: 'Shell', name: 'Install packages', how: 'Bash installing packages', act: 'a delivery truck pulls up' },
81 background: { cat: 'Shell', name: 'Background shell', how: 'Bash run_in_background', act: 'an hourglass on the console' },
82 monitor: { cat: 'Shell', name: 'Watching output', how: 'tool.call Monitor', act: 'watches through binoculars' },
83 commit: { cat: 'Git', name: 'git commit', how: 'Bash git commit', act: 'stamps a parcel' },
84 push: { cat: 'Git', name: 'git push', how: 'Bash git push', act: 'drops the parcel down the mail chute' },
85 pr: { cat: 'Git', name: 'Pull request opened', how: 'Bash gh pr create', act: 'hangs a green PR frame on the wall' },
86 checkout: { cat: 'Git', name: 'Branch switch', how: 'Bash git checkout / switch', act: 'reads the map' },
87 worktree: { cat: 'Git', name: 'Worktree created', how: 'WorktreeCreate / tool.call EnterWorktree', act: 'packs a box for the new desk' },
88 webSearch: { cat: 'Web', name: 'Web search', how: 'tool.call WebSearch', act: 'looks through the telescope' },
89 webFetch: { cat: 'Web', name: 'Fetch a page', how: 'tool.call WebFetch', act: 'a carrier pigeon brings a letter' },
90 agentStart: { cat: 'Agents', name: 'Subagent starts', how: 'agent.spawn', act: 'main calls one out; a hatted crab rides up to the workshop' },
91 agentDone: { cat: 'Agents', name: 'Subagent finishes or fails', how: 'SubagentStop / $.agent.list() status', act: 'rides up with a report (or a bandage), retires, later goes home' },
92 skill: { cat: 'Agents', name: 'Skill used', how: 'tool.call Skill', act: 'opens a glowing spellbook' },
93 taskCreated: { cat: 'Agents', name: 'Team task created', how: 'TaskCreated', act: 'stickies go up on the team kanban' },
94 taskDone: { cat: 'Agents', name: 'Team task done', how: 'TaskCompleted', act: 'a sticky moves to Done' },
95 teammateIdle: { cat: 'Agents', name: 'Teammate idle', how: 'TeammateIdle', act: 'a red-cap teammate waits in the team room' },
96 peerLetter: { cat: 'Sessions', name: 'Session messages session', how: 'another session\'s shared event file', act: 'one session crab carries a letter to the other' },
97 msgIn: { cat: 'Sessions', name: 'Message to main', how: 'session.receive', act: 'a capsule shoots up the tube; main reads it' },
98 msgOut: { cat: 'Sessions', name: 'Main messages a session', how: 'session.send / tool.call SendMessage', act: 'main sends a capsule down the tube' },
99 listAgents: { cat: 'Sessions', name: 'List other sessions', how: 'tool.call ListAgents', act: 'checks the staff directory' },
100 sessionOpen: { cat: 'Sessions', name: 'Session opens', how: '~/.claude/sessions', act: 'a crab walks into Sessions' },
101 sessionBusy: { cat: 'Sessions', name: 'Session busy', how: '~/.claude/sessions', act: 'its crab types' },
102 sessionIdle: { cat: 'Sessions', name: 'Session idle', how: '~/.claude/sessions', act: 'its crab dozes' },
103 sessionClose: { cat: 'Sessions', name: 'Session closes', how: '~/.claude/sessions', act: 'its crab heads home' },
104 mcp: { cat: 'MCP', name: 'MCP tool call', how: 'tool.call mcp__*', act: 'makes a phone call' },
105 elicitation: { cat: 'MCP', name: 'MCP asks for input', how: 'Elicitation', act: 'holds out a clipboard form' },
106 permAsk: { cat: 'Permissions', name: 'Needs permission', how: 'PermissionRequest', act: 'rings a bell, claw raised' },
107 permOk: { cat: 'Permissions', name: 'Permission granted', how: 'a tool that was asked about runs (partial)', act: 'a green stamp' },
108 permDenied: { cat: 'Permissions', name: 'Permission denied', how: 'PermissionDenied', act: 'a red stamp, a sigh' },
109 ask: { cat: 'Permissions', name: 'Asks you a question', how: 'tool.call AskUserQuestion', act: 'holds up a "?" sign' },
110 plan: { cat: 'Permissions', name: 'Plan mode', how: 'tool.call ExitPlanMode / EnterPlanMode', act: 'unrolls a blueprint' },
111 todo: { cat: 'Tasks', name: 'Todo list written or ticked', how: 'tool.call TodoWrite', act: 'writes or ticks items on the whiteboard' },
112 cron: { cat: 'Tasks', name: 'Scheduled for later', how: 'tool.call CronCreate / ScheduleWakeup', act: 'sets an alarm clock' },
113 notify: { cat: 'Tasks', name: 'Sends you a notification', how: 'tool.call PushNotification / Notification', act: 'shouts through a megaphone' },
114 remote: { cat: 'Tasks', name: 'Remote trigger', how: 'tool.call RemoteTrigger', act: 'the rooftop dish blinks' },
115 artifact: { cat: 'Tasks', name: 'Artifact published', how: 'tool.call Artifact', act: 'pins a poster on the wall' },
116 context: { cat: 'Context', name: 'Context filling up', how: 'session.measure', act: 'the wall gauge rises; papers pile up near full' },
117 compact: { cat: 'Context', name: 'Compacting', how: 'PreCompact / session.compact', act: 'sweeps the papers away' },
118 instructions: { cat: 'Context', name: 'CLAUDE.md loaded', how: 'InstructionsLoaded', act: 'reads the red rulebook' },
119 cost: { cat: 'Context', name: 'Cost milestone', how: 'session.measure cost (partial: where cost is reported)', act: 'coins pop out' },
120 rateLimit: { cat: 'Context', name: 'Rate limited', how: 'StopFailure rate_limit', act: 'waits by the hourglass' },
121 model: { cat: 'Settings', name: 'Model switched', how: 'PostModelSwitch', act: 'the crown changes colour' },
122 fast: { cat: 'Settings', name: 'Fast mode', how: 'config.set (partial: where the setting is reported)', act: 'puts on running shoes, walks faster' },
123 config: { cat: 'Settings', name: 'Settings changed', how: 'ConfigChange', act: 'tightens a bolt with a wrench' },
124 cwd: { cat: 'Settings', name: 'Folder changed', how: 'CwdChanged / DirectoryAdded', act: 'carries a box to a new spot' },
125 fileChanged: { cat: 'Settings', name: 'File changed outside', how: 'FileChanged', act: 'a newspaper arrives' },
126 remoteControl: { cat: 'Settings', name: 'Remote control connected', how: 'session.attach', act: 'a phone on the desk lights up' },
127 clear: { cat: 'Settings', name: '/clear', how: 'SessionStart (clear)', act: 'wipes the whiteboard' },
128 resume: { cat: 'Settings', name: 'Session resumed', how: 'SessionStart (resume)', act: 'walks back in with a coffee' },
129 idleLong: { cat: 'Settings', name: 'Idle a long time', how: 'no turn for 10 minutes', act: 'naps at the desk' },
130 sessionEnd: { cat: 'Settings', name: 'Session ends', how: 'SessionEnd / session.end', act: 'turns the lights off' },
131}
132
133/** What a Bash command runs, told apart by its text. */
134export function classifyBash(command: string): 'test' | 'build' | 'install' | 'commit' | 'push' | 'pr' | 'checkout' | 'bash' {
135 const c = command.toLowerCase()
136 if (/\bgh\s+pr\s+create\b/.test(c)) return 'pr'
137 if (/\bgit\s+commit\b/.test(c)) return 'commit'
138 if (/\bgit\s+push\b/.test(c)) return 'push'
139 if (/\bgit\s+(checkout|switch)\b/.test(c)) return 'checkout'
140 if (/\b(npm|pnpm|yarn|bun)\s+(i|install|add|ci)\b|\bpip3?\s+install\b|\bcargo\s+add\b|\bgo\s+get\b/.test(c)) return 'install'
141 if (/\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\bpytest\b|\b(cargo|go|plugin)\s+test\b|\b(jest|vitest|mocha)\b/.test(c)) return 'test'
142 if (/\b(npm|pnpm|yarn|bun)\s+(run\s+)?build\b|\btsc\b|\bmake\b|\b(cargo|go)\s+build\b|\bvite\s+build\b/.test(c)) return 'build'
143
144 return 'bash'
145}
146
147const TOOLS: Record<string, EventKind | undefined> = {
148 Read: 'read', Grep: 'grep', Glob: 'grep', Edit: 'edit', Write: 'edit', MultiEdit: 'edit', NotebookEdit: 'notebook',
149 LSP: 'lsp', ToolSearch: 'toolSearch', WebSearch: 'webSearch', WebFetch: 'webFetch', Skill: 'skill',
150 AskUserQuestion: 'ask', ExitPlanMode: 'plan', EnterPlanMode: 'plan', TodoWrite: 'todo', CronCreate: 'cron',
151 ScheduleWakeup: 'cron', PushNotification: 'notify', RemoteTrigger: 'remote', Monitor: 'monitor', SendMessage: 'msgOut',
152 ListAgents: 'listAgents', Artifact: 'artifact', EnterWorktree: 'worktree',
153}
154
155/**
156 * The kinds a tool call shows as. The Agent tool shows none: its crab comes
157 * from `agent.spawn`, so it is never counted twice.
158 */
159export function classifyTool(tool: string, input: Record<string, unknown>): EventKind[] {
160 if (tool === 'Bash') {
161 if (input.run_in_background === true) return ['background']
162
163 return [classifyBash(typeof input.command === 'string' ? input.command : '')]
164 }
165 if (tool.startsWith('mcp__')) return ['mcp']
166 const kind = TOOLS[tool]
167
168 return kind ? [kind] : []
169}
170
171/** A short label for a tool call: a file's base name or an MCP server; never prompt or command text. */
172export function labelFor(tool: string, input: Record<string, unknown>): string | undefined {
173 if (tool.startsWith('mcp__')) return tool.split('__')[1]
174 const path = input.file_path ?? input.notebook_path ?? input.path
175 if (typeof path !== 'string' || path === '') return undefined
176 const base = path.split(/[\\/]/).pop()
177
178 return base ? base.slice(0, 40) : undefined
179}
180hooks/feed.ts 31 lines1// The events the office has yet to act out, numbered in order and bounded:
2// while the office is closed only the last 100 are kept, so nothing grows.
3import type { OfficeEvent } from './events'
4
5export const FEED_MAX = 100
6
7export type Feed = {
8 push: (ev: Omit<OfficeEvent, 'seq'>) => OfficeEvent
9 /** The kept events numbered above `seq`, oldest first. */
10 since: (seq: number) => OfficeEvent[]
11 /** The newest event's number; 0 before any. */
12 last: () => number
13}
14
15export function createFeed(max = FEED_MAX): Feed {
16 let seq = 0
17 let kept: OfficeEvent[] = []
18
19 return {
20 push: ev => {
21 const numbered = { ...ev, seq: ++seq }
22 kept.push(numbered)
23 if (kept.length > max) kept = kept.slice(-max)
24
25 return numbered
26 },
27 since: from => kept.filter(ev => ev.seq > from),
28 last: () => seq,
29 }
30}
31hooks/office/client.ts 151 lines1// The office's surface module: it keeps a world on the frame clock, takes in
2// the hooks module's new events and roster, lays the town out again when the
3// pane is resized, and draws the cells as rows of coloured text runs.
4import type { ClientModule, ClientSurface } from 'claude-code'
5import type { OfficeEvent } from '../events'
6import { applyEvent } from './actions'
7import { runs } from './cells'
8import type { Cell } from './cells'
9import { chooseLayout } from './layout'
10import { drawWorld } from './render'
11import type { Detail } from './render'
12import { createWorld, frame, relayout, removeReal, retireReal, setSessionIdle, spawnReal } from './sim'
13import type { World } from './sim'
14import type { Accessory } from './sprites'
15
16export type RosterAgent = { id: string; type: string; status: 'running' | 'completed' | 'failed' }
17export type RosterSession = { id: string; name: string; busy: boolean }
18export type Roster = { agents: RosterAgent[]; sessions: RosterSession[] }
19export type OfficeProps = { events: OfficeEvent[]; roster: Roster; columns: number; rows: number }
20
21const FRAME_MS = 50
22const POLL_MS = 500
23const AGENT_LOOKS: Record<string, Accessory[]> = {
24 Explore: ['miner'], Plan: ['plan'], 'general-purpose': ['hard'], reviewer: ['detective'], 'code-reviewer': ['detective'],
25}
26const SESSION_LOOKS: Accessory[] = ['headphones', 'scarf', 'beanie', 'party']
27
28type Office = {
29 w: World; seen: number; columns: number; rows: number; done: Set<string>; drawMs: number[]; fps: 10 | 20
30 /** How much detail fits, and how much of the panel the town uses, under the size budget. */
31 detail: Detail; scale: number
32}
33
34const SLOW_DRAW_MS = 25
35const DRAW_SAMPLES = 40
36// The Client unmounts past 100,000 serialized characters. The budget is
37// measured on the very tree the engine serializes, so 5% under is safe.
38const SIZE_BUDGET = 95_000
39const MIN_SCALE = 0.5
40
41/** Redraws at 20 a second unless drawing has been slow, then at 10. */
42export function chooseFps(drawMs: readonly number[]): 10 | 20 {
43 if (drawMs.length === 0) return 20
44 const average = drawMs.reduce((a, b) => a + b, 0) / drawMs.length
45
46 return average > SLOW_DRAW_MS ? 10 : 20
47}
48const offices = new WeakMap<object, Office>()
49
50const OfficeView: ClientModule<OfficeProps, { tick: number }> = (props, surface) => {
51 const columns = Math.max(20, surface.columns || props.columns)
52 const rows = Math.max(10, surface.rows || props.rows)
53 let o = offices.get(surface)
54 if (!o) {
55 o = { w: createWorld(chooseLayout(columns, rows), 1), seen: 0, columns, rows, done: new Set(), drawMs: [], fps: 20, detail: 'full', scale: 1 }
56 offices.set(surface, o)
57 start(surface)
58 } else if (o.columns !== columns || o.rows !== rows) {
59 o.columns = columns
60 o.rows = rows
61 o.detail = 'full'
62 o.scale = 1
63 relayout(o.w, chooseLayout(columns, rows))
64 }
65 absorb(o, props)
66 const started = Date.now()
67 const tree = fitDrawing(o, surface.elements)
68 o.drawMs = [...o.drawMs, Date.now() - started].slice(-DRAW_SAMPLES)
69 o.fps = chooseFps(o.drawMs)
70
71 return tree
72}
73
74/**
75 * Draws the town, never past the size budget: when a drawing would be too
76 * big it draws less detail, then a smaller town, until it fits.
77 */
78export function fitDrawing(o: Office, elements: ClientSurface['elements']) {
79 const { Box, Text } = elements
80 const build = () => Box({ flexDirection: 'column', children: drawWorld(o.w, o.detail).map(line => Text({ children: runs(line).map(run => cellText(Text, run)) })) })
81 let tree = build()
82 while (JSON.stringify(tree).length > SIZE_BUDGET) {
83 if (o.detail === 'full') o.detail = 'low'
84 else if (o.scale > MIN_SCALE) {
85 o.scale = Math.max(MIN_SCALE, Math.round((o.scale - 0.1) * 10) / 10)
86 relayout(o.w, chooseLayout(Math.floor(o.columns * o.scale), Math.floor(o.rows * o.scale)))
87 } else break
88 tree = build()
89 }
90
91 return tree
92}
93
94export default OfficeView
95
96function start(surface: ClientSurface<{ tick: number }>): void {
97 surface.setState({ tick: 0 })
98 surface.every(FRAME_MS, () => {
99 const o = offices.get(surface)
100 if (!o) return
101 frame(o.w)
102 // The world always moves at 20 frames a second; when drawing is slow,
103 // only every second frame is drawn.
104 if (o.fps === 20 || o.w.frameNo % 2 === 0) surface.setState({ tick: o.w.frameNo })
105 })
106 // Asks the hooks module for what happened since the last event seen.
107 surface.every(POLL_MS, () => surface.post({ since: offices.get(surface)?.seen ?? 0 }))
108}
109
110// New events are acted out; the roster seats, retires and removes real crabs.
111function absorb(o: Office, props: OfficeProps): void {
112 for (const ev of props.events) {
113 if (ev.seq <= o.seen) continue
114 o.seen = ev.seq
115 applyEvent(o.w, ev)
116 }
117 for (const a of props.roster.agents) {
118 if (a.status === 'running' && !o.done.has(a.id)) {
119 spawnReal(o.w, { role: 'agent', realId: a.id, tag: a.type, acc: AGENT_LOOKS[a.type] ?? ['hard'] })
120 } else if (a.status !== 'running' && !o.done.has(a.id)) {
121 o.done.add(a.id)
122 retireReal(o.w, a.id, a.status === 'failed' ? 'failed' : 'done')
123 }
124 }
125 const open = new Set(props.roster.sessions.map(s => s.id))
126 props.roster.sessions.forEach(s => {
127 spawnReal(o.w, { role: 'session', realId: s.id, tag: s.name, acc: [SESSION_LOOKS[hash(s.id) % SESSION_LOOKS.length] ?? 'scarf'] })
128 setSessionIdle(o.w, s.id, !s.busy)
129 })
130 for (const c of o.w.crabs) if (c.role === 'session' && c.realId && !open.has(c.realId)) removeReal(o.w, c.realId)
131 for (const spec of o.w.street.waiting) if (spec.role === 'session' && !open.has(spec.realId)) removeReal(o.w, spec.realId)
132}
133
134function cellText(Text: ClientSurface['elements']['Text'], run: Cell) {
135 if (run.fg === undefined && run.bg === undefined) return run.ch
136
137 return Text({
138 ...(run.fg === undefined ? {} : { color: run.fg }),
139 ...(run.bg === undefined ? {} : { backgroundColor: run.bg }),
140 ...(run.bold ? { bold: true } : {}),
141 children: [run.ch],
142 })
143}
144
145function hash(s: string): number {
146 let h = 0
147 for (const ch of s) h = (h * 31 + ch.charCodeAt(0)) >>> 0
148
149 return h
150}
151hooks/sessions.ts 148 lines1// The person's other Claude Code sessions, as the office sees them: each
2// open session's record in ~/.claude/sessions, and the small event file each
3// running office writes to ~/.claude/claude-office/<session>.json so every
4// office can show what the others are doing. Files hold short labels only.
5import type { EngineInterface } from 'claude-code'
6import type { EventKind, OfficeEvent } from './events'
7import type { Roster } from './office/client'
8
9/** The file access the session functions need; the hooks module wraps $.fs. */
10export type Disk = {
11 list: (dir: string) => Promise<{ name: string; kind: string }[]>
12 read: (path: string) => Promise<string>
13 write: (path: string, text: string) => Promise<void>
14}
15
16export type SessionInfo = { sessionId: string; name: string; busy: boolean; updatedAt: number }
17
18const SHARED_MAX = 50
19// The kinds whose label is a file's base name, a tool, a server or a model:
20// safe to share. Every other label is dropped from the shared file.
21const LABELLED = new Set<EventKind>([
22 'read', 'edit', 'notebook', 'mcp', 'toolFail', 'permAsk', 'permOk', 'cwd', 'fileChanged', 'model', 'teammateIdle', 'elicitation', 'mention',
23])
24
25const parse = (body: string): unknown => {
26 try { return JSON.parse(body) } catch { return undefined }
27}
28const field = (o: unknown, key: string): unknown => (o && typeof o === 'object' ? (o as Record<string, unknown>)[key] : undefined)
29const str = (o: unknown, key: string): string | undefined => {
30 const v = field(o, key)
31
32 return typeof v === 'string' && v !== '' ? v : undefined
33}
34
35/** Where Claude Code keeps its files: CLAUDE_CONFIG_DIR, else ~/.claude. */
36export async function claudeDir($: EngineInterface): Promise<string | undefined> {
37 const configured = await $.env.get('CLAUDE_CONFIG_DIR')
38 if (configured) return configured
39 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
40
41 return home ? `${home}/.claude` : undefined
42}
43
44async function jsonFiles(disk: Disk, dir: string): Promise<{ name: string; body: unknown }[]> {
45 const entries = await disk.list(dir).catch(() => [])
46 const out: { name: string; body: unknown }[] = []
47 for (const entry of entries) {
48 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
49 const text = await disk.read(`${dir}/${entry.name}`).catch(() => undefined)
50 if (typeof text === 'string') out.push({ name: entry.name, body: parse(text) })
51 }
52
53 return out
54}
55
56/** The other open sessions, busy or idle; this one and unreadable records left out. */
57export async function readOpenSessions(disk: Disk, dir: string, selfId: string): Promise<SessionInfo[]> {
58 const out: SessionInfo[] = []
59 for (const { body } of await jsonFiles(disk, `${dir}/sessions`)) {
60 const sessionId = str(body, 'sessionId')
61 if (!sessionId || sessionId === selfId) continue
62 const updatedAt = field(body, 'updatedAt')
63 out.push({
64 sessionId,
65 name: (str(body, 'name') ?? str(body, 'cwd')?.split(/[\\/]/).pop() ?? 'session').slice(0, 24),
66 busy: str(body, 'status') === 'busy',
67 updatedAt: typeof updatedAt === 'number' ? updatedAt : 0,
68 })
69 }
70
71 return out
72}
73
74/** An event as it may be shared: kind and outcome, and a label only where it is a name, not text. */
75export function sanitize(ev: OfficeEvent): OfficeEvent {
76 const out: OfficeEvent = { seq: ev.seq, kind: ev.kind }
77 if (ev.label && LABELLED.has(ev.kind)) out.label = ev.label.slice(0, 40)
78 if (ev.session && (ev.kind === 'msgOut' || ev.kind === 'msgIn')) out.session = ev.session.slice(0, 40)
79 if (ev.agentType) out.agentType = ev.agentType.slice(0, 30)
80 if (ev.ok !== undefined) out.ok = ev.ok
81 if (ev.value !== undefined) out.value = ev.value
82
83 return out
84}
85
86/** Writes this session's recent events for the other offices to read. */
87export async function writeOwnFile(disk: Disk, dir: string, sessionId: string, name: string, events: OfficeEvent[]): Promise<void> {
88 const body = { sessionId, name, updatedAt: Date.now(), events: events.slice(-SHARED_MAX).map(sanitize) }
89 await disk.write(`${dir}/claude-office/${sessionId}.json`, JSON.stringify(body))
90}
91
92/** The other offices' recent events, by session; unreadable files and this one left out. */
93export async function readPeerFiles(disk: Disk, dir: string, selfId: string): Promise<Record<string, OfficeEvent[]>> {
94 const out: Record<string, OfficeEvent[]> = {}
95 for (const { body } of await jsonFiles(disk, `${dir}/claude-office`)) {
96 const sessionId = str(body, 'sessionId')
97 const events = field(body, 'events')
98 if (!sessionId || sessionId === selfId || !Array.isArray(events)) continue
99 out[sessionId] = events.filter((ev): ev is OfficeEvent => typeof field(ev, 'seq') === 'number' && typeof field(ev, 'kind') === 'string')
100 }
101
102 return out
103}
104
105/**
106 * The messages one open session sent another since last seen, as letters to
107 * carry between their crabs. `seen` keeps the last event read per session.
108 */
109export function lettersFrom(peers: Record<string, OfficeEvent[]>, open: SessionInfo[], seen: Map<string, number>): { from: string; to: string }[] {
110 const letters: { from: string; to: string }[] = []
111 for (const [from, events] of Object.entries(peers)) {
112 if (!open.some(s => s.sessionId === from)) continue
113 const last = seen.get(from) ?? 0
114 for (const ev of events) {
115 if (ev.seq <= last) continue
116 seen.set(from, Math.max(seen.get(from) ?? 0, ev.seq))
117 if (ev.kind !== 'msgOut' || !ev.session) continue
118 const to = open.find(s => s.name === ev.session || s.sessionId === ev.session)
119 if (to && to.sessionId !== from) letters.push({ from, to: to.sessionId })
120 }
121 }
122
123 return letters
124}
125
126/** Settles the roster's subagents against what `$.agent.list()` reports. */
127export function reconcileAgents(roster: Roster, list: { id: string; status: string }[]): Roster {
128 return {
129 ...roster,
130 agents: roster.agents.map(a => {
131 const status = list.find(l => l.id === a.id)?.status
132 if (status === 'failed' || status === 'killed') return { ...a, status: 'failed' as const }
133 if (status === 'completed') return { ...a, status: 'completed' as const }
134
135 return a
136 }),
137 }
138}
139
140const FINISHED_KEPT = 20
141
142/** Every running subagent, and only the latest finished ones, so the panel's props stay small. */
143export function trimRoster(roster: Roster): Roster {
144 const finished = roster.agents.filter(a => a.status !== 'running').slice(-FINISHED_KEPT)
145
146 return { ...roster, agents: roster.agents.filter(a => a.status === 'running' || finished.includes(a)) }
147}
148hooks/office/actions.ts 323 lines1// What each real event looks like in the office: the main crab walks to a
2// station and picks up a prop, a subagent's crab arrives or reports, a
3// session's crab types, dozes or carries a letter, and the rooms light up.
4// Main's actions play one after another from a short queue; an event of the
5// kind already playing merges into it, so three Reads keep main at the shelf.
6import type { EventKind, OfficeEvent } from '../events'
7import type { StationName } from './layout'
8import { fxOn, later, removeReal, retireReal, roomNamed, say, setSessionIdle, spawnReal, standY, station } from './sim'
9import type { Crab, World } from './sim'
10import type { Accessory } from './sprites'
11
12// How long each of main's actions holds the stage, in ticks (10 a second).
13const HOLD = 25
14const QUEUE_MAX = 5
15
16const AGENT_LOOKS: Record<string, Accessory[]> = {
17 Explore: ['miner'], Plan: ['plan'], 'general-purpose': ['hard'], reviewer: ['detective'], 'code-reviewer': ['detective'],
18}
19const CROWNS: Record<string, string> = { opus: '#E8C15A', sonnet: '#C9CED2', haiku: '#C08A4A', fable: '#B07FE8' }
20
21type Stage = { queue: OfficeEvent[]; until: number; playing?: EventKind; scheduled: boolean }
22const stages = new WeakMap<World, Stage>()
23const stageOf = (w: World): Stage => {
24 let s = stages.get(w)
25 if (!s) { s = { queue: [], until: 0, scheduled: false }; stages.set(w, s) }
26
27 return s
28}
29
30// The kinds that are played by someone other than main, at once.
31const ELSEWHERE = new Set<EventKind>([
32 'agentStart', 'agentDone', 'taskCreated', 'taskDone', 'teammateIdle', 'peerLetter',
33 'sessionOpen', 'sessionBusy', 'sessionIdle', 'sessionClose', 'context', 'cost', 'enhance',
34])
35
36export function applyEvent(w: World, ev: OfficeEvent): void {
37 if (ELSEWHERE.has(ev.kind)) { playElsewhere(w, ev); return }
38 const s = stageOf(w)
39 if (s.playing === ev.kind && !s.queue.length && w.tick < s.until) {
40 s.until = w.tick + HOLD
41 const text = bubbleFor(ev)
42 if (text) say(w, w.main, text, 30)
43
44 return
45 }
46 s.queue.push(ev)
47 if (s.queue.length > QUEUE_MAX) s.queue.shift()
48 pump(w)
49}
50
51function pump(w: World): void {
52 const s = stageOf(w)
53 if (w.tick < s.until) {
54 if (!s.scheduled) { s.scheduled = true; later(w, s.until - w.tick, () => { s.scheduled = false; pump(w) }) }
55
56 return
57 }
58 const ev = s.queue.shift()
59 if (!ev) return
60 s.playing = ev.kind
61 s.until = w.tick + HOLD
62 playMain(w, ev)
63 if (s.queue.length) pump(w)
64}
65
66function bubbleFor(ev: OfficeEvent): string | undefined {
67 const l = ev.label
68 switch (ev.kind) {
69 case 'read': return l ? `Read ${l}` : 'reading...'
70 case 'edit': return l ? `Edit ${l}` : 'editing...'
71 default: return undefined
72 }
73}
74
75// ---------- main's actions ----------
76
77// How long a prop stays in main's claws once its action has played: 6 s.
78const PROP_TICKS = 60
79let propToken = 0
80
81function playMain(w: World, ev: OfficeEvent): void {
82 const m = w.main
83 m.sleep = false
84 m.shake = false
85 m.acc = base(w)
86 const l = ev.label
87 // Puts a prop in main's claws and takes it away later, unless a newer action has.
88 const hold = (acc: Accessory[]) => {
89 m.acc = [...base(w), ...acc]
90 if (!acc.length) return
91 const token = ++propToken
92 later(w, PROP_TICKS, () => { if (token === propToken) m.acc = base(w) })
93 }
94 const at = (name: StationName, offset: number, acc: Accessory[], text: string, then?: () => void) => {
95 const s = station(w, name)
96 const room = roomNamed(w, 'Main office')
97 const x = Math.max((room?.x0 ?? 0) + 5, Math.min((room?.x1 ?? w.layout.width) - 6, (s?.x ?? m.x) + offset))
98 goWalk(w, m, s?.floor ?? m.floor, x)
99 m.arrive = () => { hold(acc); if (text) say(w, m, text, 40); then?.() }
100 }
101 const now = (acc: Accessory[], text: string, then?: () => void) => {
102 hold(acc)
103 say(w, m, text, 40)
104 then?.()
105 }
106 const fail = (text: string) => {
107 fxOn(w, 'alarm', 40)
108 m.shake = true
109 say(w, m, text, 30)
110 puff(w, m)
111 later(w, 25, () => { m.shake = false })
112 }
113
114 switch (ev.kind) {
115 case 'prompt': w.fx.letterY = 0; fxOn(w, 'letter', 45); return at('inbox', 0, ['letter'], 'a new task!')
116 case 'typing': return now([], 'hm? go on...', () => { m.hop = -1; later(w, 4, () => { m.hop = 0 }) })
117 case 'image': return at('desk', -2, [], 'ooh, a screenshot', () => { w.fx.picture = true })
118 case 'mention': return at('board', 0, [], `${l ? '@' + l : 'a file'} noted`, () => { w.fx.stickies = Math.min(4, num(w.fx.stickies) + 1) })
119 case 'expand': return now(['blueprint'], 'a slash command!')
120 case 'interrupt': return now(['stop'], 'stopping!')
121 case 'turnStart': return now([], 'right, lets go', () => { m.hop = -1; later(w, 5, () => { m.hop = 0 }) })
122 case 'thinking': {
123 const desk = station(w, 'desk')?.x ?? m.x
124 goWalk(w, m, m.floor, m.x > desk ? desk - 10 : desk + 10)
125 return now(['think'], '...')
126 }
127 case 'batch': return now(['juggle'], 'tools in parallel!')
128 case 'turnDone': return at('inbox', 2, ['wave'], 'your turn!')
129 case 'turnFail': return fail('something broke, retrying')
130 case 'read': return at('shelf', 0, ['book'], bubbleFor(ev) ?? 'reading...')
131 case 'grep': return at('catalog', 0, ['magnifier'], 'searching the files')
132 case 'edit': fxOn(w, 'typing', 60); return at('desk', 0, [], bubbleFor(ev) ?? 'editing...')
133 case 'notebook': return at('desk', 4, ['notebook'], l ? `notebook ${l}` : 'notebook edit')
134 case 'lsp': return at('shelf', 0, ['dictionary'], 'go to definition...')
135 case 'toolSearch': return at('phone', 0, ['toolbox'], 'which tool fits?')
136 case 'toolFail': return fail('oops, that failed')
137 case 'bash': return at('console', 0, [], 'running a command')
138 case 'test': return at('console', 0, [], 'running tests', () => {
139 if (ev.ok === true) later(w, 20, () => { say(w, m, 'all green!', 30); burst(w, 'confetti') })
140 if (ev.ok === false) later(w, 20, () => fail('tests failing!'))
141 })
142 case 'build': return at('console', 0, ['hammer'], 'building...')
143 case 'install': w.fx.truckAt = w.tick; fxOn(w, 'truck', 45); return at('console', 0, [], 'packages incoming')
144 case 'background': return at('console', 0, [], 'started in the background', () => fxOn(w, 'hourglass', 300))
145 case 'monitor': return at('console', -8, ['binoculars'], 'watching the output')
146 case 'commit': return at('desk', 6, ['parcel'], 'git commit')
147 case 'push': return at('chute', 4, ['parcel'], 'git push!', () => {
148 later(w, 15, () => { m.acc = base(w); say(w, m, 'whoosh, pushed', 30); w.dust.push({ x: station(w, 'chute')?.x ?? m.x, y: 8, until: w.tick + 10 }) })
149 })
150 case 'pr': return at('desk', 4, [], 'PR opened!', () => { w.fx.pr = true })
151 case 'checkout': return now(['map'], 'switching branch')
152 case 'worktree': return now(['box'], 'a new worktree!')
153 case 'webSearch': return at('telescope', 0, [], 'searching the web')
154 case 'webFetch': w.fx.birdAt = w.tick; fxOn(w, 'bird', 40); return at('telescope', 0, [], 'a pigeon brought a page!')
155 case 'skill': return at('desk', -6, ['spellbook'], l ? `casting ${l}` : 'casting a skill')
156 case 'listAgents': return at('board', 0, ['clipboard'], 'who else is in?')
157 case 'msgIn': tube(w, 'up'); return later(w, 20, () => at('tube', 0, ['capsule'], ev.session ? `message from ${ev.session}` : 'a message!'))
158 case 'msgOut': return at('tube', 0, ['capsule'], ev.session ? `message to ${ev.session}` : 'sending a message', () => { m.acc = base(w); tube(w, 'down') })
159 case 'mcp': return at('phone', 0, ['phone'], `calling ${l ?? 'a server'}...`, () => fxOn(w, 'phone', 45))
160 case 'elicitation': return at('inbox', 2, ['clipboard'], 'needs your input')
161 case 'permAsk': return at('inbox', 0, ['bell'], 'may I?')
162 case 'permOk': return now(['stampOk'], 'approved!')
163 case 'permDenied': return now(['stampNo'], 'denied... fair')
164 case 'ask': return at('inbox', 2, ['sign'], 'a question for you')
165 case 'plan': return at('board', 6, ['blueprint'], 'heres the plan')
166 case 'todo': return at('board', 0, [], ev.value ? 'tick!' : 'making a todo list', () => {
167 w.fx.todos = Math.max(3, num(w.fx.todos))
168 if (ev.value !== undefined) w.fx.ticked = Math.min(3, ev.value)
169 })
170 case 'cron': return now(['alarmclock'], 'see you later')
171 case 'notify': return now(['megaphone'], 'hey! over here!')
172 case 'remote': return now([], 'pinging the cloud', () => fxOn(w, 'dish', 35))
173 case 'artifact': return at('catalog', 0, [], 'published a page!', () => { w.fx.poster = true })
174 case 'compact': return at('desk', 3, ['broom'], 'compacting...', () => {
175 for (let i = 0; i < 6; i++) w.dust.push({ x: m.x + (i - 3) * 2, y: standY(w, m.floor) - 1, until: w.tick + 30 })
176 w.fx.context = 0.25
177 })
178 case 'instructions': return at('shelf', 0, ['rulebook'], 'reading CLAUDE.md')
179 case 'rateLimit': return at('console', 0, [], 'rate limited, waiting', () => fxOn(w, 'hourglass', 45))
180 case 'model': {
181 const name = (l ?? '').toLowerCase()
182 w.crown = Object.entries(CROWNS).find(([k]) => name.includes(k))?.[1] ?? '#9FC4E8'
183 return now([], l ? `now: ${l}` : 'a new model!')
184 }
185 case 'fast': m.fast = ev.ok ?? !m.fast; return now([], m.fast ? 'fast mode on!' : 'fast mode off')
186 case 'config': return now(['wrench'], 'settings updated')
187 case 'cwd': return at('desk', -10, ['box'], l ? `moved to ${l}` : 'a new folder')
188 case 'fileChanged': return now(['newspaper'], l ? `${l} changed!` : 'a file changed!')
189 case 'remoteControl': return at('desk', 4, [], 'hi from your phone!', () => fxOn(w, 'phoneDock', 6000))
190 case 'clear': return at('board', 0, ['broom'], 'fresh start', () => {
191 Object.assign(w.fx, { todos: 0, ticked: 0, stickies: 0, picture: false, pr: false, poster: false })
192 })
193 case 'resume': return now(['mug'], 'where were we?')
194 case 'idleLong': return at('desk', 0, [], 'zzz', () => { m.sleep = true })
195 case 'sessionEnd': say(w, m, 'night all', 30); return fxOn(w, 'lightsOff', 60)
196 default: return undefined
197 }
198}
199
200// ---------- everyone else ----------
201
202function playElsewhere(w: World, ev: OfficeEvent): void {
203 switch (ev.kind) {
204 case 'agentStart': {
205 const type = ev.agentType ?? 'general-purpose'
206 say(w, w.main, `${type}, you're up!`, 30)
207 spawnReal(w, { role: 'agent', realId: ev.realId ?? `agent-${ev.seq}`, tag: type, acc: AGENT_LOOKS[type] ?? ['hard'] })
208 return
209 }
210 case 'agentDone': if (ev.realId) retireReal(w, ev.realId, ev.ok === false ? 'failed' : 'done'); return
211 case 'taskCreated':
212 w.fx.tasksTodo = Math.min(6, num(w.fx.tasksTodo) + 1)
213 say(w, w.main, 'a new team task', 25)
214 return
215 case 'taskDone':
216 if (num(w.fx.tasksTodo) > 0) w.fx.tasksTodo = num(w.fx.tasksTodo) - 1
217 w.fx.tasksDone = Math.min(6, num(w.fx.tasksDone) + 1)
218 say(w, w.main, 'team task done!', 25)
219 return
220 case 'teammateIdle': {
221 const id = ev.realId ?? `teammate-${ev.label ?? ev.seq}`
222 const t = w.crabs.find(c => c.realId === id) ?? spawnReal(w, { role: 'teammate', realId: id, tag: ev.label ?? 'teammate', acc: ['teamcap'] })
223 if (t) say(w, t, 'anything for me?', 30)
224 return
225 }
226 case 'peerLetter': return letter(w, ev.realId, ev.session)
227 case 'sessionOpen':
228 if (ev.realId) spawnReal(w, { role: 'session', realId: ev.realId, tag: ev.label ?? 'session', acc: [lookFor(ev.realId)] })
229 return
230 case 'sessionBusy': if (ev.realId) setSessionIdle(w, ev.realId, false); return
231 case 'sessionIdle': if (ev.realId) setSessionIdle(w, ev.realId, true); return
232 case 'sessionClose': if (ev.realId) removeReal(w, ev.realId); return
233 case 'context':
234 w.fx.context = Math.max(0, Math.min(1, ev.value ?? num(w.fx.context)))
235 say(w, w.main, `context ${Math.round(num(w.fx.context) * 100)}%`, 25)
236 return
237 case 'cost': say(w, w.main, `$${ev.value ?? '?'} spent!`, 30); burst(w, 'coins'); return
238 case 'enhance': {
239 const lib = w.librarian
240 if (!lib || !w.crabs.includes(lib)) { say(w, w.main, 'prompt polished!', 30); return }
241 const inbox = station(w, 'inbox')
242 goWalk(w, lib, inbox?.floor ?? 0, (inbox?.x ?? w.main.x) + 12)
243 say(w, lib, 'polishing your prompt!', 40)
244 const home = roomNamed(w, 'Library') ?? roomNamed(w, 'Break room')
245 lib.arrive = () => {
246 lib.dir = -1
247 say(w, lib, 'clearer now!', 30)
248 later(w, 35, () => { if (home) goWalk(w, lib, home.floor, home.stations.books ?? (home.x0 + home.x1) / 2) })
249 }
250 return
251 }
252 default: return undefined
253 }
254}
255
256// One session's crab carries a letter to the other's desk.
257function letter(w: World, fromId?: string, toId?: string): void {
258 const from = w.crabs.find(c => c.realId === fromId && c.role === 'session')
259 const to = w.crabs.find(c => c.realId === toId && c.role === 'session')
260 if (!from || !to || from.plan.length) return
261 const home = from.deskX ?? from.x
262 from.acc = [...from.acc, 'letter']
263 say(w, from, `a note for ${to.tag ?? 'you'}`, 30)
264 goWalk(w, from, to.floor, to.x - 11)
265 from.arrive = () => {
266 from.dir = 1
267 to.dir = -1
268 to.sleep = false
269 later(w, 26, () => { from.acc = from.acc.filter(a => a !== 'letter'); to.acc = [...to.acc, 'letter']; say(w, to, 'thanks!', 30) })
270 later(w, 52, () => { to.acc = to.acc.filter(a => a !== 'letter'); goWalk(w, from, from.floor, home) })
271 }
272}
273
274// ---------- helpers ----------
275
276function base(w: World): Accessory[] {
277 return w.main.fast ? ['crown', 'shoes'] : ['crown']
278}
279function num(v: number | boolean | undefined): number {
280 return typeof v === 'number' ? v : 0
281}
282function lookFor(id: string): Accessory {
283 const looks: Accessory[] = ['headphones', 'scarf', 'beanie', 'party']
284 let h = 0
285 for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) >>> 0
286
287 return looks[h % looks.length] ?? 'scarf'
288}
289// Walks exactly to a station (stations are fixed places, not claimed spots).
290function goWalk(w: World, c: Crab, floor: number, x: number): void {
291 c.plan = []
292 if (floor !== c.floor && w.layout.shaftW > 0) {
293 c.waitX = w.layout.waitX
294 c.plan.push({ walk: w.layout.waitX }, { lift: floor })
295 }
296 c.tFloor = floor
297 c.tx = Math.round(x)
298 c.plan.push({ walk: Math.round(x) })
299}
300function tube(w: World, way: 'up' | 'down'): void {
301 const t = w.layout.tube
302 if (!t) return
303 w.fx.tubeAt = w.tick
304 w.fx.tubeFrom = way === 'up' ? t.floors[1] : t.floors[0]
305 w.fx.tubeTo = way === 'up' ? t.floors[0] : t.floors[1]
306 fxOn(w, 'tube', 22)
307}
308function burst(w: World, kind: 'confetti' | 'coins'): void {
309 const colours = ['#E85A9A', '#7FD1A8', '#E8C15A', '#9FC4E8', '#D97757']
310 for (let i = 0; i < 24; i++) {
311 w.parts.push({
312 kind, x: w.main.x + w.rng.range(-4, 4), y: standY(w, w.main.floor) - 8,
313 vx: w.rng.range(-0.4, 0.4), vy: w.rng.range(-0.6, -0.15), until: w.tick + 30,
314 c: kind === 'coins' ? '#E8C15A' : w.rng.pick(colours),
315 })
316 }
317}
318function puff(w: World, c: Crab): void {
319 for (let i = 0; i < 8; i++) {
320 w.parts.push({ kind: 'smoke', x: c.x + w.rng.range(-2, 2), y: standY(w, c.floor) - 7, vx: w.rng.range(-0.1, 0.1), vy: w.rng.range(-0.2, -0.05), until: w.tick + 25, c: '#888888' })
321 }
322}
323hooks/office/cells.ts 54 lines1// Pixels to terminal cells: two pixel rows to a text row with half blocks,
2// text laid over cells, and neighbouring cells drawn alike merged into runs.
3import type { Pixels } from './sprites'
4
5/** One terminal cell: a glyph, its colour and the colour behind it. */
6export type Cell = { ch: string; fg?: string; bg?: string; bold?: boolean }
7
8/** `▀` paints the top pixel over the bottom one; `▄` the bottom alone. */
9export function halfBlocks(px: Pixels): Cell[][] {
10 const width = Math.max(0, ...px.map(row => row.length))
11 const rows: Cell[][] = []
12 for (let y = 0; y < px.length; y += 2) {
13 const cells: Cell[] = []
14 for (let x = 0; x < width; x++) {
15 const top = px[y]?.[x]
16 const bottom = px[y + 1]?.[x]
17 if (top === undefined && bottom === undefined) cells.push({ ch: ' ' })
18 else if (top === undefined) cells.push({ ch: '▄', fg: bottom })
19 else if (bottom === undefined) cells.push({ ch: '▀', fg: top })
20 else if (top === bottom) cells.push({ ch: '█', fg: top })
21 else cells.push({ ch: '▀', fg: top, bg: bottom })
22 }
23 rows.push(cells)
24 }
25
26 return rows
27}
28
29/** Lays `text` over row `row` from column `col`, clipped to the row; `bg` paints behind it. */
30export function writeText(cells: Cell[][], row: number, col: number, text: string, fg: string, bold = false, bg?: string): void {
31 const line = cells[row]
32 if (!line) return
33 ;[...text].forEach((ch, i) => {
34 const at = col + i
35 if (at < 0 || at >= line.length) return
36 const cell: Cell = { ch, fg }
37 if (bold) cell.bold = true
38 if (bg) cell.bg = bg
39 line[at] = cell
40 })
41}
42
43/** Adjacent cells drawn alike, merged into one run each. */
44export function runs(row: Cell[]): Cell[] {
45 const out: Cell[] = []
46 for (const cell of row) {
47 const last = out[out.length - 1]
48 if (last && last.fg === cell.fg && last.bg === cell.bg && !!last.bold === !!cell.bold) last.ch += cell.ch
49 else out.push({ ...cell })
50 }
51
52 return out
53}
54hooks/office/layout.ts 134 lines1// Where everything in the office is, for the room the panel gives it: the
2// full building when the panel is large, fewer floors when it is medium, one
3// strip when small. Positions are in pixels; one terminal cell is 1 x 2.
4
5export const TOP = 5
6export const FLOOR_H = 17
7const MAX_FLOOR_H = 26
8const MAX_TOP = 12
9const STREET_H = 6
10const SHAFT = 22
11
12export type Size = 'large' | 'medium' | 'small'
13export type RoomName = 'Main office' | 'Agent workshop' | 'Team room' | 'Sessions' | 'Library' | 'Break room' | 'Kitchen'
14export type StationName =
15 | 'chute' | 'telescope' | 'inbox' | 'board' | 'desk' | 'console' | 'phone' | 'shelf' | 'catalog' | 'tube'
16 | 'kanban' | 'couch' | 'tv' | 'pingpong' | 'coffee' | 'cooler' | 'fridge' | 'books'
17
18export type Room = {
19 name: RoomName
20 floor: number
21 x0: number
22 x1: number
23 /** Where a crab stands to use each thing in the room. */
24 stations: Partial<Record<StationName, number>>
25 /** Seats for real crabs (subagents, sessions), at least 11 px apart. */
26 desks: number[]
27 /** Standing spots for background crabs, at least 11 px apart. */
28 spots: number[]
29}
30export type Floor = { index: number; top: number; standY: number; rooms: Room[] }
31export type Layout = {
32 size: Size
33 width: number
34 height: number
35 /** Each floor's height in pixels: 17, taller where the panel has room. */
36 floorH: number
37 floors: Floor[]
38 /** The elevator shaft's width; 0 where there is one floor. */
39 shaftW: number
40 /** Where crabs queue for the elevator. */
41 waitX: number
42 tube?: { x: number; floors: [number, number] }
43 street: { y: number; doorX: number }
44}
45
46// Each floor's rooms and where each ends, as a fraction of the floor past the shaft.
47const PLANS: Record<Size, [RoomName, number][][]> = {
48 large: [
49 [['Main office', 1]],
50 [['Agent workshop', 0.73], ['Team room', 1]],
51 [['Sessions', 0.6], ['Library', 1]],
52 [['Break room', 0.655], ['Kitchen', 1]],
53 ],
54 medium: [[['Main office', 1]], [['Agent workshop', 0.6], ['Sessions', 1]], [['Break room', 1]]],
55 small: [[['Main office', 0.5], ['Agent workshop', 1]]],
56}
57
58// The main office's stations, left to right, as fractions of its inside.
59const MAIN: [StationName, number][] = [
60 ['chute', 0], ['telescope', 0.0875], ['inbox', 0.175], ['board', 0.275], ['desk', 0.4125],
61 ['console', 0.575], ['phone', 0.6875], ['shelf', 0.8], ['catalog', 0.9125], ['tube', 0.9625],
62]
63
64export function chooseLayout(columns: number, rows: number): Layout {
65 const width = columns
66 const height = rows * 2
67 const fits = (floors: number) => height >= TOP + floors * FLOOR_H + STREET_H
68 const size: Size = columns >= 100 && fits(4) ? 'large' : columns >= 70 && fits(3) ? 'medium' : 'small'
69 const shaftW = size === 'small' ? 0 : SHAFT
70 const span = width - shaftW
71 // A tall panel gets taller floors, then a little more sky; the street keeps the rest.
72 const count = PLANS[size].length
73 const minTop = size === 'small' ? 3 : TOP
74 const floorH = Math.max(FLOOR_H, Math.min(MAX_FLOOR_H, Math.floor((height - minTop - STREET_H) / count)))
75 const top = Math.max(minTop, Math.min(MAX_TOP, height - STREET_H - count * floorH))
76
77 const floors = PLANS[size].map((plan, index): Floor => {
78 const floorTop = top + index * floorH
79 let x0 = shaftW
80 const rooms = plan.map(([name, end]): Room => {
81 const x1 = Math.round(shaftW + end * span)
82 const room = furnish(name, index, x0, x1, plan.map(([n]) => n))
83 x0 = x1
84
85 return room
86 })
87
88 return { index, top: floorTop, standY: floorTop + floorH - 1, rooms }
89 })
90 const lastStand = floors[floors.length - 1]?.standY ?? top + floorH - 1
91
92 return {
93 size,
94 width,
95 height,
96 floorH,
97 floors,
98 shaftW,
99 waitX: shaftW + 5,
100 ...(size === 'large' ? { tube: { x: width - 4, floors: [0, 2] as [number, number] } } : {}),
101 street: { y: lastStand + 1, doorX: width - 4 },
102 }
103}
104
105// A room's stations, desks and spots, from its kind and its neighbours.
106function furnish(name: RoomName, floor: number, x0: number, x1: number, onFloor: RoomName[]): Room {
107 const lo = x0 + 4
108 const hi = x1 - 5
109 const at = (f: number) => Math.round(lo + f * (hi - lo))
110 const every = (from: number, step: number) => {
111 const out: number[] = []
112 for (let x = from; x <= hi; x += step) out.push(x)
113
114 return out
115 }
116 const stations: Room['stations'] = {}
117 let desks: number[] = []
118
119 if (name === 'Main office') for (const [s, f] of MAIN) stations[s] = at(f)
120 if (name === 'Agent workshop' || name === 'Sessions') desks = every(lo + 6, 18)
121 if (name === 'Agent workshop' && !onFloor.includes('Team room')) stations.kanban = at(0.92)
122 if (name === 'Team room') { stations.kanban = at(0.5); stations.desk = at(0.3) }
123 if (name === 'Library') { stations.books = at(0.4); stations.shelf = at(0.15) }
124 if (name === 'Break room') {
125 stations.couch = at(0.1)
126 stations.tv = at(0.35)
127 stations.pingpong = at(0.6)
128 if (!onFloor.includes('Kitchen')) { stations.coffee = at(0.8); stations.books = at(0.95) }
129 }
130 if (name === 'Kitchen') { stations.coffee = at(0.15); stations.cooler = at(0.35); stations.fridge = at(0.85) }
131
132 return { name, floor, x0, x1, stations, desks, spots: every(lo + 2, 12) }
133}
134hooks/office/render.ts 239 lines1// Draws the world as terminal cells: the building, its furniture and
2// effects, the crabs with their accessories, then text over the pixels (room
3// names, name tags, speech bubbles, the elevator's "n/2" and the street's
4// "+N" sign). Ported from docs/mockup/crab-town-v5.html.
5import { halfBlocks, writeText } from './cells'
6import type { Cell } from './cells'
7import type { Room } from './layout'
8import { LIFT_CAP, isFx, standY } from './sim'
9import type { Crab, World } from './sim'
10import { blank, drawCrab, paint } from './sprites'
11import type { Pixels } from './sprites'
12
13const WALLS: Record<Room['name'], [string, string]> = {
14 'Main office': ['#3B302A', '#2B2622'],
15 'Agent workshop': ['#2C3A40', '#232A2E'],
16 'Team room': ['#3A2C36', '#2A2028'],
17 Sessions: ['#332D40', '#26232E'],
18 Library: ['#3A2E26', '#2A231E'],
19 'Break room': ['#3A332A', '#2A2620'],
20 Kitchen: ['#2F3A2A', '#242A20'],
21}
22const SPINES = ['#3E6AA8', '#D97757', '#E8C15A', '#2E9E6A', '#9A5CB4']
23const num = (v: number | boolean | undefined) => (typeof v === 'number' ? v : 0)
24
25/**
26 * How much fine pixel work to draw: `low` merges the busiest details (book
27 * spines, window bars, lamps, drawer knobs, rack lights) so a big panel's
28 * drawing stays under the Client's size limit.
29 */
30export type Detail = 'full' | 'low'
31
32export function drawWorld(w: World, detail: Detail = 'full'): Cell[][] {
33 const L = w.layout
34 const t = w.tick
35 const px = blank(L.width, L.height)
36 const rect = (x: number, y: number, ww: number, h: number, c: string) => paint(px, x, y, ww, h, c)
37 const dot = (x: number, y: number, c: string) => paint(px, x, y, 1, 1, c)
38 const top = L.floors[0]?.top ?? 0
39
40 // the sky and its drifting clouds
41 rect(0, 0, L.width, L.height, '#0E0D0C')
42 rect(0, 0, L.width, top, '#3E6E9E')
43 for (const seed of [8, 40, 75, 100]) {
44 const cx = ((seed + t * 0.015 * 2) % (L.width + 8)) - 8
45 rect(cx, seed % 3, 6, 1, '#DDE8F2')
46 rect(cx + 1, (seed % 3) + 1, 3, 1, '#F2F6FA')
47 }
48 if (L.size === 'large') { rect(L.width - 20, top - 3, 1, 3, '#888888'); rect(L.width - 22, top - 4, 5, 1, '#BBBBBB'); dot(L.width - 20, top - 5, isFx(w, 'dish') && t % 4 < 2 ? '#7FD1A8' : '#555555') }
49
50 // rooms: walls, floors, windows, lamps, doorways
51 for (const floor of L.floors) {
52 for (const r of floor.rooms) {
53 const [wall, fl] = WALLS[r.name]
54 const dark = r.name === 'Main office' && isFx(w, 'lightsOff')
55 rect(r.x0, floor.top, r.x1 - r.x0, L.floorH, dark ? '#16120F' : wall)
56 rect(r.x0, floor.standY - 1, r.x1 - r.x0, 2, fl)
57 if (r.name !== 'Library' && r.name !== 'Team room' && r.x1 - r.x0 > 24) {
58 const wx = r.x0 + 3
59 rect(wx, floor.top + 4, 7, 5, '#4D7FB0')
60 if (detail === 'full') {
61 rect(wx + ((t / 30 + floor.index * 2) % 6), floor.top + 5, 2, 1, '#E8F0F6')
62 rect(wx + 3, floor.top + 4, 1, 5, '#2A2420')
63 rect(wx, floor.top + 6, 7, 1, '#2A2420')
64 }
65 }
66 if (!dark && detail === 'full') {
67 for (const f of [0.35, 0.75]) {
68 const lx = Math.floor(r.x0 + (r.x1 - r.x0) * f)
69 const alarm = r.name === 'Main office' && isFx(w, 'alarm')
70 dot(lx, floor.top + 1, '#555555')
71 rect(lx - 1, floor.top + 2, 3, 1, alarm ? (t % 4 < 2 ? '#FF3B30' : '#7A1A15') : '#E8D7A0')
72 }
73 }
74 if (r.x0 > L.shaftW) rect(r.x0, floor.top, 1, 8, '#1A1816')
75 furnish(w, px, r, floor.top, floor.standY, t, detail)
76 }
77 rect(0, floor.top, L.width, 1, '#1A1816')
78 }
79
80 // the elevator shaft and its car
81 if (L.shaftW > 0) {
82 const last = L.floors[L.floors.length - 1]
83 const shaftH = (last?.standY ?? 0) + 1 - top
84 rect(0, top, L.shaftW, shaftH, '#1E1C1A')
85 rect(L.shaftW - 1, top, 1, shaftH, '#111111')
86 for (const f of L.floors) { rect(1, f.standY - 10, L.shaftW - 2, 1, '#3A3A3A'); dot(L.shaftW, f.standY - 11, w.lift.floor === f.index && w.lift.door > 0.5 ? '#7FD1A8' : '#7A2A2A') }
87 const cy = w.lift.y - 9
88 rect(10, top + 1, 1, Math.max(0, cy - top - 1), '#444444')
89 rect(1, cy, L.shaftW - 2, 10, '#6B6B70')
90 rect(2, cy + 1, L.shaftW - 4, 8, '#2B2B2E')
91 w.lift.riders.forEach((r, i) => crab(w, px, r, 6 + i * 10, w.lift.y))
92 const half = ((L.shaftW - 4) / 2) * (1 - w.lift.door)
93 if (half >= 0.5) { rect(2, cy + 1, half, 8, '#8A8A90'); rect(2 + (L.shaftW - 4) - half, cy + 1, half, 8, '#8A8A90') }
94 }
95
96 // the street and its door
97 rect(0, L.street.y, L.width, L.height - L.street.y, '#2A2A2A')
98 for (let x = (t / 4) % 10; x < L.width; x += 10) rect(x, L.height - 2, 4, 1, '#555555')
99 const lastStand = L.floors[L.floors.length - 1]?.standY ?? L.street.y
100 rect(L.width - 7, lastStand - 9, 6, 9, '#4B3325')
101 dot(L.width - 3, lastStand - 5, '#E8C15A')
102 if (isFx(w, 'truck')) { const tx = L.width - ((t - num(w.fx.truckAt)) / 40) * 30; rect(tx, L.height - 6, 10, 4, '#3E6AA8'); rect(tx - 3, L.height - 5, 3, 3, '#9FD3E8') }
103 if (isFx(w, 'bird')) { const k = Math.min(1, (t - num(w.fx.birdAt)) / 30); rect(2 + k * 30, top + 1 + k * 4, 2, 1, '#DDDDDD') }
104
105 // the pneumatic tube between Sessions and the Main office
106 const tube = L.tube
107 if (tube) {
108 const a = L.floors[tube.floors[0]]?.standY ?? 0
109 const b = L.floors[tube.floors[1]]?.standY ?? 0
110 rect(tube.x, a - 6, 2, b - a + 1, '#7F9AA8')
111 if (isFx(w, 'tube')) {
112 const k = Math.min(1, (t - num(w.fx.tubeAt)) / 20)
113 const from = (L.floors[num(w.fx.tubeFrom)]?.standY ?? a) - 5
114 const to = (L.floors[num(w.fx.tubeTo)]?.standY ?? a) - 5
115 rect(tube.x, from + (to - from) * k, 2, 2, '#E8C15A')
116 }
117 }
118
119 // the crabs, then dust and particles
120 for (const c of w.crabs) if (!c.inLift) crab(w, px, c, c.x - 4, standY(w, c.floor))
121 for (const d of w.dust) dot(d.x + ((t * 7) % 5) - 2, d.y - ((t * 3) % 3), '#DDDDDD')
122 for (const p of w.parts) { p.x += p.vx; p.y += p.vy; p.vy += p.kind === 'smoke' ? -0.005 : 0.04; dot(p.x, p.y, p.c) }
123
124 // text over the pixels
125 const cells = halfBlocks(px)
126 const row = (y: number) => Math.floor(y / 2)
127 for (const floor of L.floors) for (const r of floor.rooms) writeText(cells, row(floor.top + 2), r.x0 + 2, r.name, '#DDDDDD', true)
128 for (const c of w.crabs) {
129 if (c.inLift || !c.tag || (c.tagUntil !== undefined && t >= c.tagUntil)) continue
130 writeText(cells, row(standY(w, c.floor) + 1), Math.round(c.x) - 4, c.tag, c.tagColor ?? '#DDDDDD', true)
131 }
132 for (const b of w.bubbles) {
133 const ri = w.lift.riders.indexOf(b.crab)
134 const x = ri >= 0 ? 4 + ri * 10 : Math.round(b.crab.x) - 3
135 const y = ri >= 0 ? w.lift.y : standY(w, b.crab.floor)
136 const col = Math.max(0, Math.min(L.width - b.text.length - 2, x))
137 writeText(cells, row(y - 12), col, ` ${b.text} `, '#2A211D', false, '#F5EEDC')
138 }
139 if (L.shaftW > 0) writeText(cells, row(w.lift.y - 11), 8, `${w.lift.riders.length}/${LIFT_CAP}`, '#E8C15A', true)
140 const waiting = w.street.waiting.length
141 if (waiting > 0) writeText(cells, row(L.street.y + 2), L.width - 10, ` +${waiting} `, '#2A211D', true, '#E8C15A')
142
143 return cells
144}
145
146function crab(w: World, px: Pixels, c: Crab, x: number, stand: number): void {
147 const shake = c.shake && w.tick % 2 ? 1 : 0
148 drawCrab(px, Math.round(x) + shake, stand - 6, {
149 legsPhase: c.moving ? ((Math.floor(c.x * 1.2) & 3) as 0 | 1 | 2 | 3) : 0,
150 blink: !!c.sleep || (w.tick + c.seed) % 60 < 2,
151 dir: c.dir,
152 acc: c.acc,
153 t: w.tick,
154 hop: c.hop,
155 crown: c.role === 'main' ? w.crown : undefined,
156 })
157}
158
159// Each room's furniture, from its stations and desks.
160function furnish(w: World, px: Pixels, r: Room, top: number, f: number, t: number, detail: Detail): void {
161 const rect = (x: number, y: number, ww: number, h: number, c: string) => paint(px, x, y, ww, h, c)
162 const fine = detail === 'full'
163 const dot = (x: number, y: number, c: string) => { if (fine) paint(px, x, y, 1, 1, c) }
164 const s = r.stations
165 const desk = (x: number, on: boolean) => {
166 rect(x - 5, f - 3, 11, 1, '#6B4A35'); rect(x - 4, f - 2, 1, 2, '#4B3325'); rect(x + 4, f - 2, 1, 2, '#4B3325')
167 rect(x - 3, f - 7, 5, 4, '#2A2A2A'); rect(x - 2, f - 6, 3, 2, on ? (t % 4 < 2 ? '#7FD1A8' : '#5FB18A') : '#2F3F45')
168 }
169 // Full detail: a spine per book. Low: each shelf's books as one coloured band.
170 const shelf = (x: number) => {
171 rect(x, f - 12, 6, 12, '#5A3E2A')
172 for (let j = 0; j < 3; j++) {
173 if (fine) for (let i = 0; i < 5; i++) rect(x + i, f - 11 + j * 4, 1, 3, SPINES[(i + j * 2 + x) % 5] ?? '#3E6AA8')
174 else rect(x, f - 11 + j * 4, 5, 3, SPINES[(j + x) % 5] ?? '#3E6AA8')
175 }
176 }
177
178 if (s.chute !== undefined) { rect(s.chute - 2, top + 10, 3, f - top - 10, '#5A5A60'); rect(s.chute - 3, top + 10, 5, 2, '#777777') }
179 if (s.telescope !== undefined) { const x = s.telescope - 2; rect(x, f - 4, 1, 4, '#777777'); rect(x + 3, f - 4, 1, 4, '#777777'); rect(x, f - 7, 6, 2, '#B08D57'); rect(x + 5, f - 8, 2, 2, '#B08D57') }
180 if (s.inbox !== undefined) {
181 const x = s.inbox
182 rect(x, f - 5, 1, 5, '#555555'); rect(x - 2, f - 6, 5, 1, '#8A6A4A')
183 if (isFx(w, 'letter')) { w.fx.letterY = Math.min(f - 7, num(w.fx.letterY) + 0.3); rect(x - 1, top + num(w.fx.letterY), 3, 2, '#F5EEDC') }
184 }
185 if (s.board !== undefined) {
186 const x = s.board - 4
187 rect(x, top + 4, 11, 7, '#EEEEEE')
188 for (let i = 0; i < Math.min(3, num(w.fx.todos)); i++) { dot(x + 2, top + 6 + i * 2, i < num(w.fx.ticked) ? '#2E9E6A' : '#AAAAAA'); rect(x + 4, top + 6 + i * 2, 5, 1, '#3E6AA8') }
189 for (let i = 0; i < num(w.fx.stickies); i++) rect(x + 8 - i * 2, top + 9, 2, 1, '#E8C15A')
190 if (w.fx.picture) { rect(x + 12, top + 4, 6, 5, '#B08D57'); rect(x + 13, top + 5, 4, 3, '#4D7FB0') }
191 }
192 if (s.desk !== undefined && r.name === 'Main office') {
193 const x = s.desk
194 rect(x - 6, f - 3, 14, 1, '#6B4A35'); rect(x - 5, f - 2, 1, 2, '#4B3325'); rect(x + 6, f - 2, 1, 2, '#4B3325')
195 rect(x - 4, f - 7, 5, 4, '#2A2A2A'); rect(x - 3, f - 6, 3, 2, isFx(w, 'typing') ? (t % 4 < 2 ? '#7FD1A8' : '#5FB18A') : '#2F3F45')
196 if (num(w.fx.context) > 0.8) for (let i = 0; i < 4; i++) rect(x + 2 + (i % 2), f - 5 - i, 3, 1, '#F5EEDC')
197 if (isFx(w, 'phoneDock') && t % 4 < 2) dot(x + 5, f - 5, '#7FD1A8')
198 if (w.fx.pr) { rect(x + 4, top + 4, 5, 4, '#2E9E6A'); rect(x + 5, top + 5, 3, 1, '#F5EEDC') }
199 // the context gauge on the wall
200 const g = num(w.fx.context) || 0.3
201 rect(x + 11, top + 3, 1, 8, '#222222'); rect(x + 11, top + 11 - Math.round(8 * g), 1, Math.round(8 * g), g > 0.8 ? '#D9534F' : '#7FD1A8')
202 }
203 if (s.console !== undefined) {
204 const x = s.console - 4
205 const alarm = isFx(w, 'alarm')
206 rect(x, f - 9, 8, 9, '#222222'); rect(x + 1, f - 8, 6, 4, alarm ? '#3A0B0B' : '#0B2A1A')
207 for (let i = 0; i < 3; i++) rect(x + 1, f - 8 + i, 1 + ((i * 3 + Math.floor(t / 2)) % 5), 1, alarm ? '#FF6B5B' : '#3DB47A')
208 if (isFx(w, 'hourglass')) { rect(x + 5, f - 12, 3, 1, '#B08D57'); rect(x + 5, f - 9, 3, 1, '#B08D57'); dot(x + 6, f - 10 - (Math.floor(t / 6) % 2), '#E8C15A') }
209 }
210 if (s.phone !== undefined) { const x = s.phone - 3; rect(x, f - 3, 7, 1, '#6B4A35'); rect(x + 1, f - 2, 1, 2, '#4B3325'); rect(x + 5, f - 2, 1, 2, '#4B3325'); if (!isFx(w, 'phone')) rect(x + 2, f - 5, 3, 2, '#D9534F') }
211 if (s.shelf !== undefined) shelf(s.shelf - 3)
212 if (s.catalog !== undefined) {
213 const x = s.catalog - 4
214 rect(x, f - 7, 8, 7, '#7A5A3A')
215 for (let j = 0; j < 3; j++) for (let i = 0; i < 2; i++) { rect(x + 1 + i * 4, f - 6 + j * 2, 3, 1, '#5A3E2A'); dot(x + 2 + i * 4, f - 6 + j * 2, '#E8C15A') }
216 if (w.fx.poster) { rect(x, top + 3, 7, 5, '#F5EEDC'); rect(x + 1, top + 4, 5, 1, '#D97757') }
217 }
218 for (const x of r.desks) desk(x, w.crabs.some(c => c.deskX === x && c.state === 'work' && !c.idle && c.floor === r.floor))
219 if (s.kanban !== undefined) {
220 const x = s.kanban - 8
221 rect(x, top + 3, 16, 8, '#EEEEEE')
222 for (let i = 0; i < Math.min(6, num(w.fx.tasksTodo)); i++) rect(x + 1 + (i % 2) * 3, top + 5 + Math.floor(i / 2) * 2, 2, 1, '#E8C15A')
223 for (let i = 0; i < Math.min(6, num(w.fx.tasksDone)); i++) rect(x + 10 + (i % 2) * 3, top + 5 + Math.floor(i / 2) * 2, 2, 1, '#7FD1A8')
224 }
225 if (r.name === 'Agent workshop' && r.x1 - r.x0 > 50) {
226 const x = r.x1 - 8
227 rect(x, f - 12, 5, 12, '#222222')
228 for (let j = 0; j < 5; j++) dot(x + 3, f - 11 + j * 2, Math.floor(t / 4 + j * 3) % 4 ? '#3DB47A' : '#E8C15A')
229 }
230 if (r.name === 'Library') { for (let x = r.x0 + 6; x < r.x1 - 10; x += 8) shelf(x); rect(r.x1 - 7, f - 3, 3, 3, '#8A5A3A'); rect(r.x1 - 7, f - 7, 3, 4, '#3DB47A') }
231 if (s.couch !== undefined) { const x = s.couch - 6; rect(x, f - 3, 14, 2, '#5A3E66'); rect(x, f - 5, 14, 2, '#6E4C7D') }
232 if (s.tv !== undefined) { rect(s.tv - 4, top + 4, 8, 6, '#222222'); rect(s.tv - 3, top + 5, 6, 4, ['#4D7FB0', '#7FD1A8', '#D97757'][Math.floor(t / 20) % 3] ?? '#4D7FB0') }
233 if (s.pingpong !== undefined) { rect(s.pingpong - 6, f - 3, 12, 1, '#2E7D5B'); dot(s.pingpong, f - 4, '#DDDDDD') }
234 if (s.coffee !== undefined) { const x = s.coffee - 2; rect(x, f - 6, 4, 6, '#3A3A3A'); dot(x + 1, f - 4, '#D97757'); dot(x + 1 + (Math.floor(t / 5) % 2), f - 8, '#888888') }
235 if (s.cooler !== undefined) { rect(s.cooler - 1, f - 7, 3, 3, '#9FD3E8'); rect(s.cooler - 1, f - 4, 3, 4, '#DDDDDD') }
236 if (s.fridge !== undefined) { rect(s.fridge - 3, f - 11, 7, 11, '#C9CED2'); rect(s.fridge + 2, f - 9, 1, 2, '#888888') }
237 if (s.books !== undefined && r.name !== 'Library') shelf(s.books - 3)
238}
239hooks/office/sim.ts 554 lines1// The office's simulation, deterministic from a seed: crabs that walk with
2// eased steps, an elevator that carries two, standing spots that keep crabs
3// apart, background crabs living their own lives, and real crabs (subagents,
4// sessions, teammates) seated at desks or queued on the street behind a
5// "+N" sign. Ported from docs/mockup/crab-town-v5.html; one frame is 50 ms,
6// and the logic ticks every second frame (10 a second).
7import type { Layout, Room, RoomName, StationName } from './layout'
8import { createRng } from './rng'
9import type { Rng } from './rng'
10import type { Accessory } from './sprites'
11
12export type Role = 'main' | 'librarian' | 'agent' | 'teammate' | 'session' | 'decor' | 'leaving'
13type Step = { walk: number } | { lift: number }
14
15export type Crab = {
16 id: number
17 role: Role
18 floor: number
19 /** The body's centre column, in pixels. */
20 x: number
21 v: number
22 dir: 1 | -1
23 acc: Accessory[]
24 seed: number
25 plan: Step[]
26 moving: boolean
27 wait: number
28 tag?: string
29 tagColor?: string
30 tagUntil?: number
31 realId?: string
32 deskX?: number
33 /** A real crab's stage: arriving, at work, reporting, or retired. */
34 state?: 'arriving' | 'work' | 'reporting' | 'retired'
35 tFloor?: number
36 tx?: number
37 waitX?: number
38 awaitLift?: boolean
39 liftTo?: number
40 queuedAt?: number
41 inLift?: boolean
42 sleep?: boolean
43 hop?: number
44 shake?: boolean
45 fast?: boolean
46 idle?: boolean
47 fight?: Crab
48 fightUntil?: number
49 goHomeAt?: number
50 arrive?: () => void
51}
52
53export type RealSpec = { role: 'agent' | 'session' | 'teammate'; realId: string; tag: string; acc: Accessory[] }
54export type Bubble = { crab: Crab; text: string; until: number }
55export type Particle = { kind: 'confetti' | 'coins' | 'smoke'; x: number; y: number; vx: number; vy: number; until: number; c: string }
56
57export type World = {
58 layout: Layout
59 rng: Rng
60 frameNo: number
61 tick: number
62 crabs: Crab[]
63 main: Crab
64 librarian?: Crab
65 lift: { floor: number; y: number; door: number; riders: Crab[]; target: number | null; dwell: number; v: number; state: 'idle' | 'closing' | 'moving' | 'opening' | 'open' }
66 street: { waiting: RealSpec[] }
67 bubbles: Bubble[]
68 timers: { at: number; fn: () => void }[]
69 /** Room effects keyed by name: an end tick, or a value (todos, context). */
70 fx: Record<string, number | boolean | undefined>
71 parts: Particle[]
72 dust: { x: number; y: number; until: number }[]
73 crown: string
74 nextId: number
75 /** Background crabs the current layout has no room for, kept for a bigger one. */
76 parked: Crab[]
77}
78
79export const GAP = 11
80export const DECOR_CAP = 6
81export const LIFT_CAP = 2
82const RANK: Record<Role, number> = { main: 0, librarian: 1, agent: 2, teammate: 2, session: 3, decor: 4, leaving: 5 }
83
84export const LINES: string[][] = [
85 ['ship it!', 'tests first.'], ['who broke main?', 'not me', 'git blame says u'], ['need coffee', 'same'],
86 ['LGTM', 'u didnt read it', '...LGTM'], ['tabs or spaces?', 'FIGHT ME'], ['works on my machine', 'ship ur machine'],
87 ['found the bug!', 'what was it', 'a typo'], ['deploy friday?', 'no.'], ['merge conflict :(', 'rebase?', 'scary'],
88 ['nice PR!', 'ty <3'], ['token budget!!', 'compact it'], ['is it lunch', 'its 10am'],
89 ['did u see that diff?', '400 files', 'lol'], ['who ate my snack', '...', 'the Explore crab'],
90 ['how was the job?', 'grep grep grep', 'living the dream'], ['the boss is busy', 'big refactor', 'brave'],
91 ['i got a hat today', 'nice hat'], ['elevator again?', 'only fits two', 'cozy'],
92]
93const DECOR_LOOKS: Accessory[][] = [['mug'], ['party'], [], ['beanie'], ['scarf'], ['headphones']]
94
95// ---------- the layout, by name ----------
96
97export function rooms(w: World): Room[] {
98 return w.layout.floors.flatMap(f => f.rooms)
99}
100export function roomNamed(w: World, name: RoomName): Room | undefined {
101 return rooms(w).find(r => r.name === name)
102}
103/** The first room holding a station, and where it is. */
104export function station(w: World, name: StationName): { floor: number; x: number } | undefined {
105 for (const r of rooms(w)) {
106 const x = r.stations[name]
107 if (x !== undefined) return { floor: r.floor, x }
108 }
109
110 return undefined
111}
112export function standY(w: World, floor: number): number {
113 return w.layout.floors[floor]?.standY ?? w.layout.floors[0]?.standY ?? 0
114}
115const bottom = (w: World) => w.layout.floors.length - 1
116const bounds = (w: World): [number, number] => [w.layout.shaftW + 5, w.layout.width - 8]
117
118// ---------- the world ----------
119
120export function createWorld(layout: Layout, seed: number): World {
121 const main = { id: 1, role: 'main' as const, floor: 0, x: 0, v: 0, dir: -1 as const, acc: ['crown' as const], seed: 0, plan: [], moving: false, wait: 0, tag: 'main', tagColor: '#E8C15A' }
122 const w: World = {
123 layout, rng: createRng(seed), frameNo: 0, tick: 0, crabs: [main], main,
124 lift: { floor: 0, y: 0, door: 1, riders: [], target: null, dwell: 0, v: 0, state: 'idle' },
125 street: { waiting: [] }, bubbles: [], timers: [], fx: {}, parts: [], dust: [], crown: '#E8C15A', nextId: 2, parked: [],
126 }
127 placeAll(w)
128 topUpDecor(w)
129
130 return w
131}
132
133// How many background crabs each size starts with.
134const DECOR_START: Record<Layout['size'], number> = { large: 4, medium: 3, small: 0 }
135
136// Brings the background crabs up to the layout's starting number.
137function topUpDecor(w: World): void {
138 for (let i = w.crabs.filter(c => c.role === 'decor').length; i < DECOR_START[w.layout.size]; i++) {
139 const floor = Math.max(1, bottom(w) - (i % 2))
140 const c = makeCrab(w, { role: 'decor', floor, acc: [...(DECOR_LOOKS[i] ?? [])], wait: i * 15 })
141 c.x = freeSpot(w, c, floor, w.rng.range(...bounds(w)))
142 }
143}
144
145// Seats the main crab and the librarian, and parks the lift, for the layout.
146function placeAll(w: World): void {
147 const desk = station(w, 'desk')
148 w.main.floor = desk?.floor ?? 0
149 w.main.x = desk?.x ?? w.layout.width / 2
150 w.lift.floor = bottom(w)
151 w.lift.y = standY(w, bottom(w))
152 const home = roomNamed(w, 'Library') ?? roomNamed(w, 'Break room')
153 if (home && !w.librarian) {
154 w.librarian = makeCrab(w, { role: 'librarian', floor: home.floor, acc: ['librarian', 'book'], tag: 'enhancer', tagColor: '#E8A0C8' })
155 }
156 if (w.librarian && home) {
157 w.librarian.floor = home.floor
158 w.librarian.x = freeSpot(w, w.librarian, home.floor, home.stations.books ?? (home.x0 + home.x1) / 2)
159 }
160}
161
162function makeCrab(w: World, o: Partial<Crab> & Pick<Crab, 'role' | 'floor'>): Crab {
163 const c: Crab = { id: w.nextId++, x: 0, v: 0, dir: 1, acc: [], seed: Math.floor(w.rng.range(0, 60)), plan: [], moving: false, wait: 0, ...o }
164 w.crabs.push(c)
165
166 return c
167}
168
169export function say(w: World, crab: Crab, text: string, ticks = 30): void {
170 w.bubbles.push({ crab, text, until: w.tick + ticks })
171}
172export function later(w: World, ticks: number, fn: () => void): void {
173 w.timers.push({ at: w.tick + ticks, fn })
174}
175export function fxOn(w: World, name: string, ticks: number): void {
176 w.fx[name] = w.tick + ticks
177}
178export function isFx(w: World, name: string): boolean {
179 const v = w.fx[name]
180
181 return typeof v === 'number' && v > w.tick
182}
183
184// ---------- standing room ----------
185
186function taken(w: World, floor: number, x: number, self: Crab): boolean {
187 return w.crabs.some(o => o !== self && !o.inLift && (
188 (o.tFloor === floor && o.tx !== undefined && Math.abs(o.tx - x) < GAP) ||
189 (o.floor === floor && (!o.plan.length || o.awaitLift) && Math.abs(o.x - x) < GAP) ||
190 (o.floor === floor && o.waitX !== undefined && Math.abs(o.waitX - x) < GAP)))
191}
192
193export function freeSpot(w: World, c: Crab, floor: number, want: number): number {
194 const [lo, hi] = bounds(w)
195 for (let d = 0; d < 120; d++) {
196 for (const s of [1, -1]) {
197 const x = Math.round(want + s * d)
198 if (x >= lo && x <= hi && !taken(w, floor, x, c)) return x
199 }
200 }
201
202 return Math.round(Math.min(hi, Math.max(lo, want)))
203}
204
205/** Walks to a spot on a floor, taking the elevator to another, claiming it. */
206export function goTo(w: World, c: Crab, floor: number, want: number, exact = false): number {
207 const x = exact ? want : freeSpot(w, c, floor, want)
208 c.plan = []
209 c.tFloor = floor
210 c.tx = x
211 if (floor !== c.floor) {
212 const wx = freeSpot(w, c, c.floor, w.layout.waitX)
213 c.waitX = wx
214 c.plan.push({ walk: wx }, { lift: floor })
215 }
216 c.plan.push({ walk: x })
217
218 return x
219}
220
221// When two standing crabs end up too close, the less important steps aside.
222function unclump(w: World): void {
223 const standing = w.crabs.filter(c => !c.plan.length && !c.inLift && !c.awaitLift && !c.fight && c.role !== 'leaving')
224 for (const a of standing) {
225 for (const b of standing) {
226 if (a === b || a.floor !== b.floor || Math.abs(a.x - b.x) >= GAP - 2 || b.plan.length) continue
227 if (RANK[a.role] > RANK[b.role] || (RANK[a.role] === RANK[b.role] && a.id > b.id)) continue
228 if ((b.role === 'session' || b.role === 'agent') && b.x === b.deskX) continue
229 const x = freeSpot(w, b, b.floor, b.x)
230 if (Math.abs(x - b.x) >= 1) goTo(w, b, b.floor, x, true)
231 }
232 }
233}
234
235// ---------- motion ----------
236
237function move(w: World, c: Crab): void {
238 if (c.fight) {
239 const mate = c.fight
240 if (w.tick < (c.fightUntil ?? 0)) {
241 const b = w.frameNo % 4 < 2
242 c.hop = b ? -1 : 0
243 mate.hop = b ? 0 : -1
244 if (w.frameNo % 6 === 0) w.dust.push({ x: (c.x + mate.x) / 2, y: standY(w, c.floor) - 4, until: w.tick + 3 })
245
246 return
247 }
248 c.hop = 0
249 mate.hop = 0
250 c.fight = undefined
251 }
252 const step = c.plan[0]
253 if (!step || c.inLift) { c.moving = false; c.v = 0; return }
254 if ('walk' in step) {
255 const dx = step.walk - c.x
256 if (Math.abs(dx) < 0.15) {
257 c.x = step.walk
258 c.plan.shift()
259 c.moving = false
260 c.v = 0
261 if (!c.plan.length) {
262 c.tx = undefined
263 const f = c.arrive
264 c.arrive = undefined
265 f?.()
266 }
267
268 return
269 }
270 const max = c.fast ? 0.5 : 0.25
271 const want = Math.sign(dx) * Math.min(max, Math.abs(dx) * 0.18 + 0.04)
272 c.v += (want - c.v) * 0.25
273 c.dir = dx > 0 ? 1 : -1
274 c.x += c.v
275 c.moving = true
276 if (Math.sign(step.walk - c.x) !== Math.sign(dx)) c.x = step.walk
277 } else {
278 c.moving = false
279 c.v = 0
280 if (c.floor === step.lift && !c.inLift) { c.plan.shift(); c.awaitLift = false; return }
281 if (!c.awaitLift) { c.awaitLift = true; c.queuedAt = w.tick }
282 c.liftTo = step.lift
283 }
284}
285
286function stepLift(w: World): void {
287 const lift = w.lift
288 const waiting = () => w.crabs.filter(c => c.awaitLift && !c.inLift).sort((a, b) => (a.queuedAt ?? 0) - (b.queuedAt ?? 0))
289 if (lift.state === 'idle') {
290 if (lift.riders.length) lift.target = lift.riders[0]?.liftTo ?? lift.floor
291 else {
292 const first = waiting()[0]
293 if (!first) return
294 lift.target = first.floor
295 }
296 lift.state = lift.target === lift.floor ? 'open' : 'closing'
297 lift.dwell = 0
298 }
299 if (lift.state === 'closing') { lift.door = Math.max(0, lift.door - 0.08); if (lift.door === 0) lift.state = 'moving'; return }
300 if (lift.state === 'moving') {
301 const ty = standY(w, lift.target ?? 0)
302 const dist = ty - lift.y
303 if (Math.abs(dist) < 0.05) { lift.y = ty; lift.floor = lift.target ?? 0; lift.v = 0; lift.state = 'opening'; return }
304 const want = Math.sign(dist) * Math.min(0.45, Math.abs(dist) * 0.12 + 0.04)
305 lift.v += (want - lift.v) * 0.2
306 lift.y += lift.v
307 if (Math.sign(ty - lift.y) !== Math.sign(dist)) lift.y = ty
308
309 return
310 }
311 if (lift.state === 'opening') { lift.door = Math.min(1, lift.door + 0.08); if (lift.door === 1) { lift.state = 'open'; lift.dwell = 0 } return }
312 if (lift.dwell++ === 4) {
313 lift.riders.filter(r => r.liftTo === lift.floor).forEach((r, i) => {
314 r.inLift = false
315 r.awaitLift = false
316 r.waitX = undefined
317 r.floor = lift.floor
318 r.x = w.layout.shaftW + 3 + i * 10
319 r.v = 0
320 })
321 lift.riders = lift.riders.filter(r => r.inLift)
322 for (const c of waiting().filter(c => c.floor === lift.floor)) {
323 if (lift.riders.length >= LIFT_CAP) break
324 c.inLift = true
325 c.waitX = undefined
326 lift.riders.push(c)
327 }
328 }
329 if (lift.dwell > 14) lift.state = 'idle'
330}
331
332// ---------- background crabs ----------
333
334function idle(w: World, c: Crab): void {
335 if (c.plan.length || c.inLift || c.awaitLift) return
336 if (c.goHomeAt !== undefined && w.tick > c.goHomeAt) { leave(w, c); return }
337 if (c.wait > 0) { c.wait--; return }
338 c.sleep = false
339 const roll = w.rng.next()
340 const floors = w.layout.floors.length
341 const at = (s: StationName) => station(w, s)
342 if (roll < 0.26) {
343 const f = floors > 1 && w.rng.next() < 0.3 ? 1 + Math.floor(w.rng.range(0, floors - 1)) : c.floor
344 goTo(w, c, f, w.rng.range(...bounds(w)))
345 c.wait = 20
346 } else if (roll < 0.52) {
347 const mate = w.crabs.find(o => o !== c && o.role === 'decor' && o.floor === c.floor && !o.plan.length && !o.inLift && !o.awaitLift && Math.abs(o.x - c.x) < 30 && Math.abs(o.x - c.x) >= GAP)
348 if (!mate) { c.wait = 30; return }
349 const lines = w.rng.pick(LINES)
350 c.dir = mate.x > c.x ? 1 : -1
351 mate.dir = c.dir === 1 ? -1 : 1
352 mate.sleep = false
353 lines.forEach((line, i) => later(w, i * 22, () => say(w, i % 2 ? mate : c, line, 24)))
354 c.wait = 30 + lines.length * 22
355 mate.wait = c.wait
356 if (lines[1] === 'FIGHT ME' || w.rng.next() < 0.08) { c.fight = mate; c.fightUntil = w.tick + 40 + lines.length * 22 }
357 } else if (roll < 0.64 && at('couch')) {
358 const s = at('couch')
359 if (s) { goTo(w, c, s.floor, s.x); c.arrive = () => { c.sleep = true; c.wait = 120; say(w, c, 'zzz', 40) } }
360 } else if (roll < 0.74 && at('coffee')) {
361 const s = at('coffee')
362 if (s) goTo(w, c, s.floor, s.x)
363 c.arrive = () => { if (!c.acc.includes('mug')) c.acc = [...c.acc, 'mug']; say(w, c, w.rng.pick(['ahh coffee', 'refill!', '*sip*'])); c.wait = 40 }
364 } else if (roll < 0.82 && at('tv')) {
365 const s = at('tv')
366 if (s) goTo(w, c, s.floor, s.x)
367 c.arrive = () => { c.dir = -1; say(w, c, w.rng.pick(['ooh a show', 'whats on?', 'turn it up'])); c.wait = 80 }
368 } else if (roll < 0.9 && at('books')) {
369 const s = at('books')
370 if (s) goTo(w, c, s.floor, s.x)
371 c.arrive = () => { if (!c.acc.includes('book')) c.acc = [...c.acc, 'book']; say(w, c, w.rng.pick(['good read', 'hmm, docs', 'chapter 3'])); c.wait = 70 }
372 } else c.wait = Math.floor(w.rng.range(30, 80))
373}
374
375/** Sends a crab out through the street door; it is gone when it gets there. */
376export function leave(w: World, c: Crab): void {
377 c.role = 'leaving'
378 c.tag = undefined
379 c.sleep = false
380 c.fight = undefined
381 say(w, c, w.rng.pick(['bye all!', 'heading home', 'see ya']))
382 goTo(w, c, bottom(w), w.layout.street.doorX, true)
383 c.arrive = () => {
384 const i = w.crabs.indexOf(c)
385 if (i >= 0) w.crabs.splice(i, 1)
386 }
387}
388
389function keepTidy(w: World): void {
390 const decor = w.crabs.filter(o => o.role === 'decor')
391 if (decor.length > DECOR_CAP) {
392 const oldest = decor.find(o => o.goHomeAt === undefined) ?? decor[0]
393 if (oldest) leave(w, oldest)
394 }
395}
396
397// ---------- real crabs ----------
398
399function deskRoom(w: World, role: RealSpec['role']): Room | undefined {
400 if (role === 'agent') return roomNamed(w, 'Agent workshop')
401 if (role === 'session') return roomNamed(w, 'Sessions')
402
403 return roomNamed(w, 'Team room') ?? roomNamed(w, 'Agent workshop')
404}
405
406function freeDesk(w: World, room: Room, role: RealSpec['role']): number | undefined {
407 if (role === 'teammate') {
408 const spot = room.spots.find(x => !w.crabs.some(c => c.role === 'teammate' && c.deskX === x))
409
410 return spot
411 }
412
413 return room.desks.find(x => !w.crabs.some(c => c.deskX === x && c.floor !== undefined && (c.role === role) && c.tFloor !== -1))
414}
415
416/** A real crab arrives through the street door to its desk; with no desk free it waits on the street. */
417export function spawnReal(w: World, spec: RealSpec): Crab | undefined {
418 if (w.crabs.some(c => c.realId === spec.realId) || w.street.waiting.some(s => s.realId === spec.realId)) return undefined
419 const room = deskRoom(w, spec.role)
420 if (!room) return undefined
421 const deskX = freeDesk(w, room, spec.role)
422 if (deskX === undefined) { w.street.waiting.push(spec); return undefined }
423 const c = makeCrab(w, {
424 role: spec.role, realId: spec.realId, tag: spec.tag, acc: [...spec.acc], floor: bottom(w), dir: -1, deskX, state: 'arriving',
425 tagColor: spec.role === 'agent' ? '#9FC4E8' : spec.role === 'session' ? '#C9B6F2' : '#E89A9A',
426 })
427 c.x = w.layout.street.doorX
428 goTo(w, c, room.floor, deskX, true)
429 c.arrive = () => { c.state = 'work'; c.dir = 1; say(w, c, spec.role === 'session' ? 'hello, new session' : 'on it!') }
430
431 return c
432}
433
434// Seats the next real crab waiting on the street when a desk frees up.
435function seatWaiting(w: World): void {
436 for (const spec of [...w.street.waiting]) {
437 const room = deskRoom(w, spec.role)
438 if (!room || freeDesk(w, room, spec.role) === undefined) continue
439 w.street.waiting.splice(w.street.waiting.indexOf(spec), 1)
440 spawnReal(w, spec)
441 }
442}
443
444/** A subagent finished: it reports to main, retires to the break room, and later goes home. */
445export function retireReal(w: World, realId: string, outcome: 'done' | 'failed'): void {
446 const queued = w.street.waiting.findIndex(s => s.realId === realId)
447 if (queued >= 0) { w.street.waiting.splice(queued, 1); return }
448 const c = w.crabs.find(o => o.realId === realId && o.role !== 'leaving' && o.state !== 'reporting' && o.state !== 'retired')
449 if (!c) return
450 const failed = outcome === 'failed'
451 c.state = 'reporting'
452 c.deskX = undefined
453 say(w, c, failed ? 'ow... it broke' : 'done!')
454 c.acc = [...c.acc, failed ? 'bandage' : 'report']
455 goTo(w, c, w.main.floor, w.main.x + 11)
456 c.arrive = () => {
457 c.dir = w.main.x < c.x ? -1 : 1
458 say(w, c, failed ? 'it crashed, sorry' : 'heres my report', 35)
459 later(w, 25, () => say(w, w.main, failed ? 'no worries, rest up' : w.rng.pick(['thanks!', 'nice work', 'perfect'])))
460 later(w, 50, () => {
461 c.acc = c.acc.filter(a => a !== 'report')
462 c.role = 'decor'
463 c.state = 'retired'
464 c.tag = `${c.tag ?? 'agent'} (done)`
465 c.tagColor = '#888888'
466 c.tagUntil = w.tick + 150
467 c.goHomeAt = w.tick + Math.floor(w.rng.range(500, 800))
468 const rest = station(w, 'couch') ?? { floor: bottom(w), x: w.layout.width / 2 }
469 goTo(w, c, rest.floor, rest.x + w.rng.range(0, 20))
470 c.arrive = () => { c.wait = 40; say(w, c, w.rng.pick(['break time!', 'phew', 'coffee?'])) }
471 keepTidy(w)
472 })
473 }
474}
475
476/** A session closed, or a real crab is gone for good: it heads home. */
477export function removeReal(w: World, realId: string): void {
478 const queued = w.street.waiting.findIndex(s => s.realId === realId)
479 if (queued >= 0) w.street.waiting.splice(queued, 1)
480 const c = w.crabs.find(o => o.realId === realId && o.role !== 'leaving')
481 if (c) { c.deskX = undefined; leave(w, c) }
482}
483
484/** A session's crab types while busy and dozes while idle. */
485export function setSessionIdle(w: World, realId: string, isIdle: boolean): void {
486 const c = w.crabs.find(o => o.realId === realId && o.role === 'session')
487 if (c) c.idle = isIdle
488}
489
490function runReal(w: World, c: Crab): void {
491 if (c.state !== 'work' || c.plan.length) return
492 c.moving = false
493 if (c.role === 'session') {
494 c.sleep = !!c.idle
495 if (c.idle && w.tick % 50 === 0) say(w, c, 'z z', 20)
496 }
497}
498
499/** The panel was resized: the town is laid out again, every real crab kept. */
500export function relayout(w: World, layout: Layout): void {
501 w.layout = layout
502 const real = w.crabs.filter(c => c.realId && c.role !== 'leaving' && c.state !== 'retired')
503 // Background crabs, including any parked by a smaller layout: a small one parks them all.
504 const keep = [...w.crabs.filter(c => c.role === 'decor'), ...w.parked]
505 w.parked = layout.size === 'small' ? keep : keep.slice(DECOR_CAP)
506 w.crabs = [w.main, ...(w.librarian ? [w.librarian] : [])]
507 w.lift = { floor: 0, y: 0, door: 1, riders: [], target: null, dwell: 0, v: 0, state: 'idle' }
508 w.bubbles = []
509 for (const c of [w.main, ...(w.librarian ? [w.librarian] : [])]) { c.plan = []; c.tx = undefined; c.awaitLift = false; c.inLift = false }
510 if (w.librarian && !roomNamed(w, 'Library') && !roomNamed(w, 'Break room')) w.crabs = w.crabs.filter(c => c !== w.librarian)
511 placeAll(w)
512 for (const c of keep.slice(0, layout.size === 'small' ? 0 : DECOR_CAP)) {
513 Object.assign(c, { plan: [], tx: undefined, tFloor: undefined, awaitLift: false, inLift: false, waitX: undefined, fight: undefined, arrive: undefined })
514 c.floor = Math.min(c.floor, bottom(w))
515 w.crabs.push(c)
516 c.x = freeSpot(w, c, c.floor, c.x)
517 }
518 const waiting = [...w.street.waiting]
519 w.street.waiting = []
520 for (const c of real) {
521 const spec: RealSpec = { role: c.role as RealSpec['role'], realId: c.realId ?? '', tag: c.tag ?? '', acc: c.acc }
522 const room = deskRoom(w, spec.role)
523 const deskX = room ? freeDesk(w, room, spec.role) : undefined
524 if (!room || deskX === undefined) { w.street.waiting.push(spec); continue }
525 Object.assign(c, { plan: [], tx: undefined, tFloor: undefined, awaitLift: false, inLift: false, waitX: undefined, arrive: undefined, floor: room.floor, x: deskX, deskX, state: 'work' })
526 w.crabs.push(c)
527 }
528 w.street.waiting.push(...waiting)
529 topUpDecor(w)
530}
531
532// ---------- the loop ----------
533
534/** One 50 ms frame: motion every frame, logic every second one. */
535export function frame(w: World): void {
536 w.frameNo++
537 if (w.frameNo % 2 === 0) {
538 w.tick++
539 const due = w.timers.filter(t => t.at <= w.tick)
540 w.timers = w.timers.filter(t => t.at > w.tick)
541 for (const t of due) t.fn()
542 if (w.tick % 5 === 0) { unclump(w); seatWaiting(w) }
543 for (const c of [...w.crabs]) {
544 if (c.role === 'decor') idle(w, c)
545 else if (c.role === 'agent' || c.role === 'session' || c.role === 'teammate') runReal(w, c)
546 }
547 w.bubbles = w.bubbles.filter(b => b.until > w.tick && w.crabs.includes(b.crab))
548 w.dust = w.dust.filter(d => d.until > w.tick)
549 w.parts = w.parts.filter(p => p.until > w.tick)
550 }
551 for (const c of [...w.crabs]) move(w, c)
552 if (w.layout.shaftW > 0) stepLift(w)
553}
554hooks/office/sprites.ts 147 lines1// The office's crabs and their accessories, painted pixel by pixel onto a
2// canvas: Claude Code's classic 8-bit crab (9 x 6, small eyes, arms out,
3// four legs, no blush or mouth) with hats and props that say who it is or
4// what it is doing. Ported from docs/mockup/crab-town-v5.html.
5
6/** Rows of pixels, `undefined` clear. */
7export type Pixels = (string | undefined)[][]
8
9export const CRAB = { body: '#D97757', shade: '#B9583B', eye: '#1A1412', w: 9, h: 6 } as const
10
11export function blank(width: number, height: number): Pixels {
12 return Array.from({ length: height }, () => Array<string | undefined>(width).fill(undefined))
13}
14
15/** Paints a box onto the canvas, clipped; fractional sizes paint at least a pixel. */
16export function paint(canvas: Pixels, x: number, y: number, w: number, h: number, c: string | undefined): void {
17 const x0 = Math.round(x)
18 const y0 = Math.round(y)
19 const x1 = x0 + Math.max(1, Math.round(w))
20 const y1 = y0 + Math.max(1, Math.round(h))
21 for (let j = Math.max(0, y0); j < y1; j++) {
22 const row = canvas[j]
23 if (!row) break
24 for (let i = Math.max(0, x0); i < Math.min(x1, row.length); i++) row[i] = c
25 }
26}
27
28/** Copies `sprite` onto `canvas` at (x, y), its clear pixels left alone. */
29export function stamp(canvas: Pixels, sprite: Pixels, x: number, y: number): void {
30 sprite.forEach((row, j) =>
31 row.forEach((c, i) => {
32 const target = canvas[y + j]
33 if (c !== undefined && target && x + i >= 0 && x + i < target.length) target[x + i] = c
34 }),
35 )
36}
37
38const BODY = ['.OOOOOOO.', '.OEOOOEO.', 'OOOOOOOOO', '.OOOOOOS.']
39const LEGS = [
40 ['.O.O.O.O.', '.O.O.O.O.'],
41 ['.O.O.O.O.', '..O.O.O.O'],
42 ['.O.O.O.O.', '.O.O.O.O.'],
43 ['.O.O.O.O.', 'O.O.O.O..'],
44]
45
46export type Accessory =
47 | 'crown' | 'miner' | 'plan' | 'hard' | 'detective' | 'wizard' | 'teamcap' | 'librarian' | 'headphones' | 'scarf'
48 | 'beanie' | 'party' | 'bandage' | 'shoes' | 'mug' | 'book' | 'rulebook' | 'dictionary' | 'report' | 'letter'
49 | 'capsule' | 'newspaper' | 'notebook' | 'clipboard' | 'magnifier' | 'binoculars' | 'toolbox' | 'wrench' | 'hammer'
50 | 'megaphone' | 'alarmclock' | 'phone' | 'spellbook' | 'blueprint' | 'map' | 'sign' | 'stop' | 'bell' | 'stampOk'
51 | 'stampNo' | 'broom' | 'parcel' | 'box' | 'juggle' | 'wave' | 'think' | 'eyes'
52
53type Draw = (x: number, y: number, t: number, d: 1 | -1, crown: string) => void
54
55// Each accessory drawn relative to the body's top-left.
56function accessories(canvas: Pixels): Record<Accessory, Draw> {
57 const rect = (x: number, y: number, w: number, h: number, c: string) => paint(canvas, x, y, w, h, c)
58 const px = (x: number, y: number, c: string) => paint(canvas, x, y, 1, 1, c)
59 const hand = (x: number, d: number) => (d > 0 ? x + 9 : x - 2)
60 const held = (fill: (bx: number, y: number, t: number, d: 1 | -1) => void): Draw => (x, y, t, d) => fill(hand(x, d), y, t, d)
61 const OR = CRAB.body
62
63 return {
64 crown: (x, y, _t, _d, crown) => { rect(x + 2, y - 2, 5, 2, crown); px(x + 2, y - 3, crown); px(x + 4, y - 3, crown); px(x + 6, y - 3, crown); px(x + 4, y - 1, '#D9534F') },
65 miner: (x, y, _t, d) => { rect(x + 1, y - 2, 7, 2, '#7A4A2A'); rect(x, y - 1, 9, 1, '#5C3720'); rect(d > 0 ? x + 6 : x + 1, y - 2, 2, 1, '#FFF3B0') },
66 plan: (x, y, t) => { rect(x + 1, y - 2, 7, 2, '#2E9E6A'); rect(x + 3, y - 2, 3, 1, '#F2F2F2'); rect(x + 9, y - 6, 1, 7, '#AAAAAA'); rect(x + 10, y - 6 + (Math.floor(t / 5) % 2), 3, 2, '#FFFFFF') },
67 hard: (x, y) => { rect(x + 1, y - 2, 7, 2, '#E8B83A'); rect(x, y - 1, 9, 1, '#C9971F'); px(x + 4, y - 2, '#F6D77A') },
68 detective: (x, y) => { rect(x + 1, y - 2, 7, 2, '#6B5440'); rect(x, y - 1, 9, 1, '#4B3A2C'); rect(x + 2, y - 2, 5, 1, '#3A2C20') },
69 wizard: (x, y) => { rect(x + 2, y - 2, 5, 2, '#5B3FA0'); rect(x + 3, y - 4, 3, 2, '#5B3FA0'); px(x + 4, y - 5, '#5B3FA0'); px(x + 3, y - 2, '#E8C15A') },
70 teamcap: (x, y) => { rect(x + 1, y - 2, 7, 2, '#D9534F'); rect(x + 6, y - 1, 3, 1, '#B03A36') },
71 librarian: (x, y) => { rect(x + 2, y - 2, 5, 2, '#6B3A5A'); px(x + 4, y - 3, '#6B3A5A'); rect(x + 1, y + 1, 3, 1, '#2A211D'); rect(x + 5, y + 1, 3, 1, '#2A211D') },
72 headphones: (x, y) => { rect(x + 1, y - 1, 7, 1, '#2A2A2A'); rect(x, y, 1, 2, '#D97757'); rect(x + 8, y, 1, 2, '#D97757') },
73 scarf: (x, y) => { rect(x + 1, y + 2, 7, 1, '#3E6AA8'); rect(x + 6, y + 3, 1, 2, '#3E6AA8') },
74 beanie: (x, y) => { rect(x + 1, y - 2, 7, 2, '#2E9E6A'); px(x + 4, y - 3, '#E8C15A') },
75 party: (x, y) => { rect(x + 3, y - 2, 3, 2, '#E85A9A'); px(x + 4, y - 3, '#E85A9A'); px(x + 4, y - 4, '#E8C15A') },
76 bandage: (x, y) => { rect(x + 1, y - 1, 7, 1, '#F5F5F5'); px(x + 6, y - 1, '#D9534F') },
77 shoes: (x, y) => { rect(x, y + 5, 3, 1, '#E8C15A'); rect(x + 6, y + 5, 3, 1, '#E8C15A') },
78 mug: held((bx, y, t) => { rect(bx, y + 1, 2, 2, '#F5F5F5'); if (Math.floor(t / 5) % 2) px(bx, y - 1, '#888888') }),
79 book: held((bx, y) => { rect(bx, y, 2, 3, '#3E6AA8'); px(bx, y + 1, '#E8C15A') }),
80 rulebook: held((bx, y) => { rect(bx, y, 2, 3, '#B03A36'); px(bx, y + 1, '#F5EEDC') }),
81 dictionary: held((bx, y) => { rect(bx, y, 3, 3, '#777777'); px(bx + 1, y + 1, '#E8C15A') }),
82 report: held((bx, y) => { rect(bx, y, 2, 3, '#F5EEDC'); px(bx, y + 1, '#888888') }),
83 letter: held((bx, y) => { rect(bx, y, 3, 2, '#F5EEDC'); px(bx + 1, y, '#D97757') }),
84 capsule: held((bx, y) => { rect(bx, y, 2, 3, '#E8C15A'); px(bx, y + 1, '#B08D57') }),
85 newspaper: held((bx, y) => { rect(bx, y, 3, 2, '#DDDDDD'); rect(bx, y, 3, 1, '#555555') }),
86 notebook: held((bx, y, t) => { rect(bx, y, 2, 3, '#2E9E6A'); px(bx + (t % 2), y + 1, '#F5EEDC') }),
87 clipboard: held((bx, y) => { rect(bx, y, 2, 3, '#B08D57'); rect(bx, y + 1, 2, 1, '#F5EEDC') }),
88 magnifier: held((bx, y, _t, d) => { rect(bx, y - 1, 2, 2, '#9FD3E8'); px(bx + (d > 0 ? -1 : 2), y + 1, '#6B5440') }),
89 binoculars: held((bx, y) => { rect(bx, y, 1, 2, '#333333'); rect(bx + 1, y, 1, 2, '#333333') }),
90 toolbox: held((bx, y) => { rect(bx - 1, y + 1, 4, 2, '#D9534F'); rect(bx, y, 2, 1, '#888888') }),
91 wrench: held((bx, y) => { rect(bx, y, 1, 3, '#AAAAAA'); px(bx + 1, y, '#AAAAAA') }),
92 hammer: held((bx, y, t) => { rect(bx, y, 1, 3, '#8A6A4A'); rect(bx - 1, y - (t % 4 < 2 ? 1 : 0), 3, 1, '#777777') }),
93 megaphone: held((bx, y) => { rect(bx, y, 2, 2, '#E8C15A'); rect(bx + 2, y - 1, 1, 4, '#E8C15A') }),
94 alarmclock: held((bx, y, t) => { rect(bx, y, 3, 3, '#D9534F'); px(bx + 1, y + 1, '#FFFFFF'); if (t % 4 < 2) { px(bx - 1, y - 1, '#FFFFFF'); px(bx + 3, y - 1, '#FFFFFF') } }),
95 phone: (x, y, _t, d) => { rect(d > 0 ? x + 8 : x, y, 1, 3, '#D9534F') },
96 spellbook: held((bx, y, t) => { rect(bx, y, 3, 2, '#5B3FA0'); if (t % 3) px(bx + 1, y - 1 - (t % 2), '#E8C15A'); if (t % 4 < 2) px(bx + 2, y - 2, '#C9B6F2') }),
97 blueprint: (x, y) => { rect(x - 1, y + 1, 11, 3, '#3E6AA8'); rect(x, y + 2, 4, 1, '#9FD3E8'); rect(x + 5, y + 2, 3, 1, '#9FD3E8') },
98 map: (x, y) => { rect(x - 1, y + 1, 11, 3, '#E8D7A0'); rect(x + 1, y + 2, 3, 1, '#2E9E6A'); px(x + 7, y + 2, '#D9534F') },
99 sign: (x, y) => { rect(x + 3, y - 7, 1, 5, '#8A6A4A'); rect(x + 1, y - 9, 5, 3, '#F5EEDC'); px(x + 3, y - 8, '#D97757') },
100 stop: (x, y) => { rect(x + 3, y - 6, 1, 5, '#8A6A4A'); rect(x + 1, y - 10, 5, 4, '#D9534F'); rect(x + 2, y - 9, 3, 1, '#FFFFFF') },
101 bell: (x, y, t) => { rect(x + 9, y - 3, 1, 3, OR); rect(x + 9, y - 5, 2, 2, '#E8C15A'); if (t % 4 < 2) { px(x + 12, y - 6, '#FFFFFF'); px(x + 7, y - 6, '#FFFFFF') } },
102 stampOk: (x, y) => { rect(x + 3, y - 6, 3, 3, '#2E9E6A'); px(x + 4, y - 5, '#FFFFFF') },
103 stampNo: (x, y) => { rect(x + 3, y - 6, 3, 3, '#D9534F'); px(x + 3, y - 6, '#FFFFFF'); px(x + 5, y - 4, '#FFFFFF') },
104 broom: (x, y, t, d) => { const bx = d > 0 ? x + 9 : x - 1; rect(bx, y - 2, 1, 6, '#8A6A4A'); rect(bx - 1 + (t % 2), y + 4, 3, 1, '#E8C15A') },
105 parcel: held((bx, y) => { rect(bx, y, 3, 3, '#B08D57'); rect(bx, y + 1, 3, 1, '#8A6A3A') }),
106 box: held((bx, y) => { rect(bx - 1, y - 1, 4, 4, '#B08D57') }),
107 juggle: (x, y, t) => { for (let i = 0; i < 3; i++) { const a = t / 3 + i * 2.1; px(x + 4 + Math.cos(a) * 3, y - 3 + Math.sin(a) * 2, ['#D9534F', '#7FD1A8', '#E8C15A'][i] ?? '#FFFFFF') } },
108 wave: (x, y, t, d) => { if (Math.floor(t / 4) % 2) rect(d > 0 ? x + 9 : x - 1, y - 2, 1, 3, OR) },
109 think: (x, y, t) => { px(x + 8, y - 2, '#DDDDDD'); rect(x + 9, y - 5, 4, 2, '#DDDDDD'); px(x + 10 + (Math.floor(t / 4) % 2), y - 4, '#555555') },
110 eyes: (x, y, t) => { px(x + 2 + (Math.floor(t / 6) % 2), y + 1, '#FFFFFF') },
111 }
112}
113
114/** Every accessory's name. */
115export const ACCESSORIES = Object.keys(accessories(blank(1, 1))) as Accessory[]
116
117export type CrabPose = {
118 /** The 4-step leg cycle; 0 standing. */
119 legsPhase: 0 | 1 | 2 | 3
120 blink: boolean
121 dir: 1 | -1
122 acc: readonly Accessory[]
123 /** The animation clock, in ticks. */
124 t: number
125 /** Pixels up (negative) for a hop. */
126 hop?: number
127 /** The crown's colour, by model. */
128 crown?: string
129}
130
131/** Paints a crab with its body's top-left at (x, y), then its accessories. */
132export function drawCrab(canvas: Pixels, x: number, y: number, pose: CrabPose): void {
133 const top = y + (pose.hop ?? 0)
134 const rows = [...BODY, ...(LEGS[pose.legsPhase] ?? [])]
135 rows.forEach((row, j) => [...row].forEach((ch, i) => {
136 const ix = pose.dir < 0 ? CRAB.w - 1 - i : i
137 const c = ch === 'O' ? CRAB.body : ch === 'S' ? CRAB.shade : ch === 'E' ? (pose.blink ? CRAB.body : CRAB.eye) : undefined
138 if (c) paint(canvas, x + ix, top + j, 1, 1, c)
139 }))
140 if (pose.blink) {
141 paint(canvas, x + 1, top + 2, 1, 1, CRAB.eye)
142 paint(canvas, x + 7, top + 2, 1, 1, CRAB.eye)
143 }
144 const draw = accessories(canvas)
145 for (const a of pose.acc) draw[a](x, top, pose.t, pose.dir, pose.crown ?? '#E8C15A')
146}
147hooks/office/rng.ts 23 lines1// A small seeded random source (mulberry32), so a town replays the same way
2// from the same seed: what makes the simulation testable.
3
4export type Rng = { next: () => number; range: (lo: number, hi: number) => number; pick: <T>(list: readonly T[]) => T }
5
6export function createRng(seed: number): Rng {
7 let s = seed >>> 0
8 const next = () => {
9 s = (s + 0x6d2b79f5) >>> 0
10 let t = s
11 t = Math.imul(t ^ (t >>> 15), t | 1)
12 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
13
14 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
15 }
16
17 return {
18 next,
19 range: (lo, hi) => lo + next() * (hi - lo),
20 pick: list => list[Math.floor(next() * list.length)] as (typeof list)[number],
21 }
22}
23