Every Claude Code session as a pixel character in a shared office you can click and talk to

Run several Claude Code agents at once and see them all in one place: every session is a pixel-art character at a desk, grouped into one office room per repository. You can see who is working on what, who is waiting for you and who just finished, and you can talk to any of them with a click.
<video src="previews/demo.mp4" poster="previews/editor-panel-rooms.png" controls muted playsinline width="100%"></video>
![]()
| Rooms per repository | Sessions in the same repo (git worktrees included) share a room, named after the repo, with its branch, its own office scene and a roster. |
| Desk titles | Each desk shows what that agent is working on. Rename it by hand, or clear it to go back to automatic. |
| Plain-words activity | Bubbles say Running tests, Editing auth.ts, Committing. The exact command is one hover away. |
| Subagents | Subagents are kids on a play mat under their agent's desk: up to 10 shown, the rest as +N. |
| Needs you | A session waiting for a permission or an answer raises its hand; a finished one keeps a hand up until its next prompt. You get a notification with Jump, a chime, a status-bar badge and a "Needs you" queue (oldest first). Ctrl+Cmd+N goes to the next one. |
| Agent console | Click an agent to open it. You see what it's doing now, your task, its plan with progress, the files it changed and recent failures, then the conversation. Tool calls are folded into "N actions" strips with failures flagged; open them for input and output, or switch to Everything to open them all. End stops the session and takes it off the floor. |
| Talk to agents | Type in the console; it becomes that session's next prompt. |
| New agents | + in a room asks for a name and a task, then opens a Claude Code tab or terminal in that repo without taking you away from where you were. The new session takes the name and starts the task. The top-level + can also start a new room in any folder. |
| Full screen | Each office has a full-screen button that appears while the pointer moves; Esc exits. |
| Standup | Per room or for everyone: each agent reports Done / Next / Blocked from its own conversation, and they take turns presenting in the scene. |
| Collision radar | Warns when two live agents edited the same file, with a red line between their desks. |
| Insights | Per-agent git changes (+212 −40 · 6 files) and token use (new vs. cached). |
![]()
One office in full screen: desk titles (Auth fix), auth's two subagents on the play mat, the collision line between auth and ui-polish, and deploy with its hand up waiting for you.
![]()
each Claude Code session your editor (Cursor / VS Code)
┌──────────────────────────┐ ┌─────────────────────────────────┐
│ pixel-office (the mod) │ writes → │ Pixel Office (the extension) │
│ sees every tool call, │ ~/.claude/ │ rooms, console, alerts, jump, │
│ answers standups, types │ pixel- │ git + transcript insights, │
│ your messages into the │ office/ ← │ new agents │
│ session │ writes │ │
└──────────────────────────┘ └─────────────────────────────────┘
pixel-office/: a Claude Code mod (a plugin of function hooks). It runs inside every session, writes that session's presence, delivers messages to it, answers standups and claims new-agent requests. It also draws the office inside the session itself, in the terminal and the desktop app.extension/: the Cursor / VS Code extension. It reads and writes the same folder and adds what only an editor can: the canvas office, the console, native notifications, jumping to terminals, git and transcript insights, and starting new agents.Everything stays on your machine in ~/.claude/pixel-office/.
One paste: copy this into Claude Code and it sets everything up for you:
Set up Pixel Office for me (https://github.com/itsArnavPrasad/pixel-office).
1. Clone it into ~/pixel-office (or `git pull` there if it already exists).
2. Build the extension: cd ~/pixel-office/extension && npm install && npm run package
3. Install the built VSIX (extension/pixel-office-*.vsix) with `cursor --install-extension <file>`
if the `cursor` CLI exists, otherwise `code --install-extension <file>`. If neither CLI is on
my PATH, tell me to use Cmd+Shift+P → "Extensions: Install from VSIX…" and give me the path.
4. Tell me to reload the editor window and click **Enable** when Pixel Office asks (or run
"Pixel Office: Enable in All Claude Code Sessions"), then open the 🏢 view in the activity bar.
New Claude Code sessions will join the office.
If any step fails, show me the error and fix it before moving on.
Cursor / VS Code (by hand)
cd extension && npm install && npm run packageextension/pixel-office-0.2.0.vsix (or cursor --install-extension extension/pixel-office-0.2.0.vsix), then reload the window.~/.claude/pixel-office/mod and adds it to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (a backup is kept). New sessions join the office.Terminal only: claude --plugin-dir ./pixel-office, then /office in the session.
The mod draws the same office in the terminal (fullscreen layout), with chips, the needs-you band above the prompt, standups and a dialogue box.
![]()
Commands inside a session: /office · /office standup · /office name <name> · /office look [dev-1…dev-8] · /office alerts on|off · /office sound on|off.
./scripts/check.sh # the gate: everything below
claude plugin validate pixel-office # the mod's manifest and hooks
claude plugin test pixel-office # 98 mod tests (state machine, rooms, inbox, alerts, standup, scene, pane)
cd extension && npm test # 37 extension unit tests (transcript, git, setup, store over a real folder)
node --test test/smoke.test.cjs # the built extension against a fake vscode
node test/webview-harness.mjs test/out # the webview in headless Chrome, screenshots included
node scripts/terminal-preview.mjs previews # terminal renders from the real cell output
The README screenshots come from those last two: previews/editor-*.png are copies of the harness's test/out/panel-*.png, sidebar.png and fullscreen.png. previews/terminal-*.png are written in place.
| Standup for a room: Done / Next / Blocked | The activity-bar mini office |
| The console's Everything view, every tool call open | A standup in the terminal |
| Talking to another agent from the terminal |
hooks/register.tsx 697 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderPropsOf } from 'claude-code'
3
4import type { AgentRecord, LogLine, Standup, StandupAnswer } from '../types'
5import { AWAY_MS, byUrgency, newAgent, PRUNE_MS, reduce, type Signal } from './core/agent'
6import { alertLeader, alertsDue, formatWait, notifyArgv, waitingQueue } from './core/alerts'
7import { toRasterCells, toSvg } from './core/encode'
8import { DELIVERED_KEEP, asPrompt, makeMessage, messageId, parseMessage, pendingFiles } from './core/inbox'
9import { assignDesks, hash, mergeRoster, parseRecord, type Seat } from './core/roster'
10import { drawOffice, layout, type Spotlight } from './core/scene'
11import { LOOK_IDS } from './core/sprites'
12import {
13 ANSWER_WINDOW_MS, KEEP_MS, PENDING_MS, SHOW_MS, STANDUP_PROMPT, headline, latestRequest, makeRequest, parseAnswer,
14 parseReport, parseRequest, presenter, digest,
15} from './core/standup'
16import { basename, bubble, clean, truncate } from './core/text'
17import { parseTicket, roomOf, ticketsFor, uniqueName } from './core/rooms'
18
19type $ = EngineInterface
20
21const PANE = 'pixel-office'
22const me = atom({ plugin: 'pixel-office', key: 'me' } as const, null)
23const roster = atom({ plugin: 'pixel-office', key: 'roster' } as const, [])
24const selected = atom({ plugin: 'pixel-office', key: 'selected' } as const, null)
25const logs = atom({ plugin: 'pixel-office', key: 'logs' } as const, {})
26const alerted = atom({ plugin: 'pixel-office', key: 'alerted' } as const, {})
27const standup = atom({ plugin: 'pixel-office', key: 'standup' } as const, null)
28
29const HEARTBEAT_MS = 2000
30const POLL_MS = 1000
31const FRAME_MS = 125
32const SVG_FRAME_MS = 400
33const LOG_KEEP = 30
34const MAX_AGENTS = 64
35const GC_EVERY = 60 // polls
36const CHIME = 'sounds/chime.wav'
37const UI_FRESH_MS = 10_000 // an editor window heartbeating this recently does the chime + notification
38
39const ICON: Record<AgentRecord['state'], string> = {
40 arriving: '🚶', idle: '○', thinking: '💭', typing: '⌨', reading: '📖', writing: '✎', browsing: '🌐',
41 delegating: '👥', working: '●', 'needs-you': '❗', stressed: '💦', done: '✓', leaving: '👋',
42}
43
44// Module state: rebuilt on every (re)load, which also drops the old timers.
45const rt = {
46 root: '',
47 myId: '',
48 lastStatus: '',
49 lastWritten: '',
50 mounted: null as { w: number; h: number; n: number } | null,
51 blitting: null as { cancel: () => void } | null,
52 svgTicking: null as { cancel: () => void } | null,
53 isDelivering: false,
54 isAnswering: false,
55 claimedStandup: '',
56 cwd: '',
57 isAlertsOn: true,
58 isSoundOn: true,
59 polls: 0,
60}
61
62const agentsDir = () => `${rt.root}/agents`
63const inboxDir = (id: string) => `${rt.root}/inbox/${id}`
64const standupDir = () => `${rt.root}/standup`
65const uiDir = () => `${rt.root}/ui`
66const spawnDir = () => `${rt.root}/spawn`
67const titlesDir = () => `${rt.root}/titles`
68
69async function log($: $, id: string, who: string, text: string) {
70 const line: LogLine = { at: await $.clock.now(), who, text: truncate(clean(text), 500) }
71 if (!line.text) return
72 await update($, logs, all => {
73 const list = all[id] ?? []
74 if (list.at(-1)?.text === line.text && list.at(-1)?.who === who) return all
75 return { ...all, [id]: [...list, line].slice(-LOG_KEEP) }
76 })
77}
78
79async function signal($: $, s: Signal) {
80 if (!rt.myId) return
81 const now = await $.clock.now()
82 const id = rt.myId
83 await update($, me, a => reduce(a ?? newAgent(id, now), s, now))
84 await save($)
85}
86
87async function save($: $) {
88 const rec = await read($, me)
89 if (!rec || !rt.root) return
90 const body = JSON.stringify(rec)
91 if (body === rt.lastWritten) return
92 rt.lastWritten = body
93 await $.fs.write(`${agentsDir()}/${rec.id}.json`, body).catch(err => $.ui.log(`pixel-office: write failed: ${err}`))
94}
95
96async function poll($: $) {
97 const now = await $.clock.now()
98 const entries = await $.fs.list(agentsDir()).catch(() => [])
99 const files = entries.filter(f => f.kind === 'file' && f.name.endsWith('.json') && f.name !== `${rt.myId}.json`).slice(0, MAX_AGENTS)
100 const others: AgentRecord[] = []
101 const stale: string[] = []
102 for (const f of files) {
103 if (now - f.mtimeMs > PRUNE_MS) {
104 stale.push(`${agentsDir()}/${f.name}`)
105 continue
106 }
107 const rec = parseRecord(await $.fs.read(`${agentsDir()}/${f.name}`).catch(() => ''))
108 if (rec && rec.id === f.name.slice(0, -5)) others.push(rec)
109 }
110 if (stale.length) void $.process.run(['rm', '-f', '--', ...stale]).catch(() => {})
111
112 const before = new Map((await read($, roster)).map(r => [r.id, r]))
113 for (const r of others) {
114 const old = before.get(r.id)
115 if (r.lastLine && r.lastLine !== old?.lastLine) await log($, r.id, r.name, r.lastLine)
116 else if (r.bubble && r.bubble !== old?.bubble && r.state !== 'idle') await log($, r.id, r.name, `${ICON[r.state]} ${r.bubble}`)
117 }
118 const changed = others.length !== before.size || others.some(r => JSON.stringify(r) !== JSON.stringify(before.get(r.id)))
119 if (changed) await update($, roster, () => others)
120
121 const seats = mergeRoster(others, await read($, me), now)
122 const waiting = seats.filter(s => s.state === 'needs-you' && !s.isMe).length
123 const status = seats.length > 1 ? `🏢 ${seats.length} in the office${waiting ? ` · ❗ ${waiting} need${waiting === 1 ? 's' : ''} you` : ''}` : ''
124 if (status !== rt.lastStatus) $.ui.status((rt.lastStatus = status) || undefined)
125
126 await alert($, seats, now)
127 await retitle($)
128 await deliver($)
129 await pollStandup($, now)
130 if (++rt.polls % GC_EVERY === 0) await sweep($, now)
131}
132
133/** A desk title set by hand in an editor (titles/<id>.txt; gone = back to the automatic one). */
134async function retitle($: $) {
135 if (!rt.myId) return
136 const text = truncate(clean(await $.fs.read(`${titlesDir()}/${rt.myId}.txt`).catch(() => '')), 80)
137 if (text !== ((await read($, me))?.title ?? '')) await signal($, { kind: 'title', text })
138}
139
140/** Messages addressed to this session, oldest first, each delivered once. */
141async function deliver($: $) {
142 if (rt.isDelivering || !rt.myId) return
143 rt.isDelivering = true
144 try {
145 const dir = inboxDir(rt.myId)
146 const names = (await $.fs.list(dir).catch(() => [])).map(f => f.name)
147 if (!names.length) return
148 const key = `delivered:${rt.myId}`
149 const saved = await $.store.get(key)
150 const delivered = Array.isArray(saved) ? saved.filter((x): x is string => typeof x === 'string') : []
151 for (const name of pendingFiles(names, delivered)) {
152 const m = parseMessage(await $.fs.read(`${dir}/${name}`).catch(() => ''))
153 delivered.push(name.slice(0, -5))
154 await $.store.set(key, delivered.slice(-DELIVERED_KEEP))
155 void $.process.run(['rm', '-f', '--', `${dir}/${name}`]).catch(() => {})
156 if (!m) {
157 $.ui.log(`pixel-office: skipped a malformed message ${name}`)
158 continue
159 }
160 await log($, rt.myId, m.from === 'you' ? 'you' : m.fromName, m.text)
161 $.ui.toast(`📨 Message for this session${m.from === 'you' ? '' : ` from ${m.fromName}`}`)
162 // your own words read bare; another session's keep Claude Code's plugin frame
163 void $.prompt.submit(m.from === 'you' ? { text: asPrompt(m), asUser: true } : { text: asPrompt(m) })
164 }
165 } finally {
166 rt.isDelivering = false
167 }
168}
169
170/** Toasts in every session; chime and OS notification from the alert leader only. */
171async function alert($: $, seats: Seat[], now: number) {
172 const due = alertsDue(waitingQueue(seats), await read($, alerted), now)
173 if (JSON.stringify(due.memory) !== JSON.stringify(await read($, alerted))) await update($, alerted, () => due.memory)
174 if (!rt.isAlertsOn || (!due.fresh.length && !due.remind.length)) return
175 const isLeader = alertLeader(seats) === rt.myId && !(await isEditorWatching($, now))
176 for (const s of due.fresh) {
177 if (!s.isMe) $.ui.toast(`❗ ${s.name} needs you: ${s.bubble}`)
178 if (isLeader) void $.process.run(notifyArgv(`❗ ${s.name} needs you`, s.bubble || 'Waiting for you')).catch(() => {})
179 }
180 for (const s of due.remind) {
181 const waited = formatWait(now - s.since)
182 if (!s.isMe) $.ui.toast(`❗ ${s.name} is still waiting (${waited})`)
183 if (isLeader) void $.process.run(notifyArgv(`❗ ${s.name} still waiting · ${waited}`, s.bubble || 'Waiting for you')).catch(() => {})
184 }
185 if (isLeader && rt.isSoundOn) void $.audio.play({ asset: CHIME }).catch(() => {})
186}
187
188/**
189 * Whether a Pixel Office editor window has this covered: one that is focused and shows
190 * notifications. A background or minimised window would not be seen, so the mod still rings.
191 */
192async function isEditorWatching($: $, now: number): Promise<boolean> {
193 for (const f of await $.fs.list(uiDir()).catch(() => [])) {
194 if (f.kind !== 'file' || !f.name.endsWith('.json') || now - f.mtimeMs >= UI_FRESH_MS) continue
195 try {
196 const peer = JSON.parse(await $.fs.read(`${uiDir()}/${f.name}`)) as { isFocused?: unknown; isNotifying?: unknown }
197 if (peer.isFocused === true && peer.isNotifying === true) return true
198 } catch {}
199 }
200 return false
201}
202
203async function openOn($: $, id: string) {
204 await update($, selected, () => id)
205 await $.ui.open({ id: PANE, title: 'Pixel Office' })
206}
207
208async function requestStandup($: $) {
209 const now = await $.clock.now()
210 const id = messageId(now, Math.random())
211 const by = (await read($, me))?.name ?? 'someone'
212 await $.fs.write(`${standupDir()}/${id}.json`, JSON.stringify(makeRequest(id, by, now)))
213 $.ui.toast('🗣 Standup! Everyone is reporting in…')
214 await pollStandup($, now)
215}
216
217/** Loads the latest standup into state, and answers it once if it is fresh. */
218async function pollStandup($: $, now: number) {
219 const names = (await $.fs.list(standupDir()).catch(() => [])).map(f => f.name)
220 const id = latestRequest(names, now, SHOW_MS)
221 const shown = await read($, standup)
222 if (!id) {
223 if (shown) await update($, standup, () => null)
224 return
225 }
226 const request = parseRequest(await $.fs.read(`${standupDir()}/${id}.json`).catch(() => ''))
227 if (!request) return
228 const answers: StandupAnswer[] = []
229 for (const f of await $.fs.list(`${standupDir()}/${id}`).catch(() => [])) {
230 const a = parseAnswer(await $.fs.read(`${standupDir()}/${id}/${f.name}`).catch(() => ''))
231 if (a && `${a.id}.json` === f.name) answers.push(a)
232 }
233 const next = { request, answers: answers.sort((a, b) => a.at - b.at) }
234 if (JSON.stringify(next) !== JSON.stringify(shown)) await update($, standup, () => next)
235
236 const key = `standup:${rt.myId}`
237 const room = (await read($, me))?.room
238 if (request.room && request.room !== room) return // another room's standup
239 if (rt.isAnswering || rt.claimedStandup === id || now - request.requestedAt > ANSWER_WINDOW_MS) return
240 rt.claimedStandup = id // claimed before any await, so two polls in flight never both fork
241 if ((await $.store.get(key)) === id) return
242 await $.store.set(key, id)
243 rt.isAnswering = true
244 void answerStandup($, id).finally(() => (rt.isAnswering = false))
245}
246
247async function answerStandup($: $, requestId: string) {
248 let r = await $.model.fork({ prompt: STANDUP_PROMPT }).catch(() => null)
249 let isFresh = false
250 if (r && !r.isAnswered && r.reason === 'nothing-to-fork') {
251 // a resumed session has history but nothing to fork until its next turn: summarise what it holds
252 const history = await $.session.messages().catch(() => [])
253 const text = Array.isArray(history) ? digest(history) : ''
254 isFresh = !text
255 if (text) {
256 const prompt = `${STANDUP_PROMPT}\n\nThe conversation so far:\n${text}`
257 r = await $.model.complete({ model: await $.session.model(), prompt, maxTokens: 300 }).catch(() => null)
258 }
259 }
260 const report =
261 isFresh ? { done: 'Just got here, no work yet.', next: '', blocked: '' }
262 : r && r.isAnswered ? parseReport(r.text)
263 : { done: "(couldn't report)", next: '', blocked: '' }
264 const self = await read($, me)
265 const answer: StandupAnswer = {
266 v: 1, id: rt.myId, name: self?.name ?? 'claude', character: self?.character ?? 'dev-1', ...report, at: await $.clock.now(),
267 }
268 await $.fs.write(`${standupDir()}/${requestId}/${rt.myId}.json`, JSON.stringify(answer))
269}
270
271/**
272 * Housekeeping, once a minute: old standups, the inboxes and store keys of sessions that are
273 * gone, and dialogue logs of agents no longer in the office. Only names that parse are touched.
274 */
275async function sweep($: $, now: number) {
276 await sweepStandups($, now)
277 const live = new Set([rt.myId, ...(await read($, roster)).map(r => r.id)])
278 const deadInboxes = (await $.fs.list(`${rt.root}/inbox`).catch(() => []))
279 .filter(f => f.kind === 'dir' && /^[A-Za-z0-9._-]{1,100}$/.test(f.name) && !live.has(f.name))
280 .map(f => `${rt.root}/inbox/${f.name}`)
281 if (deadInboxes.length) void $.process.run(['rm', '-rf', '--', ...deadInboxes]).catch(() => {})
282 for (const key of await $.store.keys().catch(() => [])) {
283 const m = /^(cursor|delivered|standup):(.+)$/.exec(key)
284 if (m && !live.has(m[2]!)) await $.store.delete(key).catch(() => {})
285 }
286 await update($, logs, all => {
287 const kept = Object.fromEntries(Object.entries(all).filter(([id]) => live.has(id)))
288 return Object.keys(kept).length === Object.keys(all).length ? all : kept
289 })
290}
291
292/** Removes standups older than KEEP_MS; only names that parse as request ids. */
293async function sweepStandups($: $, now: number) {
294 const old = (await $.fs.list(standupDir()).catch(() => []))
295 .map(f => f.name.replace(/\.json$/, ''))
296 .filter(id => /^\d{13}-[0-9a-z]{6}$/.test(id) && now - Number(id.slice(0, 13)) > KEEP_MS)
297 const paths = [...new Set(old)].flatMap(id => [`${standupDir()}/${id}`, `${standupDir()}/${id}.json`])
298 if (paths.length) void $.process.run(['rm', '-rf', '--', ...paths]).catch(() => {})
299}
300
301async function send($: $, to: string, text: string) {
302 const m = makeMessage('you', 'you', text, await $.clock.now(), Math.random())
303 if (!m) return
304 await log($, to, 'you', m.text)
305 if (to === rt.myId) {
306 void $.prompt.submit({ text: m.text, asUser: true })
307 return
308 }
309 await $.fs.write(`${inboxDir(to)}/${m.id}.json`, JSON.stringify(m))
310 $.ui.toast('📨 Sent')
311}
312
313/** The scene shows this session's room; attention (queue, band, status) stays office-wide. */
314function myRoom(seats: Seat[]): Seat[] {
315 const room = seats.find(s => s.isMe)?.room
316 return room ? seats.filter(s => s.room === room) : seats
317}
318
319async function seatsNow($: $): Promise<{ seats: Seat[]; now: number }> {
320 const now = await $.clock.now()
321 return { seats: mergeRoster(await read($, roster), await read($, me), now), now }
322}
323
324function scene(seats: Seat[], columns: number, now: number, spot: Spotlight | null = null) {
325 const L = layout(columns, seats.length, seats.some(s => s.interns > 0))
326 const desk = assignDesks(seats.map(s => s.id), L.desks.length)
327 return { L, f: drawOffice(L, seats, desk, now, undefined, spot) }
328}
329
330async function spotlightNow($: $, seats: Seat[], now: number): Promise<Spotlight | null> {
331 const st = await read($, standup)
332 if (!st) return null
333 const here = st.answers.filter(a => seats.some(s => s.id === a.id))
334 const id = presenter(here, st.request.requestedAt, now)
335 const a = here.find(x => x.id === id)
336 return a ? { id: a.id, text: headline(a), isBlocked: !!a.blocked } : null
337}
338
339function startBlitting($: $) {
340 rt.blitting ??= $.clock.every(FRAME_MS, () => blitFrame($))
341}
342
343async function blitFrame($: $) {
344 const m = rt.mounted
345 if (!m) return
346 const all = await seatsNow($)
347 const seats = myRoom(all.seats)
348 const now = all.now
349 const { L, f } = scene(seats, m.w, now, await spotlightNow($, seats, now))
350 // more or fewer desk rows is a new mount: redraw instead of blitting
351 if (L.h !== m.h || seats.length !== m.n) return $.ui.invalidate('ui.render')
352 const res = await $.ui.blit({ requestId: PANE, key: 'office', cells: toRasterCells(f) })
353 if ('deny' in res && res.deny) stopAnimating()
354}
355
356function startSvgTicking($: $) {
357 rt.svgTicking ??= $.clock.every(SVG_FRAME_MS, () => $.ui.invalidate('ui.render'))
358}
359
360function stopAnimating() {
361 rt.blitting?.cancel()
362 rt.svgTicking?.cancel()
363 rt.blitting = rt.svgTicking = null
364 rt.mounted = null
365}
366
367async function setIdentity($: $, field: 'name' | 'character', value: string) {
368 await $.store.set(`${field === 'name' ? 'name' : 'look'}:session:${rt.myId}`, value)
369 await update($, me, a => (a ? { ...a, [field]: value } : a))
370 await save($)
371}
372
373/** After /clear: the old desk is cleared and this session sits down again under its new id. */
374async function rejoin($: $) {
375 const id = await $.session.id().catch(() => rt.myId)
376 if (!id || id === rt.myId) return
377 const old = rt.myId
378 void $.process.run(['rm', '-f', '--', `${agentsDir()}/${old}.json`]).catch(() => {})
379 const prev = await read($, me)
380 rt.myId = id
381 rt.lastWritten = ''
382 await update($, me, () => null)
383 await identify($, rt.cwd, prev ? { name: prev.name, character: prev.character } : null)
384}
385
386async function start($: $, cwd: string) {
387 rt.root = `${(await $.env.get('HOME')) ?? '~'}/.claude/pixel-office`
388 rt.myId = await $.session.id()
389 rt.cwd = cwd
390 rt.isAlertsOn = (await $.store.get('alerts')) !== 'off'
391 rt.isSoundOn = (await $.store.get('sound')) !== 'off'
392 await identify($, cwd, null)
393 await $.command.register({ name: 'office', description: 'Open the Pixel Office: every Claude session as a character you can talk to' })
394 $.clock.every(HEARTBEAT_MS, async () => {
395 await signal($, { kind: 'tick' })
396 // subagents running now, read each beat: start/stop hooks miss background and stopped ones
397 const running = await $.agent.list().then(l => l.filter(a => a.status === 'running' && a.type !== 'teammate').length, () => null)
398 if (running !== null) await signal($, { kind: 'interns', count: running })
399 })
400 $.clock.every(POLL_MS, () => poll($))
401}
402
403/** Works out this session's room, name and look, and walks it in. */
404async function identify($: $, cwd: string, keep: { name: string; character: string } | null) {
405 const { room, roomName } = roomOf(cwd, await $.session.repo().catch(() => null))
406 const ticket = keep ? null : await claimTicket($, room) // a rejoin after /clear is not a new agent
407 // a name and look belong to this session: one set earlier (it survives a resume), a ticket's, or a fresh one
408 const savedName = (await $.store.get(`name:session:${rt.myId}`)) ?? keep?.name
409 const savedLook = (await $.store.get(`look:session:${rt.myId}`)) ?? keep?.character
410 const others = await roomNames($, room)
411 const name = typeof savedName === 'string' ? savedName : uniqueName(ticket?.name || basename(cwd) || 'claude', others)
412 const character = typeof savedLook === 'string' ? savedLook
413 : ticket?.character && LOOK_IDS.includes(ticket.character) ? ticket.character
414 : LOOK_IDS[hash(rt.myId) % LOOK_IDS.length]!
415 if (ticket || keep) await $.store.set(`name:session:${rt.myId}`, name)
416 // $PPID of a child shell is this Claude process: what an editor matches its terminals against
417 const ran = await $.process.run(['sh', '-c', 'echo $PPID']).catch(() => null)
418 const pid = Number.parseInt(ran?.stdout.trim() ?? '', 10)
419 await signal($, { kind: 'start', name: truncate(name, 40), cwd, character, pid: Number.isFinite(pid) ? pid : 0, room, roomName })
420 if (ticket?.task) void $.prompt.submit({ text: ticket.task, asUser: true })
421}
422
423/** Names already used by live agents in this room, so a newcomer gets a distinct one. */
424async function roomNames($: $, room: string): Promise<string[]> {
425 const now = await $.clock.now()
426 const names: string[] = []
427 for (const f of await $.fs.list(agentsDir()).catch(() => [])) {
428 if (!f.name.endsWith('.json') || f.name === `${rt.myId}.json` || now - f.mtimeMs > AWAY_MS) continue
429 const r = parseRecord(await $.fs.read(`${agentsDir()}/${f.name}`).catch(() => ''))
430 if (r && r.room === room && r.state !== 'leaving') names.push(r.name)
431 }
432 return names
433}
434
435/**
436 * Takes the oldest fresh spawn ticket for this room, if an editor asked for a new agent here.
437 * `mv` is the claim: of two sessions starting at once, only one moves the file.
438 */
439async function claimTicket($: $, room: string) {
440 const now = await $.clock.now()
441 const tickets = []
442 for (const f of await $.fs.list(spawnDir()).catch(() => [])) {
443 if (!/^\d{13}-[0-9a-z]{6}\.json$/.test(f.name)) continue
444 const t = parseTicket(await $.fs.read(`${spawnDir()}/${f.name}`).catch(() => ''))
445 if (t && `${t.id}.json` === f.name) tickets.push(t)
446 }
447 for (const t of ticketsFor(tickets, room, now)) {
448 const from = `${spawnDir()}/${t.id}.json`
449 const moved = await $.process.run(['mv', '-n', '--', from, `${from}.claimed`]).catch(() => null)
450 const isStillThere = await $.fs.exists(from).catch(() => true)
451 if (moved?.exitCode === 0 && !isStillThere) return t
452 }
453 return null
454}
455
456async function office($: $, e: { surface: string; props: RenderPropsOf['Pane'] }) {
457 const all = await seatsNow($)
458 const seats = myRoom(all.seats)
459 const now = all.now
460 const columns = Math.max(24, e.props.bodyColumns)
461 const spot = await spotlightNow($, seats, now)
462 if (e.surface === 'terminal') {
463 const { L, f } = scene(seats, columns, now, spot)
464 rt.mounted = { w: L.w, h: L.h, n: seats.length }
465 startBlitting($)
466 return { kind: 'raster' as const, columns: L.w, rows: L.h / 2, cells: toRasterCells(f), seats, all: all.seats }
467 }
468 const { L, f } = scene(seats, Math.min(columns, 132), now, spot)
469 startSvgTicking($)
470 return { kind: 'svg' as const, source: toSvg(f, 6), width: L.w * 6, seats, all: all.seats }
471}
472
473function standupCard($: $, e: Parameters<$['ui']['resolve']>[0], st: Standup, seats: Seat[], now: number) {
474 const { Box, Text } = $.ui.resolve(e)
475 const rows = new Map<string, { name: string; a?: StandupAnswer }>()
476 for (const s of seats) rows.set(s.id, { name: s.name })
477 for (const a of st.answers) rows.set(a.id, { name: a.name, a })
478 const isLate = now - st.request.requestedAt > PENDING_MS
479 return (
480 <Box key="standup-card" flexDirection="column" borderStyle="round" borderColor="green" paddingX={1}>
481 <Text bold color="green">
482 🗣 Standup <Text dimColor>· called by {st.request.by} {formatWait(now - st.request.requestedAt)} ago</Text>
483 </Text>
484 {[...rows.values()].map(({ name, a }) =>
485 a ? (
486 <Box flexDirection="column">
487 <Text bold>{truncate(name, 24)}</Text>
488 {a.done ? <Text> ✓ {a.done}</Text> : null}
489 {a.next ? <Text dimColor> → {a.next}</Text> : null}
490 {a.blocked ? <Text color="yellow"> ❗ {a.blocked}</Text> : null}
491 </Box>
492 ) : (
493 <Text dimColor>{truncate(name, 24)} {isLate ? '(no answer)' : '… thinking'}</Text>
494 ),
495 )}
496 </Box>
497 )
498}
499
500export const register: Register = on => {
501 on('session.start', async ($, e, next) => {
502 const ran = await next(e)
503 await start($, e.cwd).catch(err => $.ui.log(`pixel-office: could not start: ${err}`))
504 return ran
505 })
506
507 on('session.end', async ($, e, next) => {
508 // /clear and an in-session resume keep this process under a new id, with no session.start:
509 // rejoin as that id instead of walking out
510 if (e.reason === 'clear' || e.reason === 'resume') {
511 $.clock.after(500, () => rejoin($))
512 return next(e)
513 }
514 await signal($, { kind: 'end' }).catch(() => {})
515 return next(e)
516 })
517
518 on('command.run', { command: 'office' }, async ($, e) => {
519 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
520 const arg = rest.join(' ').trim()
521 if (verb === 'name' && arg) {
522 await setIdentity($, 'name', truncate(arg, 40))
523 return { text: `You are now "${truncate(arg, 40)}" in the office.` }
524 }
525 if (verb === 'standup') {
526 await requestStandup($)
527 await $.ui.open({ id: PANE, title: 'Pixel Office' })
528 return { text: 'Standup called: every session is reporting in.' }
529 }
530 if ((verb === 'alerts' || verb === 'sound') && (arg === 'on' || arg === 'off')) {
531 await $.store.set(verb, arg)
532 if (verb === 'alerts') rt.isAlertsOn = arg === 'on'
533 else rt.isSoundOn = arg === 'on'
534 return { text: `${verb === 'alerts' ? 'Alerts' : 'Chime'} ${arg}.` }
535 }
536 if (verb === 'look') {
537 const cur = (await read($, me))?.character ?? ''
538 const look = LOOK_IDS.includes(arg) ? arg : LOOK_IDS[(LOOK_IDS.indexOf(cur) + 1) % LOOK_IDS.length]!
539 await setIdentity($, 'character', look)
540 return { text: `New look: ${look}. Looks: ${LOOK_IDS.join(', ')}.` }
541 }
542 await $.ui.open({ id: PANE, title: 'Pixel Office' })
543 return { text: 'Pixel Office is open. Tip: /office standup · /office name <name> · /office look · /office alerts|sound on|off' }
544 })
545
546 on('ui.close', async ($, e, next) => {
547 if (e.id === PANE) stopAnimating()
548 return next(e)
549 })
550
551 on('prompt.submit', async ($, e, next) => {
552 await signal($, { kind: 'prompt', text: e.text }).catch(() => {})
553 return next(e)
554 })
555
556 on('tool.call', async ($, e, next) => {
557 if (e.agentId) return next(e)
558 await signal($, { kind: 'tool', tool: String(e.tool), input: e as unknown as Record<string, unknown> }).catch(() => {})
559 const ran = await next(e)
560 await signal($, { kind: 'tool-done', isError: ran.isError === true }).catch(() => {})
561 return ran
562 })
563
564 on('classic.PermissionRequest', async ($, e, next) => {
565 const input = (e.tool_input ?? {}) as Record<string, unknown>
566 const detail =
567 typeof input.command === 'string' ? `: ${input.command}` : typeof input.file_path === 'string' ? `: ${basename(input.file_path)}` : ''
568 await signal($, { kind: 'needs-you', text: bubble(`Allow ${e.tool_name}${detail}?`) }).catch(() => {})
569 return next(e)
570 })
571
572 on('classic.Notification', async ($, e, next) => {
573 if (/permission|elicitation/i.test(e.notification_type)) await signal($, { kind: 'needs-you', text: e.message }).catch(() => {})
574 return next(e)
575 })
576
577 on('turn.complete', async ($, e, next) => {
578 if (!e.agentId) {
579 await signal($, { kind: 'turn-done', text: e.answer, reason: e.reason }).catch(() => {})
580 if (e.answer) await log($, rt.myId, (await read($, me))?.name ?? 'claude', e.answer).catch(() => {})
581 }
582 return next(e)
583 })
584
585 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
586 const { Box, Text, Button } = $.ui.resolve(e)
587 const view = await office($, e)
588 const pick = await read($, selected)
589 const chosen = view.all.find(s => s.id === pick) ?? null
590 const lines = chosen ? ((await read($, logs))[chosen.id] ?? []).slice(-8) : []
591
592 let scene
593 if (view.kind === 'raster' && e.surface === 'terminal') {
594 const { Raster } = $.ui.resolve(e)
595 scene = <Raster key="office" columns={view.columns} rows={view.rows} cells={view.cells} />
596 } else if (view.kind === 'svg' && e.surface !== 'terminal') {
597 const { Svg } = $.ui.resolve(e)
598 scene = <Svg source={view.source} alt={`Pixel office with ${view.seats.length} agents`} width={view.width} />
599 }
600
601 let reply
602 if (chosen && e.surface !== 'mobile') {
603 const { Input } = $.ui.resolve(e)
604 reply = (
605 <Input
606 key="reply"
607 placeholder={chosen.isMe ? 'Prompt this session…' : `Message ${chosen.name}…`}
608 submitLabel="Send"
609 autoFocus
610 onSubmit={text => send($, chosen.id, text)}
611 />
612 )
613 }
614
615 const now = await $.clock.now()
616 const queue = waitingQueue(view.all).filter(s => !s.isMe)
617 const elsewhere = view.all.length - view.seats.length
618 const roomName = view.seats.find(s => s.isMe)?.roomName ?? 'office'
619 const st = await read($, standup)
620 const chips = [...view.seats].sort(byUrgency).slice(0, 9)
621 return (
622 <Box flexDirection="column">
623 {queue.length > 0 && (
624 <Box key="queue" flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
625 <Text bold color="yellow">❗ Needs you ({queue.length})</Text>
626 {queue.slice(0, 5).map(s => (
627 <Text>
628 <Text bold>{truncate(s.name, 20)}</Text> <Text dimColor>· waiting {formatWait(now - s.since)} ·</Text> {truncate(s.bubble, 70)}
629 </Text>
630 ))}
631 </Box>
632 )}
633 {scene}
634 <Box flexDirection="row" flexWrap="wrap" gap={1}>
635 {queue[0] && <Button key="next" hotkey="n" variant="primary" label={`❗ Next: ${truncate(queue[0].name, 14)}`} onPress={() => openOn($, queue[0]!.id)} />}
636 <Button key="standup" hotkey="s" label="🗣 Standup" onPress={() => requestStandup($)} />
637 </Box>
638 <Box flexDirection="row" flexWrap="wrap" gap={1}>
639 {chips.map((s, i) => (
640 <Button
641 key={`chip-${s.id}`}
642 hotkey={String(i + 1)}
643 variant={s.state === 'needs-you' ? 'primary' : undefined}
644 label={`${ICON[s.state]} ${truncate(s.name, 16)}${s.isMe ? ' (here)' : ''}${s.isAway ? ' · away' : ''}`}
645 onPress={() => update($, selected, cur => (cur === s.id ? null : s.id))}
646 />
647 ))}
648 </Box>
649 <Text dimColor>
650 📁 {roomName} · {view.seats.length} here{elsewhere > 0 ? ` · ${elsewhere} in other rooms` : ''}
651 </Text>
652 {view.seats.length < 2 && <Text dimColor>Start another Claude Code session in this repo: it walks into this room.</Text>}
653 {st && standupCard($, e, st, view.seats, now)}
654 {chosen && (
655 <Box flexDirection="column" borderStyle="round" paddingX={1}>
656 <Text bold>
657 {ICON[chosen.state]} {chosen.name}{' '}
658 <Text dimColor>
659 · {chosen.state}{chosen.isAway ? ' · away' : ''} · {chosen.turns} turns · {chosen.tools} tools · {basename(chosen.cwd)}
660 </Text>
661 </Text>
662 {chosen.bubble ? <Text>{chosen.bubble}</Text> : null}
663 {chosen.detail ? <Text dimColor>{truncate(chosen.detail, 120)}</Text> : null}
664 {chosen.state === 'needs-you' && !chosen.isMe && (
665 <Text color="yellow">Permission prompts are answered in {chosen.name}'s own window. A message sent here runs after it.</Text>
666 )}
667 {lines.map(l => (
668 <Text dimColor={l.who !== 'you'}>
669 <Text bold>{l.who}:</Text> {truncate(l.text, 300)}
670 </Text>
671 ))}
672 {reply ?? <Text dimColor>Replying needs a surface with text input.</Text>}
673 <Button key="close" role="dismiss" label="Close" onPress={() => update($, selected, () => null)} />
674 </Box>
675 )}
676 </Box>
677 )
678 })
679
680 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
681 if (e.props.hasSurvey || !rt.myId) return next(e)
682 const { seats, now } = await seatsNow($)
683 const queue = waitingQueue(seats).filter(s => !s.isMe)
684 if (!queue.length) return next(e)
685 const { Box, Text, Button } = $.ui.resolve(e)
686 const list = queue.slice(0, 3).map(s => `${truncate(s.name, 16)} ${formatWait(now - s.since)}`).join(' · ')
687 return (
688 <Box flexDirection="row" gap={1}>
689 <Text color="yellow" wrap="truncate-end">
690 ❗ {queue.length} waiting: {list}{queue.length > 3 ? ' …' : ''}
691 </Text>
692 <Button key="band-next" hotkey="n" variant="primary" label={`Open ${truncate(queue[0]!.name, 14)}`} onPress={() => openOn($, queue[0]!.id)} />
693 </Box>
694 )
695 })
696}
697hooks/core/agent.ts 105 lines1// One session's character: a pure reducer from session signals to its record.
2import type { AgentRecord, AgentState } from '../../types'
3import { activityFor } from './signals'
4import { basename, bubble, clean, firstSentence, taskOf, truncate } from './text'
5
6export type Signal =
7 | { kind: 'start'; name: string; cwd: string; character: string; pid?: number; room?: string; roomName?: string }
8 | { kind: 'prompt'; text: string }
9 | { kind: 'tool'; tool: string; input?: Record<string, unknown> }
10 | { kind: 'tool-done'; isError: boolean }
11 | { kind: 'needs-you'; text: string }
12 | { kind: 'turn-done'; text: string; reason?: 'answer' | 'aborted' | 'refusal' | 'error' }
13 | { kind: 'interns'; count: number }
14 | { kind: 'title'; text: string }
15 | { kind: 'end' }
16 | { kind: 'tick' }
17
18export const HOLD_MS = 3000 // stressed / arriving show this long; done stays until the next prompt
19export const AWAY_MS = 20000 // no heartbeat this long → away
20export const PRUNE_MS = 10 * 60 * 1000 // no heartbeat this long → gone
21
22const EDITS = ['Edit', 'MultiEdit', 'Write', 'NotebookEdit']
23const MAX_FILES = 8
24
25function editedPath(input: Record<string, unknown> = {}): string {
26 const p = input.file_path ?? input.notebook_path
27 return typeof p === 'string' && p.startsWith('/') && p.length <= 4096 ? p : ''
28}
29
30const WORKING: AgentState[] = ['thinking', 'typing', 'reading', 'writing', 'browsing', 'delegating', 'working']
31
32export function isWorking(state: AgentState): boolean {
33 return WORKING.includes(state)
34}
35
36export function newAgent(id: string, now: number): AgentRecord {
37 return {
38 v: 1, id, name: 'claude', cwd: '', character: 'dev-1', state: 'arriving', bubble: 'Morning!', detail: '',
39 isBusy: false, joinedAt: now, since: now, heartbeat: now, turns: 0, tools: 0, interns: 0, lastLine: '', pid: 0, files: [], room: '', roomName: '', task: '', title: '',
40 }
41}
42
43export function reduce(a: AgentRecord, s: Signal, now: number): AgentRecord {
44 const to = (state: AgentState, text = a.bubble, extra: Partial<AgentRecord> = {}): AgentRecord =>
45 ({ ...a, state, bubble: text, since: state === a.state && text === a.bubble ? a.since : now, heartbeat: now, ...extra })
46
47 switch (s.kind) {
48 case 'start':
49 // a restart or hot reload keeps the arrival time, so seniority is stable
50 return { ...newAgent(a.id, now), name: s.name, cwd: s.cwd, character: s.character, turns: a.turns, tools: a.tools, joinedAt: a.joinedAt || now, pid: s.pid ?? a.pid ?? 0, files: a.files ?? [],
51 room: s.room || s.cwd, roomName: s.roomName || basename(s.cwd), task: a.task ?? '', title: a.title ?? '' }
52 case 'prompt':
53 return to('thinking', bubble(s.text), { isBusy: true, turns: a.turns + 1, task: taskOf(s.text) || a.task || '' })
54 case 'title':
55 return { ...a, title: truncate(clean(s.text), 80) }
56 case 'tool': {
57 const act = activityFor(s.tool, s.input)
58 const path = EDITS.includes(s.tool) ? editedPath(s.input) : ''
59 const files = path ? [path, ...(a.files ?? []).filter(f => f !== path)].slice(0, MAX_FILES) : a.files ?? []
60 return to(act.state, act.bubble, { isBusy: true, tools: a.tools + 1, files, detail: act.detail })
61 }
62 case 'tool-done':
63 if (s.isError) return to('stressed', 'Hmm, that failed…')
64 // a tool ending answers a pending question or permission, so needs-you clears
65 return a.state === 'needs-you' || isWorking(a.state) ? to('thinking', a.state === 'needs-you' ? 'Thanks!' : a.bubble) : a
66 case 'needs-you':
67 // a permission prompt raises this twice (the request, then its notification): keep the first
68 // wording and the time the wait started
69 return a.state === 'needs-you' ? { ...a, heartbeat: now } : to('needs-you', bubble(s.text || 'I need you'))
70 case 'turn-done': {
71 if (s.reason === 'aborted') return to('idle', 'Stopped.', { isBusy: false })
72 if (s.reason === 'error') return to('stressed', 'That turn failed.', { isBusy: false })
73 const line = firstSentence(s.text)
74 return to('done', bubble(line || 'Done!'), { isBusy: false, lastLine: line || a.lastLine })
75 }
76 case 'interns':
77 return s.count === a.interns ? a : { ...a, interns: Math.max(0, s.count), heartbeat: now }
78 case 'end':
79 return to('leaving', 'Bye!', { isBusy: false, interns: 0 })
80 case 'tick': {
81 const held = now - a.since >= HOLD_MS
82 if (held && a.state === 'arriving') return { ...to('idle', ''), heartbeat: now }
83 if (held && a.state === 'stressed') return { ...to(a.isBusy ? 'thinking' : 'idle', a.isBusy ? a.bubble : ''), heartbeat: now }
84 return { ...a, heartbeat: now }
85 }
86 }
87}
88
89/** What others see: a stale heartbeat is `away`; `null` means prune it. */
90export function presence(a: AgentRecord, now: number): 'here' | 'away' | null {
91 const age = now - a.heartbeat
92 if (age > PRUNE_MS || (a.state === 'leaving' && age > HOLD_MS)) return null
93 return age > AWAY_MS ? 'away' : 'here'
94}
95
96const RANK: Record<AgentState, number> = {
97 'needs-you': 0, stressed: 1, typing: 2, reading: 2, writing: 2, browsing: 2, delegating: 2, working: 2,
98 thinking: 3, done: 4, arriving: 5, idle: 6, leaving: 7,
99}
100
101/** Roster order for chips: who needs you first, then busiest, then by name. */
102export function byUrgency(a: AgentRecord, b: AgentRecord): number {
103 return RANK[a.state] - RANK[b.state] || a.name.localeCompare(b.name) || a.id.localeCompare(b.id)
104}
105hooks/core/alerts.ts 65 lines1// Who is waiting on you, who rings the bell, and when.
2import type { AlertMemory } from '../../types'
3import type { Seat } from './roster'
4
5export const REMIND_MS = 3 * 60 * 1000
6/** A waiting seat missing from one or two polls (a slow read) is not a new episode when it returns. */
7export const FORGET_MS = 15_000
8
9/** Seats blocked on you, longest wait first. */
10export function waitingQueue(seats: Seat[]): Seat[] {
11 return seats.filter(s => s.state === 'needs-you' && !s.isAway).sort((a, b) => a.since - b.since || a.id.localeCompare(b.id))
12}
13
14/**
15 * The one session that plays the chime and posts the OS notification, so N
16 * open sessions ring once: the earliest arrival that is not away.
17 */
18export function alertLeader(seats: Seat[]): string | null {
19 const here = seats.filter(s => !s.isAway).sort((a, b) => a.joinedAt - b.joinedAt || a.id.localeCompare(b.id))
20 return here[0]?.id ?? null
21}
22
23export type AlertsDue = { fresh: Seat[]; remind: Seat[]; memory: AlertMemory }
24
25/**
26 * Compares the queue with what was already announced. A waiting episode is
27 * keyed by the seat's `since`: a new episode fires once, gets one reminder
28 * after REMIND_MS, and is forgotten once the seat stops waiting.
29 */
30export function alertsDue(queue: Seat[], memory: AlertMemory, now: number): AlertsDue {
31 const fresh: Seat[] = []
32 const remind: Seat[] = []
33 const next: AlertMemory = {}
34 for (const s of queue) {
35 const seen = memory[s.id]
36 if (!seen || seen.since !== s.since) {
37 fresh.push(s)
38 next[s.id] = { since: s.since, isReminded: false, seenAt: now }
39 } else if (!seen.isReminded && now - s.since >= REMIND_MS) {
40 remind.push(s)
41 next[s.id] = { since: s.since, isReminded: true, seenAt: now }
42 } else next[s.id] = { ...seen, seenAt: now }
43 }
44 for (const [id, seen] of Object.entries(memory)) if (!next[id] && now - (seen.seenAt ?? 0) < FORGET_MS) next[id] = seen
45 return { fresh, remind, memory: next }
46}
47
48export function formatWait(ms: number): string {
49 const s = Math.max(0, Math.floor(ms / 1000))
50 if (s < 60) return `${s}s`
51 const m = Math.floor(s / 60)
52 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${m % 60 ? ` ${m % 60}m` : ''}`
53}
54
55/** A macOS notification; the words are argv items, never script source. */
56export function notifyArgv(title: string, body: string): string[] {
57 return [
58 'osascript',
59 '-e', 'on run argv',
60 '-e', 'display notification (item 1 of argv) with title (item 2 of argv)',
61 '-e', 'end run',
62 '--', body, title,
63 ]
64}
65hooks/core/encode.ts 95 lines1// A frame as the terminal's Raster cells, or as an SVG document.
2import type { Frame } from './pixels'
3
4// The environment has the TC39 base64 method; es2023's lib does not type it yet.
5declare global {
6 interface Uint8Array {
7 toBase64(): string
8 }
9}
10
11const UPPER_HALF = 0x2580
12
13/**
14 * Raster cells take one printable width-1 BMP code point; anything wider or
15 * invisible (emoji, CJK, combining marks, zero-width) is dropped from text.
16 */
17export function isCellChar(cp: number): boolean {
18 const ok =
19 (cp >= 0x20 && cp < 0x7f) ||
20 (cp >= 0xa0 && cp < 0x300) ||
21 (cp >= 0x370 && cp < 0x1100) ||
22 (cp >= 0x2010 && cp <= 0x2027) ||
23 (cp >= 0x2030 && cp < 0x2060) ||
24 (cp >= 0x2190 && cp < 0x2300) ||
25 (cp >= 0x2500 && cp < 0x2600)
26 return ok
27}
28
29/** columns = w, rows = h / 2: each cell is ▀ with the top pixel as fg, bottom as bg. */
30export function toRasterWords(f: Frame): Uint32Array {
31 const rows = f.h >> 1
32 const out = new Uint32Array(f.w * rows * 3)
33 for (let r = 0; r < rows; r++)
34 for (let c = 0; c < f.w; c++) {
35 const i = (r * f.w + c) * 3
36 out[i] = UPPER_HALF
37 out[i + 1] = f.px[2 * r * f.w + c]!
38 out[i + 2] = f.px[(2 * r + 1) * f.w + c]!
39 }
40 for (const t of f.texts) {
41 let c = t.col
42 // a dropped glyph (an emoji icon) takes no cell; the run closes up
43 for (const ch of [...t.text.replace(/\uFE0F/g, '')].filter(ch => isCellChar(ch.codePointAt(0)!))) {
44 if (c >= f.w) break
45 const i = (t.row * f.w + c) * 3
46 out[i] = ch.codePointAt(0)!
47 out[i + 1] = t.fg
48 out[i + 2] = t.bg
49 c++
50 }
51 }
52 return out
53}
54
55export function toRasterCells(f: Frame): string {
56 return new Uint8Array(toRasterWords(f).buffer).toBase64()
57}
58
59const hex = (c: number) => '#' + c.toString(16).padStart(6, '0')
60const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
61
62/** One <path> per colour of horizontal runs, then text runs on their plates. */
63export function toSvg(f: Frame, scale: number): string {
64 const runs = new Map<number, string[]>()
65 for (let y = 0; y < f.h; y++) {
66 let x = 0
67 while (x < f.w) {
68 const c = f.px[y * f.w + x]!
69 let n = 1
70 while (x + n < f.w && f.px[y * f.w + x + n] === c) n++
71 let list = runs.get(c)
72 if (!list) runs.set(c, (list = []))
73 list.push(`M${x} ${y}h${n}v1H${x}z`)
74 x += n
75 }
76 }
77 const parts = [
78 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${f.w} ${f.h}" width="${f.w * scale}" height="${f.h * scale}" shape-rendering="crispEdges">`,
79 ]
80 for (const [c, d] of runs) parts.push(`<path fill="${hex(c)}" d="${d.join('')}"/>`)
81 if (f.texts.length) {
82 parts.push('<g xml:space="preserve" font-family="ui-monospace,Menlo,monospace" font-size="1.66" shape-rendering="auto">')
83 for (const t of f.texts) {
84 const len = [...t.text].length
85 parts.push(
86 `<rect x="${t.col}" y="${t.row * 2}" width="${len}" height="2" fill="${hex(t.bg)}"/>`,
87 `<text x="${t.col}" y="${t.row * 2 + 1.5}" fill="${hex(t.fg)}" textLength="${len}" lengthAdjust="spacingAndGlyphs">${esc(t.text)}</text>`,
88 )
89 }
90 parts.push('</g>')
91 }
92 parts.push('</svg>')
93 return parts.join('')
94}
95hooks/core/inbox.ts 54 lines1// Messages between sessions: one file per message, ids sort by time.
2import type { InboxMessage } from '../../types'
3
4export const MAX_TEXT = 4000
5
6export function messageId(now: number, rand: number): string {
7 const r = Math.floor(rand * 36 ** 6).toString(36).padStart(6, '0')
8 return `${String(now).padStart(13, '0')}-${r}`
9}
10
11export function makeMessage(from: string, fromName: string, text: string, now: number, rand: number): InboxMessage | null {
12 const body = text.trim()
13 if (!body || body.length > MAX_TEXT) return null
14 return { v: 1, id: messageId(now, rand), from, fromName, text: body, sentAt: now }
15}
16
17const ID = /^\d{13}-[0-9a-z]{6}$/
18
19export function parseMessage(text: string): InboxMessage | null {
20 try {
21 const m: unknown = JSON.parse(text)
22 if (!m || typeof m !== 'object') return null
23 const r = m as Record<string, unknown>
24 const ok =
25 r.v === 1 && typeof r.id === 'string' && ID.test(r.id) && typeof r.from === 'string' &&
26 typeof r.fromName === 'string' && r.fromName.length <= 100 && typeof r.text === 'string' &&
27 r.text.trim().length > 0 && r.text.length <= MAX_TEXT && typeof r.sentAt === 'number'
28 return ok ? (r as InboxMessage) : null
29 } catch {
30 return null
31 }
32}
33
34/**
35 * Message files not yet delivered, oldest first. Delivery deletes the file, and the ids
36 * delivered so far are remembered too, so a file whose delete failed is never delivered twice
37 * and a message that arrives out of order is never skipped.
38 */
39export function pendingFiles(names: string[], delivered: readonly string[]): string[] {
40 const done = new Set(delivered)
41 return [...new Set(names)].filter(n => n.endsWith('.json') && ID.test(n.slice(0, -5)) && !done.has(n.slice(0, -5))).sort()
42}
43
44export const DELIVERED_KEEP = 200
45
46/** The prompt a delivered message becomes in the receiving session. */
47export function asPrompt(m: InboxMessage): string {
48 // always labelled: anything that can write the shared folder can drop a message, so the
49 // receiving session should know where the words came from
50 return m.from === 'you'
51 ? `[Sent to you through Pixel Office]\n\n${m.text}`
52 : `[Message from the Claude session "${m.fromName}" via Pixel Office]\n\n${m.text}`
53}
54hooks/core/roster.ts 84 lines1// Reading other sessions' presence files and seating them.
2import type { AgentRecord, AgentState } from '../../types'
3import { presence } from './agent'
4import { basename } from './text'
5
6const STATES: AgentState[] = [
7 'arriving', 'idle', 'thinking', 'typing', 'reading', 'writing', 'browsing',
8 'delegating', 'working', 'needs-you', 'stressed', 'done', 'leaving',
9]
10
11const isStr = (v: unknown, max = 500): v is string => typeof v === 'string' && v.length <= max
12const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
13
14/** Parses one presence file; anything malformed or foreign is `null`. */
15export function parseRecord(text: string): AgentRecord | null {
16 let r: Record<string, unknown>
17 try {
18 const parsed: unknown = JSON.parse(text)
19 if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
20 r = parsed as Record<string, unknown>
21 } catch {
22 return null
23 }
24 const ok =
25 r.v === 1 && isStr(r.id, 100) && r.id.length > 0 && isStr(r.name, 100) && isStr(r.cwd, 4096) &&
26 isStr(r.character, 50) && STATES.includes(r.state as AgentState) && isStr(r.bubble) &&
27 typeof r.isBusy === 'boolean' && [r.joinedAt, r.since, r.heartbeat, r.turns, r.tools, r.interns].every(isNum) &&
28 isStr(r.lastLine, 2000)
29 if (!ok) return null
30 // optional since 0.2: fill what an older mod did not write
31 const pid = isNum(r.pid) ? r.pid : 0
32 const files = Array.isArray(r.files) ? r.files.filter((f): f is string => isStr(f, 4096)).slice(0, 8) : []
33 const room = isStr(r.room, 4096) && r.room ? r.room : (r.cwd as string)
34 const roomName = isStr(r.roomName, 100) && r.roomName ? r.roomName : basename(room)
35 const detail = isStr(r.detail) ? r.detail : ''
36 const task = isStr(r.task, 200) ? r.task : ''
37 const title = isStr(r.title, 200) ? r.title : ''
38 return { ...(r as AgentRecord), pid, files, room, roomName, detail, task, title }
39}
40
41export type Seat = AgentRecord & { isAway: boolean; isMe: boolean }
42
43/** Live roster from parsed files: pruned, deduped by id, own record wins. */
44export function mergeRoster(records: (AgentRecord | null)[], me: AgentRecord | null, now: number): Seat[] {
45 const byId = new Map<string, AgentRecord>()
46 for (const r of records) if (r && (!byId.has(r.id) || byId.get(r.id)!.heartbeat < r.heartbeat)) byId.set(r.id, r)
47 if (me) byId.set(me.id, { ...me, heartbeat: now })
48 const seats: Seat[] = []
49 for (const r of byId.values()) {
50 if (!r.cwd) continue // never reached 'start' (a session that died while starting): no folder, no room
51 const p = presence(r, now)
52 if (p) seats.push({ ...r, isAway: p === 'away', isMe: r.id === me?.id })
53 }
54 return seats.sort((a, b) => a.joinedAt - b.joinedAt || a.id.localeCompare(b.id))
55}
56
57/** 32-bit FNV-1a: a stable number per id, for desks and default looks. */
58export function hash(text: string): number {
59 let h = 0x811c9dc5
60 for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193)
61 return h >>> 0
62}
63
64/**
65 * Desk per id: each id prefers desk hash(id) % count and probes forward, ids
66 * seated in id order so the same set always lands the same way. Ids beyond
67 * `count` get no desk (-1) and stand in the overflow row.
68 */
69export function assignDesks(ids: string[], count: number): Map<string, number> {
70 const out = new Map<string, number>()
71 const taken = new Set<number>()
72 for (const id of [...new Set(ids)].sort()) {
73 if (taken.size >= count) {
74 out.set(id, -1)
75 continue
76 }
77 let d = hash(id) % count
78 while (taken.has(d)) d = (d + 1) % count
79 taken.add(d)
80 out.set(id, d)
81 }
82 return out
83}
84hooks/core/scene.ts 213 lines1// The office: layout from the room's width, then one frame for a moment in time.
2import { HOLD_MS } from './agent'
3import { blit, frame, plot, rect, shade, text, type Frame } from './pixels'
4import type { Seat } from './roster'
5import { hash } from './roster'
6import {
7 ACCESSORY, ACCESSORY_PALETTE, COFFEE, DESK, DOOR, FACE, FURNITURE_PALETTE, HAIR, INTERN, MONITOR, PERSON,
8 PLANT, SHELF, STYLES, WINDOW, WINDOW_PALETTE, lookPalette, withHair,
9} from './sprites'
10import { truncate, wrap } from './text'
11
12export const CELL_W = 22
13export const CELL_H = 22
14export const WALL_H = 12
15export const MAX_ROWS = 64 // effectively uncapped: every agent gets a desk
16export const IDLE_ZZZ_MS = 60_000
17export const PLAY_H = 18 // the play mat under each desk row, only while someone has subagents
18export const MAX_KIDS = 10 // subagents drawn per agent; the rest are "+N"
19const KIDS_PER_ROW = 5
20const BUBBLE_W = CELL_W - 2
21const DESK_TEXT_W = 16
22
23/** A standup presenter: stands up and says `text` (yellow when blocked, else green). */
24export type Spotlight = { id: string; text: string; isBlocked: boolean }
25
26export type Layout = { w: number; h: number; cols: number; rows: number; cellH: number; desks: { x: number; y: number }[] }
27
28/** One pixel per column; desk rows grow with the head count, and gain a play mat when `play`. */
29export function layout(columns: number, count: number, play = false): Layout {
30 const cellH = CELL_H + (play ? PLAY_H : 0)
31 const w = Math.max(CELL_W + 2, Math.min(Math.floor(columns), 512))
32 const cols = Math.max(1, Math.floor((w - 2) / CELL_W))
33 const rows = Math.max(1, Math.min(MAX_ROWS, Math.ceil(count / cols)))
34 const ox = Math.floor((w - cols * CELL_W) / 2)
35 const desks: Layout['desks'] = []
36 for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) desks.push({ x: ox + c * CELL_W, y: WALL_H + r * cellH })
37 return { w, h: WALL_H + rows * cellH + 2, cols, rows, cellH, desks }
38}
39
40export const DOOR_AT = { x: 3, y: WALL_H - DOOR.length }
41
42export type Theme = {
43 wall: number; trim: number; floorA: number; floorB: number
44 bubble: number; bubbleText: number; alert: number; spot: number; plate: number; plateText: number
45 isNight: boolean
46}
47
48export const DEFAULT_THEME: Theme = {
49 wall: 0x3b3552, trim: 0x2a2540, floorA: 0xc8a77a, floorB: 0xbf9d70,
50 bubble: 0xffffff, bubbleText: 0x1a1a1a, alert: 0xffe08a, spot: 0xc8f7c5, plate: 0x2a2540, plateText: 0xf0f0f0,
51 isNight: false,
52}
53
54const SCREEN: Partial<Record<Seat['state'], number[]>> = {
55 typing: [0x39d353, 0x1e2a35], working: [0x39d353, 0x1e2a35], thinking: [0x2f5d7c, 0x1e2a35],
56 browsing: [0x4aa3ff, 0x2f6fb5], reading: [0xe8e8e8, 0xb8b8b8], writing: [0xe8e8e8, 0x39d353],
57 delegating: [0xb48cff, 0x1e2a35], 'needs-you': [0xffd23f, 0x1e2a35], stressed: [0xd94848, 0x1e2a35],
58 done: [0x39d353, 0x39d353],
59}
60
61/** Draws the office for `now`; `seats` already know their desk (`desk` map, -1 = none). */
62export function drawOffice(
63 L: Layout, seats: Seat[], desk: Map<string, number>, now: number, theme = DEFAULT_THEME, spotlight: Spotlight | null = null,
64): Frame {
65 const f = frame(L.w, L.h, theme.floorA)
66 drawRoom(f, L, theme)
67 const tick = Math.floor(now / 250) % 2
68 const walkers: (() => void)[] = []
69 const style = styles(seats)
70
71 for (const s of seats) {
72 const d = desk.get(s.id) ?? -1
73 const at = L.desks[d]
74 if (!at) continue
75 const pal = lookPalette(s.character, hash(s.id))
76 const look = style.get(s.id)!
77 const face = FACE[Math.floor(look / HAIR.length)]
78 const dim = s.isAway ? 0.45 : 1
79 const cx = at.x + 10
80 const spot = spotlight?.id === s.id ? spotlight : null
81 const cy = at.y + 6 - (spot ? 2 : 0) // a presenter stands up
82 const walking = s.state === 'arriving' || s.state === 'leaving'
83
84 if (!walking) {
85 const blinks = (now + (hash(s.id) % 3000)) % 3000 < 160
86 blit(f, withHair(blinks ? PERSON.blink : PERSON.sit, look), cx, cy, pal, dim)
87 if (face) acc(f, face, cx, cy, pal, dim)
88 // done: both arms up for a moment, then one hand stays raised until the next prompt
89 const cheering = s.state === 'done' && now - s.since < HOLD_MS
90 if (s.state === 'needs-you' || (s.state === 'done' && !cheering)) acc(f, 'handUp', cx, cy, pal, dim)
91 if (cheering) acc(f, 'armsUpL', cx, cy, pal, dim), acc(f, 'armsUpR', cx, cy, pal, dim)
92 if (s.state === 'needs-you' && tick === 0) acc(f, 'alert', cx, cy, pal, dim)
93 if (s.state === 'stressed') acc(f, 'sweat', cx, cy + (Math.floor(now / 200) % 3), pal, dim)
94 if (s.state === 'thinking') for (let i = 0; i <= Math.floor(now / 300) % 3; i++) plot(f, cx + 2 + i * 2, at.y + 4, 0xffffff)
95 }
96
97 blit(f, DESK, at.x + 2, at.y + 14, FURNITURE_PALETTE, dim)
98 // its title, written on the desk's front panel (16 cells wide, one text row)
99 const title = truncate(s.title || s.task || '', DESK_TEXT_W)
100 if (title) text(f, at.x + 3 + Math.floor((DESK_TEXT_W - [...title].length) / 2), at.y / 2 + 8, title, s.isAway ? 0x9a9a9a : 0xf3e2c0, shade(FURNITURE_PALETTE.F!, dim))
101 blit(f, MONITOR, at.x + 3, at.y + 8, FURNITURE_PALETTE, dim)
102 const screen = SCREEN[s.state]
103 if (screen && !s.isAway) {
104 // three lines of "code" whose lengths scroll with the clock
105 const scroll = Math.floor(now / 250) + hash(s.id)
106 for (let j = 0; j < 3; j++) {
107 const len = 1 + ((scroll + j) * 7) % 4
108 for (let i = 0; i < 4; i++) plot(f, at.x + 4 + i, at.y + 9 + j, screen[i < len ? 0 : 1]!)
109 }
110 }
111
112 if (!walking) {
113 if (s.state === 'typing' || s.state === 'working') acc(f, tick ? 'hands1' : 'hands2', cx, cy, pal, dim)
114 if (s.state === 'reading') acc(f, tick ? 'book1' : 'book2', cx, cy, pal, dim)
115 if (s.state === 'writing') acc(f, 'paper', cx, cy, pal, dim), acc(f, tick ? 'pencil1' : 'pencil2', cx, cy, pal, dim)
116 }
117
118 if (s.interns > 0 && L.cellH > CELL_H) drawPlayArea(f, at, s.interns, tick, pal, dim)
119
120 if (walking) {
121 walkers.push(() => {
122 const p = Math.max(0, Math.min(1, (now - s.since) / HOLD_MS))
123 const k = s.state === 'arriving' ? p : 1 - p
124 const x = Math.round(DOOR_AT.x + (cx - DOOR_AT.x) * k)
125 const y = Math.round(WALL_H - 4 + (cy - (WALL_H - 4)) * k)
126 blit(f, withHair(tick ? PERSON.walk1 : PERSON.walk2, look), x, y, pal, dim)
127 if (face) acc(f, face, x, y, pal, dim)
128 })
129 }
130
131 drawBubble(f, s, at, now, theme, spot)
132 const label = truncate(`${s.isMe ? '• ' : ''}${s.name}`, BUBBLE_W)
133 const lcol = at.x + 1 + Math.floor((BUBBLE_W - [...label].length) / 2)
134 text(f, lcol, at.y / 2 + 10, label, s.isAway ? 0x9a9a9a : theme.plateText, theme.plate)
135 }
136 walkers.forEach(w => w())
137 return f
138}
139
140/**
141 * Hairstyle + face per id, unique within the room for each colour look: earliest
142 * arrivals pick first (hash(id) % STYLES, probing forward), so newcomers never restyle anyone.
143 */
144export function styles(seats: Seat[]): Map<string, number> {
145 const out = new Map<string, number>()
146 const taken = new Set<string>()
147 for (const s of [...seats].sort((a, b) => a.joinedAt - b.joinedAt || (a.id < b.id ? -1 : 1))) {
148 let k = hash(s.id) % STYLES
149 for (let i = 0; i < STYLES && taken.has(`${s.character}:${k}`); i++) k = (k + 1) % STYLES
150 taken.add(`${s.character}:${k}`)
151 out.set(s.id, k)
152 }
153 return out
154}
155
156/** Subagents as kids on a mat under their agent's desk: two rows of five, then "+N". */
157function drawPlayArea(f: Frame, at: { x: number; y: number }, n: number, tick: number, pal: Record<string, number>, dim: number) {
158 const y = at.y + CELL_H
159 rect(f, at.x + 1, y, CELL_W - 2, PLAY_H - 1, shade(0x6fa8c9, dim))
160 rect(f, at.x + 2, y + 1, CELL_W - 4, PLAY_H - 3, shade(0x8cc4de, dim))
161 for (let i = 0; i < Math.min(MAX_KIDS, n); i++) {
162 const col = i % KIDS_PER_ROW
163 const row = Math.floor(i / KIDS_PER_ROW)
164 blit(f, INTERN, at.x + 1 + col * 4, y + 2 + row * 7 - ((tick + i) % 2), pal, dim)
165 }
166 if (n > MAX_KIDS) {
167 const more = `+${n - MAX_KIDS}`
168 text(f, at.x + Math.floor((CELL_W - more.length) / 2), (y + 16) / 2, more, 0x1a1a1a, shade(0x8cc4de, dim))
169 }
170}
171
172function acc(f: Frame, name: keyof typeof ACCESSORY, cx: number, cy: number, pal: Record<string, number>, dim: number) {
173 const a = ACCESSORY[name]
174 blit(f, a.art, cx + a.at[0], cy + a.at[1], { ...ACCESSORY_PALETTE, s: pal.s!, c: pal.c!, h: pal.h! }, dim)
175}
176
177function drawBubble(f: Frame, s: Seat, at: { x: number; y: number }, now: number, theme: Theme, spot: Spotlight | null) {
178 let words = s.bubble
179 let bg = theme.bubble
180 let fg = theme.bubbleText
181 if (spot) (words = spot.text), (bg = spot.isBlocked ? theme.alert : theme.spot)
182 else if (s.isAway) (words = '(away)'), (bg = 0x55556a), (fg = 0xdddddd)
183 else if (s.state === 'needs-you') bg = theme.alert
184 else if (s.state === 'idle' && now - s.since > IDLE_ZZZ_MS) (words = 'z z z'), (bg = theme.wall), (fg = 0xcfd6ff)
185 if (!words) return
186 const lines = wrap(words, BUBBLE_W)
187 const shown = lines.length > 2 ? [lines[0]!, truncate(lines.slice(1).join(' '), BUBBLE_W)] : lines
188 shown.forEach((line, i) => text(f, at.x + 1, at.y / 2 + i, ` ${line} `.slice(0, BUBBLE_W + 1), fg, bg))
189}
190
191function drawRoom(f: Frame, L: Layout, t: Theme) {
192 for (let y = WALL_H; y < L.h; y++)
193 for (let x = 0; x < L.w; x++) if (((x >> 2) + (y >> 2)) % 2) f.px[y * L.w + x] = t.floorB
194 rect(f, 0, 0, L.w, WALL_H, t.wall)
195 rect(f, 0, WALL_H - 1, L.w, 1, t.trim)
196 blit(f, DOOR, DOOR_AT.x, DOOR_AT.y, FURNITURE_PALETTE)
197 const win = WINDOW_PALETTE(t.isNight)
198 for (let x = 14; x + 10 <= L.w - 30; x += 30) blit(f, WINDOW, x, 2, win)
199 if (L.w >= 60) blit(f, SHELF, L.w - 20, 6, FURNITURE_PALETTE), blit(f, COFFEE, L.w - 28, WALL_H - 5, FURNITURE_PALETTE)
200 blit(f, PLANT, L.w - 10, WALL_H - 7, FURNITURE_PALETTE)
201 if (t.isNight) for (let x = 0; x < L.w; x++) for (let y = 0; y < L.h; y++) f.px[y * L.w + x] = shade(f.px[y * L.w + x]!, 0.8)
202}
203
204/** The seat id under a cell, or null: what a click at (col, row) means. */
205export function hitTest(L: Layout, desk: Map<string, number>, col: number, row: number): string | null {
206 const x = col
207 const y = row * 2
208 const d = L.desks.findIndex(at => x >= at.x && x < at.x + CELL_W && y >= at.y && y < at.y + L.cellH)
209 if (d < 0) return null
210 for (const [id, n] of desk) if (n === d) return id
211 return null
212}
213hooks/core/sprites.ts 171 lines1// Built-in art. Every sprite is string art over a palette, so packs can be plain JSON.
2import type { Palette, Sprite } from './pixels'
3
4// ── people (8 × 12) ──────────────────────────────────────────────
5// h hair · s skin · e eye · c shirt · C shirt shade · p pants · k shoes
6export const PERSON = {
7 sit: [
8 '..hhhh..',
9 '.hhhhhh.',
10 '.hssssh.',
11 '.sesses.',
12 '.ssssss.',
13 '...ss...',
14 '.cccccc.',
15 'cccccccc',
16 'sccCCccs',
17 'sccccccs',
18 '.pppppp.',
19 '.pp..pp.',
20 ],
21 blink: [
22 '..hhhh..',
23 '.hhhhhh.',
24 '.hssssh.',
25 '.ssssss.',
26 '.ssssss.',
27 '...ss...',
28 '.cccccc.',
29 'cccccccc',
30 'sccCCccs',
31 'sccccccs',
32 '.pppppp.',
33 '.pp..pp.',
34 ],
35 walk1: [
36 '..hhhh..',
37 '.hhhhhh.',
38 '.hssssh.',
39 '.sesses.',
40 '.ssssss.',
41 '...ss...',
42 '.cccccc.',
43 'scccccc.',
44 'sccCCccs',
45 '.ccccccs',
46 '.pp..pp.',
47 'kk....kk',
48 ],
49 walk2: [
50 '..hhhh..',
51 '.hhhhhh.',
52 '.hssssh.',
53 '.sesses.',
54 '.ssssss.',
55 '...ss...',
56 '.cccccc.',
57 '.ccccccs',
58 'sccCCccs',
59 'scccccc.',
60 '..pppp..',
61 '..kkkk..',
62 ],
63} satisfies Record<string, Sprite>
64
65// Hairstyles: the person's top three rows, swapped in so no two desks look alike.
66export const HAIR: readonly Sprite[] = [
67 ['..hhhh..', '.hhhhhh.', '.hssssh.'], // short
68 ['.h.hh.h.', '.hhhhhh.', '.hssssh.'], // spiky
69 ['..hhhh..', '.hhhhhh.', 'hhsssshh'], // long
70 ['........', '..hhhh..', '.ssssss.'], // buzz
71 ['...hh...', '.hhhhhh.', '.hssssh.'], // bun
72 ['..hhhh..', '.hhhhhh.', '.hhhssh.'], // fringe
73 ['..cccc..', '.ccccccc', '.hssssh.'], // cap, in the shirt colour
74 ['...hh...', '..hhhh..', '.ssssss.'], // mohawk
75]
76export const FACE = [null, 'glasses', 'beard'] as const
77export const STYLES = HAIR.length * FACE.length
78
79export const withHair = (s: Sprite, style: number): Sprite => [...HAIR[style % HAIR.length]!, ...s.slice(3)]
80
81export const INTERN: Sprite = ['.hh.', 'hssh', '.ss.', 'cccc', 'cccc', '.pp.']
82
83// ── accessories, drawn relative to the person's top-left ─────────
84export const ACCESSORY = {
85 handUp: { at: [7, -2], art: ['s', 's', 's', 's', 's', 's', 's', 's', 'c', 'c'] },
86 armsUpL: { at: [-1, -1], art: ['s', 's', 's', 's', 's', 's', 's', 's', 'c'] },
87 armsUpR: { at: [8, -1], art: ['s', 's', 's', 's', 's', 's', 's', 's', 'c'] },
88 alert: { at: [9, -2], art: ['y', 'y', 'y', '.', 'y'] },
89 sweat: { at: [7, 1], art: ['b', 'b'] },
90 book1: { at: [1, 8], art: ['rrwwrr', 'rrwwrr'] },
91 book2: { at: [1, 8], art: ['rrrwwr', 'rrrwwr'] },
92 paper: { at: [1, 9], art: ['wwwww'] },
93 pencil1: { at: [2, 8], art: ['y'] },
94 pencil2: { at: [4, 8], art: ['y'] },
95 hands1: { at: [0, 9], art: ['s......s'] },
96 hands2: { at: [0, 9], art: ['.s....s.'] },
97 dot: { at: [0, 0], art: ['w'] },
98 glasses: { at: [1, 3], art: ['g.gg.g'] },
99 beard: { at: [1, 4], art: ['h....h', '.hhhh.'] },
100} satisfies Record<string, { at: readonly [number, number]; art: Sprite }>
101
102export const ACCESSORY_PALETTE: Palette = {
103 y: 0xffd23f, b: 0x5ab8ff, r: 0xd94848, w: 0xffffff, g: 0x9aa0a6,
104}
105
106// ── furniture ────────────────────────────────────────────────────
107export const DESK: Sprite = [
108 'tttttttttttttttttt',
109 'ffffffffffffffffff',
110 'fFFFFFFFFFFFFFFFFf',
111 'fFFFFFFFFFFFFFFFFf',
112 'l................l',
113 'l................l',
114]
115export const MONITOR: Sprite = ['mmmmmm', 'mSSSSm', 'mSSSSm', 'mSSSSm', 'mmmmmm', '..mm..']
116export const PLANT: Sprite = ['.g.G.', 'gGgGg', '.gGg.', 'GgGgG', '.ooo.', '.ooo.', '..o..']
117export const COFFEE: Sprite = ['kkkkk', 'kRRRk', 'kkkkk', 'k.w.k', 'kkkkk']
118export const SHELF: Sprite = ['bbbbbbbb', 'b1234123', 'bbbbbbbb', 'b3412341', 'bbbbbbbb']
119export const WINDOW: Sprite = [
120 'wwwwwwwwww',
121 'wGGGGwHGGw',
122 'wGGGGwGGGw',
123 'wwwwwwwwww',
124 'wGGHGwGGGw',
125 'wGGGGwGGGw',
126 'wwwwwwwwww',
127]
128export const DOOR: Sprite = ['dddddd', 'dDDDDd', 'dDDDDd', 'dDDDDd', 'dDDDyd', 'dDDDDd', 'dDDDDd', 'dDDDDd']
129
130// ── looks: palette swaps per character id ───────────────────────
131type Look = { h: number; s: number; c: number; C: number; p: number; k: number }
132export const LOOKS: Readonly<Record<string, Look>> = {
133 'dev-1': { h: 0x3b2417, s: 0xf1c27d, c: 0x4a7bd1, C: 0x3a62a8, p: 0x2d2d3a, k: 0x1a1a1a },
134 'dev-2': { h: 0xe8c547, s: 0xffdbac, c: 0xd9534f, C: 0xb03f3c, p: 0x34495e, k: 0x222222 },
135 'dev-3': { h: 0x111111, s: 0x8d5524, c: 0x2ecc71, C: 0x25a25a, p: 0x2c3e50, k: 0x111111 },
136 'dev-4': { h: 0xb5651d, s: 0xe0ac69, c: 0x9b59b6, C: 0x7d4592, p: 0x3d3d3d, k: 0x1a1a1a },
137 'dev-5': { h: 0xc0c0c0, s: 0xffe0bd, c: 0xf39c12, C: 0xc87f0a, p: 0x1f3a5f, k: 0x202020 },
138 'dev-6': { h: 0xd1495b, s: 0xc68642, c: 0x1abc9c, C: 0x159a80, p: 0x2b2b2b, k: 0x111111 },
139 'dev-7': { h: 0x2c1b10, s: 0xffcd94, c: 0xecf0f1, C: 0xbdc3c7, p: 0x5d4037, k: 0x3e2723 },
140 'dev-8': { h: 0x6a3d9a, s: 0xa1665e, c: 0xe84393, C: 0xc2367a, p: 0x2d3436, k: 0x111111 },
141}
142export const LOOK_IDS = Object.keys(LOOKS)
143
144export function lookPalette(character: string, fallbackIndex: number): Palette {
145 const look = LOOKS[character] ?? LOOKS[LOOK_IDS[fallbackIndex % LOOK_IDS.length]!]!
146 return { ...look, e: 0x1a1a1a }
147}
148
149export const FURNITURE_PALETTE: Palette = {
150 t: 0xa0703f, f: 0x8b5a2b, F: 0x7a4d24, l: 0x4a2e15,
151 m: 0x2b2b2b, S: 0x1e2a35,
152 g: 0x3f9b4a, G: 0x2f7a38, o: 0xb5543c,
153 k: 0x3a3a3a, R: 0xc0392b, w: 0xdddddd,
154 b: 0x6b4a2b, 1: 0xd94848, 2: 0x4a90d9, 3: 0x3fae5a, 4: 0xe0b23f,
155 d: 0x5a3a22, D: 0x7a5232, y: 0xffd23f,
156}
157export const WINDOW_PALETTE = (isNight: boolean): Palette => ({
158 w: 0x6b5a45, G: isNight ? 0x1b2a4a : 0x8fd3ff, H: isNight ? 0xfff3b0 : 0xd5f0ff,
159})
160
161/** Problems with a sprite against its palette: ragged rows or unknown keys. */
162export function spriteProblems(name: string, s: Sprite, pal: Palette): string[] {
163 const out: string[] = []
164 const w = s[0]?.length ?? 0
165 s.forEach((row, j) => {
166 if (row.length !== w) out.push(`${name}: row ${j} is ${row.length} wide, expected ${w}`)
167 for (const ch of row) if (ch !== '.' && pal[ch] === undefined) out.push(`${name}: row ${j} has unknown key '${ch}'`)
168 })
169 return out
170}
171hooks/core/standup.ts 106 lines1// Standup: one request, every session answers from its own transcript.
2import type { StandupAnswer, StandupRequest } from '../../types'
3import { clean, truncate } from './text'
4
5export const ANSWER_WINDOW_MS = 5 * 60 * 1000 // a session answers requests this fresh
6export const SHOW_MS = 10 * 60 * 1000 // a pane shows a standup this long
7export const PENDING_MS = 90 * 1000 // after this, a missing answer is "(no answer)"
8export const TURN_MS = 5000 // each presenter holds the floor this long
9export const KEEP_MS = 60 * 60 * 1000 // files older than this are swept
10
11export const STANDUP_PROMPT = `Standup time. Report on the work in this conversation so far, for a teammate glancing at a dashboard.
12Answer with exactly three lines and nothing else:
13Done: <what you finished, at most 12 words>
14Next: <what you are doing or will do next, at most 12 words>
15Blocked: <what you need from the user, or "nothing">`
16
17const LINE = 100
18
19export type Report = { done: string; next: string; blocked: string }
20
21/** Reads the model's three lines; tolerates bullets, bold, odd casing and missing lines. */
22export function parseReport(text: string): Report {
23 const out: Report = { done: '', next: '', blocked: '' }
24 const loose: string[] = []
25 for (const raw of text.split('\n')) {
26 const line = raw.replace(/^[\s>*\-•\d.)]+/, '').replace(/\*\*|__|`/g, '').trim()
27 const m = /^(done|next|blocked|blockers?)\s*[:\-–]\s*(.*)$/i.exec(line)
28 if (m) {
29 const key = m[1]!.toLowerCase().startsWith('block') ? 'blocked' : (m[1]!.toLowerCase() as 'done' | 'next')
30 if (!out[key]) out[key] = truncate(clean(m[2]!), LINE)
31 } else if (line) loose.push(line)
32 }
33 if (!out.done && !out.next && loose.length) out.done = truncate(clean(loose.join(' ')), LINE)
34 if (/^(nothing|none|no|n\/a|-)\.?$/i.test(out.blocked)) out.blocked = ''
35 return out
36}
37
38const ID = /^\d{13}-[0-9a-z]{6}$/
39const isStr = (v: unknown, max: number): v is string => typeof v === 'string' && v.length <= max
40
41export function makeRequest(id: string, by: string, now: number): StandupRequest {
42 return { v: 1, id, by, requestedAt: now }
43}
44
45function json(text: string): Record<string, unknown> | null {
46 try {
47 const v: unknown = JSON.parse(text)
48 return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null
49 } catch {
50 return null
51 }
52}
53
54export function parseRequest(text: string): StandupRequest | null {
55 const r = json(text)
56 return r && r.v === 1 && isStr(r.id, 20) && ID.test(r.id) && isStr(r.by, 100) && typeof r.requestedAt === 'number'
57 ? (r as StandupRequest)
58 : null
59}
60
61export function parseAnswer(text: string): StandupAnswer | null {
62 const r = json(text)
63 const ok =
64 r && r.v === 1 && isStr(r.id, 100) && r.id.length > 0 && isStr(r.name, 100) && isStr(r.character, 50) &&
65 isStr(r.done, LINE + 5) && isStr(r.next, LINE + 5) && isStr(r.blocked, LINE + 5) && typeof r.at === 'number'
66 return ok ? (r as StandupAnswer) : null
67}
68
69/** The newest request file name (`<id>.json`) still inside `windowMs`, by id time. */
70export function latestRequest(names: string[], now: number, windowMs: number): string | null {
71 const ids = names.filter(n => n.endsWith('.json') && ID.test(n.slice(0, -5))).map(n => n.slice(0, -5)).sort()
72 const id = ids.at(-1)
73 return id && now - Number(id.slice(0, 13)) <= windowMs ? id : null
74}
75
76/** Who presents now: answers take TURN_MS turns in answer order, then wrap. */
77export function presenter(answers: StandupAnswer[], startedAt: number, now: number): string | null {
78 if (!answers.length) return null
79 const order = [...answers].sort((a, b) => a.at - b.at || a.id.localeCompare(b.id))
80 const turn = Math.floor(Math.max(0, now - startedAt) / TURN_MS) % order.length
81 return order[turn]!.id
82}
83
84/**
85 * The conversation as plain text for a standup asked without a fork (a resumed
86 * session has nothing to fork until its next turn): the newest messages that fit.
87 */
88export function digest(messages: { role: string; text: string; toolUses?: { tool: string }[] }[], max = 8000): string {
89 const lines: string[] = []
90 let size = 0
91 for (const m of [...messages].reverse()) {
92 const tools = m.toolUses?.length ? ` [tools: ${[...new Set(m.toolUses.map(t => t.tool))].join(', ')}]` : ''
93 const line = `${m.role}: ${truncate(clean(m.text), 600)}${tools}`
94 if (size + line.length > max) break
95 lines.unshift(line)
96 size += line.length + 1
97 }
98 return lines.join('\n')
99}
100
101/** What a presenter's bubble says. */
102export function headline(a: StandupAnswer): string {
103 if (a.blocked) return `Blocked: ${a.blocked}`
104 return a.done ? `Done: ${a.done}` : a.next ? `Next: ${a.next}` : 'Nothing to report.'
105}
106hooks/core/text.ts 79 lines1// Text shown in bubbles and dialogue: never trusted, always cleaned.
2
3const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*(\x07|\x1b\\)/g
4const CONTROL = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g
5
6export function clean(text: string): string {
7 return text.replace(ANSI, '').replace(CONTROL, ' ').replace(/\s+/g, ' ').trim()
8}
9
10export function truncate(text: string, max: number): string {
11 const chars = [...text]
12 return chars.length <= max ? text : chars.slice(0, Math.max(0, max - 1)).join('') + '…'
13}
14
15const SECRETS: [RegExp, string][] = [
16 [/\b([A-Z0-9_]*(KEY|TOKEN|SECRET|PASSWORD|PASS)[A-Z0-9_]*)=\S+/gi, '$1=***'],
17 [/\b(Bearer|Basic)\s+\S+/gi, '$1 ***'],
18 [/(--?(password|token|secret|api-key)[= ])\S+/gi, '$1***'],
19 [/\b(sk|pk|ghp|gho|xox[abp])[-_][A-Za-z0-9_-]{8,}/g, '***'],
20]
21
22export function redact(text: string): string {
23 return SECRETS.reduce((s, [re, to]) => s.replace(re, to), text)
24}
25
26/** The first sentence of a model reply, markdown stripped, for a bubble. */
27export function firstSentence(text: string): string {
28 const plain = text
29 .replace(/```[\s\S]*?```/g, ' ')
30 .replace(/[#*_`>|]/g, '')
31 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
32 const line = plain.split('\n').map(l => l.trim()).find(l => l.length > 0) ?? ''
33 const match = /^(.+?[.!?])(\s|$)/.exec(line)
34 return clean(match?.[1] ?? line)
35}
36
37/** A desk title from a prompt: its first sentence, or '' for a command or a reply too short to name the work ("yes", "go on"). */
38export function taskOf(prompt: string): string {
39 const body = prompt.replace(/^\s*\[[^\]\n]*\]\s*\n/, '') // Pixel Office's "[Sent to you…]" label
40 if (/^\s*[/<]/.test(body)) return ''
41 const s = redact(firstSentence(body))
42 // ponytail: three words = "names the work"; a real summary needs a model call
43 return s.split(' ').length >= 3 ? truncate(s, 80) : ''
44}
45
46/** A bubble: cleaned, redacted, cut to fit. */
47export function bubble(text: string, max = 60): string {
48 return truncate(redact(clean(text)), max)
49}
50
51export function basename(path: string): string {
52 return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path
53}
54
55/** Wrap to lines of at most `width` chars, breaking long words. */
56export function wrap(text: string, width: number): string[] {
57 const lines: string[] = []
58 let line = ''
59 for (const word of text.split(' ').filter(Boolean)) {
60 let w = word
61 // a word longer than a line fills what is left of this one, then hard-wraps
62 while ([...w].length > width) {
63 const room = line ? width - [...line].length - 1 : width
64 if (room <= 0) {
65 lines.push(line), (line = '')
66 continue
67 }
68 lines.push(line ? `${line} ${[...w].slice(0, room).join('')}` : [...w].slice(0, room).join(''))
69 line = ''
70 w = [...w].slice(room).join('')
71 }
72 if (!line) line = w
73 else if ([...line].length + 1 + [...w].length <= width) line += ' ' + w
74 else lines.push(line), (line = w)
75 }
76 if (line) lines.push(line)
77 return lines
78}
79hooks/core/rooms.ts 62 lines1// Rooms: every session in one repository (worktrees included) shares an office room.
2import type { SpawnTicket } from '../../types'
3import type { Seat } from './roster'
4import { basename } from './text'
5
6/** The repository name in a remote URL: git@host:acme/web-app.git → web-app. */
7export function remoteName(remote: string | null | undefined): string {
8 if (!remote) return ''
9 const last = remote.replace(/[?#].*$/, '').replace(/\/+$/, '').split(/[/:]/).at(-1) ?? ''
10 return last.replace(/\.git$/, '')
11}
12
13/** A session's room: its repository root (a worktree's main tree), else its folder. */
14export function roomOf(cwd: string, repo: { root: string; remote: string | null } | null): { room: string; roomName: string } {
15 const room = repo?.root || cwd
16 return { room, roomName: remoteName(repo?.remote) || basename(room) || 'office' }
17}
18
19export type Room = { id: string; name: string; seats: Seat[]; waiting: number }
20
21/** Seats grouped by room, rooms ordered by who needs you, then by name. */
22export function groupRooms(seats: Seat[]): Room[] {
23 const byRoom = new Map<string, Room>()
24 for (const s of seats) {
25 const id = s.room || s.cwd || 'office'
26 let r = byRoom.get(id)
27 if (!r) byRoom.set(id, (r = { id, name: s.roomName || basename(id) || 'office', seats: [], waiting: 0 }))
28 r.seats.push(s)
29 if (s.state === 'needs-you' && !s.isAway) r.waiting++
30 }
31 return [...byRoom.values()].sort((a, b) => b.waiting - a.waiting || a.name.localeCompare(b.name) || a.id.localeCompare(b.id))
32}
33
34/** `base`, or `base 2`, `base 3`… so two sessions in one room never share a name. */
35export function uniqueName(base: string, taken: string[]): string {
36 const used = new Set(taken.map(n => n.toLowerCase()))
37 if (!used.has(base.toLowerCase())) return base
38 for (let i = 2; ; i++) if (!used.has(`${base} ${i}`.toLowerCase())) return `${base} ${i}`
39}
40
41// ── spawn tickets ─────────────────────────────────────────────────
42export const TICKET_MS = 3 * 60 * 1000
43const TICKET_ID = /^\d{13}-[0-9a-z]{6}$/
44
45export function parseTicket(text: string): SpawnTicket | null {
46 try {
47 const t = JSON.parse(text) as Record<string, unknown>
48 const ok =
49 t && t.v === 1 && typeof t.id === 'string' && TICKET_ID.test(t.id) && typeof t.room === 'string' && t.room.startsWith('/') &&
50 typeof t.name === 'string' && t.name.length <= 40 && typeof t.task === 'string' && t.task.length <= 4000 &&
51 typeof t.character === 'string' && t.character.length <= 20 && typeof t.createdAt === 'number'
52 return ok ? (t as SpawnTicket) : null
53 } catch {
54 return null
55 }
56}
57
58/** Fresh tickets for this room, oldest first: the order a new session tries to claim them in. */
59export function ticketsFor(tickets: SpawnTicket[], room: string, now: number): SpawnTicket[] {
60 return tickets.filter(t => t.room === room && now - t.createdAt >= 0 && now - t.createdAt < TICKET_MS).sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id))
61}
62hooks/core/signals.ts 95 lines1// What a tool call looks like in the office: an activity, a bubble a person can read at a
2// glance ("Running tests", "Editing auth.ts"), and the raw detail for whoever wants it.
3import type { AgentState } from '../../types'
4import { basename, bubble } from './text'
5
6export type Activity = { state: AgentState; bubble: string; detail: string }
7
8type Input = Record<string, unknown>
9const str = (v: unknown): string => (typeof v === 'string' ? v : '')
10
11/** The step of a shell line that says what it is for: past `cd x &&`, env assignments and `sudo`. */
12function mainStep(command: string): string {
13 const steps = command.split(/&&|\|\||;|\n/).map(s => s.trim()).filter(Boolean)
14 const step = steps.find(s => !/^(cd|export|set|source|\.)\s/.test(s) && !/^[A-Z_][A-Z0-9_]*=\S*$/.test(s)) ?? steps[0] ?? ''
15 return step.replace(/^(\s*[A-Z_][A-Z0-9_]*=\S*\s+)+/, '').replace(/^(sudo|time|npx|bunx|exec)\s+/, '')
16}
17
18// order matters: installing a test or build tool is still installing
19const RULES: [RegExp, string][] = [
20 [/\b(npm|pnpm|yarn|bun)\s+(i|install|add|ci)\b|pip3? install|brew install|cargo add|go get|poetry add|uv (add|pip)/, 'Installing packages'],
21 [/\b(test|jest|vitest|pytest|mocha|rspec|phpunit|ava|tap)\b|go test|cargo test|plugin test|--test\b/, 'Running tests'],
22 [/\b(tsc|eslint|ruff|mypy|pyright|lint|prettier|biome|typecheck|clippy|check\.sh|validate)\b/, 'Checking types and lint'],
23 [/\b(build|esbuild|webpack|rollup|vite build|tsup|make|gradle|mvn|xcodebuild)\b|cargo build|go build|vsce package/, 'Building'],
24 [/^git (commit|cherry-pick|rebase|merge)/, 'Committing'],
25 [/^git push/, 'Pushing'],
26 [/^git (pull|fetch|clone)/, 'Pulling code'],
27 [/^git (diff|status|log|show|blame)/, 'Reviewing changes'],
28 [/^git /, 'Using git'],
29 [/^gh pr\b/, 'Working on a pull request'],
30 [/^gh /, 'Using GitHub'],
31 [/\b(dev|start|serve)\b|runserver|flask run|uvicorn|rails s/, 'Starting the app'],
32 [/^(curl|wget|http|xh)\b/, 'Calling a URL'],
33 [/^(docker|docker-compose|kubectl|helm|terraform)\b/, 'Working on infrastructure'],
34 [/^(ls|cat|head|tail|find|grep|rg|ag|sed -n|wc|tree|pwd|stat|file|du|less|awk)\b/, 'Looking through the code'],
35 [/^(mkdir|rm|mv|cp|touch|chmod|ln|tar|unzip|zip)\b/, 'Moving files around'],
36 [/^(node|python3?|ruby|deno|bun|go run|cargo run|java|php|sh|bash|zsh)\b/, 'Running a script'],
37 [/^(ps|kill|pkill|lsof|top)\b/, 'Checking processes'],
38]
39
40/** "npm test -- login.spec.ts" → "Running tests"; anything unknown is "Running a command". */
41export function describeCommand(command: string): string {
42 const step = mainStep(command)
43 return RULES.find(([re]) => re.test(step))?.[1] ?? 'Running a command'
44}
45
46export function activityFor(tool: string, input: Input = {}): Activity {
47 const file = basename(str(input.file_path) || str(input.notebook_path) || str(input.path))
48 const act = (state: AgentState, text: string, detail = ''): Activity => ({ state, bubble: bubble(text), detail: bubble(detail, 160) })
49 switch (tool) {
50 case 'Bash':
51 return act('typing', describeCommand(str(input.command)), `$ ${str(input.command)}`)
52 case 'Read':
53 return act('reading', file ? `Reading ${file}` : 'Reading', str(input.file_path))
54 case 'Grep':
55 return act('reading', str(input.pattern) ? `Searching for ${str(input.pattern)}` : 'Searching the code', str(input.path))
56 case 'Glob':
57 case 'LS':
58 return act('reading', 'Finding files', str(input.pattern) || str(input.path))
59 case 'Edit':
60 case 'MultiEdit':
61 case 'NotebookEdit':
62 return act('writing', file ? `Editing ${file}` : 'Editing', str(input.file_path) || str(input.notebook_path))
63 case 'Write':
64 return act('writing', file ? `Writing ${file}` : 'Writing a file', str(input.file_path))
65 case 'TodoWrite':
66 return act('writing', 'Updating the plan')
67 case 'WebSearch':
68 return act('browsing', `Searching the web: ${str(input.query)}`, str(input.query))
69 case 'WebFetch':
70 return act('browsing', `Reading ${hostOf(str(input.url))}`, str(input.url))
71 case 'Agent':
72 case 'Task':
73 return act('delegating', `Delegating: ${str(input.description) || 'a subtask'}`, str(input.prompt))
74 case 'AskUserQuestion':
75 return act('needs-you', firstQuestion(input) || 'I have a question')
76 }
77 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
78 if (mcp) return act('working', `Using ${mcp[1]}: ${mcp[2]!.replace(/_/g, ' ')}`)
79 return act('working', `Using ${tool}`)
80}
81
82function hostOf(url: string): string {
83 try {
84 return new URL(url).host
85 } catch {
86 return url
87 }
88}
89
90function firstQuestion(input: Input): string {
91 const qs = input.questions
92 if (Array.isArray(qs) && qs[0] && typeof qs[0] === 'object') return str((qs[0] as Input).question)
93 return ''
94}
95