Seats every agent of your Claude sessions, main loop and subagents alike, in a café and shows what each one is doing right now: in a pane (/cafe), or on your…

A Claude Code plugin that seats every agent of your Claude sessions, the main loop and every subagent it hands work to, in a café. At a glance you see who is working on what, who has finished, and who is waiting for you.
There are two ways to watch:
| | Where | Which sessions | | :- | :- | :- | | The pane (/cafe) | Inside Claude Code: the terminal, and the desktop app's Code tab. A pixel-art café | Sessions on this machine that have the plugin | | The web page | Any browser or phone, served from your own machine or domain. A full-screen 3D café (three.js) where everyone walks around, with cats, dogs and lo-fi music | Local Code and terminal sessions, plus tasks in the Claude app (two extra steps, see the web page) |

A screenshot of the web page in Chromium (software WebGL). The sessions come from a script (not included) that simulates five sessions and a dozen agents changing what they do at random, so they change faster than real ones would.
Each agent is a little person. The bubble over their head is what they are doing; their name tag gives the details. In the pane, tags that do not fit are cut short and the full text is in the list below the picture. On the web page, click a person to open their card. The bubbles mean the same in both:
| You see | It means | | :- | :- | | Three dots, chin in hand | Thinking (between two tool calls) | | Book, magnifying glass | Reading a file, searching: Reading hooks/scene.ts, Searching for PermissionRequest | | Pencil, terminal, globe | Editing a file, running a command, browsing: Editing src/app.ts, Running Run the test suite, Browsing arxiv.org | | Two little people | Handing work to a subagent | | List, gear | Updating to-dos; using another tool (an MCP tool, say) | | Yellow !, hand raised | Waiting for you: to approve a tool call, or to review a plan. The tag turns red: Needs your OK: Running git push | | Yellow ?, hand raised | Has a question for you (AskUserQuestion) | | Coffee cup | Resting. The main agent says Waiting for you, or Waiting on agents while its subagents are still at work | | Green tick | Done; leaves after 20 seconds | | Red cross | Failed | | Three numbers on the blackboard | Green: busy · yellow: waiting for you · grey: idle |
The pane's list adds how long each agent has been at it and how many tool calls it made; a subagent's own subagents are indented under it. On the web page, the tab's title becomes (1) Agent Café while someone is waiting for you.
You need Claude Code v2.1.287 or later (plugins with a hooks module, "mods", are on by default from that version). Check with claude --version.
In Claude Code:
/plugin install agent-cafe --marketplace qteddie/agent_cafe
or from a terminal:
claude plugin install agent-cafe --marketplace qteddie/agent_cafe
(If your Claude Code does not know --marketplace, add the marketplace first: /plugin marketplace add qteddie/agent_cafe, then /plugin install agent-cafe@agent-cafe.)
Start a new session and type /cafe.
Installing may ask for the plugin's settings. They are all optional: leave web_url and web_key empty until you set up the web page.
git clone https://github.com/qteddie/agent_cafe ~/agent_cafe
claude --plugin-dir ~/agent_cafe
This loads it for that one session. The desktop app cannot pass flags, so to load a clone there, set CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (merge it with what you have) and open a new Local session in the Code tab:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/agent_cafe"
}
}
Loaded this way, the plugin's id is agent-cafe@inline rather than agent-cafe@agent-cafe: use that in the claude plugin configure commands below.
/cafe appears in the list when you type /.claude -p "/cafe text"./cafe (Unknown command: /cafe). Then see the official troubleshooting steps.How to update Claude Code depends on how you installed it (which claude tells you):
| Installed with | Update | | :- | :- | | The official installer (~/.local/bin/claude) | claude update. If the version is still too old, the update channel is stable: set Auto-update channel to latest in /config and run claude update again | | Homebrew | The claude-code cask follows the stable channel, about a week behind. For the latest: brew uninstall --cask claude-code && brew install --cask claude-code@latest | | npm | npm install -g @anthropic-ai/claude-code@latest | | The desktop app | Claude → Check for Updates, then quit and reopen it |
| Command | What it does | | :- | :- | | /cafe | Opens the café's pane. Fine while Claude is working: it does not interrupt | | /cafe text | Prints the café as text into the conversation instead | | /cafe close | Closes the pane |
Only two places draw panes: claude in a terminal, and Local or SSH sessions in the desktop app's Code tab. Cloud sessions in the Code tab, Claude app tasks, the VS Code chat panel and claude -p do not; use the web page to watch those.
The desktop app draws the picture:
<img src="docs/pane.png" alt="The pane: four sessions at their tables" width="520">
A terminal has no pictures, so its pane is the list, the same as /cafe text:
Agent Café — 7 busy · 2 need you · 3 idle
agent-cafe (this session)
● main — Editing hooks/scene.ts · 42s
↳ ● Explore “Find the permission events” — Searching for PermissionRequest · 42s · 7 tool calls
↳ ◆ release “Ship v0.1.0” — Needs your OK: Running git push · 42s · 7 tool calls
↳ ◐ Plan “Plan the cross-session view” — Thinking · 42s · 7 tool calls
↳ ✓ Explore 2 “Map the hooks folder” — Done · 42s · 7 tool calls
paper-notes
● main — Editing extract/tables.py · 42s
↳ ● scout — Browsing arxiv.org · 42s · 7 tool calls
If the picture is blank, broken or flickers, turn the motion setting off (see Settings): it is then a still image.
Each session tells a small server of yours who is doing what. The server seats every session in one 3D café that updates live in your browser, behind a password. It works on phones too (held upright, you look in from the door's side):
<img src="docs/web-phone.png" alt="The web page on a phone" width="300">
Claude Code sessions ──(the plugin: the whole table every 15 s)───┐
Claude app tasks ─────(the plugin's hooks: one event at a time)───┼──▶ your server ──live──▶ your browser
The page is the café, full screen. Each session gets a long table: the main agent sits at its head and the subagents along its sides, and the table grows as more join. The placard in front names the session and the app it runs in. Up to 9 tables of 11.
Tables spread out: the first goes in the middle of the room, and each next one takes the free spot furthest from the others (with two or more, the corners first). Subagents sit in a checkerboard, across the table and then a seat further along on the other side, so the table fills evenly before anyone sits next to anyone.
Every agent is a person of their own, the same at every visit: hair, hats, glasses, beards, clothes and height all come from the agent. Each session's main agent wears Claude orange; nobody else does.
Everyone moves about by what their agent is doing:
| The agent is | In the café | | :- | :- | | Editing, running commands, browsing, using other tools | At their seat, typing on a laptop | | Reading, searching | At the bookshelf on the left, with a book or a magnifying glass | | Updating to-dos (TodoWrite and the like) | Pinning notes to the cork board | | Thinking | Chin in hand; after 8 seconds, pacing beside the table | | Handing work to subagents | Standing behind their subagents, watching them work | | Waiting for you (an approval, a question, a plan) | Standing by their seat, waving at you, the tag yellow | | Resting | Fetching a coffee at the counter, then: the sofa, the window bar, a free round table, the bookshelf, petting a cat or a dog, a stroll | | The main agent waiting on its subagents | At the head of the table with a coffee, now and then checking on them | | Done | A little cheer, then out through the door on the right | | Failed | Head down on the table, the laptop screen red |
New agents walk in through the door. An agent whose state flips several times a second does not run back and forth: they get up only once a state has lasted a moment, and someone at the bookshelf or the cork board stays there a few seconds before going back. Waiting for you, done, failed and a new turn show at once.
Whole room (bottom right) brings back the full view.Open link to the task. Click the floor or press Esc to close it.Cats − + and Dogs − + (bottom left) add and remove them, up to 8 each. Each has its own coat and name (cats Mochi, Miso, Luna…, dogs Biscuit, Latte, Mango…). Cats mostly sleep and now and then move to another spot; dogs run about, sit, nap, and come to sit by whoever is resting. Resting agents pet them. New pets come in through the door, and those you remove go home through it. The counts are kept in this browser.Woof! or Meow~ with a heart, and a pink ring shows it is picked. Then click the floor:….♪ Music (bottom left) plays a chill, lo-fi café loop: electric piano, bass, vibraphone, brushed drums and a little vinyl crackle. It is made live in your browser (Web Audio), so there is no audio file or stream to load, and it never ends the same way twice. It is off until you turn it on; the slider beside it sets the volume. Both are remembered in this browser, and when you left it on, it starts again with your first click on the page (browsers do not let a page play sound before that).| Where you use Claude | Shows up? | How it reports | | :- | :- | :- | | Local sessions in the desktop app's Code tab, and the terminal | Yes, after step 2 | The plugin sends its whole table every 15 seconds and on every change | | Claude app tasks (conversations where Claude uses tools) | Yes, after step 3 | The plugin's hooks send each event; the server puts the table together | | Cloud sessions in the Code tab | Not yet | They do not read your machine's settings or your account's plugins | | Plain chats with no tools | No | There are no agents there, and no hooks |
When both kinds of report arrive for one session (the hooks also report when node is on your machine), the plugin's whole table wins.
The server is in web/. It runs in Docker, and needs nothing but Docker on a machine that stays on (your laptop with Docker Desktop is fine while you use it).
docker compose logs cafe says why): openssl rand -hex 24 # the reporting key: CAFE_INGEST_KEY
openssl rand -hex 24 # the page's password: CAFE_VIEW_KEY
web/ folder (compose.yaml is there; skip the clone if you have one already): git clone https://github.com/qteddie/agent_cafe ~/agent_cafe
cd ~/agent_cafe/web
cp .env.example .env # put the two keys in, and your time zone (TZ)
docker compose up -d --build
CAFE_VIEW_KEY. A sign-in lasts 30 days.If you see Bind for 127.0.0.1:8080 failed: port is already allocated, something on your machine already uses 8080: set CAFE_PORT in web/.env to a free port (8787, say) and run it again.
That is enough when Claude Code runs on the same machine. To watch from your phone, or from anywhere, put the page on a domain with https. compose.yaml comes with a Cloudflare Tunnel for that (you need a domain on Cloudflare):
docker run cloudflare/cloudflared:latest tunnel --no-autoupdate run --token eyJ…. Copy just the token after --token into CLOUDFLARE_TUNNEL_TOKEN in web/.env; the command itself need not be run.docker compose --profile tunnel up -d --build.cafe.example.com, say) the Service URL http://cafe:8080. Two things are easy to get wrong there:cafe, not localhost: in the tunnel's container, localhost is the tunnel itself. cafe is the page's container.http, not https: the server speaks no https of its own, and an https service URL gets a 502. Your domain is still https: Cloudflare takes care of that part.https://cafe.example.com.Without Cloudflare, put your own reverse proxy (Caddy, nginx) with https in front of 127.0.0.1:8080. Without Docker, the server runs on Bun, from the clone: HOST=127.0.0.1 CAFE_INGEST_KEY=… CAFE_VIEW_KEY=… bun web/server.ts (without HOST, it listens on every network the machine is on).
Give the plugin the page's address and the reporting key:
echo '{"web_url": "https://cafe.example.com", "web_key": "<your CAFE_INGEST_KEY>"}' \
| claude plugin configure agent-cafe@agent-cafe --values-stdin
or in Claude Code, /plugin configure agent-cafe@agent-cafe. Use http://localhost:8080 (or your CAFE_PORT) when the page runs on the same machine; any other address must be https. New sessions start reporting; restart the ones already open (or run /reload-plugins). Without an address, the plugin makes no network calls at all.
When you load the plugin from a clone (--plugin-dir), you can instead put a web.json in the clone's root (it is in .gitignore: never commit it):
{ "url": "https://cafe.example.com", "key": "<your CAFE_INGEST_KEY>" }
Once web_url is set, web.json is not read.
Claude app tasks run in Anthropic's cloud, where neither your settings nor your folders are, and where your localhost is out of reach: this needs the page on a public https address (the tunnel above, or your own proxy). Two things get them reporting:
web.json above in a clone, then (it needs zip): sh tools/pack.sh # makes dist/agent-cafe.zip, web.json included
In the Claude app, Customize → Plugins → Add → Upload plugin, and upload the zip. New tasks then carry it. The zip holds your reporting key: upload it to your own account only, and share it with nobody.
curl -sS https://cafe.example.com/healthz: ok means it gets through; a 403 (CONNECT tunnel failed, response 403) means it is still blocked.A task has no folder to name its table after, so it shows as Claude · 14:05 (the app, and when it sat down), and its card's Open link goes to the task.
curl -s https://cafe.example.com/healthz # ok
If it does not say ok:
http://cafe:8080 (see step 1).docker compose ps should list agent-cafe-tunnel (start with --profile tunnel); docker compose logs tunnel says why. Most often, the token in web/.env is missing or pasted wrong.To see what the server receives, set CAFE_DEBUG=1 in web/.env, run docker compose up -d again (docker compose restart does not reread .env), and watch docker compose logs -f cafe: one line per report, with only the fields the café shows.
Only what the seats show is sent, and it is filtered in your session before it leaves (eventOf in hooks/wire.ts): the tool's name, a short target (a file name, a search string, a domain, a command's program), an agent's type and the few words it was given as a description, and the folder's name. Never sent: your prompts, session titles made from them, file contents, command arguments, tool results, and the model's answers. Strings that look like keys are never sent either (a guess, see what it shows and stores).
The server keeps all of this in memory only: a restart empties it, and each session fills its table again when it next reports.
web.json, and in the uploaded zip, where Claude can read it) can only send reports. Looking at the page takes the other key, the password.web_key is not a secret setting: Claude Code keeps it in plain text in ~/.claude/settings.json. That is why it can only write to the café, never read it.textContent for tags and cards; the blackboard is drawn on a canvas). The page has a strict Content Security Policy, and three.js comes bundled in /cafe3d.js from your own server: no CDN.compose.yaml opens the port on 127.0.0.1 only: the world gets in through the tunnel.Waiting for you, and leaves after 3 hours without news.scripts/report.mjs) needs Node 18.17 or later, or Node 22.21 or later behind a proxy (as in a Claude app task, which has 22.22 today). Without node on your machine, your Code sessions still report: the plugin itself does that.?lang=zh-TW to its address for Traditional Chinese (it is remembered).| Setting | Default | What it does | | :- | :- | :- | | language | en | The pane's language: en or zh-TW (the web page has its own, see above) | | share_sessions | true | Seats the agents of your other sessions on this machine in the pane too. Off, this session neither reads nor writes the shared table | | motion | true | Animates the pane's picture. Off, it is a still image | | web_url | empty | The web page's address (see step 2). Empty: no network calls | | web_key | empty | The web page's reporting key, CAFE_INGEST_KEY |
Change them in Claude Code with /plugin configure agent-cafe@agent-cafe, or from a terminal:
claude plugin configure agent-cafe@agent-cafe # lists them, and which are set
echo '{"language": "zh-TW", "motion": "false"}' | claude plugin configure agent-cafe@agent-cafe --values-stdin
Values go in as strings, "false" included. They take effect in new sessions. The web page's server has its own settings, in web/.env (see web/.env.example).
Without a web page set up, the plugin runs on your machine only: no network, and no model calls. With one, it talks to that address only. You can list every Claude Code capability it uses yourself: the calls: line of claude plugin validate <the plugin's folder>.
What it shows is short labels, filtered first:
hooks/scene.ts). Never the contents; nor a name that looks like a key.example.com), no path or query.git push …). Never any arguments (a make target counts as one).Spotting keys is a guess (well-known key prefixes, or a long run mixing letters and digits) that errs on the side of hiding. It is not a guarantee: a secret the model writes into a description that does not look like a key will show.
What it stores: with share_sessions on, each session writes its own table to the plugin's own data file on your machine (under ~/.claude/plugins/store/) so your other sessions can read it. A session removes its entry when it ends; one that did not end cleanly stops showing after 45 seconds and is cleared after an hour.
/cafe text goes into the conversation, where the model reads it, so it lists only this session's agents; other sessions are only counted.
hooks/register.tsx 843 lines1// Agent Café: every agent of your Claude Code sessions, main loop and subagents
2// alike, seated in a café pane that shows what each one is doing right now.
3// `/cafe` opens it.
4//
5// This file is the only one that talks to Claude Code. It listens (tool calls,
6// turns, spawns, permission prompts), keeps a roster of who is doing what,
7// shares it with the person's other sessions through the store, and draws the
8// pane. What a tool call means is in describe.ts, the roster in tracker.ts,
9// who sits where in view.ts, the picture in scene.ts, the words in i18n.ts.
10//
11// Every hook here only watches: it passes its event on untouched, and a slip
12// in the café's own bookkeeping is caught, so it never costs a tool call.
13//
14// Claude Code reads this file's source to list what the mod calls, so every
15// function that is handed `$` is declared here, at the top level, and takes
16// the café's state (Shop) as an argument instead of closing over it.
17
18import type { AgentInfo, Elements, EngineInterface, Register, RenderElement, RenderInput, Timer } from 'claude-code'
19
20import { actOfCall, clip, folderName, label, targetOf, tidy } from './describe'
21import type { Words } from './i18n'
22import { age, wordsFor } from './i18n'
23import type { Scene } from './scene'
24import { SOURCE_LIMIT, drawScene, fit } from './scene'
25import type { Listed, Phase, Roster } from './tracker'
26import {
27 ask,
28 askOver,
29 callThrough,
30 createRoster,
31 mateIdle,
32 reset,
33 seatsOf,
34 spawn,
35 subStart,
36 subStop,
37 sweep,
38 sync,
39 toolEnd,
40 toolStart,
41 turnEnd,
42 turnStart,
43} from './tracker'
44import type { Cafe, Line, Snap } from './view'
45import { MARKS, TABLE_SEATS, arrange, linesOf, parseSnap, signatureOf, textOf } from './view'
46import type { App, Inbox, Post } from './wire'
47import { inboxOf, inboxOfFile, isWebSet } from './wire'
48
49/** The pane's id: what `$.ui.open` names and the render hook's `requestId`. */
50const PANE = 'agent-cafe'
51/** How often the café looks around: lets the finished go, shares, redraws. */
52const TICK_MS = 2_000
53/** Every this many looks, the roster is checked against `$.agent.list()`. */
54const SYNC_EVERY = 3
55/** Every this many looks, while the pane is up, the other sessions' tables are read. */
56const LISTEN_EVERY = 2
57/** Every this many looks, pane or no pane, the store is read and tidied. */
58const TIDY_EVERY = 150
59/** A look that has not come back after this long is given up on. */
60const STUCK_MS = 30_000
61/** A session says it is still here at least this often. */
62const BEAT_MS = 15_000
63/** A session that has not said so for this long is gone from the café. */
64const FRESH_MS = 45_000
65/** How far ahead of this session's clock another's table may be dated. */
66const SKEW_MS = 5_000
67/** What nobody has kept up for this long is removed from the store. */
68const DROP_MS = 60 * 60_000
69/** After the store refused a read or a write, it is left alone for this long. */
70const RETRY_MS = 30_000
71/** Each session's key in the store is this and its id. */
72const PREFIX = 'table:'
73/** Seats a table shows in the picture, tried in order until the markup fits. */
74const CAPS: readonly number[] = [TABLE_SEATS, 8, 5, 3, 1]
75/** This session's table goes to the web page when it changed, and at least this often. */
76const WEB_BEAT_MS = 15_000
77/**
78 * After the web page refused a report or could not be reached, it is left
79 * alone for this long: well inside the 45 s the page keeps a table without one.
80 */
81const WEB_RETRY_MS = 20_000
82/** A report that has not come back after this long is given up on: the next one goes. */
83const WEB_STUCK_MS = 30_000
84
85/** The theme color of each phase's mark in the list under the picture. */
86const TONES: Readonly<Record<Phase, string>> = {
87 think: 'claude',
88 work: 'success',
89 ask: 'warning',
90 idle: 'inactive',
91 done: 'success',
92 fail: 'error',
93}
94
95/**
96 * The café's state for one load of the mod. It lives in the module, not in
97 * `$.state`: a tool call then costs no round trip to the host beyond reading
98 * the clock. A reload starts it over, and `$.agent.list()` seats whoever is
99 * still running again.
100 */
101type Shop = {
102 readonly words: Words
103 /** Whether the person's other sessions are seated too (`share_sessions`). */
104 readonly isSharing: boolean
105 /** Whether the picture moves (`motion`). */
106 readonly hasMotion: boolean
107 /** This session's agents; made at the first event, when the time is known. */
108 roster: Roster | undefined
109 /** This session's table name: its folder. */
110 title: string
111 sessionId: string
112 /** True once the session has ended: nothing more is written to the store. */
113 isClosed: boolean
114 /** The other sessions' tables, as last read from the store. */
115 others: Snap[]
116 othersMark: string
117 /** What this session last wrote to the store, and when. */
118 sharedMark: string
119 sharedAt: number
120 /** The store is left alone until this time, after it refused a call. */
121 storeAfter: number
122 /** Keys under the café's prefix that it cannot use, and since when. */
123 readonly strangers: Map<string, number>
124 /** The timer behind `tick`, kept so a second start replaces it. */
125 timer: Timer | undefined
126 looks: number
127 isLooking: boolean
128 /** When the look in progress began, on the process's own clock. */
129 lookingSince: number
130 /** The ages the list last showed: the pane is drawn again when they move on. */
131 agesDrawn: string
132 /** The last picture drawn for each width, kept while the café looks the same. */
133 readonly pictures: Map<string, { mark: string; scene: Scene | undefined }>
134 /** Where the café's web page takes reports, as web.json sets it up; undefined without one. */
135 web: Inbox | undefined
136 /** The app this session runs in, as the web page names it. */
137 app: App
138 /** What this session last reported to the web page, and when. */
139 webMark: string
140 webAt: number
141 /** The web page is left alone until this time, after it refused a report. */
142 webAfter: number
143 /** True while a report is on its way, since `reportingSince`: the next waits for it. */
144 isReporting: boolean
145 reportingSince: number
146}
147
148function openShop(language: unknown, isSharing: boolean, hasMotion: boolean): Shop {
149 return {
150 words: wordsFor(language),
151 isSharing,
152 hasMotion,
153 roster: undefined,
154 title: '',
155 sessionId: '',
156 isClosed: false,
157 others: [],
158 othersMark: '',
159 sharedMark: '',
160 sharedAt: 0,
161 storeAfter: 0,
162 strangers: new Map(),
163 timer: undefined,
164 looks: 0,
165 isLooking: false,
166 lookingSince: 0,
167 agesDrawn: '',
168 pictures: new Map(),
169 web: undefined,
170 app: '',
171 webMark: '',
172 webAt: 0,
173 webAfter: 0,
174 isReporting: false,
175 reportingSince: 0,
176 }
177}
178
179/** The app a session runs in, from the surfaces it draws on. */
180function appOfSurfaces(surfaces: readonly string[]): App {
181 if (surfaces.includes('desktop')) return 'code'
182 if (surfaces.includes('vscode')) return 'vscode'
183 if (surfaces.includes('terminal')) return 'cli'
184 return ''
185}
186
187/** A name as the café keeps one: fit to show, and no longer than a name is. */
188function nameOf(text: string): string {
189 return label(text, 40)
190}
191
192/** A task's few words as the café keeps them. */
193function taskOf(text: string): string {
194 return label(text, 80)
195}
196
197/** A tool call's arguments as a record, whatever was handed over. */
198function inputOf(value: unknown): Readonly<Record<string, unknown>> {
199 return typeof value === 'object' && value !== null ? (value as Readonly<Record<string, unknown>>) : {}
200}
201
202/** One agent of the engine's list, its strings made fit to show. */
203function tame(info: AgentInfo): Listed {
204 return {
205 id: info.id,
206 type: nameOf(info.type),
207 description: taskOf(info.description),
208 status: info.status,
209 ...(info.parentId === undefined ? {} : { parentId: info.parentId }),
210 ...(info.name === undefined ? {} : { name: nameOf(info.name) }),
211 ...(info.teammateId === undefined ? {} : { teammateId: info.teammateId }),
212 }
213}
214
215function seated(shop: Shop, now: number): Roster {
216 shop.roster ??= createRoster(now)
217 return shop.roster
218}
219
220/** This session's table as the others will read it. */
221function here(shop: Shop, now: number): Snap {
222 return {
223 v: 1,
224 id: shop.sessionId === '' ? 'here' : shop.sessionId,
225 title: shop.title,
226 at: now,
227 seats: seatsOf(seated(shop, now)),
228 }
229}
230
231/** Everyone seated: this session's table, then the others'. */
232function cafeOf(shop: Shop, now: number, viewed?: string, cap: number = TABLE_SEATS): Cafe {
233 return arrange(here(shop, now), shop.isSharing ? shop.others : [], shop.words, viewed, cap)
234}
235
236/**
237 * The café as text. A command's text is read by the model as well as the
238 * person, so it tells of this session alone and only counts the others: what
239 * another session's agents wrote stays out of this one's conversation.
240 */
241function tellOf(shop: Shop, now: number): string {
242 const own = here(shop, now)
243 const text = textOf(arrange(own, [], shop.words), shop.words, now)
244 const others = shop.isSharing ? shop.others.filter(snap => snap.id !== own.id && snap.seats.length > 0) : []
245 if (others.length === 0) return text
246 const seats = others.reduce((sum, snap) => sum + snap.seats.length, 0)
247 return `${text}\n${shop.words.elsewhere(others.length, seats)}`
248}
249
250/** How long every seat has been at what it does, as the list words it. */
251function agesOf(shop: Shop, now: number): string {
252 return [here(shop, now), ...(shop.isSharing ? shop.others : [])]
253 .flatMap(snap => snap.seats.map(seat => age(now - seat.since)))
254 .join(' ')
255}
256
257/** The picture for a pane this wide, drawn again only when the café changed. */
258function pictureOf(
259 shop: Shop,
260 cafe: Cafe,
261 now: number,
262 bodyColumns: number,
263 viewed: string | undefined,
264): Scene | undefined {
265 const { columns, scale } = fit(bodyColumns)
266 const slot = `${columns}@${scale}`
267 const mark = signatureOf(cafe)
268 const kept = shop.pictures.get(slot)
269 if (kept !== undefined && kept.mark === mark) return kept.scene
270
271 let scene: Scene | undefined
272 for (const cap of CAPS) {
273 const shown = cap === TABLE_SEATS ? cafe : cafeOf(shop, now, viewed, cap)
274 const drawn = drawScene(shown, { columns, scale, words: shop.words, hasMotion: shop.hasMotion })
275 if (drawn.source.length <= SOURCE_LIMIT) {
276 scene = drawn
277 break
278 }
279 }
280 if (shop.pictures.size >= 8) shop.pictures.clear()
281 shop.pictures.set(slot, { mark, scene })
282 return scene
283}
284
285function whisper($: EngineInterface, error: unknown): void {
286 try {
287 $.ui.log(`could not keep up: ${String(error)}`, { to: 'debug' })
288 } catch {
289 // Nothing left to tell.
290 }
291}
292
293/** Applies one change to the roster and asks for a redraw. Never throws. */
294async function note(
295 $: EngineInterface,
296 shop: Shop,
297 change: (roster: Roster, now: number) => void,
298): Promise<void> {
299 try {
300 const now = await $.clock.now()
301 change(seated(shop, now), now)
302 $.ui.invalidate('ui.render')
303 } catch (error) {
304 whisper($, error)
305 }
306}
307
308/**
309 * Checks the roster against the engine's own list of agents. True when it
310 * changed; false, too, when the list could not be read.
311 */
312async function look($: EngineInterface, shop: Shop, now: number): Promise<boolean> {
313 try {
314 const roster = seated(shop, now)
315 const before = JSON.stringify(seatsOf(roster))
316 sync(roster, (await $.agent.list()).map(tame), now)
317 return JSON.stringify(seatsOf(roster)) !== before
318 } catch (error) {
319 whisper($, error)
320 return false
321 }
322}
323
324/**
325 * Writes this session's table to the store, when it changed or a beat is due.
326 * A store that refuses is left alone for a while, and nothing else waits on it.
327 */
328async function share($: EngineInterface, shop: Shop, now: number): Promise<void> {
329 if (shop.isClosed || now < shop.storeAfter) return
330 try {
331 const seats = seatsOf(seated(shop, now))
332 const mark = JSON.stringify([shop.title, seats])
333 if (mark === shop.sharedMark && now - shop.sharedAt < BEAT_MS) return
334 // Read each time: a /clear or a resume carries the session on under a new id.
335 const id = await $.session.id()
336 // The session may have ended while that was asked: its table is not written back.
337 if (shop.isClosed) return
338 if (shop.sessionId !== '' && shop.sessionId !== id) await $.store.delete(PREFIX + shop.sessionId)
339 shop.sessionId = id
340 // `web`: when this session's table last reached the web page. The hook
341 // reporter (scripts/report.mjs) reads it and leaves the reporting to this
342 // module while it is recent.
343 const snap: Snap & { web?: number } = {
344 v: 1,
345 id,
346 title: shop.title,
347 at: now,
348 seats,
349 ...(shop.web === undefined ? {} : { web: shop.webAt }),
350 }
351 await $.store.set(PREFIX + id, snap)
352 shop.sharedMark = mark
353 shop.sharedAt = now
354 } catch (error) {
355 shop.storeAfter = now + RETRY_MS
356 whisper($, error)
357 }
358}
359
360/**
361 * Reads the other sessions' tables from the store, and clears away what
362 * nobody keeps up. True when the tables changed; false when they could not be
363 * read.
364 */
365async function listen($: EngineInterface, shop: Shop, now: number): Promise<boolean> {
366 if (now < shop.storeAfter) return false
367 try {
368 const found: Snap[] = []
369 const strange = new Set<string>()
370 for (const key of await $.store.keys()) {
371 if (!key.startsWith(PREFIX) || key === PREFIX + shop.sessionId) continue
372 const snap = parseSnap(await $.store.get(key))
373 // How long ago it was written; no number at all for what cannot be read.
374 const since = snap === undefined ? Number.NaN : now - snap.at
375 if (snap !== undefined && since >= -SKEW_MS && since < FRESH_MS) {
376 found.push(snap)
377 } else if (since > DROP_MS) {
378 // Its session is long gone and did not get to remove it.
379 await $.store.delete(key)
380 } else if (!(since >= FRESH_MS)) {
381 // Unreadable, or dated ahead of this clock: no telling how old it is.
382 // It is given an hour from when the café first saw it so.
383 strange.add(key)
384 const first = shop.strangers.get(key) ?? now
385 shop.strangers.set(key, first)
386 if (now - first > DROP_MS) await $.store.delete(key)
387 }
388 }
389 for (const key of [...shop.strangers.keys()]) {
390 if (!strange.has(key)) shop.strangers.delete(key)
391 }
392 const mark = JSON.stringify(found.map(snap => [snap.id, snap.title, snap.seats]))
393 const hasNews = mark !== shop.othersMark
394 shop.others = found
395 shop.othersMark = mark
396 return hasNews
397 } catch (error) {
398 shop.storeAfter = now + RETRY_MS
399 whisper($, error)
400 return false
401 }
402}
403
404/**
405 * Where the café's web page takes reports: the plugin's settings (`web_url`
406 * and `web_key`, set with `claude plugin configure`) once `web_url` is filled
407 * in, or else web.json, beside the plugin's manifest folder, as
408 * `{ "url": "https://…", "key": "…" }`. Undefined without either, or with one
409 * that does not say both.
410 */
411async function readInbox($: EngineInterface, url: unknown, key: unknown): Promise<Inbox | undefined> {
412 try {
413 if (isWebSet(url)) {
414 const inbox = inboxOf(url, key)
415 if (inbox === undefined) whisper($, 'the web_url and web_key settings do not name an https address and a key')
416 return inbox
417 }
418 const path = `${$.plugin.root}/web.json`
419 if (!(await $.fs.exists(path))) return undefined
420 const inbox = inboxOfFile(JSON.parse(String(await $.fs.read(path))))
421 if (inbox === undefined) whisper($, 'web.json does not name an https address and a key')
422 return inbox
423 } catch (error) {
424 whisper($, error)
425 return undefined
426 }
427}
428
429/** Posts one report to the web page. True when the page took it. Never throws. */
430async function send($: EngineInterface, web: Inbox, post: Post): Promise<boolean> {
431 try {
432 const answer = await $.http.fetch(web.url, {
433 method: 'POST',
434 headers: { authorization: `Bearer ${web.key}`, 'content-type': 'application/json' },
435 body: JSON.stringify(post),
436 })
437 if (!answer.ok) whisper($, `the web page answered ${answer.status}`)
438 return answer.ok
439 } catch (error) {
440 whisper($, error)
441 return false
442 }
443}
444
445/**
446 * Sends this session's table to the café's web page, when it changed or a
447 * beat is due. Nobody waits on it: a page slow to answer holds up nothing
448 * here, and one that refuses is left alone for a while.
449 */
450async function report($: EngineInterface, shop: Shop, now: number): Promise<void> {
451 const { web } = shop
452 if (web === undefined || shop.isClosed || now < shop.webAfter) return
453 if (shop.isReporting && now - shop.reportingSince < WEB_STUCK_MS) return
454 shop.isReporting = true
455 shop.reportingSince = now
456 try {
457 const seats = seatsOf(seated(shop, now))
458 const mark = JSON.stringify([shop.title, seats])
459 if (mark === shop.webMark && now - shop.webAt < WEB_BEAT_MS) return
460 // Read each time: a /clear or a resume carries the session on under a new id.
461 const id = await $.session.id()
462 if (shop.app === '') shop.app = appOfSurfaces(await $.session.surfaces())
463 if (shop.isClosed) return
464 const snap: Snap = { v: 1, id, title: shop.title, at: now, seats }
465 if (await send($, web, { v: 1, kind: 'snap', app: shop.app, link: '', snap })) {
466 shop.webMark = mark
467 shop.webAt = now
468 } else {
469 shop.webAfter = now + WEB_RETRY_MS
470 }
471 } catch (error) {
472 whisper($, error)
473 } finally {
474 // A report given up on that comes back late leaves the newer one's mark alone.
475 if (shop.reportingSince === now) shop.isReporting = false
476 }
477}
478
479/** One look around: runs on a timer for as long as the mod is loaded. */
480async function tick($: EngineInterface, shop: Shop): Promise<void> {
481 // One look at a time; one that never came back is given up on after a while.
482 const started = performance.now()
483 if (shop.isLooking && started - shop.lookingSince < STUCK_MS) return
484 shop.isLooking = true
485 shop.lookingSince = started
486 try {
487 shop.looks += 1
488 const now = await $.clock.now()
489 const roster = seated(shop, now)
490 let hasNews = sweep(roster, now)
491 // With the main loop resting alone there is nobody to check on.
492 const isQuiet = roster.agents.every(
493 agent => agent.kind === 'main' && (agent.phase === 'idle' || agent.phase === 'fail'),
494 )
495 if (!isQuiet && shop.looks % SYNC_EVERY === 0) hasNews = (await look($, shop, now)) || hasNews
496
497 let isUp = false
498 try {
499 isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
500 } catch (error) {
501 whisper($, error)
502 }
503 if (shop.isSharing) {
504 await share($, shop, now)
505 const isDue = (isUp && shop.looks % LISTEN_EVERY === 0) || shop.looks % TIDY_EVERY === 0
506 if (isDue) hasNews = (await listen($, shop, now)) || hasNews
507 }
508 // On its own: the look goes on while the report is under way.
509 void report($, shop, now)
510 if (hasNews || (isUp && agesOf(shop, now) !== shop.agesDrawn)) $.ui.invalidate('ui.render')
511 } catch (error) {
512 whisper($, error)
513 } finally {
514 shop.isLooking = false
515 }
516}
517
518export const register: Register = (on, options) => {
519 const shop = openShop(options['language'], options['share_sessions'] !== false, options['motion'] !== false)
520 const { words } = shop
521
522 // ---------------------------------------------------------------------------
523 // The session: set up, and clean up.
524
525 on('session.start', async ($, e, next) => {
526 // Each step stands on its own: one that fails leaves the rest working.
527 try {
528 shop.isClosed = false
529 shop.title = folderName(e.cwd)
530 const now = await $.clock.now()
531 seated(shop, now)
532 shop.sessionId = await $.session.id()
533 shop.app = appOfSurfaces(await $.session.surfaces())
534 // Whoever was already running when the mod loaded takes a seat.
535 await look($, shop, now)
536 } catch (error) {
537 whisper($, error)
538 }
539 // Read at each start: a reload, or a new web.json, takes effect there.
540 shop.web = await readInbox($, options['web_url'], options['web_key'])
541 try {
542 shop.timer?.cancel()
543 shop.timer = $.clock.every(TICK_MS, () => {
544 void tick($, shop)
545 })
546 } catch (error) {
547 whisper($, error)
548 }
549 try {
550 await $.command.register({
551 name: 'cafe',
552 description: words.command,
553 argumentHint: '[text|close]',
554 immediate: true,
555 })
556 } catch (error) {
557 whisper($, error)
558 }
559 return next(e)
560 })
561
562 on('session.end', async ($, e, next) => {
563 // Each step stands on its own: a store that refuses does not keep the roster as it was.
564 const carriesOn = e.reason === 'clear' || e.reason === 'resume'
565 // Any other end is the process's own: from here on nothing is shared, so
566 // that no look still under way writes the table back once it is removed.
567 if (!carriesOn) shop.isClosed = true
568 try {
569 if (carriesOn) {
570 // The process goes on under another id, with a new conversation. The
571 // agents it had running are still its own, and go on or stop as they will.
572 const now = await $.clock.now()
573 reset(seated(shop, now), now)
574 shop.sharedMark = ''
575 $.ui.invalidate('ui.render')
576 }
577 } catch (error) {
578 whisper($, error)
579 }
580 try {
581 if (shop.isSharing) await $.store.delete(PREFIX + e.sessionId)
582 } catch (error) {
583 whisper($, error)
584 }
585 // The web page clears this table now rather than when its reports stop.
586 // A clear or a resume reports again, under the new id, at the next look.
587 if (shop.web !== undefined) {
588 shop.webMark = ''
589 await send($, shop.web, { v: 1, kind: 'bye', id: e.sessionId })
590 }
591 return next(e)
592 })
593
594 on('command.run', { command: 'cafe' }, async ($, e) => {
595 let now = 0
596 try {
597 now = await $.clock.now()
598 const wish = e.args.trim().toLowerCase()
599 if (wish === 'close') {
600 await $.ui.close({ id: PANE })
601 return {}
602 }
603 if (wish !== '' && wish !== 'open' && wish !== 'text') return { text: words.usage }
604
605 // Catch up first, so the café opens with everyone in it. Each of these
606 // keeps its own failure to itself: none is worth the command.
607 await look($, shop, now)
608 if (shop.isSharing) {
609 await share($, shop, now)
610 await listen($, shop, now)
611 }
612 if (wish === 'text') return { text: tellOf(shop, now) }
613
614 const opened = await $.ui.open({ id: PANE, title: words.title })
615 $.ui.invalidate('ui.render')
616 if (!opened.isPlaced) return { text: `${words.noPane}\n${tellOf(shop, now)}` }
617 // Where nothing draws (a headless run) a pane is placed and never seen.
618 const surfaces = await $.session.surfaces()
619 return surfaces.length === 0 ? { text: tellOf(shop, now) } : {}
620 } catch (error) {
621 whisper($, error)
622 return { text: tellOf(shop, now) }
623 }
624 })
625
626 // ---------------------------------------------------------------------------
627 // What the agents do. `agentId` names a subagent's loop; the main loop has none.
628
629 on('turn.start', async ($, e, next) => {
630 // A turn is proof enough that the session lives, whatever ended before.
631 shop.isClosed = false
632 await note($, shop, (roster, now) => turnStart(roster, now))
633 return next(e)
634 })
635
636 on('turn.complete', async ($, e, next) => {
637 await note($, shop, (roster, now) => turnEnd(roster, e.agentId, e.reason, now))
638 return next(e)
639 })
640
641 on('tool.call', async ($, e, next) => {
642 const id = e.tool_use_id
643 const agentId = e.agentId
644 await note($, shop, (roster, now) => {
645 const tool = clip(tidy(String(e.tool)), 80)
646 const input = inputOf(e)
647 toolStart(roster, agentId, { id, tool, act: actOfCall(tool, input), target: targetOf(tool, input) }, now)
648 })
649 try {
650 return await next(e)
651 } finally {
652 await note($, shop, (roster, now) => toolEnd(roster, agentId, id, now))
653 }
654 })
655
656 on('agent.spawn', async ($, e, next) => {
657 const started = await next(e)
658 const id = 'agentId' in started ? started.agentId : undefined
659 if (id !== undefined) {
660 await note($, shop, (roster, now) => {
661 spawn(
662 roster,
663 {
664 id,
665 type: nameOf(e.subagentType),
666 task: taskOf(e.description),
667 parent: e.parentAgentId ?? '',
668 name: nameOf(e.name ?? ''),
669 isTeammate: e.isTeammate === true,
670 },
671 now,
672 )
673 // The Agent call that asked for it got through: a prompt about it is answered.
674 if (typeof e.tool_use_id === 'string') callThrough(roster, e.parentAgentId, e.tool_use_id, now)
675 })
676 }
677 return started
678 })
679
680 on('classic.SubagentStart', async ($, e, next) => {
681 await note($, shop, (roster, now) => subStart(roster, e.agent_id, nameOf(e.agent_type), now))
682 return next(e)
683 })
684
685 on('classic.SubagentStop', async ($, e, next) => {
686 const answer = await next(e)
687 // A hook that blocks the stop keeps the subagent going.
688 if (answer.block === undefined) await note($, shop, (roster, now) => subStop(roster, e.agent_id, now))
689 return answer
690 })
691
692 on('classic.TeammateIdle', async ($, e, next) => {
693 await note($, shop, (roster, now) => mateIdle(roster, nameOf(e.teammate_name), now))
694 return next(e)
695 })
696
697 // ---------------------------------------------------------------------------
698 // Who is waiting for the person.
699
700 on('classic.PermissionRequest', async ($, e, next) => {
701 // The event fires when the person is about to be asked: mark the call now,
702 // and take the mark back if a hook beneath answers in their place.
703 let hasMarked = false
704 let held: string | undefined
705 await note($, shop, (roster, now) => {
706 const tool = clip(tidy(e.tool_name), 80)
707 held = ask(roster, e.agent_id, tool, targetOf(tool, inputOf(e.tool_input)), now)
708 hasMarked = true
709 })
710 const answer = await next(e)
711 if (hasMarked && (answer.decision !== undefined || answer.block !== undefined)) {
712 await note($, shop, (roster, now) => askOver(roster, e.agent_id, held, now))
713 }
714 return answer
715 })
716
717 on('classic.PermissionDenied', async ($, e, next) => {
718 await note($, shop, (roster, now) => askOver(roster, e.agent_id, e.tool_use_id, now))
719 return next(e)
720 })
721
722 on('classic.Elicitation', async ($, e, next) => {
723 await note($, shop, (roster, now) => {
724 ask(roster, e.agent_id, '', '', now)
725 })
726 return next(e)
727 })
728
729 on('classic.ElicitationResult', async ($, e, next) => {
730 await note($, shop, (roster, now) => askOver(roster, e.agent_id, undefined, now))
731 return next(e)
732 })
733
734 // ---------------------------------------------------------------------------
735 // The pane: the picture where the surface draws one, and the list under it.
736
737 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
738 try {
739 return await paneOf($, e, shop)
740 } catch (error) {
741 // A pane whose drawing throws is closed: draw the name alone instead.
742 whisper($, error)
743 const { Text } = $.ui.resolve(e)
744 return <Text dimColor>{words.title}</Text>
745 }
746 })
747}
748
749/** The pane's tree: the picture where the surface draws one, and the list under it. */
750async function paneOf($: EngineInterface, e: RenderInput<'Pane'>, shop: Shop): Promise<RenderElement> {
751 const { words } = shop
752 const now = await $.clock.now()
753 // Read with care: an older surface may hand over fewer props than this build's.
754 const viewed = e.props.view?.agentId
755 const cafe = cafeOf(shop, now, viewed)
756 const lines = linesOf(cafe, words, now)
757 const tally = words.tally(cafe.busy, cafe.asking, cafe.idle)
758 shop.agesDrawn = agesOf(shop, now)
759
760 if (e.surface === 'terminal') {
761 const { Box, Text } = $.ui.resolve(e)
762 return (
763 <Box flexDirection="column">
764 <Text wrap="wrap">
765 <Text bold color="claude">
766 {words.title}
767 </Text>
768 <Text dimColor>{` ${tally}`}</Text>
769 </Text>
770 {lines.map(line => rowOf(Text, line))}
771 {cafe.hiddenTables > 0 && <Text dimColor>{words.moreTables(cafe.hiddenTables)}</Text>}
772 {legendOf(Text, words.legend)}
773 </Box>
774 )
775 }
776
777 const { Box, Text, Svg } = $.ui.resolve(e)
778 const scene = pictureOf(shop, cafe, now, e.props.bodyColumns, viewed)
779 return (
780 <Box flexDirection="column" rowGap={1}>
781 {scene === undefined ? (
782 <Text bold color="claude">
783 {`${words.title} ${tally}`}
784 </Text>
785 ) : (
786 // The frame lets the picture move and its seats show a tooltip; without
787 // motion it is a plain image, the surest thing a surface draws.
788 <Svg source={scene.source} alt={scene.alt} {...(shop.hasMotion ? { isInteractive: true } : {})} />
789 )}
790 <Box flexDirection="column">
791 {lines.map(line => rowOf(Text, line))}
792 {cafe.hiddenTables > 0 && <Text dimColor>{words.moreTables(cafe.hiddenTables)}</Text>}
793 </Box>
794 {legendOf(Text, words.legend)}
795 </Box>
796 )
797}
798
799/** `Text`, as every surface's table has it. */
800type TextTag = Elements['terminal']['Text']
801
802/** One line of the list: a table's name, or a seat with its mark in its phase's color. */
803function rowOf(Text: TextTag, line: Line): RenderElement {
804 if (line.type === 'table') {
805 return (
806 <Text bold wrap="wrap">
807 {line.text}
808 </Text>
809 )
810 }
811 return (
812 <Text wrap="wrap">
813 <Text color={TONES[line.phase]}>{`${line.indent}${MARKS[line.phase]} `}</Text>
814 <Text bold>{line.label}</Text>
815 {line.note !== '' && <Text dimColor>{line.note}</Text>}
816 {line.phase === 'ask' ? <Text color="warning">{` ${line.says}`}</Text> : <Text>{` ${line.says}`}</Text>}
817 <Text dimColor>{` ${whole(line.tail)}`}</Text>
818 </Text>
819 )
820}
821
822/** Text that wraps as one word: its spaces are no place to break a line. */
823function whole(text: string): string {
824 return text.replaceAll(' ', '\u00a0')
825}
826
827/** What the marks mean, in one dim line. */
828function legendOf(Text: TextTag, legend: readonly [string, string, string, string]): RenderElement {
829 const [think, work, held, idle] = legend
830 return (
831 <Text dimColor wrap="wrap">
832 <Text color={TONES.think}>{MARKS.think}</Text>
833 {` ${think} `}
834 <Text color={TONES.work}>{MARKS.work}</Text>
835 {` ${work} `}
836 <Text color={TONES.ask}>{MARKS.ask}</Text>
837 {` ${held} `}
838 <Text color={TONES.idle}>{MARKS.idle}</Text>
839 {` ${idle}`}
840 </Text>
841 )
842}
843hooks/describe.ts 418 lines1// What a tool call is, for the café: the kind of work it stands for (its act,
2// which picks the pose and the bubble) and one short word for what it works on
3// (its target: a file name, a search pattern, a host).
4//
5// Pure: no `$` here, so it runs the same in a session and in a test.
6
7/**
8 * The kinds of work a seat can show. The tools' own, then the four a seat
9 * shows between tool calls: thinking, resting, done and failed.
10 */
11export type Act =
12 | 'read'
13 | 'search'
14 | 'write'
15 | 'run'
16 | 'web'
17 | 'delegate'
18 | 'plan'
19 | 'ask'
20 | 'tool'
21 | 'think'
22 | 'rest'
23 | 'done'
24 | 'fail'
25
26const ACTS: Readonly<Record<string, Act>> = {
27 Read: 'read',
28 NotebookRead: 'read',
29 LSP: 'read',
30 TaskGet: 'read',
31 TaskList: 'read',
32 GetTask: 'read',
33 ListMcpResourcesTool: 'read',
34 ReadMcpResourceTool: 'read',
35 ReadMcpResourceDirTool: 'read',
36
37 Grep: 'search',
38 Glob: 'search',
39 LS: 'search',
40 ToolSearch: 'search',
41
42 Edit: 'write',
43 MultiEdit: 'write',
44 Write: 'write',
45 NotebookEdit: 'write',
46
47 Bash: 'run',
48 PowerShell: 'run',
49 Monitor: 'run',
50 BashOutput: 'run',
51 KillShell: 'run',
52 TaskStop: 'run',
53
54 WebFetch: 'web',
55 WebSearch: 'web',
56
57 Agent: 'delegate',
58 Task: 'delegate',
59 Workflow: 'delegate',
60 SendMessage: 'delegate',
61
62 TodoWrite: 'plan',
63 TaskCreate: 'plan',
64 TaskUpdate: 'plan',
65 EnterPlanMode: 'plan',
66 Skill: 'plan',
67
68 AskUserQuestion: 'ask',
69 ExitPlanMode: 'ask',
70}
71
72/** The kind of work a tool stands for; a tool the café does not know is `tool`. */
73export function actOf(tool: string): Act {
74 // Its own entries only: `constructor` is a name like any other.
75 return (Object.hasOwn(ACTS, tool) ? ACTS[tool] : undefined) ?? 'tool'
76}
77
78// A shell command is a search, a read or a fetch when the program it runs is
79// one: where a build has no search tool of its own, this is how agents search.
80const SEARCHES: ReadonlySet<string> = new Set([
81 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'ugrep', 'find', 'fd', 'fdfind', 'bfs', 'locate', 'ls', 'tree',
82 'select-string', 'get-childitem', 'gci', 'dir',
83])
84const READS: ReadonlySet<string> = new Set([
85 'cat', 'head', 'tail', 'less', 'more', 'bat', 'wc', 'nl', 'stat', 'file', 'get-content', 'gc', 'type',
86])
87const FETCHES: ReadonlySet<string> = new Set(['curl', 'wget', 'invoke-webrequest', 'iwr', 'invoke-restmethod', 'irm'])
88/** Steps that only set the scene: the part of the command after them says what it is for. */
89const SCENE: ReadonlySet<string> = new Set(['cd', 'pushd', 'popd', 'export', 'set', 'source', '.', 'true', ':'])
90/** Words in front of a program that run it and say nothing themselves. */
91const WRAPPERS: ReadonlySet<string> = new Set(['sudo', 'time', 'nohup', 'command', 'exec'])
92
93/**
94 * The kind of work a shell command stands for, by the first program in it
95 * that says: `cd src && rg TODO` is a search. Anything it cannot read is `run`.
96 *
97 * Only the kind leaves here, never a word of the command.
98 */
99function shellAct(command: string): Act {
100 for (const part of command.split(/&&|\|\||[;|\n]/)) {
101 const rest = pastAssignments(tidy(part))
102 if (rest === undefined) return 'run'
103 const words = rest.split(' ').filter(word => word !== '')
104 let at = 0
105 while (WRAPPERS.has(words[at] ?? '')) {
106 at += 1
107 while (words[at]?.startsWith('-') === true) at += 1
108 }
109 const word = words[at]
110 if (word === undefined) continue
111 const name = (word.split(/[\\/]/).pop() ?? word).toLowerCase()
112 if (SCENE.has(name)) continue
113 if (SEARCHES.has(name)) return 'search'
114 if (READS.has(name)) return 'read'
115 if (FETCHES.has(name)) return 'web'
116 return 'run'
117 }
118 return 'run'
119}
120
121/**
122 * The kind of work one tool call stands for: the tool's own, and for a shell
123 * command the kind of command it is.
124 */
125export function actOfCall(tool: string, input: Readonly<Record<string, unknown>>): Act {
126 if (tool !== 'Bash' && tool !== 'PowerShell') return actOf(tool)
127 const command = input['command']
128 return typeof command === 'string' ? shellAct(command) : 'run'
129}
130
131/** An MCP tool's two halves, `mcp__<server>__<tool>`; undefined for any other tool. */
132export function mcpParts(tool: string): { server: string; tool: string } | undefined {
133 if (!tool.startsWith('mcp__')) return undefined
134 const rest = tool.slice(5)
135 const cut = rest.indexOf('__')
136 if (cut <= 0) return { server: rest, tool: '' }
137 return { server: rest.slice(0, cut), tool: rest.slice(cut + 2) }
138}
139
140/**
141 * Text made fit to show: control characters and line breaks become one space,
142 * runs of white space collapse, the ends are trimmed, and what draws nothing
143 * but steers the text around it (direction marks, halves of a surrogate pair,
144 * noncharacters, the private-use planes) is dropped.
145 *
146 * Everything the café shows or shares passes through here first: a surface
147 * refuses a tree whose text holds a control character or the terminal's image
148 * placeholder (U+10EEEE, in a private-use plane), and XML one that holds a
149 * noncharacter.
150 */
151export function tidy(text: string): string {
152 let out = ''
153 let isGap = false
154 for (const char of text) {
155 const code = char.codePointAt(0) ?? 0
156 if (isUnseen(code)) continue
157 const isBlank =
158 code <= 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f) || code === 0x2028 || code === 0x2029
159 if (isBlank) {
160 isGap = out.length > 0
161 continue
162 }
163 if (isGap) out += ' '
164 isGap = false
165 out += char
166 }
167 return out
168}
169
170function isUnseen(code: number): boolean {
171 return (
172 code === 0x200e ||
173 code === 0x200f ||
174 (code >= 0x202a && code <= 0x202e) ||
175 (code >= 0x2066 && code <= 0x2069) ||
176 code === 0xfeff ||
177 (code >= 0xd800 && code <= 0xdfff) ||
178 (code >= 0xfdd0 && code <= 0xfdef) ||
179 (code & 0xfffe) === 0xfffe ||
180 // Planes 15 and 16 are private use: no text of anyone's, and where a
181 // terminal's own placeholders live.
182 code >= 0xf0000
183 )
184}
185
186/**
187 * How wide a string draws, in halves of a full-width character: a CJK
188 * character or an emoji counts two, anything else one.
189 */
190export function widthOf(text: string): number {
191 let width = 0
192 for (const char of text) width += isWide(char.codePointAt(0) ?? 0) ? 2 : 1
193 return width
194}
195
196function isWide(code: number): boolean {
197 return (
198 (code >= 0x1100 && code <= 0x115f) ||
199 (code >= 0x2e80 && code <= 0xa4cf) ||
200 (code >= 0xac00 && code <= 0xd7a3) ||
201 (code >= 0xf900 && code <= 0xfaff) ||
202 (code >= 0xfe30 && code <= 0xfe4f) ||
203 (code >= 0xff00 && code <= 0xff60) ||
204 (code >= 0xffe0 && code <= 0xffe6) ||
205 (code >= 0x1f300 && code <= 0x1faff) ||
206 (code >= 0x20000 && code <= 0x3fffd)
207 )
208}
209
210/** Cuts a string to `max` widths (widthOf), an ellipsis standing for the rest. */
211export function clip(text: string, max: number): string {
212 if (widthOf(text) <= max) return text
213 let out = ''
214 let width = 0
215 for (const char of text) {
216 const step = isWide(char.codePointAt(0) ?? 0) ? 2 : 1
217 if (width + step > max - 1) break
218 out += char
219 width += step
220 }
221 return out.trimEnd() + '…'
222}
223
224/** A path's last segment, with its folder in front when the two are short; a long one is cut. */
225export function shortPath(path: string): string {
226 const parts = tidy(path).split(/[\\/]+/).filter(part => part !== '')
227 const last = parts[parts.length - 1]
228 if (last === undefined) return ''
229 const folder = parts[parts.length - 2]
230 if (folder !== undefined && widthOf(folder) + widthOf(last) <= 26) return `${folder}/${last}`
231 return clip(last, 40)
232}
233
234/** A folder's name: the last segment of its path, cut to a table's placard. */
235export function folderName(path: string): string {
236 const parts = tidy(path)
237 .split(/[\\/]+/)
238 .filter(part => part !== '')
239 return clip(parts[parts.length - 1] ?? '', 40)
240}
241
242/**
243 * A URL's host, without `www.`; nothing for text that is no URL, since what
244 * it holds (a name and password before the host, a token after it) is unknown.
245 */
246export function hostOf(url: string): string {
247 const text = tidy(url)
248 if (!URL.canParse(text)) return ''
249 return clip(new URL(text).hostname.replace(/^www\./, ''), 40)
250}
251
252const PROGRAM = /^[A-Za-z0-9_.+-]{1,24}$/
253const SUBCOMMAND = /^[a-z][a-z0-9:_-]{0,19}$/
254
255// Programs whose first argument is a subcommand from a fixed set, never data.
256// Not make or just: their targets are whatever a project named them.
257const WITH_SUBCOMMAND: ReadonlySet<string> = new Set([
258 'git', 'gh', 'npm', 'npx', 'pnpm', 'yarn', 'bun', 'deno', 'cargo', 'rustup', 'go',
259 'pip', 'pip3', 'uv', 'poetry', 'conda', 'docker', 'kubectl', 'helm', 'terraform', 'brew', 'apt',
260 'apt-get', 'dotnet', 'gradle', 'mvn', 'swift', 'flutter', 'composer', 'bundle', 'rake', 'turbo', 'nx',
261])
262
263/** What an assignment's value may hold outside quotes and still be skipped over. */
264const PLAIN_VALUE = /^[A-Za-z0-9_.,:/@%+=~-]$/
265
266/**
267 * What is left of a command line after the `NAME=value` assignments it opens
268 * with; undefined when a value is more than the café can read past.
269 *
270 * A value may be quoted and hold spaces (`PASSWORD="two words" mysql`), so a
271 * split on spaces would take a word of it for the program. Anything in a
272 * value that could hide a space another way (a substitution, an escape, an
273 * open quote) ends the reading: better nothing shown than a word of a secret.
274 */
275function pastAssignments(line: string): string | undefined {
276 let at = 0
277 for (;;) {
278 while (line[at] === ' ') at += 1
279 const name = /^[A-Za-z_][A-Za-z0-9_]*=/.exec(line.slice(at))
280 if (name === null) return line.slice(at)
281 at += name[0].length
282 while (at < line.length && line[at] !== ' ') {
283 const char = line[at] ?? ''
284 if (char === "'") {
285 const close = line.indexOf("'", at + 1)
286 if (close < 0) return undefined
287 at = close + 1
288 } else if (char === '"') {
289 const close = line.indexOf('"', at + 1)
290 if (close < 0 || /[$`\\]/.test(line.slice(at + 1, close))) return undefined
291 at = close + 1
292 } else if (PLAIN_VALUE.test(char)) {
293 at += 1
294 } else {
295 return undefined
296 }
297 }
298 }
299}
300
301/**
302 * A shell command cut down to what is safe to show: the program's name and,
303 * for a program that takes one, its subcommand (`npm test`, `git status`).
304 *
305 * Every other argument is left out, since that is where a token, a password
306 * or a connection string would be.
307 */
308export function safeCommand(command: string): string {
309 // Leading `VAR=value` assignments say nothing about what runs.
310 const rest = pastAssignments(tidy(command))
311 if (rest === undefined) return ''
312 const words = rest.split(' ').filter(word => word !== '')
313 const program = words[0]
314 if (program === undefined) return ''
315 const name = program.split('/').pop() ?? program
316 if (!PROGRAM.test(name) || looksSecret(name)) return ''
317 const after = words[1]
318 if (after === undefined) return name
319 if (!WITH_SUBCOMMAND.has(name) || !SUBCOMMAND.test(after)) return `${name} …`
320 return words.length <= 2 ? `${name} ${after}` : `${name} ${after} …`
321}
322
323const KEY_PREFIX =
324 /(?:^|[^A-Za-z0-9])(?:sk-|rk-|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|glpat-|xox[abprs]-|AKIA|ASIA|AIza|ya29\.|eyJ[A-Za-z0-9_-]{8})|-----BEGIN/
325
326/**
327 * True for text that holds what looks like a credential: the opening of a
328 * well-known kind of key, or a long unbroken run that mixes letters and
329 * digits, as a token, a hash or an id does.
330 *
331 * A guess, and one that errs toward hiding: a search for a key should not put
332 * the key on the wall.
333 */
334export function looksSecret(text: string): boolean {
335 if (KEY_PREFIX.test(text)) return true
336 for (const run of text.match(/[A-Za-z0-9_+=-]{20,}/g) ?? []) {
337 const digits = run.replace(/[^0-9]/g, '').length
338 const letters = run.replace(/[^A-Za-z]/g, '').length
339 if (digits >= 4 && letters >= 4) return true
340 }
341 return false
342}
343
344/** Free text a model wrote, cut to `max` widths; nothing when it looks to hold a secret. */
345function plain(text: string, max: number): string {
346 return looksSecret(text) ? '' : clip(text, max)
347}
348
349/**
350 * A few words someone wrote (a task, a name), made fit to show and cut to
351 * `max` widths; nothing when they look to hold a secret.
352 */
353export function label(text: string, max: number): string {
354 return plain(tidy(text), max)
355}
356
357function text(input: Readonly<Record<string, unknown>>, key: string): string {
358 const value = input[key]
359 return typeof value === 'string' ? tidy(value) : ''
360}
361
362/**
363 * One short word for what a tool call works on, from the call's arguments; the
364 * empty string when the café has nothing safe or useful to say.
365 */
366export function targetOf(tool: string, input: Readonly<Record<string, unknown>>): string {
367 switch (tool) {
368 // A file's name can hold a secret too (a key saved under its own value).
369 case 'Read':
370 case 'Edit':
371 case 'MultiEdit':
372 case 'Write':
373 case 'NotebookRead':
374 return plain(shortPath(text(input, 'file_path')), 40)
375 case 'NotebookEdit':
376 return plain(shortPath(text(input, 'notebook_path')), 40)
377 case 'LSP':
378 return plain(shortPath(text(input, 'filePath')), 40)
379 case 'LS':
380 return plain(shortPath(text(input, 'path')), 40)
381 case 'Grep':
382 case 'Glob':
383 return plain(text(input, 'pattern'), 30)
384 case 'Bash':
385 case 'PowerShell':
386 case 'Monitor':
387 // The model's own line about the command reads better than the command,
388 // and does not hold what the command's arguments might.
389 return plain(text(input, 'description'), 40) || safeCommand(text(input, 'command'))
390 case 'WebFetch':
391 return hostOf(text(input, 'url'))
392 case 'WebSearch':
393 case 'ToolSearch':
394 return plain(text(input, 'query'), 34)
395 case 'Agent':
396 case 'Task':
397 return plain(text(input, 'description') || text(input, 'subagent_type'), 40)
398 case 'Workflow':
399 return plain(text(input, 'title') || text(input, 'name') || text(input, 'description'), 40)
400 case 'Skill':
401 return plain(text(input, 'skill'), 30)
402 case 'SendMessage':
403 return plain(text(input, 'to'), 24)
404 case 'TaskCreate':
405 case 'TaskUpdate':
406 return plain(text(input, 'subject'), 36)
407 case 'AskUserQuestion': {
408 const questions = input['questions']
409 const first: unknown = Array.isArray(questions) ? questions[0] : undefined
410 if (typeof first !== 'object' || first === null) return ''
411 const header = (first as Readonly<Record<string, unknown>>)['header']
412 return typeof header === 'string' ? plain(tidy(header), 24) : ''
413 }
414 default:
415 return ''
416 }
417}
418hooks/i18n.ts 281 lines1// The café's words, in Traditional Chinese and in English, and the sentence a
2// seat's state reads as. Pure: no `$` here.
3
4import type { Act } from './describe'
5import { clip, mcpParts } from './describe'
6
7export type Lang = 'zh-TW' | 'en'
8
9export type Words = {
10 /** The pane's title and the sign on the wall. */
11 title: string
12 /** What the main loop is called at its seat. */
13 main: string
14 /** A subagent nothing names. */
15 agent: string
16 /** The name of the table of the session the pane is open in. */
17 here: (name: string) => string
18 /** A subagent's task, set after its name. */
19 task: (text: string) => string
20 /** A seat between tool calls. */
21 thinking: string
22 /** The main loop with no turn running. */
23 waiting: string
24 /** The main loop with no turn running while agents of its own are still at work. */
25 awaiting: string
26 /** A teammate between turns. */
27 standby: string
28 done: string
29 failed: string
30 /** AskUserQuestion: the dialog is the person's to answer; `topic` is its header. */
31 question: (topic: string) => string
32 /** ExitPlanMode: the plan is the person's to approve. */
33 planReady: string
34 /** A notification asked for the person and named no tool. */
35 needsYou: string
36 /** A tool call held at a permission prompt; `what` is the call, said. */
37 needsOk: (what: string) => string
38 /** The verb for a tool, by name; `acts` is the fallback, by kind. */
39 verbs: Readonly<Record<string, string>>
40 acts: Readonly<Record<Act, string>>
41 /** An MCP tool, and a tool the café has no verb for. */
42 mcp: (server: string, tool: string) => string
43 using: (tool: string) => string
44 /** Joins a verb and what it works on. */
45 join: (verb: string, target: string) => string
46 /** The tally on the chalkboard: who works, who waits on the person, who rests. */
47 tally: (busy: number, asking: number, idle: number) => string
48 /** Seats past the ones a table shows. */
49 more: (count: number) => string
50 /** A subagent's tool calls so far. */
51 calls: (count: number) => string
52 /** The legend under the picture: thinking, working, held for the person, resting or done. */
53 legend: readonly [think: string, work: string, ask: string, idle: string]
54 /** How `/cafe` is used, said when its argument is not one it knows. */
55 usage: string
56 /** What `/cafe` is said to do in the command list. */
57 command: string
58 /** Tables past the ones the café shows. */
59 moreTables: (count: number) => string
60 /** The other sessions' tables, where the café is told as text and leaves them out. */
61 elsewhere: (tables: number, seats: number) => string
62 /** Said when the surface placed no pane. */
63 noPane: string
64 /** The alt of the picture, for a reader that cannot see it. */
65 alt: (seats: number, tables: number) => string
66}
67
68const ZH: Words = {
69 title: 'Agent Café',
70 main: 'main',
71 agent: 'agent',
72 here: name => `${name}(這個 session)`,
73 task: text => `「${text}」`,
74 thinking: '思考中',
75 waiting: '等你的指示',
76 awaiting: '等 agent 回報',
77 standby: '待命中',
78 done: '完成了',
79 failed: '出錯了',
80 question: topic => (topic === '' ? '有問題想問你' : `有問題想問你:${topic}`),
81 planReady: '等你看計畫',
82 needsYou: '需要你看一下',
83 needsOk: what => (what === '' ? '等你批准' : `等你批准:${what}`),
84 verbs: {
85 Read: '讀',
86 NotebookRead: '讀',
87 Edit: '改',
88 MultiEdit: '改',
89 Write: '寫',
90 NotebookEdit: '改',
91 Grep: '搜尋',
92 Glob: '找檔案',
93 LS: '看資料夾',
94 LSP: '查程式碼',
95 Bash: '執行',
96 PowerShell: '執行',
97 Monitor: '監看',
98 TaskStop: '停掉背景工作',
99 WebFetch: '瀏覽',
100 WebSearch: '上網查',
101 Agent: '交辦',
102 Task: '交辦',
103 Workflow: '跑流程',
104 SendMessage: '傳話給',
105 TodoWrite: '整理待辦',
106 TaskCreate: '新增待辦',
107 TaskUpdate: '更新待辦',
108 TaskList: '看待辦',
109 TaskGet: '看待辦',
110 EnterPlanMode: '開始規劃',
111 Skill: '用技能',
112 ToolSearch: '找工具',
113 },
114 acts: {
115 read: '讀取',
116 search: '搜尋',
117 write: '編輯',
118 run: '執行',
119 web: '上網',
120 delegate: '交辦',
121 plan: '規劃',
122 ask: '等你',
123 tool: '用工具',
124 think: '思考中',
125 rest: '休息中',
126 done: '完成了',
127 fail: '出錯了',
128 },
129 mcp: (server, tool) => (tool === '' ? `用 ${server}` : `用 ${server}・${tool}`),
130 using: tool => `用 ${tool}`,
131 join: (verb, target) => (target === '' ? verb : `${verb} ${target}`),
132 tally: (busy, asking, idle) => `忙 ${busy} 等你 ${asking} 閒 ${idle}`,
133 more: count => `還有 ${count} 位`,
134 calls: count => `${count} 次工具`,
135 legend: ['思考', '工作', '等你', '休息/完成'],
136 usage: '用法:/cafe 開啟咖啡廳,/cafe text 用文字列出,/cafe close 關閉。',
137 command: '打開 Agent Café:看每個 agent 現在在做什麼',
138 moreTables: count => `還有 ${count} 桌沒畫出來`,
139 elsewhere: (tables, seats) => `其他 session:${tables} 桌、${seats} 位(/cafe 打開咖啡廳可以看到)`,
140 noPane: '這個畫面放不下咖啡廳的 pane,先用文字告訴你:',
141 alt: (seats, tables) => `咖啡廳裡有 ${seats} 位 agent,分坐 ${tables} 桌。`,
142}
143
144const EN: Words = {
145 title: 'Agent Café',
146 main: 'main',
147 agent: 'agent',
148 here: name => `${name} (this session)`,
149 task: text => ` “${text}”`,
150 thinking: 'Thinking',
151 waiting: 'Waiting for you',
152 awaiting: 'Waiting on agents',
153 standby: 'On standby',
154 done: 'Done',
155 failed: 'Failed',
156 question: topic => (topic === '' ? 'Has a question for you' : `Has a question for you: ${topic}`),
157 planReady: 'Plan ready for your review',
158 needsYou: 'Needs you',
159 needsOk: what => (what === '' ? 'Needs your OK' : `Needs your OK: ${what}`),
160 verbs: {
161 Read: 'Reading',
162 NotebookRead: 'Reading',
163 Edit: 'Editing',
164 MultiEdit: 'Editing',
165 Write: 'Writing',
166 NotebookEdit: 'Editing',
167 Grep: 'Searching for',
168 Glob: 'Finding',
169 LS: 'Listing',
170 LSP: 'Inspecting',
171 Bash: 'Running',
172 PowerShell: 'Running',
173 Monitor: 'Watching',
174 TaskStop: 'Stopping a background task',
175 WebFetch: 'Browsing',
176 WebSearch: 'Searching the web for',
177 Agent: 'Delegating',
178 Task: 'Delegating',
179 Workflow: 'Running workflow',
180 SendMessage: 'Messaging',
181 TodoWrite: 'Updating to-dos',
182 TaskCreate: 'Adding task',
183 TaskUpdate: 'Updating task',
184 TaskList: 'Checking tasks',
185 TaskGet: 'Checking a task',
186 EnterPlanMode: 'Starting a plan',
187 Skill: 'Using skill',
188 ToolSearch: 'Looking for tools',
189 },
190 acts: {
191 read: 'Reading',
192 search: 'Searching',
193 write: 'Editing',
194 run: 'Running',
195 web: 'Browsing',
196 delegate: 'Delegating',
197 plan: 'Planning',
198 ask: 'Waiting on you',
199 tool: 'Using a tool',
200 think: 'Thinking',
201 rest: 'Resting',
202 done: 'Done',
203 fail: 'Failed',
204 },
205 mcp: (server, tool) => (tool === '' ? `Using ${server}` : `Using ${server}: ${tool}`),
206 using: tool => `Using ${tool}`,
207 join: (verb, target) => (target === '' ? verb : `${verb} ${target}`),
208 tally: (busy, asking, idle) => `${busy} busy · ${asking} need you · ${idle} idle`,
209 more: count => `+${count} more`,
210 calls: count => (count === 1 ? '1 tool call' : `${count} tool calls`),
211 legend: ['thinking', 'working', 'needs you', 'idle / done'],
212 usage: 'Usage: /cafe opens the café, /cafe text lists it as text, /cafe close closes it.',
213 command: 'Open the Agent Café: see what every agent is doing right now',
214 moreTables: count => (count === 1 ? '1 more table not shown' : `${count} more tables not shown`),
215 elsewhere: (tables, seats) =>
216 `Other sessions: ${tables} ${tables === 1 ? 'table' : 'tables'}, ${seats} ${seats === 1 ? 'agent' : 'agents'} (open the café with /cafe to see them)`,
217 noPane: 'This surface places no pane for the café, so here it is as text:',
218 alt: (seats, tables) =>
219 `A café with ${seats} ${seats === 1 ? 'agent' : 'agents'} at ${tables} ${tables === 1 ? 'table' : 'tables'}.`,
220}
221
222/** The words for a language; English for one the café does not have. */
223export function wordsFor(language: unknown): Words {
224 return language === 'zh-TW' ? ZH : EN
225}
226
227/** What a seat's state takes to say: the fields of a seat the sentence reads. */
228export type Saying = {
229 kind: 'main' | 'sub' | 'mate'
230 phase: 'think' | 'work' | 'ask' | 'idle' | 'done' | 'fail'
231 act: Act
232 tool: string
233 target: string
234}
235
236/** A tool call as a verb and what it works on: `改 src/app.ts`. */
237export function sayCall(tool: string, act: Act, target: string, words: Words): string {
238 const verb = Object.hasOwn(words.verbs, tool) ? words.verbs[tool] : undefined
239 if (verb !== undefined) return words.join(verb, target)
240 const mcp = mcpParts(tool)
241 if (mcp !== undefined) return words.mcp(mcp.server, clip(mcp.tool, 24))
242 if (tool !== '') return words.using(clip(tool, 24))
243 return words.join(words.acts[act], target)
244}
245
246/**
247 * What a seat is doing right now, as one sentence.
248 *
249 * @param hasCrew true for a main loop whose own agents are still at work: at
250 * rest it waits for them, not for the person
251 */
252export function say(seat: Saying, words: Words, hasCrew = false): string {
253 switch (seat.phase) {
254 case 'think':
255 return words.thinking
256 case 'idle':
257 if (seat.kind !== 'main') return words.standby
258 return hasCrew ? words.awaiting : words.waiting
259 case 'done':
260 return words.done
261 case 'fail':
262 return words.failed
263 case 'ask':
264 if (seat.tool === 'AskUserQuestion') return words.question(seat.target)
265 if (seat.tool === 'ExitPlanMode') return words.planReady
266 if (seat.tool === '') return words.needsYou
267 return words.needsOk(sayCall(seat.tool, seat.act, seat.target, words))
268 case 'work':
269 return sayCall(seat.tool, seat.act, seat.target, words)
270 }
271}
272
273/** How long something has lasted, coarsely: `8s`, `3m`, `1h 05m`. */
274export function age(ms: number): string {
275 const seconds = Math.max(0, Math.floor(ms / 1000))
276 if (seconds < 60) return `${seconds}s`
277 const minutes = Math.floor(seconds / 60)
278 if (minutes < 60) return `${minutes}m`
279 return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`
280}
281hooks/scene.ts 607 lines1// The café as a picture: one SVG document drawn from who sits where.
2//
3// Pure: no `$` here, and nothing in the picture depends on the time, so the
4// same café draws the same markup and a surface that already shows it need
5// not load it again. What moves, moves by SMIL animation inside the markup.
6//
7// Units are logical pixels (one sprite pixel); the root scales them to the
8// room the pane has.
9
10import { clip, tidy, widthOf } from './describe'
11import type { Words } from './i18n'
12import type { IconName, Look } from './sprites'
13import {
14 CAT,
15 CAT_INKS,
16 CHEST,
17 CHEST_ROW,
18 EYES_HAPPY,
19 EYES_SHUT,
20 HEADS,
21 ICONS,
22 INK,
23 MAIN_LOOK,
24 box,
25 darken,
26 hash,
27 inksOf,
28 lookOf,
29 paint,
30} from './sprites'
31import type { Cafe, Place, Table } from './view'
32import { tableTitle } from './view'
33
34export type SceneOptions = {
35 /** Seats across the room. */
36 columns: number
37 /** CSS pixels to one logical pixel. */
38 scale: number
39 words: Words
40 /** False draws the picture still, with no animation in the markup. */
41 hasMotion: boolean
42}
43
44export type Scene = {
45 /** The SVG document. */
46 source: string
47 /** Its size in CSS pixels. */
48 width: number
49 height: number
50 /** What it shows, in words. */
51 alt: string
52}
53
54/** One seat's cell, in logical pixels. */
55export const TILE_W = 34
56export const TILE_H = 48
57
58const MARGIN = 3
59const BAND = 32
60const PLACARD = 9
61const TABLE_GAP = 3
62const FOOT = 3
63
64/** A cell of a pane's code font, in CSS pixels, near enough. */
65const CELL_PX = 7.5
66
67/** An `Svg` takes at most 131072 characters; the picture stays well inside. */
68export const SOURCE_LIMIT = 120_000
69
70const WALL = '#f3e2c3'
71const WALL_LINE = '#e4cda3'
72const COUNTER_TOP = '#ecd6ae'
73const COUNTER_EDGE = '#b98557'
74const COUNTER_FRONT = '#8a623f'
75const WOOD_DARK = '#6b4a32'
76const FLOOR = '#c79a6c'
77const FLOOR_SEAM = '#b0845a'
78const BOARD = '#2f3b36'
79const CHALK = '#f4f1e6'
80const STEEL = '#b9c0c7'
81const STEEL_LIGHT = '#dfe4e8'
82const STEEL_DARK = '#59616b'
83const CUP = '#fbf7ee'
84const LAMP = '#3c3a39'
85const DESK_LIGHT = '#e2b684'
86const DESK_TOP = '#c9995f'
87const DESK_FRONT = '#9a6c41'
88const CHAIR = '#5b4030'
89const CHAIR_LIGHT = '#735241'
90const LID = '#4a5060'
91const LOGO = '#cfd8ea'
92const CLAUDE = '#d97757'
93const PLATE = '#fff8ea'
94const NAME = '#2b2320'
95const SAYS = '#5b4636'
96const BUSY = '#8fd18f'
97const ASK = '#ffd84d'
98const IDLE = '#b9bbbf'
99const GOOD = '#69b96c'
100const BAD = '#e0564b'
101const RUGS: readonly string[] = ['#9b3d2e', '#2f6f6a', '#5d4a8f', '#8a6d1f']
102
103function clamp(value: number, low: number, high: number): number {
104 return Math.min(high, Math.max(low, value))
105}
106
107/**
108 * How many seats fit across a pane `bodyColumns` cells wide, and how far to
109 * scale the picture so it fills that width.
110 */
111export function fit(bodyColumns: number): { columns: number; scale: number } {
112 // A surface that has not measured hands over no width: take an ordinary one.
113 const cells = Number.isFinite(bodyColumns) && bodyColumns > 0 ? bodyColumns : 80
114 const room = clamp(cells * CELL_PX, 220, 1000)
115 const columns = clamp(Math.floor((room / 3 - 2 * MARGIN) / TILE_W), 2, 6)
116 const logical = columns * TILE_W + 2 * MARGIN
117 // Rounded down to a quarter, so the picture is never wider than the room.
118 return { columns, scale: Math.floor(clamp(room / logical, 2, 4) * 4) / 4 }
119}
120
121/**
122 * Text made safe for XML: the five characters escaped, a lone surrogate
123 * replaced, and the characters no XML document may hold left out.
124 */
125export function escapeXml(text: string): string {
126 return text
127 .replace(/\p{Cs}/gu, '\ufffd')
128 .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/g, '')
129 .replace(/&/g, '&')
130 .replace(/</g, '<')
131 .replace(/>/g, '>')
132 .replace(/"/g, '"')
133 .replace(/'/g, ''')
134}
135
136function words2(
137 x: number,
138 y: number,
139 size: number,
140 fill: string,
141 text: string,
142 weight = 400,
143 anchor: 'start' | 'middle' | 'end' = 'middle',
144): string {
145 return `<text x="${x}" y="${y}" font-size="${size}" font-weight="${weight}" fill="${fill}" text-anchor="${anchor}">${escapeXml(text)}</text>`
146}
147
148/** Draws the animations, or nothing when the picture is to stand still. */
149type Motion = {
150 /** An `<animate>` of one attribute through `values`, stepped. */
151 step: (attribute: string, values: string, seconds: number, lead?: number) => string
152 /** An `<animate>` of one attribute through `values`, eased. */
153 ease: (attribute: string, values: string, seconds: number, lead?: number) => string
154 /** An `<animateTransform>` translating through `values`, stepped. */
155 shift: (values: string, seconds: number, lead?: number) => string
156}
157
158function motionOf(hasMotion: boolean): Motion {
159 if (!hasMotion) return { step: () => '', ease: () => '', shift: () => '' }
160 const begin = (lead: number): string => (lead > 0 ? ` begin="-${lead.toFixed(1)}s"` : '')
161 return {
162 step: (attribute, values, seconds, lead = 0) =>
163 `<animate attributeName="${attribute}" values="${values}" dur="${seconds}s"${begin(lead)} calcMode="discrete" repeatCount="indefinite"/>`,
164 ease: (attribute, values, seconds, lead = 0) =>
165 `<animate attributeName="${attribute}" values="${values}" dur="${seconds}s"${begin(lead)} repeatCount="indefinite"/>`,
166 shift: (values, seconds, lead = 0) =>
167 `<animateTransform attributeName="transform" type="translate" values="${values}" dur="${seconds}s"${begin(lead)} calcMode="discrete" repeatCount="indefinite"/>`,
168 }
169}
170
171// ---------------------------------------------------------------------------
172// The back wall: the chalkboard with the tally, the counter, the machine, a
173// lamp and the cat.
174
175function drawBand(width: number, cafe: Cafe, words: Words, motion: Motion): string {
176 let out = box(0, 0, width, BAND, WALL) + box(0, 19, width, 1, WALL_LINE)
177
178 // The counter runs the width of the room.
179 out += box(0, 23, width, 2, COUNTER_TOP) + box(0, 25, width, 1, COUNTER_EDGE)
180 out += box(0, 26, width, BAND - 26, COUNTER_FRONT)
181 let seams = ''
182 for (let x = 9; x < width; x += 12) seams += `M${x} 26h1v${BAND - 26}h-1z`
183 out += `<path fill="${WOOD_DARK}" d="${seams}"/>`
184
185 // The chalkboard: the name of the place, and who is doing what.
186 const boardW = clamp(width - 34, 38, 72)
187 const boardX = 4
188 const boardY = 2
189 const boardH = 18
190 out += box(boardX - 1, boardY - 1, boardW + 2, boardH + 2, WOOD_DARK) + box(boardX, boardY, boardW, boardH, BOARD)
191 out += words2(boardX + boardW / 2, boardY + 7.2, 5, CHALK, words.title.toUpperCase(), 700)
192 out += box(boardX + 4, boardY + 9, boardW - 8, 0.5, '#6f7d76')
193 const chips: readonly [string, number][] = [
194 [BUSY, cafe.busy],
195 [ASK, cafe.asking],
196 [IDLE, cafe.idle],
197 ]
198 chips.forEach(([color, count], index) => {
199 const center = boardX + (boardW * (2 * index + 1)) / 6
200 out += box(Math.round(center) - 5, boardY + 11.5, 3, 3, color)
201 out += words2(Math.round(center) - 0.5, boardY + 14.7, 4.2, CHALK, String(count), 700, 'start')
202 })
203
204 // The espresso machine, at the far end of the counter.
205 const machineX = width - 21
206 out += box(machineX + 1, 9, 14, 2, STEEL_DARK)
207 out += box(machineX + 3, 7, 3, 2, CUP) + box(machineX + 7, 7, 3, 2, CUP) + box(machineX + 11, 7, 3, 2, CUP)
208 out += box(machineX, 11, 16, 8, STEEL) + box(machineX, 11, 16, 1, STEEL_LIGHT)
209 out += box(machineX + 2, 13, 4, 3, '#2f343b') + box(machineX + 3, 14, 2, 1, BAD)
210 out += box(machineX + 8, 13, 6, 1, STEEL_DARK) + box(machineX + 8, 15, 6, 1, STEEL_DARK)
211 out += box(machineX + 5, 19, 6, 1, STEEL_DARK) + box(machineX + 7, 20, 2, 1, STEEL_DARK)
212 out += box(machineX + 6, 21, 4, 2, CUP) + box(machineX + 10, 21, 1, 1, CUP)
213 out += box(machineX + 1, 19, 2, 4, STEEL_DARK) + box(machineX + 13, 19, 2, 4, STEEL_DARK)
214 out +=
215 `<rect x="${machineX + 4}" y="4" width="1" height="2" fill="#fff" opacity=".7">` +
216 motion.step('y', '5;4;3;2', 1.6) +
217 motion.step('opacity', '.8;.6;.3;0', 1.6) +
218 '</rect>'
219 out +=
220 `<rect x="${machineX + 9}" y="3" width="1" height="2" fill="#fff" opacity=".5">` +
221 motion.step('y', '5;4;3;2', 1.6, 0.8) +
222 motion.step('opacity', '.8;.6;.3;0', 1.6, 0.8) +
223 '</rect>'
224
225 // Between the board and the machine: a lamp, and the cat asleep under it.
226 const gapLeft = boardX + boardW + 2
227 const gapRight = machineX - 2
228 if (gapRight - gapLeft >= 15) {
229 const middle = Math.floor((gapLeft + gapRight) / 2)
230 const catX = middle - 6
231 out += paint(CAT, CAT_INKS, catX, 17)
232 out +=
233 `<rect x="${catX + 12}" y="19" width="1" height="1" fill="${INK}">` +
234 motion.step('y', '19;19;18;19', 2.4) +
235 '</rect>'
236 out +=
237 `<text x="${catX + 1}" y="15" font-size="3.4" font-weight="700" fill="${WOOD_DARK}" opacity=".8">z` +
238 motion.ease('y', '16;12', 2.8) +
239 motion.ease('opacity', '.9;0', 2.8) +
240 '</text>'
241 out += box(middle, 0, 1, 4, INK) + box(middle - 2, 4, 5, 1, LAMP) + box(middle - 3, 5, 7, 2, LAMP)
242 out += box(middle - 1, 7, 3, 1, '#ffe9a8')
243 out +=
244 `<circle cx="${middle + 0.5}" cy="9" r="8" fill="#ffd27a" opacity=".2" shape-rendering="auto">` +
245 motion.ease('opacity', '.2;.26;.2', 3.2) +
246 '</circle>'
247 }
248
249 // The counter's shadow on the floor.
250 out += `<rect x="0" y="${BAND}" width="${width}" height="2" fill="#000" opacity=".12"/>`
251 return out
252}
253
254function drawFloor(width: number, height: number): string {
255 let seams = ''
256 let row = 0
257 for (let y = BAND; y < height; y += 8, row += 1) {
258 if (y > BAND) seams += `M0 ${y}h${width}v1h${-width}z`
259 for (let x = (row % 2) * 14 + 6; x < width; x += 28) seams += `M${x} ${y + 1}h1v7h-1z`
260 }
261 return box(0, BAND, width, height - BAND, FLOOR) + `<path fill="${FLOOR_SEAM}" d="${seams}"/>`
262}
263
264// ---------------------------------------------------------------------------
265// One seat: a chair, a person, a desk with what they work at, a bubble that
266// says what kind of work it is, and a name card.
267
268type Pose = 'type' | 'read' | 'note' | 'think' | 'ask' | 'rest' | 'done' | 'fail'
269
270function poseOf(place: Place): Pose {
271 const { phase, act } = place.seat
272 if (phase === 'think') return 'think'
273 if (phase === 'ask') return 'ask'
274 if (phase === 'idle') return 'rest'
275 if (phase === 'done') return 'done'
276 if (phase === 'fail') return 'fail'
277 if (act === 'read' || act === 'search') return 'read'
278 if (act === 'plan') return 'note'
279 return 'type'
280}
281
282const ICON_OF: Readonly<Record<string, IconName>> = {
283 read: 'book',
284 search: 'lens',
285 write: 'pencil',
286 run: 'shell',
287 web: 'globe',
288 delegate: 'people',
289 plan: 'list',
290 tool: 'gear',
291}
292
293function frame(x: number, y: number, fill: string): string {
294 const edge =
295 `M${x + 1} ${y}h11v1h-11z` +
296 `M${x} ${y + 1}h1v8h-1z` +
297 `M${x + 12} ${y + 1}h1v8h-1z` +
298 `M${x + 1} ${y + 9}h11v1h-11z` +
299 `M${x + 2} ${y + 10}h2v1h-2z` +
300 `M${x + 1} ${y + 11}h1v1h-1z`
301 return box(x + 1, y + 1, 11, 8, fill) + `<path fill="${INK}" d="${edge}"/>`
302}
303
304function drawBubble(place: Place, motion: Motion, lead: number): string {
305 const x = 20
306 const y = 0
307 const { phase, act, tool } = place.seat
308 if (phase === 'idle') return ''
309 if (phase === 'think') {
310 let dots = ''
311 for (let index = 0; index < 3; index += 1) {
312 dots +=
313 `<rect x="${x + 3 + index * 3}" y="${y + 4}" width="2" height="2" fill="${INK}">` +
314 motion.step('opacity', index === 0 ? '1;.25;.25' : index === 1 ? '.25;1;.25' : '.25;.25;1', 1.2, lead) +
315 '</rect>'
316 }
317 return frame(x, y, '#fff') + dots
318 }
319 if (phase === 'ask') {
320 const icon = tool === 'AskUserQuestion' ? ICONS.query : ICONS.bang
321 return (
322 `<g>${motion.shift('0 0;0 -1', 0.6, lead)}` +
323 frame(x, y, ASK) +
324 paint(icon, { '#': INK, a: INK }, x + 3, y + 1) +
325 '</g>'
326 )
327 }
328 if (phase === 'done') return frame(x, y, GOOD) + paint(ICONS.check, { '#': '#fff' }, x + 3, y + 1)
329 if (phase === 'fail') return frame(x, y, BAD) + paint(ICONS.cross, { '#': '#fff' }, x + 3, y + 1)
330 const name = ICON_OF[act] ?? 'gear'
331 return frame(x, y, '#fff') + paint(ICONS[name], { '#': INK, a: CLAUDE }, x + 3, y + 1)
332}
333
334function laptop(isLit: boolean, glow: string, motion: Motion, lead: number): string {
335 return (
336 box(13, 20, 8, 6, INK) +
337 box(14, 21, 6, 4, LID) +
338 `<rect x="16" y="22" width="2" height="2" fill="${glow}">` +
339 (isLit ? motion.ease('opacity', '1;.35;1', 1.8, lead) : '') +
340 '</rect>'
341 )
342}
343
344function closedLaptop(): string {
345 return box(12, 24, 10, 2, INK) + box(13, 24, 8, 1, LID)
346}
347
348function hand(x: number, y: number, skin: string, inner = ''): string {
349 return `<rect x="${x}" y="${y}" width="2" height="2" fill="${skin}">${inner}</rect>`
350}
351
352/** An arm raised beside the head, on the left as the picture is seen. */
353function raisedArm(look: Look, motion: Motion, lead: number): string {
354 return (
355 `<g>${motion.shift('0 0;-1 0;0 0;1 0', 0.9, lead)}` +
356 box(6, 12, 4, 10, INK) +
357 box(7, 12, 2, 10, look.top) +
358 box(6, 8, 4, 4, INK) +
359 box(7, 9, 2, 3, look.skin) +
360 '</g>'
361 )
362}
363
364function eyes(grid: readonly string[], look: Look): string {
365 // Paint over the open eyes, then draw the others in their rows.
366 return box(14, 14, 1, 2, look.skin) + box(19, 14, 1, 2, look.skin) + paint(grid, { E: INK }, 9, 14)
367}
368
369function drawPerson(look: Look, pose: Pose, motion: Motion, lead: number): string {
370 const inks = inksOf(look)
371 let out = box(9, 16, 16, 1, CHAIR) + box(8, 17, 18, 10, CHAIR) + box(9, 17, 16, 1, CHAIR_LIGHT)
372 out += paint(CHEST, inks, 9, 6 + CHEST_ROW)
373 out += paint(HEADS[look.head] ?? HEADS[0] ?? [], inks, 9, 6)
374
375 if (pose === 'done') out += eyes(EYES_HAPPY, look)
376 else if (pose === 'fail') out += eyes(EYES_SHUT, look)
377 else {
378 // A blink, now and then.
379 out +=
380 `<path fill="${look.skin}" opacity="0" d="M14 14h1v2h-1zM19 14h1v2h-1z">` +
381 motion.step('opacity', '0;0;0;0;0;0;0;0;0;0;0;1', 4.8, lead * 3) +
382 '</path>'
383 }
384 return out
385}
386
387function drawDesk(): string {
388 return (
389 box(1, 26, 32, 1, DESK_LIGHT) +
390 box(1, 27, 32, 2, DESK_TOP) +
391 box(2, 29, 30, 5, DESK_FRONT) +
392 box(2, 34, 30, 1, WOOD_DARK) +
393 box(3, 35, 2, 3, WOOD_DARK) +
394 box(29, 35, 2, 3, WOOD_DARK) +
395 '<rect x="2" y="38" width="30" height="1" fill="#000" opacity=".12"/>'
396 )
397}
398
399/** What is on the desk and where the hands are, drawn over the person. */
400function drawWork(look: Look, pose: Pose, motion: Motion, lead: number): string {
401 const { skin } = look
402 switch (pose) {
403 case 'type':
404 return (
405 laptop(true, LOGO, motion, lead) +
406 hand(11, 24, skin, motion.step('y', '24;23', 0.32, lead)) +
407 hand(21, 24, skin, motion.step('y', '23;24', 0.32, lead))
408 )
409 case 'read': {
410 const cover = '#3f6fb0'
411 return (
412 `<g>${motion.shift('0 0;0 -1', 2.2, lead)}` +
413 box(11, 19, 12, 7, INK) +
414 box(12, 20, 10, 5, cover) +
415 box(12, 20, 10, 1, CUP) +
416 box(16, 21, 2, 4, darken(cover)) +
417 hand(10, 22, skin) +
418 hand(22, 22, skin) +
419 '</g>'
420 )
421 }
422 case 'note':
423 return (
424 box(11, 19, 12, 7, INK) +
425 box(12, 20, 10, 5, '#e8d5a8') +
426 box(15, 18, 4, 2, STEEL_DARK) +
427 box(13, 22, 6, 1, '#9a8660') +
428 box(13, 24, 4, 1, '#9a8660') +
429 hand(10, 22, skin) +
430 hand(22, 23, skin, motion.step('x', '22;21;22;23', 0.9, lead))
431 )
432 case 'think':
433 return (
434 closedLaptop() +
435 box(19, 20, 2, 4, look.collar) +
436 box(18, 18, 3, 2, skin) +
437 box(18, 20, 3, 1, INK) +
438 hand(12, 24, skin)
439 )
440 case 'ask':
441 return laptop(false, LOGO, motion, lead) + raisedArm(look, motion, lead) + hand(21, 24, skin)
442 case 'rest':
443 return (
444 box(22, 21, 5, 5, INK) +
445 box(23, 22, 3, 3, CUP) +
446 box(27, 22, 1, 3, INK) +
447 `<rect x="24" y="18" width="1" height="2" fill="#fff" opacity=".7">` +
448 motion.step('y', '19;18;17;16', 2, lead) +
449 motion.step('opacity', '.8;.6;.3;0', 2, lead) +
450 '</rect>' +
451 hand(13, 24, skin) +
452 hand(18, 24, skin)
453 )
454 case 'done':
455 return (
456 closedLaptop() +
457 hand(11, 24, skin) +
458 hand(21, 24, skin) +
459 `<path fill="${ASK}" d="M5 11h1v3h-1zM4 12h3v1h-3zM28 15h1v3h-1zM27 16h3v1h-3z">` +
460 motion.step('opacity', '1;.2', 0.7, lead) +
461 '</path>'
462 )
463 case 'fail':
464 return (
465 laptop(false, BAD, motion, lead) +
466 hand(11, 24, skin) +
467 hand(21, 24, skin) +
468 `<rect x="24" y="10" width="1" height="2" fill="#7cc7ee">` +
469 motion.step('y', '10;11;12;13', 1.2, lead) +
470 '</rect>'
471 )
472 }
473}
474
475function drawSeat(place: Place, x: number, y: number, words: Words, motion: Motion): string {
476 const { seat } = place
477 const look = seat.kind === 'main' ? MAIN_LOOK : lookOf(place.key)
478 const pose = poseOf(place)
479 const lead = (hash(place.key) % 16) / 10
480 const title = seat.task === '' ? `${place.label} — ${place.says}` : `${place.label}${words.task(seat.task)} — ${place.says}`
481
482 let out = `<g transform="translate(${x} ${y})"><title>${escapeXml(title)}</title>`
483 if (place.isViewed) {
484 out += `<rect x=".5" y=".5" width="33" height="47" rx="2" fill="${CLAUDE}" opacity=".22" shape-rendering="auto"/>`
485 }
486 out += drawPerson(look, pose, motion, lead)
487 out += drawDesk()
488 out += drawWork(look, pose, motion, lead)
489 out += drawBubble(place, motion, lead)
490
491 // The name card: who, and what they are doing.
492 out += `<rect x=".5" y="39.2" width="33" height="8.4" rx="1.4" fill="${PLATE}" opacity=".86" shape-rendering="auto"/>`
493 // A long name is set smaller and bold text cut sooner, so both stay on the card.
494 const isHeld = seat.phase === 'ask'
495 const name = clip(place.label, 16)
496 out += words2(17, 42.9, widthOf(name) > 12 ? 3.3 : 3.9, seat.kind === 'main' ? '#b0502f' : NAME, name, 700)
497 out += words2(17, 46.6, 3.2, isHeld ? '#a8321e' : SAYS, clip(place.says, isHeld ? 16 : 18), isHeld ? 700 : 400)
498 return out + '</g>'
499}
500
501// ---------------------------------------------------------------------------
502// Tables, and the room.
503
504/** Where one table stands: which columns of the room, and how far down. */
505type Spot = {
506 table: Table
507 /** Its place among the tables, which picks its rug. */
508 index: number
509 /** Its first column, its width in columns, and its top in logical pixels. */
510 column: number
511 span: number
512 top: number
513}
514
515/**
516 * Stands the tables in the room: small ones side by side in a row, one with
517 * more seats than the room is wide across it, on as many rows as it needs.
518 *
519 * A table takes at least two columns where the room has them, so that its
520 * name fits on its placard.
521 */
522export function placeTables(
523 tables: readonly Table[],
524 columns: number,
525 hasPlacard: boolean,
526): { spots: Spot[]; bottom: number } {
527 const head = hasPlacard ? PLACARD : 0
528 const spots: Spot[] = []
529 let top = BAND + 4
530 let column = 0
531 let tall = 0
532 tables.forEach((table, index) => {
533 const seats = Math.max(1, table.places.length)
534 const rows = Math.ceil(seats / columns)
535 const span = rows > 1 ? columns : Math.min(columns, Math.max(seats, 2))
536 if (column > 0 && column + span > columns) {
537 top += tall + TABLE_GAP
538 column = 0
539 tall = 0
540 }
541 spots.push({ table, index, column, span, top })
542 tall = Math.max(tall, head + rows * TILE_H)
543 column += span
544 })
545 return { spots, bottom: top + tall }
546}
547
548function drawTable(spot: Spot, options: SceneOptions, hasPlacard: boolean, motion: Motion): string {
549 const { columns, words } = options
550 const { table } = spot
551 const left = MARGIN + spot.column * TILE_W
552 const room = spot.span * TILE_W
553 let out = ''
554 let y = spot.top
555 if (hasPlacard) {
556 const text = clip(tableTitle(table, words), Math.floor((room - 10) / 2.1))
557 const plate = Math.min(room - 2, widthOf(text) * 2.1 + 8)
558 out += box(left, y + 1, plate, 7, WOOD_DARK) + box(left, y + 1, 2, 7, table.isHere ? CLAUDE : '#8fb39b')
559 out += words2(left + 4.5, y + 6.2, 3.8, CHALK, text, 700, 'start')
560 y += PLACARD
561 }
562 // The rug under the table: out to the wall at the room's edges, a seam's
563 // width short of the table beside it.
564 const rows = Math.max(1, Math.ceil(table.places.length / columns))
565 const rug = RUGS[spot.index % RUGS.length] ?? '#9b3d2e'
566 const rugLeft = left - (spot.column === 0 ? 1.5 : -0.5)
567 const rugRight = left + room + (spot.column + spot.span >= columns ? 1.5 : -0.5)
568 out += `<rect x="${rugLeft}" y="${y}" width="${rugRight - rugLeft}" height="${rows * TILE_H - 1}" rx="3" fill="${rug}" opacity=".13" shape-rendering="auto"/>`
569 table.places.forEach((place, seat) => {
570 out += drawSeat(place, left + (seat % columns) * TILE_W, y + Math.floor(seat / columns) * TILE_H, words, motion)
571 })
572 return out
573}
574
575/** The picture of a café, sized for `options.columns` seats across. */
576export function drawScene(cafe: Cafe, options: SceneOptions): Scene {
577 const columns = clamp(Math.floor(options.columns), 1, 8)
578 const width = columns * TILE_W + 2 * MARGIN
579 const motion = motionOf(options.hasMotion)
580 const hasPlacard = cafe.tables.length > 1 || cafe.tables.some(table => table.hidden > 0) || cafe.hiddenTables > 0
581
582 const { spots, bottom } = placeTables(cafe.tables, columns, hasPlacard)
583 let body = ''
584 for (const spot of spots) body += drawTable(spot, { ...options, columns }, hasPlacard, motion)
585 const height = bottom + FOOT
586
587 const says: string[] = []
588 for (const table of cafe.tables) {
589 for (const place of table.places) says.push(`${place.label}: ${place.says}`)
590 }
591 // The alt is drawn as text by a surface that shows no picture, and a surface
592 // refuses a text that holds a control character: tidied here, whatever came in.
593 const alt = clip(tidy(`${options.words.alt(cafe.seats, cafe.tables.length)} ${says.join('; ')}`), 600)
594
595 const cssWidth = Math.round(width * options.scale)
596 const cssHeight = Math.round(height * options.scale)
597 const source =
598 `<svg xmlns="http://www.w3.org/2000/svg" width="${cssWidth}" height="${cssHeight}" viewBox="0 0 ${width} ${height}" ` +
599 `font-family="ui-sans-serif, system-ui, -apple-system, 'PingFang TC', 'Noto Sans TC', 'Microsoft JhengHei', sans-serif" ` +
600 `shape-rendering="crispEdges" role="img" aria-label="${escapeXml(alt)}">` +
601 drawFloor(width, height) +
602 drawBand(width, cafe, options.words, motion) +
603 body +
604 '</svg>'
605 return { source, width: cssWidth, height: cssHeight, alt }
606}
607hooks/tracker.ts 610 lines1// Who is in the café and what each one is doing: the roster one session keeps
2// of its own agents, moved along by the session's events.
3//
4// The hooks module owns one roster and calls these functions from its hooks;
5// every function takes the time as an argument and touches nothing else, so
6// the same calls in a test give the same roster.
7
8import type { Act } from './describe'
9
10/**
11 * Where a seat stands: between tool calls, in one, held for the person,
12 * resting with no turn running, or gone (done, failed).
13 */
14export type Phase = 'think' | 'work' | 'ask' | 'idle' | 'done' | 'fail'
15
16/** The main loop, a subagent, or a teammate (which rests between its turns). */
17export type Kind = 'main' | 'sub' | 'mate'
18
19/** The main loop's id in the roster: its events carry no `agentId`. */
20export const MAIN = 'main'
21
22/** How long a subagent that finished keeps its seat before it leaves. */
23export const LINGER_MS = 20_000
24/** How long a loop nothing has named is remembered before it is forgotten. */
25export const GHOST_MS = 60_000
26/** How long a quiet agent the list never named is kept. */
27export const STALE_MS = 30 * 60_000
28/** How long a call in flight keeps an agent nothing else has been heard of. */
29export const CALL_STALE_MS = 12 * 60 * 60_000
30/** How long the list must go on leaving an agent out before the café believes it. */
31export const MISS_MS = 4_000
32/** The list may be a moment behind the events: an end this recent stands against it. */
33const SETTLE_MS = 10_000
34
35/** One tool call in flight. */
36type Call = {
37 id: string
38 tool: string
39 act: Act
40 target: string
41 at: number
42 /** Held at a permission prompt since `askAt`. */
43 isAsking: boolean
44 askAt: number
45 /** The tool the prompt that holds it named; `''` when it named none. */
46 askedFor: string
47}
48
49/** One agent of this session, as the roster keeps it. */
50export type Agent = {
51 id: string
52 kind: Kind
53 /** `main`, the subagent type, or `''` until something says. */
54 type: string
55 /** What SendMessage addresses it by, when it has a name. */
56 name: string
57 /** The few words its spawn described the task in. */
58 task: string
59 /** The agent whose loop spawned it; `''` when the main loop did. */
60 parent: string
61 phase: Phase
62 act: Act
63 tool: string
64 target: string
65 /** When what it shows now began. */
66 since: number
67 born: number
68 /** When it finished; 0 while it is here. */
69 ended: number
70 /** Tool calls so far. */
71 calls: number
72 open: Call[]
73 /** The person is asked for, with no open call to pin it on. */
74 needsPerson: boolean
75 /**
76 * True while a turn of its own runs: with nothing in flight it is thinking.
77 * False between turns, where with nothing in flight it rests.
78 */
79 inTurn: boolean
80 /** True when the main loop's last turn ended on an error: it rests as failed. */
81 hasFailed: boolean
82 /** False while only loop events have named it: such a seat is not shown. */
83 isKnown: boolean
84 /** True once `$.agent.list()` named it: its absence there then means it left. */
85 isListed: boolean
86 /** When the list first left it out, having named it before; 0 while it is listed. */
87 missedAt: number
88 /** When an event last named it. */
89 seen: number
90}
91
92export type Roster = { agents: Agent[] }
93
94/** What a seat shows, the part of an agent the café draws and shares. */
95export type Seat = {
96 id: string
97 kind: Kind
98 type: string
99 name: string
100 task: string
101 parent: string
102 phase: Phase
103 act: Act
104 tool: string
105 target: string
106 since: number
107 calls: number
108}
109
110/** One agent as `$.agent.list()` answers it: the fields the roster reads. */
111export type Listed = {
112 id: string
113 type: string
114 description: string
115 status: string
116 parentId?: string
117 name?: string
118 teammateId?: string
119}
120
121/** What a spawn says of the agent it started. */
122export type Spawned = {
123 id: string
124 type: string
125 task: string
126 parent: string
127 name: string
128 isTeammate: boolean
129}
130
131/** Why a turn ended, as `turn.complete` says it. */
132export type EndReason = 'answer' | 'aborted' | 'refusal' | 'error'
133
134function blank(id: string, kind: Kind, now: number): Agent {
135 return {
136 id,
137 kind,
138 type: kind === 'main' ? MAIN : '',
139 name: '',
140 task: '',
141 parent: '',
142 phase: kind === 'main' ? 'idle' : 'think',
143 act: kind === 'main' ? 'rest' : 'think',
144 tool: '',
145 target: '',
146 since: now,
147 born: now,
148 ended: 0,
149 calls: 0,
150 open: [],
151 needsPerson: false,
152 // The main loop is met between turns; anyone else is met at work.
153 inTurn: kind !== 'main',
154 hasFailed: false,
155 isKnown: kind === 'main',
156 isListed: false,
157 missedAt: 0,
158 seen: now,
159 }
160}
161
162/** A roster with the main loop at its seat, resting. */
163export function createRoster(now: number): Roster {
164 return { agents: [blank(MAIN, 'main', now)] }
165}
166
167function find(roster: Roster, id: string): Agent | undefined {
168 return roster.agents.find(agent => agent.id === id)
169}
170
171/** The agent an event names, seated unseen when nothing has named it before. */
172function ensure(roster: Roster, agentId: string | undefined, now: number): Agent {
173 const id = agentId ?? MAIN
174 let agent = find(roster, id)
175 if (agent === undefined) {
176 agent = blank(id, id === MAIN ? 'main' : 'sub', now)
177 roster.agents.push(agent)
178 }
179 agent.seen = now
180 return agent
181}
182
183function put(agent: Agent, phase: Phase, act: Act, tool: string, target: string, since: number): void {
184 if (agent.phase === phase && agent.act === act && agent.tool === tool && agent.target === target) return
185 agent.phase = phase
186 agent.act = act
187 agent.tool = tool
188 agent.target = target
189 agent.since = since
190}
191
192function lastWhere<T>(items: readonly T[], test: (item: T) => boolean): T | undefined {
193 for (let index = items.length - 1; index >= 0; index -= 1) {
194 const item = items[index]
195 if (item !== undefined && test(item)) return item
196 }
197 return undefined
198}
199
200/** Works out what a live agent shows from its calls in flight and whether a turn runs. */
201function show(agent: Agent, now: number): void {
202 if (agent.ended !== 0) return
203 // A call held for the person outranks the rest: that is the one to see.
204 const held = lastWhere(agent.open, call => call.isAsking || call.act === 'ask')
205 const top = held ?? agent.open[agent.open.length - 1]
206 if (top !== undefined) {
207 const isHeld = top.isAsking || top.act === 'ask'
208 put(agent, isHeld ? 'ask' : 'work', top.act, top.tool, top.target, top.isAsking ? top.askAt : top.at)
209 return
210 }
211 if (agent.needsPerson) {
212 put(agent, 'ask', 'ask', '', '', now)
213 return
214 }
215 if (agent.inTurn) {
216 put(agent, 'think', 'think', '', '', now)
217 return
218 }
219 // Between turns there is nothing to think about: a call that ran there (a
220 // plugin's own, say) leaves the agent as its last turn did.
221 if (agent.hasFailed) put(agent, 'fail', 'fail', '', '', now)
222 else put(agent, 'idle', 'rest', '', '', now)
223}
224
225/**
226 * An agent is over: done, failed, or cut short (stopped by the person or
227 * killed), which has nothing to show for it and so leaves at the next sweep.
228 */
229function leave(agent: Agent, how: 'done' | 'fail' | 'cut', now: number): void {
230 agent.open = []
231 agent.needsPerson = false
232 agent.inTurn = false
233 const phase = how === 'fail' ? 'fail' : 'done'
234 put(agent, phase, phase, '', '', now)
235 agent.ended = how === 'cut' ? now - LINGER_MS : now
236}
237
238function rest(agent: Agent, now: number): void {
239 agent.open = []
240 agent.needsPerson = false
241 agent.inTurn = false
242 agent.hasFailed = false
243 put(agent, 'idle', 'rest', '', '', now)
244}
245
246/** A main-loop turn began: the main loop is thinking, whatever it showed. */
247export function turnStart(roster: Roster, now: number): void {
248 const agent = ensure(roster, undefined, now)
249 agent.open = []
250 agent.needsPerson = false
251 agent.ended = 0
252 agent.inTurn = true
253 agent.hasFailed = false
254 put(agent, 'think', 'think', '', '', now)
255}
256
257/** A tool call began in an agent's loop. */
258export function toolStart(
259 roster: Roster,
260 agentId: string | undefined,
261 call: { id: string; tool: string; act: Act; target: string },
262 now: number,
263): void {
264 const agent = ensure(roster, agentId, now)
265 // An agent that finished, or rested, and works again was resumed under its
266 // id. Not so the main loop: a call may run there with no turn (a plugin's).
267 agent.ended = 0
268 if (agent.kind !== 'main') agent.inTurn = true
269 agent.needsPerson = false
270 agent.calls += 1
271 agent.open = agent.open.filter(open => open.id !== call.id)
272 agent.open.push({ ...call, at: now, isAsking: false, askAt: 0, askedFor: '' })
273 show(agent, now)
274}
275
276/** A tool call ended, however it ended. */
277export function toolEnd(roster: Roster, agentId: string | undefined, id: string, now: number): void {
278 const agent = find(roster, agentId ?? MAIN)
279 if (agent === undefined) return
280 agent.seen = now
281 const before = agent.open.length
282 agent.open = agent.open.filter(open => open.id !== id)
283 // A call the roster no longer holds ended a turn ago: nothing to show for it.
284 if (agent.open.length === before) return
285 // A call got through, so the agent is moving again: nothing unpinned holds it.
286 agent.needsPerson = false
287 show(agent, now)
288}
289
290/**
291 * An agent is held for the person: `tool` names the call a prompt holds and
292 * `target` what that call works on, each `''` when the prompt does not say.
293 *
294 * The hold is pinned on the newest open call of that name and target, else of
295 * that name, else on the newest open call, and ends with that call. With no
296 * call open it stands on the agent itself until its next call, the end of its
297 * turn, or `askOver`.
298 *
299 * @returns the id of the call the hold was pinned on, if it was pinned on one
300 */
301export function ask(
302 roster: Roster,
303 agentId: string | undefined,
304 tool: string,
305 target: string,
306 now: number,
307): string | undefined {
308 const agent = ensure(roster, agentId, now)
309 if (agent.ended !== 0) return undefined
310 const call =
311 lastWhere(agent.open, open => !open.isAsking && open.tool === tool && open.target === target) ??
312 lastWhere(agent.open, open => !open.isAsking && open.tool === tool) ??
313 lastWhere(agent.open, open => !open.isAsking)
314 if (call !== undefined) {
315 call.isAsking = true
316 call.askAt = now
317 call.askedFor = tool
318 } else if (!agent.open.some(open => open.isAsking)) {
319 agent.needsPerson = true
320 }
321 show(agent, now)
322 return call?.id
323}
324
325/** The hold is over without the call having run: one call's by id, or all. */
326export function askOver(roster: Roster, agentId: string | undefined, id: string | undefined, now: number): void {
327 const agent = find(roster, agentId ?? MAIN)
328 if (agent === undefined) return
329 agent.seen = now
330 agent.needsPerson = false
331 for (const call of agent.open) {
332 if (id === undefined || call.id === id) call.isAsking = false
333 }
334 show(agent, now)
335}
336
337/**
338 * A call got through without having ended, as an Agent call does when its
339 * agent starts: the hold a prompt about that very call pinned on it is over.
340 *
341 * A hold pinned there for want of a better call, and one on the agent itself,
342 * stand: the agent starting says nothing about those.
343 */
344export function callThrough(roster: Roster, agentId: string | undefined, id: string, now: number): void {
345 const agent = find(roster, agentId ?? MAIN)
346 if (agent === undefined) return
347 const call = agent.open.find(open => open.id === id)
348 if (call === undefined || !call.isAsking || call.askedFor !== call.tool) return
349 call.isAsking = false
350 show(agent, now)
351}
352
353/** A subagent or a teammate started: seat it, or name the one a loop event seated. */
354export function spawn(roster: Roster, info: Spawned, now: number): void {
355 let agent = find(roster, info.id)
356 if (agent === undefined) {
357 agent = blank(info.id, info.isTeammate ? 'mate' : 'sub', now)
358 roster.agents.push(agent)
359 }
360 agent.kind = info.isTeammate ? 'mate' : 'sub'
361 agent.isKnown = true
362 agent.seen = now
363 if (info.type !== '') agent.type = info.type
364 if (info.task !== '') agent.task = info.task
365 if (info.name !== '') agent.name = info.name
366 agent.parent = info.parent
367 // One that has already finished stays finished: a spawn names an agent, it
368 // does not wake one. A later tool call, or the list, says it runs again.
369}
370
371/**
372 * The SubagentStart event: the agent is real, and this is its type.
373 *
374 * One that starts with no type is a loop of the engine's own (a summary, a
375 * suggestion): it takes no seat unless a spawn or the list names it.
376 */
377export function subStart(roster: Roster, id: string, type: string, now: number): void {
378 const agent = ensure(roster, id, now)
379 if (type === '') return
380 agent.isKnown = true
381 if (agent.type === '') agent.type = type
382}
383
384/**
385 * A turn ended: the main loop and a teammate rest, a subagent is done, or
386 * failed, or (interrupted) simply gone.
387 */
388export function turnEnd(roster: Roster, agentId: string | undefined, reason: EndReason, now: number): void {
389 const agent = find(roster, agentId ?? MAIN)
390 if (agent === undefined) return
391 agent.seen = now
392 const hasFailed = reason === 'error' || reason === 'refusal'
393 if (agent.kind === 'main') {
394 agent.open = []
395 agent.needsPerson = false
396 agent.inTurn = false
397 agent.hasFailed = hasFailed
398 show(agent, now)
399 return
400 }
401 if (agent.kind === 'mate') {
402 rest(agent, now)
403 return
404 }
405 if (agent.ended === 0) leave(agent, hasFailed ? 'fail' : reason === 'aborted' ? 'cut' : 'done', now)
406}
407
408/** The SubagentStop event: a subagent is done, a teammate rests. */
409export function subStop(roster: Roster, id: string, now: number): void {
410 const agent = find(roster, id)
411 if (agent === undefined || agent.ended !== 0) return
412 agent.seen = now
413 if (agent.kind === 'mate') rest(agent, now)
414 else if (agent.kind === 'sub') leave(agent, 'done', now)
415}
416
417/** The TeammateIdle event names the teammate, not its id. */
418export function mateIdle(roster: Roster, name: string, now: number): void {
419 const agent = roster.agents.find(mate => mate.kind === 'mate' && mate.name === name && mate.ended === 0)
420 if (agent === undefined) return
421 agent.seen = now
422 rest(agent, now)
423}
424
425/**
426 * What the list's word for an agent means to the café: at work, over, or
427 * parked between turns.
428 *
429 * `idle` and `waiting` are the parked ones. A teammate parks between its turns
430 * and is still here. Any other agent is listed under those words once its turn
431 * has ended, for as long as it can still be sent a message or a shell of its
432 * own still runs, and also while it waits on an agent of its own: by
433 * themselves they are no sign of life, and a call in flight is.
434 */
435function standingOf(status: string): 'live' | 'parked' | 'over' {
436 if (status === 'completed' || status === 'failed' || status === 'killed') return 'over'
437 if (status === 'running' || status === 'pending') return 'live'
438 return 'parked'
439}
440
441/**
442 * Reconciles the roster with `$.agent.list()`: seats whoever the events missed,
443 * fills in what an event did not say, and lets go of whoever the list dropped.
444 *
445 * The events stay the authority on what a running agent is doing; the list
446 * says who exists and who is over.
447 */
448export function sync(roster: Roster, list: readonly Listed[], now: number): void {
449 const ids = new Set<string>()
450 for (const info of list) {
451 ids.add(info.id)
452 const standing = standingOf(info.status)
453 let agent = find(roster, info.id)
454 const isMate = info.teammateId !== undefined || agent?.kind === 'mate'
455 if (agent === undefined || !agent.isKnown) {
456 // Whoever was over, or parked with its turn behind it, before the café
457 // looked never sits down: neither one nothing has named, nor one only
458 // its loop's last events have (an agent that was finishing as the mod
459 // loaded). A teammate that rests is still one of the team.
460 // One with a call in flight is at work whatever the list calls it: a
461 // subagent waiting on an agent of its own is listed `waiting`.
462 const isMidCall = agent !== undefined && agent.ended === 0 && agent.open.length > 0
463 if (standing === 'over' || (standing === 'parked' && !isMate && !isMidCall)) continue
464 // Nor one the events saw end a moment ago, which the list has yet to hear.
465 if (agent !== undefined && agent.ended !== 0 && now - agent.ended <= SETTLE_MS) continue
466 }
467 if (agent === undefined) {
468 agent = blank(info.id, isMate ? 'mate' : 'sub', now)
469 roster.agents.push(agent)
470 }
471 agent.isKnown = true
472 agent.isListed = true
473 agent.missedAt = 0
474 if (info.teammateId !== undefined) agent.kind = 'mate'
475 if (agent.type === '' && info.type !== '') agent.type = info.type
476 if (agent.task === '' && info.description !== '') agent.task = info.description
477 if (agent.name === '' && info.name !== undefined) agent.name = info.name
478 if (agent.parent === '' && info.parentId !== undefined) agent.parent = info.parentId
479
480 if (standing === 'over') {
481 if (agent.ended === 0) leave(agent, info.status === 'failed' ? 'fail' : info.status === 'killed' ? 'cut' : 'done', now)
482 } else if (standing === 'parked') {
483 // Its turn is behind it. One the events still had at work, with nothing
484 // in flight, rests; one that finished stays finished.
485 if (agent.ended === 0 && agent.open.length === 0 && agent.kind !== 'main') rest(agent, now)
486 } else if (agent.ended !== 0) {
487 // Running again under its id, unless the list is only a moment behind.
488 if (now - agent.ended > SETTLE_MS) {
489 agent.ended = 0
490 agent.inTurn = true
491 show(agent, now)
492 }
493 } else if (!agent.inTurn && agent.kind !== 'main') {
494 // It rested, and runs again.
495 agent.inTurn = true
496 show(agent, now)
497 }
498 }
499
500 // Whoever the list named and has since dropped is gone, once a later look agrees.
501 for (const agent of roster.agents) {
502 if (agent.kind === 'main' || !agent.isListed || agent.ended !== 0 || ids.has(agent.id)) continue
503 if (agent.missedAt === 0) agent.missedAt = now
504 else if (now - agent.missedAt >= MISS_MS) leave(agent, 'done', now)
505 }
506}
507
508/**
509 * Lets go of whoever has stayed long enough: the finished after a while, the
510 * unnamed and the long quiet after longer. True when someone left.
511 */
512export function sweep(roster: Roster, now: number): boolean {
513 const before = roster.agents.length
514 roster.agents = roster.agents.filter(agent => {
515 if (agent.kind === 'main') return true
516 if (agent.ended !== 0) return now - agent.ended < LINGER_MS
517 if (!agent.isKnown) return now - agent.seen < GHOST_MS
518 if (agent.isListed) return true
519 // A call in flight keeps its agent, unless neither has been heard of for
520 // half a day: then the call's end was lost.
521 if (agent.open.length > 0) return now - agent.seen < CALL_STALE_MS
522 return now - agent.seen < STALE_MS
523 })
524 return roster.agents.length !== before
525}
526
527/**
528 * The conversation was cleared: the main loop starts over, at rest.
529 *
530 * Its agents keep their seats. A clear does not end them: one still running
531 * goes on and reports to the new conversation, and one the clear stops says
532 * so itself, as any agent that stops does.
533 */
534export function reset(roster: Roster, now: number): void {
535 let main = find(roster, MAIN)
536 if (main === undefined) {
537 main = blank(MAIN, 'main', now)
538 roster.agents.unshift(main)
539 }
540 rest(main, now)
541 // Rested already, it still starts over: its time at rest counts from now.
542 main.since = now
543 main.calls = 0
544}
545
546function toSeat(agent: Agent): Seat {
547 return {
548 id: agent.id,
549 kind: agent.kind,
550 type: agent.type,
551 name: agent.name,
552 task: agent.task,
553 parent: agent.parent,
554 phase: agent.phase,
555 act: agent.act,
556 tool: agent.tool,
557 target: agent.target,
558 since: agent.since,
559 calls: agent.calls,
560 }
561}
562
563/**
564 * The seats to draw, in table order: the main loop, then each agent it
565 * spawned in the order they came, each followed by the agents it spawned.
566 */
567export function seatsOf(roster: Roster): Seat[] {
568 const shown = roster.agents.filter(agent => agent.isKnown)
569 const ids = new Set(shown.map(agent => agent.id))
570 const childrenOf = (parent: string): Agent[] =>
571 shown
572 .filter(agent => agent.kind !== 'main' && (agent.parent === parent || (parent === '' && !ids.has(agent.parent))))
573 .sort((one, other) => one.born - other.born)
574
575 const out: Seat[] = []
576 const placed = new Set<string>()
577 const place = (agent: Agent): void => {
578 if (placed.has(agent.id)) return
579 placed.add(agent.id)
580 out.push(toSeat(agent))
581 for (const child of childrenOf(agent.id)) place(child)
582 }
583 for (const agent of shown) {
584 if (agent.kind === 'main') {
585 placed.add(agent.id)
586 out.push(toSeat(agent))
587 }
588 }
589 for (const agent of childrenOf('')) place(agent)
590 // A cycle of parents would leave agents unplaced: seat them all the same.
591 for (const agent of shown) place(agent)
592 return out
593}
594
595/** How deep a seat sits under the main loop: 1 for its own subagents, 2 for theirs. */
596export function depthOf(seats: readonly Seat[], seat: Seat): number {
597 if (seat.kind === 'main') return 0
598 let depth = 1
599 let parent = seat.parent
600 const walked = new Set<string>([seat.id])
601 while (parent !== '' && !walked.has(parent)) {
602 const above = seats.find(other => other.id === parent)
603 if (above === undefined || above.kind === 'main') break
604 walked.add(parent)
605 depth += 1
606 parent = above.parent
607 }
608 return depth
609}
610hooks/view.ts 339 lines1// From rosters to what the café shows: the tables (one per session), who sits
2// where and under what name, and the same thing as lines of text.
3//
4// Pure: no `$` here.
5
6import type { Act } from './describe'
7import { clip, tidy } from './describe'
8import type { Words } from './i18n'
9import { age, say } from './i18n'
10import type { Kind, Phase, Seat } from './tracker'
11import { depthOf } from './tracker'
12
13/**
14 * What one session shares with the others through the store: who it is, when
15 * it last said so, and its seats.
16 */
17export type Snap = {
18 v: 1
19 id: string
20 /** The session's folder name, which names its table. */
21 title: string
22 /** When it was written, in milliseconds: an old one is a session that is gone. */
23 at: number
24 seats: Seat[]
25}
26
27/** One seat as drawn: the roster's seat, its name at the table and what it says. */
28export type Place = {
29 /** Unique in the café: the table's id and the seat's. */
30 key: string
31 seat: Seat
32 label: string
33 says: string
34 /** 0 for the main loop, 1 for its subagents, 2 for theirs. */
35 depth: number
36 /** True for the agent whose transcript the person has on screen. */
37 isViewed: boolean
38}
39
40export type Table = {
41 id: string
42 title: string
43 /** True for the session the pane is open in. */
44 isHere: boolean
45 places: Place[]
46 /** Seats the table has and does not show. */
47 hidden: number
48}
49
50export type Cafe = {
51 tables: Table[]
52 /** Seats thinking or in a tool call, over every table, shown or not. */
53 busy: number
54 /** Seats held for the person. */
55 asking: number
56 /** Seats resting, done or failed. */
57 idle: number
58 /** Seats shown. */
59 seats: number
60 /** Tables past the ones shown. */
61 hiddenTables: number
62}
63
64/** The most seats one table shows, and the most tables. */
65export const TABLE_SEATS = 12
66export const MAX_TABLES = 4
67
68const PHASES: readonly Phase[] = ['think', 'work', 'ask', 'idle', 'done', 'fail']
69const KINDS: readonly Kind[] = ['main', 'sub', 'mate']
70const ACTS: readonly Act[] = [
71 'read',
72 'search',
73 'write',
74 'run',
75 'web',
76 'delegate',
77 'plan',
78 'ask',
79 'tool',
80 'think',
81 'rest',
82 'done',
83 'fail',
84]
85
86function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
87 return typeof value === 'object' && value !== null && !Array.isArray(value)
88}
89
90function word(value: unknown, max: number): string {
91 return typeof value === 'string' ? clip(tidy(value), max) : ''
92}
93
94function oneOf<T extends string>(value: unknown, among: readonly T[], fallback: T): T {
95 return among.find(item => item === value) ?? fallback
96}
97
98function count(value: unknown): number {
99 return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0
100}
101
102function parseSeat(value: unknown): Seat | undefined {
103 if (!isRecord(value)) return undefined
104 const id = word(value['id'], 80)
105 if (id === '') return undefined
106 return {
107 id,
108 kind: oneOf(value['kind'], KINDS, 'sub'),
109 type: word(value['type'], 40),
110 name: word(value['name'], 40),
111 task: word(value['task'], 80),
112 parent: word(value['parent'], 80),
113 phase: oneOf(value['phase'], PHASES, 'think'),
114 act: oneOf(value['act'], ACTS, 'think'),
115 tool: word(value['tool'], 80),
116 target: word(value['target'], 60),
117 since: count(value['since']),
118 calls: count(value['calls']),
119 }
120}
121
122/**
123 * Reads what another session left in the store. The store is shared by every
124 * session of the machine, so nothing in it is taken on trust: a value of
125 * another shape is no snap, and every string is cut to a length.
126 */
127export function parseSnap(value: unknown): Snap | undefined {
128 if (!isRecord(value) || value['v'] !== 1) return undefined
129 const id = word(value['id'], 80)
130 const seats = value['seats']
131 if (id === '' || !Array.isArray(seats)) return undefined
132 const parsed: Seat[] = []
133 for (const item of seats.slice(0, 64)) {
134 const seat = parseSeat(item)
135 if (seat !== undefined) parsed.push(seat)
136 }
137 return { v: 1, id, title: word(value['title'], 40), at: count(value['at']), seats: parsed }
138}
139
140function baseLabel(seat: Seat, words: Words): string {
141 if (seat.kind === 'main') return words.main
142 return seat.name || seat.type || words.agent
143}
144
145function isOver(phase: Phase): boolean {
146 return phase === 'done' || phase === 'fail'
147}
148
149/** At work: thinking, in a tool call, or held in one for the person. */
150function isLive(phase: Phase): boolean {
151 return phase === 'think' || phase === 'work' || phase === 'ask'
152}
153
154function seatTable(snap: Snap, isHere: boolean, words: Words, viewed: string | undefined, cap: number): Table {
155 // When the table is full, the ones still here keep their seats.
156 const ranked = snap.seats.map((seat, index) => ({ seat, index }))
157 const kept =
158 ranked.length <= cap
159 ? ranked
160 : [...ranked]
161 .sort(
162 (one, other) =>
163 Number(other.seat.kind === 'main') - Number(one.seat.kind === 'main') ||
164 Number(isOver(one.seat.phase)) - Number(isOver(other.seat.phase)) ||
165 one.index - other.index,
166 )
167 .slice(0, cap)
168 .sort((one, other) => one.index - other.index)
169
170 // A main loop at rest waits for its own agents while any of them is at work.
171 const hasCrew = snap.seats.some(seat => seat.kind !== 'main' && isLive(seat.phase))
172 const seen = new Map<string, number>()
173 const places = kept.map(({ seat }): Place => {
174 const base = baseLabel(seat, words)
175 const nth = (seen.get(base) ?? 0) + 1
176 seen.set(base, nth)
177 return {
178 key: `${snap.id}/${seat.id}`,
179 seat,
180 label: nth === 1 ? base : `${base} ${nth}`,
181 says: say(seat, words, hasCrew),
182 depth: depthOf(snap.seats, seat),
183 isViewed: isHere && viewed !== undefined && viewed === seat.id,
184 }
185 })
186 return { id: snap.id, title: snap.title, isHere, places, hidden: snap.seats.length - kept.length }
187}
188
189/**
190 * Seats everyone: this session's table first, then the others by name.
191 *
192 * @param viewed the agent whose transcript is on screen here, if one is
193 * @param cap the most seats a table shows
194 */
195export function arrange(
196 here: Snap,
197 others: readonly Snap[],
198 words: Words,
199 viewed?: string,
200 cap: number = TABLE_SEATS,
201 maxTables: number = MAX_TABLES,
202): Cafe {
203 const rest = others
204 .filter(snap => snap.id !== here.id && snap.seats.length > 0)
205 .sort((one, other) => one.title.localeCompare(other.title) || one.id.localeCompare(other.id))
206 const all = [here, ...rest]
207 const shown = all.slice(0, Math.max(1, maxTables))
208 const tables = shown.map(snap => seatTable(snap, snap === here, words, viewed, Math.max(1, cap)))
209 return tallied(all, tables)
210}
211
212/**
213 * Seats every table in the order given, none of them the viewer's own: the
214 * café as the web page shows it, with every session a guest.
215 *
216 * @param cap the most seats a table shows
217 * @param maxTables the most tables the picture holds
218 */
219export function arrangeAll(
220 snaps: readonly Snap[],
221 words: Words,
222 cap: number = TABLE_SEATS,
223 maxTables: number = MAX_TABLES,
224): Cafe {
225 const all = snaps.filter(snap => snap.seats.length > 0)
226 const tables = all.slice(0, Math.max(1, maxTables)).map(snap => seatTable(snap, false, words, undefined, Math.max(1, cap)))
227 return tallied(all, tables)
228}
229
230/** The café over `all` tables, `tables` of them shown: the tally counts every seat. */
231function tallied(all: readonly Snap[], tables: Table[]): Cafe {
232 let busy = 0
233 let asking = 0
234 let idle = 0
235 for (const snap of all) {
236 for (const seat of snap.seats) {
237 if (seat.phase === 'ask') asking += 1
238 else if (seat.phase === 'think' || seat.phase === 'work') busy += 1
239 else idle += 1
240 }
241 }
242 return {
243 tables,
244 busy,
245 asking,
246 idle,
247 seats: tables.reduce((sum, table) => sum + table.places.length, 0),
248 hiddenTables: all.length - tables.length,
249 }
250}
251
252/** A table's name on its placard and in the list. */
253export function tableTitle(table: Table, words: Words): string {
254 const name = table.title === '' ? table.id.slice(0, 8) : table.title
255 const here = table.isHere ? words.here(name) : name
256 return table.hidden > 0 ? `${here} · ${words.more(table.hidden)}` : here
257}
258
259/** One line of the café as text: a table's name, or a seat. */
260export type Line =
261 | { type: 'table'; key: string; text: string }
262 | {
263 type: 'seat'
264 key: string
265 indent: string
266 phase: Phase
267 label: string
268 /** The subagent's task, quoted as the language quotes; `''` for a seat with none. */
269 note: string
270 says: string
271 /** How long it has been at it, and its tool calls. */
272 tail: string
273 }
274
275/** The café as lines, in the order the picture seats it. */
276export function linesOf(cafe: Cafe, words: Words, now: number): Line[] {
277 const lines: Line[] = []
278 const hasPlacards = cafe.tables.length > 1 || cafe.tables.some(table => table.hidden > 0)
279 for (const table of cafe.tables) {
280 if (hasPlacards) lines.push({ type: 'table', key: `t:${table.id}`, text: tableTitle(table, words) })
281 for (const place of table.places) {
282 const { seat } = place
283 const parts = [age(now - seat.since)]
284 if (seat.kind !== 'main' && seat.calls > 0) parts.push(words.calls(seat.calls))
285 lines.push({
286 type: 'seat',
287 key: `s:${place.key}`,
288 indent: place.depth === 0 ? '' : ' '.repeat(place.depth - 1) + '↳ ',
289 phase: seat.phase,
290 label: place.label,
291 note: seat.task === '' ? '' : words.task(clip(seat.task, 44)),
292 says: place.says,
293 tail: parts.join(' · '),
294 })
295 }
296 }
297 return lines
298}
299
300/** The mark that stands for a phase; with a color where one can be drawn, alone where none can. */
301export const MARKS: Readonly<Record<Phase, string>> = {
302 think: '◐',
303 work: '●',
304 ask: '◆',
305 idle: '○',
306 done: '✓',
307 fail: '✗',
308}
309
310/** The café as plain text, for a surface that draws no pane. */
311export function textOf(cafe: Cafe, words: Words, now: number): string {
312 const head = `${words.title} — ${words.tally(cafe.busy, cafe.asking, cafe.idle)}`
313 const body = linesOf(cafe, words, now).map(line =>
314 line.type === 'table'
315 ? line.text
316 : `${line.indent}${MARKS[line.phase]} ${line.label}${line.note} — ${line.says} · ${line.tail}`,
317 )
318 return [head, ...body].join('\n')
319}
320
321/**
322 * Everything the picture depends on, as one string: two views with the same
323 * signature draw the same picture, so the last one drawn can stand.
324 */
325export function signatureOf(cafe: Cafe): string {
326 const tables = cafe.tables.map(table =>
327 [
328 table.id,
329 table.title,
330 table.isHere ? 1 : 0,
331 table.hidden,
332 ...table.places.map(place =>
333 [place.key, place.label, place.seat.kind, place.seat.phase, place.seat.act, place.seat.tool, place.says, place.seat.task, place.isViewed ? 1 : 0].join('\u001f'),
334 ),
335 ].join('\u001e'),
336 )
337 return [cafe.busy, cafe.asking, cafe.idle, cafe.hiddenTables, ...tables].join('\u001d')
338}
339hooks/wire.ts 428 lines1// What the café sends to its web page, and how the page reads what it is sent.
2//
3// Two reporters write it. The hooks module sends its whole table now and then
4// (a snap). scripts/report.mjs, which Claude Code runs for each hook event
5// wherever the hooks module does not run (a task in the Claude app, say),
6// sends each event as it happens, trimmed here to what the café shows: a
7// tool's name, one short word for what it works on, an agent's type and its
8// task's few words. Never a prompt, a file's content, a command's arguments or
9// what a tool answered.
10//
11// The server reads both and trusts neither: every field is checked and cut
12// to a length here.
13//
14// Pure: no `$` here and nothing of Node's, so a session, the reporter, the
15// server and a test all run the same code.
16
17import type { Act } from './describe'
18import { actOfCall, clip, folderName, label, targetOf, tidy } from './describe'
19import type { Snap } from './view'
20import { parseSnap } from './view'
21
22/** The app a session runs in, as well as its reporter can tell; `''` when it cannot. */
23export type App = 'claude' | 'code' | 'cloud' | 'cli' | 'vscode' | 'sdk' | ''
24
25const APPS: readonly App[] = ['claude', 'code', 'cloud', 'cli', 'vscode', 'sdk', '']
26
27/** An agent the main loop left at work in the background, as its Stop event lists it. */
28export type Bg = { id: string; type: string; task: string; isRunning: boolean }
29
30/** What an Agent call asks for: the agent's type, its task's few words, and its name. */
31export type Order = { type: string; task: string; name: string }
32
33/**
34 * One hook event as the café keeps it. `agent` is the subagent whose loop the
35 * event comes from, `''` for the main loop.
36 */
37export type Ev =
38 | { e: 'start'; source: string }
39 | { e: 'prompt'; pid: string }
40 /** `bg` is what Stop lists still at work; null when the event did not say. */
41 | { e: 'stop'; pid: string; hasFailed: boolean; bg: Bg[] | null }
42 /** `type` is the calling agent's type (`''` for the main loop). */
43 | { e: 'pre'; agent: string; type: string; id: string; tool: string; act: Act; target: string; order: Order | null }
44 | { e: 'post'; agent: string; id: string; child: string }
45 | { e: 'batch'; agent: string; ids: string[] }
46 | { e: 'ask'; agent: string; tool: string; target: string }
47 | { e: 'denied'; agent: string; id: string }
48 | { e: 'sub'; agent: string; type: string }
49 | { e: 'subend'; agent: string; hasFailed: boolean }
50 | { e: 'idle'; name: string }
51 | { e: 'elicit'; agent: string }
52 | { e: 'elicited'; agent: string }
53 | { e: 'end'; reason: string }
54 /** The main loop has answered and waits for the person (a turn that was interrupted has no Stop). */
55 | { e: 'waiting' }
56
57/** A whole table, from the hooks module. */
58export type SnapPost = { v: 1; kind: 'snap'; app: App; link: string; snap: Snap }
59/** A session is over: its table goes. */
60export type ByePost = { v: 1; kind: 'bye'; id: string }
61/** One hook event of one session, from scripts/report.mjs. */
62export type EventPost = { v: 1; kind: 'event'; sid: string; app: App; title: string; link: string; ev: Ev }
63
64export type Post = SnapPost | ByePost | EventPost
65
66/** The most ids one batch carries, and the most background agents one stop lists. */
67const MAX_IDS = 64
68const MAX_BG = 32
69
70type Fields = Readonly<Record<string, unknown>>
71
72function isRecord(value: unknown): value is Fields {
73 return typeof value === 'object' && value !== null && !Array.isArray(value)
74}
75
76function fieldsOf(value: unknown): Fields {
77 return isRecord(value) ? value : {}
78}
79
80/** Text fit to show, cut to `max` widths. */
81function word(value: unknown, max: number): string {
82 return typeof value === 'string' ? clip(tidy(value), max) : ''
83}
84
85/**
86 * An id as the engine makes them (a session's, an agent's, a tool call's, a
87 * prompt's); `''` for anything else. Ids are never shown, only matched, and
88 * this keeps out whatever is no id at all.
89 */
90export function idOf(value: unknown): string {
91 return typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,80}$/.test(value) ? value : ''
92}
93
94function oneOf<T extends string>(value: unknown, among: readonly T[], fallback: T): T {
95 return among.find(item => item === value) ?? fallback
96}
97
98/** A link to a session on claude.ai, the one kind of link the page shows; `''` for anything else. */
99export function sessionLink(value: unknown): string {
100 return typeof value === 'string' && /^https:\/\/claude\.ai\/code\/session_[A-Za-z0-9]{8,64}$/.test(value) ? value : ''
101}
102
103function isAgentTool(tool: string): boolean {
104 return tool === 'Agent' || tool === 'Task'
105}
106
107/** The words Stop uses for a background task that is over. */
108const OVER: ReadonlySet<string> = new Set(['completed', 'failed', 'killed', 'stopped', 'cancelled', 'canceled', 'error'])
109
110/** What Stop lists still at work; null when it lists nothing at all (an older Claude Code). */
111function backgroundOf(value: unknown): Bg[] | null {
112 if (!Array.isArray(value)) return null
113 const out: Bg[] = []
114 for (const item of value.slice(0, MAX_BG)) {
115 const task = fieldsOf(item)
116 const id = idOf(task['id'])
117 // Stop lists background shells too: only agents take a seat.
118 if (id === '' || (task['type'] !== undefined && task['type'] !== 'subagent')) continue
119 out.push({
120 id,
121 type: label(String(task['agent_type'] ?? ''), 40),
122 task: label(String(task['description'] ?? ''), 80),
123 // Any word but one for an end: an agent Stop lists as still there is.
124 isRunning: !OVER.has(String(task['status'] ?? '')),
125 })
126 }
127 return out
128}
129
130/**
131 * One hook event's input, as Claude Code hands it to a hook on stdin, trimmed
132 * to what the café shows; undefined for an event the café has no use for.
133 *
134 * This runs in the session, before anything leaves it.
135 */
136export function eventOf(input: unknown): Ev | undefined {
137 const fields = fieldsOf(input)
138 const agent = idOf(fields['agent_id'])
139 const tool = clip(tidy(String(fields['tool_name'] ?? '')), 80)
140 const toolInput = fieldsOf(fields['tool_input'])
141 switch (fields['hook_event_name']) {
142 case 'SessionStart':
143 return { e: 'start', source: word(fields['source'], 16) }
144 case 'UserPromptSubmit':
145 return { e: 'prompt', pid: idOf(fields['prompt_id']) }
146 case 'Stop':
147 return { e: 'stop', pid: idOf(fields['prompt_id']), hasFailed: false, bg: backgroundOf(fields['background_tasks']) }
148 case 'StopFailure':
149 // A subagent's turn that failed ends the subagent; the main loop's ends
150 // its turn, and says nothing of the agents it left at work.
151 if (agent !== '') return { e: 'subend', agent, hasFailed: true }
152 return { e: 'stop', pid: idOf(fields['prompt_id']), hasFailed: true, bg: null }
153 case 'PreToolUse': {
154 const id = idOf(fields['tool_use_id'])
155 if (id === '' || tool === '') return undefined
156 const order = isAgentTool(tool)
157 ? {
158 type: label(String(toolInput['subagent_type'] ?? ''), 40),
159 task: label(String(toolInput['description'] ?? ''), 80),
160 name: label(String(toolInput['name'] ?? ''), 40),
161 }
162 : null
163 const type = agent === '' ? '' : label(String(fields['agent_type'] ?? ''), 40)
164 return { e: 'pre', agent, type, id, tool, act: actOfCall(tool, toolInput), target: targetOf(tool, toolInput), order }
165 }
166 case 'PostToolUse':
167 case 'PostToolUseFailure': {
168 const id = idOf(fields['tool_use_id'])
169 if (id === '') return undefined
170 // An Agent call answers with the id of the agent it started: only that is read of it.
171 const child = isAgentTool(tool) ? idOf(fieldsOf(fields['tool_response'])['agentId']) : ''
172 return { e: 'post', agent, id, child }
173 }
174 case 'PostToolBatch': {
175 const calls = Array.isArray(fields['tool_calls']) ? fields['tool_calls'] : []
176 const ids = calls
177 .slice(0, MAX_IDS)
178 .map(call => idOf(fieldsOf(call)['tool_use_id']))
179 .filter(id => id !== '')
180 return { e: 'batch', agent, ids }
181 }
182 case 'PermissionRequest':
183 return { e: 'ask', agent, tool, target: targetOf(tool, toolInput) }
184 case 'PermissionDenied': {
185 const id = idOf(fields['tool_use_id'])
186 return id === '' ? undefined : { e: 'denied', agent, id }
187 }
188 case 'SubagentStart':
189 return agent === '' ? undefined : { e: 'sub', agent, type: label(String(fields['agent_type'] ?? ''), 40) }
190 case 'SubagentStop':
191 return agent === '' ? undefined : { e: 'subend', agent, hasFailed: false }
192 case 'TeammateIdle': {
193 const name = label(String(fields['teammate_name'] ?? ''), 40)
194 return name === '' ? undefined : { e: 'idle', name }
195 }
196 case 'Elicitation':
197 return { e: 'elicit', agent }
198 case 'ElicitationResult':
199 return { e: 'elicited', agent }
200 case 'SessionEnd':
201 return { e: 'end', reason: word(fields['reason'], 24) }
202 case 'Notification':
203 return fields['notification_type'] === 'idle_prompt' ? { e: 'waiting' } : undefined
204 default:
205 return undefined
206 }
207}
208
209/** Where reports go, and the key that lets them in. */
210export type Inbox = { url: string; key: string }
211
212/**
213 * The inbox of the café's web page at `base` (the page's address, as the
214 * person set it up); undefined when either is missing or the address is not
215 * one a report may go to: https, or http to this very machine.
216 */
217export function inboxOf(base: unknown, key: unknown): Inbox | undefined {
218 if (typeof base !== 'string' || typeof key !== 'string') return undefined
219 const address = base.trim()
220 const secret = key.trim()
221 if (address === '' || secret === '' || !URL.canParse(address)) return undefined
222 const url = new URL(address)
223 const isHere = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]'
224 if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isHere)) return undefined
225 if (url.username !== '' || url.password !== '') return undefined
226 url.search = ''
227 url.hash = ''
228 if (!url.pathname.endsWith('/')) url.pathname += '/'
229 return { url: new URL('in', url).toString(), key: secret }
230}
231
232/**
233 * True when the plugin's settings name the web page: `web_url` is filled in
234 * (as hooks.json checks before it runs the reporter). web.json is then not
235 * read, and the key must come from `web_key`.
236 */
237export function isWebSet(url: unknown): boolean {
238 return typeof url === 'string' && url.trim() !== ''
239}
240
241/** The inbox web.json names, as `{ "url": "https://…", "key": "…" }`. */
242export function inboxOfFile(config: unknown): Inbox | undefined {
243 if (typeof config !== 'object' || config === null) return undefined
244 const fields = config as Readonly<Record<string, unknown>>
245 return inboxOf(fields['url'], fields['key'])
246}
247
248/** The app a hook runs in, from the environment Claude Code gives it. */
249export function appOf(env: Readonly<Record<string, string | undefined>>): App {
250 const entry = env['CLAUDE_CODE_ENTRYPOINT'] ?? ''
251 if (entry === 'remote_cowork' || env['CLAUDE_CODE_SESSION_ORIGIN'] === 'claude_ai_chat') return 'claude'
252 if (env['CLAUDE_CODE_REMOTE'] === 'true') return 'cloud'
253 if (entry.includes('desktop')) return 'code'
254 if (entry.includes('vscode')) return 'vscode'
255 if (entry === 'cli') return 'cli'
256 if (entry.startsWith('sdk')) return 'sdk'
257 return ''
258}
259
260/** The claude.ai link to a session that runs in the cloud; `''` for one that does not. */
261export function linkOf(env: Readonly<Record<string, string | undefined>>): string {
262 const remote = env['CLAUDE_CODE_REMOTE_SESSION_ID'] ?? ''
263 const found = /^cse_([A-Za-z0-9]{8,64})$/.exec(remote)
264 return found === null ? '' : sessionLink(`https://claude.ai/code/session_${found[1]}`)
265}
266
267/**
268 * A session's table name: its folder's name. Not its title, which is written
269 * from the person's first prompt. A task in the Claude app runs in a
270 * workspace whose folder says nothing of it: it has no name here.
271 */
272export function titleFor(app: App, cwd: unknown): string {
273 if (app === 'claude') return ''
274 return typeof cwd === 'string' ? folderName(cwd) : ''
275}
276
277/**
278 * The report of one hook event: the event trimmed (eventOf), and which
279 * session of which app it comes from. Undefined for an event the café has no
280 * use for, or one that names no session.
281 *
282 * @param input the hook's input, as Claude Code hands it over on stdin
283 * @param env the environment Claude Code runs the hook in
284 */
285export function eventPostOf(input: unknown, env: Readonly<Record<string, string | undefined>>): EventPost | undefined {
286 const ev = eventOf(input)
287 if (ev === undefined) return undefined
288 const fields = fieldsOf(input)
289 const sid = idOf(fields['session_id']) || idOf(env['CLAUDE_CODE_SESSION_ID'])
290 if (sid === '') return undefined
291 const app = appOf(env)
292 return { v: 1, kind: 'event', sid, app, title: titleFor(app, fields['cwd']), link: linkOf(env), ev }
293}
294
295function parseBg(value: unknown): Bg[] {
296 if (!Array.isArray(value)) return []
297 const out: Bg[] = []
298 for (const item of value.slice(0, MAX_BG)) {
299 const fields = fieldsOf(item)
300 const id = idOf(fields['id'])
301 if (id === '') continue
302 out.push({ id, type: word(fields['type'], 40), task: word(fields['task'], 80), isRunning: fields['isRunning'] === true })
303 }
304 return out
305}
306
307function parseOrder(value: unknown): Order | null {
308 if (!isRecord(value)) return null
309 return { type: word(value['type'], 40), task: word(value['task'], 80), name: word(value['name'], 40) }
310}
311
312const ACTS: readonly Act[] = [
313 'read',
314 'search',
315 'write',
316 'run',
317 'web',
318 'delegate',
319 'plan',
320 'ask',
321 'tool',
322 'think',
323 'rest',
324 'done',
325 'fail',
326]
327
328/** Reads one event of a post; undefined for one of a shape the café does not know. */
329export function parseEv(value: unknown): Ev | undefined {
330 if (!isRecord(value)) return undefined
331 const agent = idOf(value['agent'])
332 switch (value['e']) {
333 case 'start':
334 return { e: 'start', source: word(value['source'], 16) }
335 case 'prompt':
336 return { e: 'prompt', pid: idOf(value['pid']) }
337 case 'stop':
338 return {
339 e: 'stop',
340 pid: idOf(value['pid']),
341 hasFailed: value['hasFailed'] === true,
342 bg: Array.isArray(value['bg']) ? parseBg(value['bg']) : null,
343 }
344 case 'pre': {
345 const id = idOf(value['id'])
346 const tool = word(value['tool'], 80)
347 if (id === '' || tool === '') return undefined
348 return {
349 e: 'pre',
350 agent,
351 type: agent === '' ? '' : word(value['type'], 40),
352 id,
353 tool,
354 act: oneOf(value['act'], ACTS, 'tool'),
355 target: word(value['target'], 60),
356 order: parseOrder(value['order']),
357 }
358 }
359 case 'post': {
360 const id = idOf(value['id'])
361 return id === '' ? undefined : { e: 'post', agent, id, child: idOf(value['child']) }
362 }
363 case 'batch': {
364 const ids = Array.isArray(value['ids']) ? value['ids'].slice(0, MAX_IDS).map(idOf).filter(id => id !== '') : []
365 return { e: 'batch', agent, ids }
366 }
367 case 'ask':
368 return { e: 'ask', agent, tool: word(value['tool'], 80), target: word(value['target'], 60) }
369 case 'denied': {
370 const id = idOf(value['id'])
371 return id === '' ? undefined : { e: 'denied', agent, id }
372 }
373 case 'sub':
374 return agent === '' ? undefined : { e: 'sub', agent, type: word(value['type'], 40) }
375 case 'subend':
376 return agent === '' ? undefined : { e: 'subend', agent, hasFailed: value['hasFailed'] === true }
377 case 'idle': {
378 const name = word(value['name'], 40)
379 return name === '' ? undefined : { e: 'idle', name }
380 }
381 case 'elicit':
382 return { e: 'elicit', agent }
383 case 'elicited':
384 return { e: 'elicited', agent }
385 case 'end':
386 return { e: 'end', reason: word(value['reason'], 24) }
387 case 'waiting':
388 return { e: 'waiting' }
389 default:
390 return undefined
391 }
392}
393
394/**
395 * Reads what a reporter posted. Anything of another shape is no post, and
396 * every string is cut to a length, whatever the reporter sent.
397 */
398export function parsePost(value: unknown): Post | undefined {
399 if (!isRecord(value) || value['v'] !== 1) return undefined
400 switch (value['kind']) {
401 case 'snap': {
402 const snap = parseSnap(value['snap'])
403 if (snap === undefined) return undefined
404 return { v: 1, kind: 'snap', app: oneOf(value['app'], APPS, ''), link: sessionLink(value['link']), snap }
405 }
406 case 'bye': {
407 const id = idOf(value['id'])
408 return id === '' ? undefined : { v: 1, kind: 'bye', id }
409 }
410 case 'event': {
411 const sid = idOf(value['sid'])
412 const ev = parseEv(value['ev'])
413 if (sid === '' || ev === undefined) return undefined
414 return {
415 v: 1,
416 kind: 'event',
417 sid,
418 app: oneOf(value['app'], APPS, ''),
419 title: word(value['title'], 40),
420 link: sessionLink(value['link']),
421 ev,
422 }
423 }
424 default:
425 return undefined
426 }
427}
428hooks/sprites.ts 323 lines1// The café's pixel art: each sprite a small grid of characters, one character
2// a color, drawn as one SVG path per color, so a whole seat costs about three
3// thousand characters of markup rather than a rectangle per pixel.
4//
5// Pure: no `$` here. A grid's unit is one logical pixel; the scene scales it.
6
7/** A sprite: rows of equal length, `.` for a pixel left clear. */
8export type Grid = readonly string[]
9
10/** Which color each character of a grid stands for. */
11export type Inks = Readonly<Record<string, string>>
12
13/** The outline every sprite shares. */
14export const INK = '#2b2320'
15
16/**
17 * One path's `d` for every pixel of `char` in the grid, as rectangles (a run
18 * along a row, taken down every row that repeats it), offset by (`dx`, `dy`);
19 * the empty string when the grid has none.
20 */
21export function runs(grid: Grid, char: string, dx = 0, dy = 0): string {
22 const width = grid.reduce((most, row) => Math.max(most, row.length), 0)
23 const taken = new Uint8Array(width * grid.length)
24 const isFree = (x: number, y: number): boolean => grid[y]?.[x] === char && taken[y * width + x] === 0
25 let d = ''
26 for (let y = 0; y < grid.length; y += 1) {
27 for (let x = 0; x < width; x += 1) {
28 if (!isFree(x, y)) continue
29 let end = x
30 while (end < width && isFree(end, y)) end += 1
31 let bottom = y + 1
32 for (; bottom < grid.length; bottom += 1) {
33 let isWhole = true
34 for (let column = x; column < end && isWhole; column += 1) isWhole = isFree(column, bottom)
35 if (!isWhole) break
36 }
37 for (let row = y; row < bottom; row += 1) taken.fill(1, row * width + x, row * width + end)
38 d += `M${x + dx} ${y + dy}h${end - x}v${bottom - y}h${x - end}z`
39 x = end - 1
40 }
41 }
42 return d
43}
44
45/** A grid as paths, one per color the inks name, in the inks' order. */
46export function paint(grid: Grid, inks: Inks, dx = 0, dy = 0): string {
47 let out = ''
48 for (const [char, color] of Object.entries(inks)) {
49 const d = runs(grid, char, dx, dy)
50 if (d !== '') out += `<path fill="${color}" d="${d}"/>`
51 }
52 return out
53}
54
55/** A filled rectangle of whole pixels. */
56export function box(x: number, y: number, width: number, height: number, fill: string, extra = ''): string {
57 return `<rect x="${x}" y="${y}" width="${width}" height="${height}" fill="${fill}"${extra}/>`
58}
59
60// ---------------------------------------------------------------------------
61// People. A head and shoulders seen from the front, 16 wide and 20 tall: two
62// rows of headroom for a bun or a cap, the head, and the chest down to where
63// the desk hides the rest.
64// o outline H hair S skin B blush E eye M mouth
65// T top c collar
66
67const HEAD_SHORT: Grid = [
68 '................',
69 '................',
70 '....oooooooo....',
71 '...oHHHHHHHHo...',
72 '..oHHHHHHHHHHo..',
73 '.oHHHHHHHHHHHHo.',
74 '.oHHSSSSSSSSHHo.',
75 '.oHSSSSSSSSSSHo.',
76 '.oHSSESSSSESSHo.',
77 '.oHSSESSSSESSHo.',
78 '.oSBSSSSSSSSBSo.',
79 '..oSSSSMMSSSSo..',
80 '...oSSSSSSSSo...',
81]
82
83const HEAD_FRINGE: Grid = [
84 '................',
85 '................',
86 '....oooooooo....',
87 '...oHHHHHHHHo...',
88 '..oHHHHHHHHHHo..',
89 '.oHHHHHHHHHHHHo.',
90 '.oHHHHHHHSSHHHo.',
91 '.oHHHHSSSSSSHHo.',
92 '.oHSSESSSSESSHo.',
93 '.oHSSESSSSESSHo.',
94 '.oSBSSSSSSSSBSo.',
95 '..oSSSSMMSSSSo..',
96 '...oSSSSSSSSo...',
97]
98
99const HEAD_LONG: Grid = [
100 '................',
101 '................',
102 '....oooooooo....',
103 '...oHHHHHHHHo...',
104 '..oHHHHHHHHHHo..',
105 '.oHHHHHHHHHHHHo.',
106 '.oHHSSSSSSSSHHo.',
107 '.oHSSSSSSSSSSHo.',
108 '.oHSSESSSSESSHo.',
109 '.oHSSESSSSESSHo.',
110 '.oHBSSSSSSSSBHo.',
111 '.oHSSSSMMSSSSHo.',
112 '.oHHSSSSSSSSHHo.',
113]
114
115const HEAD_BUN: Grid = [
116 '......oooo......',
117 '.....oHHHHo.....',
118 '....ooHHHHoo....',
119 '...oHHHHHHHHo...',
120 '..oHHHHHHHHHHo..',
121 '.oHHHHHHHHHHHHo.',
122 '.oHHSSSSSSSSHHo.',
123 '.oHSSSSSSSSSSHo.',
124 '.oHSSESSSSESSHo.',
125 '.oHSSESSSSESSHo.',
126 '.oSBSSSSSSSSBSo.',
127 '..oSSSSMMSSSSo..',
128 '...oSSSSSSSSo...',
129]
130
131const HEAD_CAP: Grid = [
132 '................',
133 '................',
134 '....oooooooo....',
135 '...oCCCCCCCCo...',
136 '..oCCCCCCCCCCo..',
137 '.oCCCCCCCCCCCCoo',
138 '.oooooooooooooKo',
139 '.oHSSSSSSSSSSHoo',
140 '.oHSSESSSSESSHo.',
141 '.oHSSESSSSESSHo.',
142 '.oSBSSSSSSSSBSo.',
143 '..oSSSSMMSSSSo..',
144 '...oSSSSSSSSo...',
145]
146
147/** The heads, by style. A long style's hair reaches the shoulders. */
148export const HEADS: readonly Grid[] = [HEAD_SHORT, HEAD_FRINGE, HEAD_LONG, HEAD_BUN, HEAD_CAP]
149
150/** The chest, under any head: rows 13 to 19 of the 16 by 20 grid. */
151export const CHEST: Grid = [
152 '....ooSSSSoo....',
153 '..ooTTccccTToo..',
154 '.oTTTTTTTTTTTTo.',
155 '.oTTTTTTTTTTTTo.',
156 '.oTTTTTTTTTTTTo.',
157 '.oTTTTTTTTTTTTo.',
158 '.oTTTTTTTTTTTTo.',
159]
160
161/** Where the chest starts in the person grid. */
162export const CHEST_ROW = 13
163
164/** Eyes shut and smiling, over rows 8 and 9: what a seat that is done wears. */
165export const EYES_HAPPY: Grid = ['...E.E....E.E...', '....E......E....']
166
167/** Eyes shut tight, over rows 8 and 9: what a seat that failed wears. */
168export const EYES_SHUT: Grid = ['................', '...EEE....EEE...']
169
170export const SKINS: readonly string[] = ['#f6d3b3', '#eebd94', '#c98f65', '#8f5d3f']
171export const HAIRS: readonly string[] = ['#2b2320', '#4a2f1d', '#7a4a26', '#b5651d', '#d9b45b', '#8f8f9a', '#6b4fa3']
172export const TOPS: readonly string[] = [
173 '#4f86c6',
174 '#58a55c',
175 '#c9534a',
176 '#8e6bbf',
177 '#3aa7a3',
178 '#d27aa8',
179 '#6b7c93',
180 '#e0a03a',
181]
182export const CAPS: readonly string[] = ['#c9534a', '#3f6fb0', '#3d8f5a', '#e0a03a']
183
184/** How one person looks: picked once from the agent's id, the same every draw. */
185export type Look = {
186 head: number
187 skin: string
188 hair: string
189 top: string
190 collar: string
191 cap: string
192}
193
194/** The main loop's look, the same in every session: Claude's own orange. */
195export const MAIN_LOOK: Look = {
196 head: 1,
197 skin: '#f6d3b3',
198 hair: '#4a2f1d',
199 top: '#d97757',
200 collar: '#f0a58c',
201 cap: '#d97757',
202}
203
204/** A small stable hash of a string (FNV-1a), for picking a look. */
205export function hash(text: string): number {
206 let value = 0x811c9dc5
207 for (let index = 0; index < text.length; index += 1) {
208 value ^= text.charCodeAt(index)
209 value = Math.imul(value, 0x01000193)
210 }
211 return value >>> 0
212}
213
214function pick<T>(items: readonly T[], seed: number, fallback: T): T {
215 return items[seed % items.length] ?? fallback
216}
217
218/** The look of the agent with this id. */
219export function lookOf(id: string): Look {
220 const seed = hash(id)
221 const top = pick(TOPS, seed >>> 3, '#4f86c6')
222 return {
223 head: (seed >>> 1) % HEADS.length,
224 skin: pick(SKINS, seed >>> 7, '#f6d3b3'),
225 hair: pick(HAIRS, seed >>> 11, '#2b2320'),
226 top,
227 collar: lighten(top),
228 cap: pick(CAPS, seed >>> 17, '#c9534a'),
229 }
230}
231
232/** A color a third of the way to white. */
233export function lighten(hex: string): string {
234 const value = Number.parseInt(hex.slice(1), 16)
235 const mix = (channel: number): number => Math.round(channel + (255 - channel) / 3)
236 const red = mix((value >> 16) & 255)
237 const green = mix((value >> 8) & 255)
238 const blue = mix(value & 255)
239 return '#' + ((red << 16) | (green << 8) | blue).toString(16).padStart(6, '0')
240}
241
242/** A color a quarter of the way to black. */
243export function darken(hex: string): string {
244 const value = Number.parseInt(hex.slice(1), 16)
245 const mix = (channel: number): number => Math.round(channel * 0.75)
246 const red = mix((value >> 16) & 255)
247 const green = mix((value >> 8) & 255)
248 const blue = mix(value & 255)
249 return '#' + ((red << 16) | (green << 8) | blue).toString(16).padStart(6, '0')
250}
251
252/** The inks of a person's grid for a look. */
253export function inksOf(look: Look): Inks {
254 return {
255 o: INK,
256 H: look.hair,
257 C: look.cap,
258 K: darken(look.cap),
259 S: look.skin,
260 B: blushOf(look.skin),
261 E: INK,
262 M: '#b5574f',
263 T: look.top,
264 c: look.collar,
265 }
266}
267
268/** A touch redder than the skin, so the cheeks read on every tone. */
269export function blushOf(skin: string): string {
270 const value = Number.parseInt(skin.slice(1), 16)
271 const red = Math.min(255, ((value >> 16) & 255) + 6)
272 const green = Math.round(((value >> 8) & 255) * 0.82)
273 const blue = Math.round((value & 255) * 0.82)
274 return '#' + ((red << 16) | (green << 8) | blue).toString(16).padStart(6, '0')
275}
276
277// ---------------------------------------------------------------------------
278// Bubble icons, 7 by 7. `#` is the icon's own ink, `a` its accent.
279
280export type IconName =
281 | 'pencil'
282 | 'shell'
283 | 'globe'
284 | 'gear'
285 | 'people'
286 | 'book'
287 | 'lens'
288 | 'list'
289 | 'bang'
290 | 'query'
291 | 'check'
292 | 'cross'
293
294export const ICONS: Readonly<Record<IconName, Grid>> = {
295 pencil: ['.....aa', '....a#a', '...###.', '..###..', '.###...', '###....', '##.....'],
296 shell: ['.......', '#......', '.#.....', '..#....', '.#.....', '#..aaa.', '.......'],
297 globe: ['..###..', '.#a#a#.', '#aa#aa#', '#######', '#aa#aa#', '.#a#a#.', '..###..'],
298 gear: ['.#.#.#.', '#######', '.##a##.', '##aaa##', '.##a##.', '#######', '.#.#.#.'],
299 people: ['.##....', '.##..aa', '####.aa', '####aaa', '####aaa', '....aaa', '.......'],
300 book: ['.......', '###.###', '#aa#aa#', '#aa#aa#', '#aa#aa#', '###.###', '.......'],
301 lens: ['.###...', '#aaa#..', '#aaa#..', '#aaa#..', '.###...', '....##.', '.....##'],
302 list: ['a.####.', '.......', 'a.####.', '.......', 'a.###..', '.......', 'a.####.'],
303 bang: ['..###..', '..###..', '..###..', '..###..', '.......', '..###..', '..###..'],
304 query: ['.####..', '##..##.', '....##.', '...##..', '..##...', '.......', '..##...'],
305 check: ['.......', '......#', '.....##', '#...##.', '##.##..', '.###...', '..#....'],
306 cross: ['#.....#', '##...##', '.##.##.', '..###..', '.##.##.', '##...##', '#.....#'],
307}
308
309// ---------------------------------------------------------------------------
310// The café's cat, asleep on the counter: 13 by 6.
311// o outline F fur f fur shade p paw and ear pink
312
313export const CAT: Grid = [
314 '.o.o.........',
315 'oFoFo..ooooo.',
316 'oFFFFooFFFFFo',
317 'oFfFFFFFFFFFo',
318 '.oFFFFFFFFFFo',
319 '..ooooooooooo',
320]
321
322export const CAT_INKS: Inks = { o: INK, F: '#e9a34f', f: '#c47f33' }
323