SLOPSHOPPER

Agent Café

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…

paneguardcommandnetworktimeragents
v0.3.0MITupdated 2026-10-14qteddie/agent_cafe
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-cafe
│ ┃ Agent Café ✕ › fix the failing auth test and add an audit log call │ ┃ Agent Café 0 busy · 0 need you · 1 idle │ ┃ ○ main Waiting for you 0s ⏺ Read(src/auth.ts) │ ┃ ◐ thinking ● working ◆ needs you ○ idle / ⎿ Read 6 lines │ ┃ done ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /cafe │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent Café
Agent Café 0 busy · 0 need you · 1 idle ○ main Waiting for you 0s ◐ thinking ● working ◆ needs you ○ idle / done
README

Agent Café ☕

繁體中文

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

The web page: a full-screen 3D café with five sessions at work

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.

Install

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.

Try it without installing

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.

Is it loaded?

  • /cafe appears in the list when you type /.
  • Or, without opening a session, print the café as text: claude -p "/cafe text".
  • If it is not loaded, check your version first. An older Claude Code usually says nothing; there is just no /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 |

The pane

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

The web page: every session in a 3D café

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

What happens in the café

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.

  • Look around: drag to turn, scroll to zoom; on a phone, one finger turns and two zoom. Whole room (bottom right) brings back the full view.
  • Click a person to open their card (top left): their table, what they are doing, for how long, and how many tool calls. A Claude app task's card has an Open link to the task. Click the floor or press Esc to close it.
  • Cats and dogs: 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.
  • Play with them: point at one to see its name. Click it: it wakes up, jumps for joy, says Woof! or Meow~ with a heart, and a pink ring shows it is picked. Then click the floor:
  • a dog: you throw a ball there. It runs after it, brings it back toward you (the camera), and sits wagging its tail.
  • a cat: you call it over. Cats do not always come: about one time in three you get only ….
  • Clicking someone else, Esc, or 45 seconds of ignoring it lets it go.
  • Music: ♪ 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).
  • With reduced motion set in your system, people do not walk: they appear where they are going.

Which sessions show up

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

1. Run the server

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

  1. Make two different keys, 16 characters or more each (the server does not start otherwise; 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
  1. Fill them in and start it, in the repository's 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
  1. Open http://localhost:8080 and enter 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):

  1. In the Cloudflare dashboard (Networking → Tunnels), create a tunnel and pick Docker. It shows a command like 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.
  2. Start the page with the tunnel: docker compose --profile tunnel up -d --build.
  3. In the tunnel's Routes → Add route → Published application, give your hostname (cafe.example.com, say) the Service URL http://cafe:8080. Two things are easy to get wrong there:
  4. cafe, not localhost: in the tunnel's container, localhost is the tunnel itself. cafe is the page's container.
  5. 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.
  6. Open 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).

2. Point your sessions at it

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.

3. Claude app tasks (optional)

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:

  1. Upload the plugin to your Claude account, with the page's address and key inside. Write the 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.

  1. Let tasks reach your domain. A task's network allows only some sites (GitHub, npm and the like) by default; others get a 403. In Claude's Settings → Capabilities, allow your domain in code execution's network access (add it to the allowed domains, or, if there is only a switch, allow all domains). On a Team or Enterprise plan, an admin changes this in the organization's settings. It applies to new tasks. To check, ask Claude in a new task to run 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.

Check

curl -s https://cafe.example.com/healthz        # ok

If it does not say ok:

  • 502: the tunnel is up but cannot find the server. The service URL is most likely not http://cafe:8080 (see step 1).
  • Cloudflare error 1033: the tunnel is not connected. 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.

What the page is sent, shows and keeps

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.

Security

  • Two separate keys: the reporting key (in the plugin's settings, in 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.
  • Wrong passwords: after 8 from one address in 15 minutes, or 40 from everyone together, the page stops taking sign-ins for a while (browsers already signed in carry on). The server tells addresses apart behind the Cloudflare tunnel only; behind your own proxy, every visitor counts as one address.
  • Everything a session reports goes on the page as text, never as markup (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.

Known limits of the web page

  • Claude app tasks have no "ended" event: a task you close stays at its table as Waiting for you, and leaves after 3 hours without news.
  • Tables built from hooks are put together by the server: the events are sent one by one and at once, and sometimes arrive out of order. The server puts late ones back in place; one that is truly lost (the server restarting at that moment, say) is usually set right when the main agent's next turn ends.
  • When two subagents of the same type start at the same moment, their descriptions may be swapped until each finishes.
  • A turn you interrupt with Esc sends no end event: the main agent shows as busy until Claude Code's "waiting for input" notice, about a minute later.
  • The hooks reporter (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.
  • Teammates (agent teams) show as ordinary subagents, and may be taken for done when the main agent's turn ends.
  • 3D needs WebGL: with hardware acceleration off or no WebGL, the page only says it cannot draw the café. There is no flat fallback (the pixel-art café is still in the pane).
  • Up to 9 tables of 11 people are drawn; more only count in the numbers at the top right.
  • The page is in English; add ?lang=zh-TW to its address for Traditional Chinese (it is remembered).

Settings

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

What it shows and stores

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:

  • Files: the name and one folder up (hooks/scene.ts). Never the contents; nor a name that looks like a key.
  • Searches: the search string, unless it looks like a key or a token.
  • Web addresses: the domain only (example.com), no path or query.
  • Shell commands: the description the model wrote for the command (unless it looks like it holds a key); with none, only the program, plus fixed subcommands of tools like git and npm (git push …). Never any arguments (a make target counts as one).
  • Subagents: their type, name, and the few words of description they were given (unless it looks like it holds a key). Never the prompt.
  • Questions for you: their short header only.
  • Other tools: to-do titles, a skill's name, who a message goes to, a workflow's title; for MCP tools, the server's and the tool's names only.
  • Tool results and your conversation: never touched.

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.

Known limits of the pane

  • "Needs your OK" stays a moment after you approve: Claude
Source 8 files
hooks/register.tsx 843 lines
1// 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}
843
hooks/describe.ts 418 lines
1// 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}
418
hooks/i18n.ts 281 lines
1// 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}
281
hooks/scene.ts 607 lines
1// 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, '&amp;')
130    .replace(/</g, '&lt;')
131    .replace(/>/g, '&gt;')
132    .replace(/"/g, '&quot;')
133    .replace(/'/g, '&#39;')
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}
607
hooks/tracker.ts 610 lines
1// 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}
610
hooks/view.ts 339 lines
1// 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}
339
hooks/wire.ts 428 lines
1// 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}
428
hooks/sprites.ts 323 lines
1// 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