A live pixel-art office showing what every agent in the session is doing

A Claude Code mod that draws a live pixel-art office in the Claude desktop app. Every agent in the session is a character with a job title. Main is the coordinator: it talks with you, splits big jobs into parts and hires helpers for them. Helpers arrive by elevator, get briefed in the meeting room, walk to the station that matches the tool they are using, carry their report back to whoever hired them and leave. You can see who is waiting on whom, what each one was asked to do, and what each one found.
<img src="docs/images/office.svg" alt="The Pixel Office, a pixel-art floor plan. Main sits in the corner office with a speech bubble that reads waiting on 2. The Planner stands in the records library with a bubble reading session.ts. The Researcher walks across the floor carrying a yellow folder, its report, back to Main. In the meeting room the Assistant sits beside a new helper, Test fails, with a bubble reading waiting on Test fails, and the whiteboard reads find why a test fails." width="100%">
The picture above is the mod's own drawing of a made-up session, and it is animated. Main hired three helpers at once and is coordinating: its bubble says it is waiting on two of them. The Researcher has finished and is carrying its report to Main. The Assistant found a failing test and hired a helper of its own, so it is coordinating too, from the meeting room. To try it yourself, jump to Install.
<img src="docs/images/office-map.svg" alt="The same office with a coloured label on every zone: Corner office, Desks, Records library, Phone booths, Mailroom, Server room, Meeting room, Task board, Reception, Elevator and Break room." width="100%">
Each label names a zone. An agent walks to the zone that matches the tool it is using, so where a character stands tells you what it is doing. The same colours appear in the speech bubbles and in the legend under the picture.
| Zone | Stands for | What you see there |
|---|---|---|
| Corner office | Main's home base: thinking, or coordinating its helpers | Main sits here and goes back after every errand. Main's own edits and writes happen at this desk too. While it waits on helpers, its bubble says who: "waiting on Researcher", or "waiting on 3". |
| Desks | Edit, Write, NotebookEdit | Eight desks. Each helper gets its own and returns to it between jobs. |
| Records library | Read, Grep, Glob, LSP, Skill, ToolSearch | Looking things up: reading files and searching code. |
| Phone booths | WebFetch, WebSearch | Going out to the web. A booth light comes on while someone is on a call. |
| Mailroom | Outside services (mcp__ tools) | Calling connected services. A parcel appears in the pigeonholes while someone is there. |
| Server room | Bash, PowerShell, background shells | Running commands. The rack lights flash while a command runs. |
| Meeting room | Briefings, SendMessage | A new helper is briefed here, and the whiteboard shows what it was hired to do. When one agent messages another, the sender carries an envelope over to the recipient, or comes here if it cannot find them. |
| Task board | TodoWrite, Task tools, plan updates | A cork board on the wall. Green notes are done, amber notes are still open. |
| Reception | Waiting on you: questions, permissions, plan approval | A blinking "!" bubble means this agent cannot go on until you answer. |
| Elevator | Agents arrive and leave | Helpers step out of it when they are hired and step back in when they finish. |
| Break room | Idle agents, and Main between turns after a while | Where anyone with nothing to do goes to rest. |
Every helper is named for the job its kind of agent does, so you can tell what it can and cannot do at a glance. The name Claude Code uses for that kind of agent is in its details box.
| In the office | Claude Code calls it | What it does |
|---|---|---|
| Main, the Coordinator | the main agent | Talks with you, splits the work into jobs, hires helpers and puts their reports together. |
| Researcher | Explore | Reads and searches, then reports what it found. Changes nothing. |
| Planner | Plan | Works out a step-by-step plan for a job. Changes nothing. |
| Assistant | general-purpose | Can read, edit and run commands to finish one job. |
| Clone | fork | A copy of its boss that knows everything its boss knew, sent off to do a side job. |
| Guide | claude-code-guide | Looks up how Claude Code itself works. |
| Contractor | (unknown) | A helper the office did not see being hired. |
Any other kind of agent is named after its type: a code-reviewer is a "Code reviewer". A helper the engine gave a name keeps it ("Scout"), and its details box adds the role. A second helper of the same kind is named for its job, as in "Test fails" (a researcher hired to find why a test fails); with no job to go on, or the same job, it is numbered, as in "Researcher 2".
How they work together. A boss hires a helper in one of two ways, and the office shows which:
Helpers can hire helpers of their own, so a chain of delegation shows up as a deeper indent in the panel and as a second coordinator on the floor. Teammates (helpers started as part of a team) are standing colleagues rather than errands: nobody waits on them, and they rest in the break room between jobs.
A helper is a subagent: a short-lived agent that Main (or another agent) hires for one job. In the picture it goes through these steps.
auth.ts or Test the code. It sets off with the helper, so you see the task before it arrives, and the furniture it is using is outlined in the station's colour until it leaves. Helpers standing close together get their bubbles in rows, one above the other; where the top of the floor leaves no room for another row, that helper's bubble is left out and the panel still says what it is doing. The feed says it in words: "Researcher is reading auth.ts", "Assistant is running a command to run the test suite" (a command is shown by what it is for, not by how it starts. Claude usually describes a command when it runs one, and that description is used when it fits the bubble. Without one the office reads the command: it skips the setup in front (cd, variables, mkdir), follows a chain, a pipe or a heredoc to the first real step, and names it, such as "Open a pull request", "Write bar.ts" or "Search for sceneTimeOf". It knows git, GitHub, the package managers, scripts, file and text tools, the network, containers and PowerShell; a program it does not know is shown as typed. A to-do list update shows the item being worked on, a search shows where it looks, and a call to an outside service shows what it is about).Two signs worth knowing. A red puff above a character means a tool call just hit an error. A blinking "!" bubble at reception means that agent is waiting on you, for a question, a permission or a plan to approve.
<img src="docs/images/pane-preview.svg" alt="The Pixel Office pane. The office picture sits on top. Underneath, on the left, the feed What's happening lists the last few events with their times. On the right, Who's doing what lists Main as coordinating, waiting on the Planner and the Assistant; the Planner reading session.ts; the Assistant coordinating its own helper, Test fails, who is being briefed; and the Researcher, done, having handed in its report. A dim line under each helper gives its objective. A details box for the Researcher shows its role, its job, its brief, who it reports to, the start of its report, its tokens and how long it was on the job. A Show legend button and a Show this job button sit at the bottom." width="100%">
The numbers below match the numbers in the picture.
The office picture is drawn as one picture by default. There is an experimental layered mode, where the scenery is one picture and each agent is its own transparent picture stacked on top of it, so one agent doing something would never restart another agent's walk. A walker is left alone from the moment its walk starts until it arrives, and the cat is its own picture so nothing else restarts it. Its first version did not show the agents in the author's pane, because their pictures were laid out with no room. They are given the whole region now, but that has not been checked in a pane yet, so treat it as an experiment; set the picture option to layered to try it (see Commands and options).
There is a cat in the office. It has nothing to do with your session: it patrols the corridor, sits for a while at a few favourite spots, and naps on the break-room couch, on a loop of about a minute. It is drawn as animations inside the picture, so it costs no extra redraws, and it carries on from the same point of its loop whenever the picture is drawn again. It is left out only when the picture is too large to draw the decorations.
A job is everything from a message you send until the next one. A notice from a background helper or another session that wakes Main is not a new message, so it carries on the same job. Once you have sent one, a Show this job button sits beside the legend button. It opens a recap that answers the question delegation raises: did splitting the work up help? If Main edited files itself rather than through a helper, the recap counts those edits too, so a coordinator that is not delegating shows.
<img src="docs/images/job-recap.svg" alt="The recap of a finished job. This job: fix the login bug. Took 4 minutes 22 seconds, 4 helpers, up to 4 at once, 207 thousand tokens. Main made 1 file edit itself, not through a helper. Helpers did 8 minutes 28 seconds of work in 3 minutes 40 seconds: about 2.3 times faster than one after another. A timeline shows one bar per agent: Main across the whole job; the Researcher, Planner and Assistant starting together; Test fails, hired by the Assistant, starting later. Each helper's bar ends with a tick and how long it took, with its share of the tokens beside it. Under it, one line per agent gives its time, tokens and how its job ended." width="100%">
The recap only shows the job in front of you. To keep a short record of each job across sessions, set the history option to last-20 (see Commands and options): the recap then lists your earlier jobs too. It is off unless you switch it on.
The recap also shows a This session line: the jobs, helpers, time and tokens of every job since the conversation started or was cleared, with the tokens split between Main and its helpers. It is kept in memory only, unless the history option is on, in which case each saved job keeps its token split too. Each agent shows its share of the job's tokens next to its time bar. An agent that is still running shows "running" there, because the engine only reports its tokens when it finishes.
In a terminal session the pane shows the same feed and team as plain text, under a line telling you to open the session in the Claude desktop app to see the picture.
Option 1: Using only the Claude desktop app? The desktop app's Code tab has no /plugin install and usually no claude on your PATH, but the app bundles its own Claude Code CLI. Start a Code session in the app and paste this prompt:
Install the "Pixel Office" Claude Code mod for me. Use the Claude Code CLI bundled with this desktop app: on Windows it is under `%APPDATA%\Claude\claude-code\<version>\…\claude.exe`, on macOS under `~/Library/Application Support/Claude/claude-code/<version>/` (pick the newest version folder; use `claude` on PATH only if it is 2.1.295 or newer). Run `<that claude> --version` and confirm it is 2.1.295 or newer. Then run `<that claude> plugin marketplace add sb2403/pixel-office` and `<that claude> plugin install pixel-office@pixel-office --config autoOpen=session-start`, show me `<that claude> plugin list`, and tell me to start a new session.
Approve the commands when Claude asks. The --config autoOpen=session-start in that prompt makes the office open at the start of every session; to change that, see Commands and options below. If the bundled version is older than 2.1.295, update the Claude desktop app first. Then start a new session and the office opens.
Option 2: From a terminal.
Check your version. Run claude --version in a terminal. It must show 2.1.295 or newer. If it is older, run claude update first.
Install from a terminal. Run these two commands in PowerShell, Command Prompt, or the macOS/Linux Terminal, not in the Claude chat box:
claude plugin marketplace add sb2403/pixel-office
claude plugin install pixel-office@pixel-office
The same two steps also work inside a terminal Claude Code session, as /plugin marketplace add sb2403/pixel-office then /plugin install pixel-office@pixel-office.
The Claude desktop app's built-in plugin browser won't find this mod; use Option 1 or Option 2 above. Once installed, it loads in desktop sessions too.
Nothing asks you anything: the install goes ahead at user scope, which is the default, and then prints a note that options are not set yet. That is fine: each stays at its default. autoOpen, the option for when the office opens by itself, stays at on-first-subagent, and history stays off. To pick another value, add --config autoOpen=session-start (or never) to the install line, or set it later with /plugin configure pixel-office@pixel-office or the --values-stdin form under Commands and options below. Start a new session: the office opens the first time a subagent is hired, or any time with /office.
Fallback. If those commands do not work, download the repository from GitHub (Code → Download ZIP, then unzip it) or run git clone https://github.com/sb2403/pixel-office. Then run these two commands in a terminal, with the path set to the unzipped or cloned folder:
claude plugin marketplace add "<path-to-the-unzipped-folder>"
claude plugin install pixel-office@pixel-office
The quotes are needed if the path has spaces in it, and harmless if it does not.
/office shows or hides the office pane. Type it in any session, whatever autoOpen is set to. Its one-line reply is the only thing the mod ever adds to your conversation.
autoOpen decides when the office opens by itself:
on-first-subagent (default): the first time a subagent is hired.session-start: as soon as a session starts.never: only when you run /office.history decides whether the office keeps a record of your jobs across sessions:
off (default): nothing is kept once the session ends.last-20: the newest 20 jobs are kept on this computer, in the mod's own store: the start of the message that began each one (up to 120 characters), when it started, how long it took, how many helpers and how many at once, and its tokens. The recap lists them under "Earlier jobs".notify decides whether the office reminds you when an agent is waiting on you:
off (default): no reminders.after-30s: once an agent has waited 30 seconds for your answer, permission or plan approval, the office says so once, naming the agent ("Researcher is waiting for your permission"). It shows a toast over the conversation, and raises a notification through your notification setting. In a terminal that is a desktop notification or a bell, as your terminal does them; the Claude desktop app shows the toast, and only your own notification hooks run there. Claude Code may already notify you when it needs you; if the two double up, leave this off.picture decides how the office is drawn:
single (default): one picture for everybody. The cost is that every change to the picture starts the walks in it again, so walkers jump a little whenever the app takes longer or shorter to show one picture than it took for the one before.layered (experimental): each agent is its own picture stacked over the scenery, so one agent moving never restarts another one walking. Its first version drew the scenery but not the agents. That is fixed, but not yet checked in a pane, so go back to single if the agents do not show; with debug on, the log says how much room the agents' pictures got.debug is for troubleshooting (for example, walkers that jump), and is off by default:
off (default): nothing is recorded, and nothing extra runs.on: the mod keeps a log of what it does and writes it to pixel-office/debug/log.jsonl inside the plugin's folder, rewritten at most once a second and capped at the newest 3000 lines. It also draws two invisible one-cell probes in the pane (top and bottom) that report when the app mounts them, which happens again after the app unmounts and remounts them (for example when you scroll), and once a second the size of their region. With the layered picture there is a third probe, in a box laid out as an agent's picture is, so the log says how much room those pictures get: no rows means the app gave them none. Only event kinds, agent ids, tool names, counts, sizes and timings are logged: never commands, file names, labels, briefs, reports or any of your text. Send the file when asked to look into a problem, and set debug back to off afterwards.t (milliseconds) and kind: render (the pane was drawn, with its scroll and panel state), draw (a picture was drawn again, and why), layers (how many agent pictures a render had and how many were redrawn), event (something an agent did), flush (a batch of events applied, and how long it took), turn-start, turn-ignored (a helper's own turn, which is not a new task) and probe (what the pane reported back).Optional. To set it while installing, instead of answering the options screen, run this in place of the second install command (after claude plugin marketplace add sb2403/pixel-office):
claude plugin install pixel-office@pixel-office --config autoOpen=session-start
To change it later, run /plugin configure pixel-office@pixel-office inside Claude Code, or save the value from a terminal by piping a JSON object to configure:
echo '{"autoOpen":"never"}' | claude plugin configure pixel-office@pixel-office --values-stdin
That one-liner is for PowerShell and for macOS/Linux shells. In Command Prompt (cmd.exe) the single quotes would be passed through as part of the text, so use /plugin configure there, or run it from PowerShell.
Either way the new value applies to sessions you start afterwards. claude plugin configure pixel-office@pixel-office on its own only shows the options and whether each is set; it changes nothing.
Main is "idle" instead of "working"? Main is working from the moment you send a message until it finishes its turn. A working Main with no speech bubble is thinking between tools, in the corner office. When the turn ends Main reads "idle" and stays where it stood. A few s
hooks/register.tsx 399 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, TurnUsage } from 'claude-code'
3
4import type { JobRecord, OfficeEvent, Usage } from '../types'
5import { debugOn, logText, note, takeDirty } from './debuglog'
6import { MAIN, emptyScene, needsSweep, reduceAt, restore } from './model'
7import { registerPane } from './pane'
8import { HISTORY_MAX, SESSION_MAX, isRecord, jobRecord, recapOf, withRecord } from './recap'
9import { LEAD_MS, nowOf } from './render'
10import { PANE, PANE_TITLE, PROBE_BOTTOM, PROBE_LAYER, PROBE_TOP } from './state'
11import { WAITING_TOOLS, detailOf, fullText, gist, stationFor, targetOf, todoCounts } from './stations'
12
13export const FLUSH_MS = 350
14// How much of a helper's brief and of its report the office keeps, to be read in full from its details box.
15export const TEXT_MAX = 2000
16// How much of your message names the job in its recap.
17export const TASK_MAX = 120
18const SWEEP_EVERY = 15
19const CLOCK_EVERY = 3
20const QUEUE_MAX = 500
21// With reminders on: how long an agent waits on you before the office says so, once per wait.
22export const REMIND_AFTER_MS = 30_000
23export const REMIND_TITLE = 'Pixel Office'
24// The debug log is written at most this often.
25const DEBUG_WRITE_MS = 1000
26
27// The history, when it is switched on, lives under one key of the mod's store.
28export const HISTORY_KEY = 'jobs'
29const UNTRACKED = new Set(['Agent', 'Task'])
30// These wake Main about work already under way, so they continue the job; a scheduled trigger, the composer,
31// the bridge and the SDK are a person's or a host's own new message.
32const NOT_A_NEW_JOB = new Set(['task-notification', 'peer', 'peer-send-message'])
33
34// Atoms cannot be imported across files, so each file that reads one declares it again; pane.tsx has the same three.
35const sceneAtom = atom({ plugin: 'pixel-office', key: 'scene' } as const, emptyScene())
36const selectedAtom = atom({ plugin: 'pixel-office', key: 'selected' } as const, null as string | null)
37const clockAtom = atom({ plugin: 'pixel-office', key: 'clock' } as const, 0)
38const expandedAtom = atom({ plugin: 'pixel-office', key: 'expanded' } as const, null as 'brief' | 'report' | null)
39const jobOpenAtom = atom({ plugin: 'pixel-office', key: 'jobOpen' } as const, false)
40const historyAtom = atom({ plugin: 'pixel-office', key: 'history' } as const, [] as JobRecord[])
41const sessionAtom = atom({ plugin: 'pixel-office', key: 'session' } as const, [] as JobRecord[])
42
43
44// A turn's token counts, summed over its requests (absent when nothing counted: an interrupt, an API error).
45const usageOf = (u: TurnUsage | undefined): Usage | null =>
46 u === undefined
47 ? null
48 : {
49 tokens: u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens,
50 reused: u.cache_read_input_tokens,
51 model: u.model,
52 }
53
54const quietly = async (work: () => Promise<void> | void): Promise<void> => {
55 try {
56 await work()
57 } catch {
58 // The office is best effort: it never disturbs the session it watches.
59 }
60}
61
62// `epoch` counts the /clears: a flush notes it when it takes the queue, so it can tell its batch has gone stale.
63type Office = {
64 queue: OfficeEvent[]
65 isFlushing: boolean
66 flushes: number
67 isOpened: boolean
68 wantsOpen: boolean
69 epoch: number
70 keepsHistory: boolean
71 hasJobToSave: boolean
72 reminds: boolean
73 // When each agent now waiting on you started to, and which of them were reminded about this wait.
74 waitingSince: Map<string, number>
75 reminded: Set<string>
76 // The opt-in debug log: whether it is on, and when it was last written.
77 isDebug: boolean
78 lastDebugWrite: number
79 // Where the latest prompt came from (its origin kind); the turn it starts reads and clears it.
80 lastOrigin: string | null
81}
82
83// The engine only lets `$` travel into functions declared at the top of this file, so the helpers that
84// take it live here and share the mutable bits through one plain object.
85async function openPane($: EngineInterface, office: Office): Promise<void> {
86 await $.ui.open({ id: PANE, title: PANE_TITLE })
87 office.isOpened = true
88}
89
90async function loadHistory($: EngineInterface): Promise<void> {
91 const saved = await $.store.get(HISTORY_KEY)
92 const list = Array.isArray(saved) ? saved.filter(isRecord).slice(-HISTORY_MAX) : []
93 await update($, historyAtom, () => list)
94}
95
96async function saveJob($: EngineInterface): Promise<void> {
97 const recap = recapOf(restore(await read($, sceneAtom)), await $.clock.now())
98 if (recap === null) return
99 const list = withRecord(await read($, historyAtom), jobRecord(recap))
100 await $.store.set(HISTORY_KEY, list)
101 await update($, historyAtom, () => list)
102}
103
104// The session total is kept in memory whatever the history option says.
105async function recordJob($: EngineInterface): Promise<void> {
106 const recap = recapOf(restore(await read($, sceneAtom)), await $.clock.now())
107 if (recap === null) return
108 await update($, sessionAtom, list => withRecord(list, jobRecord(recap), SESSION_MAX))
109}
110
111// An agent that has waited on you for a while is said once: a toast, which shows over the transcript wherever the
112// session is drawn, and a native notification, which the person's channel raises in a terminal (in the desktop
113// app only their own notification hooks run). A wait that ends and begins again is a new wait.
114async function remind($: EngineInterface, office: Office): Promise<void> {
115 const scene = restore(await read($, sceneAtom))
116 const now = await $.clock.now()
117 const waiting = Object.values(scene.agents).filter(a => a.phase === 'waiting')
118 for (const id of [...office.waitingSince.keys()]) {
119 if (waiting.some(a => a.id === id)) continue
120 office.waitingSince.delete(id)
121 office.reminded.delete(id)
122 }
123 for (const agent of waiting) {
124 const since = office.waitingSince.get(agent.id) ?? now
125 office.waitingSince.set(agent.id, since)
126 if (now - since < REMIND_AFTER_MS || office.reminded.has(agent.id)) continue
127 office.reminded.add(agent.id)
128 const text = `${agent.label} is ${nowOf(scene, agent)}`
129 $.ui.toast(text, { timeoutMs: 10_000 })
130 await $.ui.notify(text, { title: REMIND_TITLE })
131 }
132}
133
134async function flush($: EngineInterface, office: Office): Promise<void> {
135 if (office.isFlushing) return
136 office.isFlushing = true
137 await quietly(async () => {
138 office.flushes += 1
139 const now = await $.clock.now()
140 const batch = office.queue
141 const epoch = office.epoch
142 const began = office.isDebug ? now : 0
143 office.queue = []
144 const isSweepTurn = office.flushes % SWEEP_EVERY === 0
145 // Saved from the batch being applied, so the record never misses the end of the turn it is about.
146 if (batch.some(ev => ev.kind === 'turnEnded')) office.hasJobToSave = true
147 if (batch.length === 0 && !(isSweepTurn && needsSweep(restore(await read($, sceneAtom)), now))) return
148 // The pane draws the picture as of LEAD_MS ahead, right after this write: the walks these events start begin
149 // there, so the picture shows each one from its first step.
150 const shownAt = now + LEAD_MS
151 await update($, sceneAtom, scene => {
152 // A /clear since the queue was taken makes this batch (and sweep) belong to the old conversation. The
153 // updater reruns when a write misses its version, so the check holds however the two interleave.
154 if (office.epoch !== epoch) return scene
155 const applied = batch.reduce((s, ev) => reduceAt(s, ev, shownAt), restore(scene))
156 return isSweepTurn && needsSweep(applied, now) ? reduceAt(applied, { kind: 'tick', at: now }, shownAt) : applied
157 })
158 if (office.isDebug) note('flush', { batch: batch.length, ms: (await $.clock.now()) - began }, now)
159 })
160 // The "On the job" clock of a selected agent is the one thing that changes with no event behind it:
161 // about once a second, while a row is open, write the time to state. The pane reads that atom, and a state
162 // write is what makes the desktop pane redraw (a bare redraw request from this timer does not).
163 // Only a helper still on the job has a clock to keep: not Main, not one that has left, not a pruned id.
164 // The open recap of a job still running keeps time the same way.
165 await quietly(async () => {
166 if (office.flushes % CLOCK_EVERY !== 0) return
167 const scene = restore(await read($, sceneAtom))
168 const selected = await read($, selectedAtom)
169 const agent = selected === null || selected === MAIN ? undefined : scene.agents[selected]
170 const isClockShown = agent !== undefined && agent.phase !== 'gone'
171 const isJobShown = (await read($, jobOpenAtom)) && scene.job !== null && scene.job.endedAt === null
172 if (!isClockShown && !isJobShown) return
173 const now = await $.clock.now()
174 await update($, clockAtom, () => now)
175 })
176 // The history is written after the scene, so the record holds the turn that just ended.
177 const isJobDue = office.hasJobToSave
178 office.hasJobToSave = false
179 await quietly(async () => {
180 if (isJobDue) await recordJob($)
181 })
182 await quietly(async () => {
183 if (office.keepsHistory && isJobDue) await saveJob($)
184 })
185 await quietly(async () => {
186 if (office.reminds && office.flushes % CLOCK_EVERY === 0) await remind($, office)
187 })
188 // The debug log is a file the maintainer can read: rewritten as a whole, when it has changed, at most once a second.
189 await quietly(async () => {
190 if (!office.isDebug || !takeDirty()) return
191 const at = await $.clock.now()
192 if (at - office.lastDebugWrite < DEBUG_WRITE_MS) return
193 office.lastDebugWrite = at
194 await $.fs.write(`${$.plugin.root}/debug/log.jsonl`, logText())
195 })
196 // The session and spawn hooks only ask for the pane; it opens here, off their path, and a failed open tries again next flush.
197 await quietly(async () => {
198 if (office.wantsOpen && !office.isOpened) await openPane($, office)
199 })
200 office.isFlushing = false
201}
202
203export const register: Register = (on, options) => {
204 const autoOpen = String(options.autoOpen ?? 'on-first-subagent')
205 const office: Office = {
206 queue: [],
207 isFlushing: false,
208 flushes: 0,
209 isOpened: false,
210 wantsOpen: false,
211 epoch: 0,
212 keepsHistory: String(options.history ?? 'off') === 'last-20',
213 hasJobToSave: false,
214 reminds: String(options.notify ?? 'off') === 'after-30s',
215 waitingSince: new Map(),
216 reminded: new Set(),
217 isDebug: String(options.debug ?? 'off') === 'on',
218 lastDebugWrite: 0,
219 lastOrigin: null,
220 }
221 debugOn(office.isDebug)
222
223 const push = (ev: OfficeEvent): void => {
224 if (office.isDebug) {
225 const r = ev as Record<string, unknown>
226 const agent = typeof r.agentId === 'string' ? r.agentId : typeof r.id === 'string' ? r.id : null
227 note('event', { ev: ev.kind, agent, tool: ev.kind === 'toolStarted' ? ev.tool : undefined }, ev.at)
228 }
229 office.queue.push(ev)
230 if (office.queue.length > QUEUE_MAX) office.queue = office.queue.slice(-QUEUE_MAX)
231 }
232
233 on('session.start', async ($, e, next) => {
234 // Each step stands alone: one that fails must not keep the others from running.
235 await quietly(() => {
236 $.clock.every(FLUSH_MS, () => {
237 void flush($, office)
238 })
239 })
240 await quietly(async () => {
241 await $.command.register({ name: 'office', description: 'Show or hide the pixel office' })
242 })
243 await quietly(async () => {
244 if (office.keepsHistory) await loadHistory($)
245 })
246 // The session start hook only asks for the pane, as the spawn hook does; the flush opens it.
247 if (autoOpen === 'session-start') office.wantsOpen = true
248 return next(e)
249 }).catch(($, e, next) => next(e))
250
251 on('session.end', async ($, e, next) => {
252 // A /clear ends the conversation but not the process, and no session.start follows it: the office starts
253 // over, and the flush timer keeps running. Events still queued belong to the old conversation, and so does
254 // a batch a flush has already taken off the queue: bumping the epoch makes that flush leave the new scene alone.
255 if (e.reason === 'clear') {
256 await quietly(() => {
257 office.epoch += 1
258 office.queue = []
259 })
260 await quietly(async () => {
261 await update($, sceneAtom, () => emptyScene())
262 })
263 await quietly(async () => {
264 await update($, selectedAtom, () => null)
265 })
266 await quietly(async () => {
267 await update($, expandedAtom, () => null)
268 })
269 await quietly(async () => {
270 await update($, sessionAtom, () => [])
271 })
272 }
273 return next(e)
274 }).catch(($, e, next) => next(e))
275
276 on('tool.call', async ($, e, next) => {
277 const tool = String(e.tool)
278 const agentId = e.agentId ?? MAIN
279 const callId = e.tool_use_id
280 const isTracked = !UNTRACKED.has(tool)
281 if (isTracked) {
282 await quietly(async () => {
283 const input = e as Record<string, unknown>
284 const at = await $.clock.now()
285 push({ kind: 'toolStarted', agentId, tool, target: targetOf(tool, input), detail: detailOf(tool, input), callId, at })
286 const counts = stationFor(tool) === 'taskboard' ? todoCounts(input) : null
287 if (counts !== null) push({ kind: 'todos', ...counts, at })
288 })
289 }
290 const ran = await next(e)
291 if (isTracked) {
292 await quietly(async () => {
293 const isError = ran.deny === undefined && ran.isError === true
294 push({ kind: 'toolEnded', agentId, callId, isError, at: await $.clock.now() })
295 })
296 }
297 return ran
298 }).catch(($, e, next) => next(e))
299
300 on('agent.spawn', async ($, e, next) => {
301 const spawned = await next(e)
302 await quietly(async () => {
303 if (spawned.deny !== undefined || spawned.agentId === undefined) return
304 push({
305 kind: 'spawned',
306 id: spawned.agentId,
307 parentId: e.parentAgentId ?? MAIN,
308 type: e.subagentType,
309 description: e.description,
310 brief: fullText(e.prompt, TEXT_MAX),
311 name: e.name ?? null,
312 isTeammate: e.isTeammate === true,
313 isBackground: e.background === true,
314 isResume: e.resume !== undefined,
315 at: await $.clock.now(),
316 })
317 if (autoOpen === 'on-first-subagent' && !office.isOpened) office.wantsOpen = true
318 })
319 return spawned
320 }).catch(($, e, next) => next(e))
321
322 // The engine settles a prompt before the main loop's turn starts; the turn reads where it came from.
323 on('prompt.submit', async ($, e, next) => {
324 office.lastOrigin = e.origin?.kind ?? null
325 return next(e)
326 }).catch(($, e, next) => next(e))
327
328 on('turn.start', async ($, e, next) => {
329 // The engine fires this for a helper's run too; only the main loop's turn is the person's message.
330 if (e.agentId !== undefined) {
331 if (office.isDebug) note('turn-ignored', { agent: e.agentId })
332 return next(e)
333 }
334 const origin = office.lastOrigin
335 office.lastOrigin = null
336 const isNotice = origin !== null ? NOT_A_NEW_JOB.has(origin) : e.text.trimStart().startsWith('<task-notification')
337 // A turn started without typed text, or woken by a notice, carries on the job under way.
338 const continues = e.text === '' || isNotice
339 if (office.isDebug) note('turn-start', { origin: origin ?? 'none', continues })
340 await quietly(async () =>
341 push({
342 kind: 'turnStarted',
343 isContinuation: continues,
344 task: isNotice ? '' : gist(e.text, TASK_MAX),
345 at: await $.clock.now(),
346 }),
347 )
348 return next(e)
349 }).catch(($, e, next) => next(e))
350
351 on('turn.complete', async ($, e, next) => {
352 await quietly(async () => {
353 const at = await $.clock.now()
354 const usage = usageOf(e.usage)
355 if (e.agentId !== undefined) {
356 push({ kind: 'handedBack', agentId: e.agentId, reason: e.reason, report: fullText(e.answer, TEXT_MAX), usage, at })
357 } else push({ kind: 'turnEnded', usage, at })
358 })
359 return next(e)
360 }).catch(($, e, next) => next(e))
361
362 on('classic.PermissionRequest', async ($, e, next) => {
363 await quietly(async () => {
364 // A question or a plan approval already moved and narrated the agent when its tool call began.
365 if (WAITING_TOOLS.has(e.tool_name)) return
366 push({ kind: 'waiting', agentId: e.agent_id ?? null, tool: e.tool_name, at: await $.clock.now() })
367 })
368 return next(e)
369 }).catch(($, e, next) => next(e))
370
371 on('command.run', { command: 'office' }, async $ => {
372 const panes = await $.ui.panes()
373 if (panes.some(pane => pane.id === PANE)) {
374 await $.ui.close({ id: PANE })
375 return { text: 'Office closed.' }
376 }
377 await openPane($, office)
378 return { text: 'Office opened.' }
379 })
380
381 // What this plugin's probes (a surface module inside the pane) post: only four plain fields are kept.
382 on('ui.message', async ($, e, next) => {
383 if (e.element !== PROBE_TOP && e.element !== PROBE_BOTTOM && e.element !== PROBE_LAYER) return next(e)
384 if (office.isDebug) {
385 const d = typeof e.data === 'object' && e.data !== null ? (e.data as Record<string, unknown>) : {}
386 note('probe', {
387 element: e.element,
388 ...(typeof d.kind === 'string' ? { msg: d.kind.slice(0, 32) } : {}), // not `kind`: that is the line's own
389 ...(typeof d.where === 'string' ? { where: d.where.slice(0, 32) } : {}),
390 ...(typeof d.columns === 'number' ? { columns: d.columns } : {}),
391 ...(typeof d.rows === 'number' ? { rows: d.rows } : {}),
392 })
393 }
394 return {}
395 }).catch(($, e, next) => next(e))
396
397 registerPane(on, { isLayered: String(options.picture ?? 'single') === 'layered', isDebug: office.isDebug })
398}
399hooks/debuglog.ts 35 lines1// An opt-in log of what the office does, kept in memory as JSON lines and written to a file by the flush.
2// Off by default: every function below is a boolean check and nothing more until `debugOn(true)`.
3export const LOG_MAX = 3000
4
5let isOn = false
6let lines: string[] = []
7let isDirty = false
8
9export function debugOn(enabled: boolean): void {
10 isOn = enabled
11}
12
13// Values are plain numbers, booleans and short strings: agent ids, event kinds, tool names, never text of the work.
14export function note(kind: string, data: Record<string, unknown> = {}, at?: number): void {
15 if (!isOn) return
16 lines.push(JSON.stringify({ t: at ?? Date.now(), kind, ...data }))
17 if (lines.length > LOG_MAX) lines = lines.slice(lines.length - LOG_MAX)
18 isDirty = true
19}
20
21export function logText(): string {
22 return lines.length === 0 ? '' : `${lines.join('\n')}\n`
23}
24
25export function takeDirty(): boolean {
26 const was = isDirty
27 isDirty = false
28 return was
29}
30
31export function clearLog(): void {
32 lines = []
33 isDirty = false
34}
35hooks/model.ts 634 lines1import type { Agent, Carry, OfficeEvent, Outcome, Point, Scene, StationId, Usage, Walk } from '../types'
2import { allBackLine, narrate } from './narrate'
3import { COORDINATOR, asName, roleFor } from './roles'
4import { lookFor } from './sprites'
5import {
6 EXCERPT,
7 WAITING_TOOLS,
8 WIDTH,
9 clamp,
10 doorOf,
11 fullText,
12 pathLength,
13 pointAlong,
14 route,
15 slotPoint,
16 stationFor,
17 toCorridor,
18} from './stations'
19
20export const MAIN = 'main'
21export const TICKER_MAX = 8
22export const GONE_KEEP = 50
23export const FULL_KEEP = 10
24export const LINGER_MS = 8_000
25export const IDLE_MS = 600_000
26export const BRIEFING_MAX_MS = 60_000
27export const WALK_SPEED = 70
28
29type Ev<K extends OfficeEvent['kind']> = Extract<OfficeEvent, { kind: K }>
30type Stop = { point: Point; door: Point }
31
32export function emptyScene(): Scene {
33 const main: Agent = {
34 id: MAIN,
35 parentId: null,
36 type: 'main',
37 role: COORDINATOR.title,
38 name: null,
39 label: 'Main',
40 description: '',
41 brief: '',
42 report: '',
43 outcome: null,
44 usage: null,
45 isTeammate: false,
46 isBackground: false,
47 phase: 'idle',
48 station: 'office',
49 slot: 0,
50 desk: -1,
51 pos: slotPoint('office', 0),
52 walk: null,
53 activity: null,
54 lastErrorAt: null,
55 lastEventAt: 0,
56 arrivedAt: 0,
57 doneAt: null,
58 goneAt: null,
59 carry: null,
60 look: lookFor('main', MAIN),
61 }
62 return { agents: { [MAIN]: main }, order: [MAIN], ticker: [], todos: null, briefing: null, job: null }
63}
64
65// Fields added to an agent since the first release, with what an agent saved without them stands for. Session
66// state outlives a hot reload of the mod, so a scene saved by an older version can meet this code.
67const AGENT_DEFAULTS = (agent: Agent): Partial<Agent> => ({
68 role: roleFor(agent.type).title,
69 name: null,
70 brief: '',
71 report: '',
72 outcome: null,
73 // Nothing says its boss waits on it, so it never makes its boss look busy.
74 isBackground: true,
75 carry: null,
76 usage: null,
77 doneAt: null,
78})
79const AGENT_KEYS = Object.keys(AGENT_DEFAULTS({ type: MAIN } as Agent)) as (keyof Agent)[]
80
81// The scene as this version reads it. The same object comes back when nothing is missing, so a scene that is
82// already whole is never rewritten (a write would redraw the pane).
83export function restore(scene: Scene): Scene {
84 const isWhole = (agent: Agent): boolean => AGENT_KEYS.every(key => key in agent)
85 const hasJob = 'job' in scene
86 if (hasJob && Object.values(scene.agents).every(isWhole)) return scene
87 const agents: Record<string, Agent> = {}
88 for (const [id, agent] of Object.entries(scene.agents)) {
89 agents[id] = isWhole(agent) ? agent : ({ ...AGENT_DEFAULTS(agent), ...agent } as Agent)
90 }
91 return { ...scene, agents, job: hasJob ? scene.job : null }
92}
93
94export function isWalkingAt(agent: Agent, at: number): boolean {
95 return agent.walk !== null && at < agent.walk.startedAt + agent.walk.durationMs
96}
97
98export function positionAt(agent: Agent, at: number): Point {
99 if (agent.walk === null || !isWalkingAt(agent, at)) return agent.pos
100 return pointAlong(agent.walk.points, (at - agent.walk.startedAt) / agent.walk.durationMs)
101}
102
103export function homeOf(agent: Agent): StationId {
104 return agent.desk < 0 ? 'office' : 'desk'
105}
106
107const put = (scene: Scene, agent: Agent): Scene => ({ ...scene, agents: { ...scene.agents, [agent.id]: agent } })
108
109const addAgent = (scene: Scene, agent: Agent): Scene => ({ ...put(scene, agent), order: [...scene.order, agent.id] })
110
111// A routine line replaces the same agent's last routine line, so a few busy helpers cannot push the story
112// (who hired whom, who reported back, who needs you) out of the feed within seconds.
113function withLine(scene: Scene, at: number, text: string | null, routineOf: string | null = null): Scene {
114 if (text === null) return scene
115 const kept = routineOf === null ? scene.ticker : scene.ticker.filter(line => line.routineOf !== routineOf)
116 return { ...scene, ticker: [...kept, { at, text, routineOf }].slice(-TICKER_MAX) }
117}
118
119// Tool calls that are part of the story rather than routine work: a message to another agent, and a wait on you.
120const isMilestone = (tool: string): boolean => tool === 'SendMessage' || WAITING_TOOLS.has(tool)
121
122function freeSlot(scene: Scene, station: StationId, selfId: string): number {
123 const taken = new Set(
124 Object.values(scene.agents)
125 .filter(a => a.id !== selfId && a.phase !== 'gone' && a.station === station)
126 .map(a => a.slot),
127 )
128 let slot = 0
129 while (taken.has(slot)) slot += 1
130 return slot
131}
132
133const walkMs = (length: number): number => clamp(Math.round((length / WALK_SPEED) * 1000), 700, 7000)
134
135const isNear = (a: Point, b: Point): boolean => Math.hypot(a.x - b.x, a.y - b.y) < 0.5
136
137// Every walk drops whatever the agent was carrying, unless `item` says it carries something this time: it is
138// handed over when the walk reaches the first stop in `via`, the agent it is walking to.
139function moveTo(
140 scene: Scene,
141 agent: Agent,
142 target: StationId,
143 at: number,
144 via: readonly Stop[] = [],
145 item: Carry['item'] | null = null,
146): Agent {
147 const station: StationId = target === 'desk' && agent.desk < 0 ? 'office' : target
148 if (via.length === 0 && station === agent.station) return agent
149 const slot =
150 station === agent.station
151 ? agent.slot
152 : station === 'desk'
153 ? agent.desk
154 : station === 'office'
155 ? 0
156 : freeSlot(scene, station, agent.id)
157 const dest = slotPoint(station, slot)
158 const current = agent.walk !== null && isWalkingAt(agent, at) ? agent.walk : null
159 // Sent back to where it stood before its walk began, it never left.
160 if (current !== null && via.length === 0 && at <= current.startedAt && isNear(current.points[0] as Point, dest)) {
161 return { ...agent, station, slot, pos: dest, walk: null, carry: null }
162 }
163 // A walk that changes course first gets back to the corridor along the path it is on.
164 const rejoin = current === null ? [] : toCorridor(current.points, (at - current.startedAt) / current.durationMs)
165 const lead = rejoin.length > 0 ? rejoin : [positionAt(agent, at)]
166 const start = lead[lead.length - 1] as Point
167 const stops: Stop[] = [
168 { point: start, door: current !== null ? start : doorOf(agent.station, agent.slot) },
169 ...via,
170 { point: dest, door: doorOf(station, slot) },
171 ]
172 let points: Point[] = lead
173 let handover = 0
174 for (let i = 1; i < stops.length; i++) {
175 const a = stops[i - 1] as Stop
176 const b = stops[i] as Stop
177 points = points.concat(route(a.point, a.door, b.point, b.door).slice(1))
178 if (i === 1) handover = pathLength(points)
179 }
180 const length = pathLength(points)
181 const walk: Walk | null = length < 0.5 ? null : { points, startedAt: at, durationMs: walkMs(length) }
182 const carry: Carry | null =
183 item === null || walk === null || via.length === 0
184 ? null
185 : { item, until: at + Math.round((walk.durationMs * handover) / length) }
186 return { ...agent, station, slot, pos: dest, walk, carry }
187}
188
189function beside(agent: Agent): Stop {
190 return {
191 point: { x: clamp(agent.pos.x + 9, 4, WIDTH - 4), y: agent.pos.y },
192 door: doorOf(agent.station, agent.slot),
193 }
194}
195
196// The first of a kind is plain "Researcher", the next "Researcher 2". Numbering goes on from the highest number
197// in the office, so a label is never handed out twice, even after old helpers are pruned from the model.
198function uniqueLabel(scene: Scene, base: string): string {
199 let highest = 0
200 for (const a of Object.values(scene.agents)) {
201 if (a.label === base) highest = Math.max(highest, 1)
202 else if (a.label.startsWith(`${base} `)) {
203 const n = Number(a.label.slice(base.length + 1))
204 if (Number.isInteger(n) && n > 1) highest = Math.max(highest, n)
205 }
206 }
207 return highest === 0 ? base : `${base} ${highest + 1}`
208}
209
210// What a helper is on the job for, in a few words: "Find the login code" reads as "login code". Short enough to
211// fit on a name tag.
212const TOPIC_MAX = 12
213const FILLER = /^(find|check|look|search|read|review|audit|investigate|explore|analy[sz]e|summari[sz]e|report|list|get|the|a|an|for|on|in|at|of|into|out|how|why|what|where|which|whether|if|and|to)$/i
214export function topicOf(description: string): string {
215 const words = description.split(/\s+/).filter(w => w !== '')
216 const keep = words.filter(w => !FILLER.test(w.replace(/[^A-Za-z]/g, '')))
217 const pool = (keep.length > 0 ? keep : words).map(w => w.toLowerCase())
218 // Whole words only: two if they fit, else the first, cut with an ellipsis if even that is too long.
219 const two = pool.slice(0, 2).join(' ')
220 if (Array.from(two).length <= TOPIC_MAX) return two
221 const first = pool[0] ?? ''
222 const chars = Array.from(first)
223 return chars.length <= TOPIC_MAX ? first : `${chars.slice(0, TOPIC_MAX - 1).join('')}…`
224}
225
226// An agent the engine named keeps its name. The first of a kind is its plain job title; a later one is told apart
227// by its job, "Hooks module" rather than "Researcher 2", when we know it. The job alone is the name: a tag has
228// room for about twelve characters before it runs into its neighbour's, and the details say what kind of agent it is.
229const labelFor = (scene: Scene, type: string, name: string | null, description = ''): string => {
230 if (name !== null && name !== '') return uniqueLabel(scene, asName(name))
231 const topic = topicOf(description)
232 const title = roleFor(type).title
233 const first = uniqueLabel(scene, title)
234 if (first === title || topic === '') return first
235 return uniqueLabel(scene, `${topic[0]?.toUpperCase() ?? ''}${topic.slice(1)}`)
236}
237
238function nextDesk(scene: Scene): number {
239 const used = new Set(
240 Object.values(scene.agents)
241 .filter(a => a.phase !== 'gone' && a.desk >= 0)
242 .map(a => a.desk),
243 )
244 let desk = 0
245 while (used.has(desk)) desk += 1
246 return desk
247}
248
249// A contractor (an agent the office never saw hired) is taken to work in the background: nothing says its boss
250// is waiting on it, so it never makes its boss look busy.
251function arrive(scene: Scene, id: string, type: string, label: string, at: number): Agent {
252 return {
253 id,
254 parentId: MAIN,
255 type,
256 role: roleFor(type).title,
257 name: null,
258 label,
259 description: '',
260 brief: '',
261 report: '',
262 outcome: null,
263 usage: null,
264 isTeammate: false,
265 isBackground: true,
266 phase: 'arriving',
267 station: 'elevator',
268 slot: 0,
269 desk: nextDesk(scene),
270 pos: slotPoint('elevator', 0),
271 walk: null,
272 activity: null,
273 lastErrorAt: null,
274 lastEventAt: at,
275 arrivedAt: at,
276 doneAt: null,
277 goneAt: null,
278 carry: null,
279 look: lookFor(type, id),
280 }
281}
282
283const isOut = (agent: Agent): boolean =>
284 agent.phase === 'arriving' || agent.phase === 'briefing' || agent.phase === 'working' || agent.phase === 'waiting'
285
286// The helpers `bossId` is waiting on: hired in the foreground (the boss's call to hire them only returns with
287// their report) and still out on the job, in the order they were hired. A teammate is a standing colleague
288// rather than an errand, so its boss never waits on it.
289export function waitingOn(scene: Scene, bossId: string): Agent[] {
290 const out: Agent[] = []
291 for (const id of scene.order) {
292 const a = scene.agents[id]
293 if (a !== undefined && a.parentId === bossId && !a.isBackground && !a.isTeammate && isOut(a)) out.push(a)
294 }
295 return out
296}
297
298// A boss with nothing in hand, waiting for the reports of the helpers it hired, is coordinating rather than
299// thinking. A boss that is using a tool, waiting on you, resting or gone is doing that instead.
300export function isCoordinating(scene: Scene, agent: Agent): boolean {
301 if (agent.activity !== null || agent.phase === 'gone' || agent.phase === 'idle' || agent.phase === 'waiting') return false
302 return waitingOn(scene, agent.id).length > 0
303}
304
305function endBriefing(scene: Scene, agent: Agent, at: number, t: number): Scene {
306 const stillBriefing = Object.values(scene.agents).some(a => a.phase === 'briefing')
307 let out: Scene = stillBriefing ? scene : { ...scene, briefing: null }
308 const parent = agent.parentId === null ? undefined : out.agents[agent.parentId]
309 if (parent !== undefined && parent.station === 'meeting' && parent.activity === null && parent.phase !== 'gone') {
310 out = put(out, { ...moveTo(out, parent, homeOf(parent), t), lastEventAt: at })
311 }
312 return out
313}
314
315// The agent walks to the meeting room to be briefed, its boss joins it there unless busy, and the whiteboard
316// shows the job.
317function brief(scene: Scene, agent: Agent, ev: Ev<'spawned'>, t: number): Scene {
318 let s = put(scene, { ...moveTo(scene, agent, 'meeting', t), phase: 'briefing' })
319 const parent = s.agents[ev.parentId]
320 if (parent !== undefined && parent.activity === null && parent.phase !== 'gone') {
321 s = put(s, { ...moveTo(s, parent, 'meeting', t), phase: 'working', lastEventAt: ev.at })
322 }
323 return { ...s, briefing: ev.description }
324}
325
326// A helper back for more work after it handed in its report. One still on its way out turns round where it is;
327// one that has ridden away steps out of the elevator again.
328function comeBack(scene: Scene, agent: Agent, t: number): Partial<Agent> {
329 const hasLeft = agent.goneAt === null || t >= agent.goneAt
330 return { ...(hasLeft ? { station: 'elevator' as const, slot: 0, pos: slotPoint('elevator', 0), walk: null } : {}), desk: nextDesk(scene) }
331}
332
333// A called-back agent comes back if it had left, starts a new stint and is briefed again. The message it is
334// called back with is its new brief; with none, the old brief still stands.
335function callBack(scene: Scene, agent: Agent, ev: Ev<'spawned'>, t: number): Scene {
336 const isBack = agent.phase === 'gone'
337 const returning: Agent = {
338 ...agent,
339 ...(isBack ? comeBack(scene, agent, t) : {}),
340 parentId: ev.parentId,
341 description: ev.description,
342 brief: ev.brief === '' ? agent.brief : ev.brief,
343 isBackground: ev.isBackground,
344 outcome: null,
345 doneAt: null,
346 goneAt: null,
347 carry: null,
348 activity: null,
349 arrivedAt: ev.at,
350 lastEventAt: ev.at,
351 }
352 return brief(scene, returning, ev, t)
353}
354
355function spawned(scene: Scene, ev: Ev<'spawned'>, t: number): Scene {
356 const existing = scene.agents[ev.id]
357 let s = scene
358 const hire = {
359 parentId: ev.parentId,
360 description: ev.description,
361 brief: ev.brief,
362 name: ev.name,
363 isTeammate: ev.isTeammate,
364 isBackground: ev.isBackground,
365 }
366 if (existing === undefined) {
367 const fresh = { ...arrive(s, ev.id, ev.type, labelFor(s, ev.type, ev.name, ev.description), ev.at), ...hire }
368 s = brief(addAgent(s, fresh), fresh, ev, t)
369 } else if (ev.isResume) {
370 s = callBack(s, existing, ev, t)
371 } else {
372 const label = existing.type === 'contractor' ? labelFor(s, ev.type, ev.name, ev.description) : existing.label
373 s = put(s, {
374 ...existing,
375 ...hire,
376 type: ev.type,
377 role: roleFor(ev.type).title,
378 label,
379 look: lookFor(ev.type, ev.id),
380 lastEventAt: ev.at,
381 })
382 }
383 return withLine(s, ev.at, narrate(s, ev))
384}
385
386// Messages are addressed by the name the engine knows an agent by, by its id, or (for "main") by its label.
387function findByName(scene: Scene, name: string, selfId: string): Agent | undefined {
388 const wanted = name.toLowerCase()
389 return Object.values(scene.agents).find(
390 a =>
391 a.id !== selfId &&
392 a.phase !== 'gone' &&
393 (a.name?.toLowerCase() === wanted || a.label.toLowerCase() === wanted || a.id === name),
394 )
395}
396
397const EDITING: ReadonlySet<string> = new Set(['Edit', 'Write', 'NotebookEdit'])
398
399function toolStarted(scene: Scene, ev: Ev<'toolStarted'>, t: number): Scene {
400 let s = scene
401 let agent = s.agents[ev.agentId]
402 if (agent === undefined) {
403 agent = arrive(s, ev.agentId, 'contractor', uniqueLabel(s, 'Contractor'), ev.at)
404 s = addAgent(s, agent)
405 } else if (agent.phase === 'gone') {
406 agent = {
407 ...agent,
408 ...comeBack(s, agent, t),
409 phase: 'arriving',
410 goneAt: null,
411 doneAt: null,
412 outcome: null,
413 carry: null,
414 arrivedAt: ev.at,
415 }
416 }
417 const wasBriefing = agent.phase === 'briefing'
418 let moved: Agent
419 if (ev.tool === 'SendMessage') {
420 const recipient = findByName(s, ev.target, agent.id)
421 moved =
422 recipient !== undefined
423 ? moveTo(s, agent, agent.station, t, [beside(recipient)], 'message')
424 : moveTo(s, agent, 'meeting', t)
425 } else {
426 moved = moveTo(s, agent, stationFor(ev.tool), t)
427 }
428 s = put(s, {
429 ...moved,
430 phase: WAITING_TOOLS.has(ev.tool) ? 'waiting' : 'working',
431 activity: { tool: ev.tool, target: ev.target, detail: ev.detail, callId: ev.callId, since: ev.at },
432 lastEventAt: ev.at,
433 })
434 if (wasBriefing) s = endBriefing(s, agent, ev.at, t)
435 // The coordinator is meant to hire helpers for edits; the recap says how many it made itself.
436 if (agent.id === MAIN && EDITING.has(ev.tool) && s.job !== null) s = { ...s, job: { ...s.job, mainEdits: (s.job.mainEdits ?? 0) + 1 } }
437 return withLine(s, ev.at, narrate(s, ev), isMilestone(ev.tool) ? null : agent.id)
438}
439
440function toolEnded(scene: Scene, ev: Ev<'toolEnded'>, t: number): Scene {
441 const agent = scene.agents[ev.agentId]
442 if (agent === undefined || agent.phase === 'gone') return scene
443 const isCurrent = agent.activity?.callId === ev.callId
444 let next: Agent = {
445 ...agent,
446 activity: isCurrent ? null : agent.activity,
447 lastErrorAt: ev.isError ? ev.at : agent.lastErrorAt,
448 lastEventAt: ev.at,
449 }
450 if (isCurrent && agent.phase === 'waiting') next = { ...moveTo(scene, next, homeOf(next), t), phase: 'working' }
451 return withLine(put(scene, next), ev.at, narrate(scene, ev))
452}
453
454function latestUser(scene: Scene, tool: string): string {
455 let best: Agent | undefined
456 for (const a of Object.values(scene.agents)) {
457 if (a.phase === 'gone' || a.activity?.tool !== tool) continue
458 if (best === undefined || a.activity.since > (best.activity?.since ?? -1)) best = a
459 }
460 return best?.id ?? MAIN
461}
462
463function waiting(scene: Scene, ev: Ev<'waiting'>, t: number): Scene {
464 const id = ev.agentId !== null && scene.agents[ev.agentId] !== undefined ? ev.agentId : latestUser(scene, ev.tool)
465 const agent = scene.agents[id]
466 if (agent === undefined || agent.phase === 'gone') return scene
467 const s = put(scene, { ...moveTo(scene, agent, 'reception', t), phase: 'waiting', lastEventAt: ev.at })
468 return withLine(s, ev.at, narrate(s, { ...ev, agentId: id }))
469}
470
471// A message from you starts a new job; a turn that carries on without one reopens the job it belongs to.
472function turnStarted(scene: Scene, ev: Ev<'turnStarted'>, t: number): Scene {
473 const main = scene.agents[MAIN]
474 if (main === undefined) return scene
475 const job =
476 ev.isContinuation && scene.job !== null
477 ? { ...scene.job, endedAt: null }
478 : { startedAt: ev.at, endedAt: null, task: ev.task, mainUsage: null, mainEdits: 0 }
479 const s = put({ ...scene, job }, { ...moveTo(scene, main, 'office', t), phase: 'working', lastEventAt: ev.at })
480 return withLine(s, ev.at, narrate(s, ev))
481}
482
483// Main's turn is over: it rests where it stands until the next message. No walk; the sweep takes it
484// home after a short while and to the break room after a long one.
485// A teammate or a called-back helper works more than one turn, and Main works one per message: their costs add up.
486export function addUsage(sum: Usage | null, more: Usage | null): Usage | null {
487 if (more === null) return sum
488 if (sum === null) return more
489 return { tokens: sum.tokens + more.tokens, reused: sum.reused + more.reused, model: more.model }
490}
491
492function turnEnded(scene: Scene, ev: Ev<'turnEnded'>): Scene {
493 const main = scene.agents[MAIN]
494 if (main === undefined) return scene
495 const job = scene.job === null ? null : { ...scene.job, endedAt: ev.at, mainUsage: addUsage(scene.job.mainUsage, ev.usage) }
496 const s = put({ ...scene, job }, { ...main, phase: 'idle', activity: null, usage: addUsage(main.usage, ev.usage), lastEventAt: ev.at })
497 return withLine(s, ev.at, narrate(s, ev))
498}
499
500function prune(scene: Scene): Scene {
501 const gone = scene.order.filter(id => scene.agents[id]?.phase === 'gone')
502 if (gone.length <= GONE_KEEP) return scene
503 const drop = new Set(gone.slice(0, gone.length - GONE_KEEP))
504 const agents = { ...scene.agents }
505 for (const id of drop) delete agents[id]
506 return { ...scene, agents, order: scene.order.filter(id => !drop.has(id)) }
507}
508
509// Only the most recently finished helpers keep their whole brief and report; older ones keep as much as the
510// one-line excerpt shows. That bounds the scene, which is written to state on every change.
511function trimTexts(scene: Scene): Scene {
512 const isLong = (a: Agent): boolean => a.brief.length > EXCERPT || a.report.length > EXCERPT
513 const old = Object.values(scene.agents)
514 .filter(a => a.phase === 'gone')
515 .sort((a, b) => (b.goneAt ?? 0) - (a.goneAt ?? 0))
516 .slice(FULL_KEEP)
517 .filter(isLong)
518 if (old.length === 0) return scene
519 const agents = { ...scene.agents }
520 for (const a of old) agents[a.id] = { ...a, brief: fullText(a.brief, EXCERPT), report: fullText(a.report, EXCERPT) }
521 return { ...scene, agents }
522}
523
524const OUTCOMES: Readonly<Record<Ev<'handedBack'>['reason'], Outcome>> = {
525 answer: 'reported',
526 aborted: 'stopped',
527 refusal: 'failed',
528 error: 'failed',
529}
530
531// The helper carries its report to its boss, then leaves by the elevator (a teammate goes for a break instead).
532// When it was the last report its boss was waiting for, the feed says so: that is when the boss takes over again.
533function handedBack(scene: Scene, ev: Ev<'handedBack'>, t: number): Scene {
534 const agent = scene.agents[ev.agentId]
535 if (agent === undefined || agent.id === MAIN || agent.phase === 'gone') return scene
536 const parent = agent.parentId === null ? undefined : scene.agents[agent.parentId]
537 const isParentHere = parent !== undefined && parent.phase !== 'gone'
538 const via = isParentHere ? [beside(parent)] : []
539 const outcome = OUTCOMES[ev.reason]
540 const item = outcome === 'stopped' ? null : 'report'
541 let next: Agent
542 if (agent.isTeammate) {
543 next = { ...moveTo(scene, agent, 'breakroom', t, via, item), phase: 'idle' }
544 } else {
545 const moved = moveTo(scene, agent, 'elevator', t, via, item)
546 next = { ...moved, phase: 'gone', goneAt: t + (moved.walk?.durationMs ?? 0) }
547 }
548 const awaitedBy = isParentHere && waitingOn(scene, parent.id).some(a => a.id === agent.id) ? parent : undefined
549 let s = put(scene, {
550 ...next,
551 activity: null,
552 report: ev.report,
553 outcome,
554 usage: addUsage(agent.usage, ev.usage),
555 doneAt: ev.at,
556 lastEventAt: ev.at,
557 })
558 if (agent.phase === 'briefing') s = endBriefing(s, agent, ev.at, t)
559 s = trimTexts(prune(s))
560 s = withLine(s, ev.at, narrate(s, ev))
561 if (awaitedBy !== undefined && waitingOn(s, awaitedBy.id).length === 0) s = withLine(s, ev.at, allBackLine(awaitedBy.label))
562 return s
563}
564
565// A boss waiting on its helpers is at work however long they take, so the sweep may walk it home but never
566// sends it to the break room.
567function sweepTarget(agent: Agent, now: number, isWaitingOnTeam: boolean): StationId | null {
568 // An agent waiting on you stays at reception until you answer, however long that takes.
569 if (agent.activity !== null || agent.phase === 'gone' || agent.phase === 'waiting') return null
570 const quiet = now - agent.lastEventAt
571 // A briefing normally ends with the agent's first tool; one that never comes must not hold the room forever.
572 if (agent.phase === 'briefing') return quiet >= BRIEFING_MAX_MS ? homeOf(agent) : null
573 if (agent.phase === 'idle') {
574 // Resting in the break room is the end of the road, and an office where nothing has happened yet
575 // (Main's clock still reads zero) has nobody to send for coffee.
576 if (agent.station === 'breakroom' || agent.lastEventAt === 0) return null
577 if (quiet >= IDLE_MS) return 'breakroom'
578 return quiet >= LINGER_MS && agent.station !== homeOf(agent) ? homeOf(agent) : null
579 }
580 if (quiet >= IDLE_MS && !isWaitingOnTeam) return 'breakroom'
581 if (quiet >= LINGER_MS && agent.station !== homeOf(agent)) return homeOf(agent)
582 return null
583}
584
585export function needsSweep(scene: Scene, now: number): boolean {
586 return Object.values(scene.agents).some(a => sweepTarget(a, now, isCoordinating(scene, a)) !== null)
587}
588
589function tick(scene: Scene, at: number, t: number): Scene {
590 let s = scene
591 for (const id of scene.order) {
592 const agent = s.agents[id]
593 if (agent === undefined) continue
594 const target = sweepTarget(agent, at, isCoordinating(s, agent))
595 if (target === null) continue
596 s = put(s, { ...moveTo(s, agent, target, t), phase: target === 'breakroom' || agent.phase === 'idle' ? 'idle' : 'working' })
597 if (agent.phase === 'briefing') s = endBriefing(s, agent, at, t)
598 }
599 return s
600}
601
602export function reduce(scene: Scene, ev: OfficeEvent): Scene {
603 return reduceAt(scene, ev, ev.at)
604}
605
606// The event as the picture plays it: `shownAt` is the first moment the picture can show it, and the walks it
607// starts begin then rather than at the event's own time. Begun at the event's time, a walk would already be part
608// way along when it first appeared (the wait for the next flush, and the time the picture takes to appear), so the
609// walker would leap ahead at the start of every walk. What the office reports (the feed, the recap, the clocks)
610// keeps the event's own time.
611export function reduceAt(scene: Scene, ev: OfficeEvent, shownAt: number): Scene {
612 const t = Math.max(ev.at, shownAt)
613 switch (ev.kind) {
614 case 'spawned':
615 return spawned(scene, ev, t)
616 case 'toolStarted':
617 return toolStarted(scene, ev, t)
618 case 'toolEnded':
619 return toolEnded(scene, ev, t)
620 case 'waiting':
621 return waiting(scene, ev, t)
622 case 'turnStarted':
623 return turnStarted(scene, ev, t)
624 case 'turnEnded':
625 return turnEnded(scene, ev)
626 case 'handedBack':
627 return handedBack(scene, ev, t)
628 case 'todos':
629 return { ...scene, todos: { done: ev.done, open: ev.open } }
630 case 'tick':
631 return tick(scene, ev.at, t)
632 }
633}
634hooks/pane.tsx 254 lines1import { atom, read, update } from 'claude-code'
2import type { On } from 'claude-code'
3
4import type { JobRecord } from '../types'
5import { note } from './debuglog'
6import { emptyScene, restore } from './model'
7import { SCROLL_BUCKET, STATUS_WORDS, agentDetails, altText, clockLabel, fitSize, isCut, objectiveLine, officeLayers, officeSource, orgRows, scrollKey, svgSize } from './render'
8import { historyLine, recapAlt, recapOf, recapRows, recapSummary, recapSvg, sessionLines, sessionTotals } from './recap'
9import { PANE, PROBE_BOTTOM, PROBE_LAYER, PROBE_TOP } from './state'
10import { STATIONS, STATION_ORDER } from './stations'
11
12const ORG_MAX = 14
13const sceneAtom = atom({ plugin: 'pixel-office', key: 'scene' } as const, emptyScene())
14const legendAtom = atom({ plugin: 'pixel-office', key: 'legendOpen' } as const, false)
15const selectedAtom = atom({ plugin: 'pixel-office', key: 'selected' } as const, null as string | null)
16const clockAtom = atom({ plugin: 'pixel-office', key: 'clock' } as const, 0)
17const expandedAtom = atom({ plugin: 'pixel-office', key: 'expanded' } as const, null as 'brief' | 'report' | null)
18const jobOpenAtom = atom({ plugin: 'pixel-office', key: 'jobOpen' } as const, false)
19const historyAtom = atom({ plugin: 'pixel-office', key: 'history' } as const, [] as JobRecord[])
20const sessionAtom = atom({ plugin: 'pixel-office', key: 'session' } as const, [] as JobRecord[])
21const LINK = '#378add'
22// The details lines a whole text belongs under.
23const PARTS = [
24 { part: 'brief', line: 'Brief: ' },
25 { part: 'report', line: 'Report: ' },
26] as const
27// Coordinating wears the meeting room's teal: a boss waiting on its helpers, not on you.
28const STATUS_COLOR = { working: 'green', coordinating: STATIONS.meeting.color, waiting: 'yellow', idle: 'gray', done: 'gray' } as const
29
30export function registerPane(on: On, { isLayered, isDebug }: { isLayered: boolean; isDebug?: boolean } = { isLayered: false }): void {
31 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
32 const scene = restore(await read($, sceneAtom))
33 const isLegendOpen = await read($, legendAtom)
34 const expanded = await read($, expandedAtom)
35 const isJobOpen = await read($, jobOpenAtom)
36 const history = await read($, historyAtom)
37 const session = await read($, sessionAtom)
38 const selected = await read($, selectedAtom)
39 // Reading the clock atom subscribes the pane to it: each write from the flush timer redraws the pane.
40 const now = Math.max(await $.clock.now(), await read($, clockAtom))
41 const rows = orgRows(scene, now)
42 const shown = rows.slice(0, ORG_MAX)
43 const hidden = rows.length - shown.length
44 const details = selected === null ? [] : agentDetails(scene, selected, now)
45 const recap = recapOf(scene, now)
46
47 if (e.surface === 'terminal') {
48 const { Box, Text } = $.ui.resolve(e)
49 return (
50 <Box flexDirection="column">
51 <Text dimColor>Open this session in the Claude desktop app to see the pixel office.</Text>
52 {shown.map(row => {
53 const objective = objectiveLine(scene, row.id, row.depth, e.props.bodyColumns)
54 return (
55 <Box key={`row-${row.id}`} flexDirection="column">
56 <Text wrap="truncate-end">
57 {' '.repeat(row.depth)}
58 {row.label}: {STATUS_WORDS[row.status]}, {row.now}
59 </Text>
60 {objective !== '' && <Text dimColor>{objective}</Text>}
61 </Box>
62 )
63 })}
64 {scene.ticker.map(line => (
65 <Text dimColor>
66 {clockLabel(line.at)} {line.text}
67 </Text>
68 ))}
69 {recap !== null && recapSummary(recap).map(line => <Text>{line}</Text>)}
70 {recap !== null && sessionLines(sessionTotals(session, recap)).map(line => <Text>{line}</Text>)}
71 </Box>
72 )
73 }
74
75 const { Box, Text, Button, Svg, Markdown, Client } = $.ui.resolve(e)
76 const chosen = selected === null ? undefined : scene.agents[selected]
77 const orgWidth = Math.max(24, Math.min(40, Math.floor(e.props.bodyColumns / 2)))
78 // Layered: the scenery is one picture and each agent its own, stacked over it, so one agent's event only redraws
79 // that agent's picture. The panel's own state (a selected row, the legend) is deliberately not part of it.
80 let redrawn = 0
81 const trace =
82 isDebug === true
83 ? (d: Record<string, unknown>): void => {
84 if (d.layer === 'agent') redrawn += 1
85 note('draw', d, now)
86 }
87 : undefined
88 const key = scrollKey(e.props.scroll?.offset ?? 0)
89 const layers = isLayered ? officeLayers(scene, now, key, trace) : undefined
90 if (isDebug === true) {
91 note(
92 'render',
93 {
94 mode: layers === undefined ? 'single' : 'layered',
95 scroll: e.props.scroll?.offset ?? 0,
96 ...(layers === undefined ? {} : { scrollKey: key }),
97 columns: e.props.bodyColumns,
98 selected: selected !== null,
99 jobOpen: isJobOpen,
100 legendOpen: isLegendOpen,
101 agents: Object.keys(scene.agents).length,
102 },
103 now,
104 )
105 if (layers !== undefined) note('layers', { n: layers.agents.length, redrawn }, now)
106 }
107 const officePicture =
108 layers !== undefined
109 ? layers.base.source
110 : officeSource(scene, now, `${selected ?? ''}|${isLegendOpen}|${isJobOpen}|${expanded ?? ''}|${Math.floor((e.props.scroll?.offset ?? 0) / SCROLL_BUCKET)}`)
111 const size = fitSize(svgSize(officePicture), e.props.bodyColumns)
112 const recapPicture = isJobOpen && recap !== null ? recapSvg(recap) : undefined
113 return (
114 <Box flexDirection="column" gap={1}>
115 {isDebug === true && <Client key={PROBE_TOP} module="./probe.tsx" props={{ where: 'top' }} />}
116 {layers === undefined ? (
117 <Svg source={officePicture} alt={altText(scene)} {...size} />
118 ) : (
119 <Box key="office" position="relative">
120 <Svg key="office-base" source={layers.base.source} alt={altText(scene)} {...size} />
121 <Box key={layers.pet.key} position="absolute" top={0} left={0} width="100%" height="100%">
122 <Svg key={layers.pet.key + '-svg'} source={layers.pet.source} alt="The office cat" {...size} />
123 </Box>
124 {layers.agents.map(layer => (
125 // An absolute Box has no size unless it is given one, so each layer is given the parent's whole region.
126 <Box key={layer.key} position="absolute" top={0} left={0} width="100%" height="100%">
127 <Svg key={layer.key + '-svg'} source={layer.source} alt={'Layer for ' + layer.key.replace(/^agent-/, '')} {...size} />
128 </Box>
129 ))}
130 {isDebug === true && (
131 // Laid out as an agent's layer is: a layer the desktop gives no room shows in the log as a probe with none.
132 <Box key="probe-layer-box" position="absolute" top={0} left={0} width="100%" height="100%">
133 <Client key={PROBE_LAYER} module="./probe.tsx" props={{ where: 'layer' }} width="100%" height="100%" />
134 </Box>
135 )}
136 </Box>
137 )}
138 <Box flexDirection="row" gap={2}>
139 <Box key="ticker" flexDirection="column" flexGrow={1}>
140 <Text bold>What's happening</Text>
141 {scene.ticker.length === 0 && <Text dimColor>Quiet so far. Agents appear as they start working.</Text>}
142 {scene.ticker.map(line => (
143 <Text wrap="truncate-end">
144 {clockLabel(line.at)} {line.text}
145 </Text>
146 ))}
147 </Box>
148 <Box key="org" flexDirection="column" width={orgWidth}>
149 <Text bold>Who's doing what</Text>
150 {shown.map(row => {
151 const objective = objectiveLine(scene, row.id, row.depth, orgWidth)
152 return (
153 <Box key={`row-${row.id}`} flexDirection="column">
154 <Button
155 key={`agent-${row.id}`}
156 plain
157 onPress={async () => {
158 await update($, selectedAtom, current => (current === row.id ? null : row.id))
159 await update($, expandedAtom, () => null)
160 }}
161 >
162 <Text color={STATUS_COLOR[row.status]} dimColor={row.status === 'done'} wrap="truncate-end">
163 {' '.repeat(row.depth)}
164 {row.label} · {STATUS_WORDS[row.status]} · {row.now}
165 </Text>
166 </Button>
167 {objective !== '' && (
168 <Text color={row.status === 'done' ? 'gray' : undefined} dimColor>
169 {objective}
170 </Text>
171 )}
172 </Box>
173 )
174 })}
175 {hidden > 0 && <Text dimColor>+{hidden} more</Text>}
176 {details.length > 0 && (
177 <Box key="details" flexDirection="column" marginTop={1}>
178 {details.map(line => {
179 // A brief or a report the line cuts short can be read whole, under its line.
180 const found = PARTS.find(p => line.startsWith(p.line))
181 const whole = found === undefined || chosen === undefined ? '' : chosen[found.part]
182 if (found === undefined || !isCut(whole)) return <Text dimColor>{line}</Text>
183 const isOpen = expanded === found.part
184 return (
185 <Box key={`part-${found.part}`} flexDirection="column">
186 <Text dimColor>{line}</Text>
187 <Button
188 key={`read-${found.part}`}
189 plain
190 onPress={() => update($, expandedAtom, current => (current === found.part ? null : found.part))}
191 >
192 <Text color={LINK}>{isOpen ? `Hide the full ${found.part}` : `Read the full ${found.part}`}</Text>
193 </Button>
194 {isOpen && <Markdown key={`full-${found.part}`} text={whole} />}
195 </Box>
196 )
197 })}
198 </Box>
199 )}
200 </Box>
201 </Box>
202 <Box key="buttons" flexDirection="row" gap={2}>
203 <Button
204 key="legend"
205 label={isLegendOpen ? 'Hide legend' : 'Show legend'}
206 onPress={() => update($, legendAtom, isOpen => !isOpen)}
207 />
208 {recap !== null && (
209 <Button
210 key="job"
211 label={isJobOpen ? 'Hide this job' : 'Show this job'}
212 onPress={() => update($, jobOpenAtom, isOpen => !isOpen)}
213 />
214 )}
215 </Box>
216 {recapPicture !== undefined && recap !== null && (
217 <Box key="job-view" flexDirection="column">
218 {recapSummary(recap).map((line, i) => (
219 <Text bold={i === 0}>{line}</Text>
220 ))}
221 {sessionLines(sessionTotals(session, recap)).map(line => (
222 <Text>{line}</Text>
223 ))}
224 <Svg source={recapPicture} alt={recapAlt(recap)} {...svgSize(recapPicture)} />
225 {recapRows(recap).map(line => (
226 <Text dimColor>{line}</Text>
227 ))}
228 {history.length > 0 && (
229 <Box key="history" flexDirection="column" marginTop={1}>
230 <Text bold>Earlier jobs</Text>
231 {[...history].reverse().map(record => (
232 <Text dimColor wrap="truncate-end">
233 {historyLine(record)}
234 </Text>
235 ))}
236 </Box>
237 )}
238 </Box>
239 )}
240 {isLegendOpen && (
241 <Box key="legend-list" flexDirection="column">
242 {STATION_ORDER.map(id => (
243 <Text color={STATIONS[id].color}>
244 ■ {STATIONS[id].name}: {STATIONS[id].tools}
245 </Text>
246 ))}
247 </Box>
248 )}
249 {isDebug === true && <Client key={PROBE_BOTTOM} module="./probe.tsx" props={{ where: 'bottom' }} />}
250 </Box>
251 )
252 })
253}
254hooks/recap.ts 415 lines1import type { Agent, JobRecord, Outcome, Scene } from '../types'
2import { MAIN } from './model'
3import { escapeXml } from './render'
4import { tokenCount } from './roles'
5import { clip } from './stations'
6
7// How each agent worked with Main in this job: Main itself, a helper Main waited for, or one working alongside
8// (in the background, or a teammate). The recap colors its bars by this, not by role: the role is the row's name
9// already, and these three are the colors that stay apart for every reader, with any two of them side by side.
10export type LaneKind = 'main' | 'waited' | 'alongside'
11
12export type Lane = {
13 id: string
14 label: string
15 depth: number
16 kind: LaneKind
17 start: number
18 end: number
19 isOpen: boolean
20 outcome: Outcome | null
21 tokens: number | null
22}
23
24export type Recap = {
25 task: string
26 startedAt: number
27 endedAt: number
28 isOpen: boolean
29 lanes: Lane[]
30 helpers: number
31 // Every helper's time added up, and the time during which at least one helper was at work.
32 helperMs: number
33 togetherMs: number
34 peak: number
35 tokens: number | null
36 // Main's own tokens, and the sum of the helpers' counted tokens; null when none were counted.
37 mainTokens: number | null
38 helperTokens: number | null
39 // File edits Main made itself rather than through a helper.
40 mainEdits: number
41}
42
43// A helper belongs to the job it was hired (or called back) in. A called-back helper's tokens are all its work,
44// earlier stints included: the engine reports them per turn, and the office adds them up.
45export function recapOf(scene: Scene, now: number): Recap | null {
46 const job = scene.job
47 if (job === null) return null
48 const isInJob = (a: Agent): boolean => a.id !== MAIN && a.arrivedAt >= job.startedAt
49 const children = new Map<string, string[]>()
50 for (const id of scene.order) {
51 const a = scene.agents[id]
52 if (a === undefined || !isInJob(a)) continue
53 const parent = a.parentId !== null && scene.agents[a.parentId] !== undefined && (a.parentId === MAIN || isInJob(scene.agents[a.parentId] as Agent)) ? a.parentId : MAIN
54 children.set(parent, [...(children.get(parent) ?? []), id])
55 }
56 const lanes: Lane[] = []
57 const visit = (id: string, depth: number): void => {
58 const a = scene.agents[id]
59 if (a === undefined) return
60 if (id === MAIN) {
61 lanes.push({
62 id,
63 label: a.label,
64 depth,
65 kind: 'main',
66 start: job.startedAt,
67 end: job.endedAt ?? now,
68 isOpen: job.endedAt === null,
69 outcome: null,
70 tokens: job.mainUsage?.tokens ?? null,
71 })
72 } else {
73 const isOpen = a.doneAt === null && a.phase !== 'gone' && a.phase !== 'idle'
74 lanes.push({
75 id,
76 label: a.label,
77 depth,
78 kind: a.isBackground || a.isTeammate ? 'alongside' : 'waited',
79 start: a.arrivedAt,
80 end: a.doneAt ?? (isOpen ? now : Math.max(a.arrivedAt, a.lastEventAt)),
81 isOpen,
82 outcome: a.outcome,
83 tokens: a.usage?.tokens ?? null,
84 })
85 }
86 for (const child of children.get(id) ?? []) visit(child, depth + 1)
87 }
88 visit(MAIN, 0)
89 const helpers = lanes.filter(l => l.kind !== 'main')
90 const counted = lanes.filter(l => l.tokens !== null)
91 const countedHelpers = helpers.filter(l => l.tokens !== null)
92 return {
93 task: job.task,
94 startedAt: job.startedAt,
95 endedAt: Math.max(...lanes.map(l => l.end)),
96 isOpen: lanes.some(l => l.isOpen),
97 lanes,
98 helpers: helpers.length,
99 helperMs: helpers.reduce((sum, l) => sum + (l.end - l.start), 0),
100 togetherMs: unionMs(helpers),
101 peak: peakOf(helpers),
102 tokens: counted.length === 0 ? null : counted.reduce((sum, l) => sum + (l.tokens ?? 0), 0),
103 mainTokens: lanes.find(l => l.kind === 'main')?.tokens ?? null,
104 helperTokens: countedHelpers.length === 0 ? null : countedHelpers.reduce((sum, l) => sum + (l.tokens ?? 0), 0),
105 mainEdits: job.mainEdits ?? 0,
106 }
107}
108
109// How long at least one of them was at work: their spans merged where they overlap, then added up.
110function unionMs(lanes: readonly Lane[]): number {
111 const spans = lanes.map(l => [l.start, l.end] as const).sort((a, b) => a[0] - b[0])
112 let total = 0
113 let open: [number, number] | null = null
114 for (const [s, e] of spans) {
115 if (open === null || s > open[1]) {
116 if (open !== null) total += open[1] - open[0]
117 open = [s, e]
118 } else open[1] = Math.max(open[1], e)
119 }
120 return open === null ? total : total + open[1] - open[0]
121}
122
123// The most helpers at work at the same moment. An end and a start at the same moment do not overlap.
124function peakOf(lanes: readonly Lane[]): number {
125 const marks = lanes.flatMap(l => [
126 { at: l.start, step: 1 },
127 { at: l.end, step: -1 },
128 ])
129 marks.sort((a, b) => a.at - b.at || a.step - b.step)
130 let now = 0
131 let peak = 0
132 for (const m of marks) {
133 now += m.step
134 peak = Math.max(peak, now)
135 }
136 return peak
137}
138
139// 45s, 4m 10s, 1h 2m.
140export function span(ms: number): string {
141 const secs = Math.max(0, Math.round(ms / 1000))
142 if (secs < 60) return `${secs}s`
143 const mins = Math.floor(secs / 60)
144 if (mins < 60) return `${mins}m ${secs % 60}s`
145 return `${Math.floor(mins / 60)}h ${mins % 60}m`
146}
147
148const ENDINGS: Record<Outcome, string> = { reported: 'handed in its report', stopped: 'was stopped', failed: 'ran into a problem' }
149
150function endingOf(lane: Lane): string {
151 if (lane.isOpen) return 'still working'
152 if (lane.kind === 'main') return 'finished'
153 return lane.outcome === null ? 'went quiet' : ENDINGS[lane.outcome]
154}
155
156// Doing the helpers' work one after another would take all their time added up; together it took less. Said
157// only when it is a real saving, with two or more helpers.
158export function speedUp(recap: Recap): number | null {
159 if (recap.helpers < 2 || recap.togetherMs <= 0) return null
160 const ratio = recap.helperMs / recap.togetherMs
161 return ratio >= 1.2 ? ratio : null
162}
163
164// The recap in words: what the job was, how long it took, how many helpers and how much at once, what it cost,
165// and what working in parallel bought. These lines stand on their own without the picture.
166export function recapSummary(recap: Recap): string[] {
167 const took = `${recap.isOpen ? 'Running for' : 'Took'} ${span(recap.endedAt - recap.startedAt)}`
168 const crew =
169 recap.helpers === 0
170 ? 'no helpers'
171 : `${recap.helpers} helper${recap.helpers === 1 ? '' : 's'}, up to ${recap.peak} at once`
172 const split = [
173 ...(recap.mainTokens === null ? [] : [`Main ${tokenCount(recap.mainTokens)}`]),
174 ...(recap.helperTokens === null ? [] : [`helpers ${tokenCount(recap.helperTokens)}`]),
175 ]
176 const cost =
177 recap.tokens === null ? '' : ` · ${tokenCount(recap.tokens)} tokens${split.length === 0 ? '' : ` (${split.join(', ')})`}`
178 const lines = [recap.task === '' ? 'This job' : `This job: ${recap.task}`, `${took} · ${crew}${cost}`]
179 if (recap.mainEdits > 0) {
180 lines.push(`Main made ${recap.mainEdits} file edit${recap.mainEdits === 1 ? '' : 's'} itself${recap.helpers === 0 ? '' : ', not through a helper'}`)
181 }
182 const ratio = speedUp(recap)
183 if (ratio !== null) {
184 lines.push(
185 `Helpers did ${span(recap.helperMs)} of work in ${span(recap.togetherMs)}: about ${ratio.toFixed(1)}× faster than one after another`,
186 )
187 }
188 return lines
189}
190
191// One line per agent, indented by who hired whom: the table the picture is drawn from.
192export function recapRows(recap: Recap): string[] {
193 return recap.lanes.map(l => {
194 const indent = ' '.repeat(l.depth)
195 // The engine reports an agent's tokens when its turn ends, so a lane still at work has none yet.
196 if (l.isOpen && l.tokens === null) return `${indent}${l.label}: ${span(l.end - l.start)}, running, tokens counted when it finishes`
197 const tokens = l.tokens === null ? '' : `, ${tokenCount(l.tokens)} tokens`
198 return `${indent}${l.label}: ${span(l.end - l.start)}${tokens}, ${endingOf(l)}`
199 })
200}
201
202const pad = (v: number): string => String(v).padStart(2, '0')
203const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
204
205// "15 Oct 14:05 fix the login bug: took 4m 10s, 3 helpers, 182k tokens", in local time.
206export function historyLine(record: JobRecord): string {
207 const d = new Date(record.startedAt)
208 const when = `${d.getDate()} ${MONTHS[d.getMonth()] ?? ''} ${pad(d.getHours())}:${pad(d.getMinutes())}`
209 const crew = record.helpers === 0 ? 'no helpers' : `${record.helpers} helper${record.helpers === 1 ? '' : 's'}`
210 const tokens = record.tokens === null ? '' : `, ${tokenCount(record.tokens)} tokens`
211 return `${when} ${record.task === '' ? 'a job' : record.task}: took ${span(record.tookMs)}, ${crew}${tokens}`
212}
213
214// The history keeps the newest 20 jobs.
215export const HISTORY_MAX = 20
216
217// What the store holds is read back from disk: anything that is not a well-formed record is left out.
218export function isRecord(value: unknown): value is JobRecord {
219 const r = value as Partial<JobRecord> | null
220 return (
221 typeof r === 'object' &&
222 r !== null &&
223 typeof r.startedAt === 'number' &&
224 typeof r.task === 'string' &&
225 typeof r.tookMs === 'number' &&
226 typeof r.helpers === 'number' &&
227 typeof r.peak === 'number' &&
228 (r.tokens === null || typeof r.tokens === 'number') &&
229 (r.mainTokens === undefined || r.mainTokens === null || typeof r.mainTokens === 'number') &&
230 (r.helperTokens === undefined || r.helperTokens === null || typeof r.helperTokens === 'number') &&
231 (r.mainEdits === undefined || typeof r.mainEdits === 'number')
232 )
233}
234
235// A job is saved each time Main's turn ends, so a turn that carries on later updates the same record.
236export function withRecord(list: readonly JobRecord[], record: JobRecord, max: number = HISTORY_MAX): JobRecord[] {
237 return [...list.filter(r => r.startedAt !== record.startedAt), record]
238 .sort((a, b) => a.startedAt - b.startedAt)
239 .slice(-max)
240}
241
242// The session keeps up to this many jobs in memory.
243export const SESSION_MAX = 500
244
245export type SessionTotals = {
246 jobs: number
247 helpers: number
248 workMs: number
249 tokens: number | null
250 mainTokens: number | null
251 helperTokens: number | null
252 mainEdits: number
253}
254
255// Every job since the conversation began, the one still running included (it replaces its own saved record).
256export function sessionTotals(records: readonly JobRecord[], open: Recap | null): SessionTotals {
257 const all = open === null ? [...records] : withRecord(records, jobRecord(open), Number.MAX_SAFE_INTEGER)
258 const sum = (pick: (r: JobRecord) => number | null | undefined): number | null => {
259 const values = all.map(pick).filter((v): v is number => typeof v === 'number')
260 return values.length === 0 ? null : values.reduce((a, b) => a + b, 0)
261 }
262 return {
263 jobs: all.length,
264 helpers: all.reduce((a, r) => a + r.helpers, 0),
265 workMs: all.reduce((a, r) => a + r.tookMs, 0),
266 tokens: sum(r => r.tokens),
267 mainTokens: sum(r => r.mainTokens),
268 helperTokens: sum(r => r.helperTokens),
269 mainEdits: all.reduce((a, r) => a + (r.mainEdits ?? 0), 0),
270 }
271}
272
273// One line: This session: 4 jobs · 9 helpers · 12m 40s · 482k tokens (Main 211k, helpers 271k).
274export function sessionLines(totals: SessionTotals): string[] {
275 const crew = totals.helpers === 0 ? 'no helpers' : `${totals.helpers} helper${totals.helpers === 1 ? '' : 's'}`
276 const split = [
277 ...(totals.mainTokens === null ? [] : [`Main ${tokenCount(totals.mainTokens)}`]),
278 ...(totals.helperTokens === null ? [] : [`helpers ${tokenCount(totals.helperTokens)}`]),
279 ]
280 const cost =
281 totals.tokens === null ? '' : ` · ${tokenCount(totals.tokens)} tokens${split.length === 0 ? '' : ` (${split.join(', ')})`}`
282 return [`This session: ${totals.jobs} job${totals.jobs === 1 ? '' : 's'} · ${crew} · ${span(totals.workMs)}${cost}`]
283}
284
285export function jobRecord(recap: Recap): JobRecord {
286 return {
287 startedAt: recap.startedAt,
288 task: recap.task,
289 tookMs: recap.endedAt - recap.startedAt,
290 helpers: recap.helpers,
291 peak: recap.peak,
292 tokens: recap.tokens,
293 mainTokens: recap.mainTokens,
294 helperTokens: recap.helperTokens,
295 mainEdits: recap.mainEdits,
296 }
297}
298
299// ---------------------------------------------------------------------------------------------------
300// The swimlane: one row per agent, a bar from when it started to when it finished, on one time axis.
301// Colors were checked with the data-viz palette validator, all pairs, light and dark surfaces. Light values
302// are presentation attributes, so the picture stays readable where a stylesheet is not applied; the dark
303// steps come in through prefers-color-scheme. Text uses the ink colors, never a bar's.
304// ---------------------------------------------------------------------------------------------------
305
306const LIGHT = {
307 surface: '#fcfcfb',
308 ink: '#0b0b0b',
309 muted: '#52514e',
310 grid: '#e4e2dc',
311 main: '#eb6834',
312 waited: '#2a78d6',
313 alongside: '#1baf7a',
314 token: '#9a968c',
315} as const
316const DARK = {
317 surface: '#1a1a19',
318 ink: '#f0efec',
319 muted: '#c3c2b7',
320 grid: '#383835',
321 main: '#d95926',
322 waited: '#3987e5',
323 alongside: '#199e70',
324 token: '#7d7a72',
325} as const
326// Status colors stand for how a helper's job ended, never for a kind of helper, and always with a shape.
327const STATUS = { reported: { glyph: '✓', fill: '#0ca30c' }, stopped: { glyph: '■', fill: '#fab219' }, failed: { glyph: '✕', fill: '#d03b3b' } } as const
328
329export const KIND_WORDS: Record<LaneKind, string> = {
330 main: 'Main',
331 waited: 'A helper Main waited for',
332 alongside: 'A helper working alongside',
333}
334
335const W = 758
336const PAD = 12
337const LABEL_W = 136
338const END_W = 74
339const X0 = PAD + LABEL_W
340const TOK_W = 118
341const X1 = W - PAD - END_W - TOK_W
342const TOP = 34
343const ROW_H = 22
344const BAR_H = 10
345const STEPS = [5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200].map(s => s * 1000)
346
347const r1 = (v: number): number => Math.round(v * 10) / 10
348
349function tickLabel(ms: number): string {
350 if (ms === 0) return '0'
351 return ms < 60_000 ? `${ms / 1000}s` : ms < 3_600_000 ? `${ms / 60_000}m` : `${ms / 3_600_000}h`
352}
353
354export function recapSvg(recap: Recap): string {
355 const total = Math.max(1000, recap.endedAt - recap.startedAt)
356 const x = (t: number): number => X0 + ((t - recap.startedAt) / total) * (X1 - X0)
357 const step = STEPS.find(s => total / s <= 6) ?? (STEPS[STEPS.length - 1] as number)
358 const axisY = TOP + recap.lanes.length * ROW_H + 4
359 const height = axisY + 24
360 const css =
361 `<style>@media (prefers-color-scheme: dark){` +
362 `.s{fill:${DARK.surface}}.i{fill:${DARK.ink}}.m{fill:${DARK.muted}}.g{stroke:${DARK.grid}}` +
363 `.k-main{fill:${DARK.main}}.k-waited{fill:${DARK.waited}}.k-alongside{fill:${DARK.alongside}}.t{fill:${DARK.token}}}</style>`
364 let out = `<rect class="s" x="0" y="0" width="${W}" height="${height}" rx="8" fill="${LIGHT.surface}"/>`
365 // Legend: the kinds this job has, in a fixed order, so a color always means the same kind.
366 let lx = PAD
367 for (const kind of (['main', 'waited', 'alongside'] as const).filter(k => recap.lanes.some(l => l.kind === k))) {
368 out += `<rect class="k-${kind}" x="${lx}" y="9" width="10" height="10" rx="2" fill="${LIGHT[kind]}"/>`
369 out += `<text class="m" x="${lx + 15}" y="18" font-size="11" fill="${LIGHT.muted}">${escapeXml(KIND_WORDS[kind])}</text>`
370 lx += r1(15 + KIND_WORDS[kind].length * 5.6 + 16)
371 }
372 const tokX = X1 + END_W + 8
373 out += `<text class="m" x="${tokX}" y="18" font-size="11" text-anchor="start" fill="${LIGHT.muted}">Tokens</text>`
374 const tokenTotal = recap.lanes.reduce((sum, l) => sum + (l.tokens ?? 0), 0)
375 // Time axis: a few clean ticks, faint solid lines behind the bars.
376 for (let t = 0; t <= total; t += step) {
377 const tx = r1(x(recap.startedAt + t))
378 out += `<line class="g" x1="${tx}" y1="${TOP - 4}" x2="${tx}" y2="${axisY}" stroke="${LIGHT.grid}" stroke-width="1"/>`
379 out += `<text class="m" x="${tx}" y="${axisY + 15}" font-size="10" text-anchor="middle" fill="${LIGHT.muted}">${tickLabel(t)}</text>`
380 }
381 recap.lanes.forEach((lane, i) => {
382 const mid = TOP + i * ROW_H + ROW_H / 2
383 const name = clip(lane.label, 20 - lane.depth * 2)
384 out += `<text class="i" x="${PAD + lane.depth * 10}" y="${r1(mid + 4)}" font-size="12" fill="${LIGHT.ink}">${escapeXml(name)}</text>`
385 const bx = r1(x(lane.start))
386 const bw = r1(Math.max(4, x(lane.end) - x(lane.start)))
387 out += `<rect class="k-${lane.kind}" x="${bx}" y="${r1(mid - BAR_H / 2)}" width="${bw}" height="${BAR_H}" rx="4" fill="${LIGHT[lane.kind]}"/>`
388 // At the bar's end: how it ended (a shape and a color) and how long it took, in ink.
389 const status = lane.kind === 'main' || lane.isOpen || lane.outcome === null ? null : STATUS[lane.outcome]
390 const ex = r1(bx + bw + 6)
391 const glyph = status === null ? '' : `<tspan fill="${status.fill}">${status.glyph}</tspan> `
392 const words = lane.isOpen ? `${span(lane.end - lane.start)}…` : span(lane.end - lane.start)
393 out += `<text class="i" x="${ex}" y="${r1(mid + 4)}" font-size="11" fill="${LIGHT.ink}">${glyph}${escapeXml(words)}</text>`
394 // The token column: this agent's share of the job's counted tokens, or why there is none yet.
395 if (lane.tokens !== null) {
396 const share = tokenTotal > 0 ? lane.tokens / tokenTotal : 0
397 const tw = lane.tokens > 0 ? r1(Math.max(2, share * 44)) : 0
398 out += `<rect class="t" x="${tokX}" y="${r1(mid - 2)}" width="${tw}" height="4" rx="2" fill="${LIGHT.token}"/>`
399 out += `<text class="i" x="${tokX + 48}" y="${r1(mid + 4)}" font-size="11" fill="${LIGHT.ink}">${tokenCount(lane.tokens)} · ${Math.round(share * 100)}%</text>`
400 } else {
401 out += `<text class="m" x="${tokX}" y="${r1(mid + 4)}" font-size="11" fill="${LIGHT.muted}">${lane.isOpen ? 'running' : '—'}</text>`
402 }
403 })
404 const title = `Timeline of this job: ${recap.lanes.length} agents`
405 return (
406 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${height}" width="${W}" height="${height}" ` +
407 `font-family="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif"><title>${escapeXml(title)}</title>${css}${out}</svg>`
408 )
409}
410
411// What the picture shows, for anyone who cannot see it.
412export function recapAlt(recap: Recap): string {
413 return `Timeline of this job. ${recapRows(recap).map(line => line.trim()).join('. ')}.`
414}
415hooks/render.ts 685 lines1import type { Agent, Scene, StationId } from '../types'
2import { etaDetail, etaOf, etaWords } from './eta'
3import { MAIN, isCoordinating, isWalkingAt, positionAt, waitingOn } from './model'
4import { doing } from './narrate'
5import { OFFICE_DEFS, officeBase, officeDecor, rect } from './office'
6import { petSvg } from './pet'
7import { modelName, roleFor, tokenCount } from './roles'
8import { iconSvg, itemSvg, personSvg } from './sprites'
9import type { Variant } from './sprites'
10import { EXCERPT, HEIGHT, STATIONS, WAITING_TOOLS, WIDTH, clamp, clip, gist, splitAt, stripControls } from './stations'
11
12export const SVG_BUDGET = 120_000
13export const PUFF_MS = 2500
14
15const PUFF = '#e8453c'
16const BOB: Partial<Record<StationId, number>> = {
17 office: 0.8,
18 desk: 0.4,
19 library: 0.7,
20 server: 0.35,
21 mailroom: 0.6,
22 phone: 1,
23 taskboard: 0.6,
24 meeting: 0.9,
25}
26
27const XML: Readonly<Record<string, string>> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
28
29export const escapeXml = (text: string): string => stripControls(text).replace(/[&<>"']/g, ch => XML[ch] ?? ch)
30
31const n = (v: number): number => Math.round(v * 100) / 100
32
33export function isNight(now: number): boolean {
34 const hour = new Date(now).getHours()
35 return hour < 7 || hour >= 19
36}
37
38// `now` says in plain words what the agent is doing at this moment, or how its job ended.
39export type OrgRow = {
40 id: string
41 depth: number
42 label: string
43 status: 'working' | 'coordinating' | 'waiting' | 'idle' | 'done'
44 now: string
45}
46
47// What each status reads as in the panel. `waiting` is always waiting on you; waiting on helpers is coordinating.
48export const STATUS_WORDS: Readonly<Record<OrgRow['status'], string>> = {
49 working: 'working',
50 coordinating: 'coordinating',
51 waiting: 'needs you',
52 idle: 'idle',
53 done: 'done',
54}
55
56export function statusOf(scene: Scene, agent: Agent): OrgRow['status'] {
57 if (isCoordinating(scene, agent)) return 'coordinating'
58 switch (agent.phase) {
59 case 'waiting':
60 return 'waiting'
61 case 'idle':
62 return 'idle'
63 case 'gone':
64 return 'done'
65 default:
66 return 'working'
67 }
68}
69
70export function orgRows(scene: Scene, now: number | null = null): OrgRow[] {
71 const children = new Map<string, string[]>()
72 for (const id of scene.order) {
73 const agent = scene.agents[id]
74 if (agent === undefined || agent.parentId === null) continue
75 const parent = scene.agents[agent.parentId] !== undefined ? agent.parentId : MAIN
76 children.set(parent, [...(children.get(parent) ?? []), id])
77 }
78 // Live agents come first among siblings so a trimmed chart keeps them; arrival order holds inside each group.
79 const isGone = (id: string): boolean => scene.agents[id]?.phase === 'gone'
80 for (const siblings of children.values()) siblings.sort((a, b) => Number(isGone(a)) - Number(isGone(b)))
81 const rows: OrgRow[] = []
82 const seen = new Set<string>()
83 const visit = (id: string, depth: number): void => {
84 if (seen.has(id)) return
85 seen.add(id)
86 const agent = scene.agents[id]
87 if (agent === undefined) return
88 const eta = now === null ? null : etaOf(scene, agent, now)
89 const activity = nowOf(scene, agent)
90 rows.push({ id, depth, label: agent.label, status: statusOf(scene, agent), now: eta === null ? activity : `${activity}, ${etaWords(eta)}` })
91 for (const child of children.get(id) ?? []) visit(child, depth + 1)
92 }
93 visit(MAIN, 0)
94 for (const id of scene.order) visit(id, 1)
95 return rows
96}
97
98// "Researcher", "Researcher and Planner", "Researcher, Planner and Assistant", "4 helpers".
99function names(agents: readonly Agent[]): string {
100 const labels = agents.map(a => a.label)
101 if (labels.length > 3) return `${labels.length} helpers`
102 if (labels.length <= 1) return labels[0] ?? ''
103 return `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1] ?? ''}`
104}
105
106const ENDINGS = { reported: 'handed in its report', stopped: 'was stopped', failed: 'ran into a problem' } as const
107
108// What an agent is up to right now, in the words the feed uses: "reading auth.ts", "waiting on Researcher".
109export function nowOf(scene: Scene, agent: Agent): string {
110 if (agent.phase === 'gone') return agent.outcome === null ? 'left' : ENDINGS[agent.outcome]
111 if (isCoordinating(scene, agent)) return `waiting on ${names(waitingOn(scene, agent.id))}`
112 // A tool held at a permission prompt has not run yet: what the agent does now is wait for you.
113 if (agent.phase === 'waiting' && (agent.activity === null || !WAITING_TOOLS.has(agent.activity.tool))) {
114 return 'waiting for your permission'
115 }
116 if (agent.activity !== null) return doing(agent.activity)
117 switch (agent.phase) {
118 case 'arriving':
119 return 'arriving'
120 case 'briefing':
121 return 'being briefed'
122 case 'idle':
123 if (agent.station === 'breakroom') return 'on a break'
124 return agent.id === MAIN ? 'ready for your next message' : 'free for the next job'
125 default:
126 return 'thinking'
127 }
128}
129
130// The second line under a helper in the org chart: what it was hired to do, fitted to a column `width` wide.
131export function objectiveLine(scene: Scene, id: string, depth: number, width: number): string {
132 const agent = scene.agents[id]
133 if (agent === undefined || agent.id === MAIN) return ''
134 const objective = clip(agent.description, Math.max(1, width - depth * 2 - 2))
135 return objective === '' ? '' : `${' '.repeat(depth)}↳ ${objective}`
136}
137
138const pad = (v: number): string => String(v).padStart(2, '0')
139
140export function clockLabel(at: number): string {
141 const d = new Date(at)
142 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
143}
144
145export function altText(scene: Scene): string {
146 const here = Object.values(scene.agents).filter(a => a.phase !== 'gone')
147 const noun = here.length === 1 ? 'agent' : 'agents'
148 return `Pixel office with ${here.length} ${noun}: ${here.map(a => `${a.label} at ${STATIONS[a.station].name}`).join(', ')}`
149}
150
151// The details box shows a brief or a report as one line; the whole text is a press away.
152export const excerptOf = (text: string): string => gist(text, EXCERPT)
153// Whether the one line leaves something out, so the whole text is worth offering.
154export const isCut = (text: string): boolean => excerptOf(text).endsWith('…')
155
156// Lines that only belong in the panel: the brief and the report are long, and the job clock ticks every second.
157const PANEL_ONLY = ['Brief:', 'Report:', 'On the job:', 'Expected:', 'Used']
158
159// The details box: who this is, what its kind of agent does, the job it was given, what it is doing, who it
160// works for and how that boss works with it, and what it handed back.
161export function agentDetails(scene: Scene, id: string, now: number): string[] {
162 const agent = scene.agents[id]
163 if (agent === undefined) return []
164 const role = roleFor(agent.type)
165 const boss = agent.parentId === null ? undefined : scene.agents[agent.parentId]?.label
166 const used =
167 agent.usage === null
168 ? []
169 : [`${agent.id === MAIN ? 'Used so far' : 'Used'}: ${tokenCount(agent.usage.tokens)} tokens (${tokenCount(agent.usage.reused)} reused) on ${modelName(agent.usage.model)}`]
170 if (agent.id === MAIN) return [`Main · ${role.title}`, role.blurb, `Now: ${nowOf(scene, agent)}`, ...used]
171 // "Researcher 2 · Explore agent" ties the office's name to the one Claude Code shows; a named agent also gets its role.
172 const isRoleName = agent.label === role.title || agent.label.startsWith(`${role.title} `)
173 const title =
174 agent.type === 'contractor'
175 ? agent.label
176 : isRoleName
177 ? `${agent.label} · ${agent.type} agent`
178 : `${agent.label} · ${role.title} (${agent.type} agent)`
179 const lines = [title, role.blurb]
180 if (agent.description !== '') lines.push(`Job: ${agent.description}`)
181 if (agent.brief !== '') lines.push(`Brief: ${excerptOf(agent.brief)}`)
182 lines.push(`Now: ${nowOf(scene, agent)}`)
183 if (boss !== undefined) {
184 const how = agent.isTeammate ? 'leads the team' : agent.isBackground ? 'keeps working meanwhile' : 'waits for its report'
185 lines.push(`Reports to: ${boss}, who ${how}`)
186 }
187 if (agent.report !== '') lines.push(`Report: ${excerptOf(agent.report)}`)
188 lines.push(...used)
189 if (agent.id !== MAIN) {
190 // A helper that has left stops the clock where it left.
191 const until = agent.phase === 'gone' && agent.goneAt !== null ? Math.min(now, agent.goneAt) : now
192 const secs = Math.max(0, Math.round((until - agent.arrivedAt) / 1000))
193 lines.push(`On the job: ${Math.floor(secs / 60)}m ${secs % 60}s`)
194 const eta = etaOf(scene, agent, now)
195 if (eta !== null) lines.push(etaDetail(eta))
196 }
197 return lines
198}
199
200// A boss waiting on its helpers shows who it is waiting on, with the meeting-room icon.
201type BubbleBox = { text: string; station: StationId; isWaiting: boolean; width: number; left: number; top: number }
202
203// What a speech bubble says and where it would sit over its agent, or null when the agent has nothing to say.
204function bubbleBox(agent: Agent, scene: Scene): BubbleBox | null {
205 const isWaiting = agent.phase === 'waiting'
206 const team = isCoordinating(scene, agent) ? waitingOn(scene, agent.id) : []
207 if (agent.activity === null && !isWaiting && team.length === 0) return null
208 const station: StationId = isWaiting ? 'reception' : team.length > 0 ? 'meeting' : agent.station
209 const text =
210 team.length === 1 ? clip(`waiting on ${team[0]?.label ?? ''}`) : team.length > 1 ? `waiting on ${team.length}` : (agent.activity?.target ?? '')
211 const width = 9 + Math.ceil(text.length * 2.4)
212 const left = clamp(-Math.round(width / 2), 2 - agent.pos.x, WIDTH - 2 - agent.pos.x - width)
213 const top = Math.max(-27, 1 - agent.pos.y)
214 return { text, station, isWaiting, width, left, top }
215}
216
217// Bubbles of agents standing close together would cover each other, so each takes the lowest lane (one bubble
218// higher than the last) that is free, and a thin stem joins a raised bubble to its agent. Near the top of the floor
219// there is no room to go higher: a bubble with no free lane is left out (-1) rather than hidden under another, and
220// the panel still says what that agent is doing.
221const BUBBLE_HEIGHT = 10
222const BUBBLE_STEP = 11
223const BUBBLE_LANES = 3
224
225function bubbleLanes(
226 agents: readonly Agent[],
227 scene: Scene,
228 now: number,
229 positionOf = (a: Agent) => positionAt(a, now),
230): Map<string, number> {
231 const placed: Array<{ x0: number; x1: number; y0: number; y1: number }> = []
232 const lanes = new Map<string, number>()
233 for (const agent of agents) {
234 const box = bubbleBox(agent, scene)
235 if (box === null) continue
236 const at = positionOf(agent)
237 const x0 = at.x + box.left
238 const x1 = x0 + box.width
239 let found = -1
240 let highest = Infinity
241 for (let lane = 0; lane <= BUBBLE_LANES; lane++) {
242 const y0 = at.y + Math.max(1 - agent.pos.y, box.top - lane * BUBBLE_STEP)
243 // A lane that is no higher than the last, because the floor's top edge stops it, is no new lane.
244 if (y0 >= highest) break
245 highest = y0
246 const rect = { x0, x1, y0, y1: y0 + BUBBLE_HEIGHT }
247 if (!placed.some(r => r.x0 < rect.x1 && rect.x0 < r.x1 && r.y0 < rect.y1 && rect.y0 < r.y1)) {
248 placed.push(rect)
249 found = lane
250 break
251 }
252 }
253 lanes.set(agent.id, found)
254 }
255 return lanes
256}
257
258function bubble(agent: Agent, scene: Scene, lane = 0): string {
259 const box = bubbleBox(agent, scene)
260 if (box === null || lane < 0) return ''
261 const { text, station, isWaiting, width, left } = box
262 const top = Math.max(1 - agent.pos.y, box.top - lane * BUBBLE_STEP)
263 const blink = isWaiting ? '<animate attributeName="opacity" values="1;0.35;1" dur="0.8s" repeatCount="indefinite"/>' : ''
264 const words = text === '' ? '' : `<text x="8.5" y="6" font-size="4" fill="#2c2c2a">${escapeXml(text)}</text>`
265 const stem = box.top - top > 0 ? rect(-left - 0.5, BUBBLE_HEIGHT, 1, box.top - top, '#2c2c2a') : ''
266 return (
267 `<g transform="translate(${n(left)},${n(top)})">${blink}` +
268 rect(0, 0, width, 8, '#ffffff', ' stroke="#2c2c2a" stroke-width="0.5"') +
269 rect(-left - 1, 8, 2, 2, '#ffffff') +
270 stem +
271 iconSvg(station, 2, 1.5, STATIONS[station].color) +
272 `${words}</g>`
273 )
274}
275
276function errorPuff(ms: number): string {
277 return (
278 `<g transform="translate(5,-16)">${rect(-2, -2, 4, 4, PUFF)}${rect(-1, -4, 2, 2, '#f09595')}${rect(2, -1, 2, 2, '#f09595')}` +
279 `<animate attributeName="opacity" values="1;0" dur="${Math.round(ms)}ms" fill="freeze"/></g>`
280 )
281}
282
283function leaveAt(agent: Agent, now: number): string {
284 if (agent.phase !== 'gone' || agent.goneAt === null) return ''
285 return `<set attributeName="visibility" to="hidden" begin="${Math.max(0, Math.round(agent.goneAt - now))}ms"/>`
286}
287
288// The folder or envelope an agent carries to another agent, hidden at the moment it is handed over.
289function carried(agent: Agent, now: number): string {
290 if (agent.carry === null || now >= agent.carry.until) return ''
291 return `<g>${itemSvg(agent.carry.item, -1, -5)}<set attributeName="visibility" to="hidden" begin="${Math.round(agent.carry.until - now)}ms"/></g>`
292}
293
294// Name tags sit over heads, or under the feet when a neighbour's tag already takes the space over a head:
295// a boss briefing a new helper sits right beside it, and both names must stay readable.
296const TAG_ABOVE = -14
297const TAG_BELOW = 5.5
298const TAG_CHAR = 2.1
299
300type TagSpot = { x0: number; x1: number; y: number; isBelow: boolean }
301
302export function tagsBelow(agents: readonly Agent[], now: number, positionOf = (a: Agent) => positionAt(a, now)): Set<string> {
303 const placed: TagSpot[] = []
304 const below = new Set<string>()
305 for (const agent of agents) {
306 const pos = positionOf(agent)
307 const half = (Array.from(agent.label).length * TAG_CHAR) / 2 + 0.5
308 const spot = { x0: pos.x - half, x1: pos.x + half, y: pos.y }
309 const overlap = (isBelow: boolean): number =>
310 placed.reduce(
311 (sum, t) =>
312 t.isBelow === isBelow && Math.abs(t.y - spot.y) < 6
313 ? sum + Math.max(0, Math.min(t.x1, spot.x1) - Math.max(t.x0, spot.x0))
314 : sum,
315 0,
316 )
317 const above = overlap(false)
318 const isBelow = above > 0 && overlap(true) < above
319 placed.push({ ...spot, isBelow })
320 if (isBelow) below.add(agent.id)
321 }
322 return below
323}
324
325// A thin bar under a helper's feet that fills over the time its predecessors took. It is animated inside the
326// picture, so it needs no redraw as it fills; one past what is usual stays full and pulses amber instead.
327const BAR_WIDTH = 9
328export function etaBar(scene: Scene, agent: Agent, now: number): string {
329 const eta = etaOf(scene, agent, now)
330 if (eta === null) return ''
331 const x = -BAR_WIDTH / 2
332 const track = rect(x, 1.8, BAR_WIDTH, 1, '#000000', ' fill-opacity="0.45"')
333 if (eta.leftMs <= 1000) {
334 return `${track}<rect x="${x}" y="1.8" width="${BAR_WIDTH}" height="1" fill="#fac775"><animate attributeName="opacity" values="1;0.4;1" dur="1.2s" repeatCount="indefinite"/></rect>`
335 }
336 const start = n(BAR_WIDTH * clamp((now - agent.arrivedAt) / eta.typicalMs, 0, 1))
337 return `${track}<rect x="${x}" y="1.8" width="${start}" height="1" fill="#97c459"><animate attributeName="width" from="${start}" to="${BAR_WIDTH}" dur="${Math.round(eta.leftMs)}ms" fill="freeze"/></rect>`
338}
339
340function drawAgent(agent: Agent, scene: Scene, now: number, isTagBelow = false, lane = 0): string {
341 const variant: Variant = agent.id === MAIN ? 'main' : agent.isTeammate ? 'teammate' : 'plain'
342 // The clock line ticks every second; keeping it out of the drawing keeps an idle office's source unchanged.
343 const facts = agentDetails(scene, agent.id, now).filter(line => !PANEL_ONLY.some(start => line.startsWith(start)))
344 const head = `<title>${escapeXml(facts.join('\n'))}</title>${leaveAt(agent, now)}`
345 const tag =
346 `<text x="0" y="${isTagBelow ? TAG_BELOW : TAG_ABOVE}" font-size="3.5" text-anchor="middle" fill="#ffffff" stroke="#2c2c2a" ` +
347 `stroke-width="0.8" paint-order="stroke">${escapeXml(agent.label)}</text>`
348 const puff = agent.lastErrorAt !== null && now - agent.lastErrorAt < PUFF_MS ? errorPuff(PUFF_MS - (now - agent.lastErrorAt)) : ''
349 const id = `agent-${escapeXml(agent.id)}`
350 if (agent.walk !== null && isWalkingAt(agent, now)) {
351 const rest = splitAt(agent.walk.points, (now - agent.walk.startedAt) / agent.walk.durationMs)
352 // A walk drawn before its first step plays from that step at its own pace.
353 const ms = Math.max(1, Math.round(Math.min(agent.walk.durationMs, agent.walk.startedAt + agent.walk.durationMs - now)))
354 const path = `M${rest.map(p => `${n(p.x)},${n(p.y)}`).join(' L')}`
355 return `<g id="${id}">${head}<animateMotion dur="${ms}ms" fill="freeze" path="${path}"/>${personSvg(agent.look, variant, ms)}${carried(agent, now)}${etaBar(scene, agent, now)}${tag}${bubble(agent, scene, lane)}${puff}</g>`
356 }
357 const period = agent.activity !== null ? BOB[agent.station] : undefined
358 const body = personSvg(agent.look, variant, 0)
359 const working =
360 period === undefined
361 ? body
362 : `<g>${body}<animateTransform attributeName="transform" type="translate" values="0,0;0,-0.6;0,0" dur="${period}s" repeatCount="indefinite"/></g>`
363 return `<g id="${id}" transform="translate(${n(agent.pos.x)},${n(agent.pos.y)})">${head}${working}${etaBar(scene, agent, now)}${tag}${bubble(agent, scene, lane)}${puff}</g>`
364}
365
366// The furniture an agent at work is using, outlined in its station's colour: where on the floor each station's
367// things stand. A desk and a phone booth belong to one agent, so each has its own; the rest are shared.
368type Box = { x: number; y: number; w: number; h: number }
369const IN_USE: Partial<Record<StationId, (slot: number) => Box>> = {
370 desk: slot => ({ x: 77 + 22 * (slot % 4), y: 20 + 30 * Math.floor(slot / 4), w: 18, h: 19 }),
371 library: () => ({ x: 177, y: 11, w: 63, h: 24 }),
372 mailroom: () => ({ x: 177, y: 51, w: 30, h: 19 }),
373 phone: slot => ({ x: 213 + 14 * Math.min(slot, 1), y: 51, w: 14, h: 30 }),
374 server: () => ({ x: 185, y: 101, w: 46, h: 28 }),
375 meeting: () => ({ x: 91, y: 115, w: 44, h: 14 }),
376 taskboard: () => ({ x: 71, y: 0, w: 34, h: 10 }),
377 reception: () => ({ x: 35, y: 122, w: 28, h: 11 }),
378}
379
380function inUse(here: readonly Agent[]): string {
381 const seen = new Set<string>()
382 let out = ''
383 for (const agent of here) {
384 const place = IN_USE[agent.station]
385 if (place === undefined || agent.activity === null) continue
386 const box = place(agent.slot)
387 const key = `${agent.station}:${box.x}:${box.y}`
388 if (seen.has(key)) continue
389 seen.add(key)
390 const color = STATIONS[agent.station].color
391 out +=
392 `<rect x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}" fill="${color}" fill-opacity="0.14" stroke="${color}" stroke-width="0.8">` +
393 '<animate attributeName="stroke-opacity" values="0.95;0.35;0.95" dur="1.6s" repeatCount="indefinite"/></rect>'
394 }
395 return out
396}
397
398function overlays(scene: Scene, now: number): string {
399 const all = Object.values(scene.agents)
400 const here = all.filter(a => a.phase !== 'gone')
401 const busy = (station: StationId): Agent[] =>
402 here.filter(a => a.station === station && a.activity !== null && !isWalkingAt(a, now))
403 let out = inUse(here)
404 if (scene.briefing !== null) {
405 out += `<text x="116" y="99" font-size="3.5" text-anchor="middle" fill="#2c2c2a">${escapeXml(clip(scene.briefing, 18))}</text>`
406 }
407 if (scene.todos !== null) {
408 const notes = [
409 ...Array<string>(Math.min(scene.todos.done, 6)).fill('#97c459'),
410 ...Array<string>(Math.min(scene.todos.open, 6)).fill('#fac775'),
411 ]
412 notes.forEach((fill, i) => {
413 out += rect(74 + (i % 6) * 5, 2.5 + Math.floor(i / 6) * 3, 4, 2.5, fill)
414 })
415 }
416 if (busy('server').length > 0) {
417 for (const x of [188, 204, 220]) {
418 out +=
419 `<rect x="${x}" y="124" width="6" height="1" fill="#ef9f27">` +
420 '<animate attributeName="opacity" values="1;0.2;1" dur="0.25s" repeatCount="indefinite"/></rect>'
421 }
422 }
423 for (const agent of busy('phone')) out += rect(agent.slot === 0 ? 219 : 233, 53, 2, 1, '#fac775')
424 if (busy('mailroom').length > 0) out += rect(196, 57, 5, 4, '#d3a36b') + rect(198, 57, 1, 4, '#854f0b')
425 if (here.some(a => a.activity?.tool === 'Write')) {
426 out +=
427 '<rect x="166" y="34" width="5" height="1" fill="#ffffff">' +
428 '<animate attributeName="height" values="1;5;1" dur="1s" repeatCount="indefinite"/></rect>'
429 }
430 const isElevatorBusy = all.some(
431 a =>
432 (a.id !== MAIN && now - a.arrivedAt < 2000) ||
433 (a.phase === 'gone' && a.goneAt !== null && now < a.goneAt + 1500),
434 )
435 if (isElevatorBusy) out += rect(9, 112, 14, 24, '#2c2c2a')
436 if (here.some(a => a.phase === 'waiting')) {
437 out +=
438 '<rect x="45" y="117" width="6" height="1" fill="#fac775">' +
439 '<animate attributeName="opacity" values="1;0;1" dur="0.5s" repeatCount="indefinite"/></rect>'
440 }
441 return out
442}
443
444function badge(extra: number): string {
445 return `<g>${rect(196, 1, 42, 7, '#2c2c2a')}<text x="217" y="6.3" font-size="4.5" text-anchor="middle" fill="#ffffff">+${extra} more</text></g>`
446}
447
448// The moment the scene last changed: an event, the start of a walk, an error. It tells the pane when the picture
449// has to be drawn again.
450export function sceneTimeOf(scene: Scene): number {
451 let latest = scene.ticker[scene.ticker.length - 1]?.at ?? 0
452 for (const a of Object.values(scene.agents)) {
453 latest = Math.max(latest, a.lastEventAt, a.arrivedAt, a.lastErrorAt ?? 0, a.walk?.startedAt ?? 0)
454 }
455 return latest
456}
457
458// The desktop draws about 7.9 px per cell column, and a little under is used so the picture never overflows the pane.
459export const CELL_PX = 7.5
460
461// Rows of scroll that count as a new place; the picture is drawn again as of now when the scroll crosses into another
462// bucket, so a picture that is mounted fresh starts from where everyone is, while small scrolls leave it undisturbed.
463export const SCROLL_BUCKET = 24
464
465// The probes showed the panel is not remounted while it is scrolled near the top, so the picture is left alone there.
466// Only a scroll far enough to take it out of view changes the key (once out, once back), in case the app unmounts it.
467export const SCROLL_NEAR = 48
468export function scrollKey(offset: number): string {
469 return Number.isFinite(offset) && offset >= SCROLL_NEAR ? 'off' : '0'
470}
471
472// The size the picture takes in a pane of this many columns, keeping its shape.
473export function fitSize(size: { width: number; height: number } | undefined, columns: number): { width: number; height: number } | undefined {
474 if (size === undefined || !Number.isFinite(columns) || columns <= 0) return undefined
475 const width = Math.min(2400, Math.max(320, Math.round(columns * CELL_PX)))
476 return { width, height: Math.round((width * size.height) / size.width) }
477}
478
479// A picture whose box is not sized collapses while the host swaps it, which scrolls the pane back to the top, so
480// each picture is given the size its markup declares: the root element's width and height, never a nested one's.
481export function svgSize(source: string): { width: number; height: number } | undefined {
482 const tag = /<svg\b[^>]*>/.exec(source)?.[0]
483 if (tag === undefined) return undefined
484 const read = (name: string): number => {
485 const value = new RegExp(`\\s${name}="(\\d+(?:\\.\\d+)?|\\.\\d+)"`).exec(tag)?.[1]
486 return value === undefined ? NaN : Number(value)
487 }
488 const width = read('width')
489 const height = read('height')
490 return width > 0 && height > 0 && Number.isFinite(width) && Number.isFinite(height) ? { width, height } : undefined
491}
492
493// The picture is drawn as of this far ahead of the clock, because it takes about this long to appear and start its
494// animations; drawn as of the clock, every redraw would put everyone back by that much.
495export const LEAD_MS = 250
496
497// What the picture shows, without the time. Events that change none of it (an agent doing the same thing again) leave
498// it alone, so the desktop is not asked to draw the picture again.
499function agentFacts(id: string, a: Agent): unknown[] {
500 return [
501 id, a.label, a.type, a.phase, a.station, a.slot, a.desk, a.pos.x, a.pos.y, a.isTeammate, a.isBackground,
502 a.parentId, a.activity?.tool ?? null, a.activity?.target ?? null, a.walk?.startedAt ?? null,
503 a.walk?.durationMs ?? null, a.carry?.item ?? null, a.carry?.until ?? null, a.lastErrorAt, a.goneAt, a.outcome,
504 ]
505}
506
507export function pictureSignature(scene: Scene): string {
508 const agents: unknown[] = []
509 for (const id of scene.order) {
510 const a = scene.agents[id]
511 if (a === undefined) continue
512 agents.push(agentFacts(id, a))
513 }
514 return JSON.stringify([agents, scene.briefing, scene.todos === null ? null : [scene.todos.done, scene.todos.open]])
515}
516
517// The picture the pane shows. A picture whose source changes is drawn again by the desktop, which restarts every
518// walk in it and can resize it under the person's scroll, so the source only changes when something visible did: it
519// is drawn once, as of a moment ahead of now, and the same text comes back until the next change. Walks and blinking
520// play on inside it meanwhile.
521let drawn: { key: string; source: string } | null = null
522
523// `interaction` names what the person has just done in the panel (which row is open, whether the legend is shown):
524// the desktop may draw the picture again when the panel changes, which restarts every animation in it from the
525// moment it was drawn, so a click draws it again as of now and a restart carries on from where everyone really is.
526export function officeSource(scene: Scene, now: number, interaction = ''): string {
527 const key = `${pictureSignature(scene)}:${isNight(now)}:${interaction}`
528 if (drawn === null || drawn.key !== key) drawn = { key, source: renderOffice(scene, now + LEAD_MS) }
529 return drawn.source
530}
531
532export function forgetOffice(): void {
533 drawn = null
534}
535
536const ROOT =
537 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${WIDTH} ${HEIGHT}" width="${WIDTH * 4}" height="${HEIGHT * 4}" ` +
538 'shape-rendering="crispEdges" font-family="monospace">'
539
540function rankedAgents(scene: Scene, now: number): Agent[] {
541 return Object.values(scene.agents)
542 .filter(a => !(a.phase === 'gone' && a.goneAt !== null && now >= a.goneAt))
543 .sort((a, b) => (a.id === MAIN ? -1 : b.id === MAIN ? 1 : b.lastEventAt - a.lastEventAt))
544}
545
546// The picture as layers: the scenery is one picture and each agent is its own transparent one stacked over it. The
547// desktop draws a picture again when its source changes, and that restarts every walk inside that picture, so one
548// agent's event must only change that agent's own picture. A layer whose signature is unchanged returns the very same
549// text, which is what keeps the desktop from drawing it again.
550export const MAX_LAYERS = 24
551export type Layer = { key: string; source: string }
552
553export function agentSignature(scene: Scene, a: Agent, lane: number, isBelow: boolean, at = 0): string {
554 return JSON.stringify([
555 ...agentFacts(a.id, a), lane, isBelow, waitingOn(scene, a.id).map(h => h.label), Math.round(etaOf(scene, a, at)?.typicalMs ?? 0),
556 ])
557}
558
559// What a walk is made of and nothing else: the agent's bubble, lane, tag side, ETA bar and activity are left out.
560export function walkSignature(a: Agent, scroll: string): string {
561 return `${JSON.stringify([
562 a.id, a.label, a.type, a.isTeammate, a.isBackground, a.station, a.slot, a.walk?.startedAt ?? null,
563 a.walk?.durationMs ?? null, a.phase === 'gone', a.goneAt, a.carry?.item ?? null, a.carry?.until ?? null,
564 ])}:${scroll}`
565}
566
567export function baseSignature(scene: Scene, extra = 0): string {
568 const agents = scene.order.flatMap(id => {
569 const a = scene.agents[id]
570 return a === undefined
571 ? []
572 : [[id, a.station, a.slot, a.activity !== null, a.activity?.tool === 'Write', a.phase === 'waiting', a.phase === 'gone', a.arrivedAt, a.goneAt]]
573 })
574 return JSON.stringify([scene.briefing, scene.todos === null ? null : [scene.todos.done, scene.todos.open], agents, extra])
575}
576
577let baseDrawn: { key: string; source: string; sig: string; night: boolean } | null = null
578// `parts` is kept only while a debug trace is listening: what the key was made of, to say what changed.
579type DrawnParts = { state: string; lane: number; below: boolean; scroll: string; walking: boolean }
580const agentDrawn = new Map<string, { key: string; source: string; parts?: DrawnParts }>()
581// The cat is drawn once and the same text is returned forever: its loop starts from the clock at the first draw and
582// repeats, so the same text stays right, and no redraw of anything else restarts it.
583let petDrawn: Layer | null = null
584
585export function forgetLayers(): void {
586 baseDrawn = null
587 petDrawn = null
588 agentDrawn.clear()
589}
590
591export function officeLayers(
592 scene: Scene,
593 now: number,
594 scroll = '0',
595 trace?: (e: Record<string, unknown>) => void,
596): { base: Layer; pet: Layer; agents: Layer[] } {
597 const at = now + LEAD_MS
598 const visible = rankedAgents(scene, at)
599 const ids = new Set(visible.slice(0, MAX_LAYERS).map(a => a.id))
600 const drawn = scene.order.filter(id => ids.has(id)).flatMap(id => (scene.agents[id] === undefined ? [] : [scene.agents[id]!]))
601 const extra = visible.length - drawn.length
602 // Decided from where each agent is going, not where it is this instant, so a walk never changes the answers.
603 const below = tagsBelow(drawn, at, a => a.pos)
604 const lanes = bubbleLanes(drawn, scene, at, a => a.pos)
605
606 const baseSig = baseSignature(scene, extra)
607 const baseKey = `${baseSig}:${isNight(now)}`
608 if (baseDrawn === null || baseDrawn.key !== baseKey) {
609 if (trace !== undefined) {
610 const why = baseDrawn === null ? ['first'] : [...(baseDrawn.sig !== baseSig ? ['signature'] : []), ...(baseDrawn.night !== isNight(now) ? ['night'] : [])]
611 trace({ layer: 'base', why })
612 }
613 const source = `${ROOT}${OFFICE_DEFS}${officeBase(isNight(now))}${officeDecor()}${overlays(scene, at)}${extra > 0 ? badge(extra) : ''}</svg>`
614 baseDrawn = { key: baseKey, source, sig: baseSig, night: isNight(now) }
615 }
616
617 if (petDrawn === null) petDrawn = { key: 'pet', source: `${ROOT}${petSvg(at)}</svg>` }
618
619 const agents = drawn.map(a => {
620 const lane = lanes.get(a.id) ?? 0
621 const isBelow = below.has(a.id)
622 const isWalking = isWalkingAt(a, at)
623 // A redraw restarts the walk, and any error in how far ahead it is drawn shows as a jump, so a walker is left alone
624 // from the moment its walk starts until it arrives; a walk that starts anew (a new walk.startedAt) is drawn afresh.
625 // Once it has arrived the key is the full signature, so the agent is drawn once more, standing, as it is by then.
626 const key = isWalking ? walkSignature(a, scroll) : agentSignature(scene, a, lane, isBelow, at)
627 let layer = agentDrawn.get(a.id)
628 if (layer === undefined || layer.key !== key) {
629 let parts: DrawnParts | undefined
630 if (trace !== undefined) {
631 const state = isWalking
632 ? walkSignature(a, '')
633 : JSON.stringify([...agentFacts(a.id, a), waitingOn(scene, a.id).map(h => h.label), Math.round(etaOf(scene, a, at)?.typicalMs ?? 0)])
634 parts = { state, lane, below: isBelow, scroll: isWalking ? scroll : '', walking: isWalking }
635 const prev = layer?.parts
636 const why: string[] = []
637 if (layer === undefined) why.push('first')
638 else if (prev === undefined) why.push('unknown')
639 else if (isWalking) {
640 if (!prev.walking || prev.state !== parts.state) why.push('walk')
641 else if (prev.scroll !== parts.scroll) why.push('scroll')
642 } else {
643 if (prev.state !== parts.state) why.push('state')
644 if (prev.lane !== lane) why.push('lane')
645 if (prev.below !== isBelow) why.push('below')
646 if (prev.scroll !== parts.scroll && prev.scroll !== '' && parts.scroll !== '') why.push('scroll')
647 }
648 trace({ layer: 'agent', id: a.id, why, walking: isWalking, lane, isBelow, scroll })
649 }
650 layer = { key, source: `${ROOT}${drawAgent(a, scene, at, isBelow, lane)}</svg>`, parts }
651 agentDrawn.set(a.id, layer)
652 }
653 return { key: `agent-${a.id}`, source: layer.source }
654 })
655 for (const id of Array.from(agentDrawn.keys())) if (!ids.has(id)) agentDrawn.delete(id)
656 return { base: { key: 'base', source: baseDrawn.source }, pet: petDrawn, agents }
657}
658
659export function renderOffice(scene: Scene, now: number, budget = SVG_BUDGET): string {
660 const ranked = rankedAgents(scene, now)
661 const head = `${ROOT}${OFFICE_DEFS}${officeBase(isNight(now))}`
662 const props = overlays(scene, now)
663 const build = (withDecor: boolean, count: number): string => {
664 const drawn = ranked
665 .slice(0, count)
666 .map(a => ({ a, y: positionAt(a, now).y }))
667 .sort((p, q) => p.y - q.y)
668 .map(p => p.a)
669 const below = tagsBelow(drawn, now)
670 const lanes = bubbleLanes(drawn, scene, now)
671 const people = drawn.map(a => drawAgent(a, scene, now, below.has(a.id), lanes.get(a.id) ?? 0)).join('')
672 const extra = ranked.length - count
673 return `${head}${withDecor ? officeDecor() + petSvg(now) : ''}${props}${people}${extra > 0 ? badge(extra) : ''}</svg>`
674 }
675 let svg = build(true, ranked.length)
676 if (svg.length <= budget) return svg
677 let count = ranked.length
678 svg = build(false, count)
679 while (svg.length > budget && count > 1) {
680 count -= 1
681 svg = build(false, count)
682 }
683 return svg
684}
685hooks/state.ts 10 lines1// Shared names only. The engine reads each file's own `atom(...)` declarations to list the state a module
2// touches, so a file that reads or writes an atom declares it in that file instead of importing it from here.
3export const PANE = 'pixel-office'
4export const PANE_TITLE = 'Office'
5// The keys of the probes (debug mode) the pane draws, which the ui.message hook recognises. The layer probe sits in
6// a box laid out as an agent's layer is, so it reports the region such a box really gets.
7export const PROBE_TOP = 'probe-top'
8export const PROBE_BOTTOM = 'probe-bottom'
9export const PROBE_LAYER = 'probe-layer'
10hooks/stations.ts 404 lines1import type { Point, StationId } from '../types'
2import { commandIntent, explainCommand } from './commands'
3
4export { explainCommand }
5
6export const WIDTH = 240
7export const HEIGHT = 150
8export const CORRIDOR_Y = 88
9export const DESK_COUNT = 8
10
11export type StationDef = {
12 name: string
13 tools: string
14 color: string
15 slots: readonly Point[]
16 door: Point | null
17 slotDoors?: readonly Point[]
18}
19
20export const clamp = (value: number, lo: number, hi: number): number => Math.min(Math.max(value, lo), hi)
21
22// What XML 1.0 refuses: everything below space except tab, line feed and carriage return, delete, the two
23// non-characters U+FFFE and U+FFFF, and any UTF-16 surrogate that is not half of a proper pair. The first
24// alternative is a proper pair, which is kept (`$1`); a surrogate it did not take is a lone one and goes.
25const UNSAFE = /([\uD800-\uDBFF][\uDC00-\uDFFF])|[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\uD800-\uDFFF\uFFFE\uFFFF]/g
26
27export const stripControls = (text: string): string => text.replace(UNSAFE, '$1')
28
29// Fits `text` to `max` characters, counting and cutting by code point so an emoji is never split in half.
30export function clip(text: string, max = 24): string {
31 const chars = Array.from(stripControls(text.replace(/\s+/g, ' ')).trim())
32 return chars.length > max ? `${chars.slice(0, max - 1).join('')}…` : chars.join('')
33}
34
35const DESK_SEATS: Point[] = Array.from({ length: DESK_COUNT }, (_, i) => ({
36 x: 86 + 22 * (i % 4),
37 y: 42 + 30 * Math.floor(i / 4),
38}))
39
40const MEETING_SEATS: Point[] = [
41 { x: 98, y: 140 },
42 { x: 110, y: 140 },
43 { x: 122, y: 140 },
44 { x: 98, y: 114 },
45 { x: 110, y: 114 },
46 { x: 122, y: 114 },
47 { x: 134, y: 140 },
48 { x: 134, y: 114 },
49]
50
51export const STATIONS: Record<StationId, StationDef> = {
52 office: {
53 name: 'Corner office',
54 tools: "Main's home base, thinking",
55 color: '#d97757',
56 slots: [{ x: 27, y: 50 }],
57 door: { x: 55, y: 62 },
58 },
59 desk: {
60 name: 'Desks',
61 tools: 'Edit, Write, NotebookEdit',
62 color: '#378add',
63 slots: DESK_SEATS,
64 door: null,
65 slotDoors: DESK_SEATS.map(p => ({ x: p.x + 11, y: p.y })),
66 },
67 library: {
68 name: 'Records library',
69 tools: 'Read, Grep, Glob, LSP, Skill, ToolSearch',
70 color: '#7f77dd',
71 slots: [
72 { x: 184, y: 44 },
73 { x: 194, y: 44 },
74 { x: 204, y: 44 },
75 { x: 216, y: 44 },
76 { x: 228, y: 44 },
77 ],
78 door: { x: 172, y: 44 },
79 },
80 phone: {
81 name: 'Phone booths',
82 tools: 'WebFetch, WebSearch',
83 color: '#e24b4a',
84 slots: [
85 { x: 220, y: 78 },
86 { x: 234, y: 78 },
87 ],
88 door: null,
89 },
90 mailroom: {
91 name: 'Mailroom',
92 tools: 'Outside services (mcp__ tools)',
93 color: '#ba7517',
94 slots: [
95 { x: 186, y: 80 },
96 { x: 198, y: 80 },
97 ],
98 door: null,
99 },
100 server: {
101 name: 'Server room',
102 tools: 'Bash, PowerShell, background shells',
103 color: '#639922',
104 slots: [
105 { x: 191, y: 142 },
106 { x: 207, y: 142 },
107 { x: 223, y: 142 },
108 ],
109 door: { x: 184, y: 94 },
110 },
111 meeting: {
112 name: 'Meeting room',
113 tools: 'Briefings, SendMessage',
114 color: '#1d9e75',
115 slots: MEETING_SEATS,
116 door: null,
117 slotDoors: MEETING_SEATS.map(p => ({ x: 68, y: p.y })),
118 },
119 taskboard: {
120 name: 'Task board',
121 tools: 'TodoWrite, Task tools, plan updates',
122 color: '#ef9f27',
123 slots: [
124 { x: 82, y: 20 },
125 { x: 92, y: 20 },
126 ],
127 door: { x: 97, y: 20 },
128 },
129 reception: {
130 name: 'Reception',
131 tools: 'Waiting on you: questions, permissions, plan approval',
132 color: '#d4537e',
133 slots: [
134 { x: 44, y: 144 },
135 { x: 56, y: 144 },
136 ],
137 door: { x: 66, y: 144 },
138 },
139 elevator: {
140 name: 'Elevator',
141 tools: 'Agents arrive and leave',
142 color: '#888780',
143 slots: [{ x: 16, y: 138 }],
144 door: { x: 32, y: 138 },
145 },
146 breakroom: {
147 name: 'Break room',
148 tools: 'Idle agents',
149 color: '#5dcaa5',
150 slots: [
151 { x: 26, y: 82 },
152 { x: 40, y: 84 },
153 { x: 10, y: 102 },
154 { x: 18, y: 102 },
155 { x: 26, y: 102 },
156 ],
157 door: null,
158 },
159}
160
161export const STATION_ORDER: readonly StationId[] = [
162 'office',
163 'desk',
164 'library',
165 'phone',
166 'mailroom',
167 'server',
168 'meeting',
169 'taskboard',
170 'reception',
171 'elevator',
172 'breakroom',
173]
174
175const TOOL_STATIONS: Readonly<Record<string, StationId>> = {
176 Edit: 'desk',
177 MultiEdit: 'desk',
178 Write: 'desk',
179 NotebookEdit: 'desk',
180 Read: 'library',
181 Grep: 'library',
182 Glob: 'library',
183 LSP: 'library',
184 Skill: 'library',
185 ToolSearch: 'library',
186 Bash: 'server',
187 PowerShell: 'server',
188 BashOutput: 'server',
189 KillShell: 'server',
190 Monitor: 'server',
191 TaskStop: 'server',
192 WebFetch: 'phone',
193 WebSearch: 'phone',
194 SendMessage: 'meeting',
195 TodoWrite: 'taskboard',
196 TaskCreate: 'taskboard',
197 TaskUpdate: 'taskboard',
198 TaskList: 'taskboard',
199 TaskGet: 'taskboard',
200 AskUserQuestion: 'reception',
201 ExitPlanMode: 'reception',
202}
203
204export const WAITING_TOOLS: ReadonlySet<string> = new Set(['AskUserQuestion', 'ExitPlanMode'])
205
206export function stationFor(tool: string): StationId {
207 const known = TOOL_STATIONS[tool]
208 if (known !== undefined) return known
209 if (/^mcp__.+__(update_plan|wrap_up)$/.test(tool)) return 'taskboard'
210 if (tool.startsWith('mcp__')) return 'mailroom'
211 return 'desk'
212}
213
214function baseName(path: string): string {
215 const parts = path.split(/[\\/]/)
216 return parts[parts.length - 1] || path
217}
218
219// What the bubble over an agent says about a command. Claude's own description of it is the most particular, so it
220// is used when it fits a bubble; else what the command is for, read from its first real step; else (a program the
221// office does not know) that step as typed, past the `cd` and variables in front of it.
222const BUBBLE_MAX = 28
223function commandTarget(description: string, command: string): string {
224 const said = description.trim()
225 if (said !== '' && Array.from(said).length <= BUBBLE_MAX) return clip(said, BUBBLE_MAX)
226 const intent = commandIntent(command)
227 if (intent.phrase !== '') return clip(intent.phrase, BUBBLE_MAX)
228 return clip(said !== '' ? said : intent.step, BUBBLE_MAX)
229}
230
231// The active item of a to-do list, said as the list says it: "Fixing the login bug".
232function activeTodo(input: Record<string, unknown>): string {
233 const todos = input.todos
234 if (!Array.isArray(todos)) return ''
235 const items = todos.filter((t): t is Record<string, unknown> => typeof t === 'object' && t !== null)
236 const active = items.find(t => t.status === 'in_progress') ?? items.find(t => t.status === 'pending')
237 const words = active === undefined ? '' : (active.activeForm ?? active.content)
238 return typeof words === 'string' ? words : ''
239}
240
241// What a call to an outside service (an MCP tool) is about: the first of the arguments that names a thing.
242const SUBJECT_KEYS = ['title', 'subject', 'name', 'query', 'q', 'path', 'file', 'repo', 'url', 'id', 'text']
243
244export function targetOf(tool: string, input: Record<string, unknown>): string {
245 const str = (key: string): string => {
246 const value = input[key]
247 return typeof value === 'string' ? value : ''
248 }
249 if (str('file_path')) return clip(baseName(str('file_path')))
250 if (str('notebook_path')) return clip(baseName(str('notebook_path')))
251 if (tool === 'TodoWrite' && activeTodo(input) !== '') return clip(activeTodo(input), BUBBLE_MAX)
252 if (str('command')) return commandTarget(str('description'), str('command'))
253 if (str('pattern')) {
254 // A search says where it looks when it was pointed somewhere: `login in src`.
255 const where = str('path') || str('glob')
256 return clip(where === '' ? str('pattern') : `${str('pattern')} in ${baseName(where)}`)
257 }
258 if (str('query')) return clip(str('query'))
259 if (str('url')) {
260 try {
261 return clip(new URL(str('url')).host)
262 } catch {
263 return clip(str('url'))
264 }
265 }
266 if (str('skill')) return clip(str('skill'))
267 if (str('to')) return clip(str('to'))
268 if (str('description')) return clip(str('description'))
269 if (str('subject')) return clip(str('subject'))
270 if (tool.startsWith('mcp__')) {
271 const key = SUBJECT_KEYS.find(k => str(k) !== '')
272 return clip(key === undefined ? (tool.split('__')[1] ?? tool) : str(key))
273 }
274 return ''
275}
276
277// A short, readable excerpt of free text (a brief, a report, a message): Markdown heading marks, bold and code
278// ticks dropped, everything on one line, cut to `max` characters.
279export function gist(text: string, max: number): string {
280 return clip(text.replace(/^\s*#{1,6}\s+/gm, '').replace(/\*\*|__|`/g, ''), max)
281}
282
283// How long the one-line excerpt of a brief or a report is.
284export const EXCERPT = 200
285
286// Free text kept to be read in full (a brief, a report): line breaks stay, as Markdown needs them, and so do
287// tabs; every other control character goes, Windows line ends become plain ones, runs of blank lines shrink
288// to one, and it is cut to `max` characters by code point.
289export function fullText(text: string, max: number): string {
290 const lines = stripControls(text.replace(/\r\n?/g, '\n')).replace(/\n{3,}/g, '\n\n').trim()
291 const chars = Array.from(lines)
292 return chars.length > max ? `${chars.slice(0, max - 1).join('')}…` : lines
293}
294
295// The plain-English gist of a call, from what Claude wrote into its input: the description it gives every
296// command (falling back to what the command is commonly for), or the summary of a message. Empty otherwise.
297export function detailOf(tool: string, input: Record<string, unknown>): string {
298 const str = (key: string): string => {
299 const value = input[key]
300 return typeof value === 'string' ? value : ''
301 }
302 if (tool === 'Bash' || tool === 'PowerShell') return gist(str('description'), 60) || explainCommand(str('command'))
303 if (tool === 'SendMessage') return gist(str('summary') || (str('message').split('\n')[0] ?? ''), 48)
304 return ''
305}
306
307export function todoCounts(input: Record<string, unknown>): { done: number; open: number } | null {
308 const list = Array.isArray(input.todos) ? input.todos : Array.isArray(input.items) ? input.items : null
309 if (list === null) return null
310 let done = 0
311 let open = 0
312 for (const item of list) {
313 if ((item as { status?: unknown } | null)?.status === 'completed') done += 1
314 else open += 1
315 }
316 return { done, open }
317}
318
319export function slotPoint(station: StationId, slot: number): Point {
320 const { slots } = STATIONS[station]
321 const exact = slots[slot]
322 if (exact !== undefined) return exact
323 const last = slots[slots.length - 1] as Point
324 const k = slot - slots.length + 1
325 return { x: clamp(last.x - 7 * k, 4, WIDTH - 4), y: clamp(last.y + 3 * k, 16, HEIGHT - 2) }
326}
327
328export function doorOf(station: StationId, slot: number): Point {
329 const def = STATIONS[station]
330 return def.slotDoors?.[slot] ?? def.door ?? slotPoint(station, slot)
331}
332
333function dedupe(points: readonly Point[]): Point[] {
334 const out: Point[] = []
335 for (const p of points) {
336 const last = out[out.length - 1]
337 if (last === undefined || Math.abs(last.x - p.x) > 0.01 || Math.abs(last.y - p.y) > 0.01) out.push(p)
338 }
339 return out
340}
341
342export function route(from: Point, fromDoor: Point, to: Point, toDoor: Point): Point[] {
343 return dedupe([
344 from,
345 { x: fromDoor.x, y: from.y },
346 fromDoor,
347 { x: fromDoor.x, y: CORRIDOR_Y },
348 { x: toDoor.x, y: CORRIDOR_Y },
349 toDoor,
350 { x: toDoor.x, y: to.y },
351 to,
352 ])
353}
354
355const dist = (a: Point, b: Point): number => Math.hypot(b.x - a.x, b.y - a.y)
356
357export function pathLength(points: readonly Point[]): number {
358 let total = 0
359 for (let i = 1; i < points.length; i++) total += dist(points[i - 1] as Point, points[i] as Point)
360 return total
361}
362
363export function splitAt(points: readonly Point[], fraction: number): Point[] {
364 if (points.length === 0) return []
365 let remaining = pathLength(points) * clamp(fraction, 0, 1)
366 for (let i = 1; i < points.length; i++) {
367 const a = points[i - 1] as Point
368 const b = points[i] as Point
369 const seg = dist(a, b)
370 if (remaining <= seg) {
371 const t = seg === 0 ? 0 : remaining / seg
372 return [{ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }, ...points.slice(i)]
373 }
374 remaining -= seg
375 }
376 return [points[points.length - 1] as Point]
377}
378
379export function pointAlong(points: readonly Point[], fraction: number): Point {
380 return splitAt(points, fraction)[0] ?? { x: 0, y: 0 }
381}
382
383const isOnCorridor = (p: Point): boolean => Math.abs(p.y - CORRIDOR_Y) < 0.01
384
385// The way back to the corridor from `fraction` along a walk, staying on the walk's own path: ahead to its next
386// point on the corridor or back to its last one, whichever is shorter. A walk that changes course takes it before
387// setting off for somewhere else, rather than cutting from where it stands through a wall. It starts where the
388// walker is and ends on the corridor (or where the walker is, when the path never reaches it).
389export function toCorridor(points: readonly Point[], fraction: number): Point[] {
390 const ahead = splitAt(points, fraction)
391 const here = ahead[0]
392 if (here === undefined) return []
393 if (isOnCorridor(here)) return [here]
394 const behind = splitAt([...points].reverse(), 1 - clamp(fraction, 0, 1))
395 const reach = (path: readonly Point[]): Point[] | null => {
396 const i = path.findIndex(isOnCorridor)
397 return i < 0 ? null : [here, ...path.slice(1, i + 1)]
398 }
399 const forward = reach(ahead)
400 const back = reach(behind)
401 if (forward === null || back === null) return forward ?? back ?? [here]
402 return pathLength(back) < pathLength(forward) ? back : forward
403}
404hooks/narrate.ts 143 lines1import type { Activity, OfficeEvent, Scene } from '../types'
2import { gist, stationFor } from './stations'
3
4export const REPORT_IN_FEED = 60
5
6function nameOf(scene: Scene, id: string | null): string {
7 if (id === null) return 'An agent'
8 return scene.agents[id]?.label ?? 'An agent'
9}
10
11// "Run the tests" continues a sentence as "run the tests"; "NPM", "CI" and other capitals stay as they are.
12const lowerFirst = (text: string): string => (/^[A-Z][a-z]/.test(text) ? `${text[0]?.toLowerCase() ?? ''}${text.slice(1)}` : text)
13
14// `mcp__github__create_issue` is GitHub's "create issue".
15function service(tool: string): { name: string; action: string } {
16 const [, name = tool, action = ''] = tool.split('__')
17 return { name, action: action.replace(/_+/g, ' ').trim() }
18}
19
20// What an agent is doing, said the way you would say it of a colleague, with no subject: "reading auth.ts",
21// "running a command to test the code". The feed puts a name in front of it; the panel shows it as is.
22export function doing(activity: Pick<Activity, 'tool' | 'target' | 'detail'>): string {
23 const { tool, target, detail } = activity
24 const t = target === '' ? '' : ` ${target}`
25 switch (tool) {
26 case 'Read':
27 return `reading${t}`
28 case 'Edit':
29 case 'MultiEdit':
30 return `editing${t}`
31 case 'Write':
32 return `writing${t}`
33 case 'NotebookEdit':
34 return `editing the notebook${t}`
35 case 'Grep':
36 return `searching the files for${t}`
37 case 'Glob':
38 return `looking for files named${t}`
39 case 'LSP':
40 return t === '' ? 'asking the code index' : `asking the code index about${t}`
41 case 'ToolSearch':
42 return 'looking up which tools it can use'
43 case 'Skill':
44 return t === '' ? 'reading a manual' : `reading the${t} manual`
45 case 'Bash':
46 case 'PowerShell':
47 return detail === '' ? `running a command:${t}` : `running a command to ${lowerFirst(detail)}`
48 case 'BashOutput':
49 return 'checking on a background command'
50 case 'Monitor':
51 return 'keeping an eye on a background job'
52 case 'KillShell':
53 case 'TaskStop':
54 return 'stopping a background job'
55 case 'WebSearch':
56 return `searching the web for${t}`
57 case 'WebFetch':
58 return `reading a web page on${t}`
59 case 'SendMessage':
60 return `messaging${t}${detail === '' ? '' : `: ${detail}`}`
61 case 'AskUserQuestion':
62 return 'waiting for you to answer a question'
63 case 'ExitPlanMode':
64 return 'waiting for you to approve a plan'
65 }
66 const station = stationFor(tool)
67 if (station === 'taskboard') return t === '' ? 'updating the task board' : `updating the task board: ${target}`
68 if (station === 'mailroom') {
69 const { name, action } = service(tool)
70 return action === '' ? `using ${name}${t}` : `using ${name} to ${action}`
71 }
72 return `using ${tool}${t}`
73}
74
75// What a permission prompt is asking for, in words: "run a command" rather than "use Bash".
76function permissionFor(tool: string): string {
77 switch (tool) {
78 case 'Bash':
79 case 'PowerShell':
80 return 'run a command'
81 case 'Edit':
82 case 'MultiEdit':
83 case 'Write':
84 case 'NotebookEdit':
85 return 'change a file'
86 case 'WebFetch':
87 case 'WebSearch':
88 return 'go on the web'
89 }
90 return tool.startsWith('mcp__') ? `use ${service(tool).name}` : `use ${tool}`
91}
92
93const quoted = (report: string): string => {
94 const excerpt = gist(report, REPORT_IN_FEED)
95 return excerpt === '' ? '' : `: ${excerpt}`
96}
97
98// The line for the moment a boss stops waiting: every helper it hired to work while it waited has reported.
99export const allBackLine = (boss: string): string => `${boss} has every report back and is reviewing the results`
100
101export function narrate(scene: Scene, ev: OfficeEvent): string | null {
102 switch (ev.kind) {
103 case 'spawned': {
104 const who = nameOf(scene, ev.id)
105 const boss = nameOf(scene, ev.parentId)
106 if (ev.isTeammate) {
107 return ev.isResume ? `${boss} brought ${who} back to the team: ${ev.description}` : `${boss} added ${who} to the team: ${ev.description}`
108 }
109 const verb = ev.isResume ? `called ${who} back` : `hired ${who}`
110 if (ev.isBackground) return `${boss} ${verb} to work alongside it: ${ev.description}`
111 return `${boss} ${verb} and is waiting for its report: ${ev.description}`
112 }
113 case 'toolStarted':
114 return `${nameOf(scene, ev.agentId)} is ${doing(ev)}`
115 case 'toolEnded':
116 return ev.isError ? `${nameOf(scene, ev.agentId)} hit an error` : null
117 case 'waiting':
118 return `${nameOf(scene, ev.agentId)} needs your permission to ${permissionFor(ev.tool)}`
119 case 'turnStarted':
120 return ev.isContinuation ? 'Main picked up where it left off' : 'You gave Main a new task'
121 case 'turnEnded':
122 return 'Main finished and is waiting for your next message'
123 case 'handedBack': {
124 const agent = scene.agents[ev.agentId]
125 if (agent === undefined) return null
126 const boss = nameOf(scene, agent.parentId)
127 const isStopped = ev.reason === 'aborted'
128 const isFailed = ev.reason === 'error' || ev.reason === 'refusal'
129 if (agent.isTeammate) {
130 return isStopped
131 ? `${agent.label} was stopped and took a break`
132 : `${agent.label} reported to ${boss} and took a break${quoted(ev.report)}`
133 }
134 if (isStopped) return `${agent.label} was stopped and left`
135 if (isFailed) return `${agent.label} ran into a problem, told ${boss} and left${quoted(ev.report)}`
136 return `${agent.label} handed its report to ${boss} and left${quoted(ev.report)}`
137 }
138 case 'todos':
139 case 'tick':
140 return null
141 }
142}
143hooks/roles.ts 58 lines1// What the office calls each kind of agent, and one line that explains the job to someone who has never seen an
2// agent before. The engine's type names (`Explore`, `general-purpose`) are written for Claude; these are for people.
3
4export type Role = { title: string; blurb: string }
5
6export const COORDINATOR: Role = {
7 title: 'Coordinator',
8 blurb: 'Talks with you, splits the work into jobs, hires helpers and puts their reports together',
9}
10
11const ROLES: Readonly<Record<string, Role>> = {
12 Explore: { title: 'Researcher', blurb: 'Reads and searches, then reports what it found. Changes nothing' },
13 Plan: { title: 'Planner', blurb: 'Works out a step-by-step plan for a job. Changes nothing' },
14 'general-purpose': { title: 'Assistant', blurb: 'Can read, edit and run commands to finish one job' },
15 claude: { title: 'Assistant', blurb: 'Can read, edit and run commands to finish one job' },
16 fork: { title: 'Clone', blurb: 'A copy of its boss that knows everything its boss knew, sent off to do a side job' },
17 'claude-code-guide': { title: 'Guide', blurb: 'Looks up how Claude Code itself works' },
18 'web-fetch': { title: 'Web reader', blurb: 'Reads web pages and reports what they say' },
19 contractor: { title: 'Contractor', blurb: 'A helper the office did not see being hired, so its job is unknown' },
20}
21
22// `code-reviewer`, `my-plugin:code-reviewer` and `codeReviewer` all read as "Code reviewer".
23export function humanize(type: string): string {
24 const words = (type.split(':').pop() ?? type)
25 .replace(/([a-z])([A-Z])/g, '$1 $2')
26 .replace(/[-_]+/g, ' ')
27 .trim()
28 .toLowerCase()
29 return words === '' ? 'Helper' : `${words[0]?.toUpperCase() ?? ''}${words.slice(1)}`
30}
31
32export function roleFor(type: string): Role {
33 if (type === 'main') return COORDINATOR
34 return ROLES[type] ?? { title: humanize(type), blurb: 'A specialist helper set up for one kind of job' }
35}
36
37// The model an agent ran on, the way people say it: `claude-haiku-5-5` is Haiku 5.5, `claude-sonnet-4-5-20250929`
38// Sonnet 4.5, `claude-3-5-haiku-20241022` just Haiku (the number before the name is not read as its version).
39// An id with no family name in it is shown as it is.
40export function modelName(id: string): string {
41 const m = /(opus|sonnet|haiku|fable)(?:-(\d{1,2})(?:-(\d{1,2}))?(?!\d))?/i.exec(id)
42 if (m === null) return id
43 const family = `${m[1]?.[0]?.toUpperCase() ?? ''}${m[1]?.slice(1).toLowerCase() ?? ''}`
44 const version = m[2] === undefined ? '' : ` ${m[2]}${m[3] === undefined ? '' : `.${m[3]}`}`
45 return `${family}${version}`
46}
47
48// 850, 3.4k, 38k, 1.3M.
49export function tokenCount(n: number): string {
50 if (n < 1000) return String(n)
51 if (n < 10_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`
52 if (n < 1_000_000) return `${Math.round(n / 1000)}k`
53 return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
54}
55
56// A name the engine gave an agent reads as a name: `scout` is shown as Scout. Messages still go to `scout`.
57export const asName = (name: string): string => `${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
58hooks/sprites.ts 116 lines1import type { Carry, Look, StationId } from '../types'
2
3export type Grid = readonly string[]
4export type Variant = 'main' | 'teammate' | 'plain'
5
6export function rle(grid: Grid, colors: Readonly<Record<string, string>>, ox = 0, oy = 0): string {
7 let out = ''
8 grid.forEach((row, y) => {
9 let x = 0
10 while (x < row.length) {
11 const ch = row[x] as string
12 let end = x + 1
13 while (end < row.length && row[end] === ch) end += 1
14 const fill = colors[ch]
15 if (fill !== undefined) out += `<rect x="${ox + x}" y="${oy + y}" width="${end - x}" height="1" fill="${fill}"/>`
16 x = end
17 }
18 })
19 return out
20}
21
22const HEAD: Grid = ['..hhhh..', '.hhhhhh.', '.hssssh.', '.sesses.', '.ssssss.', '..ssss..']
23
24const BODY: Record<Variant, Grid> = {
25 plain: [...HEAD, '.cccccc.', 'cccccccc', 's.cccc.s', '..pppp..'],
26 main: [...HEAD, '.ccttcc.', 'cccttccc', 's.cttc.s', '..pppp..'],
27 teammate: [...HEAD, '.cccccc.', 'ccccyccc', 's.ccyc.s', '..pppp..'],
28}
29
30const EYES = '#2c2c2a'
31const TIE = '#791f1f'
32const LANYARD = '#fac775'
33const PANTS = '#444441'
34const SHOES = '#2c2c2a'
35
36export function personSvg(look: Look, variant: Variant, walkMs: number): string {
37 const colors = { h: look.hair, s: look.skin, e: EYES, c: look.shirt, t: TIE, y: LANYARD, p: PANTS }
38 // A stride: each leg swings forward lifted, plants, swings back, and the other leg does the same half a step
39 // later, in whole pixels so it stays crisp. The body rises a pixel on each step.
40 const leg = (x: number, begin: number): string => {
41 const rects =
42 `<rect x="${x}" y="10" width="1" height="1" fill="${PANTS}"/>` +
43 `<rect x="${x}" y="11" width="1" height="1" fill="${SHOES}"/>`
44 if (walkMs <= 0) return rects
45 return (
46 `<g>${rects}<animateTransform attributeName="transform" type="translate" calcMode="discrete" ` +
47 `values="0,0;1,-1;1,0;0,0;-1,0;-1,0" keyTimes="0;0.17;0.34;0.5;0.67;0.84" dur="0.6s" begin="${begin}s" repeatDur="${walkMs}ms"/></g>`
48 )
49 }
50 const body = (inner: string): string =>
51 walkMs <= 0
52 ? inner
53 : `<g>${inner}<animateTransform attributeName="transform" type="translate" calcMode="discrete" values="0,0;0,-1" keyTimes="0;0.5" dur="0.3s" repeatDur="${walkMs}ms"/></g>`
54 const ground = '<rect x="-4" y="-1" width="8" height="2" fill="#000000" fill-opacity="0.22"/><rect x="-3" y="1" width="6" height="1" fill="#000000" fill-opacity="0.14"/>'
55 return `${ground}<g transform="translate(-4,-12)">${body(rle(BODY[variant], colors))}${leg(2, 0)}${leg(5, 0.3)}</g>`
56}
57
58const ICONS: Record<StationId, Grid> = {
59 office: ['.....', '.....', 'x.x.x', '.....', '.....'],
60 desk: ['....x', '...x.', '..x..', '.x...', 'x....'],
61 library: ['.xx..', 'x..x.', 'x..x.', '.xx..', '....x'],
62 phone: ['.xxx.', 'x.x.x', 'xxxxx', 'x.x.x', '.xxx.'],
63 mailroom: ['xxxxx', 'x.x.x', 'xxxxx', 'x...x', 'xxxxx'],
64 server: ['x....', '.x...', 'x....', '.....', '..xxx'],
65 meeting: ['xxxxx', 'x...x', 'xxxxx', '.x...', 'x....'],
66 taskboard: ['....x', '...x.', 'x.x..', '.x...', '.....'],
67 reception: ['..x..', '..x..', '..x..', '.....', '..x..'],
68 elevator: ['..x..', '.xxx.', 'x.x.x', '..x..', '..x..'],
69 breakroom: ['xxxx.', 'x..xx', 'x..xx', 'xxxx.', '.....'],
70}
71
72export function iconSvg(station: StationId, x: number, y: number, color: string): string {
73 return rle(ICONS[station], { x: color }, x, y)
74}
75
76// What an agent carries to another agent, held in front of its body: a manila folder for a report, an
77// envelope for a message.
78const ITEMS: Record<Carry['item'], { grid: Grid; colors: Readonly<Record<string, string>> }> = {
79 report: { grid: ['tt...', 'fffff', 'fffff', 'fffff'], colors: { t: '#ba7517', f: '#fac775' } },
80 message: { grid: ['wwwww', 'gwwwg', 'wgwgw', 'wwgww'], colors: { w: '#ffffff', g: '#888780' } },
81}
82
83export function itemSvg(item: Carry['item'], x: number, y: number): string {
84 return rle(ITEMS[item].grid, ITEMS[item].colors, x, y)
85}
86
87export function hash(text: string): number {
88 let h = 0x811c9dc5
89 for (let i = 0; i < text.length; i++) {
90 h ^= text.charCodeAt(i)
91 h = Math.imul(h, 0x01000193)
92 }
93 return h >>> 0
94}
95
96const TYPE_SHIRTS: Readonly<Record<string, string>> = {
97 main: '#d97757',
98 Explore: '#7f77dd',
99 Plan: '#1d9e75',
100 'general-purpose': '#378add',
101 fork: '#888780',
102 contractor: '#5f5e5a',
103}
104const SHIRT_POOL = ['#d4537e', '#ba7517', '#639922', '#185fa5', '#993c1d', '#534ab7', '#0f6e56', '#a32d2d']
105const HAIR = ['#412402', '#2c2c2a', '#854f0b', '#ef9f27', '#712b13', '#888780']
106const SKIN = ['#f5c4b3', '#f0997b', '#c8875e', '#8d5a3b']
107
108const pick = (list: readonly string[], n: number): string => list[n % list.length] as string
109
110export function lookFor(type: string, id: string): Look {
111 const shirt = TYPE_SHIRTS[type] ?? pick(SHIRT_POOL, hash(type))
112 const hair = type === 'contractor' ? '#ef9f27' : pick(HAIR, hash(id))
113 const skin = pick(SKIN, hash(id) >>> 8)
114 return { shirt, hair, skin }
115}
116hooks/probe.tsx 26 lines1import type { ClientModule } from 'claude-code'
2
3type ProbeState = { at: number; ticks: number }
4
5// Runs inside the app, not in the hooks module: it draws nothing visible and tells the hooks when the app mounts it
6// (again after the app unmounts it and mounts it fresh) and, once a second, how big its region is.
7const Probe: ClientModule<{ where?: string }, ProbeState> = (props, surface) => {
8 const where = props.where ?? ''
9 const say = (kind: string): void => {
10 try {
11 surface.post({ kind, where, columns: surface.columns, rows: surface.rows })
12 } catch {
13 // A thrown error would unmount the instance.
14 }
15 }
16 // `state` is undefined on the first call of an instance, and only then: the one place to start things.
17 if (surface.state === undefined) {
18 surface.setState({ at: 1, ticks: 0 })
19 say('mounted')
20 surface.every(1000, () => say('tick'))
21 }
22 return surface.elements.Text({ children: ' ' })
23}
24
25export default Probe
26