{"owner":"pablodelucca","repo":"pixel-agents","hasSkills":true,"totalSkillsCount":1,"totalTokensCount":10612,"categories":["claude-rule"],"hasMcp":false,"mcpConfig":null,"found":["CLAUDE.md"],"skills":{"CLAUDE.md":"# Pixel Agents — Compressed Reference\n\nPixel art office where AI agents (Claude Code terminals today, any tool tomorrow) become animated characters. Ships as a **VS Code extension** and an **`npx pixel-agents` standalone CLI** from the same source tree.\n\n`CONTEXT.md` is the canonical glossary — read it for what terms like Agent, Sub-agent, Teammate, Lead, Adopt, or Headless agent mean here, and use its vocabulary in code, comments, and docs.\n\n## Architecture\n\nStrict layering: `core/` depends on nothing; `server/` depends only on `core/`; `webview-ui/` depends only on `core/`; `adapters/vscode/` depends on `core/` and `server/`. The standalone CLI never imports `adapters/vscode/` and vice versa.\n\n```\ncore/                                Protocol + interface definitions (zero runtime side effects)\n  asyncapi.yaml                      AsyncAPI 3.0 contract — single source of truth\n  src/\n    messages.ts                      AUTO-GENERATED discriminated unions (do not edit)\n    schemas.ts                       AgentMeta, SpriteData, FurnitureCatalogEntry\n    provider.ts                      HookProvider, AgentEvent (the integration boundary)\n    teamProvider.ts                  Optional TeamProvider (semantic queries for Lead + Teammates)\n    transport.ts                     MessageTransport interface, TransportState\n    adapter.ts                       StateAdapter, AssetCache, PersistedAgent, AgentSeat\n    terminalAdapter.ts               TerminalAdapter (editor-driven terminal management)\n    normalizeProjectPath.ts\n    constants.ts\n\nserver/                              Lifecycle runtime + Fastify HTTP/WS server\n  src/\n    providers/hook/claude/           Reference HookProvider — only place that knows Claude specifics\n      claude.ts                      normalizeHookEvent for 11 Claude events, formatToolStatus, file fallback\n      claudeTeamProvider.ts          TeamProvider: reads ~/.claude/teams/<name>/config.json\n      claudeHookInstaller.ts         Consent-gated install/uninstall in ~/.claude/settings.json (abort on unparseable file or non-array hooks.<Event>; one-time .pixel-agents.backup, exclusive-create, no backup ⇒ no write — but skipped when the replaced content is entirely our own install's output, since backing up our own file masquerades as the user's original (`settingsHoldOnlyOurHooks`, compared against makeHookEntry — the WRITER — so a field added to what we write can't silently revive the bug; only `command`/`timeout` may differ, they vary across installs); every write failure THROWS; mode preserved, 0600 on create; re-read verify immediately before rename + retry; hook identity = `/.pixel-agents/hooks/claude-hook.js` suffix anchored at both ends of the command's first token, case-insensitive; `areHooksInstalled` = ANY of our commands on ANY event)\n      consentCopy.ts                 Claude's first-run consent disclosure text (scope/data/undo), served through consentDisclosure()\n      constants.ts                   Claude hook event names, script path\n      hooks/claude-hook.ts           Hook script (CJS+shebang, bundled to dist/hooks/claude-hook.js)\n    providers/hook/consentGate.ts    Provider-agnostic consent POLICY: when to ask (hooksConsentRequest per provider) and what an answer means (consentActionFor(choice, {installed, consent}) — see docs/adr/0001)\n    providers/hook/consentExecutor.ts Provider-agnostic consent EXECUTION: applyConsentChoice(providerId, choice, ConsentEffects) runs the six actions in one order for both surfaces, and SERIALIZES answers per process across ALL providers\n    providers/index.ts               Provider registry (claudeProvider + the hookProviders list the consent gate loops over)\n    agentRuntime.ts                  Lifecycle core: timers, scanners, HookEventHandler, SessionRouter, DismissalTracker\n    agentStateStore.ts               EventEmitter-backed single source of truth (typed mutations + events)\n    sessionRouter.ts                 session_id → agent_id mapping, event buffering, pending external sessions\n    dismissalTracker.ts              Unified dismissal state (replaces four legacy globals)\n    hookEventHandler.ts              Dispatches normalized AgentEvent into runtime\n    httpServer.ts                    Fastify: POST /api/hooks/:providerId, GET /api/health, GET /ws, SPA (standalone)\n    clientMessageHandler.ts          Single dispatch point for ClientMessage from webview\n    server.ts                        Top-level composition\n    cli.ts                           npx pixel-agents entry (npm bin)\n    fileStateAdapter.ts              Namespaced ~/.pixel-agents/ persistence\n    configPersistence.ts             { vscode, standalone, externalAssetDirectories, hooksConsent: {providerId: granted|declined}, hooksEnabled: {providerId: boolean} }\n    layoutPersistence.ts             ~/.pixel-agents/layout.json with atomic tmp+rename\n    fileWatcher.ts                   Hybrid fs.watch + 500ms polling, JSONL line buffering, /clear detection\n    transcriptParser.ts              JSONL parsing for heuristic / file-fallback mode\n    timerManager.ts                  Waiting / permission timers\n    assetLoader.ts                   PNG → SpriteData via pngjs\n    teamUtils.ts                     isInlineTeammateOf, getInlineTeammates, hasInlineTeammates\n    types.ts                         ServerAgentState\n    constants.ts                     All timing/scanning constants\n  __tests__/                         28 Vitest files\n  manual-hook-events.http            Manual hook testing helper (REST-Client format)\n\nadapters/vscode/                     VS Code surface — composes core + server\n  extension.ts                       activate() / deactivate()\n  PixelAgentsViewProvider.ts         WebviewViewProvider, thin bridge to AgentRuntime\n  agentManager.ts                    Terminal lifecycle (claude --session-id <uuid>), restore, persist\n  vscodeTerminalAdapter.ts           TerminalAdapter implementation\n  uninstall.ts                       vscode:uninstall hook — removes hook entries + factory-resets hooks config after extension removal\n  migrateVsCodeState.ts              One-time legacy state migration (verify-before-clear)\n  constants.ts                       VS Code IDs, command names, key names\n\nwebview-ui/                          React 19 + Canvas UI (depends only on core/)\n  src/\n    transport/\n      index.ts                       createTransport() — single runtime branching point\n      postMessageTransport.ts        VS Code mode (acquireVsCodeApi)\n      webSocketTransport.ts          Standalone mode (exponential backoff, send queue)\n      types.ts                       Re-exports MessageTransport from core\n    runtime.ts                       isBrowserRuntime detection\n    browserMock.ts                   Standalone-browser asset fetch + message injection\n    testHooks.ts                     window globals exposed for e2e (officeState, helpers)\n    main.tsx                         React entry (StrictMode + createRoot)\n    App.tsx                          Composition root (hooks + components + EditActionBar)\n    constants.ts                     Webview magic numbers/strings\n    notificationSound.ts             Web Audio API chime\n    changelogData.ts                 Changelog modal content\n    components/                      React UI (toolbars, modals, settings)\n      BottomToolbar.tsx, ZoomControls.tsx, SettingsModal.tsx, InfoModal.tsx,\n      Tooltip.tsx, DebugView.tsx, ui/Button.tsx, ...\n      AgentCard.tsx, AgentCardBar.tsx  Agent cards (mug shot + status dot); the right-edge bar on desktop/VS Code\n      cardBar.ts                     Pure card-bar rules: cardVariant, mergeOrder, reorderByPointer (Node-tested)\n      TerminalDrawer.tsx             Desktop standalone: right-docked panel, cards as tabs\n      TerminalPaneStack.tsx          Every agent's TerminalPane mounted, the shown one visible (both shells)\n      TerminalPane.tsx               One xterm bound to one agent's PTY\n      MobileAgentBar.tsx             Mobile bottom card scroller (+ launch card, long-press reorder)\n      MobileTerminalPage.tsx         Mobile terminal page (a TerminalPaneStack)\n      MobileKeyBar.tsx               Accessory keys above the software keyboard (esc, shift+tab, trackpad, paste)\n    mobile/                          The phone shell (standalone only, see \"Mobile shell\")\n      MobileShell.tsx                Sliding office/terminal track + card bar + key bar\n      useMobileShell.ts              Page state and navigation rules (toggle, card tap, launch-then-slide)\n      edgeSwipe.ts                   Edge-swipe state machine (pure reducer, Node-runner tested)\n      keyboardViewport.ts            Software-keyboard decision: open vs per-width baseline, clamp only when the visual viewport is shorter (pure)\n      useEdgeSwipe.ts                DOM adapter for the reducer (capture listeners, detached-target rescue)\n    touch/\n      touchPrimitives.ts             findTouch (track one finger by id) + withinTapSlop (the one tap metric)\n    terminal/                        Standalone terminal (xterm) support\n      terminalClient.ts              /terminal/:agentId socket (reconnect, replay)\n      terminalLinks.ts               Safe link opener + urlAtCell (one URL pattern for clicks and taps)\n      touchGesture.ts                Terminal touch state machine: scroll, flick, tap, long-press select, handles (pure reducer)\n      touchInput.ts                  DOM adapter: listeners, flick rAF, long-press timer, detached-target rescue\n      selectionMath.ts               Word expansion, range ordering, handle clamping (pure)\n      selectionOverlay.ts            Copy pill (Copy | Copy as text) + selection handles (DOM; styled in index.css)\n      clipboard.ts                   The one clipboard write/read: Clipboard API on secure origins; plain http falls back to a copy event (write) / a native prompt (paste)\n      flowCopy.ts                    terminalCopyText: verbatim by default; opt-in re-flow of Claude Code's soft wraps (pure)\n      clipboardKeys.ts               Clipboard chords (Ctrl+C/Ctrl+Shift+C copy, Alt+C copy as text, Ctrl+V paste) + OSC 52 decode (pure)\n      terminalDom.ts                 xterm screen geometry + isTypingInTerminal\n    hooks/\n      useExtensionMessages.ts        Message handler — translates ServerMessage into OfficeState mutations\n      useEditorActions.ts            Editor state + callbacks\n      useFurnitureColorActions.ts    Furniture colour sliders + colour-only eyedropper\n      useEditorKeyboard.ts           Keyboard shortcuts (R, T, Esc, Ctrl+Z/Y)\n      introTourState.ts              Intro tour wire-state machine (pure reducer, Node-runner tested)\n      useIntroTour.ts                Wires the reducer to React + transport (snapshot, verdict, choices)\n      useTerminalDrawer.ts           Terminal-pane model for both shells: active/shown pane, socket status, card lookups\n      useIsMobile.ts                 Mobile breakpoint — always false in the VS Code webview\n      useKeyboardViewport.ts         Samples the viewport: keyboard open (key bar) + iOS shell clamp height\n    office/\n      types.ts                       OfficeLayout, Character, etc. + re-exports constants\n      toolUtils.ts                   STATUS_TO_TOOL mapping, extractToolName (DOM-free; defaultZoom lives in useEditorActions)\n      projection.ts                  World→screen math shared by renderer + DOM overlays (mapOffset, overlayProjection)\n      colorize.ts                    Colorize (grayscale→HSL) + Adjust (HSL shift)\n      floorTiles.ts                  Floor sprite storage + colorized cache\n      wallTiles.ts                   Wall auto-tile: 16 bitmask sprites\n      sprites/\n        spriteData.ts                Pixel data (characters, furniture, tiles, bubbles)\n        spriteCache.ts               SpriteData → offscreen canvas, per-zoom WeakMap\n      editor/\n        editorActions.ts             Pure layout ops\n        editorState.ts               Imperative state (tools, ghost, selection, undo/redo + undo session, drag, placing)\n        editorRenderState.ts         buildEditorRenderState: ghosts + selection for the renderer (pure)\n        EditorToolbar.tsx\n      layout/\n        furnitureCatalog.ts          Dynamic catalog from loaded assets\n        layoutSerializer.ts          OfficeLayout ↔ runtime (tileMap, furniture, seats)\n        tileMap.ts                   Walkability, BFS pathfinding\n      engine/\n        characters.ts                Character FSM (idle/walk/type) + wander AI\n        officeState.ts               Game world (layout, characters, seats, selection, subagents, consent greeter)\n        gameLoop.ts                  rAF loop with delta-time cap (0.1 s)\n        renderer.ts                  Canvas: tiles, z-sorted entities, overlays, edit UI\n        matrixEffect.ts              Spawn/despawn digital rain (drawing only)\n        matrixEffectState.ts         Effect state: startMatrixEffect/advanceMatrixEffect (DOM-free)\n      components/\n        OfficeCanvas.tsx             Canvas, resize, DPR, mouse hit-testing, drag-to-move\n        useCanvasTouchGestures.ts    Touch pan / pinch zoom / tap on the canvas\n        ToolOverlay.tsx              Activity label + speech bubbles (DOM, above the labels) per character\n\ne2e/                                 Playwright suite (real VS Code + mock-claude scenarios)\n  playwright.config.ts\n  global-setup.ts\n  fixtures/\n    pixel-agents.ts                  VS Code fixture: launch Electron, wait for panel\n    standalone.ts                    Standalone CLI fixture: spawn server + browser page\n    mock-claude, mock-claude.cmd     Bash + cmd wrapper invoked instead of real claude\n    mock-claude-runner.cjs           Scenario runner: appendJsonl, emitHook, holdOpen\n  helpers/\n    launch.ts                        Electron app + isolated HOME/workspace\n    mock-claude.ts                   claudeScenario() builder\n    office.ts                        Overlay locators + assertions\n    webview.ts                       Settings/modal helpers\n    hooks.ts                         Hook server lifecycle helpers\n    standalone.ts                    Standalone server + WebSocket browser helpers\n    internal-agent.ts                spawnInternalAgentAndWait\n    lifecycle.ts                     Reusable scenario fragments\n    team.ts                          Team config seeding + teammate helpers\n    allure-labels.ts                 @area:<tag> → Allure epic\n  tests/\n    claude/hooks-on/                 basic.spec.ts, lifecycle.spec.ts, teams.spec.ts\n    claude/hooks-off/                lifecycle.spec.ts, matrix.spec.ts\n    standalone/                      hooks.spec.ts\n  README.md                          Auto-generated test inventory (regen via npm run e2e:inventory)\n\nscripts/\n  generate-messages.ts               AsyncAPI → core/src/messages.ts via Modelina (with CI drift check)\n  run-e2e.mjs                        Playwright wrapper (run-id namespacing, video attach flags)\n  generate-e2e-inventory.mjs         Splices test list into e2e/README.md (CI drift check)\n  build-allure-report.mjs            Combine e2e+server+webview Allure results\n  assemble-vercel-output.mjs         Stage /reports/allure/ for Vercel deploy\n  asset-manager.html                 Unified furniture editor (positions + metadata)\n  jsonl-viewer.html                  Standalone JSONL transcript inspector\n  wall-tile-editor.html              Wall sprite editor\n\ncore/                                npm workspace (no separate package; root manages)\nserver/                              npm workspace\nwebview-ui/                          npm workspace\n```\n\n## Distribution\n\nTwo artifacts from one source tree:\n\n- **VS Code extension** (`.vsix`) — `pablodelucca.pixel-agents` on VS Code Marketplace and Open VSX. Bundles VS Code adapter + webview SPA + assets + hook scripts.\n- **npm package** (`pixel-agents`) — `npx pixel-agents [--port 3100]` runs the Fastify server and serves the SPA on the same port. Bundles CLI + webview SPA + assets + hook scripts + `core/asyncapi.yaml` (so third-party clients can regenerate from it).\n\n`package.json:files` allowlist controls the npm tarball: `dist/cli.js{,.map}`, `dist/webview/`, `dist/assets/`, `dist/hooks/`, `core/asyncapi.yaml`, `icon.png`. `dist/extension.js` is intentionally excluded since the VS Code entry ships through the `.vsix`.\n\n## Communication Flow\n\nHub-and-spoke. The server is the single aggregation point for all agent activity, regardless of source.\n\n```\nHook scripts ─POST /api/hooks/:providerId─┐\n                                          ├─→ HookProvider.normalizeHookEvent()\nJSONL transcripts ─FileWatcher─→ TranscriptParser ┤\n                                                   ↓\n                                              AgentEvent (canonical)\n                                                   ↓\n                                              AgentRuntime (dispatch on .kind)\n                                                   ↓\n                                           AgentStateStore (mutate)\n                                                   ↓\n                                              StoreEvents → broadcast\n                                                   ↓\n                          PostMessageTransport ──┤├── WebSocketTransport\n                                 (VS Code)      (standalone browser)\n```\n\nThe VS Code adapter wires `PostMessageTransport` against `acquireVsCodeApi()`. The standalone CLI exposes the same protocol over WebSocket at `/ws` with the webview SPA served from the same Fastify instance. **The protocol shape is identical; only the wire differs.**\n\nAdding a new CLI integration is one subdirectory under `server/src/providers/hook/<id>/`: provider, optional `TeamProvider`, installer, hook scripts. Zero changes to the runtime, the UI, or any existing provider.\n\n## AsyncAPI Protocol Contract\n\n`core/asyncapi.yaml` is the contract. Pinned to **3.0.0** because `@asyncapi/modelina@5.10.1` declares `supportedVersions: ['3.0.0']` only; bumping to 3.1.0 produces `export type Root = any`. Revisit when Modelina ships 3.1.0 support.\n\n- **39 ServerMessage variants** (server → client): agent lifecycle, agent activity, sub-agent activity, team + context usage, assets, access (`officeAccess` — sent first on `webviewReady`; `readOnly` hides every control a read-only office would only have refused), settings (`bypassPermissionsUpdated` — the skip-permissions posture, broadcast to every office on change because every launch reads it) + directories (`directoriesLoaded`, `directorySaved` — a save's ack to its sender, carrying the entry as stored, whose name keys the Area mapping; `directoryRejected`, `directorySuggestions`), terminal, diagnostics.\n- **27 ClientMessage variants** (client → server): lifecycle (`webviewReady`, `launchAgent`, `focusAgent`, `closeAgent`), layout (`saveAgentSeats`, `saveLayout`, `exportLayout`, `importLayout`), settings (`setSoundEnabled`, `setHooksEnabled`, `setWatchAllSessions`, `setAlwaysShowLabels`, `setHooksInfoShown`, `setLastSeenVersion`, `setBypassPermissions`, `setShowHostDirectories` — whether the host's own Directories are listed, off by default; only the list is gated, launches still name agents after them), directories (`saveDirectory`, `removeDirectory`, `requestDirectorySuggestions`), terminal, discovery + assets, diagnostics.\n\nBoth unions use `oneOf` with `discriminator: type`. Every concrete message sets `additionalProperties: false`.\n\n`core/src/messages.ts` is generated by `scripts/generate-messages.ts` invoking `@asyncapi/modelina` with custom constraints (`propertyKey` preserves `type`/`status` field names; `constant` inlines `const` literals). The file has an auto-generation banner. **CI runs `asyncapi:generate` then `git diff --exit-code core/src/messages.ts` — any non-empty diff fails the build.**\n\n## Transport Abstraction\n\n```typescript\nexport interface MessageTransport {\n  send(msg: ClientMessage): void;\n  onMessage(handler: (msg: ServerMessage) => void): () => void;\n  readonly ready: Promise<void>;\n  readonly state: TransportState;\n  onStateChange(handler: (state: TransportState) => void): () => void;\n  dispose(): void;\n}\nexport type TransportState = 'connecting' | 'connected' | 'reconnecting' | 'disconnected';\n```\n\n`PostMessageTransport` reports `connected` for its entire lifetime. `WebSocketTransport` reconnects with exponential backoff (250 ms, 500 ms, 1 s, 2 s, 4 s, capped) and queues sends while disconnected.\n\n`createTransport()` in `webview-ui/src/transport/index.ts` is the **only branching point** in the UI codebase. Everything downstream uses the `MessageTransport` interface and never knows which transport is active.\n\n## Provider Abstraction\n\n`HookProvider` (`core/src/provider.ts`) is the integration boundary. Today only Claude Code is implemented; the Claude provider supports every transcript/hook format up to **Claude Code v2.1.220** (current as of 2026-07-30 — Task-era `agent_progress` records, explicit and implicit teams, background-by-default Agent spawns, sidecar-backed background agents). Newer CLI releases may add formats that need provider updates. The interface:\n\n- **Required**: `normalizeHookEvent(raw)` → `{ sessionId, event: AgentEvent } | null`; `installHooks` / `uninstallHooks` / `areHooksInstalled`; `formatToolStatus`; `permissionExemptTools`, `subagentToolNames`, `readingTools` sets.\n- **Optional file fallback**: `getSessionDirs(workspace)`, `getAllSessionRoots()`, `sessionFilePattern`, `parseTranscriptLine(line)`, `buildLaunchCommand(sessionId, cwd, opts)`. Used when hooks aren't installed.\n- **Optional team extension**: `team?: TeamProvider` for Lead + Teammates support.\n\n`AgentEvent.kind` values: `toolStart`, `toolEnd`, `turnEnd`, `subagentStart`, `subagentEnd`, `subagentTurnEnd`, `progress`, `permissionRequest`, `sessionStart`, `sessionEnd`. The runtime dispatches on `kind`, never on CLI-specific tool names.\n\n### TeamProvider (Lead + Teammates)\n\nOptional extension for CLIs that support team workflows (Claude Agent Teams today). Semantic queries (`discoverTeammates`, `getTeamMembers`, `getTeamMetadataForSession`, `extractTeammateNameFromEvent`, `isTeammateSpawnCall`) — providers choose their own storage strategy.\n\nFour teammate modes:\n\n| Mode                            | Trigger                                          | Detection                                                                                                                                                                                                                                                                                                                                                                               |\n| ------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Basic subagent (teams OFF)      | `Task(...)` or `Agent(run_in_background: false)` | `subagentStart` with team gate not matched, or JSONL `agent_progress`                                                                                                                                                                                                                                                                                                                   |\n| Inline teammate (teams ON)      | `Agent(run_in_background: true)` in-process      | `onTeammateDetected` → `discoverTeammates(projectDir, leadSessionId)`                                                                                                                                                                                                                                                                                                                   |\n| Session teammate (teams + tmux) | `Agent(run_in_background: true)` in tmux pane    | Own session, own hooks, routes through normal flow                                                                                                                                                                                                                                                                                                                                      |\n| Implicit-team agent (Claude 5)  | `Agent(...)` — background by default, NO flag    | Lead-side: spawn tool_result `agent_id: <name>@<team>` → `extractTeammateSpawnFromToolResult` sets lead `teamName`; teammate = own top-level UUID session tagged `teamName`/`agentName` on every record, discovered by `discoverTeammates(projectDir, leadSessionId, teamName)` and fast-attached in `onExternalSessionDetected` (bypasses the Watch All gate when the lead is tracked) |\n\nNewer harnesses (Claude 5) never tag the LEAD's records with team metadata and never write `agent_progress`/`queue-operation` records for these spawns — the spawn tool_result and the teammate's own session (own JSONL, own hooks) are the only signals. The implicit team writes `~/.claude/teams/session-<8hex>/config.json` (its `leadSessionId` matches no transcript on disk — don't link by it). Own-session teammates are registered with the SessionRouter (`setTeammateRegisterCallback`) so their hooks route directly to them.\n\n**Team generations on resume (last-wins latch)**: every CLI run of a session mints a FRESH implicit team, so a resumed lead's transcript carries spawn tool_results from several `session-<8hex>` generations. A tag-less lead re-latches to the newest spawn's team (`setTeamSwitchCallback` removes the defunct team's teammates); tag-derived identity (`teamNameFromTags`, set by the record-tags branch) is authoritative and never overwritten by spawn results. `linkTeammates` never badges a named teammate as LEAD — if only teammates are tracked, linking waits until the real lead is detected (prevents a phantom LEAD character when a teammate session is adopted before/without its lead). Restore drops `isTeamLead` from persisted agents that carry an `agentName`.\n\n**Background spawns without a CLI team (sidecar-backed)**: `Agent(...)` spawns that never register a team take the async path — result \"Async agent launched successfully. agentId: \\<hex>\" (no team anywhere), transcript + sidecar under `<projectDir>/<leadSessionId>/subagents/` (sidecar carries `agentType`/`description`/`toolUseId`, plus `name` when the spawn was named), completion via `queue-operation`. `scanForBackgroundAgentFiles` classifies them by the sidecar `name` — the domain model's sole Sub-agent vs Teammate distinction (see CONTEXT.md):\n\n- **Named → Teammate**: its own seated character, `agentName` from the sidecar `name`; the spawner gets `isTeamLead` + an `agentTeamInfo` broadcast (derived team — NO `teamName`, so config polling stays away) and the transient Subtask is ghost-killed via `subagentClear`. The child has `leadAgentId` + `spawnToolUseId`; it's removed on the completion queue-operation or lead sessionEnd (`removeTeammate` must NOT unregister its session — it shares the lead's).\n- **Unnamed → Sub-agent**: the Subtask sub-character stays; the spawn's transcript is watched in a **shadow `AgentStateStore`** (`server/src/subagentWatch.ts`, owned by `AgentRuntime`, ids from 1 000 000 up) whose broadcasts are translated onto the main store as `subagentToolStart/Done/Permission` keyed `(leadId, spawnToolUseId)` — live activity on the sub-character, no protocol change. `agentToolsClear` on the shadow agent synthesizes `subagentToolDone` for still-live tools so the sub idles between turns. Never persisted, never announced.\n\nThe gate for both: the sidecar `toolUseId` must match a LIVE entry in the lead's `backgroundAgentToolIds` (anti-spurious; no teamName involved). `scanForTeammateFiles` skips sidecars carrying a live `toolUseId` so it can't race the classification. The lead's `backgroundAgentToolIds` are persisted (children never are — they're derived state the 1 s scan re-materializes after a reload; restore skips stale entries with `leadAgentId` but no `teamName`). The turn-end background-tool re-sends (transcriptParser, hookEventHandler, timerManager's `clearAgentActivity`) must include `toolName` + `runInBackground` (or the webview can't recreate the Subtask after `agentToolsClear`) and must skip tools whose spawn became a teammate character (`hasPromotedBackgroundAgent`, or a ghost Subtask appears next to it).\n\nTeammate dismissal is driven by team config polling (`getTeamMembers(teamName)` is authoritative; members with `isActive: false` are treated as departed), by the teammate's own `sessionEnd` (own-session teammates), and by `sessionEnd` on the lead.\n\n**Subtle gate (the one that bit us recently)**: in `webview-ui/src/hooks/useExtensionMessages.ts`, `agentToolStart` with `runInBackground=true` only creates a Subtask sub-character when the **parent has no `teamName`**. With a team, `onTeammateDetected` creates the teammate instead (and the gate prevents a ghost sub-agent). Without a team, the teammate path never fires and the basic Subtask sub-character is the only visible representation. The gate's blind spot — a TEAMED lead's unnamed background spawn — is covered by `subagentToolStart` creating the sub-character lazily when it's missing (`addSubagent` is idempotent); the same lazy path recreates subs after a panel reload.\n\n## Server Runtime\n\n`AgentRuntime` (`server/src/agentRuntime.ts`) is the shared lifecycle core. Both surfaces (`adapters/vscode/PixelAgentsViewProvider.ts` and `server/src/cli.ts`) compose the same runtime; only the `StateAdapter` namespace, `MessageTransport`, and `TerminalAdapter` differ.\n\n```typescript\nexport class AgentRuntime {\n  constructor(opts: {\n    store: AgentStateStore;\n    providerRegistry: ProviderRegistry;\n    layoutPersistence: LayoutPersistence;\n    assetCache: AssetCache;\n    config: Pick<AdapterSettings, 'hooksEnabled' | 'watchAllSessions'>;\n    terminalAdapter?: TerminalAdapter;\n    callbacks: RuntimeLifecycleCallbacks;\n  });\n  registerAgent / unregisterAgent / removeAgent / removeTeammate / removeTeammates\n  restoreExternalAgents / handleHookEvent\n  startProjectScan / startExternalScanning / startStaleCheck\n  dispose();\n}\n```\n\nOwns timer Maps (waiting, permission, text-idle, stale), scanners (project-dir 1 s, external 3 s, stale 30 s), `HookEventHandler`, `SessionRouter`, `DismissalTracker`. Scanners are skipped entirely while hooks are flowing (`hookDelivered` is set on every agent). `RuntimeLifecycleCallbacks` is the only seam between runtime and host.\n\n### AgentStateStore\n\nEventEmitter-backed container in `server/src/agentStateStore.ts`. Typed mutations, typed events (`agentAdded`, `agentRemoved`, `agentUpdated`, `broadcast`). The broadcast layer subscribes once at boot and translates `StoreEvents` into `ServerMessage` over the active transport. **No module under `server/` calls a transport method directly.**\n\n### SessionRouter and DismissalTracker\n\nTwo extracted classes that replaced ad-hoc module state. `SessionRouter` owns `session_id → agent_id` mapping, pre-registration event buffering, and pending external sessions. `DismissalTracker` unifies four legacy globals (`dismissedJsonlFiles`, `clearDismissedFiles`, `seededMtimes`, `pendingClearFiles`) into one class with typed reasons.\n\n### HTTP + WebSocket Server\n\nFastify v5 with `@fastify/cors`, `@fastify/websocket`, and (in standalone) `@fastify/static`:\n\n| Method | Path                     | Purpose                                 |\n| ------ | ------------------------ | --------------------------------------- |\n| POST   | `/api/hooks/:providerId` | Bearer-authenticated hook event ingress |\n| GET    | `/api/health`            | Liveness: `{ ok, version, port, pid }`  |\n| GET    | `/ws`                    | Bidirectional protocol channel          |\n| GET    | `/*` (standalone only)   | Webview SPA via `@fastify/static`       |\n\n`/ws` is gated in **two tiers**, because messages over this socket launch shells, grant durable machine-wide hook consent, and rewrite persisted state.\n\n1. **Connection** (`isAllowedWebSocketOrigin`): embedded requires the Bearer token; standalone requires a same-origin handshake. A missing Origin still connects (non-browser clients send none). This tier is weak by design — a DNS-rebound page sends `Origin` and `Host` as the same attacker-chosen name and IS accepted (`httpServerWs.test.ts` pins that).\n2. **Privileged messages** (`standaloneTokenValid`, `ClientMessageContext.privileged`): proved by an out-of-band secret — embedded via its Bearer token, standalone via the server token in the `/ws` `?token=` query, which the CLI prints in the local URL and the SPA forwards (`webview-ui/src/transport/index.ts`). **Never a network position**: peer address, `Host` and `Origin` all ride the channel a proxy speaks, so a LAN-bound forwarder piping bytes to 127.0.0.1 satisfies all three. The token is a replayable bearer capability, not evidence of locality — it reaches browser history (Fastify's request log redacts it, `wsAuth.redactTokenQuery`), so the printed URL is documented to the user as a secret. Standalone persists it in `~/.pixel-agents/standalone-token` (0600) so a bookmarked URL survives restarts; `--rotate-token` replaces it (revoking every device at once; refused while another standalone server is running, since it would keep honouring the old one). The terminal socket (`/terminal/:agentId`) carries it the same way and both sockets decide privilege with ONE predicate, `wsAuth.standaloneHandshakeVerdict`: the token AND an allowed `Host` — loopback, a specific `--host`, or a name passed with `--allowed-host` (anti-DNS-rebinding defence in depth). The allowlist is explicit rather than \"loopback when loopback-bound\" because a reverse proxy (Tailscale Serve) forwards to 127.0.0.1 under its public name; a loopback-only rule forced `--host 0.0.0.0`, exposing the port and the token in transit to the LAN. **An unprivileged client is read-only**: it may load and watch the office (`webviewReady`) and NOTHING else — every other message is refused by one gate at the top of `handleClientMessage` (`refuseReadOnly`), so a new message type is privileged by default. Where the UI updates optimistically (settings toggles, hooks toggle, asset directories, area mappings), the refused client is re-sent the real state. A tokened connection under an unlisted Host is read-only too and logs the `--allowed-host` hint. A second standalone launch reuses a running server only when its access matches (`ServerConfig.standalone`: terminal on/off, bind host, allowed hosts) — otherwise `--no-terminal` would silently attach to a server with a shell.\n\nThe **hooks preference is persisted only after the install/uninstall settled and the on-disk result agrees** — writing it first strands the user when an uninstall fails: entries still firing, but a persisted hooks-off makes the next startup skip the consent/install path entirely.\n\nServer discovery written to `~/.pixel-agents/server.json` with `{ port, pid, authToken }`. Multi-window safe: a second server detects an existing `server.json` and reuses or replaces it based on PID liveness.\n\n### ClientMessageHandler\n\nSingle dispatch point for `ClientMessage`. Each variant calls into `AgentRuntime`, `AgentStateStore`, `LayoutPersistence`, or `FileStateAdapter`, or delegates to host-specific callbacks (`onLaunchAgent`, `onOpenSessionsFolder`, `onExportLayout`, `onImportLayout`, `onSetHooksEnabled`). Both surfaces wire the same handler.\n\n### ServerAgentState (server/src/types.ts)\n\nPer-agent runtime data: provider reference, session key, transcript-fallback fields (`jsonlFile`, `fileOffset`, `lineBuffer`), tool state Maps and Sets, team fields (`teamName`, `agentName`, `isTeamLead`, `leadAgentId`, `teamUsesTmux`), context usage (`contextTokens`, `maxContextTokens`, `sawMainChainUsage`), and the **`hookDelivered`** flag that suppresses heuristic timers when hooks are flowing.\n\n## Persistence\n\n```\n~/.pixel-agents/\n  config.json              { vscode, standalone, externalAssetDirectories, hooksConsent, hooksEnabled (both per-provider) }\n  vscode-state.json        { agents, seats }\n  standalone-state.json    { agents, seats }\n  layout.json              OfficeLayout (shared across surfaces)\n  server.json              { port, pid, authToken }\n  hooks/claude-hook.js     Bundled hook script (CJS, shebang)\n```\n\n`FileStateAdapter({ namespace })` backs both runtimes. Per-namespace settings: `soundEnabled`, `lastSeenVersion`, `alwaysShowLabels`, `watchAllSessions`, `hooksInfoShown` (the hooks preference is per-provider and machine-global, at the config top level). Running both surfaces in parallel never clobbers either.\n\n`migrateVsCodeState` (VS Code adapter only) walks each known legacy key once with **verify-before-clear** semantics: write to file, read back, only then clear the legacy key. While anything remains unmigrated, activation shows a non-blocking warning.\n\nLayout writes are atomic via tmp + rename. Cross-window watching is hybrid (`fs.watch` + 2 s polling). `markOwnWrite()` prevents the watcher from re-reading our own write.\n\n## Agent Status Tracking\n\nJSONL transcripts at `~/.claude/projects/<project-hash>/<session-id>.jsonl`. Project hash = workspace path with `:`/`\\`/`/` → `-`.\n\n**JSONL record types**: `assistant` (tool_use or thinking), `user` (tool_result or text prompt), `system` with `subtype: \"turn_duration\"` (reliable turn-end signal), `progress` with `data.type`: `agent_progress` (sub-agent tool_use/tool_result, non-exempt tools trigger permission timers), `bash_progress` (Bash output — restarts permission timer), `mcp_progress` (MCP tool — same timer restart). Also observed but not tracked: `file-history-snapshot`, `queue-operation`.\n\n**File watching**: 500 ms polling with partial-line buffering for mid-write reads. Tool-done messages delayed 300 ms to prevent React batching from hiding brief active states.\n\n### Dual-mode detection\n\n| Mode                     | Source                                                 | Detection                                                                                                                                                                                                                                                                         |\n| ------------------------ | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| **Hooks** (preferred)    | Claude Code Hooks API → HTTP POST → `HookEventHandler` | Instant, reliable. Installed events are `CLAUDE_HOOK_EVENTS`. `UserPromptSubmit`/`TaskCreated` are deliberately NOT installed — they normalize to null, so installing them only forwarded prompt text to be dropped; `normalizeHookEvent` still tolerates them for stale installs |\n| **Heuristic** (fallback) | Polling JSONL files                                    | Per-agent 500 ms JSONL polling for /clear detection; 1 s main scanner for terminal adoption; 3 s external scanner; 30 s stale check. Content-based /clear detection (`/clear</command-name>` in first 8 KB)                                                                       |\n\nThe `hookDelivered` flag (per agent) and `hooksEnabled` (global) gate timer logic. JSONL polling always runs in both modes for tool content (status text, animations); only permission (7 s) and text-idle (5 s) timers are suppressed by `hookDelivered`.\n\n### Sub-agent permission detection (heuristic)\n\nWhen a sub-agent runs a non-exempt tool, `startPermissionTimer` fires on the parent agent. If `PERMISSION_TIMER_DELAY_MS` (7 s) elapses with no data, permission bubbles appear on both parent and sub-agent characters via `agentToolPermission` + `subagentToolPermission` broadcasts.\n\n`activeSubagentToolNames: Map<parentToolId, Map<subToolId, toolName>>` tracks which sub-tools are active for the exempt check. Cleared when data resumes, Task completes, or `turn_duration` arrives.\n\n**Timing budget for tests**: the test waits for the bubble AFTER waiting for the \"Subtask:\" overlay. But the Subtask overlay appears when the parent's **Task tool_use** is parsed (scenario time T), while the timer only starts when the **sub-tool tool_use** in the progress record is parsed (~1 s later). Plus 7 s timer + 300 ms IPC/render slop = a wait timeout less than ~10 s is unsafe.\n\n### Context usage (server/src/contextUsage.ts)\n\nEvery agent's context gauge. Fed from `message.usage` on assistant records by `processTranscriptLine`, broadcast as `agentContextUsage` and rendered by `ToolOverlay`.\n\n- **Snapshot, not a total.** Context = the newest turn's `input_tokens + cache_creation_input_tokens + cache_read_input_tokens + output_tokens`. Summing turns measures spend, not occupancy, and misses the cached tokens that ARE the context (`input_tokens` is single digits once caching kicks in). Falls on compaction and `/clear` for free.\n- **All-zero usage means \"no news\"** — synthetic records (API errors, interrupts) report zeros and must not blank the gauge.\n- **Sidechain rule**: a lead's sidechain records are its sub-agents' turns and must not move its gauge; a teammate's own transcript is sidechain top to bottom and would otherwise never report. Positional latch (`sawMainChainUsage`): once a file produces a main-chain turn, later sidechain records belong to someone else.\n- **The window comes from the provider**, not from the runtime: `HookProvider.contextWindowForModel(model)` (Claude: 1M for the current line, 200k for Haiku and the older models, `undefined` for ids it can't place). Transcripts state usage but never the limit, and guessing 200k for a 1M model reads five times too full. `widenContextWindow` is the backstop for unrecognized models and unknown-larger windows — it only ever widens, since a context that doesn't fit disproves the assumption while a shrinking one proves nothing.\n- **`seedContextUsage` runs once per agent in `startFileWatching`** — the single seam every watched agent passes through. Agents adopted or restored mid-session start at end-of-file, so without a tail read they'd have no gauge until their next turn.\n- Sub-agents get no gauge: no session of their own, and the shadow store never forwards `agentContextUsage`.\n\n## Office UI\n\n**Rendering**: Game state in imperative `OfficeState` class (not React state). Pixel-perfect: zoom = integer device-pixels-per-sprite-pixel (1x–10x). No `ctx.scale(dpr)`. Default zoom = `Math.round(2 * devicePixelRatio)`. Z-sort all entities by Y. Pan via middle-mouse drag (`panRef`). **Camera follow**: `cameraFollowId` (separate from `selectedAgentId`) smoothly centers camera on the followed agent; set on agent click, cleared on deselection or manual pan.\n\n**UI styling**: Pixel art aesthetic — sharp corners (`borderRadius: 0`), solid backgrounds (`#1e1e2e`), `2px solid` borders, hard offset shadows (`2px 2px 0px #0a0a14`, no blur). CSS variables in `index.css` `:root` (`--pixel-bg`, `--pixel-border`, `--pixel-accent`, ...). Pixel font: FS Pixel Sans (`webview-ui/src/fonts/`), loaded via `@font-face`, applied globally.\n\nCustom ESLint rules (`eslint-rules/pixel-agents-rules.mjs`) enforce: `no-inline-colors` (hex/rgb/rgba/hsl/hsla literals only in `constants.ts`), `pixel-shadow` (must use `var(--pixel-shadow)` or `2px 2px 0px`), `pixel-font` (must reference FS Pixel Sans). All `error`-level — they block PRs.\n\n**Characters**: FSM states — active (pathfind to seat, typing/reading animation by tool type), idle (wander randomly with BFS, return to seat after `wanderLimit` moves). 4-directional sprites, left = flipped right. Tool animations: typing (Write/Edit/Bash/Task) vs reading (Read/Grep/Glob/WebFetch). Sitting offset: characters shift down 6 px in TYPE state. Z-sort uses `ch.y + TILE_SIZE/2 + 0.5` so characters render in front of same-row furniture but behind lower-row furniture. **Chair z-sorting**: non-back chairs use `zY = (row+1)*TILE_SIZE` (capped to first row); back-facing chairs use `zY = (row+1)*TILE_SIZE + 1` so the chair back renders in front of the character. Chair tiles are blocked for all characters except their own assigned seat (per-character pathfinding via `withOwnSeatUnblocked`).\n\n**Diverse palette assignment**: `pickDiversePalette()` counts palettes of current non-sub-agent characters; picks randomly from least-used palette(s). First 6 agents each get a unique skin; beyond 6, skins repeat with a random hue shift (45–315°) via `adjustSprite()`. Character stores `palette` (0-5) + `hueShift` (degrees). Sprite cache keyed by `\"palette:hueShift\"`.\n\n**Spawn/despawn effect**: Matrix-style digital rain animation (0.3 s). 16 vertical columns sweep top-to-bottom with staggered timing. Spawn: green rain reveals character pixels. Despawn: character pixels consumed by green rain trails. `matrixEffect` field on Character (`'spawn'`/`'despawn'`/`null`). Normal FSM is paused during effect. Restored agents (`existingAgents`) use `skipSpawnEffect: true` to appear instantly.\n\n**Sub-agents**: Negative IDs (from -1 down). Created on `agentToolStart` with \"Subtask:\" prefix, or lazily by `subagentToolStart` when missing (watched background spawns, post-reload recreation). Same palette + hueShift as parent. Click focuses parent terminal. Not persisted. Spawn at the closest free walkable tile to the parent (`closestFreeWalkableTile`) — around it, never in a seat. Idle (stop typing) when every tracked sub-tool row is done; overlay shows the latest non-done sub-tool status, falling back to the Subtask label.\n\n**Speech bubbles**: Permission (\"...\" amber dots) stays until clicked/cleared. Waiting (green checkmark) auto-fades 2 s. Sprites in `spriteData.ts`.\n\n**Sound notifications**: Ascending two-note chime (E5 → E6) via Web Audio API plays when waiting bubble appears (`agentStatus: 'waiting'`). `notificationSound.ts` manages AudioContext lifecycle; `unlockAudio()` on canvas mousedown resumes the context (webviews start suspended). Toggled via Settings modal. Persisted per-namespace in `~/.pixel-agents/config.json`.\n\n**Mobile shell** (standalone only): below `MOBILE_MEDIA_QUERY` the browser renders `mobile/MobileShell` instead of the desktop layout — office and terminal as two full-screen pages in a 200%-wide sliding track (both always laid out, never `display:none`, so the canvas and xterm keep dimensions); the track's transform makes it the containing block of `position: fixed`, so modals and other viewport overlays mount at App's top level, never inside the office region), the agent cards in a bottom scroller, and `MobileKeyBar` above the software keyboard. \"Keyboard open\" and \"clamp the shell\" are separate answers (`mobile/keyboardViewport.ts`, sampled by `useKeyboardViewport`) because the two resize policies differ: iOS shrinks only the visual viewport (open, and the shell is clamped to it so the PTY resizes instead of hiding its input line), Android `interactive-widget=resizes-content` shrinks both (open, nothing to clamp). Open = an editable focused AND the visual viewport `KEYBOARD_MIN_HEIGHT_PX` below the tallest height seen at the current width (per-orientation baseline). `useIsMobile` is gated on `isBrowserRuntime`: a narrow VS Code panel must never get it (it broke every VS Code e2e shard once). Navigation rules live in `useMobileShell`: the >_ / Office toggle and the edge swipe share one handler; a card tap in the office is two-step (focus the character, then open its terminal), in the terminal it switches panes; + slides over only for launches made from it (a reload re-announcing sessions stays on the office). The terminal-pane model (active vs shown pane, socket status, `statusFor`) is `useTerminalDrawer`, shared with the desktop drawer. Touch rules: every gesture tracks ONE finger by identifier (`findTouch`) and uses one tap slop (`withinTapSlop`); gesture decisions are pure reducers with thin DOM adapters (`edgeSwipe.ts`/`useEdgeSwipe.ts`, `terminal/touchGesture.ts`/`touchInput.ts`), because WebKit keeps addressing a gesture's events to its touchstart node — when terminal rows repaint, that node detaches and events stop bubbling, so each adapter binds \"rescue\" listeners on the touch target. **Terminal copy is verbatim by default** (Cmd/Ctrl+C, the browser's Copy, the pill's Copy): `flowTerminalCopy` is a heuristic that joins a long row with a two-space-indented next line, which is also what YAML under a long key or a method body under a long signature look like, so re-flowing is opt-in only — Alt+C (Option+C) on desktop, \"Copy as text\" on the pill. Every write goes through `terminal/clipboard.ts`. PWA: `manifest.webmanifest` (icon paths relative — the file is served verbatim), conventional-path icons in `public/`, `?v=N` cache-busters on icon links.\n\n**Seats**: Derived from chair furniture. `layoutToSeats()` creates a seat at every footprint tile of every chair. Multi-tile chairs produce multiple seats keyed `uid` / `uid:1` / `uid:2`. Facing direction priority: 1) chair `orientation` from catalog (front→DOWN, back→UP, left→LEFT, right→RIGHT), 2) adjacent desk direction, 3) forward (DOWN). Click character → select (white outline) → click available seat → reassign.\n\n## Layout Editor\n\nToggle via \"Layout\" button. Tools: SELECT (default), Floor paint, Wall paint, Erase (set tiles to VOID), Furniture place, Furniture Copy (`FURNITURE_PICK`: type + colour off a placed item), colour Copy (`COLOR_PICK`: colour only), Eyedrop"},"files":{"CLAUDE.md":"# Pixel Agents — Compressed Reference\n\nPixel art office where AI agents (Claude Code terminals today, any tool tomorrow) become animated characters. Ships as a **VS Code extension** and an **`npx pixel-agents` standalone CLI** from the same source tree.\n\n`CONTEXT.md` is the canonical glossary — read it for what terms like Agent, Sub-agent, Teammate, Lead, Adopt, or Headless agent mean here, and use its vocabulary in code, comments, and docs.\n\n## Architecture\n\nStrict layering: `core/` depends on nothing; `server/` depends only on `core/`; `webview-ui/` depends only on `core/`; `adapters/vscode/` depends on `core/` and `server/`. The standalone CLI never imports `adapters/vscode/` and vice versa.\n\n```\ncore/                                Protocol + interface definitions (zero runtime side effects)\n  asyncapi.yaml                      AsyncAPI 3.0 contract — single source of truth\n  src/\n    messages.ts                      AUTO-GENERATED discriminated unions (do not edit)\n    schemas.ts                       AgentMeta, SpriteData, FurnitureCatalogEntry\n    provider.ts                      HookProvider, AgentEvent (the integration boundary)\n    teamProvider.ts                  Optional TeamProvider (semantic queries for Lead + Teammates)\n    transport.ts                     MessageTransport interface, TransportState\n    adapter.ts                       StateAdapter, AssetCache, PersistedAgent, AgentSeat\n    terminalAdapter.ts               TerminalAdapter (editor-driven terminal management)\n    normalizeProjectPath.ts\n    constants.ts\n\nserver/                              Lifecycle runtime + Fastify HTTP/WS server\n  src/\n    providers/hook/claude/           Reference HookProvider — only place that knows Claude specifics\n      claude.ts                      normalizeHookEvent for 11 Claude events, formatToolStatus, file fallback\n      claudeTeamProvider.ts          TeamProvider: reads ~/.claude/teams/<name>/config.json\n      claudeHookInstaller.ts         Consent-gated install/uninstall in ~/.claude/settings.json (abort on unparseable file or non-array hooks.<Event>; one-time .pixel-agents.backup, exclusive-create, no backup ⇒ no write — but skipped when the replaced content is entirely our own install's output, since backing up our own file masquerades as the user's original (`settingsHoldOnlyOurHooks`, compared against makeHookEntry — the WRITER — so a field added to what we write can't silently revive the bug; only `command`/`timeout` may differ, they vary across installs); every write failure THROWS; mode preserved, 0600 on create; re-read verify immediately before rename + retry; hook identity = `/.pixel-agents/hooks/claude-hook.js` suffix anchored at both ends of the command's first token, case-insensitive; `areHooksInstalled` = ANY of our commands on ANY event)\n      consentCopy.ts                 Claude's first-run consent disclosure text (scope/data/undo), served through consentDisclosure()\n      constants.ts                   Claude hook event names, script path\n      hooks/claude-hook.ts           Hook script (CJS+shebang, bundled to dist/hooks/claude-hook.js)\n    providers/hook/consentGate.ts    Provider-agnostic consent POLICY: when to ask (hooksConsentRequest per provider) and what an answer means (consentActionFor(choice, {installed, consent}) — see docs/adr/0001)\n    providers/hook/consentExecutor.ts Provider-agnostic consent EXECUTION: applyConsentChoice(providerId, choice, ConsentEffects) runs the six actions in one order for both surfaces, and SERIALIZES answers per process across ALL providers\n    providers/index.ts               Provider registry (claudeProvider + the hookProviders list the consent gate loops over)\n    agentRuntime.ts                  Lifecycle core: timers, scanners, HookEventHandler, SessionRouter, DismissalTracker\n    agentStateStore.ts               EventEmitter-backed single source of truth (typed mutations + events)\n    sessionRouter.ts                 session_id → agent_id mapping, event buffering, pending external sessions\n    dismissalTracker.ts              Unified dismissal state (replaces four legacy globals)\n    hookEventHandler.ts              Dispatches normalized AgentEvent into runtime\n    httpServer.ts                    Fastify: POST /api/hooks/:providerId, GET /api/health, GET /ws, SPA (standalone)\n    clientMessageHandler.ts          Single dispatch point for ClientMessage from webview\n    server.ts                        Top-level composition\n    cli.ts                           npx pixel-agents entry (npm bin)\n    fileStateAdapter.ts              Namespaced ~/.pixel-agents/ persistence\n    configPersistence.ts             { vscode, standalone, externalAssetDirectories, hooksConsent: {providerId: granted|declined}, hooksEnabled: {providerId: boolean} }\n    layoutPersistence.ts             ~/.pixel-agents/layout.json with atomic tmp+rename\n    fileWatcher.ts                   Hybrid fs.watch + 500ms polling, JSONL line buffering, /clear detection\n    transcriptParser.ts              JSONL parsing for heuristic / file-fallback mode\n    timerManager.ts                  Waiting / permission timers\n    assetLoader.ts                   PNG → SpriteData via pngjs\n    teamUtils.ts                     isInlineTeammateOf, getInlineTeammates, hasInlineTeammates\n    types.ts                         ServerAgentState\n    constants.ts                     All timing/scanning constants\n  __tests__/                         28 Vitest files\n  manual-hook-events.http            Manual hook testing helper (REST-Client format)\n\nadapters/vscode/                     VS Code surface — composes core + server\n  extension.ts                       activate() / deactivate()\n  PixelAgentsViewProvider.ts         WebviewViewProvider, thin bridge to AgentRuntime\n  agentManager.ts                    Terminal lifecycle (claude --session-id <uuid>), restore, persist\n  vscodeTerminalAdapter.ts           TerminalAdapter implementation\n  uninstall.ts                       vscode:uninstall hook — removes hook entries + factory-resets hooks config after extension removal\n  migrateVsCodeState.ts              One-time legacy state migration (verify-before-clear)\n  constants.ts                       VS Code IDs, command names, key names\n\nwebview-ui/                          React 19 + Canvas UI (depends only on core/)\n  src/\n    transport/\n      index.ts                       createTransport() — single runtime branching point\n      postMessageTransport.ts        VS Code mode (acquireVsCodeApi)\n      webSocketTransport.ts          Standalone mode (exponential backoff, send queue)\n      types.ts                       Re-exports MessageTransport from core\n    runtime.ts                       isBrowserRuntime detection\n    browserMock.ts                   Standalone-browser asset fetch + message injection\n    testHooks.ts                     window globals exposed for e2e (officeState, helpers)\n    main.tsx                         React entry (StrictMode + createRoot)\n    App.tsx                          Composition root (hooks + components + EditActionBar)\n    constants.ts                     Webview magic numbers/strings\n    notificationSound.ts             Web Audio API chime\n    changelogData.ts                 Changelog modal content\n    components/                      React UI (toolbars, modals, settings)\n      BottomToolbar.tsx, ZoomControls.tsx, SettingsModal.tsx, InfoModal.tsx,\n      Tooltip.tsx, DebugView.tsx, ui/Button.tsx, ...\n      AgentCard.tsx, AgentCardBar.tsx  Agent cards (mug shot + status dot); the right-edge bar on desktop/VS Code\n      cardBar.ts                     Pure card-bar rules: cardVariant, mergeOrder, reorderByPointer (Node-tested)\n      TerminalDrawer.tsx             Desktop standalone: right-docked panel, cards as tabs\n      TerminalPaneStack.tsx          Every agent's TerminalPane mounted, the shown one visible (both shells)\n      TerminalPane.tsx               One xterm bound to one agent's PTY\n      MobileAgentBar.tsx             Mobile bottom card scroller (+ launch card, long-press reorder)\n      MobileTerminalPage.tsx         Mobile terminal page (a TerminalPaneStack)\n      MobileKeyBar.tsx               Accessory keys above the software keyboard (esc, shift+tab, trackpad, paste)\n    mobile/                          The phone shell (standalone only, see \"Mobile shell\")\n      MobileShell.tsx                Sliding office/terminal track + card bar + key bar\n      useMobileShell.ts              Page state and navigation rules (toggle, card tap, launch-then-slide)\n      edgeSwipe.ts                   Edge-swipe state machine (pure reducer, Node-runner tested)\n      keyboardViewport.ts            Software-keyboard decision: open vs per-width baseline, clamp only when the visual viewport is shorter (pure)\n      useEdgeSwipe.ts                DOM adapter for the reducer (capture listeners, detached-target rescue)\n    touch/\n      touchPrimitives.ts             findTouch (track one finger by id) + withinTapSlop (the one tap metric)\n    terminal/                        Standalone terminal (xterm) support\n      terminalClient.ts              /terminal/:agentId socket (reconnect, replay)\n      terminalLinks.ts               Safe link opener + urlAtCell (one URL pattern for clicks and taps)\n      touchGesture.ts                Terminal touch state machine: scroll, flick, tap, long-press select, handles (pure reducer)\n      touchInput.ts                  DOM adapter: listeners, flick rAF, long-press timer, detached-target rescue\n      selectionMath.ts               Word expansion, range ordering, handle clamping (pure)\n      selectionOverlay.ts            Copy pill (Copy | Copy as text) + selection handles (DOM; styled in index.css)\n      clipboard.ts                   The one clipboard write/read: Clipboard API on secure origins; plain http falls back to a copy event (write) / a native prompt (paste)\n      flowCopy.ts                    terminalCopyText: verbatim by default; opt-in re-flow of Claude Code's soft wraps (pure)\n      clipboardKeys.ts               Clipboard chords (Ctrl+C/Ctrl+Shift+C copy, Alt+C copy as text, Ctrl+V paste) + OSC 52 decode (pure)\n      terminalDom.ts                 xterm screen geometry + isTypingInTerminal\n    hooks/\n      useExtensionMessages.ts        Message handler — translates ServerMessage into OfficeState mutations\n      useEditorActions.ts            Editor state + callbacks\n      useFurnitureColorActions.ts    Furniture colour sliders + colour-only eyedropper\n      useEditorKeyboard.ts           Keyboard shortcuts (R, T, Esc, Ctrl+Z/Y)\n      introTourState.ts              Intro tour wire-state machine (pure reducer, Node-runner tested)\n      useIntroTour.ts                Wires the reducer to React + transport (snapshot, verdict, choices)\n      useTerminalDrawer.ts           Terminal-pane model for both shells: active/shown pane, socket status, card lookups\n      useIsMobile.ts                 Mobile breakpoint — always false in the VS Code webview\n      useKeyboardViewport.ts         Samples the viewport: keyboard open (key bar) + iOS shell clamp height\n    office/\n      types.ts                       OfficeLayout, Character, etc. + re-exports constants\n      toolUtils.ts                   STATUS_TO_TOOL mapping, extractToolName (DOM-free; defaultZoom lives in useEditorActions)\n      projection.ts                  World→screen math shared by renderer + DOM overlays (mapOffset, overlayProjection)\n      colorize.ts                    Colorize (grayscale→HSL) + Adjust (HSL shift)\n      floorTiles.ts                  Floor sprite storage + colorized cache\n      wallTiles.ts                   Wall auto-tile: 16 bitmask sprites\n      sprites/\n        spriteData.ts                Pixel data (characters, furniture, tiles, bubbles)\n        spriteCache.ts               SpriteData → offscreen canvas, per-zoom WeakMap\n      editor/\n        editorActions.ts             Pure layout ops\n        editorState.ts               Imperative state (tools, ghost, selection, undo/redo + undo session, drag, placing)\n        editorRenderState.ts         buildEditorRenderState: ghosts + selection for the renderer (pure)\n        EditorToolbar.tsx\n      layout/\n        furnitureCatalog.ts          Dynamic catalog from loaded assets\n        layoutSerializer.ts          OfficeLayout ↔ runtime (tileMap, furniture, seats)\n        tileMap.ts                   Walkability, BFS pathfinding\n      engine/\n        characters.ts                Character FSM (idle/walk/type) + wander AI\n        officeState.ts               Game world (layout, characters, seats, selection, subagents, consent greeter)\n        gameLoop.ts                  rAF loop with delta-time cap (0.1 s)\n        renderer.ts                  Canvas: tiles, z-sorted entities, overlays, edit UI\n        matrixEffect.ts              Spawn/despawn digital rain (drawing only)\n        matrixEffectState.ts         Effect state: startMatrixEffect/advanceMatrixEffect (DOM-free)\n      components/\n        OfficeCanvas.tsx             Canvas, resize, DPR, mouse hit-testing, drag-to-move\n        useCanvasTouchGestures.ts    Touch pan / pinch zoom / tap on the canvas\n        ToolOverlay.tsx              Activity label + speech bubbles (DOM, above the labels) per character\n\ne2e/                                 Playwright suite (real VS Code + mock-claude scenarios)\n  playwright.config.ts\n  global-setup.ts\n  fixtures/\n    pixel-agents.ts                  VS Code fixture: launch Electron, wait for panel\n    standalone.ts                    Standalone CLI fixture: spawn server + browser page\n    mock-claude, mock-claude.cmd     Bash + cmd wrapper invoked instead of real claude\n    mock-claude-runner.cjs           Scenario runner: appendJsonl, emitHook, holdOpen\n  helpers/\n    launch.ts                        Electron app + isolated HOME/workspace\n    mock-claude.ts                   claudeScenario() builder\n    office.ts                        Overlay locators + assertions\n    webview.ts                       Settings/modal helpers\n    hooks.ts                         Hook server lifecycle helpers\n    standalone.ts                    Standalone server + WebSocket browser helpers\n    internal-agent.ts                spawnInternalAgentAndWait\n    lifecycle.ts                     Reusable scenario fragments\n    team.ts                          Team config seeding + teammate helpers\n    allure-labels.ts                 @area:<tag> → Allure epic\n  tests/\n    claude/hooks-on/                 basic.spec.ts, lifecycle.spec.ts, teams.spec.ts\n    claude/hooks-off/                lifecycle.spec.ts, matrix.spec.ts\n    standalone/                      hooks.spec.ts\n  README.md                          Auto-generated test inventory (regen via npm run e2e:inventory)\n\nscripts/\n  generate-messages.ts               AsyncAPI → core/src/messages.ts via Modelina (with CI drift check)\n  run-e2e.mjs                        Playwright wrapper (run-id namespacing, video attach flags)\n  generate-e2e-inventory.mjs         Splices test list into e2e/README.md (CI drift check)\n  build-allure-report.mjs            Combine e2e+server+webview Allure results\n  assemble-vercel-output.mjs         Stage /reports/allure/ for Vercel deploy\n  asset-manager.html                 Unified furniture editor (positions + metadata)\n  jsonl-viewer.html                  Standalone JSONL transcript inspector\n  wall-tile-editor.html              Wall sprite editor\n\ncore/                                npm workspace (no separate package; root manages)\nserver/                              npm workspace\nwebview-ui/                          npm workspace\n```\n\n## Distribution\n\nTwo artifacts from one source tree:\n\n- **VS Code extension** (`.vsix`) — `pablodelucca.pixel-agents` on VS Code Marketplace and Open VSX. Bundles VS Code adapter + webview SPA + assets + hook scripts.\n- **npm package** (`pixel-agents`) — `npx pixel-agents [--port 3100]` runs the Fastify server and serves the SPA on the same port. Bundles CLI + webview SPA + assets + hook scripts + `core/asyncapi.yaml` (so third-party clients can regenerate from it).\n\n`package.json:files` allowlist controls the npm tarball: `dist/cli.js{,.map}`, `dist/webview/`, `dist/assets/`, `dist/hooks/`, `core/asyncapi.yaml`, `icon.png`. `dist/extension.js` is intentionally excluded since the VS Code entry ships through the `.vsix`.\n\n## Communication Flow\n\nHub-and-spoke. The server is the single aggregation point for all agent activity, regardless of source.\n\n```\nHook scripts ─POST /api/hooks/:providerId─┐\n                                          ├─→ HookProvider.normalizeHookEvent()\nJSONL transcripts ─FileWatcher─→ TranscriptParser ┤\n                                                   ↓\n                                              AgentEvent (canonical)\n                                                   ↓\n                                              AgentRuntime (dispatch on .kind)\n                                                   ↓\n                                           AgentStateStore (mutate)\n                                                   ↓\n                                              StoreEvents → broadcast\n                                                   ↓\n                          PostMessageTransport ──┤├── WebSocketTransport\n                                 (VS Code)      (standalone browser)\n```\n\nThe VS Code adapter wires `PostMessageTransport` against `acquireVsCodeApi()`. The standalone CLI exposes the same protocol over WebSocket at `/ws` with the webview SPA served from the same Fastify instance. **The protocol shape is identical; only the wire differs.**\n\nAdding a new CLI integration is one subdirectory under `server/src/providers/hook/<id>/`: provider, optional `TeamProvider`, installer, hook scripts. Zero changes to the runtime, the UI, or any existing provider.\n\n## AsyncAPI Protocol Contract\n\n`core/asyncapi.yaml` is the contract. Pinned to **3.0.0** because `@asyncapi/modelina@5.10.1` declares `supportedVersions: ['3.0.0']` only; bumping to 3.1.0 produces `export type Root = any`. Revisit when Modelina ships 3.1.0 support.\n\n- **39 ServerMessage variants** (server → client): agent lifecycle, agent activity, sub-agent activity, team + context usage, assets, access (`officeAccess` — sent first on `webviewReady`; `readOnly` hides every control a read-only office would only have refused), settings (`bypassPermissionsUpdated` — the skip-permissions posture, broadcast to every office on change because every launch reads it) + directories (`directoriesLoaded`, `directorySaved` — a save's ack to its sender, carrying the entry as stored, whose name keys the Area mapping; `directoryRejected`, `directorySuggestions`), terminal, diagnostics.\n- **27 ClientMessage variants** (client → server): lifecycle (`webviewReady`, `launchAgent`, `focusAgent`, `closeAgent`), layout (`saveAgentSeats`, `saveLayout`, `exportLayout`, `importLayout`), settings (`setSoundEnabled`, `setHooksEnabled`, `setWatchAllSessions`, `setAlwaysShowLabels`, `setHooksInfoShown`, `setLastSeenVersion`, `setBypassPermissions`, `setShowHostDirectories` — whether the host's own Directories are listed, off by default; only the list is gated, launches still name agents after them), directories (`saveDirectory`, `removeDirectory`, `requestDirectorySuggestions`), terminal, discovery + assets, diagnostics.\n\nBoth unions use `oneOf` with `discriminator: type`. Every concrete message sets `additionalProperties: false`.\n\n`core/src/messages.ts` is generated by `scripts/generate-messages.ts` invoking `@asyncapi/modelina` with custom constraints (`propertyKey` preserves `type`/`status` field names; `constant` inlines `const` literals). The file has an auto-generation banner. **CI runs `asyncapi:generate` then `git diff --exit-code core/src/messages.ts` — any non-empty diff fails the build.**\n\n## Transport Abstraction\n\n```typescript\nexport interface MessageTransport {\n  send(msg: ClientMessage): void;\n  onMessage(handler: (msg: ServerMessage) => void): () => void;\n  readonly ready: Promise<void>;\n  readonly state: TransportState;\n  onStateChange(handler: (state: TransportState) => void): () => void;\n  dispose(): void;\n}\nexport type TransportState = 'connecting' | 'connected' | 'reconnecting' | 'disconnected';\n```\n\n`PostMessageTransport` reports `connected` for its entire lifetime. `WebSocketTransport` reconnects with exponential backoff (250 ms, 500 ms, 1 s, 2 s, 4 s, capped) and queues sends while disconnected.\n\n`createTransport()` in `webview-ui/src/transport/index.ts` is the **only branching point** in the UI codebase. Everything downstream uses the `MessageTransport` interface and never knows which transport is active.\n\n## Provider Abstraction\n\n`HookProvider` (`core/src/provider.ts`) is the integration boundary. Today only Claude Code is implemented; the Claude provider supports every transcript/hook format up to **Claude Code v2.1.220** (current as of 2026-07-30 — Task-era `agent_progress` records, explicit and implicit teams, background-by-default Agent spawns, sidecar-backed background agents). Newer CLI releases may add formats that need provider updates. The interface:\n\n- **Required**: `normalizeHookEvent(raw)` → `{ sessionId, event: AgentEvent } | null`; `installHooks` / `uninstallHooks` / `areHooksInstalled`; `formatToolStatus`; `permissionExemptTools`, `subagentToolNames`, `readingTools` sets.\n- **Optional file fallback**: `getSessionDirs(workspace)`, `getAllSessionRoots()`, `sessionFilePattern`, `parseTranscriptLine(line)`, `buildLaunchCommand(sessionId, cwd, opts)`. Used when hooks aren't installed.\n- **Optional team extension**: `team?: TeamProvider` for Lead + Teammates support.\n\n`AgentEvent.kind` values: `toolStart`, `toolEnd`, `turnEnd`, `subagentStart`, `subagentEnd`, `subagentTurnEnd`, `progress`, `permissionRequest`, `sessionStart`, `sessionEnd`. The runtime dispatches on `kind`, never on CLI-specific tool names.\n\n### TeamProvider (Lead + Teammates)\n\nOptional extension for CLIs that support team workflows (Claude Agent Teams today). Semantic queries (`discoverTeammates`, `getTeamMembers`, `getTeamMetadataForSession`, `extractTeammateNameFromEvent`, `isTeammateSpawnCall`) — providers choose their own storage strategy.\n\nFour teammate modes:\n\n| Mode                            | Trigger                                          | Detection                                                                                                                                                                                                                                                                                                                                                                               |\n| ------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Basic subagent (teams OFF)      | `Task(...)` or `Agent(run_in_background: false)` | `subagentStart` with team gate not matched, or JSONL `agent_progress`                                                                                                                                                                                                                                                                                                                   |\n| Inline teammate (teams ON)      | `Agent(run_in_background: true)` in-process      | `onTeammateDetected` → `discoverTeammates(projectDir, leadSessionId)`                                                                                                                                                                                                                                                                                                                   |\n| Session teammate (teams + tmux) | `Agent(run_in_background: true)` in tmux pane    | Own session, own hooks, routes through normal flow                                                                                                                                                                                                                                                                                                                                      |\n| Implicit-team agent (Claude 5)  | `Agent(...)` — background by default, NO flag    | Lead-side: spawn tool_result `agent_id: <name>@<team>` → `extractTeammateSpawnFromToolResult` sets lead `teamName`; teammate = own top-level UUID session tagged `teamName`/`agentName` on every record, discovered by `discoverTeammates(projectDir, leadSessionId, teamName)` and fast-attached in `onExternalSessionDetected` (bypasses the Watch All gate when the lead is tracked) |\n\nNewer harnesses (Claude 5) never tag the LEAD's records with team metadata and never write `agent_progress`/`queue-operation` records for these spawns — the spawn tool_result and the teammate's own session (own JSONL, own hooks) are the only signals. The implicit team writes `~/.claude/teams/session-<8hex>/config.json` (its `leadSessionId` matches no transcript on disk — don't link by it). Own-session teammates are registered with the SessionRouter (`setTeammateRegisterCallback`) so their hooks route directly to them.\n\n**Team generations on resume (last-wins latch)**: every CLI run of a session mints a FRESH implicit team, so a resumed lead's transcript carries spawn tool_results from several `session-<8hex>` generations. A tag-less lead re-latches to the newest spawn's team (`setTeamSwitchCallback` removes the defunct team's teammates); tag-derived identity (`teamNameFromTags`, set by the record-tags branch) is authoritative and never overwritten by spawn results. `linkTeammates` never badges a named teammate as LEAD — if only teammates are tracked, linking waits until the real lead is detected (prevents a phantom LEAD character when a teammate session is adopted before/without its lead). Restore drops `isTeamLead` from persisted agents that carry an `agentName`.\n\n**Background spawns without a CLI team (sidecar-backed)**: `Agent(...)` spawns that never register a team take the async path — result \"Async agent launched successfully. agentId: \\<hex>\" (no team anywhere), transcript + sidecar under `<projectDir>/<leadSessionId>/subagents/` (sidecar carries `agentType`/`description`/`toolUseId`, plus `name` when the spawn was named), completion via `queue-operation`. `scanForBackgroundAgentFiles` classifies them by the sidecar `name` — the domain model's sole Sub-agent vs Teammate distinction (see CONTEXT.md):\n\n- **Named → Teammate**: its own seated character, `agentName` from the sidecar `name`; the spawner gets `isTeamLead` + an `agentTeamInfo` broadcast (derived team — NO `teamName`, so config polling stays away) and the transient Subtask is ghost-killed via `subagentClear`. The child has `leadAgentId` + `spawnToolUseId`; it's removed on the completion queue-operation or lead sessionEnd (`removeTeammate` must NOT unregister its session — it shares the lead's).\n- **Unnamed → Sub-agent**: the Subtask sub-character stays; the spawn's transcript is watched in a **shadow `AgentStateStore`** (`server/src/subagentWatch.ts`, owned by `AgentRuntime`, ids from 1 000 000 up) whose broadcasts are translated onto the main store as `subagentToolStart/Done/Permission` keyed `(leadId, spawnToolUseId)` — live activity on the sub-character, no protocol change. `agentToolsClear` on the shadow agent synthesizes `subagentToolDone` for still-live tools so the sub idles between turns. Never persisted, never announced.\n\nThe gate for both: the sidecar `toolUseId` must match a LIVE entry in the lead's `backgroundAgentToolIds` (anti-spurious; no teamName involved). `scanForTeammateFiles` skips sidecars carrying a live `toolUseId` so it can't race the classification. The lead's `backgroundAgentToolIds` are persisted (children never are — they're derived state the 1 s scan re-materializes after a reload; restore skips stale entries with `leadAgentId` but no `teamName`). The turn-end background-tool re-sends (transcriptParser, hookEventHandler, timerManager's `clearAgentActivity`) must include `toolName` + `runInBackground` (or the webview can't recreate the Subtask after `agentToolsClear`) and must skip tools whose spawn became a teammate character (`hasPromotedBackgroundAgent`, or a ghost Subtask appears next to it).\n\nTeammate dismissal is driven by team config polling (`getTeamMembers(teamName)` is authoritative; members with `isActive: false` are treated as departed), by the teammate's own `sessionEnd` (own-session teammates), and by `sessionEnd` on the lead.\n\n**Subtle gate (the one that bit us recently)**: in `webview-ui/src/hooks/useExtensionMessages.ts`, `agentToolStart` with `runInBackground=true` only creates a Subtask sub-character when the **parent has no `teamName`**. With a team, `onTeammateDetected` creates the teammate instead (and the gate prevents a ghost sub-agent). Without a team, the teammate path never fires and the basic Subtask sub-character is the only visible representation. The gate's blind spot — a TEAMED lead's unnamed background spawn — is covered by `subagentToolStart` creating the sub-character lazily when it's missing (`addSubagent` is idempotent); the same lazy path recreates subs after a panel reload.\n\n## Server Runtime\n\n`AgentRuntime` (`server/src/agentRuntime.ts`) is the shared lifecycle core. Both surfaces (`adapters/vscode/PixelAgentsViewProvider.ts` and `server/src/cli.ts`) compose the same runtime; only the `StateAdapter` namespace, `MessageTransport`, and `TerminalAdapter` differ.\n\n```typescript\nexport class AgentRuntime {\n  constructor(opts: {\n    store: AgentStateStore;\n    providerRegistry: ProviderRegistry;\n    layoutPersistence: LayoutPersistence;\n    assetCache: AssetCache;\n    config: Pick<AdapterSettings, 'hooksEnabled' | 'watchAllSessions'>;\n    terminalAdapter?: TerminalAdapter;\n    callbacks: RuntimeLifecycleCallbacks;\n  });\n  registerAgent / unregisterAgent / removeAgent / removeTeammate / removeTeammates\n  restoreExternalAgents / handleHookEvent\n  startProjectScan / startExternalScanning / startStaleCheck\n  dispose();\n}\n```\n\nOwns timer Maps (waiting, permission, text-idle, stale), scanners (project-dir 1 s, external 3 s, stale 30 s), `HookEventHandler`, `SessionRouter`, `DismissalTracker`. Scanners are skipped entirely while hooks are flowing (`hookDelivered` is set on every agent). `RuntimeLifecycleCallbacks` is the only seam between runtime and host.\n\n### AgentStateStore\n\nEventEmitter-backed container in `server/src/agentStateStore.ts`. Typed mutations, typed events (`agentAdded`, `agentRemoved`, `agentUpdated`, `broadcast`). The broadcast layer subscribes once at boot and translates `StoreEvents` into `ServerMessage` over the active transport. **No module under `server/` calls a transport method directly.**\n\n### SessionRouter and DismissalTracker\n\nTwo extracted classes that replaced ad-hoc module state. `SessionRouter` owns `session_id → agent_id` mapping, pre-registration event buffering, and pending external sessions. `DismissalTracker` unifies four legacy globals (`dismissedJsonlFiles`, `clearDismissedFiles`, `seededMtimes`, `pendingClearFiles`) into one class with typed reasons.\n\n### HTTP + WebSocket Server\n\nFastify v5 with `@fastify/cors`, `@fastify/websocket`, and (in standalone) `@fastify/static`:\n\n| Method | Path                     | Purpose                                 |\n| ------ | ------------------------ | --------------------------------------- |\n| POST   | `/api/hooks/:providerId` | Bearer-authenticated hook event ingress |\n| GET    | `/api/health`            | Liveness: `{ ok, version, port, pid }`  |\n| GET    | `/ws`                    | Bidirectional protocol channel          |\n| GET    | `/*` (standalone only)   | Webview SPA via `@fastify/static`       |\n\n`/ws` is gated in **two tiers**, because messages over this socket launch shells, grant durable machine-wide hook consent, and rewrite persisted state.\n\n1. **Connection** (`isAllowedWebSocketOrigin`): embedded requires the Bearer token; standalone requires a same-origin handshake. A missing Origin still connects (non-browser clients send none). This tier is weak by design — a DNS-rebound page sends `Origin` and `Host` as the same attacker-chosen name and IS accepted (`httpServerWs.test.ts` pins that).\n2. **Privileged messages** (`standaloneTokenValid`, `ClientMessageContext.privileged`): proved by an out-of-band secret — embedded via its Bearer token, standalone via the server token in the `/ws` `?token=` query, which the CLI prints in the local URL and the SPA forwards (`webview-ui/src/transport/index.ts`). **Never a network position**: peer address, `Host` and `Origin` all ride the channel a proxy speaks, so a LAN-bound forwarder piping bytes to 127.0.0.1 satisfies all three. The token is a replayable bearer capability, not evidence of locality — it reaches browser history (Fastify's request log redacts it, `wsAuth.redactTokenQuery`), so the printed URL is documented to the user as a secret. Standalone persists it in `~/.pixel-agents/standalone-token` (0600) so a bookmarked URL survives restarts; `--rotate-token` replaces it (revoking every device at once; refused while another standalone server is running, since it would keep honouring the old one). The terminal socket (`/terminal/:agentId`) carries it the same way and both sockets decide privilege with ONE predicate, `wsAuth.standaloneHandshakeVerdict`: the token AND an allowed `Host` — loopback, a specific `--host`, or a name passed with `--allowed-host` (anti-DNS-rebinding defence in depth). The allowlist is explicit rather than \"loopback when loopback-bound\" because a reverse proxy (Tailscale Serve) forwards to 127.0.0.1 under its public name; a loopback-only rule forced `--host 0.0.0.0`, exposing the port and the token in transit to the LAN. **An unprivileged client is read-only**: it may load and watch the office (`webviewReady`) and NOTHING else — every other message is refused by one gate at the top of `handleClientMessage` (`refuseReadOnly`), so a new message type is privileged by default. Where the UI updates optimistically (settings toggles, hooks toggle, asset directories, area mappings), the refused client is re-sent the real state. A tokened connection under an unlisted Host is read-only too and logs the `--allowed-host` hint. A second standalone launch reuses a running server only when its access matches (`ServerConfig.standalone`: terminal on/off, bind host, allowed hosts) — otherwise `--no-terminal` would silently attach to a server with a shell.\n\nThe **hooks preference is persisted only after the install/uninstall settled and the on-disk result agrees** — writing it first strands the user when an uninstall fails: entries still firing, but a persisted hooks-off makes the next startup skip the consent/install path entirely.\n\nServer discovery written to `~/.pixel-agents/server.json` with `{ port, pid, authToken }`. Multi-window safe: a second server detects an existing `server.json` and reuses or replaces it based on PID liveness.\n\n### ClientMessageHandler\n\nSingle dispatch point for `ClientMessage`. Each variant calls into `AgentRuntime`, `AgentStateStore`, `LayoutPersistence`, or `FileStateAdapter`, or delegates to host-specific callbacks (`onLaunchAgent`, `onOpenSessionsFolder`, `onExportLayout`, `onImportLayout`, `onSetHooksEnabled`). Both surfaces wire the same handler.\n\n### ServerAgentState (server/src/types.ts)\n\nPer-agent runtime data: provider reference, session key, transcript-fallback fields (`jsonlFile`, `fileOffset`, `lineBuffer`), tool state Maps and Sets, team fields (`teamName`, `agentName`, `isTeamLead`, `leadAgentId`, `teamUsesTmux`), context usage (`contextTokens`, `maxContextTokens`, `sawMainChainUsage`), and the **`hookDelivered`** flag that suppresses heuristic timers when hooks are flowing.\n\n## Persistence\n\n```\n~/.pixel-agents/\n  config.json              { vscode, standalone, externalAssetDirectories, hooksConsent, hooksEnabled (both per-provider) }\n  vscode-state.json        { agents, seats }\n  standalone-state.json    { agents, seats }\n  layout.json              OfficeLayout (shared across surfaces)\n  server.json              { port, pid, authToken }\n  hooks/claude-hook.js     Bundled hook script (CJS, shebang)\n```\n\n`FileStateAdapter({ namespace })` backs both runtimes. Per-namespace settings: `soundEnabled`, `lastSeenVersion`, `alwaysShowLabels`, `watchAllSessions`, `hooksInfoShown` (the hooks preference is per-provider and machine-global, at the config top level). Running both surfaces in parallel never clobbers either.\n\n`migrateVsCodeState` (VS Code adapter only) walks each known legacy key once with **verify-before-clear** semantics: write to file, read back, only then clear the legacy key. While anything remains unmigrated, activation shows a non-blocking warning.\n\nLayout writes are atomic via tmp + rename. Cross-window watching is hybrid (`fs.watch` + 2 s polling). `markOwnWrite()` prevents the watcher from re-reading our own write.\n\n## Agent Status Tracking\n\nJSONL transcripts at `~/.claude/projects/<project-hash>/<session-id>.jsonl`. Project hash = workspace path with `:`/`\\`/`/` → `-`.\n\n**JSONL record types**: `assistant` (tool_use or thinking), `user` (tool_result or text prompt), `system` with `subtype: \"turn_duration\"` (reliable turn-end signal), `progress` with `data.type`: `agent_progress` (sub-agent tool_use/tool_result, non-exempt tools trigger permission timers), `bash_progress` (Bash output — restarts permission timer), `mcp_progress` (MCP tool — same timer restart). Also observed but not tracked: `file-history-snapshot`, `queue-operation`.\n\n**File watching**: 500 ms polling with partial-line buffering for mid-write reads. Tool-done messages delayed 300 ms to prevent React batching from hiding brief active states.\n\n### Dual-mode detection\n\n| Mode                     | Source                                                 | Detection                                                                                                                                                                                                                                                                         |\n| ------------------------ | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| **Hooks** (preferred)    | Claude Code Hooks API → HTTP POST → `HookEventHandler` | Instant, reliable. Installed events are `CLAUDE_HOOK_EVENTS`. `UserPromptSubmit`/`TaskCreated` are deliberately NOT installed — they normalize to null, so installing them only forwarded prompt text to be dropped; `normalizeHookEvent` still tolerates them for stale installs |\n| **Heuristic** (fallback) | Polling JSONL files                                    | Per-agent 500 ms JSONL polling for /clear detection; 1 s main scanner for terminal adoption; 3 s external scanner; 30 s stale check. Content-based /clear detection (`/clear</command-name>` in first 8 KB)                                                                       |\n\nThe `hookDelivered` flag (per agent) and `hooksEnabled` (global) gate timer logic. JSONL polling always runs in both modes for tool content (status text, animations); only permission (7 s) and text-idle (5 s) timers are suppressed by `hookDelivered`.\n\n### Sub-agent permission detection (heuristic)\n\nWhen a sub-agent runs a non-exempt tool, `startPermissionTimer` fires on the parent agent. If `PERMISSION_TIMER_DELAY_MS` (7 s) elapses with no data, permission bubbles appear on both parent and sub-agent characters via `agentToolPermission` + `subagentToolPermission` broadcasts.\n\n`activeSubagentToolNames: Map<parentToolId, Map<subToolId, toolName>>` tracks which sub-tools are active for the exempt check. Cleared when data resumes, Task completes, or `turn_duration` arrives.\n\n**Timing budget for tests**: the test waits for the bubble AFTER waiting for the \"Subtask:\" overlay. But the Subtask overlay appears when the parent's **Task tool_use** is parsed (scenario time T), while the timer only starts when the **sub-tool tool_use** in the progress record is parsed (~1 s later). Plus 7 s timer + 300 ms IPC/render slop = a wait timeout less than ~10 s is unsafe.\n\n### Context usage (server/src/contextUsage.ts)\n\nEvery agent's context gauge. Fed from `message.usage` on assistant records by `processTranscriptLine`, broadcast as `agentContextUsage` and rendered by `ToolOverlay`.\n\n- **Snapshot, not a total.** Context = the newest turn's `input_tokens + cache_creation_input_tokens + cache_read_input_tokens + output_tokens`. Summing turns measures spend, not occupancy, and misses the cached tokens that ARE the context (`input_tokens` is single digits once caching kicks in). Falls on compaction and `/clear` for free.\n- **All-zero usage means \"no news\"** — synthetic records (API errors, interrupts) report zeros and must not blank the gauge.\n- **Sidechain rule**: a lead's sidechain records are its sub-agents' turns and must not move its gauge; a teammate's own transcript is sidechain top to bottom and would otherwise never report. Positional latch (`sawMainChainUsage`): once a file produces a main-chain turn, later sidechain records belong to someone else.\n- **The window comes from the provider**, not from the runtime: `HookProvider.contextWindowForModel(model)` (Claude: 1M for the current line, 200k for Haiku and the older models, `undefined` for ids it can't place). Transcripts state usage but never the limit, and guessing 200k for a 1M model reads five times too full. `widenContextWindow` is the backstop for unrecognized models and unknown-larger windows — it only ever widens, since a context that doesn't fit disproves the assumption while a shrinking one proves nothing.\n- **`seedContextUsage` runs once per agent in `startFileWatching`** — the single seam every watched agent passes through. Agents adopted or restored mid-session start at end-of-file, so without a tail read they'd have no gauge until their next turn.\n- Sub-agents get no gauge: no session of their own, and the shadow store never forwards `agentContextUsage`.\n\n## Office UI\n\n**Rendering**: Game state in imperative `OfficeState` class (not React state). Pixel-perfect: zoom = integer device-pixels-per-sprite-pixel (1x–10x). No `ctx.scale(dpr)`. Default zoom = `Math.round(2 * devicePixelRatio)`. Z-sort all entities by Y. Pan via middle-mouse drag (`panRef`). **Camera follow**: `cameraFollowId` (separate from `selectedAgentId`) smoothly centers camera on the followed agent; set on agent click, cleared on deselection or manual pan.\n\n**UI styling**: Pixel art aesthetic — sharp corners (`borderRadius: 0`), solid backgrounds (`#1e1e2e`), `2px solid` borders, hard offset shadows (`2px 2px 0px #0a0a14`, no blur). CSS variables in `index.css` `:root` (`--pixel-bg`, `--pixel-border`, `--pixel-accent`, ...). Pixel font: FS Pixel Sans (`webview-ui/src/fonts/`), loaded via `@font-face`, applied globally.\n\nCustom ESLint rules (`eslint-rules/pixel-agents-rules.mjs`) enforce: `no-inline-colors` (hex/rgb/rgba/hsl/hsla literals only in `constants.ts`), `pixel-shadow` (must use `var(--pixel-shadow)` or `2px 2px 0px`), `pixel-font` (must reference FS Pixel Sans). All `error`-level — they block PRs.\n\n**Characters**: FSM states — active (pathfind to seat, typing/reading animation by tool type), idle (wander randomly with BFS, return to seat after `wanderLimit` moves). 4-directional sprites, left = flipped right. Tool animations: typing (Write/Edit/Bash/Task) vs reading (Read/Grep/Glob/WebFetch). Sitting offset: characters shift down 6 px in TYPE state. Z-sort uses `ch.y + TILE_SIZE/2 + 0.5` so characters render in front of same-row furniture but behind lower-row furniture. **Chair z-sorting**: non-back chairs use `zY = (row+1)*TILE_SIZE` (capped to first row); back-facing chairs use `zY = (row+1)*TILE_SIZE + 1` so the chair back renders in front of the character. Chair tiles are blocked for all characters except their own assigned seat (per-character pathfinding via `withOwnSeatUnblocked`).\n\n**Diverse palette assignment**: `pickDiversePalette()` counts palettes of current non-sub-agent characters; picks randomly from least-used palette(s). First 6 agents each get a unique skin; beyond 6, skins repeat with a random hue shift (45–315°) via `adjustSprite()`. Character stores `palette` (0-5) + `hueShift` (degrees). Sprite cache keyed by `\"palette:hueShift\"`.\n\n**Spawn/despawn effect**: Matrix-style digital rain animation (0.3 s). 16 vertical columns sweep top-to-bottom with staggered timing. Spawn: green rain reveals character pixels. Despawn: character pixels consumed by green rain trails. `matrixEffect` field on Character (`'spawn'`/`'despawn'`/`null`). Normal FSM is paused during effect. Restored agents (`existingAgents`) use `skipSpawnEffect: true` to appear instantly.\n\n**Sub-agents**: Negative IDs (from -1 down). Created on `agentToolStart` with \"Subtask:\" prefix, or lazily by `subagentToolStart` when missing (watched background spawns, post-reload recreation). Same palette + hueShift as parent. Click focuses parent terminal. Not persisted. Spawn at the closest free walkable tile to the parent (`closestFreeWalkableTile`) — around it, never in a seat. Idle (stop typing) when every tracked sub-tool row is done; overlay shows the latest non-done sub-tool status, falling back to the Subtask label.\n\n**Speech bubbles**: Permission (\"...\" amber dots) stays until clicked/cleared. Waiting (green checkmark) auto-fades 2 s. Sprites in `spriteData.ts`.\n\n**Sound notifications**: Ascending two-note chime (E5 → E6) via Web Audio API plays when waiting bubble appears (`agentStatus: 'waiting'`). `notificationSound.ts` manages AudioContext lifecycle; `unlockAudio()` on canvas mousedown resumes the context (webviews start suspended). Toggled via Settings modal. Persisted per-namespace in `~/.pixel-agents/config.json`.\n\n**Mobile shell** (standalone only): below `MOBILE_MEDIA_QUERY` the browser renders `mobile/MobileShell` instead of the desktop layout — office and terminal as two full-screen pages in a 200%-wide sliding track (both always laid out, never `display:none`, so the canvas and xterm keep dimensions); the track's transform makes it the containing block of `position: fixed`, so modals and other viewport overlays mount at App's top level, never inside the office region), the agent cards in a bottom scroller, and `MobileKeyBar` above the software keyboard. \"Keyboard open\" and \"clamp the shell\" are separate answers (`mobile/keyboardViewport.ts`, sampled by `useKeyboardViewport`) because the two resize policies differ: iOS shrinks only the visual viewport (open, and the shell is clamped to it so the PTY resizes instead of hiding its input line), Android `interactive-widget=resizes-content` shrinks both (open, nothing to clamp). Open = an editable focused AND the visual viewport `KEYBOARD_MIN_HEIGHT_PX` below the tallest height seen at the current width (per-orientation baseline). `useIsMobile` is gated on `isBrowserRuntime`: a narrow VS Code panel must never get it (it broke every VS Code e2e shard once). Navigation rules live in `useMobileShell`: the >_ / Office toggle and the edge swipe share one handler; a card tap in the office is two-step (focus the character, then open its terminal), in the terminal it switches panes; + slides over only for launches made from it (a reload re-announcing sessions stays on the office). The terminal-pane model (active vs shown pane, socket status, `statusFor`) is `useTerminalDrawer`, shared with the desktop drawer. Touch rules: every gesture tracks ONE finger by identifier (`findTouch`) and uses one tap slop (`withinTapSlop`); gesture decisions are pure reducers with thin DOM adapters (`edgeSwipe.ts`/`useEdgeSwipe.ts`, `terminal/touchGesture.ts`/`touchInput.ts`), because WebKit keeps addressing a gesture's events to its touchstart node — when terminal rows repaint, that node detaches and events stop bubbling, so each adapter binds \"rescue\" listeners on the touch target. **Terminal copy is verbatim by default** (Cmd/Ctrl+C, the browser's Copy, the pill's Copy): `flowTerminalCopy` is a heuristic that joins a long row with a two-space-indented next line, which is also what YAML under a long key or a method body under a long signature look like, so re-flowing is opt-in only — Alt+C (Option+C) on desktop, \"Copy as text\" on the pill. Every write goes through `terminal/clipboard.ts`. PWA: `manifest.webmanifest` (icon paths relative — the file is served verbatim), conventional-path icons in `public/`, `?v=N` cache-busters on icon links.\n\n**Seats**: Derived from chair furniture. `layoutToSeats()` creates a seat at every footprint tile of every chair. Multi-tile chairs produce multiple seats keyed `uid` / `uid:1` / `uid:2`. Facing direction priority: 1) chair `orientation` from catalog (front→DOWN, back→UP, left→LEFT, right→RIGHT), 2) adjacent desk direction, 3) forward (DOWN). Click character → select (white outline) → click available seat → reassign.\n\n## Layout Editor\n\nToggle via \"Layout\" button. Tools: SELECT (default), Floor paint, Wall paint, Erase (set tiles to VOID), Furniture place, Furniture Copy (`FURNITURE_PICK`: type + colour off a placed item), colour Copy (`COLOR_PICK`: colour only), Eyedrop"},"items":[{"name":"CLAUDE.md","path":"CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/pablodelucca/pixel-agents/HEAD/CLAUDE.md","title":"Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"# Pixel Agents — Compressed Reference\n\nPixel art office where AI agents (Claude Code terminals today, any tool tomorrow) become animated characters. Ships as a **VS Code extension** and an **`npx pixel-agents` standalone CLI** from the same source tree.\n\n`CONTEXT.md` is the canonical glossary — read it for what terms like Agent, Sub-agent, Teammate, Lead, Adopt, or Headless agent mean here, and use its vocabulary in code, comments, and docs.\n\n## Architecture\n\nStrict layering: `core/` depends on nothing; `server/` depends only on `core/`; `webview-ui/` depends only on `core/`; `adapters/vscode/` depends on `core/` and `server/`. The standalone CLI never imports `adapters/vscode/` and vice versa.\n\n```\ncore/                                Protocol + interface definitions (zero runtime side effects)\n  asyncapi.yaml                      AsyncAPI 3.0 contract — single source of truth\n  src/\n    messages.ts                      AUTO-GENERATED discriminated unions (do not edit)\n    schemas.ts                       AgentMeta, SpriteData, FurnitureCatalogEntry\n    provider.ts                      HookProvider, AgentEvent (the integration boundary)\n    teamProvider.ts                  Optional TeamProvider (semantic queries for Lead + Teammates)\n    transport.ts                     MessageTransport interface, TransportState\n    adapter.ts                       StateAdapter, AssetCache, PersistedAgent, AgentSeat\n    terminalAdapter.ts               TerminalAdapter (editor-driven terminal management)\n    normalizeProjectPath.ts\n    constants.ts\n\nserver/                              Lifecycle runtime + Fastify HTTP/WS server\n  src/\n    providers/hook/claude/           Reference HookProvider — only place that knows Claude specifics\n      claude.ts                      normalizeHookEvent for 11 Claude events, formatToolStatus, file fallback\n      claudeTeamProvider.ts          TeamProvider: reads ~/.claude/teams/<name>/config.json\n      claudeHookInstaller.ts         Consent-gated install/uninstall in ~/.claude/settings.json (abort on unparseable file or non-array hooks.<Event>; one-time .pixel-agents.backup, exclusive-create, no backup ⇒ no write — but skipped when the replaced content is entirely our own install's output, since backing up our own file masquerades as the user's original (`settingsHoldOnlyOurHooks`, compared against makeHookEntry — the WRITER — so a field added to what we write can't silently revive the bug; only `command`/`timeout` may differ, they vary across installs); every write failure THROWS; mode preserved, 0600 on create; re-read verify immediately before rename + retry; hook identity = `/.pixel-agents/hooks/claude-hook.js` suffix anchored at both ends of the command's first token, case-insensitive; `areHooksInstalled` = ANY of our commands on ANY event)\n      consentCopy.ts                 Claude's first-run consent disclosure text (scope/data/undo), served through consentDisclosure()\n      constants.ts                   Claude hook event names, script path\n      hooks/claude-hook.ts           Hook script (CJS+shebang, bundled to dist/hooks/claude-hook.js)\n    providers/hook/consentGate.ts    Provider-agnostic consent POLICY: when to ask (hooksConsentRequest per provider) and what an answer means (consentActionFor(choice, {installed, consent}) — see docs/adr/0001)\n    providers/hook/consentExecutor.ts Provider-agnostic consent EXECUTION: applyConsentChoice(providerId, choice, ConsentEffects) runs the six actions in one order for both surfaces, and SERIALIZES answers per process across ALL providers\n    providers/index.ts               Provider registry (claudeProvider + the hookProviders list the consent gate loops over)\n    agentRuntime.ts                  Lifecycle core: timers, scanners, HookEventHandler, SessionRouter, DismissalTracker\n    agentStateStore.ts               EventEmitter-backed single source of truth (typed mutations + events)\n    sessionRouter.ts                 session_id → agent_id mapping, event buffering, pending external sessions\n    dismissalTracker.ts              Unified dismissal state (replaces four legacy globals)\n    hookEventHandler.ts              Dispatches normalized AgentEvent into runtime\n    httpServer.ts                    Fastify: POST /api/hooks/:providerId, GET /api/health, GET /ws, SPA (standalone)\n    clientMessageHandler.ts          Single dispatch point for ClientMessage from webview\n    server.ts                        Top-level composition\n    cli.ts                           npx pixel-agents entry (npm bin)\n    fileStateAdapter.ts              Namespaced ~/.pixel-agents/ persistence\n    configPersistence.ts             { vscode, standalone, externalAssetDirectories, hooksConsent: {providerId: granted|declined}, hooksEnabled: {providerId: boolean} }\n    layoutPersistence.ts             ~/.pixel-agents/layout.json with atomic tmp+rename\n    fileWatcher.ts                   Hybrid fs.watch + 500ms polling, JSONL line buffering, /clear detection\n    transcriptParser.ts              JSONL parsing for heuristic / file-fallback mode\n    timerManager.ts                  Waiting / permission timers\n    assetLoader.ts                   PNG → SpriteData via pngjs\n    teamUtils.ts                     isInlineTeammateOf, getInlineTeammates, hasInlineTeammates\n    types.ts                         ServerAgentState\n    constants.ts                     All timing/scanning constants\n  __tests__/                         28 Vitest files\n  manual-hook-events.http            Manual hook testing helper (REST-Client format)\n\nadapters/vscode/                     VS Code surface — composes core + server\n  extension.ts                       activate() / deactivate()\n  PixelAgentsViewProvider.ts         WebviewViewProvider, thin bridge to AgentRuntime\n  agentManager.ts                    Terminal lifecycle (claude --session-id <uuid>), restore, persist\n  vscodeTerminalAdapter.ts           TerminalAdapter implementation\n  uninstall.ts                       vscode:uninstall hook — removes hook entries + factory-resets hooks config after extension removal\n  migrateVsCodeState.ts              One-time legacy state migration (verify-before-clear)\n  constants.ts                       VS Code IDs, command names, key names\n\nwebview-ui/                          React 19 + Canvas UI (depends only on core/)\n  src/\n    transport/\n      index.ts                       createTransport() — single runtime branching point\n      postMessageTransport.ts        VS Code mode (acquireVsCodeApi)\n      webSocketTransport.ts          Standalone mode (exponential backoff, send queue)\n      types.ts                       Re-exports MessageTransport from core\n    runtime.ts                       isBrowserRuntime detection\n    browserMock.ts                   Standalone-browser asset fetch + message injection\n    testHooks.ts                     window globals exposed for e2e (officeState, helpers)\n    main.tsx                         React entry (StrictMode + createRoot)\n    App.tsx                          Composition root (hooks + components + EditActionBar)\n    constants.ts                     Webview magic numbers/strings\n    notificationSound.ts             Web Audio API chime\n    changelogData.ts                 Changelog modal content\n    components/                      React UI (toolbars, modals, settings)\n      BottomToolbar.tsx, ZoomControls.tsx, SettingsModal.tsx, InfoModal.tsx,\n      Tooltip.tsx, DebugView.tsx, ui/Button.tsx, ...\n      AgentCard.tsx, AgentCardBar.tsx  Agent cards (mug shot + status dot); the right-edge bar on desktop/VS Code\n      cardBar.ts                     Pure card-bar rules: cardVariant, mergeOrder, reorderByPointer (Node-tested)\n      TerminalDrawer.tsx             Desktop standalone: right-docked panel, cards as tabs\n      TerminalPaneStack.tsx          Every agent's TerminalPane mounted, the shown one visible (both shells)\n      TerminalPane.tsx               One xterm bound to one agent's PTY\n      MobileAgentBar.tsx             Mobile bottom card scroller (+ launch card, long-press reorder)\n      MobileTerminalPage.tsx         Mobile terminal page (a TerminalPaneStack)\n      MobileKeyBar.tsx               Accessory keys above the software keyboard (esc, shift+tab, trackpad, paste)\n    mobile/                          The phone shell (standalone only, see \"Mobile shell\")\n      MobileShell.tsx                Sliding office/terminal track + card bar + key bar\n      useMobileShell.ts              Page state and navigation rules (toggle, card tap, launch-then-slide)\n      edgeSwipe.ts                   Edge-swipe state machine (pure reducer, Node-runner tested)\n      keyboardViewport.ts            Software-keyboard decision: open vs per-width baseline, clamp only when the visual viewport is shorter (pure)\n      useEdgeSwipe.ts                DOM adapter for the reducer (capture listeners, detached-target rescue)\n    touch/\n      touchPrimitives.ts             findTouch (track one finger by id) + withinTapSlop (the one tap metric)\n    terminal/                        Standalone terminal (xterm) support\n      terminalClient.ts              /terminal/:agentId socket (reconnect, replay)\n      terminalLinks.ts               Safe link opener + urlAtCell (one URL pattern for clicks and taps)\n      touchGesture.ts                Terminal touch state machine: scroll, flick, tap, long-press select, handles (pure reducer)\n      touchInput.ts                  DOM adapter: listeners, flick rAF, long-press timer, detached-target rescue\n      selectionMath.ts               Word expansion, range ordering, handle clamping (pure)\n      selectionOverlay.ts            Copy pill (Copy | Copy as text) + selection handles (DOM; styled in index.css)\n      clipboard.ts                   The one clipboard write/read: Clipboard API on secure origins; plain http falls back to a copy event (write) / a native prompt (paste)\n      flowCopy.ts                    terminalCopyText: verbatim by default; opt-in re-flow of Claude Code's soft wraps (pure)\n      clipboardKeys.ts               Clipboard chords (Ctrl+C/Ctrl+Shift+C copy, Alt+C copy as text, Ctrl+V paste) + OSC 52 decode (pure)\n      terminalDom.ts                 xterm screen geometry + isTypingInTerminal\n    hooks/\n      useExtensionMessages.ts        Message handler — translates ServerMessage into OfficeState mutations\n      useEditorActions.ts            Editor state + callbacks\n      useFurnitureColorActions.ts    Furniture colour sliders + colour-only eyedropper\n      useEditorKeyboard.ts           Keyboard shortcuts (R, T, Esc, Ctrl+Z/Y)\n      introTourState.ts              Intro tour wire-state machine (pure reducer, Node-runner tested)\n      useIntroTour.ts                Wires the reducer to React + transport (snapshot, verdict, choices)\n      useTerminalDrawer.ts           Terminal-pane model for both shells: active/shown pane, socket status, card lookups\n      useIsMobile.ts                 Mobile breakpoint — always false in the VS Code webview\n      useKeyboardViewport.ts         Samples the viewport: keyboard open (key bar) + iOS shell clamp height\n    office/\n      types.ts                       OfficeLayout, Character, etc. + re-exports constants\n      toolUtils.ts                   STATUS_TO_TOOL mapping, extractToolName (DOM-free; defaultZoom lives in useEditorActions)\n      projection.ts                  World→screen math shared by renderer + DOM overlays (mapOffset, overlayProjection)\n      colorize.ts                    Colorize (grayscale→HSL) + Adjust (HSL shift)\n      floorTiles.ts                  Floor sprite storage + colorized cache\n      wallTiles.ts                   Wall auto-tile: 16 bitmask sprites\n      sprites/\n        spriteData.ts                Pixel data (characters, furniture, tiles, bubbles)\n        spriteCache.ts               SpriteData → offscreen canvas, per-zoom WeakMap\n      editor/\n        editorActions.ts             Pure layout ops\n        editorState.ts               Imperative state (tools, ghost, selection, undo/redo + undo session, drag, placing)\n        editorRenderState.ts         buildEditorRenderState: ghosts + selection for the renderer (pure)\n        EditorToolbar.tsx\n      layout/\n        furnitureCatalog.ts          Dynamic catalog from loaded assets\n        layoutSerializer.ts          OfficeLayout ↔ runtime (tileMap, furniture, seats)\n        tileMap.ts                   Walkability, BFS pathfinding\n      engine/\n        characters.ts                Character FSM (idle/walk/type) + wander AI\n        officeState.ts               Game world (layout, characters, seats, selection, subagents, consent greeter)\n        gameLoop.ts                  rAF loop with delta-time cap (0.1 s)\n        renderer.ts                  Canvas: tiles, z-sorted entities, overlays, edit UI\n        matrixEffect.ts              Spawn/despawn digital rain (drawing only)\n        matrixEffectState.ts         Effect state: startMatrixEffect/advanceMatrixEffect (DOM-free)\n      components/\n        OfficeCanvas.tsx             Canvas, resize, DPR, mouse hit-testing, drag-to-move\n        useCanvasTouchGestures.ts    Touch pan / pinch zoom / tap on the canvas\n        ToolOverlay.tsx              Activity label + speech bubbles (DOM, above the labels) per character\n\ne2e/                                 Playwright suite (real VS Code + mock-claude scenarios)\n  playwright.config.ts\n  global-setup.ts\n  fixtures/\n    pixel-agents.ts                  VS Code fixture: launch Electron, wait for panel\n    standalone.ts                    Standalone CLI fixture: spawn server + browser page\n    mock-claude, mock-claude.cmd     Bash + cmd wrapper invoked instead of real claude\n    mock-claude-runner.cjs           Scenario runner: appendJsonl, emitHook, holdOpen\n  helpers/\n    launch.ts                        Electron app + isolated HOME/workspace\n    mock-claude.ts                   claudeScenario() builder\n    office.ts                        Overlay locators + assertions\n    webview.ts                       Settings/modal helpers\n    hooks.ts                         Hook server lifecycle helpers\n    standalone.ts                    Standalone server + WebSocket browser helpers\n    internal-agent.ts                spawnInternalAgentAndWait\n    lifecycle.ts                     Reusable scenario fragments\n    team.ts                          Team config seeding + teammate helpers\n    allure-labels.ts                 @area:<tag> → Allure epic\n  tests/\n    claude/hooks-on/                 basic.spec.ts, lifecycle.spec.ts, teams.spec.ts\n    claude/hooks-off/                lifecycle.spec.ts, matrix.spec.ts\n    standalone/                      hooks.spec.ts\n  README.md                          Auto-generated test inventory (regen via npm run e2e:inventory)\n\nscripts/\n  generate-messages.ts               AsyncAPI → core/src/messages.ts via Modelina (with CI drift check)\n  run-e2e.mjs                        Playwright wrapper (run-id namespacing, video attach flags)\n  generate-e2e-inventory.mjs         Splices test list into e2e/README.md (CI drift check)\n  build-allure-report.mjs            Combine e2e+server+webview Allure results\n  assemble-vercel-output.mjs         Stage /reports/allure/ for Vercel deploy\n  asset-manager.html                 Unified furniture editor (positions + metadata)\n  jsonl-viewer.html                  Standalone JSONL transcript inspector\n  wall-tile-editor.html              Wall sprite editor\n\ncore/                                npm workspace (no separate package; root manages)\nserver/                              npm workspace\nwebview-ui/                          npm workspace\n```\n\n## Distribution\n\nTwo artifacts from one source tree:\n\n- **VS Code extension** (`.vsix`) — `pablodelucca.pixel-agents` on VS Code Marketplace and Open VSX. Bundles VS Code adapter + webview SPA + assets + hook scripts.\n- **npm package** (`pixel-agents`) — `npx pixel-agents [--port 3100]` runs the Fastify server and serves the SPA on the same port. Bundles CLI + webview SPA + assets + hook scripts + `core/asyncapi.yaml` (so third-party clients can regenerate from it).\n\n`package.json:files` allowlist controls the npm tarball: `dist/cli.js{,.map}`, `dist/webview/`, `dist/assets/`, `dist/hooks/`, `core/asyncapi.yaml`, `icon.png`. `dist/extension.js` is intentionally excluded since the VS Code entry ships through the `.vsix`.\n\n## Communication Flow\n\nHub-and-spoke. The server is the single aggregation point for all agent activity, regardless of source.\n\n```\nHook scripts ─POST /api/hooks/:providerId─┐\n                                          ├─→ HookProvider.normalizeHookEvent()\nJSONL transcripts ─FileWatcher─→ TranscriptParser ┤\n                                                   ↓\n                                              AgentEvent (canonical)\n                                                   ↓\n                                              AgentRuntime (dispatch on .kind)\n                                                   ↓\n                                           AgentStateStore (mutate)\n                                                   ↓\n                                              StoreEvents → broadcast\n                                                   ↓\n                          PostMessageTransport ──┤├── WebSocketTransport\n                                 (VS Code)      (standalone browser)\n```\n\nThe VS Code adapter wires `PostMessageTransport` against `acquireVsCodeApi()`. The standalone CLI exposes the same protocol over WebSocket at `/ws` with the webview SPA served from the same Fastify instance. **The protocol shape is identical; only the wire differs.**\n\nAdding a new CLI integration is one subdirectory under `server/src/providers/hook/<id>/`: provider, optional `TeamProvider`, installer, hook scripts. Zero changes to the runtime, the UI, or any existing provider.\n\n## AsyncAPI Protocol Contract\n\n`core/asyncapi.yaml` is the contract. Pinned to **3.0.0** because `@asyncapi/modelina@5.10.1` declares `supportedVersions: ['3.0.0']` only; bumping to 3.1.0 produces `export type Root = any`. Revisit when Modelina ships 3.1.0 support.\n\n- **39 ServerMessage variants** (server → client): agent lifecycle, agent activity, sub-agent activity, team + context usage, assets, access (`officeAccess` — sent first on `webviewReady`; `readOnly` hides every control a read-only office would only have refused), settings (`bypassPermissionsUpdated` — the skip-permissions posture, broadcast to every office on change because every launch reads it) + directories (`directoriesLoaded`, `directorySaved` — a save's ack to its sender, carrying the entry as stored, whose name keys the Area mapping; `directoryRejected`, `directorySuggestions`), terminal, diagnostics.\n- **27 ClientMessage variants** (client → server): lifecycle (`webviewReady`, `launchAgent`, `focusAgent`, `closeAgent`), layout (`saveAgentSeats`, `saveLayout`, `exportLayout`, `importLayout`), settings (`setSoundEnabled`, `setHooksEnabled`, `setWatchAllSessions`, `setAlwaysShowLabels`, `setHooksInfoShown`, `setLastSeenVersion`, `setBypassPermissions`, `setShowHostDirectories` — whether the host's own Directories are listed, off by default; only the list is gated, launches still name agents after them), directories (`saveDirectory`, `removeDirectory`, `requestDirectorySuggestions`), terminal, discovery + assets, diagnostics.\n\nBoth unions use `oneOf` with `discriminator: type`. Every concrete message sets `additionalProperties: false`.\n\n`core/src/messages.ts` is generated by `scripts/generate-messages.ts` invoking `@asyncapi/modelina` with custom constraints (`propertyKey` preserves `type`/`status` field names; `constant` inlines `const` literals). The file has an auto-generation banner. **CI runs `asyncapi:generate` then `git diff --exit-code core/src/messages.ts` — any non-empty diff fails the build.**\n\n## Transport Abstraction\n\n```typescript\nexport interface MessageTransport {\n  send(msg: ClientMessage): void;\n  onMessage(handler: (msg: ServerMessage) => void): () => void;\n  readonly ready: Promise<void>;\n  readonly state: TransportState;\n  onStateChange(handler: (state: TransportState) => void): () => void;\n  dispose(): void;\n}\nexport type TransportState = 'connecting' | 'connected' | 'reconnecting' | 'disconnected';\n```\n\n`PostMessageTransport` reports `connected` for its entire lifetime. `WebSocketTransport` reconnects with exponential backoff (250 ms, 500 ms, 1 s, 2 s, 4 s, capped) and queues sends while disconnected.\n\n`createTransport()` in `webview-ui/src/transport/index.ts` is the **only branching point** in the UI codebase. Everything downstream uses the `MessageTransport` interface and never knows which transport is active.\n\n## Provider Abstraction\n\n`HookProvider` (`core/src/provider.ts`) is the integration boundary. Today only Claude Code is implemented; the Claude provider supports every transcript/hook format up to **Claude Code v2.1.220** (current as of 2026-07-30 — Task-era `agent_progress` records, explicit and implicit teams, background-by-default Agent spawns, sidecar-backed background agents). Newer CLI releases may add formats that need provider updates. The interface:\n\n- **Required**: `normalizeHookEvent(raw)` → `{ sessionId, event: AgentEvent } | null`; `installHooks` / `uninstallHooks` / `areHooksInstalled`; `formatToolStatus`; `permissionExemptTools`, `subagentToolNames`, `readingTools` sets.\n- **Optional file fallback**: `getSessionDirs(workspace)`, `getAllSessionRoots()`, `sessionFilePattern`, `parseTranscriptLine(line)`, `buildLaunchCommand(sessionId, cwd, opts)`. Used when hooks aren't installed.\n- **Optional team extension**: `team?: TeamProvider` for Lead + Teammates support.\n\n`AgentEvent.kind` values: `toolStart`, `toolEnd`, `turnEnd`, `subagentStart`, `subagentEnd`, `subagentTurnEnd`, `progress`, `permissionRequest`, `sessionStart`, `sessionEnd`. The runtime dispatches on `kind`, never on CLI-specific tool names.\n\n### TeamProvider (Lead + Teammates)\n\nOptional extension for CLIs that support team workflows (Claude Agent Teams today). Semantic queries (`discoverTeammates`, `getTeamMembers`, `getTeamMetadataForSession`, `extractTeammateNameFromEvent`, `isTeammateSpawnCall`) — providers choose their own storage strategy.\n\nFour teammate modes:\n\n| Mode                            | Trigger                                          | Detection                                                                                                                                                                                                                                                                                                                                                                               |\n| ------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Basic subagent (teams OFF)      | `Task(...)` or `Agent(run_in_background: false)` | `subagentStart` with team gate not matched, or JSONL `agent_progress`                                                                                                                                                                                                                                                                                                                   |\n| Inline teammate (teams ON)      | `Agent(run_in_background: true)` in-process      | `onTeammateDetected` → `discoverTeammates(projectDir, leadSessionId)`                                                                                                                                                                                                                                                                                                                   |\n| Session teammate (teams + tmux) | `Agent(run_in_background: true)` in tmux pane    | Own session, own hooks, routes through normal flow                                                                                                                                                                                                                                                                                                                                      |\n| Implicit-team agent (Claude 5)  | `Agent(...)` — background by default, NO flag    | Lead-side: spawn tool_result `agent_id: <name>@<team>` → `extractTeammateSpawnFromToolResult` sets lead `teamName`; teammate = own top-level UUID session tagged `teamName`/`agentName` on every record, discovered by `discoverTeammates(projectDir, leadSessionId, teamName)` and fast-attached in `onExternalSessionDetected` (bypasses the Watch All gate when the lead is tracked) |\n\nNewer harnesses (Claude 5) never tag the LEAD's records with team metadata and never write `agent_progress`/`queue-operation` records for these spawns — the spawn tool_result and the teammate's own session (own JSONL, own hooks) are the only signals. The implicit team writes `~/.claude/teams/session-<8hex>/config.json` (its `leadSessionId` matches no transcript on disk — don't link by it). Own-session teammates are registered with the SessionRouter (`setTeammateRegisterCallback`) so their hooks route directly to them.\n\n**Team generations on resume (last-wins latch)**: every CLI run of a session mints a FRESH implicit team, so a resumed lead's transcript carries spawn tool_results from several `session-<8hex>` generations. A tag-less lead re-latches to the newest spawn's team (`setTeamSwitchCallback` removes the defunct team's teammates); tag-derived identity (`teamNameFromTags`, set by the record-tags branch) is authoritative and never overwritten by spawn results. `linkTeammates` never badges a named teammate as LEAD — if only teammates are tracked, linking waits until the real lead is detected (prevents a phantom LEAD character when a teammate session is adopted before/without its lead). Restore drops `isTeamLead` from persisted agents that carry an `agentName`.\n\n**Background spawns without a CLI team (sidecar-backed)**: `Agent(...)` spawns that never register a team take the async path — result \"Async agent launched successfully. agentId: \\<hex>\" (no team anywhere), transcript + sidecar under `<projectDir>/<leadSessionId>/subagents/` (sidecar carries `agentType`/`description`/`toolUseId`, plus `name` when the spawn was named), completion via `queue-operation`. `scanForBackgroundAgentFiles` classifies them by the sidecar `name` — the domain model's sole Sub-agent vs Teammate distinction (see CONTEXT.md):\n\n- **Named → Teammate**: its own seated character, `agentName` from the sidecar `name`; the spawner gets `isTeamLead` + an `agentTeamInfo` broadcast (derived team — NO `teamName`, so config polling stays away) and the transient Subtask is ghost-killed via `subagentClear`. The child has `leadAgentId` + `spawnToolUseId`; it's removed on the completion queue-operation or lead sessionEnd (`removeTeammate` must NOT unregister its session — it shares the lead's).\n- **Unnamed → Sub-agent**: the Subtask sub-character stays; the spawn's transcript is watched in a **shadow `AgentStateStore`** (`server/src/subagentWatch.ts`, owned by `AgentRuntime`, ids from 1 000 000 up) whose broadcasts are translated onto the main store as `subagentToolStart/Done/Permission` keyed `(leadId, spawnToolUseId)` — live activity on the sub-character, no protocol change. `agentToolsClear` on the shadow agent synthesizes `subagentToolDone` for still-live tools so the sub idles between turns. Never persisted, never announced.\n\nThe gate for both: the sidecar `toolUseId` must match a LIVE entry in the lead's `backgroundAgentToolIds` (anti-spurious; no teamName involved). `scanForTeammateFiles` skips sidecars carrying a live `toolUseId` so it can't race the classification. The lead's `backgroundAgentToolIds` are persisted (children never are — they're derived state the 1 s scan re-materializes after a reload; restore skips stale entries with `leadAgentId` but no `teamName`). The turn-end background-tool re-sends (transcriptParser, hookEventHandler, timerManager's `clearAgentActivity`) must include `toolName` + `runInBackground` (or the webview can't recreate the Subtask after `agentToolsClear`) and must skip tools whose spawn became a teammate character (`hasPromotedBackgroundAgent`, or a ghost Subtask appears next to it).\n\nTeammate dismissal is driven by team config polling (`getTeamMembers(teamName)` is authoritative; members with `isActive: false` are treated as departed), by the teammate's own `sessionEnd` (own-session teammates), and by `sessionEnd` on the lead.\n\n**Subtle gate (the one that bit us recently)**: in `webview-ui/src/hooks/useExtensionMessages.ts`, `agentToolStart` with `runInBackground=true` only creates a Subtask sub-character when the **parent has no `teamName`**. With a team, `onTeammateDetected` creates the teammate instead (and the gate prevents a ghost sub-agent). Without a team, the teammate path never fires and the basic Subtask sub-character is the only visible representation. The gate's blind spot — a TEAMED lead's unnamed background spawn — is covered by `subagentToolStart` creating the sub-character lazily when it's missing (`addSubagent` is idempotent); the same lazy path recreates subs after a panel reload.\n\n## Server Runtime\n\n`AgentRuntime` (`server/src/agentRuntime.ts`) is the shared lifecycle core. Both surfaces (`adapters/vscode/PixelAgentsViewProvider.ts` and `server/src/cli.ts`) compose the same runtime; only the `StateAdapter` namespace, `MessageTransport`, and `TerminalAdapter` differ.\n\n```typescript\nexport class AgentRuntime {\n  constructor(opts: {\n    store: AgentStateStore;\n    providerRegistry: ProviderRegistry;\n    layoutPersistence: LayoutPersistence;\n    assetCache: AssetCache;\n    config: Pick<AdapterSettings, 'hooksEnabled' | 'watchAllSessions'>;\n    terminalAdapter?: TerminalAdapter;\n    callbacks: RuntimeLifecycleCallbacks;\n  });\n  registerAgent / unregisterAgent / removeAgent / removeTeammate / removeTeammates\n  restoreExternalAgents / handleHookEvent\n  startProjectScan / startExternalScanning / startStaleCheck\n  dispose();\n}\n```\n\nOwns timer Maps (waiting, permission, text-idle, stale), scanners (project-dir 1 s, external 3 s, stale 30 s), `HookEventHandler`, `SessionRouter`, `DismissalTracker`. Scanners are skipped entirely while hooks are flowing (`hookDelivered` is set on every agent). `RuntimeLifecycleCallbacks` is the only seam between runtime and host.\n\n### AgentStateStore\n\nEventEmitter-backed container in `server/src/agentStateStore.ts`. Typed mutations, typed events (`agentAdded`, `agentRemoved`, `agentUpdated`, `broadcast`). The broadcast layer subscribes once at boot and translates `StoreEvents` into `ServerMessage` over the active transport. **No module under `server/` calls a transport method directly.**\n\n### SessionRouter and DismissalTracker\n\nTwo extracted classes that replaced ad-hoc module state. `SessionRouter` owns `session_id → agent_id` mapping, pre-registration event buffering, and pending external sessions. `DismissalTracker` unifies four legacy globals (`dismissedJsonlFiles`, `clearDismissedFiles`, `seededMtimes`, `pendingClearFiles`) into one class with typed reasons.\n\n### HTTP + WebSocket Server\n\nFastify v5 with `@fastify/cors`, `@fastify/websocket`, and (in standalone) `@fastify/static`:\n\n| Method | Path                     | Purpose                                 |\n| ------ | ------------------------ | --------------------------------------- |\n| POST   | `/api/hooks/:providerId` | Bearer-authenticated hook event ingress |\n| GET    | `/api/health`            | Liveness: `{ ok, version, port, pid }`  |\n| GET    | `/ws`                    | Bidirectional protocol channel          |\n| GET    | `/*` (standalone only)   | Webview SPA via `@fastify/static`       |\n\n`/ws` is gated in **two tiers**, because messages over this socket launch shells, grant durable machine-wide hook consent, and rewrite persisted state.\n\n1. **Connection** (`isAllowedWebSocketOrigin`): embedded requires the Bearer token; standalone requires a same-origin handshake. A missing Origin still connects (non-browser clients send none). This tier is weak by design — a DNS-rebound page sends `Origin` and `Host` as the same attacker-chosen name and IS accepted (`httpServerWs.test.ts` pins that).\n2. **Privileged messages** (`standaloneTokenValid`, `ClientMessageContext.privileged`): proved by an out-of-band secret — embedded via its Bearer token, standalone via the server token in the `/ws` `?token=` query, which the CLI prints in the local URL and the SPA forwards (`webview-ui/src/transport/index.ts`). **Never a network position**: peer address, `Host` and `Origin` all ride the channel a proxy speaks, so a LAN-bound forwarder piping bytes to 127.0.0.1 satisfies all three. The token is a replayable bearer capability, not evidence of locality — it reaches browser history (Fastify's request log redacts it, `wsAuth.redactTokenQuery`), so the printed URL is documented to the user as a secret. Standalone persists it in `~/.pixel-agents/standalone-token` (0600) so a bookmarked URL survives restarts; `--rotate-token` replaces it (revoking every device at once; refused while another standalone server is running, since it would keep honouring the old one). The terminal socket (`/terminal/:agentId`) carries it the same way and both sockets decide privilege with ONE predicate, `wsAuth.standaloneHandshakeVerdict`: the token AND an allowed `Host` — loopback, a specific `--host`, or a name passed with `--allowed-host` (anti-DNS-rebinding defence in depth). The allowlist is explicit rather than \"loopback when loopback-bound\" because a reverse proxy (Tailscale Serve) forwards to 127.0.0.1 under its public name; a loopback-only rule forced `--host 0.0.0.0`, exposing the port and the token in transit to the LAN. **An unprivileged client is read-only**: it may load and watch the office (`webviewReady`) and NOTHING else — every other message is refused by one gate at the top of `handleClientMessage` (`refuseReadOnly`), so a new message type is privileged by default. Where the UI updates optimistically (settings toggles, hooks toggle, asset directories, area mappings), the refused client is re-sent the real state. A tokened connection under an unlisted Host is read-only too and logs the `--allowed-host` hint. A second standalone launch reuses a running server only when its access matches (`ServerConfig.standalone`: terminal on/off, bind host, allowed hosts) — otherwise `--no-terminal` would silently attach to a server with a shell.\n\nThe **hooks preference is persisted only after the install/uninstall settled and the on-disk result agrees** — writing it first strands the user when an uninstall fails: entries still firing, but a persisted hooks-off makes the next startup skip the consent/install path entirely.\n\nServer discovery written to `~/.pixel-agents/server.json` with `{ port, pid, authToken }`. Multi-window safe: a second server detects an existing `server.json` and reuses or replaces it based on PID liveness.\n\n### ClientMessageHandler\n\nSingle dispatch point for `ClientMessage`. Each variant calls into `AgentRuntime`, `AgentStateStore`, `LayoutPersistence`, or `FileStateAdapter`, or delegates to host-specific callbacks (`onLaunchAgent`, `onOpenSessionsFolder`, `onExportLayout`, `onImportLayout`, `onSetHooksEnabled`). Both surfaces wire the same handler.\n\n### ServerAgentState (server/src/types.ts)\n\nPer-agent runtime data: provider reference, session key, transcript-fallback fields (`jsonlFile`, `fileOffset`, `lineBuffer`), tool state Maps and Sets, team fields (`teamName`, `agentName`, `isTeamLead`, `leadAgentId`, `teamUsesTmux`), context usage (`contextTokens`, `maxContextTokens`, `sawMainChainUsage`), and the **`hookDelivered`** flag that suppresses heuristic timers when hooks are flowing.\n\n## Persistence\n\n```\n~/.pixel-agents/\n  config.json              { vscode, standalone, externalAssetDirectories, hooksConsent, hooksEnabled (both per-provider) }\n  vscode-state.json        { agents, seats }\n  standalone-state.json    { agents, seats }\n  layout.json              OfficeLayout (shared across surfaces)\n  server.json              { port, pid, authToken }\n  hooks/claude-hook.js     Bundled hook script (CJS, shebang)\n```\n\n`FileStateAdapter({ namespace })` backs both runtimes. Per-namespace settings: `soundEnabled`, `lastSeenVersion`, `alwaysShowLabels`, `watchAllSessions`, `hooksInfoShown` (the hooks preference is per-provider and machine-global, at the config top level). Running both surfaces in parallel never clobbers either.\n\n`migrateVsCodeState` (VS Code adapter only) walks each known legacy key once with **verify-before-clear** semantics: write to file, read back, only then clear the legacy key. While anything remains unmigrated, activation shows a non-blocking warning.\n\nLayout writes are atomic via tmp + rename. Cross-window watching is hybrid (`fs.watch` + 2 s polling). `markOwnWrite()` prevents the watcher from re-reading our own write.\n\n## Agent Status Tracking\n\nJSONL transcripts at `~/.claude/projects/<project-hash>/<session-id>.jsonl`. Project hash = workspace path with `:`/`\\`/`/` → `-`.\n\n**JSONL record types**: `assistant` (tool_use or thinking), `user` (tool_result or text prompt), `system` with `subtype: \"turn_duration\"` (reliable turn-end signal), `progress` with `data.type`: `agent_progress` (sub-agent tool_use/tool_result, non-exempt tools trigger permission timers), `bash_progress` (Bash output — restarts permission timer), `mcp_progress` (MCP tool — same timer restart). Also observed but not tracked: `file-history-snapshot`, `queue-operation`.\n\n**File watching**: 500 ms polling with partial-line buffering for mid-write reads. Tool-done messages delayed 300 ms to prevent React batching from hiding brief active states.\n\n### Dual-mode detection\n\n| Mode                     | Source                                                 | Detection                                                                                                                                                                                                                                                                         |\n| ------------------------ | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| **Hooks** (preferred)    | Claude Code Hooks API → HTTP POST → `HookEventHandler` | Instant, reliable. Installed events are `CLAUDE_HOOK_EVENTS`. `UserPromptSubmit`/`TaskCreated` are deliberately NOT installed — they normalize to null, so installing them only forwarded prompt text to be dropped; `normalizeHookEvent` still tolerates them for stale installs |\n| **Heuristic** (fallback) | Polling JSONL files                                    | Per-agent 500 ms JSONL polling for /clear detection; 1 s main scanner for terminal adoption; 3 s external scanner; 30 s stale check. Content-based /clear detection (`/clear</command-name>` in first 8 KB)                                                                       |\n\nThe `hookDelivered` flag (per agent) and `hooksEnabled` (global) gate timer logic. JSONL polling always runs in both modes for tool content (status text, animations); only permission (7 s) and text-idle (5 s) timers are suppressed by `hookDelivered`.\n\n### Sub-agent permission detection (heuristic)\n\nWhen a sub-agent runs a non-exempt tool, `startPermissionTimer` fires on the parent agent. If `PERMISSION_TIMER_DELAY_MS` (7 s) elapses with no data, permission bubbles appear on both parent and sub-agent characters via `agentToolPermission` + `subagentToolPermission` broadcasts.\n\n`activeSubagentToolNames: Map<parentToolId, Map<subToolId, toolName>>` tracks which sub-tools are active for the exempt check. Cleared when data resumes, Task completes, or `turn_duration` arrives.\n\n**Timing budget for tests**: the test waits for the bubble AFTER waiting for the \"Subtask:\" overlay. But the Subtask overlay appears when the parent's **Task tool_use** is parsed (scenario time T), while the timer only starts when the **sub-tool tool_use** in the progress record is parsed (~1 s later). Plus 7 s timer + 300 ms IPC/render slop = a wait timeout less than ~10 s is unsafe.\n\n### Context usage (server/src/contextUsage.ts)\n\nEvery agent's context gauge. Fed from `message.usage` on assistant records by `processTranscriptLine`, broadcast as `agentContextUsage` and rendered by `ToolOverlay`.\n\n- **Snapshot, not a total.** Context = the newest turn's `input_tokens + cache_creation_input_tokens + cache_read_input_tokens + output_tokens`. Summing turns measures spend, not occupancy, and misses the cached tokens that ARE the context (`input_tokens` is single digits once caching kicks in). Falls on compaction and `/clear` for free.\n- **All-zero usage means \"no news\"** — synthetic records (API errors, interrupts) report zeros and must not blank the gauge.\n- **Sidechain rule**: a lead's sidechain records are its sub-agents' turns and must not move its gauge; a teammate's own transcript is sidechain top to bottom and would otherwise never report. Positional latch (`sawMainChainUsage`): once a file produces a main-chain turn, later sidechain records belong to someone else.\n- **The window comes from the provider**, not from the runtime: `HookProvider.contextWindowForModel(model)` (Claude: 1M for the current line, 200k for Haiku and the older models, `undefined` for ids it can't place). Transcripts state usage but never the limit, and guessing 200k for a 1M model reads five times too full. `widenContextWindow` is the backstop for unrecognized models and unknown-larger windows — it only ever widens, since a context that doesn't fit disproves the assumption while a shrinking one proves nothing.\n- **`seedContextUsage` runs once per agent in `startFileWatching`** — the single seam every watched agent passes through. Agents adopted or restored mid-session start at end-of-file, so without a tail read they'd have no gauge until their next turn.\n- Sub-agents get no gauge: no session of their own, and the shadow store never forwards `agentContextUsage`.\n\n## Office UI\n\n**Rendering**: Game state in imperative `OfficeState` class (not React state). Pixel-perfect: zoom = integer device-pixels-per-sprite-pixel (1x–10x). No `ctx.scale(dpr)`. Default zoom = `Math.round(2 * devicePixelRatio)`. Z-sort all entities by Y. Pan via middle-mouse drag (`panRef`). **Camera follow**: `cameraFollowId` (separate from `selectedAgentId`) smoothly centers camera on the followed agent; set on agent click, cleared on deselection or manual pan.\n\n**UI styling**: Pixel art aesthetic — sharp corners (`borderRadius: 0`), solid backgrounds (`#1e1e2e`), `2px solid` borders, hard offset shadows (`2px 2px 0px #0a0a14`, no blur). CSS variables in `index.css` `:root` (`--pixel-bg`, `--pixel-border`, `--pixel-accent`, ...). Pixel font: FS Pixel Sans (`webview-ui/src/fonts/`), loaded via `@font-face`, applied globally.\n\nCustom ESLint rules (`eslint-rules/pixel-agents-rules.mjs`) enforce: `no-inline-colors` (hex/rgb/rgba/hsl/hsla literals only in `constants.ts`), `pixel-shadow` (must use `var(--pixel-shadow)` or `2px 2px 0px`), `pixel-font` (must reference FS Pixel Sans). All `error`-level — they block PRs.\n\n**Characters**: FSM states — active (pathfind to seat, typing/reading animation by tool type), idle (wander randomly with BFS, return to seat after `wanderLimit` moves). 4-directional sprites, left = flipped right. Tool animations: typing (Write/Edit/Bash/Task) vs reading (Read/Grep/Glob/WebFetch). Sitting offset: characters shift down 6 px in TYPE state. Z-sort uses `ch.y + TILE_SIZE/2 + 0.5` so characters render in front of same-row furniture but behind lower-row furniture. **Chair z-sorting**: non-back chairs use `zY = (row+1)*TILE_SIZE` (capped to first row); back-facing chairs use `zY = (row+1)*TILE_SIZE + 1` so the chair back renders in front of the character. Chair tiles are blocked for all characters except their own assigned seat (per-character pathfinding via `withOwnSeatUnblocked`).\n\n**Diverse palette assignment**: `pickDiversePalette()` counts palettes of current non-sub-agent characters; picks randomly from least-used palette(s). First 6 agents each get a unique skin; beyond 6, skins repeat with a random hue shift (45–315°) via `adjustSprite()`. Character stores `palette` (0-5) + `hueShift` (degrees). Sprite cache keyed by `\"palette:hueShift\"`.\n\n**Spawn/despawn effect**: Matrix-style digital rain animation (0.3 s). 16 vertical columns sweep top-to-bottom with staggered timing. Spawn: green rain reveals character pixels. Despawn: character pixels consumed by green rain trails. `matrixEffect` field on Character (`'spawn'`/`'despawn'`/`null`). Normal FSM is paused during effect. Restored agents (`existingAgents`) use `skipSpawnEffect: true` to appear instantly.\n\n**Sub-agents**: Negative IDs (from -1 down). Created on `agentToolStart` with \"Subtask:\" prefix, or lazily by `subagentToolStart` when missing (watched background spawns, post-reload recreation). Same palette + hueShift as parent. Click focuses parent terminal. Not persisted. Spawn at the closest free walkable tile to the parent (`closestFreeWalkableTile`) — around it, never in a seat. Idle (stop typing) when every tracked sub-tool row is done; overlay shows the latest non-done sub-tool status, falling back to the Subtask label.\n\n**Speech bubbles**: Permission (\"...\" amber dots) stays until clicked/cleared. Waiting (green checkmark) auto-fades 2 s. Sprites in `spriteData.ts`.\n\n**Sound notifications**: Ascending two-note chime (E5 → E6) via Web Audio API plays when waiting bubble appears (`agentStatus: 'waiting'`). `notificationSound.ts` manages AudioContext lifecycle; `unlockAudio()` on canvas mousedown resumes the context (webviews start suspended). Toggled via Settings modal. Persisted per-namespace in `~/.pixel-agents/config.json`.\n\n**Mobile shell** (standalone only): below `MOBILE_MEDIA_QUERY` the browser renders `mobile/MobileShell` instead of the desktop layout — office and terminal as two full-screen pages in a 200%-wide sliding track (both always laid out, never `display:none`, so the canvas and xterm keep dimensions); the track's transform makes it the containing block of `position: fixed`, so modals and other viewport overlays mount at App's top level, never inside the office region), the agent cards in a bottom scroller, and `MobileKeyBar` above the software keyboard. \"Keyboard open\" and \"clamp the shell\" are separate answers (`mobile/keyboardViewport.ts`, sampled by `useKeyboardViewport`) because the two resize policies differ: iOS shrinks only the visual viewport (open, and the shell is clamped to it so the PTY resizes instead of hiding its input line), Android `interactive-widget=resizes-content` shrinks both (open, nothing to clamp). Open = an editable focused AND the visual viewport `KEYBOARD_MIN_HEIGHT_PX` below the tallest height seen at the current width (per-orientation baseline). `useIsMobile` is gated on `isBrowserRuntime`: a narrow VS Code panel must never get it (it broke every VS Code e2e shard once). Navigation rules live in `useMobileShell`: the >_ / Office toggle and the edge swipe share one handler; a card tap in the office is two-step (focus the character, then open its terminal), in the terminal it switches panes; + slides over only for launches made from it (a reload re-announcing sessions stays on the office). The terminal-pane model (active vs shown pane, socket status, `statusFor`) is `useTerminalDrawer`, shared with the desktop drawer. Touch rules: every gesture tracks ONE finger by identifier (`findTouch`) and uses one tap slop (`withinTapSlop`); gesture decisions are pure reducers with thin DOM adapters (`edgeSwipe.ts`/`useEdgeSwipe.ts`, `terminal/touchGesture.ts`/`touchInput.ts`), because WebKit keeps addressing a gesture's events to its touchstart node — when terminal rows repaint, that node detaches and events stop bubbling, so each adapter binds \"rescue\" listeners on the touch target. **Terminal copy is verbatim by default** (Cmd/Ctrl+C, the browser's Copy, the pill's Copy): `flowTerminalCopy` is a heuristic that joins a long row with a two-space-indented next line, which is also what YAML under a long key or a method body under a long signature look like, so re-flowing is opt-in only — Alt+C (Option+C) on desktop, \"Copy as text\" on the pill. Every write goes through `terminal/clipboard.ts`. PWA: `manifest.webmanifest` (icon paths relative — the file is served verbatim), conventional-path icons in `public/`, `?v=N` cache-busters on icon links.\n\n**Seats**: Derived from chair furniture. `layoutToSeats()` creates a seat at every footprint tile of every chair. Multi-tile chairs produce multiple seats keyed `uid` / `uid:1` / `uid:2`. Facing direction priority: 1) chair `orientation` from catalog (front→DOWN, back→UP, left→LEFT, right→RIGHT), 2) adjacent desk direction, 3) forward (DOWN). Click character → select (white outline) → click available seat → reassign.\n\n## Layout Editor\n\nToggle via \"Layout\" button. Tools: SELECT (default), Floor paint, Wall paint, Erase (set tiles to VOID), Furniture place, Furniture Copy (`FURNITURE_PICK`: type + colour off a placed item), colour Copy (`COLOR_PICK`: colour only), Eyedrop","isInternal":false,"tokens":10612,"sizeBytes":50215}],"systemPromptSnippet":"<yakaai_skills repo=\"pablodelucca/pixel-agents\">\n<!-- File: CLAUDE.md (Tokens: ~10612 | Category: claude-rule) -->\n# Pixel Agents — Compressed Reference\n\nPixel art office where AI agents (Claude Code terminals today, any tool tomorrow) become animated characters. Ships as a **VS Code extension** and an **`npx pixel-agents` standalone CLI** from the same source tree.\n\n`CONTEXT.md` is the canonical glossary — read it for what terms like Agent, Sub-agent, Teammate, Lead, Adopt, or Headless agent mean here, and use its vocabulary in code, comments, and docs.\n\n## Architecture\n\nStrict layering: `core/` depends on nothing; `server/` depends only on `core/`; `webview-ui/` depends only on `core/`; `adapters/vscode/` depends on `core/` and `server/`. The standalone CLI never imports `adapters/vscode/` and vice versa.\n\n```\ncore/                                Protocol + interface definitions (zero runtime side effects)\n  asyncapi.yaml                      AsyncAPI 3.0 contract — single source of truth\n  src/\n    messages.ts                      AUTO-GENERATED discriminated unions (do not edit)\n    schemas.ts                       AgentMeta, SpriteData, FurnitureCatalogEntry\n    provider.ts                      HookProvider, AgentEvent (the integration boundary)\n    teamProvider.ts                  Optional TeamProvider (semantic queries for Lead + Teammates)\n    transport.ts                     MessageTransport interface, TransportState\n    adapter.ts                       StateAdapter, AssetCache, PersistedAgent, AgentSeat\n    terminalAdapter.ts               TerminalAdapter (editor-driven terminal management)\n    normalizeProjectPath.ts\n    constants.ts\n\nserver/                              Lifecycle runtime + Fastify HTTP/WS server\n  src/\n    providers/hook/claude/           Reference HookProvider — only place that knows Claude specifics\n      claude.ts                      normalizeHookEvent for 11 Claude events, formatToolStatus, file fallback\n      claudeTeamProvider.ts          TeamProvider: reads ~/.claude/teams/<name>/config.json\n      claudeHookInstaller.ts         Consent-gated install/uninstall in ~/.claude/settings.json (abort on unparseable file or non-array hooks.<Event>; one-time .pixel-agents.backup, exclusive-create, no backup ⇒ no write — but skipped when the replaced content is entirely our own install's output, since backing up our own file masquerades as the user's original (`settingsHoldOnlyOurHooks`, compared against makeHookEntry — the WRITER — so a field added to what we write can't silently revive the bug; only `command`/`timeout` may differ, they vary across installs); every write failure THROWS; mode preserved, 0600 on create; re-read verify immediately before rename + retry; hook identity = `/.pixel-agents/hooks/claude-hook.js` suffix anchored at both ends of the command's first token, case-insensitive; `areHooksInstalled` = ANY of our commands on ANY event)\n      consentCopy.ts                 Claude's first-run consent disclosure text (scope/data/undo), served through consentDisclosure()\n      constants.ts                   Claude hook event names, script path\n      hooks/claude-hook.ts           Hook script (CJS+shebang, bundled to dist/hooks/claude-hook.js)\n    providers/hook/consentGate.ts    Provider-agnostic consent POLICY: when to ask (hooksConsentRequest per provider) and what an answer means (consentActionFor(choice, {installed, consent}) — see docs/adr/0001)\n    providers/hook/consentExecutor.ts Provider-agnostic consent EXECUTION: applyConsentChoice(providerId, choice, ConsentEffects) runs the six actions in one order for both surfaces, and SERIALIZES answers per process across ALL providers\n    providers/index.ts               Provider registry (claudeProvider + the hookProviders list the consent gate loops over)\n    agentRuntime.ts                  Lifecycle core: timers, scanners, HookEventHandler, SessionRouter, DismissalTracker\n    agentStateStore.ts               EventEmitter-backed single source of truth (typed mutations + events)\n    sessionRouter.ts                 session_id → agent_id mapping, event buffering, pending external sessions\n    dismissalTracker.ts              Unified dismissal state (replaces four legacy globals)\n    hookEventHandler.ts              Dispatches normalized AgentEvent into runtime\n    httpServer.ts                    Fastify: POST /api/hooks/:providerId, GET /api/health, GET /ws, SPA (standalone)\n    clientMessageHandler.ts          Single dispatch point for ClientMessage from webview\n    server.ts                        Top-level composition\n    cli.ts                           npx pixel-agents entry (npm bin)\n    fileStateAdapter.ts              Namespaced ~/.pixel-agents/ persistence\n    configPersistence.ts             { vscode, standalone, externalAssetDirectories, hooksConsent: {providerId: granted|declined}, hooksEnabled: {providerId: boolean} }\n    layoutPersistence.ts             ~/.pixel-agents/layout.json with atomic tmp+rename\n    fileWatcher.ts                   Hybrid fs.watch + 500ms polling, JSONL line buffering, /clear detection\n    transcriptParser.ts              JSONL parsing for heuristic / file-fallback mode\n    timerManager.ts                  Waiting / permission timers\n    assetLoader.ts                   PNG → SpriteData via pngjs\n    teamUtils.ts                     isInlineTeammateOf, getInlineTeammates, hasInlineTeammates\n    types.ts                         ServerAgentState\n    constants.ts                     All timing/scanning constants\n  __tests__/                         28 Vitest files\n  manual-hook-events.http            Manual hook testing helper (REST-Client format)\n\nadapters/vscode/                     VS Code surface — composes core + server\n  extension.ts                       activate() / deactivate()\n  PixelAgentsViewProvider.ts         WebviewViewProvider, thin bridge to AgentRuntime\n  agentManager.ts                    Terminal lifecycle (claude --session-id <uuid>), restore, persist\n  vscodeTerminalAdapter.ts           TerminalAdapter implementation\n  uninstall.ts                       vscode:uninstall hook — removes hook entries + factory-resets hooks config after extension removal\n  migrateVsCodeState.ts              One-time legacy state migration (verify-before-clear)\n  constants.ts                       VS Code IDs, command names, key names\n\nwebview-ui/                          React 19 + Canvas UI (depends only on core/)\n  src/\n    transport/\n      index.ts                       createTransport() — single runtime branching point\n      postMessageTransport.ts        VS Code mode (acquireVsCodeApi)\n      webSocketTransport.ts          Standalone mode (exponential backoff, send queue)\n      types.ts                       Re-exports MessageTransport from core\n    runtime.ts                       isBrowserRuntime detection\n    browserMock.ts                   Standalone-browser asset fetch + message injection\n    testHooks.ts                     window globals exposed for e2e (officeState, helpers)\n    main.tsx                         React entry (StrictMode + createRoot)\n    App.tsx                          Composition root (hooks + components + EditActionBar)\n    constants.ts                     Webview magic numbers/strings\n    notificationSound.ts             Web Audio API chime\n    changelogData.ts                 Changelog modal content\n    components/                      React UI (toolbars, modals, settings)\n      BottomToolbar.tsx, ZoomControls.tsx, SettingsModal.tsx, InfoModal.tsx,\n      Tooltip.tsx, DebugView.tsx, ui/Button.tsx, ...\n      AgentCard.tsx, AgentCardBar.tsx  Agent cards (mug shot + status dot); the right-edge bar on desktop/VS Code\n      cardBar.ts                     Pure card-bar rules: cardVariant, mergeOrder, reorderByPointer (Node-tested)\n      TerminalDrawer.tsx             Desktop standalone: right-docked panel, cards as tabs\n      TerminalPaneStack.tsx          Every agent's TerminalPane mounted, the shown one visible (both shells)\n      TerminalPane.tsx               One xterm bound to one agent's PTY\n      MobileAgentBar.tsx             Mobile bottom card scroller (+ launch card, long-press reorder)\n      MobileTerminalPage.tsx         Mobile terminal page (a TerminalPaneStack)\n      MobileKeyBar.tsx               Accessory keys above the software keyboard (esc, shift+tab, trackpad, paste)\n    mobile/                          The phone shell (standalone only, see \"Mobile shell\")\n      MobileShell.tsx                Sliding office/terminal track + card bar + key bar\n      useMobileShell.ts              Page state and navigation rules (toggle, card tap, launch-then-slide)\n      edgeSwipe.ts                   Edge-swipe state machine (pure reducer, Node-runner tested)\n      keyboardViewport.ts            Software-keyboard decision: open vs per-width baseline, clamp only when the visual viewport is shorter (pure)\n      useEdgeSwipe.ts                DOM adapter for the reducer (capture listeners, detached-target rescue)\n    touch/\n      touchPrimitives.ts             findTouch (track one finger by id) + withinTapSlop (the one tap metric)\n    terminal/                        Standalone terminal (xterm) support\n      terminalClient.ts              /terminal/:agentId socket (reconnect, replay)\n      terminalLinks.ts               Safe link opener + urlAtCell (one URL pattern for clicks and taps)\n      touchGesture.ts                Terminal touch state machine: scroll, flick, tap, long-press select, handles (pure reducer)\n      touchInput.ts                  DOM adapter: listeners, flick rAF, long-press timer, detached-target rescue\n      selectionMath.ts               Word expansion, range ordering, handle clamping (pure)\n      selectionOverlay.ts            Copy pill (Copy | Copy as text) + selection handles (DOM; styled in index.css)\n      clipboard.ts                   The one clipboard write/read: Clipboard API on secure origins; plain http falls back to a copy event (write) / a native prompt (paste)\n      flowCopy.ts                    terminalCopyText: verbatim by default; opt-in re-flow of Claude Code's soft wraps (pure)\n      clipboardKeys.ts               Clipboard chords (Ctrl+C/Ctrl+Shift+C copy, Alt+C copy as text, Ctrl+V paste) + OSC 52 decode (pure)\n      terminalDom.ts                 xterm screen geometry + isTypingInTerminal\n    hooks/\n      useExtensionMessages.ts        Message handler — translates ServerMessage into OfficeState mutations\n      useEditorActions.ts            Editor state + callbacks\n      useFurnitureColorActions.ts    Furniture colour sliders + colour-only eyedropper\n      useEditorKeyboard.ts           Keyboard shortcuts (R, T, Esc, Ctrl+Z/Y)\n      introTourState.ts              Intro tour wire-state machine (pure reducer, Node-runner tested)\n      useIntroTour.ts                Wires the reducer to React + transport (snapshot, verdict, choices)\n      useTerminalDrawer.ts           Terminal-pane model for both shells: active/shown pane, socket status, card lookups\n      useIsMobile.ts                 Mobile breakpoint — always false in the VS Code webview\n      useKeyboardViewport.ts         Samples the viewport: keyboard open (key bar) + iOS shell clamp height\n    office/\n      types.ts                       OfficeLayout, Character, etc. + re-exports constants\n      toolUtils.ts                   STATUS_TO_TOOL mapping, extractToolName (DOM-free; defaultZoom lives in useEditorActions)\n      projection.ts                  World→screen math shared by renderer + DOM overlays (mapOffset, overlayProjection)\n      colorize.ts                    Colorize (grayscale→HSL) + Adjust (HSL shift)\n      floorTiles.ts                  Floor sprite storage + colorized cache\n      wallTiles.ts                   Wall auto-tile: 16 bitmask sprites\n      sprites/\n        spriteData.ts                Pixel data (characters, furniture, tiles, bubbles)\n        spriteCache.ts               SpriteData → offscreen canvas, per-zoom WeakMap\n      editor/\n        editorActions.ts             Pure layout ops\n        editorState.ts               Imperative state (tools, ghost, selection, undo/redo + undo session, drag, placing)\n        editorRenderState.ts         buildEditorRenderState: ghosts + selection for the renderer (pure)\n        EditorToolbar.tsx\n      layout/\n        furnitureCatalog.ts          Dynamic catalog from loaded assets\n        layoutSerializer.ts          OfficeLayout ↔ runtime (tileMap, furniture, seats)\n        tileMap.ts                   Walkability, BFS pathfinding\n      engine/\n        characters.ts                Character FSM (idle/walk/type) + wander AI\n        officeState.ts               Game world (layout, characters, seats, selection, subagents, consent greeter)\n        gameLoop.ts                  rAF loop with delta-time cap (0.1 s)\n        renderer.ts                  Canvas: tiles, z-sorted entities, overlays, edit UI\n        matrixEffect.ts              Spawn/despawn digital rain (drawing only)\n        matrixEffectState.ts         Effect state: startMatrixEffect/advanceMatrixEffect (DOM-free)\n      components/\n        OfficeCanvas.tsx             Canvas, resize, DPR, mouse hit-testing, drag-to-move\n        useCanvasTouchGestures.ts    Touch pan / pinch zoom / tap on the canvas\n        ToolOverlay.tsx              Activity label + speech bubbles (DOM, above the labels) per character\n\ne2e/                                 Playwright suite (real VS Code + mock-claude scenarios)\n  playwright.config.ts\n  global-setup.ts\n  fixtures/\n    pixel-agents.ts                  VS Code fixture: launch Electron, wait for panel\n    standalone.ts                    Standalone CLI fixture: spawn server + browser page\n    mock-claude, mock-claude.cmd     Bash + cmd wrapper invoked instead of real claude\n    mock-claude-runner.cjs           Scenario runner: appendJsonl, emitHook, holdOpen\n  helpers/\n    launch.ts                        Electron app + isolated HOME/workspace\n    mock-claude.ts                   claudeScenario() builder\n    office.ts                        Overlay locators + assertions\n    webview.ts                       Settings/modal helpers\n    hooks.ts                         Hook server lifecycle helpers\n    standalone.ts                    Standalone server + WebSocket browser helpers\n    internal-agent.ts                spawnInternalAgentAndWait\n    lifecycle.ts                     Reusable scenario fragments\n    team.ts                          Team config seeding + teammate helpers\n    allure-labels.ts                 @area:<tag> → Allure epic\n  tests/\n    claude/hooks-on/                 basic.spec.ts, lifecycle.spec.ts, teams.spec.ts\n    claude/hooks-off/                lifecycle.spec.ts, matrix.spec.ts\n    standalone/                      hooks.spec.ts\n  README.md                          Auto-generated test inventory (regen via npm run e2e:inventory)\n\nscripts/\n  generate-messages.ts               AsyncAPI → core/src/messages.ts via Modelina (with CI drift check)\n  run-e2e.mjs                        Playwright wrapper (run-id namespacing, video attach flags)\n  generate-e2e-inventory.mjs         Splices test list into e2e/README.md (CI drift check)\n  build-allure-report.mjs            Combine e2e+server+webview Allure results\n  assemble-vercel-output.mjs         Stage /reports/allure/ for Vercel deploy\n  asset-manager.html                 Unified furniture editor (positions + metadata)\n  jsonl-viewer.html                  Standalone JSONL transcript inspector\n  wall-tile-editor.html              Wall sprite editor\n\ncore/                                npm workspace (no separate package; root manages)\nserver/                              npm workspace\nwebview-ui/                          npm workspace\n```\n\n## Distribution\n\nTwo artifacts from one source tree:\n\n- **VS Code extension** (`.vsix`) — `pablodelucca.pixel-agents` on VS Code Marketplace and Open VSX. Bundles VS Code adapter + webview SPA + assets + hook scripts.\n- **npm package** (`pixel-agents`) — `npx pixel-agents [--port 3100]` runs the Fastify server and serves the SPA on the same port. Bundles CLI + webview SPA + assets + hook scripts + `core/asyncapi.yaml` (so third-party clients can regenerate from it).\n\n`package.json:files` allowlist controls the npm tarball: `dist/cli.js{,.map}`, `dist/webview/`, `dist/assets/`, `dist/hooks/`, `core/asyncapi.yaml`, `icon.png`. `dist/extension.js` is intentionally excluded since the VS Code entry ships through the `.vsix`.\n\n## Communication Flow\n\nHub-and-spoke. The server is the single aggregation point for all agent activity, regardless of source.\n\n```\nHook scripts ─POST /api/hooks/:providerId─┐\n                                          ├─→ HookProvider.normalizeHookEvent()\nJSONL transcripts ─FileWatcher─→ TranscriptParser ┤\n                                                   ↓\n                                              AgentEvent (canonical)\n                                                   ↓\n                                              AgentRuntime (dispatch on .kind)\n                                                   ↓\n                                           AgentStateStore (mutate)\n                                                   ↓\n                                              StoreEvents → broadcast\n                                                   ↓\n                          PostMessageTransport ──┤├── WebSocketTransport\n                                 (VS Code)      (standalone browser)\n```\n\nThe VS Code adapter wires `PostMessageTransport` against `acquireVsCodeApi()`. The standalone CLI exposes the same protocol over WebSocket at `/ws` with the webview SPA served from the same Fastify instance. **The protocol shape is identical; only the wire differs.**\n\nAdding a new CLI integration is one subdirectory under `server/src/providers/hook/<id>/`: provider, optional `TeamProvider`, installer, hook scripts. Zero changes to the runtime, the UI, or any existing provider.\n\n## AsyncAPI Protocol Contract\n\n`core/asyncapi.yaml` is the contract. Pinned to **3.0.0** because `@asyncapi/modelina@5.10.1` declares `supportedVersions: ['3.0.0']` only; bumping to 3.1.0 produces `export type Root = any`. Revisit when Modelina ships 3.1.0 support.\n\n- **39 ServerMessage variants** (server → client): agent lifecycle, agent activity, sub-agent activity, team + context usage, assets, access (`officeAccess` — sent first on `webviewReady`; `readOnly` hides every control a read-only office would only have refused), settings (`bypassPermissionsUpdated` — the skip-permissions posture, broadcast to every office on change because every launch reads it) + directories (`directoriesLoaded`, `directorySaved` — a save's ack to its sender, carrying the entry as stored, whose name keys the Area mapping; `directoryRejected`, `directorySuggestions`), terminal, diagnostics.\n- **27 ClientMessage variants** (client → server): lifecycle (`webviewReady`, `launchAgent`, `focusAgent`, `closeAgent`), layout (`saveAgentSeats`, `saveLayout`, `exportLayout`, `importLayout`), settings (`setSoundEnabled`, `setHooksEnabled`, `setWatchAllSessions`, `setAlwaysShowLabels`, `setHooksInfoShown`, `setLastSeenVersion`, `setBypassPermissions`, `setShowHostDirectories` — whether the host's own Directories are listed, off by default; only the list is gated, launches still name agents after them), directories (`saveDirectory`, `removeDirectory`, `requestDirectorySuggestions`), terminal, discovery + assets, diagnostics.\n\nBoth unions use `oneOf` with `discriminator: type`. Every concrete message sets `additionalProperties: false`.\n\n`core/src/messages.ts` is generated by `scripts/generate-messages.ts` invoking `@asyncapi/modelina` with custom constraints (`propertyKey` preserves `type`/`status` field names; `constant` inlines `const` literals). The file has an auto-generation banner. **CI runs `asyncapi:generate` then `git diff --exit-code core/src/messages.ts` — any non-empty diff fails the build.**\n\n## Transport Abstraction\n\n```typescript\nexport interface MessageTransport {\n  send(msg: ClientMessage): void;\n  onMessage(handler: (msg: ServerMessage) => void): () => void;\n  readonly ready: Promise<void>;\n  readonly state: TransportState;\n  onStateChange(handler: (state: TransportState) => void): () => void;\n  dispose(): void;\n}\nexport type TransportState = 'connecting' | 'connected' | 'reconnecting' | 'disconnected';\n```\n\n`PostMessageTransport` reports `connected` for its entire lifetime. `WebSocketTransport` reconnects with exponential backoff (250 ms, 500 ms, 1 s, 2 s, 4 s, capped) and queues sends while disconnected.\n\n`createTransport()` in `webview-ui/src/transport/index.ts` is the **only branching point** in the UI codebase. Everything downstream uses the `MessageTransport` interface and never knows which transport is active.\n\n## Provider Abstraction\n\n`HookProvider` (`core/src/provider.ts`) is the integration boundary. Today only Claude Code is implemented; the Claude provider supports every transcript/hook format up to **Claude Code v2.1.220** (current as of 2026-07-30 — Task-era `agent_progress` records, explicit and implicit teams, background-by-default Agent spawns, sidecar-backed background agents). Newer CLI releases may add formats that need provider updates. The interface:\n\n- **Required**: `normalizeHookEvent(raw)` → `{ sessionId, event: AgentEvent } | null`; `installHooks` / `uninstallHooks` / `areHooksInstalled`; `formatToolStatus`; `permissionExemptTools`, `subagentToolNames`, `readingTools` sets.\n- **Optional file fallback**: `getSessionDirs(workspace)`, `getAllSessionRoots()`, `sessionFilePattern`, `parseTranscriptLine(line)`, `buildLaunchCommand(sessionId, cwd, opts)`. Used when hooks aren't installed.\n- **Optional team extension**: `team?: TeamProvider` for Lead + Teammates support.\n\n`AgentEvent.kind` values: `toolStart`, `toolEnd`, `turnEnd`, `subagentStart`, `subagentEnd`, `subagentTurnEnd`, `progress`, `permissionRequest`, `sessionStart`, `sessionEnd`. The runtime dispatches on `kind`, never on CLI-specific tool names.\n\n### TeamProvider (Lead + Teammates)\n\nOptional extension for CLIs that support team workflows (Claude Agent Teams today). Semantic queries (`discoverTeammates`, `getTeamMembers`, `getTeamMetadataForSession`, `extractTeammateNameFromEvent`, `isTeammateSpawnCall`) — providers choose their own storage strategy.\n\nFour teammate modes:\n\n| Mode                            | Trigger                                          | Detection                                                                                                                                                                                                                                                                                                                                                                               |\n| ------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| Basic subagent (teams OFF)      | `Task(...)` or `Agent(run_in_background: false)` | `subagentStart` with team gate not matched, or JSONL `agent_progress`                                                                                                                                                                                                                                                                                                                   |\n| Inline teammate (teams ON)      | `Agent(run_in_background: true)` in-process      | `onTeammateDetected` → `discoverTeammates(projectDir, leadSessionId)`                                                                                                                                                                                                                                                                                                                   |\n| Session teammate (teams + tmux) | `Agent(run_in_background: true)` in tmux pane    | Own session, own hooks, routes through normal flow                                                                                                                                                                                                                                                                                                                                      |\n| Implicit-team agent (Claude 5)  | `Agent(...)` — background by default, NO flag    | Lead-side: spawn tool_result `agent_id: <name>@<team>` → `extractTeammateSpawnFromToolResult` sets lead `teamName`; teammate = own top-level UUID session tagged `teamName`/`agentName` on every record, discovered by `discoverTeammates(projectDir, leadSessionId, teamName)` and fast-attached in `onExternalSessionDetected` (bypasses the Watch All gate when the lead is tracked) |\n\nNewer harnesses (Claude 5) never tag the LEAD's records with team metadata and never write `agent_progress`/`queue-operation` records for these spawns — the spawn tool_result and the teammate's own session (own JSONL, own hooks) are the only signals. The implicit team writes `~/.claude/teams/session-<8hex>/config.json` (its `leadSessionId` matches no transcript on disk — don't link by it). Own-session teammates are registered with the SessionRouter (`setTeammateRegisterCallback`) so their hooks route directly to them.\n\n**Team generations on resume (last-wins latch)**: every CLI run of a session mints a FRESH implicit team, so a resumed lead's transcript carries spawn tool_results from several `session-<8hex>` generations. A tag-less lead re-latches to the newest spawn's team (`setTeamSwitchCallback` removes the defunct team's teammates); tag-derived identity (`teamNameFromTags`, set by the record-tags branch) is authoritative and never overwritten by spawn results. `linkTeammates` never badges a named teammate as LEAD — if only teammates are tracked, linking waits until the real lead is detected (prevents a phantom LEAD character when a teammate session is adopted before/without its lead). Restore drops `isTeamLead` from persisted agents that carry an `agentName`.\n\n**Background spawns without a CLI team (sidecar-backed)**: `Agent(...)` spawns that never register a team take the async path — result \"Async agent launched successfully. agentId: \\<hex>\" (no team anywhere), transcript + sidecar under `<projectDir>/<leadSessionId>/subagents/` (sidecar carries `agentType`/`description`/`toolUseId`, plus `name` when the spawn was named), completion via `queue-operation`. `scanForBackgroundAgentFiles` classifies them by the sidecar `name` — the domain model's sole Sub-agent vs Teammate distinction (see CONTEXT.md):\n\n- **Named → Teammate**: its own seated character, `agentName` from the sidecar `name`; the spawner gets `isTeamLead` + an `agentTeamInfo` broadcast (derived team — NO `teamName`, so config polling stays away) and the transient Subtask is ghost-killed via `subagentClear`. The child has `leadAgentId` + `spawnToolUseId`; it's removed on the completion queue-operation or lead sessionEnd (`removeTeammate` must NOT unregister its session — it shares the lead's).\n- **Unnamed → Sub-agent**: the Subtask sub-character stays; the spawn's transcript is watched in a **shadow `AgentStateStore`** (`server/src/subagentWatch.ts`, owned by `AgentRuntime`, ids from 1 000 000 up) whose broadcasts are translated onto the main store as `subagentToolStart/Done/Permission` keyed `(leadId, spawnToolUseId)` — live activity on the sub-character, no protocol change. `agentToolsClear` on the shadow agent synthesizes `subagentToolDone` for still-live tools so the sub idles between turns. Never persisted, never announced.\n\nThe gate for both: the sidecar `toolUseId` must match a LIVE entry in the lead's `backgroundAgentToolIds` (anti-spurious; no teamName involved). `scanForTeammateFiles` skips sidecars carrying a live `toolUseId` so it can't race the classification. The lead's `backgroundAgentToolIds` are persisted (children never are — they're derived state the 1 s scan re-materializes after a reload; restore skips stale entries with `leadAgentId` but no `teamName`). The turn-end background-tool re-sends (transcriptParser, hookEventHandler, timerManager's `clearAgentActivity`) must include `toolName` + `runInBackground` (or the webview can't recreate the Subtask after `agentToolsClear`) and must skip tools whose spawn became a teammate character (`hasPromotedBackgroundAgent`, or a ghost Subtask appears next to it).\n\nTeammate dismissal is driven by team config polling (`getTeamMembers(teamName)` is authoritative; members with `isActive: false` are treated as departed), by the teammate's own `sessionEnd` (own-session teammates), and by `sessionEnd` on the lead.\n\n**Subtle gate (the one that bit us recently)**: in `webview-ui/src/hooks/useExtensionMessages.ts`, `agentToolStart` with `runInBackground=true` only creates a Subtask sub-character when the **parent has no `teamName`**. With a team, `onTeammateDetected` creates the teammate instead (and the gate prevents a ghost sub-agent). Without a team, the teammate path never fires and the basic Subtask sub-character is the only visible representation. The gate's blind spot — a TEAMED lead's unnamed background spawn — is covered by `subagentToolStart` creating the sub-character lazily when it's missing (`addSubagent` is idempotent); the same lazy path recreates subs after a panel reload.\n\n## Server Runtime\n\n`AgentRuntime` (`server/src/agentRuntime.ts`) is the shared lifecycle core. Both surfaces (`adapters/vscode/PixelAgentsViewProvider.ts` and `server/src/cli.ts`) compose the same runtime; only the `StateAdapter` namespace, `MessageTransport`, and `TerminalAdapter` differ.\n\n```typescript\nexport class AgentRuntime {\n  constructor(opts: {\n    store: AgentStateStore;\n    providerRegistry: ProviderRegistry;\n    layoutPersistence: LayoutPersistence;\n    assetCache: AssetCache;\n    config: Pick<AdapterSettings, 'hooksEnabled' | 'watchAllSessions'>;\n    terminalAdapter?: TerminalAdapter;\n    callbacks: RuntimeLifecycleCallbacks;\n  });\n  registerAgent / unregisterAgent / removeAgent / removeTeammate / removeTeammates\n  restoreExternalAgents / handleHookEvent\n  startProjectScan / startExternalScanning / startStaleCheck\n  dispose();\n}\n```\n\nOwns timer Maps (waiting, permission, text-idle, stale), scanners (project-dir 1 s, external 3 s, stale 30 s), `HookEventHandler`, `SessionRouter`, `DismissalTracker`. Scanners are skipped entirely while hooks are flowing (`hookDelivered` is set on every agent). `RuntimeLifecycleCallbacks` is the only seam between runtime and host.\n\n### AgentStateStore\n\nEventEmitter-backed container in `server/src/agentStateStore.ts`. Typed mutations, typed events (`agentAdded`, `agentRemoved`, `agentUpdated`, `broadcast`). The broadcast layer subscribes once at boot and translates `StoreEvents` into `ServerMessage` over the active transport. **No module under `server/` calls a transport method directly.**\n\n### SessionRouter and DismissalTracker\n\nTwo extracted classes that replaced ad-hoc module state. `SessionRouter` owns `session_id → agent_id` mapping, pre-registration event buffering, and pending external sessions. `DismissalTracker` unifies four legacy globals (`dismissedJsonlFiles`, `clearDismissedFiles`, `seededMtimes`, `pendingClearFiles`) into one class with typed reasons.\n\n### HTTP + WebSocket Server\n\nFastify v5 with `@fastify/cors`, `@fastify/websocket`, and (in standalone) `@fastify/static`:\n\n| Method | Path                     | Purpose                                 |\n| ------ | ------------------------ | --------------------------------------- |\n| POST   | `/api/hooks/:providerId` | Bearer-authenticated hook event ingress |\n| GET    | `/api/health`            | Liveness: `{ ok, version, port, pid }`  |\n| GET    | `/ws`                    | Bidirectional protocol channel          |\n| GET    | `/*` (standalone only)   | Webview SPA via `@fastify/static`       |\n\n`/ws` is gated in **two tiers**, because messages over this socket launch shells, grant durable machine-wide hook consent, and rewrite persisted state.\n\n1. **Connection** (`isAllowedWebSocketOrigin`): embedded requires the Bearer token; standalone requires a same-origin handshake. A missing Origin still connects (non-browser clients send none). This tier is weak by design — a DNS-rebound page sends `Origin` and `Host` as the same attacker-chosen name and IS accepted (`httpServerWs.test.ts` pins that).\n2. **Privileged messages** (`standaloneTokenValid`, `ClientMessageContext.privileged`): proved by an out-of-band secret — embedded via its Bearer token, standalone via the server token in the `/ws` `?token=` query, which the CLI prints in the local URL and the SPA forwards (`webview-ui/src/transport/index.ts`). **Never a network position**: peer address, `Host` and `Origin` all ride the channel a proxy speaks, so a LAN-bound forwarder piping bytes to 127.0.0.1 satisfies all three. The token is a replayable bearer capability, not evidence of locality — it reaches browser history (Fastify's request log redacts it, `wsAuth.redactTokenQuery`), so the printed URL is documented to the user as a secret. Standalone persists it in `~/.pixel-agents/standalone-token` (0600) so a bookmarked URL survives restarts; `--rotate-token` replaces it (revoking every device at once; refused while another standalone server is running, since it would keep honouring the old one). The terminal socket (`/terminal/:agentId`) carries it the same way and both sockets decide privilege with ONE predicate, `wsAuth.standaloneHandshakeVerdict`: the token AND an allowed `Host` — loopback, a specific `--host`, or a name passed with `--allowed-host` (anti-DNS-rebinding defence in depth). The allowlist is explicit rather than \"loopback when loopback-bound\" because a reverse proxy (Tailscale Serve) forwards to 127.0.0.1 under its public name; a loopback-only rule forced `--host 0.0.0.0`, exposing the port and the token in transit to the LAN. **An unprivileged client is read-only**: it may load and watch the office (`webviewReady`) and NOTHING else — every other message is refused by one gate at the top of `handleClientMessage` (`refuseReadOnly`), so a new message type is privileged by default. Where the UI updates optimistically (settings toggles, hooks toggle, asset directories, area mappings), the refused client is re-sent the real state. A tokened connection under an unlisted Host is read-only too and logs the `--allowed-host` hint. A second standalone launch reuses a running server only when its access matches (`ServerConfig.standalone`: terminal on/off, bind host, allowed hosts) — otherwise `--no-terminal` would silently attach to a server with a shell.\n\nThe **hooks preference is persisted only after the install/uninstall settled and the on-disk result agrees** — writing it first strands the user when an uninstall fails: entries still firing, but a persisted hooks-off makes the next startup skip the consent/install path entirely.\n\nServer discovery written to `~/.pixel-agents/server.json` with `{ port, pid, authToken }`. Multi-window safe: a second server detects an existing `server.json` and reuses or replaces it based on PID liveness.\n\n### ClientMessageHandler\n\nSingle dispatch point for `ClientMessage`. Each variant calls into `AgentRuntime`, `AgentStateStore`, `LayoutPersistence`, or `FileStateAdapter`, or delegates to host-specific callbacks (`onLaunchAgent`, `onOpenSessionsFolder`, `onExportLayout`, `onImportLayout`, `onSetHooksEnabled`). Both surfaces wire the same handler.\n\n### ServerAgentState (server/src/types.ts)\n\nPer-agent runtime data: provider reference, session key, transcript-fallback fields (`jsonlFile`, `fileOffset`, `lineBuffer`), tool state Maps and Sets, team fields (`teamName`, `agentName`, `isTeamLead`, `leadAgentId`, `teamUsesTmux`), context usage (`contextTokens`, `maxContextTokens`, `sawMainChainUsage`), and the **`hookDelivered`** flag that suppresses heuristic timers when hooks are flowing.\n\n## Persistence\n\n```\n~/.pixel-agents/\n  config.json              { vscode, standalone, externalAssetDirectories, hooksConsent, hooksEnabled (both per-provider) }\n  vscode-state.json        { agents, seats }\n  standalone-state.json    { agents, seats }\n  layout.json              OfficeLayout (shared across surfaces)\n  server.json              { port, pid, authToken }\n  hooks/claude-hook.js     Bundled hook script (CJS, shebang)\n```\n\n`FileStateAdapter({ namespace })` backs both runtimes. Per-namespace settings: `soundEnabled`, `lastSeenVersion`, `alwaysShowLabels`, `watchAllSessions`, `hooksInfoShown` (the hooks preference is per-provider and machine-global, at the config top level). Running both surfaces in parallel never clobbers either.\n\n`migrateVsCodeState` (VS Code adapter only) walks each known legacy key once with **verify-before-clear** semantics: write to file, read back, only then clear the legacy key. While anything remains unmigrated, activation shows a non-blocking warning.\n\nLayout writes are atomic via tmp + rename. Cross-window watching is hybrid (`fs.watch` + 2 s polling). `markOwnWrite()` prevents the watcher from re-reading our own write.\n\n## Agent Status Tracking\n\nJSONL transcripts at `~/.claude/projects/<project-hash>/<session-id>.jsonl`. Project hash = workspace path with `:`/`\\`/`/` → `-`.\n\n**JSONL record types**: `assistant` (tool_use or thinking), `user` (tool_result or text prompt), `system` with `subtype: \"turn_duration\"` (reliable turn-end signal), `progress` with `data.type`: `agent_progress` (sub-agent tool_use/tool_result, non-exempt tools trigger permission timers), `bash_progress` (Bash output — restarts permission timer), `mcp_progress` (MCP tool — same timer restart). Also observed but not tracked: `file-history-snapshot`, `queue-operation`.\n\n**File watching**: 500 ms polling with partial-line buffering for mid-write reads. Tool-done messages delayed 300 ms to prevent React batching from hiding brief active states.\n\n### Dual-mode detection\n\n| Mode                     | Source                                                 | Detection                                                                                                                                                                                                                                                                         |\n| ------------------------ | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| **Hooks** (preferred)    | Claude Code Hooks API → HTTP POST → `HookEventHandler` | Instant, reliable. Installed events are `CLAUDE_HOOK_EVENTS`. `UserPromptSubmit`/`TaskCreated` are deliberately NOT installed — they normalize to null, so installing them only forwarded prompt text to be dropped; `normalizeHookEvent` still tolerates them for stale installs |\n| **Heuristic** (fallback) | Polling JSONL files                                    | Per-agent 500 ms JSONL polling for /clear detection; 1 s main scanner for terminal adoption; 3 s external scanner; 30 s stale check. Content-based /clear detection (`/clear</command-name>` in first 8 KB)                                                                       |\n\nThe `hookDelivered` flag (per agent) and `hooksEnabled` (global) gate timer logic. JSONL polling always runs in both modes for tool content (status text, animations); only permission (7 s) and text-idle (5 s) timers are suppressed by `hookDelivered`.\n\n### Sub-agent permission detection (heuristic)\n\nWhen a sub-agent runs a non-exempt tool, `startPermissionTimer` fires on the parent agent. If `PERMISSION_TIMER_DELAY_MS` (7 s) elapses with no data, permission bubbles appear on both parent and sub-agent characters via `agentToolPermission` + `subagentToolPermission` broadcasts.\n\n`activeSubagentToolNames: Map<parentToolId, Map<subToolId, toolName>>` tracks which sub-tools are active for the exempt check. Cleared when data resumes, Task completes, or `turn_duration` arrives.\n\n**Timing budget for tests**: the test waits for the bubble AFTER waiting for the \"Subtask:\" overlay. But the Subtask overlay appears when the parent's **Task tool_use** is parsed (scenario time T), while the timer only starts when the **sub-tool tool_use** in the progress record is parsed (~1 s later). Plus 7 s timer + 300 ms IPC/render slop = a wait timeout less than ~10 s is unsafe.\n\n### Context usage (server/src/contextUsage.ts)\n\nEvery agent's context gauge. Fed from `message.usage` on assistant records by `processTranscriptLine`, broadcast as `agentContextUsage` and rendered by `ToolOverlay`.\n\n- **Snapshot, not a total.** Context = the newest turn's `input_tokens + cache_creation_input_tokens + cache_read_input_tokens + output_tokens`. Summing turns measures spend, not occupancy, and misses the cached tokens that ARE the context (`input_tokens` is single digits once caching kicks in). Falls on compaction and `/clear` for free.\n- **All-zero usage means \"no news\"** — synthetic records (API errors, interrupts) report zeros and must not blank the gauge.\n- **Sidechain rule**: a lead's sidechain records are its sub-agents' turns and must not move its gauge; a teammate's own transcript is sidechain top to bottom and would otherwise never report. Positional latch (`sawMainChainUsage`): once a file produces a main-chain turn, later sidechain records belong to someone else.\n- **The window comes from the provider**, not from the runtime: `HookProvider.contextWindowForModel(model)` (Claude: 1M for the current line, 200k for Haiku and the older models, `undefined` for ids it can't place). Transcripts state usage but never the limit, and guessing 200k for a 1M model reads five times too full. `widenContextWindow` is the backstop for unrecognized models and unknown-larger windows — it only ever widens, since a context that doesn't fit disproves the assumption while a shrinking one proves nothing.\n- **`seedContextUsage` runs once per agent in `startFileWatching`** — the single seam every watched agent passes through. Agents adopted or restored mid-session start at end-of-file, so without a tail read they'd have no gauge until their next turn.\n- Sub-agents get no gauge: no session of their own, and the shadow store never forwards `agentContextUsage`.\n\n## Office UI\n\n**Rendering**: Game state in imperative `OfficeState` class (not React state). Pixel-perfect: zoom = integer device-pixels-per-sprite-pixel (1x–10x). No `ctx.scale(dpr)`. Default zoom = `Math.round(2 * devicePixelRatio)`. Z-sort all entities by Y. Pan via middle-mouse drag (`panRef`). **Camera follow**: `cameraFollowId` (separate from `selectedAgentId`) smoothly centers camera on the followed agent; set on agent click, cleared on deselection or manual pan.\n\n**UI styling**: Pixel art aesthetic — sharp corners (`borderRadius: 0`), solid backgrounds (`#1e1e2e`), `2px solid` borders, hard offset shadows (`2px 2px 0px #0a0a14`, no blur). CSS variables in `index.css` `:root` (`--pixel-bg`, `--pixel-border`, `--pixel-accent`, ...). Pixel font: FS Pixel Sans (`webview-ui/src/fonts/`), loaded via `@font-face`, applied globally.\n\nCustom ESLint rules (`eslint-rules/pixel-agents-rules.mjs`) enforce: `no-inline-colors` (hex/rgb/rgba/hsl/hsla literals only in `constants.ts`), `pixel-shadow` (must use `var(--pixel-shadow)` or `2px 2px 0px`), `pixel-font` (must reference FS Pixel Sans). All `error`-level — they block PRs.\n\n**Characters**: FSM states — active (pathfind to seat, typing/reading animation by tool type), idle (wander randomly with BFS, return to seat after `wanderLimit` moves). 4-directional sprites, left = flipped right. Tool animations: typing (Write/Edit/Bash/Task) vs reading (Read/Grep/Glob/WebFetch). Sitting offset: characters shift down 6 px in TYPE state. Z-sort uses `ch.y + TILE_SIZE/2 + 0.5` so characters render in front of same-row furniture but behind lower-row furniture. **Chair z-sorting**: non-back chairs use `zY = (row+1)*TILE_SIZE` (capped to first row); back-facing chairs use `zY = (row+1)*TILE_SIZE + 1` so the chair back renders in front of the character. Chair tiles are blocked for all characters except their own assigned seat (per-character pathfinding via `withOwnSeatUnblocked`).\n\n**Diverse palette assignment**: `pickDiversePalette()` counts palettes of current non-sub-agent characters; picks randomly from least-used palette(s). First 6 agents each get a unique skin; beyond 6, skins repeat with a random hue shift (45–315°) via `adjustSprite()`. Character stores `palette` (0-5) + `hueShift` (degrees). Sprite cache keyed by `\"palette:hueShift\"`.\n\n**Spawn/despawn effect**: Matrix-style digital rain animation (0.3 s). 16 vertical columns sweep top-to-bottom with staggered timing. Spawn: green rain reveals character pixels. Despawn: character pixels consumed by green rain trails. `matrixEffect` field on Character (`'spawn'`/`'despawn'`/`null`). Normal FSM is paused during effect. Restored agents (`existingAgents`) use `skipSpawnEffect: true` to appear instantly.\n\n**Sub-agents**: Negative IDs (from -1 down). Created on `agentToolStart` with \"Subtask:\" prefix, or lazily by `subagentToolStart` when missing (watched background spawns, post-reload recreation). Same palette + hueShift as parent. Click focuses parent terminal. Not persisted. Spawn at the closest free walkable tile to the parent (`closestFreeWalkableTile`) — around it, never in a seat. Idle (stop typing) when every tracked sub-tool row is done; overlay shows the latest non-done sub-tool status, falling back to the Subtask label.\n\n**Speech bubbles**: Permission (\"...\" amber dots) stays until clicked/cleared. Waiting (green checkmark) auto-fades 2 s. Sprites in `spriteData.ts`.\n\n**Sound notifications**: Ascending two-note chime (E5 → E6) via Web Audio API plays when waiting bubble appears (`agentStatus: 'waiting'`). `notificationSound.ts` manages AudioContext lifecycle; `unlockAudio()` on canvas mousedown resumes the context (webviews start suspended). Toggled via Settings modal. Persisted per-namespace in `~/.pixel-agents/config.json`.\n\n**Mobile shell** (standalone only): below `MOBILE_MEDIA_QUERY` the browser renders `mobile/MobileShell` instead of the desktop layout — office and terminal as two full-screen pages in a 200%-wide sliding track (both always laid out, never `display:none`, so the canvas and xterm keep dimensions); the track's transform makes it the containing block of `position: fixed`, so modals and other viewport overlays mount at App's top level, never inside the office region), the agent cards in a bottom scroller, and `MobileKeyBar` above the software keyboard. \"Keyboard open\" and \"clamp the shell\" are separate answers (`mobile/keyboardViewport.ts`, sampled by `useKeyboardViewport`) because the two resize policies differ: iOS shrinks only the visual viewport (open, and the shell is clamped to it so the PTY resizes instead of hiding its input line), Android `interactive-widget=resizes-content` shrinks both (open, nothing to clamp). Open = an editable focused AND the visual viewport `KEYBOARD_MIN_HEIGHT_PX` below the tallest height seen at the current width (per-orientation baseline). `useIsMobile` is gated on `isBrowserRuntime`: a narrow VS Code panel must never get it (it broke every VS Code e2e shard once). Navigation rules live in `useMobileShell`: the >_ / Office toggle and the edge swipe share one handler; a card tap in the office is two-step (focus the character, then open its terminal), in the terminal it switches panes; + slides over only for launches made from it (a reload re-announcing sessions stays on the office). The terminal-pane model (active vs shown pane, socket status, `statusFor`) is `useTerminalDrawer`, shared with the desktop drawer. Touch rules: every gesture tracks ONE finger by identifier (`findTouch`) and uses one tap slop (`withinTapSlop`); gesture decisions are pure reducers with thin DOM adapters (`edgeSwipe.ts`/`useEdgeSwipe.ts`, `terminal/touchGesture.ts`/`touchInput.ts`), because WebKit keeps addressing a gesture's events to its touchstart node — when terminal rows repaint, that node detaches and events stop bubbling, so each adapter binds \"rescue\" listeners on the touch target. **Terminal copy is verbatim by default** (Cmd/Ctrl+C, the browser's Copy, the pill's Copy): `flowTerminalCopy` is a heuristic that joins a long row with a two-space-indented next line, which is also what YAML under a long key or a method body under a long signature look like, so re-flowing is opt-in only — Alt+C (Option+C) on desktop, \"Copy as text\" on the pill. Every write goes through `terminal/clipboard.ts`. PWA: `manifest.webmanifest` (icon paths relative — the file is served verbatim), conventional-path icons in `public/`, `?v=N` cache-busters on icon links.\n\n**Seats**: Derived from chair furniture. `layoutToSeats()` creates a seat at every footprint tile of every chair. Multi-tile chairs produce multiple seats keyed `uid` / `uid:1` / `uid:2`. Facing direction priority: 1) chair `orientation` from catalog (front→DOWN, back→UP, left→LEFT, right→RIGHT), 2) adjacent desk direction, 3) forward (DOWN). Click character → select (white outline) → click available seat → reassign.\n\n## Layout Editor\n\nToggle via \"Layout\" button. Tools: SELECT (default), Floor paint, Wall paint, Erase (set tiles to VOID), Furniture place, Furniture Copy (`FURNITURE_PICK`: type + colour off a placed item), colour Copy (`COLOR_PICK`: colour only), Eyedrop\n</yakaai_skills>"}