Agent Kitchen has two halves that share one brain:
flowchart TB
subgraph Machine["Your machine"]
direction LR
CLI["Copilot CLI"] --> SS
APP["GitHub Copilot app"] --> SS
SDK["SDK clients"] --> SS
SS[("~/.copilot/session-state/<uuid>/<br/>events.jsonl · workspace.yaml · inuse.<pid>.lock")]
end
subgraph Server["Node server (server/)"]
W["CopilotWatcher<br/>watcher.js"] --> T["FileTailer<br/>tail.js"]
W --> N1["KitchenState<br/>shared/normalize.js"]
N1 --> H["HTTP + SSE<br/>server.js"]
end
subgraph Browser["Browser (public/)"]
S["Store"] --> D["Director"]
S --> HUD["HUD · Panels · Overlay"]
D --> K["three.js Kitchen"]
DEMO["DemoShift"] --> N2["KitchenState<br/>(same normalizer)"] --> S
end
SS -. read-only .-> W
H -- "snapshot, update (SSE)" --> S
public/shared/holds the code both halves run: the station catalog/classifier (stations.js) and the event normalizer (normalize.js). It's plain ESM with no Node or DOM APIs.- In live mode the server normalizes events and the browser only renders.
- In demo mode the browser generates synthetic Copilot events and runs them through the very same normalizer, so the demo exercises the real pipeline (and works from a static file server).
The Copilot CLI, the GitHub Copilot app (which runs the CLI under the hood) and SDK clients all keep per-session state in ~/.copilot/session-state/<session-uuid>/ (or $COPILOT_HOME/session-state):
| File | Used for |
|---|---|
events.jsonl |
Append-only event log; one JSON object per line |
workspace.yaml |
Session name, summary, repository, branch, cwd, client (github/autopilot = app, github/cli = CLI) |
inuse.<pid>.lock |
Written when a Copilot process opens the session; removed on a clean exit, left behind after a crash |
CopilotWatcher (server/watcher.js):
- Scans the folder every 3 s. A session is loaded if its
events.jsonlchanged since--since(default: midnight) or its lock file names a live process. Older sessions are remembered by mtime and loaded the moment they change (e.g. a resumed session). - Replays a newly tracked file (up to the last 64 MB) silently to rebuild state — tickets, stats, current tool — without animating history.
- Polls tracked files every 500 ms.
FileTailerreads only the new bytes, keeps partial trailing lines (and split UTF-8 characters) for the next read, and restarts if a file shrinks. - Checks liveness (
server/liveness.js): a chef is on shift while its session has a live lock, or for a 5-minute grace period after its last event. A lock is live only if a process with that PID exists and started before the lock was written. A crashed process leaves its lock behind and the OS eventually hands the PID to some unrelated program, whichprocess.kill(pid, 0)alone can't tell apart. Start times come from one batched lookup per new lock (PowerShellGet-Processon Windows,/procon Linux,pson macOS) and are cached; if they can't be looked up, the watcher falls back to trusting the PID. When the process is gone, in-flight tools and open tickets are cleared and the chef clocks out. - Limits idle clutter (
KitchenState.isVisible): a live chef whose derived status isidleleaves the visible roster after--idleminutes (60 by default). Working, thinking and waiting chefs remain visible regardless of age, and any new event or session resume refresheslastEventAtso the chef immediately returns.--idle 0disables this limit.
Events are filtered before parsing: Copilot writes {"type":"… first, so wantsLine() skips the large lines the kitchen doesn't need (model snapshots, hooks, usage records) without JSON.parse. A typical startup (8 sessions, tens of MB) takes ~0.4 s.
The watcher never writes anything to the state folder.
SessionTracker.ingest(event) (public/shared/normalize.js) is a small state machine per session. It keeps:
orders— open tickets (one peruser.message/interactionId)tools— in-flight tool calls bytoolCallIdsubagents— active sub-agents byagentIdstats— served, cancelled, coins, steps, fires, steps per station (only events aftercountFromcount)recent— the last 30 steps with ✓/✗, for the Brigade panel
and returns effects (fx) for the renderer:
| Raw event | State change | Effect |
|---|---|---|
session.start / session.resume |
metadata (repo, branch, model) | — |
user.message |
open an order (steering messages reuse the running interaction's id and join its ticket) | order |
assistant.turn_start |
in a turn; remembers the active interactionId and opens an implicit "Follow-up" order if it has none |
order (implicit) |
assistant.message |
remember whether it requested tools | say (first sentence) |
assistant.turn_end |
if the last message had no tool requests → the active interaction finished | served (coins) |
tool.execution_start |
add tool; classify station; add station to the order's ingredients | tool |
tool.execution_complete |
remove tool; failure = success:false or non-zero shellExecution.exitCode / completed with exit code N |
tool-end, fire |
subagent.started / completed / failed |
sous-chef joins / leaves | sub-start, sub-end |
abort |
cancel the active order (−10 coins) | failed |
session.error |
fire at the chef's current station | fire |
permission.requested / completed |
chef waits at the order bell | — |
session.compaction_start / complete |
chef "tidies up" at the sink | say |
session.task_complete |
serve the active order (autopilot) | served |
session.shutdown |
chef clocks out | — |
Events from sub-agents carry a top-level agentId; their tools are tracked separately so they move the sous-chef, not the head chef, and don't add ingredients to the head chef's ticket.
Tickets follow interactions. Steps are credited to the active interaction's ticket, and serving or cancelling closes only that ticket, so a prompt queued behind a running interaction keeps its own. Older tickets that never got an ending of their own are cleared without coins. Sessions without interaction ids (older Copilot versions) fall back to closing every open ticket.
Status is derived, not stored: offline (process gone / shut down) → waiting (ask_user or a permission prompt) → working (a tool is running, a sub-agent is active, or compacting) → thinking (in a turn, between tools) → idle.
Coins: 20 per served order plus a speed tip (+8 under a minute, +6 under 3, +4 under 10, +2 under 30). Cancelled orders cost 10; the total never goes below zero.
classifyTool(name, args) (public/shared/stations.js) returns { station, label }:
- Known tools map directly (
view→ read,edit→ code,web_fetch→ research,ask_user→ ask,task_complete→ ship, …). apply_patchis parsed for*** Add/Update/Delete File:lines; delete-only patches go to the bin.- MCP tools (
server-tool_name) default to research; names likecreate_pull_request,push_files,send_*ship,delete_*bins,search_codesearches. - Shell commands (
powershell,bash, …) are split into segments on&&,||,;,|and newlines. Each segment is tokenized, prefixes likesudo,npx -y,cmd /c,$x =and env assignments are stripped, and the command is matched by tool family:gitsub-commands,ghareas,npm/pnpm/yarn/bunscripts (test:*,build,lint…),python -m,cargo,go,dotnet,make/gradle/mvntargets,docker,kubectl,terraform, linters, test runners, and generic file/search/network commands. The highest-priority segment wins (ship > commit > test > build > lint > install > trash > research > search > read > shell). If nothing matched, the tool's humandescriptionis used as a hint ("Run unit tests" → test). - Follow-ups (
read_powershell,write_powershell, …) inherit the station of the shell they refer to, so waiting on a long build keeps the chef at the rice cooker.
To add a station: add it to STATIONS, map tools/commands to it, place a letter for it in MAP (public/js/scene/layout.js), and write a builder in stationProps*.js. test/layout.test.js checks that every catalog station exists in the kitchen and is reachable.
world.js— renderer, ACES tone mapping, soft shadows, and a fixed 56° "diorama" camera that binary-searches its distance so the kitchen always fits the window (leaving room for the HUD).layout.js— the floor plan as an ASCII map, tile ↔ world conversion, station access tiles (where a chef stands, facing which way), spare "overflow" spots, idle spots, and A* pathfinding with line-of-sight smoothing.kitchen.js,environment.js,stationProps*.js,build.js— everything is built from primitives (rounded boxes, cylinders, spheres) with procedural canvas textures: plank floors, shoji walls, crate lids with painted ingredients, conveyor belts. Counters are instanced. Each station has ananimate()that reacts toactivity(0–1, eased) — lids open, knives chop, pots rattle and steam, baskets dip into bubbling oil, water runs, plates assemble.chef.js— the chef model (body, head, toque or bandana, floating hands, feet, blob shadow) and a pose function for walking and for each station action (chop, stir, fry, wash, grab, read, plate, serve, bin, wait, extinguish), plus moods (happy, sad, panic, sleep).fx.js— two GPU point-sprite layers (soft + additive glow) for steam, smoke, fire, embers, bubbles, water, foam, dust, sparkles and poofs.
The director turns session state + effects into chef behaviour:
- Each
tooleffect becomes a task in the chef's queue (consecutive tools at the same station merge). Each task has a minimum dwell time so quick steps (a 50 ms file read) stay visible, but the queue is capped at 3 and older finished steps are dropped first, so chefs never fall far behind real time. Chefs dash when they have a backlog. - Starting a task reserves a free spot at the nearest matching station (or an overflow spot beside it), so chefs don't stand inside each other. Burning stations are avoided.
- When nothing is queued, the chef follows the ambient state: the station of the tool that's still running, the order bell when waiting on you, the recipe book after ~3 s of thinking, or an idle spot (falling asleep after 25 s).
- Special tasks: a
fireeffect makes the chef panic, grab the extinguisher and spray foam at the station; aservedeffect turns the pending ship step into a hand-off at the serving hatch (dish slides down the conveyor, sparkles, +coins). - Sous-chefs spawn at the door for
subagent.started, run their own queue from tools tagged with theiragentId, and walk out aftersubagent.completed.
HTML on top of the canvas: order tickets, coin counter, clock, ticker, Brigade, Menu, Settings, and 3D-pinned labels (station names, cooking progress bars, fire alerts, chef name tags, speech bubbles, emotes, coin pop-ups). All untrusted text (prompts, file names, session names) is escaped before it reaches innerHTML. Privacy mode swaps prompts, session names, branch/folder names, file names and chatter for generic labels.
| Route | |
|---|---|
GET / |
the app (public/) |
GET /api/state |
JSON snapshot: visible sessions + totals |
GET /api/stream |
Server-Sent Events: snapshot on connect, then update { sessions, removed, fx, totals } as things change, plus a keep-alive comment every 15 s |
GET /api/health |
readiness and counts |
Security: binds 127.0.0.1; rejects non-local Host headers (DNS rebinding); GET/HEAD only; static files are confined to public/ (path traversal is rejected); strict CSP, nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer.
npm testtest/stations.test.js— the tool and shell classifier (100+ real-world commands).test/normalize.test.js— the session state machine: full orders, fires, background shells, sub-agents, waiting, aborts, liveness, stats windows.test/server-units.test.js— tailer (partial lines, split UTF-8, truncation), YAML, CLI options, host checks, path traversal, browser launch.test/liveness.test.js— lock liveness: recycled PIDs, caching, fallback, and start-time parsing for Windows, Linux and macOS (plus a real lookup on the current OS).test/integration.test.js— a real watcher + HTTP server against a temp state folder: discovery, tailing, renames, clock-out, crashed sessions with recycled PIDs, SSE snapshot/update.test/layout.test.js— every station is in the kitchen and reachable; idle spots don't block stations.
Visual checks use scripts/shot.js (headless Edge/Chrome over the DevTools protocol, zero dependencies), which fails on any page error; npm run screenshots drives a full demo shift and captures the docs images.