--- title: Architecture description: How Agent Orchestrator fits together — plugin slots, session lifecycle, event bus, prompt assembly, and activity detection. --- Agent Orchestrator (AO) is a Node.js orchestrator that spawns and manages parallel AI coding agents across isolated git worktrees. Every moving part is a plugin; the core provides the state machine, event bus, and prompt assembly that ties them together. ## The 8 Plugin Slots Each abstraction in AO is a named interface defined in `packages/core/src/types.ts`. Seven of the eight slots are pluggable at runtime; the eighth (Lifecycle) is built into core and cannot be replaced. | Slot | Default | Purpose | Interface | |------|---------|---------|-----------| | Runtime | [tmux](/docs/plugins/runtimes/tmux) | Where agent sessions execute (tmux, process, docker, k8s) | `Runtime` | | Agent | [claude-code](/docs/plugins/agents/claude-code) | Which AI coding tool is launched | `Agent` | | Workspace | [worktree](/docs/plugins/workspaces/worktree) | Code isolation — each session gets its own git worktree or clone | `Workspace` | | Tracker | [github](/docs/plugins/trackers/github) | Issue tracking (GitHub Issues, Linear, GitLab) | `Tracker` | | SCM | [github](/docs/plugins/scm/github) | PR lifecycle, CI checks, and code reviews | `SCM` | | Notifier | [desktop](/docs/plugins/notifiers/desktop) | Push notifications to the human (desktop, Slack, webhook) | `Notifier` | | Terminal | [iterm2](/docs/plugins/terminals/iterm2) | How humans view and interact with running sessions | `Terminal` | | Lifecycle | core (non-pluggable) | State machine, poll loop, and reaction engine | `LifecycleManager` | The Lifecycle slot is not pluggable. It is instantiated by core and wired to all other plugins automatically. You configure its behaviour (poll interval, reactions, thresholds) through `agent-orchestrator.yaml` rather than by replacing the implementation. --- ## Session Status Lifecycle Every session moves through a well-defined set of statuses. The values are defined by the `SESSION_STATUS` constant in `packages/core/src/types.ts`. ``` spawning │ ▼ working ──────────────────────────────────────────────► stuck │ ▲ ▼ │ pr_open ──────────────────────────────────────────────► stuck │ ├──► ci_failed │ ├──► review_pending │ ├──► changes_requested │ └──► approved │ ▼ mergeable │ ▼ merged ──► cleanup ──► done ``` Terminal statuses (session is dead and will no longer be polled): `killed`, `terminated`, `done`, `cleanup`, `errored`, `merged`. | Status | Description | |--------|-------------| | `spawning` | Session is being created — worktree, branch, and tmux window are initialising | | `working` | Agent is active; no PR yet | | `pr_open` | Agent has pushed a PR; CI and reviews are pending | | `ci_failed` | One or more CI checks on the PR are failing | | `review_pending` | PR has been submitted for review; waiting for a decision | | `changes_requested` | Reviewer(s) have requested changes | | `approved` | PR is approved but not yet mergeable (e.g. still behind base) | | `mergeable` | PR is approved, CI is green, and it can be merged | | `merged` | PR has been merged (terminal) | | `cleanup` | Post-merge cleanup in progress (terminal) | | `done` | Session completed cleanly (terminal) | | `needs_input` | Agent is waiting for a permission prompt or human input | | `stuck` | Agent has been idle beyond the configured `agent-stuck` threshold | | `errored` | Unexpected error — session is dead (terminal) | | `killed` | Session was explicitly killed or the PR was closed (terminal) | | `idle` | Agent process is alive but has not produced activity for an extended period | | `terminated` | Session was terminated externally (terminal) | ### How transitions are determined The lifecycle manager calls `determineStatus(session)` on every poll cycle. The logic follows this cascade: 1. **Runtime liveness** — If the runtime reports the session is not alive, return `killed`. 2. **Agent activity** — `getActivityState()` is called; `waiting_input` maps to `needs_input`, `exited` maps to `killed`, and idle beyond the configured threshold maps to `stuck`. 3. **PR auto-detection** — If no PR is recorded and the agent has a branch, `scm.detectPR()` is called once per cycle to catch PRs created without a metadata hook. 4. **PR state** — If a PR exists, the SCM plugin provides CI status, review decision, and merge readiness to determine `ci_failed`, `review_pending`, `changes_requested`, `approved`, `mergeable`, or `merged`. 5. **Default** — Fall back to `working` (or preserve `stuck`/`needs_input`). --- ## Event Bus After each status transition, the lifecycle manager constructs a typed `OrchestratorEvent` and fans it out to all configured notifiers and reaction handlers. Events have four priority levels: `urgent`, `action`, `warning`, and `info`. Priority is inferred by `inferPriority()` in `lifecycle-manager.ts`: - **urgent** — events containing `stuck`, `needs_input`, or `errored` - **action** — events containing `approved`, `ready`, `merged`, or `completed` - **warning** — events containing `fail`, `changes_requested`, or `conflicts` - **info** — everything else, including all `summary.*` events | `event.type` | Priority | When emitted | |---|---|---| | `session.spawned` | info | Session transitions out of `spawning` | | `session.working` | info | Session enters `working` | | `session.exited` | info | Agent process exits | | `session.killed` | info | Session is killed | | `session.idle` | info | Session enters `idle` | | `session.stuck` | urgent | Session exceeds the `agent-stuck` threshold | | `session.needs_input` | urgent | Agent is waiting on a permission prompt | | `session.errored` | urgent | Session enters `errored` | | `pr.created` | info | Session transitions to `pr_open` | | `pr.updated` | info | PR title or state changes | | `pr.merged` | action | PR is merged | | `pr.closed` | info | PR is closed without merging | | `ci.passing` | action | CI checks recover from failing to passing | | `ci.failing` | warning | Session enters `ci_failed` | | `ci.fix_sent` | info | CI fix message sent to agent | | `ci.fix_failed` | warning | CI fix attempt failed | | `review.pending` | info | Session enters `review_pending` | | `review.approved` | action | Session enters `approved` | | `review.changes_requested` | warning | Session enters `changes_requested` | | `review.comments_sent` | info | Review comments forwarded to agent | | `review.comments_unresolved` | warning | Unresolved review comments still present | | `automated_review.found` | warning | Bot/automated review comments detected | | `automated_review.fix_sent` | info | Automated review fix sent to agent | | `merge.ready` | action | Session enters `mergeable` | | `merge.conflicts` | warning | PR has merge conflicts | | `merge.completed` | action | Session enters `merged` | | `reaction.triggered` | info | A configured reaction fired | | `reaction.escalated` | urgent | A reaction exceeded its retry/escalation threshold | | `summary.all_complete` | info | All sessions have reached terminal statuses | For the webhook wire format, see [Webhook Notifier](/docs/plugins/notifiers/webhook). For configuring which events trigger automated reactions, see [Reactions](/docs/configuration/reactions). --- ## Poll Loop The lifecycle manager runs a recurring poll loop. The default interval is **30 seconds** (configurable via `start(intervalMs)`). Each cycle: 1. Lists all active sessions via `sessionManager.list()`. 2. **Batch-fetches PR enrichment data** — a single GraphQL query retrieves CI status, review decision, and merge readiness for all open PRs at once, replacing N×3 individual REST calls with one request. 3. Checks each session concurrently — `checkSession(session)` calls `determineStatus()`, detects transitions, fires events, and evaluates reactions. 4. Prunes stale tracker entries for sessions that no longer exist. 5. Checks whether all sessions are complete and fires `summary.all_complete` if so (emitted once per batch, not repeatedly). The dashboard then receives these state changes via SSE at a 5-second cadence. The poll loop and SSE cadence are independent — the dashboard may show state that is up to 5 seconds behind the last poll cycle. --- ## Prompt Assembly (3 Layers) Every agent session is launched with a composed prompt built by `buildPrompt()` in `packages/core/src/prompt-builder.ts`. The three layers are always concatenated in order: ### Layer 1 — Base prompt (fixed) `BASE_AGENT_PROMPT` provides identity, session lifecycle rules, git workflow guidance, and PR best practices. It is identical across all sessions. For projects without a remote repository, a trimmed variant (`BASE_AGENT_PROMPT_NO_REPO`) is used instead — it omits PR and CI instructions that do not apply. ### Layer 2 — Config context (per-project) Built from the project configuration. Includes: - Project name and ID - Repository (`owner/repo`) - Default branch - Tracker plugin name - Issue ID and issue body (when spawning from a tracker issue) - Reaction hints — lists which events will auto-send instructions back to the agent ### Layer 3 — User rules (per-project) Loaded from `agentRules` (inline string in `agent-orchestrator.yaml`) and/or `agentRulesFile` (path to a file, relative to the project root). Both are concatenated when present. If neither is provided, this layer is omitted. An explicit `userPrompt` is appended after Layer 3 as "Additional Instructions" — it has the highest precedence and overrides anything above it. ### Orchestrator rules The `orchestratorRules` field in `ProjectConfig` is reserved for orchestrator-role sessions but is not applied by `buildPrompt()`. Orchestrator sessions receive a completely different prompt generated by `generateOrchestratorPrompt()` — see the next section. --- ## Orchestrator Prompt Orchestrator sessions do not receive the standard three-layer prompt. Instead, `generateOrchestratorPrompt()` in `packages/core/src/orchestrator-prompt.ts` builds a standalone prompt that provides: - **Role rules** — read-only investigations only; never own a PR; never use `tmux send-keys` directly; always use `ao send` / `ao spawn` to delegate. - **Project info** — name, repo, default branch, session prefix, local path, dashboard port. - **Quick-start commands** — `ao status`, `ao spawn`, `ao batch-spawn`, `ao session claim-pr`, `ao send`, `ao open`. - **Available `ao` commands table** — full reference adapted to whether a repo is configured. - **Session management workflows** — spawning, monitoring, PR takeover, investigation workflow, cleanup. - **Dashboard info** — URL and feature summary. - **Automated reactions** — lists configured reactions so the orchestrator knows what the system will handle automatically. - **Common workflows** — bulk issue processing, handling stuck agents, PR review flow, manual intervention. - **Project-specific rules** — content of `orchestratorRules` from `ProjectConfig`, appended last. For a guide on per-role agents, see [Per-Role Agents](/docs/guides/per-role-agents). --- ## Activity Detection Every agent plugin must implement `getActivityState(session, readyThresholdMs?)`. This is the most critical method in the agent plugin — the dashboard, lifecycle manager, and stuck-detection all depend on it. ### The 6 activity states | State | Meaning | When | |---|---|---| | `active` | Agent is processing — thinking, writing code, running tools | Activity within the last 30 seconds | | `ready` | Agent finished its turn and is alive, waiting for input | 30 seconds – 5 minutes since last activity | | `idle` | Agent has been quiet for an extended period | More than 5 minutes since last activity (default threshold) | | `waiting_input` | Agent is at a permission prompt or asking a question | Permission request detected | | `blocked` | Agent hit an error it cannot recover from on its own | Error state detected | | `exited` | Agent process is no longer running | `isProcessRunning` returns false | ### The `getActivityState` cascade Every agent plugin must implement this cascade in order: ``` 1. PROCESS CHECK └─ isProcessRunning() → false → return { state: "exited" } 2. ACTIONABLE STATES └─ checkActivityLogState() → waiting_input or blocked → return immediately 3. NATIVE SIGNAL (agent-specific) └─ session list API, native JSONL timestamp, etc. └─ classify by age: active (<30s) / ready (30s–threshold) / idle (>threshold) 4. JSONL ENTRY FALLBACK (mandatory) └─ getActivityFallbackState(activityResult, activeWindowMs, threshold) └─ age-based decay: active→ready→idle (never promotes) └─ staleness cap: waiting_input/blocked entries expire after 5 minutes 5. Return null only when there is genuinely no data at all ``` Step 4 (the JSONL entry fallback) is mandatory. Skipping it means `getActivityState` returns `null` whenever the native API fails — the dashboard shows no activity state and stuck-detection breaks for the entire session lifetime. This was a real bug in the OpenCode plugin. ### Two JSONL patterns | Pattern | Used by | How it works | |---|---|---| | **Agent-native JSONL** | Claude Code, Codex | The agent writes its own JSONL with rich state entries (`permission_request`, `tool_call`, `error`, etc.). `getActivityState` reads the last entry and maps it to activity states. | | **AO activity JSONL** | Aider, OpenCode, new agents | The agent implements `recordActivity`, which calls `recordTerminalActivity()` → `classifyTerminalActivity()` → `appendActivityEntry()` to write to `{workspacePath}/.ao/activity.jsonl`. `getActivityState` reads from this file. | ### Thresholds | Constant | Value | Purpose | |---|---|---| | `DEFAULT_ACTIVE_WINDOW_MS` | 30 seconds | Activity newer than this is `active`; older is `ready` | | `DEFAULT_READY_THRESHOLD_MS` | 5 minutes | `ready` sessions older than this become `idle` | | `ACTIVITY_INPUT_STALENESS_MS` | 5 minutes | Deprecated compatibility export. `waiting_input` / `blocked` entries no longer expire by wallclock; they persist until process death or a newer entry overrides them. | --- ## PATH Wrappers When an agent creates a PR or switches a branch, AO needs to update the session metadata (e.g. write `pr=https://...` or `branch=feat/INT-123`) so the dashboard and lifecycle manager stay in sync. Two mechanisms exist: **Claude Code — PostToolUse hooks** Claude Code writes `.claude/settings.json` with a `PostToolUse` hook that fires after every `gh pr create` or `git checkout` command. The hook script calls `update_ao_metadata` directly. **All other agents — PATH wrappers** Agents without a native hook system (Codex, Aider, OpenCode, custom agents) use `~/.ao/bin/gh` and `~/.ao/bin/git` shell wrappers. These wrappers are installed to `~/.ao/bin/` by `setupPathWrapperWorkspace(workspacePath)` from `packages/core/src/agent-workspace-hooks.ts`. The function also writes session context to `{workspacePath}/.ao/AGENTS.md` (gitignored — does not touch tracked files). The wrappers intercept: - `gh pr create` — captures the PR URL from stdout and writes `pr=` and `status=pr_open` - `gh pr merge` — writes `status=merged` - `git checkout -b ` / `git switch -c ` — writes `branch=` All other commands pass through transparently via `exec "$real_gh" "$@"` or `exec "$real_git" "$@"`. For the user-facing storage summary, see [Configuration — Where data lives](/docs/configuration#where-data-lives). --- ## Observability The lifecycle manager, session manager, and plugin registry emit structured telemetry using project observers created by `createProjectObserver()`. Each running process writes a JSON snapshot to: ``` ~/.agent-orchestrator/{hash}-observability/processes/{component}-{pid}.json ``` The hash is the first 12 characters of the SHA-256 of the config directory path. The `{component}` segment matches the internal observer name (e.g. `lifecycle-manager`, `session-manager`). The dashboard's `/api/observability` route reads and merges these per-process snapshots to produce a live observability view. **Feedback reports** from the agent's `bug_report` and `improvement_suggestion` tools are written as flat key-value files at: ``` ~/.agent-orchestrator/{hash}-{projectId}/feedback-reports/*.kv ``` --- ## Data Flow Summary ``` agent-orchestrator.yaml ──► Config Loader (Zod) ──► Plugin Registry │ ┌───────────────┘ │ ▼ Session Manager ◄─── ao spawn / ao session │ ▼ Lifecycle Manager ────► Events ────► Notifiers │ │ │ │ Reactions Webhook │ ▼ Dashboard API (Next.js App Router) │ ┌───────────┴──────────────┐ │ │ ▼ ▼ SSE (5s) WebSocket (terminal) │ │ ▼ ▼ React UI xterm.js ``` --- ## Next Steps