TypeScript repository by @pablodelucca. AI agent rules & system prompts with 9.9k stars.
# Pixel Agents β Compressed Reference
Pixel 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.
`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.
## Architecture
Strict 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.
```
core/ Protocol + interface definitions (zero runtime side effects)
asyncapi.yaml AsyncAPI 3.0 contract β single source of truth
src/
messages.ts AUTO-GENERATED discriminated unions (do not edit)
schemas.ts AgentMeta, SpriteData, FurnitureCatalogEntry
provider.ts HookProvider, AgentEvent (the integration boundary)
teamProvider.ts Optional TeamProvider (semantic queries for Lead + Teammates)
transport.ts MessageTransport interface, TransportState
adapter.ts StateAdapter, AssetCache, PersistedAgent, AgentSeat
terminalAdapter.ts TerminalAdapter (editor-driven terminal management)
normalizeProjectPath.ts
constants.ts
server/ Lifecycle runtime + Fastify HTTP/WS server
src/
providers/hook/claude/ Reference HookProvider β only place that knows Claude specifics
claude.ts normalizeHookEvent for 11 Claude events, formatToolStatus, file fallback
claudeTeamProvider.ts TeamProvider: reads ~/.claude/teams/<name>/config.json
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)
consentCopy.ts Claude's first-run consent disclosure text (scope/data/undo), served through consentDisclosure()
constants.ts Claude hook event names, script path
hooks/claude-hook.ts Hook script (CJS+shebang, bundled to dist/hooks/claude-hook.js)
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)
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
providers/index.ts Provider registry (claudeProvider + the hookProviders list the consent gate loops over)
agentRuntime.ts Lifecycle core: timers, scanners, HookEventHandler, SessionRouter, DismissalTracker
agentStateStore.ts EventEmitter-backed single source of truth (typed mutations + events)
sessionRouter.ts session_id β agent_id mapping, event buffering, pending external sessions
dismissalTracker.ts Unified dismissal state (replaces four legacy globals)
hookEventHandler.ts Dispatches normalized AgentEvent into runtime
httpServer.ts Fastify: POST /api/hooks/:providerId, GET /api/health, GET /ws, SPA (standalone)
clientMessageHandler.ts Single dispatch point for ClientMessage from webview
server.ts Top-level composition
cli.ts npx pixel-agents entry (npm bin)
fileStateAdapter.ts Namespaced ~/.pixel-agents/ persistence
configPersistence.ts { vscode, standalone, externalAssetDirectories, hooksConsent: {providerId: granted|declined}, hooksEnabled: {providerId: boolean} }
layoutPersistence.ts ~/.pixel-agents/layout.json with atomic tmp+rename
fileWatcher.ts Hybrid fs.watch + 500ms polling, JSONL line buffering, /clear detection
transcriptParser.ts JSONL parsing for heuristic / file-fallback mode
timerManager.ts Waiting / permission timers
assetLoader.ts PNG β SpriteData via pngjs
teamUtils.ts isInlineTeammateOf, getInlineTeammates, hasInlineTeammates
types.ts ServerAgentState
constants.ts All timing/scanning constants
__tests__/ 28 Vitest files
manual-hook-events.http Manual hook testing helper (REST-Client format)
adapters/vscode/ VS Code surface β composes core + server
extension.ts activate() / deactivate()
PixelAgentsViewProvider.ts WebviewViewProvider, thin bridge to AgentRuntime
agentManager.ts Terminal lifecycle (claude --session-id <uuid>), restore, persist
vscodeTerminalAdapter.ts TerminalAdapter implementation
uninstall.ts vscode:uninstall hook β removes hook entries + factory-resets hooks config after extension removal
migrateVsCodeState.ts One-time legacy state migration (verify-before-clear)
constants.ts VS Code IDs, command names, key names
webview-ui/ React 19 + Canvas UI (depends only on core/)
src/
transport/
index.ts createTransport() β single runtime branching point
postMessageTransport.ts VS Code mode (acquireVsCodeApi)
webSocketTransport.ts Standalone mode (exponential backoff, send queue)
types.ts Re-exports MessageTransport from core
runtime.ts isBrowserRuntime detection
browserMock.ts Standalone-browser asset fetch + message injection
testHooks.ts window globals exposed for e2e (officeState, helpers)
main.tsx React entry (StrictMode + createRoot)
App.tsx Composition root (hooks + components + EditActionBar)
constants.ts Webview magic numbers/strings
notificationSound.ts Web Audio API chime
changelogData.ts Changelog modal content
components/ React UI (toolbars, modals, settings)
BottomToolbar.tsx, ZoomControls.tsx, SettingsModal.tsx, InfoModal.tsx,
Tooltip.tsx, DebugView.tsx, ui/Button.tsx, ...
AgentCard.tsx, AgentCardBar.tsx Agent cards (mug shot + status dot); the right-edge bar on desktop/VS Code
cardBar.ts Pure card-bar rules: cardVariant, mergeOrder, reorderByPointer (Node-tested)
TerminalDrawer.tsx Desktop standalone: right-docked panel, cards as tabs
TerminalPaneStack.tsx Every agent's TerminalPane mounted, the shown one visible (both shells)
TerminalPane.tsx One xterm bound to one agent's PTY
MobileAgentBar.tsx Mobile bottom card scroller (+ launch card, long-press reorder)
MobileTerminalPage.tsx Mobile terminal page (a TerminalPaneStack)
MobileKeyBar.tsx Accessory keys above the software keyboard (esc, shift+tab, trackpad, paste)
mobile/ The phone shell (standalone only, see "Mobile shell")
MobileShell.tsx Sliding office/terminal track + card bar + key bar
useMobileShell.ts Page state and navigation rules (toggle, card tap, launch-then-slide)
edgeSwipe.ts Edge-swipe state machine (pure reducer, Node-runner tested)
keyboardViewport.ts Software-keyboard decision: open vs per-width baseline, clamp only when the visual viewport is shorter (pure)
useEdgeSwipe.ts DOM adapter for the reducer (capture listeners, detached-target rescue)
touch/
touchPrimitives.ts findTouch (track one finger by id) + withinTapSlop (the one tap metric)
terminal/ Standalone terminal (xterm) support
terminalClient.ts /terminal/:agentId socket (reconnect, replay)
terminalLinks.ts Safe link opener + urlAtCell (one URL pattern for clicks and taps)
touchGesture.ts Terminal touch state machine: scroll, flick, tap, long-press select, handles (pure reducer)
touchInput.ts DOM adapter: listeners, flick rAF, long-press timer, detached-target rescue
selectionMath.ts Word expansion, range ordering, handle clamping (pure)
selectionOverlay.ts Copy pill (Copy | Copy as text) + selection handles (DOM; styled in index.css)
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)
flowCopy.ts terminalCopyText: verbatim by default; opt-in re-flow of Claude Code's soft wraps (pure)
clipboardKeys.ts Clipboard chords (Ctrl+C/Ctrl+Shift+C copy, Alt+C copy as text, Ctrl+V paste) + OSC 52 decode (pure)
terminalDom.ts xterm screen geometry + isTypingInTerminal
hooks/
useExtensionMessages.ts Message handler β translates ServerMessage into OfficeState mutations
useEditorActions.ts Editor state + callbacks
useFurnitureColorActions.ts Furniture colour sliders + colour-only eyedropper
useEditorKeyboard.ts Keyboard shortcuts (R, T, Esc, Ctrl+Z/Y)
introTourState.ts Intro tour wire-state machine (pure reducer, Node-runner tested)
useIntroTour.ts Wires the reducer to React + transport (snapshot, verdict, choices)
useTerminalDrawer.ts Terminal-pane model for both shells: active/shown pane, socket status, card lookups
useIsMobile.ts Mobile breakpoint β always false in the VS Code webview
useKeyboardViewport.ts Samples the viewport: keyboard open (key bar) + iOS shell clamp height
office/
types.ts OfficeLayout, Character, etc. + re-exports constants
toolUtils.ts STATUS_TO_TOOL mapping, extractToolName (DOM-free; defaultZoom lives in useEditorActions)
projection.ts Worldβscreen math shared by renderer + DOM overlays (mapOffset, overlayProjection)
colorize.ts Colorize (grayscaleβHSL) + Adjust (HSL shift)
floorTiles.ts Floor sprite storage + colorized cache
wallTiles.ts Wall auto-tile: 16 bitmask sprites
sprites/
spriteData.ts Pixel data (characters, furniture, tiles, bubbles)
spriteCache.ts SpriteData β offscreen canvas, per-zoom WeakMap
editor/
editorActions.ts Pure layout ops
editorState.ts Imperative state (tools, ghost, selection, undo/redo + undo session, drag, placing)
editorRenderState.ts buildEditorRenderState: ghosts + selection for the renderer (pure)
EditorToolbar.tsx
layout/
furnitureCatalog.ts Dynamic catalog from loaded assets
layoutSerializer.ts OfficeLayout β runtime (tileMap, furniture, seats)
tileMap.ts Walkability, BFS pathfinding
engine/
characters.ts Character FSM (idle/walk/type) + wander AI
officeState.ts Game world (layout, characters, seats, selection, subagents, consent greeter)
gameLoop.ts rAF loop with delta-time cap (0.1 s)
renderer.ts Canvas: tiles, z-sorted entities, overlays, edit UI
matrixEffect.ts Spawn/despawn digital rain (drawing only)
matrixEffectState.ts Effect state: startMatrixEffect/advanceMatrixEffect (DOM-free)
components/
OfficeCanvas.tsx Canvas, resize, DPR, mouse hit-testing, drag-to-move
useCanvasTouchGestures.ts Touch pan / pinch zoom / tap on the canvas
ToolOverlay.tsx Activity label + speech bubbles (DOM, above the labels) per character
e2e/ Playwright suite (real VS Code + mock-claude scenarios)
playwright.config.ts
global-setup.ts
fixtures/
pixel-agents.ts VS Code fixture: launch Electron, wait for panel
standalone.ts Standalone CLI fixture: spawn server + browser page
mock-claude, mock-claude.cmd Bash + cmd wrapper invoked instead of real claude
mock-claude-runner.cjs Scenario runner: appendJsonl, emitHook, holdOpen
helpers/
launch.ts Electron app + isolated HOME/workspace
mock-claude.ts claudeScenario() builder
office.ts Overlay locators + assertions
webview.ts Settings/modal helpers
hooks.ts Hook server lifecycle helpers
standalone.ts Standalone server + WebSocket browser helpers
internal-agent.ts spawnInternalAgentAndWait
lifecycle.ts Reusable scenario fragments
team.ts Team config seeding + teammate helpers
allure-labels.ts @area:<tag> β Allure epic
tests/
claude/hooks-on/ basic.spec.ts, lifecycle.spec.ts, teams.spec.ts
claude/hooks-off/ lifecycle.spec.ts, matrix.spec.ts
standalone/ hooks.spec.ts
README.md Auto-generated test inventory (regen via npm run e2e:inventory)
scripts/
generate-messages.ts AsyncAPI β core/src/messages.ts via Modelina (with CI drift check)
run-e2e.mjs Playwright wrapper (run-id namespacing, video attach flags)
generate-e2e-inventory.mjs Splices test list into e2e/README.md (CI drift check)
build-allure-report.mjs Combine e2e+server+webview Allure results
assemble-vercel-output.mjs Stage /reports/allure/ for Vercel deploy
asset-manager.html Unified furniture editor (positions + metadata)
jsonl-viewer.html Standalone JSONL transcript inspector
wall-tile-editor.html Wall sprite editor
core/ npm workspace (no separate package; root manages)
server/ npm workspace
webview-ui/ npm workspace
```
## Distribution
Two artifacts from one source tree:
- **VS Code extension** (`.vsix`) β `pablodelucca.pixel-agents` on VS Code Marketplace and Open VSX. Bundles VS Code adapter + webview SPA + assets + hook scripts.
- **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).
`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`.
## Communication Flow
Hub-and-spoke. The server is the single aggregation point for all agent activity, regardless of source.
```
Hook scripts βPOST /api/hooks/:providerIdββ
βββ HookProvider.normalizeHookEvent()
JSONL transcripts βFileWatcherββ TranscriptParser β€
β
AgentEvent (canonical)
β
AgentRuntime (dispatch on .kind)
β
AgentStateStore (mutate)
β
StoreEvents β broadcast
β
PostMessageTransport βββ€βββ WebSocketTransport
(VS Code) (standalone browser)
```
The 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.**
Adding 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.
## AsyncAPI Protocol Contract
`core/asyncapi.yaml` is the contract. Pinned to **3.0.0** because `@asyncapi/[email protected]` declares `supportedVersions: ['3.0.0']` only; bumping to 3.1.0 produces `export type Root = any`. Revisit when Modelina ships 3.1.0 support.
- **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.
- **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.
Both unions use `oneOf` with `discriminator: type`. Every concrete message sets `additionalProperties: false`.
`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.**
## Transport Abstraction
```typescript
export interface MessageTransport {
send(msg: ClientMessage): void;
onMessage(handler: (msg: ServerMessage) => void): () => void;
readonly ready: Promise<void>;
readonly state: TransportState;
onStateChange(handler: (state: TransportState) => void): () => void;
dispose(): void;
}
export type TransportState = 'connecting' | 'connected' | 'reconnecting' | 'disconnected';
```
`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.
`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.
## Provider Abstraction
`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:
- **Required**: `normalizeHookEvent(raw)` β `{ sessionId, event: AgentEvent } | null`; `installHooks` / `uninstallHooks` / `areHooksInstalled`; `formatToolStatus`; `permissionExemptTools`, `subagentToolNames`, `readingTools` sets.
- **Optional file fallback**: `getSessionDirs(workspace)`, `getAllSessionRoots()`, `sessionFilePattern`, `parseTranscriptLine(line)`, `buildLaunchCommand(sessionId, cwd, opts)`. Used when hooks aren't installed.
- **Optional team extension**: `team?: TeamProvider` for Lead + Teammates support.
`AgentEvent.kind` values: `toolStart`, `toolEnd`, `turnEnd`, `subagentStart`, `subagentEnd`, `subagentTurnEnd`, `progress`, `permissionRequest`, `sessionStart`, `sessionEnd`. The runtime dispatches on `kind`, never on CLI-specific tool names.
### TeamProvider (Lead + Teammates)
Optional extension for CLIs that support team workflows (Claude Agent Teams today). Semantic queries (`discoverTeammates`, `getTeamMembers`, `getTeamMetadataForSession`, `extractTeammateNameFromEvent`, `isTeammateSpawnCall`) β providers choose their own storage strategy.
Four teammate modes:
| Mode | Trigger | Detection |
| ------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Basic subagent (teams OFF) | `Task(...)` or `Agent(run_in_background: false)` | `subagentStart` with team gate not matched, or JSONL `agent_progress` |
| Inline teammate (teams ON) | `Agent(run_in_background: true)` in-process | `onTeammateDetected` β `discoverTeammates(projectDir, leadSessionId)` |
| Session teammate (teams + tmux) | `Agent(run_in_background: true)` in tmux pane | Own session, own hooks, routes through normal flow |
| 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) |
Newer 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.
**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`.
**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):
- **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).
- **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.
The 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).
Teammate 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.
**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.
## Server Runtime
`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.
```typescript
export class AgentRuntime {
constructor(opts: {
store: AgentStateStore;
providerRegistry: ProviderRegistry;
layoutPersistence: LayoutPersistence;
assetCache: AssetCache;
config: Pick<AdapterSettings, 'hooksEnabled' | 'watchAllSessions'>;
terminalAdapter?: TerminalAdapter;
callbacks: RuntimeLifecycleCallbacks;
});
registerAgent / unregisterAgent / removeAgent / removeTeammate / removeTeammates
restoreExternalAgents / handleHookEvent
startProjectScan / startExternalScanning / startStaleCheck
dispose();
}
```
Owns 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.
### AgentStateStore
EventEmitter-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.**
### SessionRouter and DismissalTracker
Two 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.
### HTTP + WebSocket Server
Fastify v5 with `@fastify/cors`, `@fastify/websocket`, and (in standalone) `@fastify/static`:
| Method | Path | Purpose |
| ------ | ------------------------ | --------------------------------------- |
| POST | `/api/hooks/:providerId` | Bearer-authenticated hook event ingress |
| GET | `/api/health` | Liveness: `{ ok, version, port, pid }` |
| GET | `/ws` | Bidirectional protocol channel |
| GET | `/*` (standalone only) | Webview SPA via `@fastify/static` |
`/ws` is gated in **two tiers**, because messages over this socket launch shells, grant durable machine-wide hook consent, and rewrite persisted state.
1. **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).
2. **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.
The **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.
Server 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.
### ClientMessageHandler
Single 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.
### ServerAgentState (server/src/types.ts)
Per-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.
## Persistence
```
~/.pixel-agents/
config.json { vscode, standalone, externalAssetDirectories, hooksConsent, hooksEnabled (both per-provider) }
vscode-state.json { agents, seats }
standalone-state.json { agents, seats }
layout.json OfficeLayout (shared across surfaces)
server.json { port, pid, authToken }
hooks/claude-hook.js Bundled hook script (CJS, shebang)
```
`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.
`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.
Layout 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.
## Agent Status Tracking
JSONL transcripts at `~/.claude/projects/<project-hash>/<session-id>.jsonl`. Project hash = workspace path with `:`/`\`/`/` β `-`.
**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`.
**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.
### Dual-mode detection
| Mode | Source | Detection |
| ------------------------ | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **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 |
| **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) |
The `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`.
### Sub-agent permission detection (heuristic)
When 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.
`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.
**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.
### Context usage (server/src/contextUsage.ts)
Every agent's context gauge. Fed from `message.usage` on assistant records by `processTranscriptLine`, broadcast as `agentContextUsage` and rendered by `ToolOverlay`.
- **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.
- **All-zero usage means "no news"** β synthetic records (API errors, interrupts) report zeros and must not blank the gauge.
- **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.
- **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.
- **`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.
- Sub-agents get no gauge: no session of their own, and the shadow store never forwards `agentContextUsage`.
## Office UI
**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.
**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.
Custom 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.
**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`).
**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"`.
**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.
**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.
**Speech bubbles**: Permission ("..." amber dots) stays until clicked/cleared. Waiting (green checkmark) auto-fades 2 s. Sprites in `spriteData.ts`.
**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`.
**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.
**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.
## Layout Editor
Toggle 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