pixel-agents Documentation

Official TypeScript developer resources and documentation for pablodelucca/pixel-agents with 9.8k stars. Verified technical context for AI.

9,762 stars TypeScript TypeScript 8 files Β· ~31,362 tokens
RAW Doc

Technical Documentation: pablodelucca/pixel-agents

ℹ️ Provenance: Multi-Tier Ingestion Β· CodeWiki Reference

Overview (`README.md`)

The most playful way to orchestrate your agents

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 and Open VSX. 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.

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 and Discussions for what's open, and CONTRIBUTING.md to jump in.

Requirements

  • Claude Code CLI 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 or Open VSX.
  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 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 for the development workflow and 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 to chat with other users and follow development. Use Issues to report bugs or request features, and Discussions for questions and ideas.

See CONTRIBUTING.md before opening a pull request and read our Code of Conduct before participating.

Supporting the Project

Star History

License

Pixel Agents is available under the MIT 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:

text
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:

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.

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 and 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:

text
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) by Donarg β€” 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.


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

text
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
[email protected] 960 ms require() succeeds, spawn() throws posix_spawnp failed
@lydell/[email protected] 621 ms works β€” DATA:"PTY_OK\r\n", EXIT 0

Two independent problems with official node-pty:

1. No Linux prebuilds. [email protected] ships prebuilds/ for exactly four targets:

text
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/ β†’ 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: "", 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:/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:

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

text
$ 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:

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

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
/<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:

text
$ 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:

text
$ 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:

text
…-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
/<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
/<currentSessionId>/subagents/agent-.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:

text
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 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:

text
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":

text
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:

text
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:

text
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 /<sessionId>/subagents/agent-.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 ---