# Technical Documentation: pablodelucca/pixel-agents

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/pablodelucca/pixel-agents)

### Overview (`README.md`)

<h1 align="center">
  <a href="https://github.com/pixel-agents-hq/pixel-agents/discussions">
    <img src="webview-ui/public/banner.png" alt="Pixel Agents">
  </a>
</h1>

<h2 align="center">The most playful way to orchestrate your agents</h2>

<div align="center">

[](https://github.com/pixel-agents-hq/pixel-agents/releases)
[](https://marketplace.visualstudio.com/items?itemName=pablodelucca.pixel-agents)
[](https://www.npmjs.com/package/pixel-agents)
[](https://github.com/pixel-agents-hq/pixel-agents/stargazers)
[](https://github.com/pixel-agents-hq/pixel-agents/blob/main/LICENSE)
[](https://discord.gg/Yk7jXebv9H)

</div>

<div align="center">
<a href="https://marketplace.visualstudio.com/items?itemName=pablodelucca.pixel-agents">🛒 VS Code Marketplace</a> • <a href="https://open-vsx.org/extension/pablodelucca/pixel-agents">🛒 Open VSX</a> • <a href="https://www.npmjs.com/package/pixel-agents">📦 npm</a> • <a href="https://discord.gg/Yk7jXebv9H">👾 Discord</a> • <a href="https://github.com/pixel-agents-hq/pixel-agents/discussions">💬 Discussions</a> • <a href="CONTRIBUTING.md">🤝 Contributing</a> • <a href="CHANGELOG.md">📋 Changelog</a>
</div>

<br/>

Pixel Agents turns the AI coding agents running in your terminals into animated pixel-art characters working in a tiny office. They walk to their desks, sit down, type when they're editing files, read when they're searching, and flag you visually when they're stuck waiting for input.

It ships in two forms from the same codebase:

- **VS Code extension** — [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=pablodelucca.pixel-agents) and [Open VSX](https://open-vsx.org/extension/pablodelucca/pixel-agents). Agents launch into VS Code terminals; characters render in the panel area.
- **Standalone CLI** — `npx pixel-agents` starts a local server and serves the same office as a browser app, useful for tmux, remote, and non-VS Code workflows.

The architecture is fully agent-agnostic and editor-agnostic: a typed `HookProvider` interface defines the integration boundary so adding a new AI tool is a single subdirectory of code. Claude Code is the reference implementation today; Codex, Gemini, Cursor, and others are on the roadmap.



## Features

- **One agent, one character** — every Claude Code terminal gets its own animated character
- **Live activity tracking** — characters animate based on what the agent is actually doing (writing, reading, running commands)
- **Office layout editor** — design your office with floors, walls, and furniture using a built-in editor
- **Speech bubbles** — visual indicators when an agent is waiting for input or awaiting permission
- **Sound notifications** — optional chimes when an agent finishes its turn or requests permission
- **Sub-agents and Agent Teams** — see ephemeral sub-agents and persistent Claude teammates as separate characters, including team roles and lifecycle changes
- **Persistent layouts** — your office design is saved and shared across VS Code windows
- **Shared layout and assets** — import/export layouts and load external character, pet, and furniture packs
- **Areas** — paint named areas onto the office, map workspace folders to them, and new agents sit inside the areas mapped to their folder
- **Diverse characters** — 6 diverse characters. These are based on the amazing work of [JIK-A-4, Metro City](https://jik-a-4.itch.io/metrocity-free-topdown-character-pack).

<p align="center">
  <img src="webview-ui/public/characters.png" alt="Pixel Agents characters" width="320" height="72" style="image-rendering: pixelated;">
</p>

## Where This Is Going

The vision is: play a game, build a product. Two goals follow from it: to build a familiar, intuitive interface for running and orchestrating a lot of agents; and to make the hours you spend doing it feel less like administration and more like play.

Roughly three stages get there:

1. **Everywhere, with everything.** Today it's Claude Code in VS Code or the browser. It should be whatever agent you run, wherever you work. A new CLI is a subdirectory, not a rewrite — this is where help is most useful right now.
2. **Actually a game.** Health bars for rate limits and token budgets. Scores for whatever you care about. Furniture that _does_ things. Offices you open like save files, one per project.
3. **Expand the orchestration frontier.** Orchestrator characters. Form a team by dragging a box around them. Hand work between agents. Point them at a board and let them pick up tasks themselves.

Most of this is still ahead. See [Issues](https://github.com/pixel-agents-hq/pixel-agents/issues) and [Discussions](https://github.com/pixel-agents-hq/pixel-agents/discussions) for what's open, and [CONTRIBUTING.md](CONTRIBUTING.md) to jump in.

## Requirements

- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured
- **VS Code extension:** VS Code 1.105.0 or later
- **Standalone CLI:** Node.js 20 or later
- Windows, Linux, or macOS

## Getting Started

### VS Code extension

1. Install Pixel Agents from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=pablodelucca.pixel-agents) or [Open VSX](https://open-vsx.org/extension/pablodelucca/pixel-agents).
2. Open the **Pixel Agents** panel beside the terminal.
3. Click **+ Agent** to open its drawer of **Directories**, then click one to launch Claude Code in it. Your workspace folders are listed there once you turn on **Settings → Show Workspace Folders**; **+ Directory** adds your own (a name, a path, and optionally the Areas its agents should sit in), and the pencil on a row you added edits or deletes it.

To use Claude with `--dangerously-skip-permissions`, turn on **Settings → Skip Permissions**. It stays on for every agent launched from that host until you turn it off. Only use this when you accept the security implications.

Pixel Agents also detects Claude sessions started outside the extension. Turn on **Settings → Watch All Sessions** to include sessions from other workspaces.

### Standalone CLI

Run Pixel Agents from the workspace whose Claude sessions you want to see:

```bash
cd /path/to/your/project
npx pixel-agents
```

The CLI chooses a free local port and prints the URL. Click **+ Agent** to open the Directory drawer and click a Directory to launch Claude Code in it; the agent's terminal opens in the browser. **+ Directory** adds your own, and **Settings → Show Start Directory** lists the directory the CLI was started from. On a phone, tap the **+** card to open the same drawer and tap a Directory to launch (long-press a Directory you added to edit it). Claude Code started in your own terminal is picked up too. To install the command globally instead:

```bash
npm install pixel-agents
pixel-agents
```

Use a fixed address or port when needed:

```bash
pixel-agents --port 3100
pixel-agents --host 127.0.0.1 --port 3100
pixel-agents --no-terminal
pixel-agents --help
```

The default bind address is `127.0.0.1`. Binding to `0.0.0.0` exposes the UI and WebSocket to the local network; do this only on a trusted network.

Open the URL the CLI prints - it carries a `?token=` for this server. Any browser can watch the office without it, but installing or removing hooks (which edits your agent tool's own settings file, like the `~/.claude/settings.json`), launching agents from the browser and attaching to their terminals are only offered to a session that has the token, so an untokened client on the network cannot approve or do any of it. Open the bare address instead and the hooks toggle in Settings is refused (reporting the actual install state rather than appearing to work) and the **+ Agent** button explains what it needs. Standalone keeps the same token across restarts (in `~/.pixel-agents/standalone-token`, readable only by you), so a bookmarked or home-screen URL keeps working — as long as the port stays the same too, which takes `--port` (the default port is picked fresh on every start).

Treat that URL as a secret: the token is a bearer capability, not proof of being local. Whoever holds it can approve the hook install from anywhere the server is reachable — so don't paste the URL into a shared channel, and note that it also lands in your browser history (the server's own request log redacts it).

Pass `--no-terminal` to disable the embedded terminal — watch agents without launching or attaching to them from the browser.

Copying from the embedded terminal is verbatim: Cmd+C (Mac), Ctrl+C or Ctrl+Shift+C with a selection (Windows, Linux), the browser's Copy, and the **Copy** button on a phone copy exactly what is on screen. Claude Code wraps long prose into lines indented two spaces; to copy such a passage as flowing text instead, select it and press **Alt+C** (**Option+C** on a Mac), or tap **Copy as text** on a phone. On a phone opened over plain `http://` (a LAN address), the paste key asks for the text in a prompt, since the page cannot read the clipboard there.

### Running the extension and standalone together

The extension and standalone CLI can run at the same time. Each server registers under `~/.pixel-agents/servers/`; the hook script sends events to all active registrations. VS Code and standalone keep separate agents, seats, and settings while using the shared office layout.

Stop a standalone server with **Ctrl+C**. It removes only its own registration.

## Customizing the Office

Click **Layout** to edit the office:

- Paint floor patterns and walls, with color and contrast controls.
- Place, rotate, recolor, select, and remove furniture.
- Paint auto-tiling carpets and customize their main and accent colors.
- Add animated pets; click a pet in the office to interact with it.
- Create named **Areas**, paint their tiles, and assign Directories to them (agents launched in a Directory prefer seats in its Areas).
- Undo/redo changes, then import or export the complete layout as JSON.

Layouts can grow to 64×64 tiles by clicking the ghost border outside the current grid.

### Office assets

Bundled furniture, floors, walls, carpets, characters, and pets live under `webview-ui/public/assets/`. Furniture manifests describe sprites, rotation groups, state groups, and animation frames.

Use **Settings → Add Asset Directory** to load external characters, pets, and furniture. See [docs/external-assets.md](docs/external-assets.md) for furniture directory structure and manifest details. The visual asset manager at `scripts/asset-manager.html` helps create furniture manifests.

## How It Works

Pixel Agents uses two Claude Code detection paths:

- **Hooks mode** (default) — a hook script receives Claude events such as `SessionStart`, `PreToolUse`, `PermissionRequest`, and `Stop`. It discovers active Pixel Agents servers and sends authenticated events to each one.
- **Heuristic mode** (fallback) — when hooks are unavailable, the runtime infers agent status by scanning Claude's JSONL session transcripts under `~/.claude/projects/`. Transcripts are also read in hooks mode for details not present in an event.

The Claude provider normalizes both sources into a shared `AgentEvent` model. `AgentRuntime` updates the central state store, and the active transport sends typed messages to the React webview. The office renders through Canvas 2D with pathfinding and character state machines.

Pixel Agents does not modify Claude Code. Its hook configuration and persistent data live under `~/.claude/` and `~/.pixel-agents/` respectively.

### Architecture

- **`core/`** — provider, adapter, transport, schema, and AsyncAPI message contracts with no runtime side effects.
- **`server/`** — shared Fastify server, agent runtime, persistence, Claude provider, transcript scanning, and standalone CLI.
- **`adapters/vscode/`** — the VS Code adapter: terminal, persistence, and webview bridge.
- **`webview-ui/`** — React 19, Vite, Canvas 2D, and adapter-specific transports for VS Code and browser WebSocket clients.

The extension and CLI are bundled with esbuild; the webview is built with Vite. Unit tests use Vitest and Node's test runner, and end-to-end coverage uses Playwright against VS Code and standalone.

## Development

```bash
git clone https://github.com/pixel-agents-hq/pixel-agents.git
cd pixel-agents
npm install
npm run build
```

Press **F5** in VS Code to launch the Extension Development Host. To run the standalone bundle built from source:

```bash
node dist/cli.js
```

Common checks:

```bash
npm run check-types
npm run lint
npm test
npm run e2e
```

See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow and [e2e/README.md](e2e/README.md) for the end-to-end suite.

### Hosted Test Reports

Build the combined Allure report locally and stage it for Vercel:

```bash
npm run test
npm run e2e
npm run e2e -- --attach-videos-on-success
npm run vercel:prepare
```

Use `npm run test:report` to build the combined report without preparing the Vercel output, then `npm run test:report:open` to serve it locally.

The staged output serves the combined `e2e`, `server`, and `webview` Allure report at `/reports/allure/`; it does not include a standalone webview preview. GitHub Actions creates a Vercel Preview deployment only for same-repository pull requests targeting `main`. The deploy job expects `VERCEL_TOKEN`, `VERCEL_ORG_ID`, and `VERCEL_PROJECT_ID` secrets and skips fork pull requests.

## Troubleshooting

- **Standalone will not start:** verify Node.js 20+, omit `--port` to choose a free port, or select another fixed port.
- **An agent is missing:** confirm **Settings → Instant Detection (Hooks)** is on and that the session belongs to the current workspace. Enable **Watch All Sessions** if needed.
- **The UI looks disconnected:** open **Settings → Debug View** to inspect the server connection, transcript path, and latest agent data.
- **Extension and standalone are both running:** this is supported. Current versions create separate files under `~/.pixel-agents/servers/`; stopping one does not remove the other.

## Community & Contributing

Join the [Discord](https://discord.gg/Yk7jXebv9H) to chat with other users and follow development. Use [Issues](https://github.com/pixel-agents-hq/pixel-agents/issues) to report bugs or request features, and [Discussions](https://github.com/pixel-agents-hq/pixel-agents/discussions) for questions and ideas.

See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request and read our [Code of Conduct](CODE_OF_CONDUCT.md) before participating.

## Supporting the Project

<a href="https://github.com/sponsors/pablodelucca">
  <img src="https://img.shields.io/badge/Sponsor-GitHub-ea4aaa?logo=github" alt="GitHub Sponsors">
</a>
<a href="https://ko-fi.com/pablodelucca">
  <img src="https://img.shields.io/badge/Support-Ko--fi-ff5e5b?logo=ko-fi" alt="Ko-fi">
</a>

## Star History

<a href="https://www.star-history.com/?repos=pixel-agents-hq%2Fpixel-agents&type=date&legend=bottom-right">
 <picture>
   <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=pixel-agents-hq/pixel-agents&type=date&theme=dark&legend=bottom-right&sealed_token=Vn3YGMuZ_HFZAf56zIUQGCBJDYtDq38sOReKlcxWklxR_ilwVLynb7CPraf5uPhnAU7fwHXXoO88tzLkq9tpEYIExl4N8tcXOmu0ehAXPu5DdXNwjixYsxb00LSfeJ25f_jLkcZcTpRKLKYOb9p4_dR1jjAyrWDs7aicdbqejaDtLcVyj-oSoKkBfrS5" />
   <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=pixel-agents-hq/pixel-agents&type=date&legend=bottom-right&sealed_token=Vn3YGMuZ_HFZAf56zIUQGCBJDYtDq38sOReKlcxWklxR_ilwVLynb7CPraf5uPhnAU7fwHXXoO88tzLkq9tpEYIExl4N8tcXOmu0ehAXPu5DdXNwjixYsxb00LSfeJ25f_jLkcZcTpRKLKYOb9p4_dR1jjAyrWDs7aicdbqejaDtLcVyj-oSoKkBfrS5" />
   <img alt="Star History Chart" src="https://api.star-history.com/chart?repos=pixel-agents-hq/pixel-agents&type=date&legend=bottom-right&sealed_token=Vn3YGMuZ_HFZAf56zIUQGCBJDYtDq38sOReKlcxWklxR_ilwVLynb7CPraf5uPhnAU7fwHXXoO88tzLkq9tpEYIExl4N8tcXOmu0ehAXPu5DdXNwjixYsxb00LSfeJ25f_jLkcZcTpRKLKYOb9p4_dR1jjAyrWDs7aicdbqejaDtLcVyj-oSoKkBfrS5" />
 </picture>
</a>

## License

Pixel Agents is available under the [MIT License](LICENSE).

---

### Overview (`e2e/README.md`)

# Pixel Agents e2e tests

Playwright end-to-end tests for the VS Code extension and the standalone `npx pixel-agents` server. This README is the single source of truth for what's e2e-tested, what's not, and how to run the suite.

## What this suite covers

Behavioral overview by area. Each area corresponds to a `test.describe` block in the spec files, an `@area:` tag on each test title, and an Allure `epic` label.

### Spawn paths (`@area:spawn`)

Agents being created and adopted. Covers internal terminals launched by clicking `+ Agent`, external Claude sessions adopted by the hook server or the JSONL scanner, basic Task subagent appearance/despawn, and lead+teammate routing for inline and tmux team modes.

### Lifecycle regressions (`@area:lifecycle`)

Edge cases that historically caused agent-character desync: `/clear`, `--resume`, X-button close, dismissal cooldown, parallel sub-agents, teammate add/remove, rapid `/clear` followed by a new tool, late resume after stale cleanup.

### Cross-cutting checks (`@area:cross-cutting`)

Invariants that should hold across every spawn path: tool status text matches the active tool name, sound chimes fire on the right events, restored agents skip the matrix spawn animation, hook installer preserves third-party hooks, settings persist across webview reload, sub-agent permission timer fires, layout editor enter/paint/save/exit smoke.

### Teams routing (`@area:teams`)

Lead and teammate tool routing in both inline and tmux team modes, internal and external.

### Hooks-off matrix (`@area:matrix`)

Every spawn permutation (internal vs external origin × basic vs inline-teammate vs tmux-teammate mode) re-verified against the heuristic JSONL-polling path with the hook server disabled. Confirms the polling-based detection produces the same agent state as the hook-driven path.

### Standalone server (`@area:standalone`)

The `npx pixel-agents` CLI path: hook-driven lifecycle propagates from the local server into the browser SPA via the single `/ws` WebSocket endpoint.

### Pet system (`@area:pets`)

The animated pets feature, which has no hook dependency. Pet sprites load and the `petSpritesLoaded` broadcast arrives with manifest display names; placing a pet from the Pets-tab carousel toggles it on/off and persists across a panel reload via `~/.pixel-agents/layout.json`; clicking a pet shows a heart bubble that auto-dismisses and dismisses again on re-click. Pets render only on the canvas, so live state is read through the `getPets` / `petClick` e2e test hooks. FSM internals, pathfinding, FOLLOW, z-sort, and legacy-layout migration are covered by webview unit tests, not e2e.

### Layout editor (`@area:editor`)

Erase semantics and stroke-based undo in the layout editor. The erase tool clears a tile to VOID **and** deletes any furniture whose footprint the stroke passes through (left-click and right-drag entry points both covered, plus the negative case where an item away from the stroke survives). Undo is stroke-scoped: one click-drag of floor paint, wall paint, or erase collapses to a single undo entry, and a mouse-up splits consecutive strokes into separate entries. Tiles render only on the canvas, so state is read through the `getTiles` / `getFurniture` e2e test hooks; stroke boundaries go through a real canvas `mouseup` rather than a hook, since "every edit collapses into one undo entry" is the regression being guarded. Carpet's equivalent stroke-undo test lives in `@area:carpet`.

### Mobile shell (`@area:mobile`)

The standalone page on a phone-sized touch viewport (390×844, `hasTouch`): the mobile shell renders instead of the desktop toolbar; the + card launches an agent into the server PTY and slides to its terminal; the >_ / Office toggle, the two-step card tap (focus, then open), and the edge swipe in both directions switch pages; tapping a URL in the terminal opens it. Taps use Playwright's real touch input; the edge swipe dispatches the TouchEvent sequence of a finger drag, since `touchscreen` has no multi-step drag. The gesture state machines (edge swipe, terminal touch, card order) are unit-tested on the webview Node runner. That the VS Code webview never renders the mobile shell is pinned by the VS Code fixture itself, which waits for the desktop toolbar's `+ Agent` button.

## What's NOT covered (gaps + deferred)

Scenarios that exist as product behavior but are not in the automated suite. PRs that close a gap should remove the corresponding row.

| Scenario                                                                                 | Why not automated                                                                                                                            | Tracked                      |
| ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| Multi-window `layout.json` cross-sync                                                    | Needs two VS Code instances simultaneously; fixture work                                                                                     | none                         |
| External asset directory add/remove via Settings                                         | Needs bundled test asset packs                                                                                                               | none                         |
| Bypass-permissions startup flag                                                          | Security-sensitive; manual review path                                                                                                       | none                         |
| Workspace folder add/remove mid-session                                                  | Edge case; infra-heavy                                                                                                                       | none                         |
| Heuristic-timer cancellation after **internal-terminal** agent close                     | VS Code terminal panel collapse races the canvas click on the X overlay; covered via the external-agent variant which dodges the layout race | external variant in suite    |
| Mobile key bar (esc, shift+tab, trackpad, paste) above the software keyboard             | Only shows while the iOS/Android keyboard shrinks the visual viewport; headless browsers have no software keyboard                           | manual (phone)               |
| Terminal long-press selection, copy pill, handles, flick scroll; canvas pinch zoom       | Multi-touch timing gestures; the decision logic is unit-tested (`touchGesture`, `edgeSwipe`), the device feel is not                         | manual (phone)               |
| Mobile card bar long-press reorder                                                       | Long-press + drag on a horizontal scroller; ordering rules unit-tested (`cardBar.test.ts`)                                                   | manual (phone)               |
| Producer/viewer relay scenarios (multi-viewer replay, producer reconnect reconciliation) | Producer endpoint not yet built                                                                                                              | `feat/producer-viewer-split` |

## Pre-release manual smoke (~30 min)

CI green on this suite is the safety net for behavioral regressions. The checks below are what e2e can't meaningfully assert on (visual polish, real-Claude integration, cross-process behaviors). Run them before tagging a Marketplace release — not on every PR.

**Visual + interactive polish** (after any change touching `renderer.ts`, `spriteCache.ts`, `colorize.ts`, `*.tsx`, CSS, or `editorActions.ts`):

- Pan around the office with middle-mouse drag — characters z-sort correctly against same-row chairs and lower-row desks, no flicker.
- Spawn 3+ agents — matrix spawn animation renders cleanly, characters move smoothly between seats.
- Open the Layout editor — paint floor with HSBC sliders, place + rotate (R) furniture, toggle on/off (T) state, drag-to-move in SELECT, multi-stage Esc unwinds correctly.
- Hover and click characters — overlay text positioning is correct, selection outline crisp, click on a seat reassigns.

**Real Claude Code integration** (mock-claude is a fixture; real Claude's JSONL has edge cases the mock doesn't):

- Launch the Extension Development Host (F5), click + Agent, ask Claude to do a few tool-heavy turns and a permission-requiring tool. Watch for character desync, missing animations, stuck permission bubbles.
- Use a session with a large pasted image (multi-MB base64 user message) — confirm the "Possible format issue" warning doesn't false-fire and tool tracking still works.
- Test with one MCP server installed — confirm `mcp_progress` records don't break tool status.

**`npx pixel-agents` standalone** (e2e covers Chrome via Playwright; verify other browsers + real workflow):

- `node dist/cli.js` (or `npx pixel-agents` after publish), open `http://localhost:3100` in Firefox AND Safari, run a real Claude session in a terminal — confirm characters appear and animate via WebSocket.
- Refresh the browser mid-session — WebSocketTransport reconnects, agents reappear from server state.

**Cross-window sync** (rarely covered by CI, easy to break):

- Open two VS Code windows. Edit the layout in one (paint a tile, save). Within ~2 s the other window picks it up.

**First-run experience** (before publishing):

- Delete `~/.pixel-agents/` entirely. Launch the extension fresh — default layout loads, first-run tooltip appears, no console errors, hooks auto-install on first agent spawn.

**Platform sanity** (CI hosts ≠ your machine):

- On the OS you primarily develop on, run a normal session for ~5 minutes — confirm no surprise CPU spikes, no leaked file watchers, panel reload doesn't lose state.

Skip the F5 matrix walk-through that used to take hours — the e2e suite covers it. Hand-driven testing now exists only to catch what automated assertions structurally can't see.

## Running

```bash
cd pixel-agents
npm run compile && npm run e2e               # full suite (~10 min)

npm run e2e -- --grep "@area:spawn"          # filter by area tag
npm run e2e -- --grep "@area:cross-cutting"
npm run e2e -- --headed                      # watch chromium for standalone test

npm run e2e:inventory                        # regenerate the inventory section below
npm run test:report                          # build the Allure dashboard from latest run
npm run test:report:open                     # serve + open the Allure dashboard in a browser
```

## Mocking model & rules

E2E tests drive Pixel Agents through a Claude-like **process boundary**, not by poking internals. The mocked `claude` (`e2e/fixtures/mock-claude` → `mock-claude-runner.cjs`) behaves like the real CLI for the parts Pixel Agents observes: it spawns as a process, creates its own append-only JSONL transcripts, and executes the installed hook script under `~/.pixel-agents/hooks` — the same path the real CLI uses. The builder API itself (`claudeScenario(...)`, `.at()`, `.appendJsonl()`, `.emitHook()`, `.holdOpenFor()`) is documented in CONTRIBUTING.md → "Mock claude".

Rules for a correct test:

- **Drive behavior through a scenario, not by hand.** Define timed actions with the `claudeScenario(...)` builder and let the mock perform them. Don't hand-write transcript files or hand-fire hooks inside a terminal-driven test body.
- **Transcripts are append-only.** Existing JSONL lines are never mutated in place; new records appear later in the stream. Scenarios model this with timed `.appendJsonl(...)` steps.
- **Assert only on Playwright-visible outcomes** — agent overlays, character state, sound hooks — never on the mock's internals. The mock never decides pass/fail.
- **Standalone is the one exception.** `standalone/hooks.spec.ts` has no VS Code terminal to host a mocked `claude`, so it POSTs to the server's hook endpoint directly via `sendHookEvent`. That is correct _only_ for the standalone-server path; every terminal-driven test must use the scenario builder.

## What to read before adding a test

- `pixel-agents/CLAUDE.md` — architecture and message protocol
- `pixel-agents/e2e/fixtures/pixel-agents.ts` — fixture lifecycle
- `pixel-agents/e2e/helpers/` — every helper, especially `hooks.ts`, `mock-claude.ts`, `office.ts`, `webview.ts`

When you add a new test:

- Pick a `test.describe` block that matches an existing `@area:` tag, OR add a new area to the "What this suite covers" section above and pick a tag.
- Add `@area:<tag>` to the test title.
- Add Allure `epic` / `feature` / `story` labels matching the area.
- Run `npm run e2e:inventory` and commit the regenerated section.

When you remove a test:

- Run `npm run e2e:inventory` so the inventory drops it.
- If the scenario it tested is now manual or deferred, add a row to "What's NOT covered".

## Narration

Every VS Code run video is narrated. The narrator writes one yellow `[test]` line per
action taken and per assertion verified to a per-test log, and several surfaces
display it by simply tailing that log:

```
narrate.step()/check()  ──►  <tmpHome>/.claude-mock/test-narration.log     (yellow [test])
external mock stdout     ──►  <tmpHome>/.claude-mock/external-narration.log (magenta [external·tag])

Surface A — the "e2e monitor" terminal: opened by the fixture after VS Code
            finishes restoring the review layout (openMonitorTerminal). Tails
            BOTH logs, so even a test with no agents has a narrated surface
            from its first action.
Surface B — every mock-claude terminal tab: the wrapper backgrounds a headerless
            tail of both logs into its own stdout, interleaved with the runner's
            cyan [mock-claude] lines. Whichever tab has focus, the full story
            shows. Because the tail starts at byte 0, a tab opened mid-test
            replays the whole story so far.
```

Standalone recordings are deliberately raw browser artifacts. Their fixture has
no VS Code terminal or narration surface, so the eight standalone videos are
outside this narration contract.

Usage: the `pixelAgents` fixture exposes a `narrator` on its payload — tests call
`narrator.step('…')` before an action and `narrator.check('…')` after an
assertion resolves. Shared helpers narrate universal moments (spawning/closing an
agent) via the module-level `narrate` in `helpers/test-narration.ts`.

**Cosmetic-only contract (never violate):** Pixel Agents never reads terminal
output — its inputs are JSONL transcripts and hook POSTs — so narration cannot
change what a test exercises. Narration must **never carry an assertion, gate
logic, or affect timing**. Deleting every `step`/`check` call must leave all
tests passing. Never call the narrator from inside a browser-context callback
(`frame.waitForFunction`/`evaluate`/`.poll`) — it is Node-side only. Never add a
`waitForTimeout` for narration's sake. After the test body is complete, the
fixture may wait for the final narration marker to render before closing the
recorded window; that teardown-only synchronization cannot gate test behavior.

## Test inventory

This section is auto-generated. Do not edit between the markers; CI fails on drift.



138 tests total. Generated by `scripts/generate-e2e-inventory.mjs`. Re-run after adding or removing tests.

### `@area:spawn` (5 tests)

- `e2e/claude/hooks-off/launch-drawer.spec.ts:40` — hovering + Agent opens a drawer with a badged, read-only row per workspace folder (Launch drawer (multi-root))
- `e2e/claude/hooks-off/launch-drawer.spec.ts:63` — tapping a drawer row launches an agent labeled with that Directory (Launch drawer (multi-root))
- `e2e/claude/hooks-off/launch-drawer.spec.ts:80` — a Directory defined in the office joins the drawer, launches, and survives a panel reload (Launch drawer (multi-root))
- `e2e/claude/hooks-on/basic.spec.ts:34` — internal terminal spawns agent and Task subagent appears then despawns (Hooks ON / spawn paths)
- `e2e/claude/hooks-on/basic.spec.ts:125` — external Claude session adopted via hook confirmation lifecycle (Hooks ON / spawn paths)

### `@area:lifecycle` (22 tests)

- `e2e/claude/hooks-off/lifecycle.spec.ts:59` — /clear on internal agent reassigns the same character via JSONL polling (Hooks OFF / lifecycle)
- `e2e/claude/hooks-off/lifecycle.spec.ts:137` — /resume at startup reassigns the same agent via JSONL polling (Hooks OFF / lifecycle)
- `e2e/claude/hooks-off/lifecycle.spec.ts:183` — /clear edge case with a sibling agent in the same projectDir via JSONL polling (Hooks OFF / lifecycle)
- `e2e/claude/hooks-off/lifecycle.spec.ts:268` — /clear retains its character after the terminal editor moves (Hooks OFF / lifecycle)
- `e2e/claude/hooks-off/lifecycle.spec.ts:346` — heuristic late --resume after stale cleanup prevents zombie agents (Hooks OFF / lifecycle)
- `e2e/claude/hooks-off/lifecycle.spec.ts:410` — three parallel Task subagents in one turn render distinct sub-characters via polling (Hooks OFF / lifecycle)
- `e2e/claude/hooks-off/lifecycle.spec.ts:478` — inline teammate removed from team config disappears within one second via polling (Hooks OFF / lifecycle)
- `e2e/claude/hooks-off/lifecycle.spec.ts:540` — rapid /clear then new tool within 500ms lands on the reassigned agent via polling (Hooks OFF / lifecycle)
- `e2e/claude/hooks-off/lifecycle.spec.ts:601` — close via X prevents re-adoption of old JSONL during dismissal cooldown via polling (Hooks OFF / lifecycle)
- `e2e/claude/hooks-off/lifecycle.spec.ts:675` — external basic subagent with run_in_background but no teamName routes to basic path (Hooks OFF / lifecycle)
- `e2e/claude/hooks-on/lifecycle.spec.ts:83` — /clear on internal agent reassigns the same character to the new JSONL (Hooks ON / lifecycle)
- `e2e/claude/hooks-on/lifecycle.spec.ts:145` — /resume reassigns the same agent within the grace window (Hooks ON / lifecycle)
- `e2e/claude/hooks-on/lifecycle.spec.ts:212` — /clear edge case with a sibling agent in the same projectDir (Hooks ON / lifecycle)
- `e2e/claude/hooks-on/lifecycle.spec.ts:303` — --resume after the grace window expires cleans up the old agent (Hooks ON / lifecycle)
- `e2e/claude/hooks-on/lifecycle.spec.ts:388` — three parallel Task subagents in one turn render distinct sub-characters (Hooks ON / lifecycle)
- `e2e/claude/hooks-on/lifecycle.spec.ts:457` — inline teammate removed from team config disappears within one second (Hooks ON / lifecycle)
- `e2e/claude/hooks-on/lifecycle.spec.ts:519` — lead SessionEnd cascade-removes active inline teammates (Hooks ON / lifecycle)
- `e2e/claude/hooks-on/lifecycle.spec.ts:605` — external basic subagent with run_in_background routes to basic path (Hooks ON / lifecycle)
- `e2e/claude/hooks-on/lifecycle.spec.ts:674` — lead permission_prompt routes bubble to teammate not lead when teammates exist (Hooks ON / lifecycle)
- `e2e/claude/hooks-on/lifecycle.spec.ts:763` — TeammateIdle marks only the targeted teammate done and leaves lead unchanged (Hooks ON / lifecycle)
- `e2e/claude/hooks-on/lifecycle.spec.ts:877` — rapid /clear then new tool within 500ms lands on the reassigned agent (Hooks ON / lifecycle)
- `e2e/claude/hooks-on/lifecycle.spec.ts:935` — close via X prevents re-adoption of old JSONL during dismissal cooldown (Hooks ON / lifecycle)

### `@area:cross-cutting` (24 tests)

- `e2e/claude/hooks-off/lifecycle.spec.ts:735` — agentToolsClear fires at turn end via turn_duration JSONL record (Hooks OFF / lifecycle)
- `e2e/claude/hooks-off/lifecycle.spec.ts:800` — heuristic permission timer is cancelled when an agent is closed via overlay (Hooks OFF / lifecycle)
- `e2e/claude/hooks-off/lifecycle.spec.ts:879` — sub-agent permission bubble fires on stalled non-exempt sub-tool via heuristic timer (Hooks OFF / lifecycle)
- `e2e/claude/hooks-on/consent.spec.ts:125` — fresh install: the Intro pages to the disclosure and Install writes the hooks (Hooks consent gate)
- `e2e/claude/hooks-on/consent.spec.ts:217` — Not Now writes nothing, continues the tour, and leaves consent ungranted (Hooks consent gate)
- `e2e/claude/hooks-on/consent.spec.ts:244` — Don't Ask Again writes nothing and persists hooks off (Hooks consent gate)
- `e2e/claude/hooks-on/consent.spec.ts:269` — the close x aborts the tour and writes nothing, exactly like Not Now (Hooks consent gate)
- `e2e/claude/hooks-on/consent.spec.ts:298` — Back from the closing step lets Don't Ask Again undo a landed install (Hooks consent gate)
- `e2e/claude/hooks-on/consent.spec.ts:334` — Back after Don't Ask Again lets Not Now bring the ask back (Hooks consent gate)
- `e2e/claude/hooks-on/consent.spec.ts:381` — a failed install is reported, and Not Now brings the ask back (Hooks consent gate › when settings.json cannot be parsed)
- `e2e/claude/hooks-on/consent.spec.ts:427` — clicking the office around the bubble neither answers nor dismisses (Hooks consent gate)
- `e2e/claude/hooks-on/consent.spec.ts:464` — a pre-consent 14-event install migrates to 12 with no prompt (Hooks consent gate / pre-consent install)
- `e2e/claude/hooks-on/consent.spec.ts:504` — Settings toggle removes the migrated hooks and keeps third-party entries (Hooks consent gate / pre-consent install)
- `e2e/claude/hooks-on/consent.spec.ts:555` — a failed uninstall does not persist hooks-off (Hooks consent gate / toggle-off failure)
- `e2e/claude/hooks-on/lifecycle.spec.ts:1034` — done sound chime fires on agentStatus waiting (Hooks ON / lifecycle)
- `e2e/claude/hooks-on/lifecycle.spec.ts:1127` — restored agents skip the matrix spawn animation (Hooks ON / lifecycle)
- `e2e/claude/hooks-on/lifecycle.spec.ts:1211` — tool status text matches every PreToolUse tool name (Hooks ON / lifecycle)
- `e2e/claude/hooks-on/lifecycle.spec.ts:1306` — permission sound chime fires on agentToolPermission (Hooks ON / lifecycle)
- `e2e/claude/hooks-on/lifecycle.spec.ts:1428` — pixel-agents hook is installed in settings.json on extension startup (Hooks ON / lifecycle)
- `e2e/claude/hooks-on/lifecycle.spec.ts:1450` — hook install and uninstall round-trip via the Settings toggle (Hooks ON / lifecycle)
- `e2e/claude/hooks-on/lifecycle.spec.ts:1504` — permission bubble auto-clears when a fresh PreToolUse arrives (Hooks ON / lifecycle)
- `e2e/claude/hooks-on/lifecycle.spec.ts:1578` — settings toggles persist across a webview reload (Hooks ON / lifecycle)
- `e2e/claude/hooks-on/lifecycle.spec.ts:1626` — layout editor enter paint save persist and exit round-trip (Hooks ON / lifecycle)
- `e2e/claude/hooks-on/lifecycle.spec.ts:1707` — hook uninstall preserves a pre-existing third-party hook entry (Hooks ON / lifecycle)

### `@area:teams` (7 tests)

- `e2e/claude/hooks-on/teams.spec.ts:64` — internal terminal lead with inline teammate routes tools to teammate (Hooks ON / teams)
- `e2e/claude/hooks-on/teams.spec.ts:121` — internal terminal lead with tmux teammate routes tools to teammate (Hooks ON / teams)
- `e2e/claude/hooks-on/teams.spec.ts:195` — new-harness background agent becomes a named teammate character (Hooks ON / teams)
- `e2e/claude/hooks-on/teams.spec.ts:287` — unnamed background spawn stays a sub-agent with live activity and survives Stop (Hooks ON / teams)
- `e2e/claude/hooks-on/teams.spec.ts:358` — named background spawn becomes a teammate and badges the spawner LEAD (Hooks ON / teams)
- `e2e/claude/hooks-on/teams.spec.ts:422` — external session lead with inline teammate routes tools to teammate (Hooks ON / teams)
- `e2e/claude/hooks-on/teams.spec.ts:494` — external session lead with tmux teammate routes tools to teammate (Hooks ON / teams)

### `@area:matrix` (6 tests)

- `e2e/claude/hooks-off/matrix.spec.ts:49` — internal basic spawn adopted via JSONL polling (Hooks OFF / matrix)
- `e2e/claude/hooks-off/matrix.spec.ts:87` — internal inline teammate adopted via JSONL polling (Hooks OFF / matrix)
- `e2e/claude/hooks-off/matrix.spec.ts:143` — internal tmux teammate adopted via JSONL polling (Hooks OFF / matrix)
- `e2e/claude/hooks-off/matrix.spec.ts:208` — external basic spawn adopted via JSONL polling (Hooks OFF / matrix)
- `e2e/claude/hooks-off/matrix.spec.ts:253` — external inline teammate adopted via JSONL polling (Hooks OFF / matrix)
- `e2e/claude/hooks-off/matrix.spec.ts:312` — external tmux teammate adopted via JSONL polling (Hooks OFF / matrix)

### `@area:standalone` (28 tests)

- `e2e/standalone/directory-modal.spec.ts:44` — Enter on Cancel cancels and Enter on an Area toggles it; neither saves (Standalone / Directory modal keyboard)
- `e2e/standalone/directory-modal.spec.ts:78` — Enter in the path field saves (Standalone / Directory modal keyboard)
- `e2e/standalone/directory-modal.spec.ts:99` — an unnamed Directory keeps its Area under the name the host gave it (Standalone / Directory Area mapping)
- `e2e/standalone/hooks.spec.ts:20` — propagates hook-driven lifecycle into the browser UI (Standalone / hooks)
- `e2e/standalone/hooks.spec.ts:137` — the tokened page shows the Intro and Install writes the hooks (Standalone / hooks consent)
- `e2e/standalone/hooks.spec.ts:164` — an untokened spectator page never sees the consent dialog (Standalone / hooks consent)
- `e2e/standalone/hooks.spec.ts:186` — an untokened spectator page shows no toolbar, only a View only label (Standalone / hooks consent)
- `e2e/standalone/hooks.spec.ts:213` — the hooks checkbox reflects install state and its click is the consent grant (Standalone / hooks consent)
- `e2e/standalone/launch-drawer.spec.ts:45` — the server start directory is a badged host row that launches an agent labeled with it (Standalone / launch drawer)
- `e2e/standalone/launch-drawer.spec.ts:97` — a Directory added in the modal launches an agent and is still there after a reload (Standalone / user-defined Directories)
- `e2e/standalone/launch-drawer.spec.ts:152` — deleting a Directory drops its row and leaves its running agent alone (Standalone / user-defined Directories)
- `e2e/standalone/launch-drawer.spec.ts:204` — an invalid path is refused inline and nothing is persisted (Standalone / Directory validation)
- `e2e/standalone/launch-drawer.spec.ts:253` — a past session offers its working directory as a tap that fills the path (Standalone / Directory suggestions)
- `e2e/standalone/launch-drawer.spec.ts:309` — the start directory is hidden by default and Show Start Directory lists it (Standalone / host Directory setting)
- `e2e/standalone/launch-drawer.spec.ts:341` — the skip-permissions setting lands on disk and survives a reload (Standalone / permission posture)
- `e2e/standalone/launch-drawer.spec.ts:377` — turning skip-permissions on in one office shows it on in another already open (Standalone / permission posture)
- `e2e/standalone/launch-drawer.spec.ts:432` — the drawer fits the viewport, its rows scroll, and + Directory stays reachable (Standalone / long Directory lists)
- `e2e/standalone/multi-server-hooks.spec.ts:31` — extension and standalone both stay hook-driven without cross-contamination (Standalone / multi-server hooks)
- `e2e/standalone/terminal.spec.ts:26` — browser-launched agent gets a PTY terminal, hooks route to its character, close cleans up (Standalone / terminal)
- `e2e/standalone/terminal.spec.ts:82` — every tab takes mouse input, and a narrow window keeps the cards and close reachable (Standalone / terminal)
- `e2e/standalone/terminal.spec.ts:117` — copies are verbatim; Alt+C copies the selection re-flowed as text (Standalone / terminal)
- `e2e/standalone/terminal.spec.ts:214` — --no-terminal keeps the office watch-only: launch disabled, hook-driven agents still render (Standalone / terminal disabled)
- `e2e/standalone/ui.spec.ts:27` — closeAgent despawns the character (Standalone / UI)
- `e2e/standalone/ui.spec.ts:61` — Debug View renders JSONL diagnostics in standalone (Standalone / UI)
- `e2e/standalone/ui.spec.ts:95` — adding an external asset directory triggers a live asset reload (Standalone / UI)
- `e2e/standalone/ui.spec.ts:125` — browser Export Layout downloads the layout file (Standalone / UI)
- `e2e/standalone/ui.spec.ts:140` — browser Import Layout applies the chosen file (Standalone / UI)
- `e2e/standalone/ui.spec.ts:175` — ConnectionIndicator appears when the WebSocket connection drops (Standalone / UI)

### `@area:areas` (10 tests)

- `e2e/claude/hooks-off/areas-multiroot.spec.ts:84` — painting an area labels tiles in the layout (Areas (multi-root))
- `e2e/claude/hooks-off/areas-multiroot.spec.ts:112` — areas can be added and removed (Areas (multi-root))
- `e2e/claude/hooks-off/areas-multiroot.spec.ts:145` — a folder can be mapped to an area and the mapping persists (Areas (multi-root))
- `e2e/claude/hooks-off/areas-multiroot.spec.ts:180` — an agent for the MAPPED folder takes a seat inside its area (Areas (multi-root) › seat preference (alpha → Engineering))
- `e2e/claude/hooks-off/areas-multiroot.spec.ts:211` — an agent for an UNMAPPED folder is not forced into the area (Areas (multi-root) › seat preference (alpha → Engineering))
- `e2e/claude/hooks-off/areas-multiroot.spec.ts:255` — a Directory assigned an Area in its modal seats the agent it launches inside that Area (Areas (multi-root) › Area assignment from the Directory modal)
- `e2e/claude/hooks-off/areas-multiroot.spec.ts:308` — renaming a Directory carries its Area mapping to the new name (Areas (multi-root) › Area assignment from the Directory modal › rename)
- `e2e/claude/hooks-off/areas.spec.ts:40` — seeded areas + areaTiles load and showAreas is effective (Areas (single-folder) › seeded area data + show-areas state)
- `e2e/claude/hooks-off/areas.spec.ts:72` — the host-contributed workspace folder makes the Areas tool reachable (Areas (single-folder))
- `e2e/claude/hooks-off/areas.spec.ts:92` — the Areas tool button is visible with a seeded areas layout (Areas (single-folder) › seeded areas layout (positive gate))

### `@area:carpet` (8 tests)

- `e2e/claude/hooks-off/carpet.spec.ts:50` — carpet sprites load + broadcast, and the Carpet category renders variants (Carpet)
- `e2e/claude/hooks-off/carpet.spec.ts:71` — painting a tile records it in the carpet layer (Carpet)
- `e2e/claude/hooks-off/carpet.spec.ts:88` — autotiling: the junction case reflects neighboring carpet tiles (Carpet)
- `e2e/claude/hooks-off/carpet.spec.ts:129` — erasing removes a carpet tile (Carpet)
- `e2e/claude/hooks-off/carpet.spec.ts:152` — the carpet eyedropper copies a tile’s variant (Carpet)
- `e2e/claude/hooks-off/carpet.spec.ts:180` — a carpet stroke is a single undo entry (Carpet)
- `e2e/claude/hooks-off/carpet.spec.ts:201` — carpet tiles persist across a save + panel reload (Carpet)
- `e2e/claude/hooks-off/carpet.spec.ts:297` — a seeded carpet coexists with furniture on the same tile (Carpet surface placement (seeded))

### `@area:editor` (17 tests)

- `e2e/claude/hooks-off/editor.spec.ts:101` — erasing a tile deletes the furniture it passes through (Layout editor — erase)
- `e2e/claude/hooks-off/editor.spec.ts:124` — erasing away from an item leaves it in place (Layout editor — erase)
- `e2e/claude/hooks-off/editor.spec.ts:146` — one undo restores both the tiles and the furniture an erase stroke removed (Layout editor — erase)
- `e2e/claude/hooks-off/editor.spec.ts:184` — a right-drag erase stroke is a single undo entry (Layout editor — erase)
- `e2e/claude/hooks-off/editor.spec.ts:223` — a floor paint stroke is a single undo entry (Layout editor — stroke undo)
- `e2e/claude/hooks-off/editor.spec.ts:261` — a wall paint stroke is a single undo entry (Layout editor — stroke undo)
- `e2e/claude/hooks-off/editor.spec.ts:297` — separate strokes stay separate undo entries (Layout editor — stroke undo)
- `e2e/claude/hooks-off/editor.spec.ts:388` — dragging a desk moves the item standing on it (Layout editor — desks carry what stands on them)
- `e2e/claude/hooks-off/editor.spec.ts:413` — rotating a desk turns the item standing on it (Layout editor — desks carry what stands on them)
- `e2e/claude/hooks-off/editor.spec.ts:439` — alt-dragging a desk copies it with the item standing on it (Layout editor — desks carry what stands on them)
- `e2e/claude/hooks-off/editor.spec.ts:471` — an alt-drag copy that overlaps the original is refused (Layout editor — desks carry what stands on them)
- `e2e/claude/hooks-off/editor.spec.ts:496` — one undo removes the whole copied group (Layout editor — desks carry what stands on them)
- `e2e/claude/hooks-off/editor.spec.ts:514` — selecting a placed item closes the open tool panel, and R rotates it (Layout editor — desks carry what stands on them)
- `e2e/claude/hooks-off/editor.spec.ts:565` — a drag that would push the item on the desk off the grid is refused (Layout editor — a desk group fits as a whole or not at all)
- `e2e/claude/hooks-off/editor.spec.ts:619` — a rotation that would swing the item on the desk into a chair is refused (Layout editor — a rotation fits as a whole or not at all)
- `e2e/claude/hooks-off/editor.spec.ts:673` — a copy of an uncoloured item stays uncoloured under a Colorize palette (Layout editor — Furniture Copy)
- `e2e/claude/hooks-off/editor.spec.ts:736` — eyedropping the item on a desk copies its colour, as its own undo entry (Layout editor — colour eyedropper)

### `@area:mobile` (8 tests)

- `e2e/standalone/launch-drawer.spec.ts:463` — the phone drawer fits above the card bar and keeps + Directory reachable (Standalone / long Directory lists on a phone)
- `e2e/standalone/mobile.spec.ts:164` — phone shell: + card launches and slides to the terminal; toggle, card taps and edge swipe navigate (Standalone / mobile shell)
- `e2e/standalone/mobile.spec.ts:208` — tapping a URL in the terminal opens it; a sideways drag from it does not (Standalone / mobile shell)
- `e2e/standalone/mobile.spec.ts:243` — the key bar shows while the software keyboard is up, under both viewport resize policies (Standalone / mobile shell)
- `e2e/standalone/mobile.spec.ts:272` — copy and paste work on a plain-http origin (no Clipboard API) (Standalone / mobile shell)
- `e2e/standalone/mobile.spec.ts:344` — the hooks info modal fits the phone screen (Standalone / mobile shell)
- `e2e/standalone/mobile.spec.ts:369` — a two-finger pan stops the camera following a character; a pure pinch does not (Standalone / mobile shell)
- `e2e/standalone/mobile.spec.ts:409` — the trackpad key follows the finger that pressed it, not touches[0] (Standalone / mobile shell)

### `@area:pets` (3 tests)

- `e2e/claude/hooks-off/pets.spec.ts:90` — pet sprites load, broadcast, and expose manifest names in the editor (Pets)
- `e2e/claude/hooks-off/pets.spec.ts:117` — placing a pet toggles it on/off and persists across a panel reload (Pets)
- `e2e/claude/hooks-off/pets.spec.ts:202` — clicking a pet shows a heart bubble that auto-dismisses and dismisses on re-click (Pets)



## Coverage philosophy

We do not measure e2e via code coverage (too noisy, doesn't map to user-observable scenarios). Coverage is tracked by:

1. **The inventory section above** — every test in the suite with its area tag and file:line.
2. **The "What's NOT covered" gap list** — deliberately maintained; closing a gap removes the corresponding row.
3. **Allure dashboard** — `epic` / `feature` / `story` labels group tests by area without needing this file. Run `npm run test:report` after a suite run, then open `allure-report/allure/index.html` → Behaviors view.

---

### Overview (`webview-ui/README.md`)

# React + TypeScript + Vite

This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.

Currently, two official plugins are available:

- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh

## React Compiler

The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).

## Expanding the ESLint configuration

If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:

```js
export default defineConfig([
  globalIgnores(['dist']),
  {
    files: ['**/*.{ts,tsx}'],
    extends: [
      // Other configs...

      // Remove tseslint.configs.recommended and replace with this
      tseslint.configs.recommendedTypeChecked,
      // Alternatively, use this for stricter rules
      tseslint.configs.strictTypeChecked,
      // Optionally, add this for stylistic rules
      tseslint.configs.stylisticTypeChecked,

      // Other configs...
    ],
    languageOptions: {
      parserOptions: {
        project: ['./tsconfig.node.json', './tsconfig.app.json'],
        tsconfigRootDir: import.meta.dirname,
      },
      // other options...
    },
  },
])
```

You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:

```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'

export default defineConfig([
  globalIgnores(['dist']),
  {
    files: ['**/*.{ts,tsx}'],
    extends: [
      // Other configs...
      // Enable lint rules for React
      reactX.configs['recommended-typescript'],
      // Enable lint rules for React DOM
      reactDom.configs.recommended,
    ],
    languageOptions: {
      parserOptions: {
        project: ['./tsconfig.node.json', './tsconfig.app.json'],
        tsconfigRootDir: import.meta.dirname,
      },
      // other options...
    },
  },
])
```

---

### External Assets (`docs/external-assets.md`)

# External Asset Directories

Pixel Agents supports loading furniture assets from directories outside the extension. This lets you use custom or third-party pixel art asset packs alongside the built-in furniture.

## Adding an External Directory

1. Open the Pixel Agents panel and click **Settings**
2. Click **Add Asset Directory** and pick a folder
3. Your custom assets will appear in the furniture palette immediately, merged with the built-ins
4. The directory path is saved to `~/.pixel-agents/config.json` and reloaded automatically on restart

To remove a directory, open Settings and click the **X** next to it.

## Directory Structure

Your asset directory must follow this structure:

```
my-assets/
  assets/
    furniture/
      MY_CHAIR/
        manifest.json
        MY_CHAIR.png
      MY_DESK/
        manifest.json
        MY_DESK_FRONT.png
        MY_DESK_SIDE.png
```

Each furniture item gets its own subfolder containing a `manifest.json` and one or more PNG sprite files. The folder name doesn't matter — the `id` field in the manifest is what identifies the item.

## Manifest Format

### Simple asset (single sprite, no rotation)

```json
{
  "id": "MY_ITEM",
  "name": "My Item",
  "category": "decor",
  "type": "asset",
  "file": "MY_ITEM.png",
  "width": 16,
  "height": 16,
  "footprintW": 1,
  "footprintH": 1,
  "canPlaceOnWalls": false,
  "canPlaceOnSurfaces": false,
  "backgroundTiles": 0
}
```

### Rotation group (2-way)

```json
{
  "id": "MY_DESK",
  "name": "My Desk",
  "category": "desks",
  "type": "group",
  "groupType": "rotation",
  "rotationScheme": "2-way",
  "canPlaceOnWalls": false,
  "canPlaceOnSurfaces": false,
  "backgroundTiles": 1,
  "members": [
    {
      "type": "asset",
      "id": "MY_DESK_FRONT",
      "file": "MY_DESK_FRONT.png",
      "width": 32,
      "height": 32,
      "footprintW": 2,
      "footprintH": 2,
      "orientation": "front"
    },
    {
      "type": "asset",
      "id": "MY_DESK_SIDE",
      "file": "MY_DESK_SIDE.png",
      "width": 16,
      "height": 32,
      "footprintW": 1,
      "footprintH": 2,
      "orientation": "side"
    }
  ]
}
```

### Rotation group (3-way with mirrored side)

Use `"rotationScheme": "3-way-mirror"` and add `"mirrorSide": true` to the side member — the engine auto-generates the mirrored left variant so you only need one side sprite.

```json
{
  "id": "MY_CHAIR",
  "name": "My Chair",
  "category": "chairs",
  "type": "group",
  "groupType": "rotation",
  "rotationScheme": "3-way-mirror",
  "canPlaceOnWalls": false,
  "canPlaceOnSurfaces": false,
  "backgroundTiles": 0,
  "members": [
    {
      "type": "asset",
      "id": "MY_CHAIR_FRONT",
      "file": "MY_CHAIR_FRONT.png",
      "width": 16,
      "height": 16,
      "footprintW": 1,
      "footprintH": 1,
      "orientation": "front"
    },
    {
      "type": "asset",
      "id": "MY_CHAIR_BACK",
      "file": "MY_CHAIR_BACK.png",
      "width": 16,
      "height": 16,
      "footprintW": 1,
      "footprintH": 1,
      "orientation": "back"
    },
    {
      "type": "asset",
      "id": "MY_CHAIR_SIDE",
      "file": "MY_CHAIR_SIDE.png",
      "width": 16,
      "height": 16,
      "footprintW": 1,
      "footprintH": 1,
      "orientation": "side",
      "mirrorSide": true
    }
  ]
}
```

## Field Reference

### Root fields (all manifests)

| Field | Type | Description |
|---|---|---|
| `id` | string | Unique identifier. Must be unique across all loaded assets |
| `name` | string | Display name shown in the palette |
| `category` | string | Palette category: `desks`, `chairs`, `electronics`, `storage`, `decor`, `misc`, `wall` |
| `type` | `"asset"` \| `"group"` | Single sprite or grouped (rotation/state/animation) |
| `canPlaceOnWalls` | boolean | Whether the item can be placed on wall tiles |
| `canPlaceOnSurfaces` | boolean | Whether the item can be placed on top of desk surfaces |
| `backgroundTiles` | number | Number of floor tiles the sprite extends below its footprint (for tall sprites) |

### Asset-only fields

| Field | Type | Description |
|---|---|---|
| `file` | string | PNG filename relative to the item folder |
| `width` | number | Sprite width in pixels |
| `height` | number | Sprite height in pixels |
| `footprintW` | number | Footprint width in tiles (1 tile = 16px) |
| `footprintH` | number | Footprint height in tiles |

### Group fields

| Field | Type | Description |
|---|---|---|
| `groupType` | `"rotation"` \| `"state"` \| `"animation"` | How members relate to each other |
| `rotationScheme` | `"2-way"` \| `"3-way-mirror"` \| `"4-way"` | Rotation variants available |
| `members` | array | Child assets or nested groups |

### Member orientation values

`"front"`, `"back"`, `"side"`, `"left"`, `"right"`

## Using Third-Party Asset Packs

If you have a pixel art asset pack (such as **[Office Interior Tileset (16x16)](https://donarg.itch.io/officetileset)** by [Donarg](https://donarg.itch.io/) — highly recommended), you'll need to slice the tileset into individual PNGs and create a `manifest.json` for each item.

The manifest format is simple enough that an AI assistant like Claude Code can generate them for you — just describe your sprites or share the PNGs and ask it to write the manifests.

The `scripts/asset-manager.html` in this repo also provides a visual editor for creating and editing manifests.

---

### Adr 0001 Consent Choices Send Immediately And Revise As Absolute State (`docs/adr/0001-consent-choices-send-immediately-and-revise-as-absolute-state.md`)

# Consent choices send immediately, and a revised choice is an absolute state command

The Intro wraps the first-run hooks consent ask in a four-step tour whose closing step
has a Back button, so an already-answered ask can be re-answered. A consent-step click
sends its `hooksConsentResponse` the moment it happens (no deferred commit on "Let's
Go"), and the server therefore reads every choice as a statement of the state the user
wants _now_, against everything an earlier answer left behind.

Everything below is keyed by `HookProvider.id`. Consent is per-human per-provider
(`hooksConsent` / `hooksEnabled` maps at the top level of `config.json`), every
consent-bearing wire message carries a `providerId` the client only ever echoes, and
one process-wide queue serializes answers across all providers because they share one
`config.json`.

## What an earlier answer can leave behind

Three things, which is why the revision state is `{ installed, consent }` with consent
a TRI-STATE (`'granted' | 'declined'`, absent = unanswered) rather than a boolean:

- **Our hooks on disk.** The obvious one.
- **A grant with nothing on disk.** `install` records the grant _before_ it writes, so
  an install that then failed — the unparseable `settings.json` of issue #377, exactly
  the population this gate exists for — leaves a grant alone. The grant alone retires
  the ask, so a revert keyed only on the install state reads "nothing to undo" and
  leaves the user with an ask that can never come back.
- **A persisted hooks-off the answer itself wrote.** `never` turns the provider's hooks
  preference off, and the ask gate reads a hooks-off preference as never-ask-again.
  `'declined'` is the provenance marker that distinguishes this from a Settings
  toggle-off (which never records consent), so a revised "Not Now" knows the preference
  is its to take back.

## The choice→action rule

`consentActionFor(choice, { installed, consent })`, in `server/src/providers/hook/`:

| choice | nothing left | grant (installed or not) | decline |
| --- | --- | --- | --- |
| `install` | `install` | `install` | `install` |
| `never` | `persistOff` | `disable` if installed, else `persistOff` | `persistOff` |
| `notNow` | `none` | `revert` | `revertDecline` |
| anything else | `none` | `none` | `none` |

- `install` grants and installs through the same path as the Settings toggle. A grant
  REPLACING a decline also deletes that decline's hooks-off remnant in the same write:
  without it, `never → install (which fails) → notNow` ends at unanswered + hooks-off,
  an ask suppressed forever although the final answer asked for it to return.
- `disable` takes the full toggle-off path — uninstall, then persist hooks-off only
  once the disk agrees — because a persisted hooks-off beside live entries is the
  stranding bug: entries firing, checkbox lying, gate skipped.
- `revert` undoes the install and clears the consent record, leaving the preference
  alone: a granted install never wrote it, and resetting it would clobber a genuine
  Settings toggle-off made between answer and revision.
- `revertDecline` clears the record AND restores the preference default by deleting the
  key, so "never answered" and "answered and reverted" are indistinguishable on disk —
  which is exactly what "the world as if never answered" means.
- Every unrecognized value writes nothing: junk must never read as approval, as a
  durable decline, or as an uninstall trigger.

Callers pass a fresh `areHooksInstalled()` and degrade an unreadable settings file to
`installed: false`, so no choice ever uninstalls on a guess.

## Considered options

- **Deferred commit** — record the choice locally and send once, at "Let's Go". Keeps
  the one-answer-per-ask invariant by construction and needs no revision semantics.
  Rejected: a user who clicks Install and then closes the tour with the close x (or
  loses the window) would have their explicit decision silently discarded.
- **Immediate send with one-shot semantics** — rejected because replaying
  `never → persist hooks-off, touch nothing` after an install recreates the stranding
  bug described above.
- **A boolean consent record** — rejected because it cannot express the failed-install
  grant or the decline's own preference write, and both of those are things a revision
  has to undo.

## Consequences

- **One answer is one config write.** `recordHooksDecline` (consent + preference off)
  and `clearHooksAnswer` (both keys deleted) are single `readConfig`→`writeConfig`
  cycles. Split across two writes, a failed second write leaves a state the answer
  disavows — a decline with the default-on preference, or a hooks-off with no
  provenance. The surface effect only mirrors the result into live runtime state.
- **Answers are serialized.** Revision is the whole point of this decision, so two
  answers in quick succession are designed for, not an edge case — and both surfaces
  dispatch without awaiting. An unserialized revision can observe the first answer's
  install mid-flight, read `installed: false`, and degrade into its no-op variant,
  leaving hooks installed against the user's final answer. `consentExecutor.ts` holds
  one queue per process, which is where the Back-and-revise race lives; two separate
  windows racing is a different problem, and the installer's re-read-before-rename is
  what narrows that one.
- **The webview keeps the tour mounted after its own choice.** The install's
  `hooksStatus installed: true` broadcast moots an _unanswered_ ask but must not yank
  the closing step from the person who just answered, so the tour snapshots the request
  and gates the moot on a sent choice.
- **The closing step reports the outcome, not the click.** An install can fail for
  reasons unrelated to the answer, and a tour that congratulates regardless leaves the
  user believing hooks are running over a file that was never written. The verdict
  settles on the ARRIVAL of a `hooksStatus` (`hooksStatusSeq`, per provider), because a
  failed install re-reports the same `false` the webview already held — the value never
  changes, only the message arrives, and another provider's status is not this tour's
  verdict.
- **Asks queue per provider.** The server sends one request per provider that needs
  one; the webview renders the head and advances on answer or dismissal, and a request
  for a different provider starts a clean tour rather than inheriting the previous
  provider's sent choice or armed wait.

---

### Adr 0002 Protocol Follows Glossary (`docs/adr/0002-protocol-follows-glossary.md`)

# The wire contract follows the glossary, compatibility yields to vocabulary

When the Directory concept replaced "workspace folder" (CONTEXT.md), the AsyncAPI contract still
spoke folder: `workspaceFolders`, `launchAgent.folderPath`, `folderNames`, folder→Area mappings.
We renamed the wire surface in one breaking sweep (`directories`, `directoryPath`,
`directoryNames`, …) instead of adding directory-named messages beside folder-named fields.

Both ends of the wire ship in lockstep and the contract is young, so the one-time cost falls
almost entirely on hypothetical third-party clients regenerating from the shipped
`core/asyncapi.yaml`. The alternative — additive compatibility — would have left the protocol
speaking two vocabularies permanently, violating the glossary's own _Avoid: folder_ rule in our
canonical artifact. Precedent: when the glossary and the contract disagree, rename the contract.

---

### Design Standalone Terminal (`docs/design/standalone-terminal.md`)

# Standalone Terminal

Status: implemented (`feat/mobile-web-app`, landing together with the mobile shell and Directories)

## Goal

Give the standalone surface (`npx pixel-agents`) launch/focus/close parity with the VS Code
extension by embedding a real terminal in the browser SPA.

Today the two surfaces are asymmetric:

| Capability       | VS Code                                   | Standalone (before)                   |
| ---------------- | ----------------------------------------- | ------------------------------------- |
| Launch an agent  | `launchNewTerminal()` → `vscode.Terminal` | **`launchAgent` silently dropped**    |
| Focus an agent   | `terminalRef.show()`                      | **`focusAgent` silently dropped**     |
| Close an agent   | `terminalRef.dispose()`                   | dismiss + remove (no process to kill) |
| See agent output | VS Code terminal panel                    | **nothing**                           |

`server/src/clientMessageHandler.ts` routed `launchAgent` and `focusAgent` into its `default:`
branch ("require IDE-specific handling"), and `BottomToolbar.tsx` hid the `+ Agent` button
behind `!isBrowserRuntime`. Standalone could only _observe_ agents started elsewhere.

After this change, standalone spawns Claude in a server-side PTY and streams it to an
xterm.js drawer in the browser.

## Architecture

```
Browser SPA                          Fastify server                     OS
───────────                          ──────────────                     ──
BottomToolbar "+ Agent"
   └─ launchAgent (ClientMessage, /ws)
                          ──────────▶ clientMessageHandler
                                        └─ launchStandaloneAgent()
                                             ├─ provider.buildLaunchCommand(sessionId, cwd)
                                             ├─ PtySessionManager.create(agentId, …) ─▶ pty.spawn(claude)
                                             ├─ store.set(agent)  ─▶ agentCreated broadcast
                                             └─ terminalSessionOpened broadcast
TerminalDrawer (xterm.js)
   └─ WS /terminal/:agentId  ────────▶ terminal WS route
        ◀── {type:"replay"} ──────────── PtySession.snapshot() (serialized mirror)
        ◀── {type:"output"} ──────────── pty.onData
        ─── {type:"input"} ───────────▶  pty.write
        ─── {type:"resize"} ──────────▶  pty.resize
        ◀── {type:"exit"} ────────────── pty.onExit
```

Layering is unchanged: everything new on the server lives under `server/src/terminal/`, depends
only on `core/`, and is never imported by `adapters/vscode/`. The webview drawer depends only on
`core/` + the existing transport singleton.

### Why a separate launch path instead of a shared one

`adapters/vscode/agentManager.ts:launchNewTerminal()` is a 190-line function that takes twelve
positional dependency arguments and imports `vscode`. The standalone launcher
(`server/src/terminal/standaloneAgentLauncher.ts`) deliberately **mirrors** it rather than
refactoring it into a shared core:

- The VS Code path can't move into `server/` (it imports `vscode`).
- Unifying both would mean rewriting `launchNewTerminal`'s signature, which touches
  `PixelAgentsViewProvider` and the whole VS Code lifecycle — well outside this feature, and a
  guaranteed conflict with six sibling branches developing off the same base.

Both launchers reuse `claudeProvider.buildLaunchCommand(sessionId, cwd, { bypassPermissions })`,
so the _command_ is shared even though the _hosting_ is not. **Unifying the two launch paths is
explicitly deferred** — see "Deferred".

### `TerminalAdapter` is not the seam this feature needs

`core/src/terminalAdapter.ts` (`ITerminalAdapter`) exposes only `activeTerminal()` /
`allTerminals()`, and its sole consumer is `fileWatcher.ts`, which casts the handles to
`vscode.Terminal` to adopt JSONL files onto the terminal the user is looking at. It is a
_VS Code terminal-adoption_ seam, not a terminal-lifecycle seam, and `AgentRuntime` never
touches it. Implementing it for standalone would mean implementing `activeTerminal()` for a
concept standalone doesn't have (there is no "focused" terminal server-side; focus is a browser
UI concern).

So this branch leaves `ITerminalAdapter` alone and introduces `PtySessionManager` as the
standalone terminal-lifecycle owner. **See open question 2 below.**

## Dependency strategy: node-pty (the important part)

A PTY is non-negotiable for this feature: Claude Code is a full-screen TUI. `child_process.spawn`
with pipes gives no TTY, no resize, no line discipline, and Claude renders unusably. `node-pty` is
the standard, but it is a **native module**, which is a real concern for `npx pixel-agents`.

### Measurements (npm 11.16.0, Node 26.3.1, darwin-arm64)

| Package                          | Install | Result                                                           |
| -------------------------------- | ------- | ---------------------------------------------------------------- |
| `node-pty@1.1.0`                 | 960 ms  | **`require()` succeeds, `spawn()` throws `posix_spawnp failed`** |
| `@lydell/node-pty@1.2.0-beta.12` | 621 ms  | **works** — `DATA:"PTY_OK\r\n"`, `EXIT 0`                        |

Two independent problems with official `node-pty`:

**1. No Linux prebuilds.** `node-pty@1.1.0` ships `prebuilds/` for exactly four targets:

```
darwin-arm64  darwin-x64  win32-arm64  win32-x64
```

There is **no `linux-x64` and no `linux-arm64`**. On Linux its install script
(`node scripts/prebuild.js || node-gyp rebuild`) falls through to `node-gyp`, which needs Python
and a C++ toolchain. For an `npx` one-liner aimed at "run this and see your agents", requiring a
build toolchain on the most common server/dev-container platform is not acceptable.

**2. npm now blocks install scripts by default.** npm 11.16 gates lifecycle scripts behind
`npm approve-scripts`. This is npm's new default, not a local setting — verified:
`npm config get ignore-scripts` → `false`, and no `.npmrc` in the repo or `~`. With scripts
gated, `node-pty`'s `prebuild.js` (copies `prebuilds/<platform>` → `build/Release`) and
`post-install.js` (chmod `+x` the `spawn-helper` binary) never run. The module still
_loads_ — and then fails at `spawn()` with `posix_spawnp failed`, because `spawn-helper` is
`-rw-r--r--`.

That second finding drove a design decision: **`require()` succeeding does not mean the terminal
works.** Availability must be established by actually spawning a PTY, not by a successful import.

### Decision

**Primary: `@lydell/node-pty` as an `optionalDependency`, with `node-pty` as a fallback candidate.**

`@lydell/node-pty` distributes per-platform prebuilt binaries as optionalDependencies
(`@lydell/node-pty-{linux,darwin,win32}-{x64,arm64}` — all six targets) with **no install
scripts at all**, the same model esbuild uses. npm picks the right binary by `os`/`cpu`; nothing
compiles, nothing needs chmod, and npm's script gating is irrelevant.

The loader (`server/src/terminal/ptyModule.ts`) tries a _list_ of module ids in order:

```ts
const PTY_MODULE_CANDIDATES = ['@lydell/node-pty', 'node-pty'];
```

so a user or distro that prefers the Microsoft package can simply install it and it will be
picked up — we are not hard-coupled to the fork.

**Tradeoff, stated plainly:** `@lydell/node-pty` is a third-party fork on a **beta** version tag
(`1.2.0-beta.12`), whereas `node-pty` is maintained by Microsoft and powers VS Code itself.
Taking the fork trades maintainer pedigree for install reliability. Three things de-risk it:

1. It is an `optionalDependency` — if it fails to install, `npm install` still succeeds.
2. The feature degrades gracefully (below) — a missing/broken PTY turns the terminal off,
   it never breaks the office view, which is the app's actual core.
3. The fallback candidate list means switching back to `node-pty` is a one-line change.

### Graceful degradation

`PtySessionManager.probe()` runs **once**, lazily, on first use:

1. Try each candidate module id in `require()`. All fail → unavailable
   (`reason: "no PTY module installed"`).
2. Actually spawn a throwaway PTY, and kill it. Throws → unavailable
   (`reason: "<the real error>"`, e.g. `posix_spawnp failed`).

The result is cached. Availability is broadcast to the client as `terminalAvailability
{ available, reason? }` on `webviewReady`, and:

- The server logs one clear `[Pixel Agents]` warning line with the reason and the remedy.
- The SPA hides `+ Agent` and shows the reason in the drawer instead of a dead terminal.

This is why availability is a _probe_ and not a `try { require } catch`: the npm-11 case produces
a module that imports fine and fails on first keystroke.

## Security model

**The terminal endpoint is arbitrary code execution.** It spawns a process and pipes a browser's
keystrokes into it. It is the most sensitive surface in the codebase and is treated accordingly.

### Bind behavior (verified, unchanged)

`server/src/cli.ts:parseArgs` defaults `host` to `127.0.0.1`, and
`httpServer.ts:createHttpServer` passes `options.host ?? '127.0.0.1'` to `app.listen`. The server
is loopback-only by default. `--host` can override it; the CLI now prints an explicit warning
when `--host` is set to a non-loopback address _and_ a terminal is available, because that
exposes a shell to the network.

`--no-terminal` removes the shell surface entirely: the CLI builds the manager via
`PtySessionManager.disabled(reason)`, which reports "unavailable" through the same plumbing as a
failed module resolution (availability broadcast, session route, launcher), so the browser shows
a disabled + Agent button with the reason and never opens a terminal socket. This is the intended
configuration for a watch-only dashboard, especially one bound off-loopback.

### One privilege model with `/ws`

`/ws` (see `httpServer.ts`, `standaloneTokenValid`) separates _connecting_ from _acting_: any
same-origin page may connect and watch the office, but messages that reach outside
`~/.pixel-agents/` are honoured only on a **privileged** connection — one whose handshake carried
the server token as `?token=`, i.e. a page opened from the URL the CLI printed. Nothing about a
network position (peer address, `Host`, `Origin`) is ever consulted for privilege, because every
position is reproducible by a forwarder or a rebound page.

The terminal is the second privileged surface, and it follows that model exactly:

- An unprivileged `/ws` connection is read-only: one gate at the top of `handleClientMessage`
  refuses every message except `webviewReady`, so `launchAgent` and `closeAgent` (like every other
  change) need the token. An untokened client is told the terminal is unavailable, with the
  reason, so the **+ Agent** button explains itself instead of silently doing nothing.
- **`GET /terminal/:agentId`** requires the same token, carried the same way as `/ws`: in the
  handshake's `?token=` query, checked by the same `standaloneTokenValid` in
  `server/src/wsAuth.ts` (constant-time compare, length-guarded). One predicate, one carry, one
  thing to audit. WebSocket connections are exempt from CORS, so without the token any page the
  user visits could open `ws://127.0.0.1:<port>/terminal/1` and get a shell; port-scanning
  localhost from a web page is a well-known, practical attack.

  _Why `?token=` is safe to log-adjacent code:_ standalone runs Fastify with a request logger,
  which would write `req.url` for every handshake. The logger's request serializer redacts the
  token value before the line is written (`redactTokenQuery`), so neither `/ws` nor the terminal
  socket ever puts the secret in stdout or a log file. An earlier iteration carried the terminal
  token as a `Sec-WebSocket-Protocol` value to dodge the log instead; that left `/ws` leaking and
  gave the same secret two carry mechanisms and two comparison paths, so it was replaced.

### How the token reaches the browser

Only inside the URL the CLI prints. The SPA reads `?token=` once at load
(`webview-ui/src/transport/serverToken.ts`) and uses it for both the `/ws` handshake and every
terminal socket. **The server never hands the token out over HTTP.** An earlier iteration served
it from a same-origin `GET /api/terminal/session` guarded by `Origin`/`Host` checks; that makes two
attacker-supplied headers the gate, and it was dropped in favour of the `/ws` model when the two
were reconciled.

Privilege on BOTH sockets is one predicate, `standaloneHandshakeVerdict` (`server/src/wsAuth.ts`):
the token, arriving under an allowed `Host`. The allowlist is loopback, the bind address when it is
a specific one, and every `--allowed-host` name. It is defence in depth on top of the token, never
the gate: it blunts DNS rebinding, where a rebound page reaches 127.0.0.1 while its `Host` is still
the attacker's domain. The terminal socket additionally applies the same-origin check.

An earlier iteration enforced a loopback-only `Host` on the terminal socket alone, and only when
bound to loopback. That broke reverse proxies (Tailscale Serve forwards to 127.0.0.1 with its public
name as `Host`), and the only escape was `--host 0.0.0.0` — which also exposes the port, and the
token in transit, to the LAN over plain HTTP. The explicit allowlist keeps the bind on loopback, and
applying it to `/ws` too means no privileged path is weaker than another. A tokened `/ws` connection
under an unlisted `Host` stays read-only and the server logs the `--allowed-host` hint.

### Other properties

- The standalone token is persisted in `~/.pixel-agents/standalone-token` (mode `0o600`; the
  directory itself is usually created earlier by config/layout persistence with the default mode,
  so the file's own mode is the protection) so the printed URL survives restarts — a bookmark or
  home-screen web app pointing at a long-running server keeps working. The embedded (VS Code)
  server still mints a `crypto.randomUUID()` per process. A persisted token never expires, so
  `--rotate-token` is the revocation path for a leaked URL: it replaces the file (tmp + rename, 0600) and every URL carrying the old token stops granting privilege, on every device. It is
  refused while another standalone server is running, which would keep honouring the old token.
  The token stays in the address bar on purpose (stripping it would break bookmarks); the SPA
  sets `referrer: no-referrer` so it never leaves as a Referer.
- A second `npx pixel-agents` reuses a running standalone server only when its access matches
  (`ServerConfig.standalone`: terminal on/off, bind host, allowed hosts). Otherwise `--no-terminal`
  would report the terminal off while attaching the user to a server that has one.
- Terminal links: plain URLs (`@xterm/addon-web-links`) and OSC 8 hyperlinks (`linkHandler`) both
  go through `terminalLinkOpener`, which opens only `http:`/`https:` in a new tab with
  `noopener,noreferrer`. xterm's default OSC 8 activator opens any scheme (`javascript:`,
  `file:`, OS protocol handlers) into a same-origin popup behind a generic `confirm()`.
  Touch is separate: the pane's touch handling prevents every default, which also suppresses the
  synthesized click link providers need, so a tap looks up the plain URL under the tapped cell
  itself (`urlAtCell`, same pattern as the addon) and sends it through the same opener. OSC 8
  links are click-only, since xterm exposes no public per-cell link lookup.
- Every token comparison in the server -- hook Bearer, embedded `/ws` Bearer, standalone `/ws`
  and terminal `?token=` -- goes through the one `timingSafeStringEqual` in `wsAuth.ts`.
- The PTY inherits the server's uid/gid — no privilege boundary is claimed or implied. This
  feature does not make a local shell _more_ reachable to a local user; it makes it reachable
  to a _browser page_, which is exactly what the token prevents.
- `/terminal/:agentId` only attaches to PTYs that this server spawned, keyed by agent id. It
  cannot spawn a process on its own and cannot attach to externally-detected (non-PTY) agents.

## Transport: why raw I/O is outside AsyncAPI

`core/asyncapi.yaml` models a **control plane**: discrete, discriminated, schema-validated
messages, with `additionalProperties: false` and a CI drift check against generated bindings.
Terminal I/O is a **data plane**: a high-frequency, unstructured byte stream (a `ls` of a big
directory is thousands of chunks/second).

Putting it in the AsyncAPI unions would mean every keystroke and every output chunk passes
through the `ServerMessage` discriminated union and the store's broadcast fan-out — which
broadcasts to _all_ connected clients, so agent A's output would be delivered to every browser
tab. It would also grow the generated `messages.ts` union with a variant no other client can do
anything meaningful with.

**Decision:** a dedicated WS endpoint per agent (`/terminal/:agentId`) carries the stream and is
documented here rather than in the contract. Only the control-plane facts go through AsyncAPI,
additively:

| Message                 | Direction       | Purpose                                         |
| ----------------------- | --------------- | ----------------------------------------------- |
| `terminalAvailability`  | server → client | `{ available, reason? }` — gates the whole UI   |
| `terminalSessionOpened` | server → client | `{ agentId }` — a PTY exists, open a drawer tab |
| `terminalSessionClosed` | server → client | `{ agentId, exitCode? }` — process exited       |

All three are new `ServerMessage` variants; **no existing message or client message changed**, so
the diff stays additive for the six sibling branches. Notably `launchAgent`, `focusAgent`, and
`closeAgent` already exist and needed no protocol change — standalone just stopped ignoring them.

### Frame format (the data plane)

JSON text frames both directions, `additionalProperties`-free by convention:

```
server → client   {"type":"output","data":"..."}      raw PTY output
                  {"type":"exit","exitCode":0}
client → server   {"type":"input","data":"a"}
                  {"type":"resize","cols":80,"rows":24}
```

JSON rather than binary frames because `node-pty` already decodes to UTF-8 strings and xterm.js
already accepts strings — binary framing would add two conversions and re-introduce the
multi-byte-boundary bug node-pty already solves. The overhead is JSON string escaping on a
loopback socket, which is not a bottleneck. Revisit if it ever is.

### Scrollback / reconnect

Each `PtySession` mirrors its PTY output into a headless xterm (`@xterm/headless`, scrollback
capped at `TERMINAL_MIRROR_SCROLLBACK_LINES`). A browser that connects late, reloads, or
reconnects after the WS drops receives a `{type:"replay"}` frame carrying a
`@xterm/addon-serialize` snapshot of the mirrored screen plus the PTY geometry; the client
resets, resizes to that geometry, writes the snapshot, and then re-fits to its container. This
replaces an earlier raw byte ring: a ring starts mid-stream and a full-screen TUI's cursor
movements assume screen state a fresh client doesn't have, so replaying it produced garbled
overlapping fragments — and since the PTY size usually hasn't changed across a reload, no
SIGWINCH ever arrived to trigger a repaint. The snapshot is valid on a fresh terminal at any
attach point by construction. Output racing the snapshot is queued server-side and flushed after
the replay frame, so no byte is dropped or doubled across the handoff.

Closing the WS does **not** kill the PTY — only `closeAgent` (or the process exiting) does. So a
reload reattaches to a still-running Claude session.

## In scope

- `PtySessionManager`: spawn/write/resize/dispose per agent id, headless-xterm screen mirror
  with serialized replay snapshots, exit propagation, availability probe, `disposeAll()` on
  shutdown.
- `launchStandaloneAgent()`: PTY-backed mirror of the VS Code launch path, reusing
  `buildLaunchCommand`, pre-registering the expected JSONL, polling for it, and wiring the
  existing file-watcher + session-router machinery.
- `standalone` `launchAgent` / `closeAgent` handling (close now kills the PTY).
- `GET /terminal/:agentId` WS, token-authenticated; `launchAgent`/`closeAgent` honoured on privileged `/ws` only.
- Three additive AsyncAPI `ServerMessage` variants.
- xterm.js drawer in the SPA: a right-docked panel the office reflows beside (clamped to the
  viewport so the card bar stays on screen), one tab per PTY-backed agent, character-click
  focuses its tab (a Teammate's focuses its Lead's),
  standalone-only, pixel-art chrome + mono terminal content, xterm assets bundled (no CDN).
- Clipboard: every copy is verbatim (Cmd/Ctrl+C, Ctrl+Shift+C, the browser's Copy, the
  phone's Copy pill). Re-flowing Claude Code's two-space soft wraps into prose is opt-in —
  Alt+C / Option+C with a selection, or the pill's "Copy as text" — because the heuristic
  can't tell a soft wrap from indented structure under a long row (YAML, code). Writes go
  through `terminal/clipboard.ts` (Clipboard API, or a copy event on plain-http origins).
- Distribution: esbuild externals, `package.json` `optionalDependencies` + `files`.
- Server unit tests: session manager lifecycle, WS auth, message framing.

## Deferred (with rationale)

- **Terminal persistence across server restarts.** PTYs die with the server. Restoring them would
  need a detachable session supervisor (tmux/abduco-style) or a daemon that outlives the CLI —
  a much larger design. Today a restart leaves the Claude _session_ intact (the JSONL is on
  disk), so `--resume` recovers the conversation, just not the live process.
- **Multiple terminals per agent.** The manager is keyed `agentId → session` (1:1). Going 1:N
  means a session id in the key, the drawer, and the WS path. No demand yet.
- **Windows beyond what node-pty handles.** Both candidates use ConPTY on Windows 10 1809+
  (`@lydell/node-pty` ships `win32-x64` + `win32-arm64` prebuilds, and drops the old winpty
  fallback). Untested on Windows in this branch — see risks.
- **Unifying the VS Code and standalone launch paths** behind one core seam (see "Why a separate
  launch path").
- **Heuristic (hooks-off) `/clear` detection for PTY agents.** `fileWatcher`'s per-agent `/clear`
  heuristic is gated on `agent.terminalRef` (a `vscode.Terminal`), so it never fires for
  standalone PTY agents. With hooks on (the default) `SessionEnd`/`SessionStart` handle `/clear`
  correctly, so this only affects hooks-off standalone.
- **Making `/ws` authenticate in standalone** (pre-existing gap, not introduced here).

## E2E coverage

`e2e/tests/standalone/terminal.spec.ts` covers the browser-launched path end to end: `+ Agent`
spawns a PTY, hooks route to the new character, mock-claude output renders in the drawer, a
reload reattaches to the still-running PTY (replay), and close cleans up. A second scenario
pins the `--no-terminal` degradation (launch disabled with its reason, hook-driven agents still
render). Both skip on Windows, where spawning the `.cmd` mock shim through a PTY is untested.

The security negatives are pinned at the unit level, against a real server where a socket is
involved: `terminalRoutes.test.ts` (no token, wrong token, same-length token, valid token from a
foreign origin, DNS-rebound attach, attach to an agent with no terminal) and
`clientMessageHandler.test.ts` (every mutating message from an untokened `/ws` client changes
nothing on disk, and `webviewReady` tells that client why the terminal is unavailable). Not covered end to end: the module-failure
degradation path (see open question 3).

## Risks

- **Beta dependency.** `@lydell/node-pty` is `1.2.0-beta.12`. Mitigated by optionality, the
  fallback candidate list, and graceful degradation, but it is a real supply-chain call that
  deserves a maintainer's sign-off.
- **Windows untested.** ConPTY is claimed by prebuilds but not exercised here. The e2e terminal
  spec (`e2e/tests/standalone/terminal.spec.ts`) covers launch → drawer → I/O → reload → close on
  macOS/Linux, but skips the PTY-launch path on Windows: it spawns `claude` directly (no shell
  hop), and running a `.cmd` shim that way is exactly the untested part.
- **`npx` install size.** Adds one prebuilt `.node` (~100–200 KB) for the host platform only.
- **Non-loopback `--host`.** A user who sets `--host 0.0.0.0` exposes a token-guarded shell.
  Warned at startup; not blocked (blocking would be a behavior change to an existing flag).
  `--no-terminal` is the sanctioned answer for network-exposed watch-only dashboards.
- **Probe cost.** The availability probe spawns and kills a real PTY once per process. It runs
  lazily on first use, not at boot, so a user who never opens a terminal never pays it.

## Open questions

1. **`@lydell/node-pty` (beta fork, all 6 platforms prebuilt, no install scripts) vs `node-pty`
   (Microsoft, no Linux prebuilds, broken under npm ≥11.16's script gating)?** The fork is the
   primary candidate with the official package as a fallback. This is the single biggest call in
   the branch and is easy to reverse.
2. **Should `ITerminalAdapter` grow into a real terminal-lifecycle seam** (`launch/write/resize/
dispose`) that both surfaces implement, or stay the VS Code adoption helper it is today? The
   branch assumes the latter and puts the lifecycle in `PtySessionManager`.
3. **A test seam to force PTY-unavailable** (e.g. `PIXEL_AGENTS_DISABLE_PTY=1`) would make the
   module-failure degradation path e2e-testable. Today only the `--no-terminal` path is covered
   end to end, and the probe has no override.

Resolved since the first draft:

- **`/ws` privilege.** `/ws` separates connecting (same-origin viewers) from acting (the
  `?token=` the CLI printed), and the terminal rides that same privilege bit — see "One privilege
  model with `/ws`".
- **Directory picker.** `+ Agent` opens the launch drawer on both hosts; standalone contributes
  its start directory as a host Directory, and user-defined Directories are shared machine-wide
  (`server/src/directories.ts`).

---

### Research Claude Harness Spawn Formats (`docs/research/claude-harness-spawn-formats.md`)

# Claude Code 2.1.220 harness: sub-agent and teammate spawn formats

Research note investigating two claims about how the current Claude Code CLI spawns
sub-agents and teammates, and what it writes to disk. Relevant to this repo because
`server/src/providers/hook/claude/` and the transcript-fallback parser key off these
formats.

**Investigated:** 2026-07-29
**CLI under test:** `claude --version` → `2.1.220 (Claude Code)`
(binary at `~/.local/share/claude/versions/2.1.220`)
**Evidence base:** official docs, the official changelog, the installed binary, and a
machine-wide sweep of every JSONL transcript under `~/.claude/projects/` — 939 files
across 46 project directories, spanning 30 distinct CLI versions (2.1.170 – 2.1.220)
and content dates 2026-06-10 to 2026-07-29.

Web-sourced and machine-local evidence are kept in separate sections so each can be
judged on its own. The two claims below are argued mainly from docs, changelog and
controlled probes; the [Local empirical evidence](#local-empirical-evidence) section is
the independent corpus sweep.

---

## Claim 1

> The current Claude Code harness has no Task tool anymore; the Agent tool replaced it.
> Only an older CLI can produce within-turn Task sub-agent records — `progress` records
> with `data.type: "agent_progress"` in the session JSONL.

**Verdict: confirmed**, with one correction on the tool name and one on where
`agent_progress` lives.

### 1a — Task vs Agent

The rename is documented explicitly:

> "In version 2.1.63, the Task tool was renamed to Agent. Existing `Task(...)`
> references in settings and agent definitions still work as aliases."
> — <https://code.claude.com/docs/en/sub-agents>

So the tool exposed to the model for spawning sub-agents is `Agent`. The correction to
the claim: the name `Task` is **not fully gone** — it survives as an alias in
permission rules and agent-definition `tools:` frontmatter. Both syntaxes were
introduced while the tool was still called `Task`, and the 2.1.63 rename kept them
working:

- `Task(AgentName)` in `settings.json` permissions / `--disallowedTools` (changelog v2.1.0)
- `Task(agent_type)` in agent `tools` frontmatter (changelog v2.1.33)

The changelog also still refers to "the Task tool" by its historical name well after the
rename — e.g. v2.1.212: _"Deprecated the Task tool's `mode` parameter (now ignored);
subagents inherit the parent session's permission mode by default"_. That is prose, not
evidence of a live tool named `Task`.

Empirical confirmation across all local transcripts (counts as of the full sweep below;
they drift upward as sessions run):

```
$ grep -rl '"name":"Task"'  ~/.claude/projects --include='*.jsonl' | wc -l
0
$ grep -rl '"name":"Agent"' ~/.claude/projects --include='*.jsonl' | wc -l
98
```

No transcript written by 2.1.170 or later contains a `Task` tool_use. The rename at
2.1.63 predates every transcript retained on this machine, so the cutover cannot be
dated locally — it is dated from the docs.

### 1b — `agent_progress` records in session JSONL

Confirmed: **the current CLI does not write `agent_progress` records into session
JSONL.** Not for background sub-agents, and — contrary to what one might assume —
not for foreground ones either.

Machine-wide, zero genuine records exist:

```
$ grep -rl '"type":"agent_progress"' ~/.claude/projects --include='*.jsonl' | wc -l
0
$ grep -rho '"type":"[a-z_]*progress[a-z_]*"' ~/.claude/projects --include='*.jsonl' | sort -u
(no output — no progress records of any kind)
```

A plain `grep -rl 'agent_progress'` returns 28 files, but every hit is prose: the string
appears escaped (`\"agent_progress\"`) inside conversation content, because this repo's
`CLAUDE.md` and a memory file discuss it. Those are not records. The quoted-form grep
above is the one that discriminates.

Three controlled probes were run against 2.1.220 to rule out "we just never hit the
code path":

| Probe                  | Setup                                                                                                        | `agent_progress` in session JSONL | on stream-json stdout                |
| ---------------------- | ------------------------------------------------------------------------------------------------------------ | --------------------------------- | ------------------------------------ |
| Foreground sub-agent   | `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1`, `run_in_background=false`, `--output-format stream-json --verbose` | 0                                 | 0                                    |
| Same + forwarding      | as above plus `--forward-subagent-text`                                                                      | 0                                 | 0 (and 0 `"type":"progress"` events) |
| Named background spawn | `--teammate-mode in-process`, `run_in_background=true`                                                       | 0                                 | n/a                                  |

Probe transcripts: `~/.claude/projects/-private-tmp-…-scratchpad-probe-stream/`,
`…-probe-fwd/`, `…-probe-bg/` (all stamped `"version":"2.1.220"`).

Instead of progress records, every sub-agent gets its own transcript file. This is the
documented behaviour:

> "find IDs in the transcript files at `~/.claude/projects/{project}/{sessionId}/subagents/`.
> Each transcript is stored as `agent-{agentId}.jsonl`." … "when the main conversation
> compacts, subagent transcripts are unaffected. They're stored in separate files."
> — <https://code.claude.com/docs/en/sub-agents>

Confirmed on disk: 64 `subagents/` directories and 147 sidecars machine-wide, the oldest
stamped `"version":"2.1.170"` (2026-06-10). Full breakdown in
[Local empirical evidence](#local-empirical-evidence).

In the `--forward-subagent-text` probe the sub-agent did real work — its transcript
records one `Bash` tool_use with no permission denial — so the zero result is not an
artefact of the sub-agent never getting started.

**Caveat on the "older CLI only" half of the claim.** The 2.1.220 binary still contains
live code that _constructs_ `agent_progress` objects — `strings` on the binary yields 18
occurrences, including a writer whose failure path logs `bg-subagent progress write
failed`, and a `forked-command-${name}` emitter. So the record type is not dead code
that was deleted; it exists on an in-process/streaming channel. What is established
empirically is narrower and is what matters for a transcript parser: **it is never
persisted to session JSONL by any version from 2.1.170 onward**, and it did not appear
on stream-json stdout in either probe. Whether some other flag combination surfaces it
on the stream was not exhaustively determined.

**Dating is approximate.** The oldest transcript on this machine is v2.1.170
(2026-06-10), and it already uses the `subagents/` file layout with no progress records.
The transition therefore happened at or before 2.1.170 and cannot be pinned down from
local evidence. The most likely anchor from the changelog is the shift of sub-agents to
their own transcript files, which is already in force by 2.1.170; the separate change
making sub-agents _background by default_ landed later, at v2.1.198 (see timeline).

---

## Claim 2

> The old "inline teammate" format — a named `Agent(run_in_background: true)` spawn with
> agent teams, writing the teammate transcript under the lead session's own directory
> `<projectDir>/<leadSessionId>/subagents/` — is no longer producible. On the current CLI
> a NAMED Agent spawn always creates an independent top-level session […]

**Verdict: partially confirmed — the description of the new format is accurate, but the
words "always" and "no longer producible" are refuted.**

The `<leadSessionId>/subagents/` layout is still produced by 2.1.220 for named
background Agent spawns. What determines which layout you get is whether the spawn
actually becomes a registered _team teammate_, not the CLI version.

### The described "new" format is real and current

Verified against this very session. The researcher agent that produced this document was
spawned as a named teammate, and its transcript is a top-level UUID session:

```
$ ls ~/.claude/projects/<this-repo-project-dir>/39fff716-….jsonl
$ grep -o '"teamName":"[^"]*"'  39fff716-….jsonl | sort -u   → "teamName":"session-1b296605"
$ grep -o '"agentName":"[^"]*"' 39fff716-….jsonl | sort -u   → "agentName":"harness-researcher"
$ grep -o '"version":"[0-9.]*"' 39fff716-….jsonl | sort -u   → "version":"2.1.220"
```

The implicit team config exists at `~/.claude/teams/session-1b296605/config.json`, and
its `leadSessionId` (`1b296605-7ae3-4623-a5ab-7944a1603e3d`) matches **no** transcript on
disk — confirming the existing note in this repo's `CLAUDE.md` that leads must not be
linked by that field. The lead's own transcript
(`d41e8bfa-ad2e-446f-9754-84e0743043c6.jsonl`) carries a spawn tool_result reading
`agent_id: fixture-teammate@session-1b296605` and carries **no** `teamName`/`agentName`
tags itself (`grep -c` → 0 for both).

The `session-<8hex>` naming is documented:

> "Teams and tasks are stored locally under a session-derived name. The name is
> `session-` followed by the first eight characters of the session ID: Team config:
> `~/.claude/teams/{team-name}/config.json`"
> — <https://code.claude.com/docs/en/agent-teams>

The unnamed-spawn format is also confirmed. Both sidecars in the lead's `subagents/` dir
carry exactly the fields the claim describes:

```json
{
  "agentType": "general-purpose",
  "description": "Foreground sub-agent fixture (B)",
  "toolUseId": "toolu_01JAsN89upg4q4cp9itv3mCd",
  "spawnDepth": 1,
  "model": "sonnet"
}
```

Note this sidecar is from a **foreground** spawn — the lead transcript records
`"run_in_background":false` for it. So the `subagents/` layout is not background-specific;
it is where all sub-agent transcripts go.

### What refutes the claim

First, `teammateMode` still accepts `in-process`, and the installed CLI says so itself.
Asking for an invalid value makes it print its own enum:

```
$ claude --teammate-mode nonsense-value -p hi
error: option '--teammate-mode <mode>' argument 'nonsense-value' is invalid.
       Allowed choices are auto, tmux, iterm2, in-process.
```

The docs confirm `in-process` is not merely accepted but is the **default**:

> "The default is `"in-process"`. Before v2.1.179 the default was `"auto"` […] To override
> the default, set `teammateMode` in `~/.claude/settings.json` […] To set the mode for a
> single session, pass it as a flag: `claude --teammate-mode auto`. The `--teammate-mode`
> flag is experimental and doesn't appear in `claude --help`."
> — <https://code.claude.com/docs/en/agent-teams>

A direct experiment on 2.1.220 produced the old inline layout. In an isolated working
directory, with `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` and `--teammate-mode in-process`,
a **named** Agent spawn with **`run_in_background=true`** (verified in the lead transcript:
`grep -o '"run_in_background":[a-z]*'` → `1 "run_in_background":true`) wrote:

```
…-scratchpad-probe-bg/98ce7d00-dae9-493d-a167-d88972c9a74c.jsonl          ← lead
…-scratchpad-probe-bg/98ce7d00-…/subagents/agent-a4aa8ed701f305423.jsonl  ← the named agent
…-scratchpad-probe-bg/98ce7d00-…/subagents/agent-a4aa8ed701f305423.meta.json
```

with the sidecar carrying the name:

```json
{
  "agentType": "general-purpose",
  "description": "Spawn probemate2 test agent",
  "name": "probemate2",
  "toolUseId": "toolu_01W2Uk84YaqswF5Sbm2NU2ZT",
  "spawnDepth": 1
}
```

and **no** separate top-level UUID session in that project dir. So a named,
backgrounded Agent spawn on the current CLI can and does write under
`<projectDir>/<leadSessionId>/subagents/`. "No longer producible" is false, and "a NAMED
Agent spawn _always_ creates an independent top-level session" is false.

An identical probe run with `--teammate-mode tmux` instead of `in-process` produced the
**same** result — `probe-tmux/eb35b74a-…/subagents/agent-a474cd2000e1f6177.jsonl`, again
with `"run_in_background":true` and no top-level teammate session. So `teammateMode` was
_not_ the deciding factor in these runs.

### Important qualification — what the probes do and do not show

In neither probe did a team actually form. For the `in-process` run: no
`~/.claude/teams/session-98ce7d00/` directory appeared during a 2-second-interval poll
across the whole run, no `~/.claude/tasks/session-98ce7d00/`, the agent transcript carried
no `teamName`/`agentName` tags (`grep -c` → 0), and the spawn tool_result read
`Async agent launched successfully.` rather than `agent_id: <name>@<team>`. The `tmux`
run behaved the same way. Both were non-interactive (`-p`), and that — not the teammate
mode — is the most likely reason team initialisation never happened.

So what the probes strictly establish is:

- A **named background sub-agent** that does not become a team teammate lands in
  `<leadSessionId>/subagents/` with a `name`-bearing sidecar, on 2.1.220. This alone
  refutes "always creates an independent top-level session".
- They do **not** establish where a fully team-registered _in-process teammate_ writes
  its transcript. That case could not be produced non-interactively, and no instance of
  it exists locally (see below).

On the unresolved case, two indirect signals point toward the inline location: the docs
describe in-process teammates as running "inside your main terminal" as part of the lead
process, and the binary's transcript-path builder resolves sub-agent transcripts to
`<projectDir>/<currentSessionId>/subagents/agent-<id>.jsonl` with no team-specific branch.
Neither is proof. Confirming it would need an interactive session with
`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` and `teammateMode: "in-process"`, driven through
a pty.

For a transcript-parsing consumer such as this repo the practical upshot is unaffected:
the `<leadSessionId>/subagents/` layout is still emitted by the current CLI and must
still be handled.

### Why this machine only shows the top-level format for teammates

Every teammate ever recorded locally used the tmux backend, which is why the inline form
looks extinct here. Across all 31 local team configs:

```
backendType=in-process, role=lead:      30
backendType=tmux,       role=teammate:  17
backendType=in-process, role=teammate:   0
```

The lead is always `in-process` (it _is_ the main process); every teammate on this machine
was spawned into a tmux pane, so each became an independent top-level session. Note this
is despite `in-process` being the documented default since 2.1.179 — something in the
local setup (the harness that launches these sessions) selects the tmux backend. That is
a property of the local configuration, not of the CLI version. See
[Local empirical evidence](#local-empirical-evidence) for the full config sweep.

---

## Local empirical evidence

An independent sweep of every transcript, sidecar and team config on this machine, run
separately from the web sources above. Where it agrees with the docs it is corroboration;
where it goes further (the organic-frequency findings) it is evidence the docs do not
provide.

### Corpus

| Property                           | Value                              |
| ---------------------------------- | ---------------------------------- |
| JSONL transcripts                  | 939                                |
| Project directories                | 46                                 |
| Distinct CLI versions              | 30 (2.1.170 … 2.1.220)             |
| Content-timestamp span             | 2026-06-10 → 2026-07-29 (~7 weeks) |
| `subagents/` directories           | 64                                 |
| `.meta.json` sidecars              | 147                                |
| Team configs in `~/.claude/teams/` | 31                                 |

Version coverage is dense rather than sparse — every version from 2.1.197 onward is
represented, so the recent window is well sampled:

```
2.1.170    4 files  2026-06-10        2.1.204   28  2026-07-06..07-14
2.1.181    1        2026-06-18        2.1.205    4  2026-07-09..07-12
2.1.185    1        2026-06-22        2.1.206   21  2026-07-09..07-10
2.1.187   15        2026-06-24        2.1.207   69  2026-07-08..07-14
2.1.190    2        2026-06-24        2.1.209   26  2026-07-11..07-15
2.1.191    4        2026-06-22..07-03 2.1.210   13  2026-07-15..07-20
2.1.193    1        2026-06-22        2.1.211   25  2026-07-12..07-18
2.1.195    2        2026-06-22..06-30 2.1.212    5  2026-07-17..07-18
2.1.197    1        2026-07-02        2.1.214   28  2026-07-18..07-19
2.1.198    1        2026-07-02        2.1.215  110  2026-07-15..07-23
2.1.199   25        2026-07-02..07-06 2.1.216   56  2026-07-20..07-21
2.1.200   29        2026-07-03..07-04 2.1.217   32  2026-07-21..07-24
2.1.201  272        2026-06-25..07-10 2.1.218   19  2026-07-23..07-24
2.1.202   11        2026-07-03..07-08 2.1.219    4  2026-07-21..07-24
2.1.203    1        2026-07-08        2.1.220  150  2026-07-21..07-29
```

**Retention caveat.** Claude Code deletes transcripts after `cleanupPeriodDays` (30 by
default), so this corpus is a rolling ~7-week window, not the full history of the machine.
Anything that stopped happening before 2026-06-10 leaves no trace here. That is the single
biggest limit on what these numbers can date.

### Claim 1 — `agent_progress` and the Task tool

Every form of the search returns nothing:

| Query                                                     | Files matched |
| --------------------------------------------------------- | ------------- |
| `grep -l '"agent_progress"' ~/.claude/projects/*/*.jsonl` | **0**         |
| same, recursive (incl. `subagents/`)                      | **0**         |
| `grep -rl '"type":"agent_progress"'`                      | **0**         |
| `grep -rl '"name":"Task"'`                                | **0**         |
| `grep -rl '"name": "Task"'` (space variant)               | **0**         |
| `grep -rl '"name":"Agent"'` (control)                     | **98**        |
| bare word `agent_progress`                                | 27            |

The 27 bare-word hits are **prose, not records**. The string occurs escaped
(`\"agent_progress\"`) inside conversation content, because this repo's `CLAUDE.md` and a
memory file discuss the format. The quoted-form greps are the ones that discriminate, and
they return zero. This is the trap the sweep was designed to catch: a naive
`grep -rl agent_progress` would have "found" 27 files and inverted the conclusion.

A per-file pass over all 939 transcripts (recording `grep -c` for both patterns) confirms
it file by file: **0 transcripts with `agent_progress` > 0, 0 with `Task` tool_use > 0.**

Newest 10 transcripts by mtime — all `2026-07-29`, all `"version":"2.1.220"`:

```
2026-07-29 20:07 | ap=0 task=0 agent=4 | d41e8bfa-….jsonl   (lead)
2026-07-29 20:07 | ap=0 task=0 agent=1 | 15cbecf8-….jsonl
2026-07-29 20:07 | ap=0 task=0 agent=0 | 39fff716-….jsonl   (this session)
2026-07-29 20:07 | ap=0 task=0 agent=0 | subagents/agent-ab1116a554b6317b7.jsonl
… (remaining 6 identical: ap=0 task=0)
```

Oldest 10 by mtime — `2026-06-10` / `2026-06-24`, versions `2.1.170` / `2.1.187`,
also all `ap=0 task=0`, and already using the `subagents/agent-*.jsonl` layout.

**Result: there is no "newest version that still wrote `agent_progress`" on this machine —
the format is absent from the entire retained corpus.** The same holds for the `Task` tool
name. Both therefore died at or before **v2.1.170 / 2026-06-10**, which is an upper bound,
not a date. The local corpus cannot narrow it further; the docs supply the actual rename
date (2.1.63) for the Task→Agent half.

This corroborates the web-sourced conclusion without depending on it: even if the docs
were wrong about the rename version, no transcript written in the last seven weeks
contains either construct.

### Claim 2 — inline teammates vs session teammates

**Sidecar shapes.** All 147 `.meta.json` sidecars were parsed and keyed by field set:

```
 117  agentType, description, spawnDepth, toolUseId
  17  agentType, description, model, spawnDepth, toolUseId
   4  agentType, description, spawnDepth, stoppedByUser, toolUseId
   3  agentType, description, name, spawnDepth, toolUseId
   3  agentType, description, toolUseId
   2  agentType, description, parentAgentId, spawnDepth, stoppedByUser, toolUseId
   1  agentType, description, parentAgentId, spawnDepth, toolUseId
```

**Zero sidecars are missing `toolUseId`** — there is no "old shape" (`agentType`-only)
sidecar anywhere in the corpus. The oldest sidecar (2026-06-10) is already
`agentType + description + toolUseId`; `spawnDepth` appears in everything after it, so
that field is the only shape change visible in this window.

**Named (teammate-shaped) sidecars: 3 — and all three are the synthetic probes created
for this note today** (`probemate`, `probemate2`, `tmuxmate`). In seven weeks of real
usage, **not one organic sub-agent was spawned with a `name` into a `subagents/`
directory.** So the old inline-teammate format is producible (the probes prove that) but
does not occur naturally under this machine's configuration.

**Session-teammate format.** 233 top-level transcripts carry `"agentName"`. Earliest is
**2026-07-03 at v2.1.199**; latest 2026-07-29 at v2.1.220. Distribution of first-seen
version across those 233: 2.1.199 (6), 2.1.200 (19), 2.1.201 (14), 2.1.204 (10),
2.1.206 (4), 2.1.207 (28), 2.1.209 (19), 2.1.210 (2), 2.1.211 (14), 2.1.215 (54),
2.1.217 (17), 2.1.218 (3), 2.1.220 (41).

38 distinct `teamName` values appear across all transcripts, and **every one matches
`session-<8hex>`** — zero named-team tags. The implicit-team naming scheme is the only one
present in the retained window.

**Team configs.** Of 31 entries in `~/.claude/teams/`:

- 30 are implicit `session-<8hex>` teams, earliest surviving `createdAt` **2026-07-15**
  (`session-c45753fc`), latest 2026-07-29.
- 1 is a **named** team, `poem-team`, `createdAt` **2026-04-07** — a pre-2.1.178
  explicitly-created team, older than any retained transcript. Its two teammates
  (`researcher`, `poet`) both used the tmux backend.

The 2026-07-15 date for the first surviving implicit config is _later_ than the
2026-07-03 first tagged teammate transcript. That is not a contradiction: the docs state
the team config directory "is removed when the session ends", so surviving configs are a
biased, incomplete sample. Transcript tags are the more reliable dating signal, and they
put implicit teams in use from at least v2.1.199 / 2026-07-03.

**Backend types**, across all 31 configs:

```
in-process, lead:      30
tmux,       teammate:  17
in-process, teammate:   0
```

Every teammate ever recorded on this machine ran in a tmux pane. The lead is always
`in-process` because it _is_ the main process. Despite `in-process` being the documented
default since 2.1.179, zero in-process teammates exist locally — the harness that launches
these sessions selects tmux.

**Who owns the `subagents/` directories.** Of the 64 `subagents/` dirs, the parent
transcript is team-tagged for **24** of them, untagged for **39** (one parent transcript
had been cleaned up). So the `<sessionId>/subagents/` layout is _not_ a lead-only or
legacy artefact: teammates spawn ordinary sub-agents into their own session directory the
same way leads do. It is the current, universal sub-agent layout.

### What the sweep changes

- **Strengthens Claim 1.** Independent of any web source, neither `agent_progress` nor a
  `Task` tool_use appears in 939 transcripts across 30 CLI versions and 7 weeks.
- **Strengthens the practical reading of Claim 2.** The organic corpus contains zero
  named inline teammates; every real teammate is a tagged top-level session. So the claim
  is right about what you will _observe_ in practice.
- **Does not rescue the claim's absolutes.** "No longer producible" stays refuted — the
  probes produced the layout on 2.1.220. The corpus shows it is unused here, not
  unavailable.
- **Adds a caveat the docs do not.** The `<leadSessionId>/subagents/` path is very much
  alive: 64 directories, 147 sidecars, 24 of them owned by teammates. Any consumer that
  treats that path as legacy will mis-handle current transcripts.

---

## Version timeline

| Version   | Change                                                                                                                                                             | Source                                                     | Dating                                                                                 |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| 2.1.63    | `Task` tool renamed to `Agent`; `Task(...)` kept as an alias in settings and agent-definition frontmatter                                                          | docs, sub-agents page                                      | exact                                                                                  |
| ≤ 2.1.170 | Sub-agent transcripts written to `<projectDir>/<sessionId>/subagents/agent-<id>.jsonl` + `.meta.json`; no `agent_progress` records in session JSONL                | oldest local transcript, `"version":"2.1.170"`, 2026-06-10 | **approximate** — upper bound only; the change predates all retained local transcripts |
| 2.1.178   | `TeamCreate`/`TeamDelete` removed; every session gets one implicit team; teammates spawned via the Agent tool's `name` parameter; `team_name` accepted but ignored | changelog v2.1.178; docs agent-teams note                  | exact                                                                                  |
| 2.1.179   | `teammateMode` default changed from `"auto"` to `"in-process"`                                                                                                     | docs, agent-teams page                                     | exact                                                                                  |
| 2.1.186   | `teammateMode: "iterm2"` added                                                                                                                                     | docs, agent-teams page; changelog v2.1.186                 | exact                                                                                  |
| 2.1.198   | Sub-agents run in the **background by default** (previously a gradual rollout)                                                                                     | changelog v2.1.198; docs sub-agents page                   | exact                                                                                  |
| 2.1.212   | Agent/Task tool's `mode` parameter deprecated and ignored                                                                                                          | changelog v2.1.212                                         | exact                                                                                  |
| 2.1.217   | `CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS` cap (default 20)                                                                                                            | changelog v2.1.217                                         | exact                                                                                  |
| 2.1.220   | Version under test                                                                                                                                                 | `claude --version`                                         | exact                                                                                  |

Nothing in the changelog records a version at which `agent_progress` stopped being
written to session JSONL, and no local transcript is old enough to bracket it. That one
row is the only genuinely undated transition here.

---

## Relevant knobs on the current CLI

| Knob                                       | Effect                                                                                                                                                                                                                                   | Source                                  |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
| `teammateMode` setting / `--teammate-mode` | `auto \| tmux \| iterm2 \| in-process`; documented as the teammate _display_ mode — split panes vs inside the lead's terminal. Default `in-process` since 2.1.179. Did not change transcript location in the non-interactive probes here | CLI enum error output; docs agent-teams |
| `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`   | Required to enable agent teams at all; "Without that variable, no team is set up at session start, no team directories are written"                                                                                                      | docs agent-teams                        |
| `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1`   | Keeps sub-agents synchronous/foreground                                                                                                                                                                                                  | docs sub-agents                         |
| `run_in_background` on the Agent tool      | Per-call background/foreground; `background: true` in agent frontmatter forces background                                                                                                                                                | docs sub-agents                         |
| `--forward-subagent-text`                  | Forwards sub-agent text/thinking into stream-json output; did **not** surface `agent_progress` in testing                                                                                                                                | `claude --help`; probe                  |

---

## Sources

- <https://code.claude.com/docs/en/sub-agents> (redirected from `docs.claude.com/en/docs/claude-code/sub-agents`)
- <https://code.claude.com/docs/en/agent-teams> (redirected from `docs.claude.com/en/docs/claude-code/agent-teams`)
- <https://raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md>
- Installed CLI `2.1.220` — `claude --version`, `claude --help`, `--teammate-mode` enum error, `strings` on the binary
- Machine-wide sweep of `~/.claude/projects/` (939 transcripts, 46 project dirs,
  30 CLI versions 2.1.170 – 2.1.220, content dates 2026-06-10 – 2026-07-29),
  147 `.meta.json` sidecars, and 31 team configs under `~/.claude/teams/`
- Probe sessions created for this note under a scratchpad working directory
  (`probe-inproc`, `probe-bg`, `probe-stream`, `probe-fwd`, `probe-tmux`), all stamped `"version":"2.1.220"`

---

--- METRICS ---
- Files Extracted: 8
- Estimated Token Budget: ~31276 tokens
- Canonical Reference: https://codewiki.google/github.com/pablodelucca/pixel-agents
