CLAUDE.md
Rama main · réplica de lectura del repositorio original.
# autonomOS — Agent Development Guide
## Start Here
1. Read [`README.md`](README.md) — project overview, monorepo structure
2. Read [`docs/FEATURES.md`](docs/FEATURES.md) — feature specifications and design intent
3. Browse [`docs/decisions/`](docs/decisions/README.md) — all architectural decisions (one file per ADR, with an index) with context and rationale
4. Read [`docs/ROADMAP.md`](docs/ROADMAP.md) — current priorities and what to work on
5. Read [`docs/RESEARCH.md`](docs/RESEARCH.md) — research findings, competitor analysis, learnings
## Project Vision
autonomOS is a **mission control platform for autonomous agents** — a terminal dashboard that spawns Claude Code sessions, tracks their status via hook telemetry, and enables multi-agent coordination through a messaging gateway.
**Current state:** Terminal dashboard + session management + hook relay + multi-agent gateway + MCP tools. The orchestrator/projects/workspaces model described below is the long-term vision, not yet implemented.
**Core concepts (future):**
- **Orchestrator** — PM agent, the main interface. Understands project goals, delegates to workspace agents, tracks progress.
- **Projects** — Logical goals with roadmaps. Can span multiple workspaces. Multiple projects per workspace.
- **Workspaces** — Physical repositories, auto-discovered. Each has active agent sessions.
Two paths that share a common core:
- **Dev Path** — control plane for agent tools (Claude Code, etc.)
- **Robot Path** — persistent agent platform for robotics (aspirational, future)
## Monorepo Structure
```
autonomOS/
├── packages/
│ ├── dashboard/ # Web UI — observability & control
│ │ └── src/layout/ # dockview tabs + split panes (ADR-047, the only layout engine)
│ ├── server/ # Hono + node-pty — API, WebSocket, PTY management
│ │ ├── src/gateway/ # URI-based message router + platform adapters
│ │ ├── src/channel-server/ # Standalone MCP subprocess (server:autonomos)
│ │ └── src/mcp/ # Shared MCP tool definitions (used by both servers)
│ └── core/ # Shared agent abstractions & types
├── docs/
│ ├── decisions/ # Architectural Decision Records, one file per ADR (append-only)
│ ├── FEATURES.md # Feature specifications (F-001 through F-016)
│ ├── ROADMAP.md # Current priorities
│ ├── RESEARCH.md # Research findings & competitor analysis
│ ├── VISION.md # Project vision
│ └── research/ # Deep-dive research topics
├── CLAUDE.md # This file — agent development guide
└── README.md # Project overview
```
## Key Systems
### Hook Relay (`--settings` inline curl)
Every spawned session gets `--settings` with inline hook entries for all 13 Claude Code events. Each hook runs `curl -d @- $AUTONOMOS_SERVER/api/hooks/$SESSION_ID`. The server processes events for agent status tracking (`deriveStatus()` state machine) and notification generation (SendUserMessage, Stop, Notification, PermissionRequest).
**Events:** SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PostToolUseFailure, Stop, Notification, PermissionRequest, SubagentStart, SubagentStop, PreCompact, PostCompact, SessionEnd
### Session Spawning Flags
Sessions are spawned with: `--session-id` (pre-generated UUID), `--brief` (enables SendUserMessage), `--append-system-prompt` (autonomOS context + MCP tool descriptions), `--settings` (hook relay), the agent's permission-mode flags (see below), and optionally `--dangerously-load-development-channels` / `--channels`, and `--mcp-config` (channel server subprocess).
### Permission Modes (`core/src/types/permissions.ts`, ADR-045 + ADR-061)
**Per-runtime canonical permissions (ADR-115) — the SOURCE of truth now.** An agent's `permission` is `{runtime, values}` in its CLI's OWN vocabulary (`core/src/types/runtimePermissions.ts`: `RUNTIME_PERMISSIONS` table + `completePermission`/`parseRuntimePermission`/`formatPermission`/`permissionFromLegacyMode`); providers build argv from it via `effectivePermission()` (`providers/shared.ts`). `permissionMode` below survives only as the LEGACY projection (`legacyModeFor`) for readers not yet migrated, and as accepted-but-DEPRECATED API input mapped to exactly what it always ran. Records migrate at load (`store.ts`; Codex auto/plan get a one-time notice). Spawn order: explicit > record (resume) > template `permissions` > operator's `runtimeDefaults` (server settings) > built-in default. Two measured exceptions (ADR-119): Claude `manual` is spawned WITHOUT a flag on a fresh spawn (the flag leaked processes past teardown in an A/B), and Codex `collaboration_mode` is per-turn so non-default values are REFUSED (`perTurn`). **But flagless is not manual** (ADR-152): Claude Code 2.1.284+ starts a flagless session in AUTO, and an interactive `--resume` restores the session's last mode. So `manual` is pinned: `permissions.defaultMode: "default"` in the inline `--settings` on every spawn, plus `--permission-mode manual` on a resume/fork (the only thing that beats the restore; an A/B found no teardown cost there). `server/runtimeProbe.ts` checks the table against the INSTALLED CLI without starting a session. Never add a user-facing label that isn't the CLI's own value. **What's set vs what runs:** the record says what an agent was SET to; Claude Code reports what it RUNS in every hook's `permission_mode` (not SessionStart; manual arrives as `default`). Hook ingest keeps it as `AgentActivityState.livePermission` (`claudeLiveMode`), pushed like any status change, and the Org Chart inspector shows "set: X · running: Y" when they differ (no sidebar chip). It's known only after the first prompt/tool event of each process.
`PermissionMode` is `ask | auto | plan | bypass` — **our** vocabulary, mapped per provider. `PERMISSION_MODES` in core is the canonical list; the type derives from it, and both Zod schemas are built from it so a new value reaches them automatically.
What that does and does NOT enforce, precisely: adding a value gives a compile error only where a mode is exhaustively keyed (`PERMISSION_MODE_INFO`'s `Record<PermissionMode, …>`). It does **not** error in the provider mappers — they end in `default:` catch-alls, so a fifth mode would silently map to ask-equivalent behavior. And `mcp/tools.ts` keeps a hand-copy (see below) checked by a test, not the compiler. Adding a mode means: core list, `PERMISSION_MODE_INFO`, all three provider mappers — and note **Codex has TWO** (`codexApprovalPolicy` for shell/exec approval **and** `codexMcpApprovalMode` for MCP-tool approval), both ending in `default:` catch-alls that fall through to the supervised (ask-equivalent) value — plus `mcp/tools.ts`.
**Codex resume (ADR-104):** a resumed Codex thread keeps the approval/sandbox policy it was CREATED with, and codex rejects permission overrides on `resume --remote` — so the resume argv carries none, and a mode change on resume is refused with a notice (the record keeps the mode actually running). Codex's `approval_policy` has only `on-request | never`; its auto review (`approvals_reviewer=auto_review`) and Plan collaboration mode exist but aren't wired up, so `auto` and `plan` both clamp to ask (`unsupportedBy: ["codex"]` + a spawn notice that says "isn't wired up yet" — never "Codex has no …").
Two mappings are non-obvious and deliberate: **`ask` emits NO Claude flag** (it IS Claude Code's built-in behavior, and passing `--permission-mode default` perturbs TUI startup enough to break the usage-queue auto-Enter), and **`bypass` emits `--dangerously-skip-permissions` without `--permission-mode`**. Gemini's own flag value for ask-before-acting is the word `default` — an our-name → their-name translation, not an identity.
**One agent, one mode, one source.** `spawnAgent` resolves the effective mode **once, after the agent is resolved** — `params.permissionMode ?? agent.permissionMode` — and the argv plus the reattach write-back both read it. Ordering is the correctness argument: on a reattach the fallback is the EXISTING record, so a resume that says nothing changes nothing; on a fresh spawn it is the just-built record (request → template → `ask`).
Callers (`routes/agents.ts`, `mcp.ts`) must forward `undefined` rather than pre-resolving to `DEFAULT_PERMISSION_MODE` — collapsing it at the call site erases "the caller said nothing", which silently demoted `bypass` agents on every body-less resume. A template's mode ranks BELOW the record on a resume (`templatePermissionMode`), matching `respawnAgent`, which reads `tmpl?.systemPrompt` but pointedly not `tmpl?.permissionMode`. Any mode change on a resume is logged. `restart-all` / `/attach` send no body and respawn from each agent's own record, so they **cannot** re-level an agent.
Persisted layers may still hold the pre-rename spelling `"default"`; every load boundary normalizes via `permissionModeFromStored`. The operator's per-runtime default (`runtimeDefaults`, Settings → Runtimes) is SERVER-side and applies to every spawn that names none — including agent-initiated ones (the old browser-only Permission Mode preference is gone; a stored one is ignored). **Guarded by detection, not access control (ADR-138):** a PUT that widens a default to a never-asks value needs `confirmNeverAsks: true` (400 `CONFIRM_NEVER_ASKS` otherwise); every change, incl. a direct `settings.json` edit, is reported in the bell and logged (`runtimeDefaultsWatch.ts`); no MCP tool writes defaults (a test pins it). Agents share the server's OS user (and V3 leaks its token), so none of this makes the defaults operator-only — keep the wording "never silent", never "protected".
`mcp/tools.ts` duplicates the value list on purpose — it must NOT import `@autonomos/core` (it is bundled into `channel-server/dist.mjs` and copied into the binary bundle dir, where that specifier won't resolve). `tools-permission-schema.test.ts` keeps the copy honest.
### Auto-Trust
`attachStartupWatcher()` monitors PTY output for Claude Code's interactive trust prompts and auto-dismisses them. Watches for "Yes, I trust this folder" and "WARNING: Loading development channels" needles after ANSI stripping. Each Enter is needle-verified (retry if the dialog re-renders or the PTY stays silent, capped attempts) because CC's TUI attaches its stdin handler 100-500ms after first paint — blind early writes get swallowed. Configurable via settings panel toggle (default: ON).
**Gemini** has its own folder trust: in an untrusted folder it shows "Do you trust the files in this folder?" and, until trusted, overrides any `--approval-mode` to `default` (a bypass agent silently runs as ask; "Don't trust" makes it permanent). With Auto-Trust ON the spawn sets `GEMINI_CLI_TRUST_WORKSPACE=true` (read by Gemini's `checkPathTrust()`): process-scoped, no dialog, mode honored, `~/.gemini/trustedFolders.json` NOT written (all measured on 0.46). Deliberately an env var, not the equivalent `--skip-trust` flag: Gemini parses argv strictly, so an older Gemini without the flag would refuse to start, while it simply ignores an unknown env var and falls back to its dialog. With it OFF the dialog shows, and a **startup notice** (`AgentProvider.startupNotices`, needle-verified against a real render, scanned whatever the setting) tells the operator why the agent is waiting. Trust in either CLI lets it load the folder's own config (hooks, MCP servers, commands) — the Settings help text says so.
### Prompt Delivery Receipt (`agents/promptDelivery.ts`)
A starting prompt travels only as a CLI arg (`claude ... -- <prompt>`), so a startup-dialog race can silently drop it. Sessions spawned WITH a prompt are tracked through the hook stream: spawn → [startup settles] → SessionStart → UserPromptSubmit confirms delivery. **The windows are SETTLE-GATED** (ADR-074): nothing arms until the auto-trust watcher reports the startup dialogs out of the way (its `cleanup()` fires `onSettled` on every terminal path; a 45s fallback self-settles if no watcher runs, so a wiring regression can't silently disable the detector). The original fixed 15s/20s spawn-anchored windows measured TUI boot latency, not delivery — under a multi-agent boot sweep the argv prompt routinely takes >40s to submit, so every worker spawn got a false "stuck, needs a manual nudge" pair AND the paste fallback double-delivered the brief (both copies queued behind the booting TUI and both submitted). Current shape: SessionStart window 30s from settle; UserPromptSubmit window 90s from the later of SessionStart/settle; then ONE bracketed-paste + Enter re-delivery; then a 90s confirm window. Any turn activity cancels — double-submission is worse than a manual nudge. Giving up is **retractable**: the tracker parks (`given_up`, 10min retention) and a late receipt retracts the failure `SystemWarning` (the factual "was re-delivered" note is never retracted) and logs the correction.
**Hook-relay providers only** (`hooks.eventCount > 0` — Claude Code, Gemini). Codex derives status from its app-server event stream and emits no hook events, so it can never produce a receipt; tracking it fired a false "may have failed to boot" warning on every prompted Codex agent. Consequence: **Codex spawn-with-prompt has no delivery detector** — a `--remote` TUI that fails to attach loses the prompt, and the daemon reports the thread idle, so it looks identical to a finished agent. A daemon-side receipt via `statusLoop` is possible follow-up work.
### Restart & Projects across runtimes (ADR-118)
**Restart is ONE server call**, `POST /api/agents/:id/restart` (`restartAgent`). It stops the agent, WAITS for its process and daemon to exit (`killAttachment` only signals, so a kill → attach could respawn over a still-running Codex daemon), then respawns from the record in the same conversation. Refusals are typed (409 restarting, 404, 503 stopping); a failed respawn leaves the agent crashed plus a notice. **Orphaned daemons (ADR-132):** a Codex daemon outlives a server that dies without disposing it and keeps the agent's thread LOADED; a new daemon can't load a held thread (empty `thread/loaded/list` → inbound never lands, channel server never starts). So every daemon is recorded in `$configDir/sidecars/<agentId>.json` and a live recorded one is reaped (Codex two-stage TERM, then KILL) before any new daemon for that agent starts, plus once at boot before the resume sweep. The pid is only signaled if its command line still has `app-server --listen <RECORDED endpoint>`; never kill Codex daemons by name. The dashboard shows every outcome in the **action toast** (`showActionToast`, `ActionToast.tsx`). Don't write user-facing outcomes only to the store's `status` string: it is read as a busy flag and never rendered.
**Gemini keeps its chat:** fresh spawns pass `--session-id <agent's providerSessionId>` and respawns pass `--resume <id>`, behind `findGeminiSession`, a three-state pre-flight on the cwd's project under the CHILD's `GEMINI_CLI_HOME`. Each `--resume` writes a stub file reusing the session id, so the scanner collapses by id.
**Projects** lists Claude Code (SDK), Codex (`$CODEX_HOME/sessions` rollouts) and Gemini (`~/.gemini/tmp/*/chats`) via `sessionScanners.ts`: read-only, capped (400 files, 256KB each), mtime-cached, and the route never mutates a scanned row (a mutated cache entry broke matching after the first poll). Managed agents are matched by thread (Codex) or session id (Gemini), and a managed row carries the agent's providerSessionId (what `/attach` resolves) under the agent's own directory. **Projects are git repositories (ADR-121):** `projectResolver.ts` folds a worktree into its main repo (`git rev-parse --git-common-dir`, OFF the request path, `GIT_DIR`-style env stripped), persists learned roots in `$configDir/project-roots.json` (so a deleted worktree keeps its repo), and falls back to the `~/.claude-worktrees/<repo>-<branch>` convention for dirs never seen or husks. Temp dirs are classified BEFORE git (`kind: "temp"`); there is no "Unknown" group. Claude Code rows get `cwd`/`entrypoint` by reading their JSONL until found (first lines can be 239KB). Sessions carry `cwd`, `cwdExists`, `headless`; projects carry `kind` and `counts`. External Codex/Gemini rows can't be resumed yet (the next PR adopts them). Adoption is gated by the `adoptsExternalSession` provider capability, not by `hasResumableSession` (Gemini has that pre-flight but no adopt path). During a restart's wait, a kill WINS (recorded, no respawn) and a reattach 409s (`assertNotRestarting`).
### Terminal Streaming & Render Efficiency (ADR-072)
The terminal pipeline is **PTY → per-connection coalescer → WebSocket → keep-alive client terminal**. Three mechanisms, all measured by the harness in `packages/server/perf/` (read its README before touching any of this):
1. **Live-stream coalescing** (`routes/terminal.ts` `makeStreamForwarder`, #253/#260): Ink emits many tiny chunks per repaint; per-connection buffering with a 5ms **trailing-edge** window (every chunk waits ≤5ms for siblings, so a multi-chunk repaint always ships as ONE frame — unsynchronized TUIs like gemini-cli's Ink emit no DECSET 2026, and the old leading-edge flush shipped their erase-half alone, painting every repaint torn) collapses bursts ~570×. Cost: ≤5ms echo latency after idle, below one display frame. `AUTONOMOS_WS_COALESCE=0` restores per-chunk sends; `AUTONOMOS_WS_COALESCE_LEADING=1` restores leading-edge for ablation.
2. **Reconnect-replay coalescing** (`buildReplayFrames`, ADR-072): on WS connect the server replays the session's whole scrollback buffer (up to 1MB). That replay is an already-complete sequence — time plays no role — so it's a pure fold into ~64KB frames (~16 frames instead of ~19k). Separate flag (`AUTONOMOS_WS_REPLAY_COALESCE=0`) because "today's baseline" for ablation is live-coalescing ON + replay batching OFF.
3. **Keep-alive terminal cache** (`dashboard/src/terminal/liveTerminals.ts`, ADR-072): one live terminal per session — xterm instance, scrollback, WebSocket, reconnect loop — owned by a module cache, NOT by the React mount. Dockview still tears down panels on every switch (ADR-047); `useTerminal` just borrows the cached terminal and reparents its host `<div>`, so the remount is cheap and a switch-back re-streams **nothing** (measured: 37,075 frames / ~1s → 0 frames / ~44ms on loopback; 0 bytes cross the network, so throttling cannot re-introduce the re-stream — ~320ms settled on Slow-3G vs the 85s a cold re-stream takes there). LRU cap 8, attached panes never evicted. A pane whose viewport is parked in the scrollback (trackpad flick, Shift+PageUp — xterm-native scrolling; Codex captures no mouse) shows a "↓ Jump to latest" pill (visible on unfocused panes too); typing also re-follows via xterm's default `scrollOnUserInput`.
Invariants the cache makes load-bearing (each pinned by a dom test; breaking one resurrects a shipped bug): **reset-before-replay** — the server replays the FULL buffer on every connect, so a REconnect must `terminal.reset()` first or duplicate history accumulates permanently; **superseded-socket guards** — every WS handler no-ops unless its socket is still current, else a stale `onclose` re-arms a reconnect that kills the healthy socket in a ~1s churn loop; **deferred session-end** — 4010/4004 frees the cache slot immediately but keeps the final output on screen until the pane departs (and `connect()` refuses `ended` entries); **`detach(container)` is ownership-checked** so a stale mount's cleanup can't blank a newer mount. Do NOT move terminal teardown back into the React effect — that teardown was the original bug. **Never force a repaint with a fake resize from focus/visibility paths** (ADR-087 — `nudgeResize` survives ONLY on reconnect, its #16 birthplace, where follow-state is freshly pinned): ratatui (codex) answers every SIGWINCH with a full rebuild including `\e[3J`, which wipes xterm's scrollback and strands a parked viewport at the top; an ED3/DECSED3 parser hook re-pins a parked viewport to the tail when an app genuinely wipes.
**Connection health (ADR-113).** While you TYPE, per-keystroke acks are the signal: after the server advertises `input-ack` (on the OSC 7777 end-of-replay marker), the pane sends keystrokes as BINARY `[0x01][seq][sentAtMs][bytes]` frames and the server acks `[0x02][seq]` after `pty.write` — unacked at 1s → "Not reaching server… · N keystrokes waiting", 3s → give up + reconnect (keys counted *not sent*; the server drops frames older than 2.5s so they can never land late); acked-but-unechoed at 2s → subtle "Waiting for agent…", 5s → "Agent not responding" (measured providers only; suppressed at `needs_input`). Text frames stay terminal bytes; binary is the negotiated control plane — never send it to a server that didn't advertise. While IDLE, the status-bar dot reads the `/ws/agents` heartbeat (2s ping, stale 5s → "Reconnecting… last heard Ns ago", 20s → "Disconnected · retrying"); a stale socket is ABANDONED and replaced at once — never gate a reconnect on `close()`/`onclose` (on a half-open link it never fires). The server also pings every terminal socket at the WS protocol level (1s) and terminates one silent for 4s, and fences input on superseded sockets (`?client=&gen=`, gen PAGE-wide). **Never add a new message type on the terminal socket's text channel** — unknown JSON falls through to `pty.write()`, so an older server would type it into the agent; use negotiated binary frames, query params, protocol ping/pong, a downstream OSC, or a REST route. Replay: xterm re-answers the queries in the replayed scrollback; replies are dropped until the OSC 7777 marker is parsed (else `1;2c` lands in the agent's prompt).
**Replay restores STATE, not just bytes (ADR-135).** **Codex runs INLINE** (`-c tui.alternate_screen="never"` on every spawn path incl. `resume --remote`): Codex 0.159 defaulted to the alternate screen, where xterm.js has no scrollback and ALWAYS turns the wheel into ↑/↓ (it ignores `?1007`), and in Codex ↑ walks the prompt history, so the transcript couldn't be scrolled. Never re-enable Codex's alternate screen without a wheel answer. For alternate-screen TUIs that keep it (Claude no_flicker: `?1049h` + mouse tracking): the replay buffer is the last 1MB, front-trimmed, so the server tracks the sticky DEC private modes (`agents/terminalModes.ts`; `head` = state at the buffer's start, fed the chunks the trim drops) and prefixes the replay with `head.preamble()`; on `?replayGeom=1` it sends `OSC 7777;autonomos-replay-begin;cols=;rows=` FIRST so the pane parses at the PTY's size (the alternate screen never reflows), and re-fits after the end marker; after a trimmed alternate-screen replay it nudges a repaint with a REAL size change (same-size TIOCSWINSZ sends no SIGWINCH). In acked mode a stale heartbeat never cuts panes. Verify wheel behavior only where rAF runs (an occluded automation tab freezes it; xterm 6 wheel-scrolls on rAF): headless Playwright against a dev dashboard (`scripts/wheel-probe.mjs`). Guards: `terminal-modes.test.ts`, `terminal-replay-state.test.ts`, codex argv + `runtime-probe` fixture (`codex-alt-screen-probe.json`), and `e2e/terminal-replay.spec.ts` (A: inline Codex wheel scrolls the transcript, sends nothing; B: replay at PTY width; C: Claude wheel → mouse reports; `E2E_OLD_REPLAY=1` / `E2E_FORCE_ALT=1` must fail it).
**Perf-harness mode:** `AUTONOMOS_PERF=1` mounts `/api/perf` (synthetic FakePty sessions + deterministic ink-bursts) and drops auth on the PUBLIC listener — it refuses to engage unless the bind host is loopback, never touches the internal socket's auth, and is stripped from spawned agents' env. Run `./perf/run-l2.sh terminal-switch` (isolated ports + config dir; add `PERF_THROTTLE=1` for the Slow-3G proof) or `bun run perf:l1-replay` for the transport-only ablation.
### Agent Communication (URI-based)
Agents communicate through the gateway using one scheme: **`agent://name`**. There is no broadcast and there are no platform schemes — both were removed in ADR-064. `broadcast://all` returned success unconditionally (including to a fleet of zero, and when every recipient was unreachable) and let any agent inject a turn into every running Codex agent; `slack://` was backed by a `StubAdapter` whose `send()` was a `console.log` returning a fabricated message id, so it reported success for every message by construction. Agents spawned before the removal still carry `broadcast://all` in their baked-in system prompt, so the router answers it with a pointer to `agent://` rather than a bare "unknown scheme".
**A `null` return from `routeMessage` means the destination ACCEPTED the message**, not that a plausible recipient was found. That distinction is load-bearing — the old contract ack'd on resolution, so a sender was told "sent" for a message injected into a dead daemon, a threadless agent, or a socket mid-close. What "accepted" means per provider: **Claude Code** — the write landed on an OPEN, registered, token-verified channel-server socket (NOT a receipt that the agent read it; there is no application-level ack from the far side). **Codex** — the agent's app-server daemon replied to `turn/start`.
**Codex inbound (`gateway/codexControl.ts`)** takes a different path than Claude Code. CC consumes inbound through its channel-server MCP subprocess; Codex has no such channel, so the gateway opens a second JSON-RPC client to the agent's `app-server` daemon and injects a `turn/start`. Delivery is **immediate, including into a busy thread** — Codex owns mid-turn safety via its `followup_task` contract (deliver at a message boundary while sampling, or after the pending tool call completes). An earlier idle gate duplicated that guarantee and deadlocked agents blocked in `collaboration.wait_agent`, which reads as `active`; it was removed in ADR-060 (reversing ADR-057's untested assumption). What remains: a best-effort skip while the last observed status is compacting — untested conservatism, not a measured requirement. It does not guarantee we never inject during compaction: the value is a cache, so it is cleared with its socket and the hold is bounded, after which delivery proceeds and the operator is told the status looked stale. See ADR-060. The queue is a **retry buffer for transport failures only** (socket down, no thread yet, a refused turn — buffer and retry, never drop), and `thread/read` is for dashboard **status** only, never as a delivery gate. A **restart suspends** the controller (`suspendCodexControl`: queue kept, nothing dials the old daemon) and the respawn's `startCodexStatusWatch` re-points and drains it; only a restart that doesn't end running disposes it (drop + notice). Don't reintroduce `disposeCodexControl` at a restart's stop: `dispose()` is the one hard drop.
**Accepted messages are recorded for the Org Chart** (ADR-108): `gateway/messageLog.ts` `recordAcceptedMessage` runs at each accept point (never on a failure path) and broadcasts a `message.routed` delta carrying only a sanitized, 60-character one-line preview. Full text (capped at 400) stays in a per-agent in-memory ring buffer read via `GET /api/agents/:id/messages`, and is never pushed.
Delivery is **confirmed, not assumed** (ADR-064). `deliverToCodex` returns a promise that settles only on a terminal outcome — the `turn/start` reply (delivered) or teardown (dropped) — and the router awaits it under a bounded window (`DEFAULT_DELIVERY_ACK_MS`, 2s). A message still buffered for a transport retry settles at neither, deliberately: "still trying" is not terminal, so the window expires and the sender is told it has **not** landed and must not re-send (a duplicate makes a Codex agent execute the same instruction twice). It is still retried, and persistent failures still reach the operator via a notification — the sender's ack is now the first signal rather than the only-after-the-fact one. `turn/start` is acked by the daemon on **accept**, not on turn completion, so this does not couple the sender to the recipient's turn length: ADR-060's measurement injected into a thread held busy for 90s and the reply landed inside the 30s RPC deadline. The bound is what keeps that safe if a future Codex changes it. Any caller awaiting `deliverToCodex` **must** bound its own wait.
Two gotchas worth knowing. First, **observed on codex 0.144.6** (NOT on 0.154, where a prompt-less agent gets a thread within seconds): an agent spawned *without* a starting prompt showed no thread for minutes — so inbound to it queued indefinitely. On 0.154 the thread exists but codex doesn't SAVE it (rollout written lazily on the first turn), which is why the resume path has a thread pre-flight (ADR-104). Second, the queue is shifted on the `turn/start` **reply**, not on send, so a teardown racing a reply can log a false `DROPPING` — deliberate, because shifting earlier would reopen the #287 silent-drop class.
### CSRF / cross-site WebSocket guard (ADR-122)
The session cookie is `SameSite=Lax`, and a "site" ignores the port, so a page on ANY other port of the dashboard's host rides the operator's cookie. `sameOriginGuard` (`server/src/sameOriginGuard.ts`) is mounted on the public app for `/api/*` and `/ws/*`, registered before every route, and refuses every mutating request and every WS upgrade unless: the `Origin` is the configured `CORS_ORIGIN` (trusted whatever the label, ADR-125), or `Sec-Fetch-Site` is `same-origin`/`none` (else `Origin` names this Host); and the body, if any, is JSON (a Bearer caller is exempt from the JSON rule only). Non-browser clients send neither header, so they are unaffected. **Hono runs handlers in registration order: a public route registered ABOVE the guard skips it** (`same-origin-guard-integration.test.ts` probes each route family). Don't add per-route CSRF checks; the shared guard is the one boundary. **Never add a state-changing GET** (or HEAD/OPTIONS): the guard leaves reads alone (only WS upgrades are checked), so a GET that mutates is CSRF-able from any page. Real-browser proof: `cd packages/dashboard && bunx tsx scripts/csrf-e2e.ts`.
### MCP Tool Architecture
Tool definitions live in `packages/server/src/mcp/tools.ts` — shared between:
- **HTTP MCP server** (`mcp.ts`) — served on the internal Unix control socket (`$configDir/control.sock`), NOT the public port (ADR-055). Reachable only by same-user processes on the box; a remote client would need a tunnel or a local forwarder. Still requires the auth token.
- **Channel MCP server** (`channel-server/`) — for autonomOS-spawned CC sessions
Both servers expose: `create_agent`, `list_agents`, `kill_agent`, `set_manager`, `get_org_chart`, `list_templates`, `create_template`, `self_exit`, `create_schedule`, `list_schedules`, `get_schedule`, `update_schedule`, `delete_schedule`, `run_schedule`, `list_env_presets`, `create_env_preset`, `update_env_preset`, `delete_env_preset`. The channel server also has `send` (requires gateway WebSocket).
### Cron Scheduler (`scheduler.ts` + `schedules.ts`)
Native timer-based scheduling using Croner v10. Each enabled schedule gets its own `Cron` instance (no polling). Schedules stored as individual JSON files in `~/.autonomos/schedules/<name>.json` (config + state). Run history as append-only JSONL in `~/.autonomos/schedule-runs/<name>.jsonl` (auto-pruned at 2000 lines).
**One execution mode: `agent:<name>`** — sends the prompt to a running agent via the gateway (`routeMessage`). A schedule fires a message; the agent does the work under **its own** `permissionMode`. A schedule cannot grant autonomy, and the target must be alive when it fires (a dead agent = a failed run until it's back).
Note the completion semantics: a run is `success` as soon as the prompt is **delivered**, not when the agent finishes. That's why `onComplete` is deprecated rather than rehomed onto this target — it would announce a completion that hasn't happened. Its delivery code is deleted outright: left gated on the removed target it was still reachable for schedules already on disk, which is the population the deprecation exists to protect.
**The `isolated` mode was REMOVED** (spawned a headless `claude -p`). It was the one execution path in the product outside `PermissionMode`, and it was fail-open in three places at once: the executor's `autonomous !== false`, the MCP schema's `.default(true)`, and the REST route's `: true`. Omitting the field granted `--dangerously-skip-permissions`. Fail-closed wasn't an option — measured, a headless run with no permission flag can't do write work at all, and "no flag" isn't even well-defined there (it inherits the user's `~/.claude/settings.json`). So the path went instead of the default flipping.
**Deprecated to accept-and-ignore no-ops** (kept in the type so pre-removal schedule files and MCP clients still load, per the ADR-058 `capabilities` pattern): `autonomous`, `workingDirectory` (was **required**), `template` (was *already* dead — advertised, read by nothing), `onComplete`, and `RunRecord.output`. An existing `isolated` schedule still loads and is editable, warns once at startup naming itself, and fails any run with a message pointing at `agent:<name>`.
**Key behaviors:** Overlap policies (`skip` default, `allow`), global concurrency limits (`maxConcurrentRuns`, default 3, FIFO queue), startup catch-up for missed runs, one-time schedules (`once:ISO` format). Agents create schedules via MCP tools; the dashboard SchedulesPanel monitors and controls them (no create button in UI). REST API: `GET/POST /api/schedules`, `GET/PUT/DELETE /api/schedules/:name`, `POST /api/schedules/:name/run`, `GET /api/schedules/:name/runs`, `GET /api/scheduler/status`, `PUT /api/scheduler/settings`. See ADR-026 and the scheduler-removal ADR.
### Agent Templates (`~/.autonomos/templates/`)
Reusable blueprints for creating agents. Individual JSON files with: `role`, `description`, `systemPrompt`, `permissionMode`, `model`. Created via `create_template` MCP tool or by dropping a `.json` file in the templates directory. Used via `create_agent(template: "team-lead", ...)`.
Two fields are accepted-and-ignored for backward compatibility: `autonomousMode` (migrated to `permissionMode`, ADR-045 — and a `permissionMode: "default"` written before ADR-061 loads as `ask`) and `capabilities` (removed, ADR-058 — it filtered the MCP tool list without restricting the REST API every agent can already reach, while the injected system prompt advertised the full list either way). Restrict worker agents via `systemPrompt` prose instead.
### Env Presets — model overrides (`~/.autonomos/env-presets/`, ADR-067)
Named, reusable sets of env vars applied to an agent at spawn to override its model backend — the motivating case is running Kimi (Moonshot) through the **real Claude Code binary** by injecting `ANTHROPIC_BASE_URL`/`ANTHROPIC_MODEL` + a secret `ANTHROPIC_AUTH_TOKEN` (Moonshot's Anthropic-compatible endpoint). No new provider — the override is entirely env, so hooks/MCP/statusline/permission modes all keep working. One 0600 JSON per preset (`envPresets.ts`, mirrors `schedules.ts`).
**The credential boundary is asymmetric and load-bearing.** A preset splits its vars: `env` (non-secret, plaintext), `secretKeys` (declared secret NAMES, agent-set), `secrets` (VALUES). **Read is a hard wall** — `maskEnvPreset` redacts every secret value on every REST GET and MCP read; `getEnvPresetRaw` (unmasked) exists only for spawn injection and is wired to no response. **Write is a surface convention** — the MCP tools (`create_env_preset`/`update_env_preset`) and the channel-server dispatch omit/strip `secrets`, so an agent configures everything *except* the key; the REST route (dashboard) accepts secrets, so a human keys it in the Presets tab. Tool descriptions tell agents not to solicit tokens in chat. Honest caveat (see ADR-067, narrowed by review): the guarantee is *only* that the management API never returns a full key (last-4 mask) and no MCP/channel path writes one — NOT "the key is unreadable." A preset is a capability to USE a credential, so a spawned child has the real key in its env (echo + relay leaks it) and an agent can repoint `ANTHROPIC_BASE_URL` to exfiltrate it on the next spawn. Presets assume a **trusted agent fleet**, not a malicious one.
Injection is provider-agnostic in `runtime.ts` after `provider.buildEnv` (base env < global `customEnvVars` < per-agent preset). A preset may set ONLY allowlisted keys (`envPresets.ts`, ADR-143/ADR-144): `PRESET_ALLOWED_ENV_KEYS`, built from the providers' OWN Claude Code/Codex/Gemini setup guides (each pinned as a fixture in `env-preset-providers.test.ts`; add a provider there, not just to the list), plus the operator-only `envPresetExtraKeys` setting. `NEVER_PRESET_KEYS` (anything that runs code or loads config from elsewhere) beats both. Unknown keys are refused on create/edit (`PresetKeyError` → 400) and REFUSE the spawn (422 / dashboard warning naming the key + fix), never skipped: a missing routing key can silently run a different model. `RESERVED_ENV_KEYS` (`shared.ts`) is never allowed. Don't reintroduce a denylist: ADR-067's denylist claimed to stop code execution in a sibling agent and missed `BASH_ENV`, `SHELL`, `GIT_SSH_COMMAND` and more. A preset whose required secret is unset **refuses to spawn** with a message pointing at the Presets tab. The Agent record persists the preset NAME only; it's resolved once via the "explicit param wins, else the record's value on a body-less resume" rule (like `permissionMode`, ADR-061) and re-applied on resume. `create_agent(envPreset: "kimi-k2.7-code", ...)`. UI: a left "Presets" tab + a gold accent-highlighted pill right after the repo·branch text on each agent row's bottom line (no Kimi icon, no provider relabel — a Kimi-backed agent stays a "Claude Code" agent; the gold is the theme accent, deliberately NOT a provider brand color).
### Agent Hierarchy (Org Chart)
Each agent record (`$configDir/agents/<id>.json`) carries a `managerId`; the hierarchy is derived at query time by ONE algorithm in core (`buildTreeFromRecords` / `buildAgentTreeNodes`, `core/src/tree.ts`) used by `GET /api/agents/tree`, MCP `get_org_chart`, and the dashboard push bridge. "Live" and "manager" also have ONE definition there (`isLiveAgent` = `status === "running"`, `hierarchyOf`), shared by the tree and the statusline's `GET /api/agents/:id/self`, so the two can't disagree. Its default filters exited agents and promotes their reports to roots — right for the sidebar and MCP, wrong for the chart. Set at runtime via `set_manager` (MCP) or `POST /api/agents/:id/manager` (cycles → 409).
**The Org Chart tab** (`HierarchyPanel.tsx` + `components/orgchart/`, ADR-112) reads its own `orgTreePoll` (`?includeExited=true`, push-fed via the same derive) and prunes in the client (`pruneExited`): an exited agent is drawn as a dashed ghost only while its subtree still holds a running agent, so a crashed manager keeps its team; other exited agents hide behind "Show N exited". Layout is a tidy tree (`layout.ts`: teams side by side, reportless roots on an "Unassigned" shelf). Every color comes from `orgChartTokens(page)` + `statusLabelStyle` — never add a literal color to a card. A click SELECTS (never navigates away): the chain stays lit, and a docked inspector (`OrgInspector`) shows the agent with explicit actions. Double-click / Enter / the inspector's button opens the terminal; arrows walk the chart. The inspector STICKS: an empty-canvas click does not close it, another card switches it, and it closes on its ×, Esc (escape stack, only while the chart is engaged), or when the pane is hidden (`HierarchyPanel visible`, forwarded from dockview). Cards sharing a name carry a short `#id` hint. **Canvas** (ADR-120, `orgchart/useCanvasView.ts` + pure math in `orgchart/viewport.ts`): drag EMPTY canvas to pan (< 4 px = a click, which does nothing); a drag starting on a card/button/bubble never pans — card drags are reserved for PR 4 (reassign); scroll pans, pinch / ⌘ + scroll zooms at the pointer (30–200%); opens fitted with a 60% floor; Fit / 0 / + / − (chart-local keys). The view `{x,y,k}` lives OUTSIDE React state — the view is written to the DOM and only subscribers (zoom %, the map) re-render; never move it into state (a pan would re-render every card per frame). When the drawn cards change (agents come or go), one `recheck()` decides (ADR-159): a view the USER placed stays put while any card is still on screen; after Fit it fits again; otherwise (untouched, or no card left on screen) it re-opens on the current fleet. A no-op reveal (every card click) is not "user placed"; a change while the pane is hidden (0×0) is re-checked when it shows; visibility is per card via `anyRectVisible` (≥12px showing), never the chart's box. Don't restore ADR-120's "never move": it stranded a surviving team off-screen and the chart read as empty. The bottom-right `Minimap` shows only when the chart doesn't fit. Zoom is CSS `zoom` on the stage and pan a whole-pixel translate on a `data-org-pan` wrapper — NEVER `transform: scale()` (it stretches a bitmap: blurry cards). Bubbles counter-ZOOM (`zoom: var(--org-inv-k)`) inside a zero-size corner anchor. The viewport never scrolls natively (a scroll guard; focus pans to the card; `touch-none`). Right-click opens the shared `AgentContextMenu`, **portaled to `<body>`** because dockview's `.dv-render-overlay` (transform + contain) re-anchors `position: fixed` to the pane. Cards expose `data-org-card` / `data-org-status`, and the stage is `data-org-stage` (the hero script fits it by that hook). **Team rollups + collapse** (`orgchart/teams.ts`): each lead shows chips counting its whole subtree AS DRAWN (after pruning, so hidden exited records never inflate "N exited"), computed BEFORE folding so a collapsed lead still says who needs you. Folded teams persist per browser under the `autonomos.orgchart.collapsed` localStorage key; a folded root stays in the team row via `layoutOrg`'s `isTeam`. **Message flow** (`orgchart/MessageLayer.tsx`, ADR-108): `message.routed` frames arrive on a transient `agentsSocket.onMessageRouted` channel (not the snapshot), become envelopes plus speech bubbles (text only, coalesced per recipient, at most 3 in flight, a timer backstop because rAF pauses in hidden tabs), and the inspector's Communication section reads the on-demand log. The mode (Animated / Quiet / Off) is remembered per browser under `autonomos.orgchart.messages`. **Inspector analytics** (`orgchart/Inspector.tsx` + `agents/analytics.ts`, ADR-110): `GET /api/agents/:id/analytics` returns counts since the server started. They come from existing taps: `emitStatusDelta` (time in state, turns, waits, 24h strip), hook ingest (tools and failures), and the spawn site/`markExited`/PTY exit (restarts, crashes, exit code). Starts are counted in `spawnAgent`, not `markRunning`. An exit closes the state as `stopped`, so a killed agent is never "waiting now". `support` flags per runtime make the panel show "n/a for Codex" rather than zeros. Usage (context/cost/model) comes only through TerminalRender's shared statusline mechanism, never a second path.
### Base Context Injection
Every autonomOS-spawned session gets `--append-system-prompt` with a `BASE_CONTEXT` constant covering:
1. **Identity** — agent is named, part of an organization, has manager/peers/reports
2. **Communication** — available MCP tools, async messaging, `from_uri` response pattern
3. **Environment** — dashboard observability, shared codebase, no direct terminal access between agents
4. **Lifecycle** — some agents are long-lived, others exit after a task. Sessions persist across restarts until ended by human, self, or manager
The tool list section interpolates `MCP_INSTRUCTIONS` from `mcp/tools.ts` (single source of truth). Use `--append-system-prompt` (preserves CC defaults + CLAUDE.md). Use `--system-prompt` only for full override.
### Keyboard Shortcuts (dashboard)
All app-level chords live in the registry at `packages/dashboard/src/shortcuts/registry.ts` (ADR-063) — one table consumed by the window capture-phase dispatcher (`useShortcuts`) AND xterm's key handler (`isReservedChord` consult). Never add an ad-hoc keydown listener for a global shortcut; add a registry entry (its unit test enforces chord uniqueness and the browser-reserved / terminal-sacred deny-lists). Current set: mod+1-9 switch to the Nth SIDEBAR agent row, mod+↑/↓ previous/next agent (clamped, ↑/↓ hint chips on the active agent's neighbors during hold) (ADR-066 — rendered order, published by Sidebar as `sidebarRowOrder`; NOT open-pane positions), mod+B sidebar, mod+/ help overlay, mod+K agent quick-switcher (fuzzy type-ahead; terminal-clear moved to mod+shift+K, ADR-071). Holding mod ~350ms shows each agent row's digit badge (`useModKeyHold` + `sidebarRowOrder.ts` — badges and shortcuts read the same published list, so the hint cannot lie). Escape dismissal is the registry's `ui.dismiss` entry backed by the LIFO escape stack (`shortcuts/escapeStack.ts`, ADR-065) — dismissible UI pushes a closer while mounted-open; never add a document-level Escape listener (bubble-phase listeners are dead under terminal focus). ADR-065 also freed the legacy ctrl+d/w/b terminal swallow; `mod+w` stays declined deliberately (browser-owned close-tab). The key-capture boundary (which chords the app steals from a focused terminal) is owned by Shortcuts@autonomOS.
## Key Conventions
### Decision Records (CRITICAL)
Every architectural decision is its own file in [`docs/decisions/`](docs/decisions/README.md): `ADR-NNN-<slug>.md`, browsable by number and title in GitHub's folder view (`make adr-index` prints a table with date, status and supersession; it is never committed, ADR-157). Start one with `make adr NEW="Short title"`. It takes the next free number (checking origin/main AND open PRs) and writes a template. Don't append to `docs/DECISIONS.md` (it's a pointer stub now, and CI rejects entries there). Each entry must include:
- **Date** (YYYY-MM-DD) and **Decided by** (human vs agent)
- **Context**: why this decision was needed
- **Decision**: what was chosen
- **Rationale**: why this over alternatives
- **Alternatives considered**: what else was evaluated
- **Source**: where the decision happened (Discord channel, CC session, etc.)
The labels are exact (`**Alternatives considered:**`, not `**Alternatives:**`). `make check`, and therefore CI and the pre-push gate, rejects a new ADR that is missing a field, still holds a template TODO, or reuses a number.
Never delete or modify past entries; the migrated ones are hash-locked in `docs/decisions/legacy-manifest.json`. If a decision is reversed, add a new entry referencing the old one (an optional `**Supersedes:** ADR-NNN` field, or `part of ADR-NNN` / `ADR-NNN (part: …)`, feeds the `make adr-index` table). If two parallel PRs pick the same number, whoever merges later runs `make adr-renumber FILE=docs/decisions/ADR-NNN-….md`: one file changes, nothing else. Main doesn't require up-to-date branches, so both can occasionally merge; main's CI then goes red with the same message, and whoever merged later runs that `make adr-renumber` in a follow-up PR. There is no bot for this (ADR-157). A branch that still appended to the old `docs/DECISIONS.md` moves its entry with `make adr-import REF=HEAD` (steps in `docs/decisions/README.md`).
### Research & Learnings
All research goes in `docs/RESEARCH.md` or `docs/research/` subdirectories. When investigating competitors, frameworks, or approaches:
- Document what you found with links
- Note what's relevant to autonomOS
- Include your assessment (not just raw info)
### Commit Messages
- `feat:` — new features
- `fix:` — bug fixes
- `perf:` — performance improvements
- `refactor:` — structural changes
- `docs:` — documentation changes
- `research:` — research findings
- `init:` — initial setup
### Terminology
- **UI says "agents"** — sidebar, buttons, labels
- **Code says "sessions"** — types, APIs, server internals
- Both refer to the same entity — a managed CC PTY process
### Session Naming
CC owns session names via JSONL `customTitle`. `titleCache.ts` reads them (256KB tail scan, mtime-cached). The `--name` flag sets the initial name at spawn. `/rename` updates it. The titleCache is more reliable than the SDK's `listSessions()` (which only reads 64KB).
### Running tests on the shared box
Agents share one machine with Terry's live fleet. Full runs QUEUE through one machine-wide slot (`scripts/ci-gate-lock.sh`, re-entrant): `make check` (including `AUTONOMOS_INTEGRATION=1`), the pre-push gate, and `make load-test` all take it; CI skips it. The holder's run is BOUNDED (`scripts/run-bounded.sh`, `AUTONOMOS_CI_GATE_RUN_TIMEOUT`, default 25 min): past it the run's whole process group is stopped, the still-running test files are named, it exits 124, and the slot frees; waiters wait up to 30 min. `make check`'s node runner uses `--test-force-exit`, so a test file that leaks a live handle can't keep a finished run (and the slot) alive. Run a single file with `scripts/test-file.sh <file>` (default `--test-timeout` + `--test-force-exit`, so a hung test fails and EXITS instead of orphaning for days; git env stripped). A fleet harness (many agents/processes) must call `assertFleetSlot()` and `startLoadWatchdog()` from `__tests__/helpers/fleet-guard.ts`: refuse outside the slot, abort if the load climbs. A test that spawns `git` passes `env: gitEnv()` (`helpers/git-env.ts`); `make check` also strips git's location vars, because a fixture `git init` under a hook's `GIT_DIR` flips the shared repo to `core.bare=true`.
### README Hero Image (keep it current)
The README's hero screenshot (`docs/assets/hero.png`) is generated, not hand-captured. It's produced by `make hero` (→ `packages/dashboard/scripts/capture-hero.ts`), which boots an isolated demo instance (own config dir + fake HOME + ephemeral port — never touches `:3100`), stages a multi-agent scene (org chart across Claude Code / Codex / Gemini, live terminals, both usage bars), and screenshots it via headless Chrome.
**If you change the dashboard UI, re-run `make hero` and commit the updated `docs/assets/hero.png`** — otherwise the front-page hero drifts from the real product. This applies to any visible change: layout/dockview, sidebar, org chart, status bar, terminal chrome, themes, provider icons, usage bars. It needs the `claude`, `codex` and `gemini` binaries plus Chromium, and uses **no real login** (#551): every pane is the CLI's REAL TUI on one local mock backend that speaks all three wire APIs (Anthropic `/v1/messages`; Codex via a throwaway `CODEX_HOME` whose only `model_providers` entry is the mock's Responses API with a dummy `env_key`, and no login file; Gemini via a dummy `GEMINI_API_KEY` + `GOOGLE_GEMINI_BASE_URL`). Each turn includes a real read-only tool call. The usage bars are route-mocked. `HERO_GEMINI_PANE=1` shows Gemini's terminal instead of Codex's; `HERO_CROPS_DIR` saves per-pane crops; `PANE_SIZE` must match the layout (spawning at the pane's size avoids Ink's stale resize frames; `HERO_DEBUG_GEOMETRY=1` re-measures). Never bring back a scripted or synthetic pane (CI rejects `/api/perf` in the script). On macOS `make hero` runs inside `scripts/hero-no-credentials.sb`, a sandbox that denies `~/.codex` (except read-only `~/.codex/packages`, the binaries), `~/.gemini`, the Claude credentials file, the keychain (files and the `securityd` service) and the `security` CLI, and `scripts/hero-credential-guard.ts` fails the run if a credential lands in its fake HOME. Its `heroCliEnv` builds the server's env everywhere (Linux has no sandbox): every `ANTHROPIC_`/`CLAUDE_`/`CODEX_`/`OPENAI_`/`GEMINI_`/`GOOGLE_`/`AWS_` var is purged by PREFIX, then `CODEX_HOME`/`GEMINI_CLI_HOME` are pinned to the fake HOME and the dummy keys set; add a CLI's new variable family there, not a name to a list. **Never add a real-credential path back** (copying `auth.json` or a keychain token rotates the operator's real login on refresh); CI's `hero-credential-guard.test.ts` rejects it. Re-shoots are near-identical (every turn is mock-backed).
### Development Philosophy
- **Personal tool first** — ship for Terry, generalize later
- **Both paths share core** — abstractions should work for dev agents AND robots
## What NOT to Do
- Don't make architectural decisions without recording them in `docs/decisions/` (`make adr`)
- Don't start building without checking ROADMAP.md for priorities
- Don't ignore existing research — check RESEARCH.md before investigating something
- Don't over-engineer for the robot path yet — it's aspirational
- Don't define MCP tool schemas directly in `mcp.ts` or `channel-server/index.ts` — they go in `mcp/tools.ts`
- Don't ship a visible dashboard UI change without re-running `make hero` — the README hero (`docs/assets/hero.png`) must reflect the real product
## Agent Workflow
When working on this repo:
1. Check ROADMAP.md — what's the current priority?
2. Check `docs/decisions/` — has this been decided already?
3. Do the work
4. Update ROADMAP.md if priorities shifted
5. Add any new decisions to `docs/decisions/` (`make adr NEW="…"`)
6. Update RESEARCH.md with any new findings