SLOPSHOPPER

pixel-office

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

newpaneguardcommandtoastprompt
★ 1v0.5.1MITupdated 2026-10-16sb2403/pixel-office/pixel-office
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pixel-office
│ ┃ Office ✕ › fix the failing auth test and add an audit log call │ ┃ Open this session in the Claude desktop app │ ┃ to see the pixel office. ⏺ Read(src/auth.ts) │ ┃ Main: idle, ready for your next message ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /office │ ⎿ pixel-office: Office opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Office
Open this session in the Claude desktop app to see the pixel office. Main: idle, ready for your next message
README

Pixel Office

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.

A tour of the office

<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.

ZoneStands forWhat you see there
Corner officeMain's home base: thinking, or coordinating its helpersMain 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".
DesksEdit, Write, NotebookEditEight desks. Each helper gets its own and returns to it between jobs.
Records libraryRead, Grep, Glob, LSP, Skill, ToolSearchLooking things up: reading files and searching code.
Phone boothsWebFetch, WebSearchGoing out to the web. A booth light comes on while someone is on a call.
MailroomOutside services (mcp__ tools)Calling connected services. A parcel appears in the pigeonholes while someone is there.
Server roomBash, PowerShell, background shellsRunning commands. The rack lights flash while a command runs.
Meeting roomBriefings, SendMessageA 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 boardTodoWrite, Task tools, plan updatesA cork board on the wall. Green notes are done, amber notes are still open.
ReceptionWaiting on you: questions, permissions, plan approvalA blinking "!" bubble means this agent cannot go on until you answer.
ElevatorAgents arrive and leaveHelpers step out of it when they are hired and step back in when they finish.
Break roomIdle agents, and Main between turns after a whileWhere anyone with nothing to do goes to rest.

Who's on the team

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 officeClaude Code calls itWhat it does
Main, the Coordinatorthe main agentTalks with you, splits the work into jobs, hires helpers and puts their reports together.
ResearcherExploreReads and searches, then reports what it found. Changes nothing.
PlannerPlanWorks out a step-by-step plan for a job. Changes nothing.
Assistantgeneral-purposeCan read, edit and run commands to finish one job.
CloneforkA copy of its boss that knows everything its boss knew, sent off to do a side job.
Guideclaude-code-guideLooks 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:

  • And waits for the report. The usual way. The feed says "Main hired Researcher and is waiting for its report". Until the reports come back, the boss is coordinating: it has nothing else in hand, so its speech bubble and its row say who it is waiting on. When the last one is back the feed says "Main has every report back and is reviewing the results".
  • To work alongside it, in the background. The boss carries on with its own work meanwhile.

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.

The life of a helper

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.

  1. Hired. Main hands over a one-line job description and a fuller brief. The feed says who hired whom and whether the boss is waiting ("Main hired Researcher and is waiting for its report: find the login code"), and a new row appears in the panel.
  2. Arrives by elevator. The elevator doors open and the helper steps out.
  3. Briefed in the meeting room. The helper walks to the meeting room and the whiteboard shows its objective, cut short if it is long. Its boss walks over to join it unless the boss is busy.
  4. Works at stations. Each time the helper uses a tool it walks to the matching station. A speech bubble above its head says what it is doing, for example 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).
  5. Carries its report to its boss. When it is done, the helper walks over to its boss holding a yellow folder and hands it over. The feed quotes the start of the report, and the helper's details box keeps it.
  6. Leaves by elevator. The helper rides away and its row in the panel turns grey and says how the job ended: "handed in its report", "was stopped" or "ran into a problem". A teammate goes to the break room instead of leaving, ready for its next job.

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.

Reading the panel

<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.

  1. The office. The animated picture. Every character has a name tag, and the speech bubble shows what it is doing right now.
  2. What's happening. A plain-English feed with the time of each line, newest at the bottom, eight lines long. It keeps the story: who hired whom, who messaged whom, who reported back, who needs you and what went wrong. Routine work (reading, editing, running a command) gets one line per agent, its latest, so a few busy helpers cannot push the story out of view.
  3. Who's doing what. The team, as an org chart: indentation shows who reports to whom. Each row reads name, status, and what it is doing right now in words ("reading session.ts", "waiting on Planner and Assistant"). Green is working, teal is coordinating (waiting on helpers), yellow is "needs you" (waiting on your answer or permission), grey is idle or done. The dim line with an arrow under a helper is its objective, the job it was hired to do. Once two helpers of a kind have handed in a report, a helper of that kind still on the job also shows how long its predecessors took ("about 20s left", or "taking longer than usual"). That is a guess from earlier helpers, never a promise. In the picture the same estimate is a thin bar under the helper's feet that fills over the time left (it stays full and pulses amber once the helper is past what is usual); the words stay in the panel. A long chart ends with a "+N more" line.
  4. Details. Click a name to open its details: its role and what that kind of agent does, its job, the start of the brief its boss wrote, what it is doing now, who it reports to and whether that boss is waiting on it, the start of its report once it has handed one in, what its work cost once it has (tokens, and the model it ran on), an "On the job" timer that counts up while it works, and, when the office has the examples for it, how long helpers of its kind usually take. A brief or report too long for one line has a "Read the full brief" (or report) link under it that opens the whole text, formatted. Click the name again to close the box.
  5. Show legend. A button that lists every zone with its colour and the tools it stands for. It is the same list as the table above.
  6. Show this job. A button that opens the recap of the job in front of you: see below.

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).

The office cat

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.

The recap of a job

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 summary says how long the job took, how many helpers it used and how many were at work at once, and what it cost in tokens. With two or more helpers whose work overlapped, it adds the time their work would have taken one after another, compared with the time it took together.
  • The timeline has one bar per agent, indented by who hired whom, from when it started to when it handed in its report. Orange is Main, blue a helper Main waited for, green a helper working alongside. A tick, a square or a cross at the end says whether it handed in its report, was stopped, or ran into a problem. The colours were checked to stay apart for colour-blind readers, and the picture has a dark version for a dark screen.
  • One line per agent under the timeline gives the same facts in words.

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.

Requirements

  • Claude Code 2.1.295 or newer. Older versions report that the mod failed to load.
  • The Claude desktop app for the pixel picture. In a terminal you get a text version of the same office.

Install

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.

Commands and options

/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.
  • How to read it: one JSON object per line, with 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.

What does it mean when...

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

Source 17 files
hooks/register.tsx 399 lines
1import { 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}
399
hooks/debuglog.ts 35 lines
1// 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}
35
hooks/model.ts 634 lines
1import 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}
634
hooks/pane.tsx 254 lines
1import { 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}
254
hooks/recap.ts 415 lines
1import 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}
415
hooks/render.ts 685 lines
1import 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>> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' }
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}
685
hooks/state.ts 10 lines
1// 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'
10
hooks/stations.ts 404 lines
1import 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}
404
hooks/narrate.ts 143 lines
1import 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}
143
hooks/roles.ts 58 lines
1// 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)}`
58
hooks/sprites.ts 116 lines
1import 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}
116
hooks/probe.tsx 26 lines
1import 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