# CLAUDE.md ## What is this project? Agent Orchestrator (AO) is a platform for spawning and managing parallel AI coding agents across distributed systems. It runs multiple agents (Claude Code, Codex, Aider, OpenCode) simultaneously — each in an isolated git worktree with its own PR — and provides a single dashboard to supervise them all. Agents autonomously fix CI failures, address review comments, and manage PRs. **Org:** ComposioHQ **Repo:** `github.com/ComposioHQ/agent-orchestrator` **License:** MIT ## Monorepo Structure pnpm workspace (v9.15.4) with ~30 packages: ``` packages/ core/ # Engine: types, config, session manager, lifecycle, plugin registry cli/ # CLI tool (`ao` command) — depends on all plugins web/ # Next.js 15 dashboard (App Router, React 19, Tailwind v4) ao/ # Global CLI wrapper (thin shim around cli) plugins/ agent-claude-code/ agent-aider/ agent-codex/ agent-opencode/ runtime-tmux/ runtime-process/ workspace-worktree/ workspace-clone/ tracker-github/ tracker-linear/ tracker-gitlab/ scm-github/ scm-gitlab/ notifier-desktop/ notifier-slack/ notifier-webhook/ notifier-composio/ notifier-openclaw/ terminal-iterm2/ terminal-web/ integration-tests/ # E2E tests ``` **Build order:** core -> plugins -> cli/web (parallel). `pnpm build` at root handles this. ## Tech Stack | Layer | Stack | |-------|-------| | Language | TypeScript (strict mode, ES2022, Node16 modules) | | Runtime | Node.js 20+ | | Package Manager | pnpm 9.15.4 (`workspace:*` protocol) | | Web | Next.js 15 (App Router) + React 19 | | Styling | Tailwind CSS v4 + CSS custom properties (`@theme` block in `globals.css`) | | Terminal UI | xterm.js 5.3.0 + WebSocket to tmux PTYs | | Validation | Zod | | Testing | Vitest + @testing-library/react | | Linting | ESLint 10 (flat config) + Prettier 3.8 | | CI/CD | GitHub Actions (lint, typecheck, test, release) | | Versioning | Changesets | | Git hooks | Husky + gitleaks (secret scanning) | | Container | OCI via Containerfile (Podman/Docker) | ## Commands ```bash # Install & build pnpm install pnpm build # Development pnpm dev # Web dashboard (Next.js + 2 WS servers) # Type checking pnpm typecheck # All packages pnpm --filter @aoagents/ao-web typecheck # Web only # Testing pnpm test # All packages (excludes web) pnpm --filter @aoagents/ao-web test # Web tests pnpm --filter @aoagents/ao-web test:watch # Web watch mode pnpm test:integration # Integration tests # Lint & format pnpm lint pnpm lint:fix pnpm format pnpm format:check ``` ## Architecture ### Plugin System (8 Slots) Every abstraction is a pluggable interface defined in `packages/core/src/types.ts`: | Slot | Default | Purpose | |------|---------|---------| | Runtime | tmux | Where agents execute | | Agent | claude-code | Which AI tool to use | | Workspace | worktree | Code isolation (worktree vs clone) | | Tracker | github | Issue tracking (GitHub, Linear, GitLab) | | SCM | github | PR, CI, reviews | | Notifier | desktop | Notification delivery | | Terminal | iterm2 | Human attachment UI | | Lifecycle | core (non-pluggable) | State machine + polling | ### Session Lifecycle Sessions have a **canonical lifecycle** (in `lifecycle-state.ts`) with separate `state` and `reason` fields, and a **legacy status** derived from them for display. **Canonical session states:** `not_started`, `working`, `idle`, `needs_input`, `stuck`, `detecting`, `done`, `terminated` **Terminal reasons:** `manually_killed`, `runtime_lost`, `agent_process_exited`, `probe_failure`, `error_in_process`, `auto_cleanup`, `pr_merged` **Legacy status flow (derived via `deriveLegacyStatus`):** ``` spawning -> working -> pr_open -> ci_failed / review_pending | | changes_requested approved | | +-> mergeable -> merged -> cleanup -> done ``` **Stale runtime reconciliation:** `sm.list()` detects dead runtimes (tmux/process gone) during enrichment and persists `detecting` state with `runtime_lost` reason to disk. The lifecycle manager's `resolveProbeDecision` pipeline is the single authority on terminal decisions — `sm.list()` never writes `terminated` directly (#1735). ### Data Flow ``` agent-orchestrator.yaml -> Config Loader (Zod) -> Plugin Registry -> Session Manager -> Lifecycle Manager (polling loop, state machine) -> Events -> Notifiers -> Web API Routes (Next.js) -> SSE (5s interval) + WebSocket (terminal) -> Dashboard (React + xterm.js) ``` ### Storage No database. Flat files + memory: - **Config:** `agent-orchestrator.yaml` (Zod-validated) - **Global config:** `~/.agent-orchestrator/config.yaml` (all registered projects) - **Session metadata:** `~/.agent-orchestrator/{hash}-{projectId}/sessions/{sessionId}` (key-value pairs) - **Worktrees:** `~/.agent-orchestrator/{hash}-{projectId}/worktrees/{sessionId}/` - **Archives:** `~/.agent-orchestrator/{hash}-{projectId}/archive/{sessionId}_{timestamp}` - **Running state:** `~/.agent-orchestrator/running.json` (current ao start PID, port, projects) - **Last-stop state:** `~/.agent-orchestrator/last-stop.json` (sessions killed by ao stop / Ctrl+C, includes `otherProjects` for cross-project sessions — used by ao start to offer session restore) Hash = SHA-256 of config directory (first 12 chars). Prevents collision across multiple checkouts. **Config resolution:** `loadConfig()` searches up from cwd and finds the nearest `agent-orchestrator.yaml` (typically 1 project). The global config at `~/.agent-orchestrator/config.yaml` contains all registered projects. CLI commands that need cross-project visibility (ao stop, tab completions) fall back to the global config. ### Prompt Assembly (3 Layers) 1. Base prompt (system instructions in core) 2. Config prompt (project-specific rules from YAML) 3. Rules files (optional `.agent-rules.md` from repo) ## Working Principles These behavioral guidelines apply to every agent working on this codebase. They are not optional - they prevent the most common causes of PR rejection and rewrite. ### Think Before Coding Don't assume. Don't hide confusion. Surface tradeoffs. - State assumptions explicitly. If uncertain, ask. - If multiple interpretations of a task exist, present them - don't pick silently. - If a simpler approach exists, say so. Push back when warranted. - If something is unclear, stop. Name what's confusing. Ask. - When editing `lifecycle-manager.ts` or `session-manager.ts`: state which invariants your change preserves. These files have subtle state dependencies. ### Simplicity First Minimum code that solves the problem. Nothing speculative. - No features beyond what was asked. - No abstractions for single-use code. - No "flexibility" or "configurability" that wasn't requested. - No error handling for impossible scenarios. - Plugin slots are the extension point. Don't add configuration surface when a new plugin is the right answer. - If you write 200 lines and it could be 50, rewrite it. Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. ### Surgical Changes Touch only what you must. Clean up only your own mess. - Don't "improve" adjacent code, comments, or formatting. - Don't refactor things that aren't broken. - Match existing style, even if you'd do it differently. - If your changes create orphans (unused imports, dead variables), remove them. - Don't remove pre-existing dead code unless asked. - Every changed line should trace directly to the task description. This is especially critical in: - `types.ts` - changing an interface breaks every plugin. Minimize surface changes. - `globals.css` - tokens are consumed across 50+ components. Don't rename casually. - `lifecycle-manager.ts` - state transitions have implicit dependencies. Document why a transition is safe. ### Goal-Driven Execution Define success criteria. Loop until verified. Transform tasks into verifiable goals: - "Add a new status" -> "Add to enum, update `isTerminalSession`, add to dashboard column mapping, write tests for all three" - "Fix the bug" -> "Write a test that reproduces it, then make it pass" - "Refactor X" -> "Ensure tests pass before and after" For multi-step tasks, state a brief plan: [Step] -> verify: [check] [Step] -> verify: [check] [Step] -> verify: [check] Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. ## CLI Behavior (ao start / ao stop) ### ao start - Registers in `running.json` (PID, port, projects) - Offers to restore sessions from `last-stop.json` — includes cross-project sessions via `otherProjects` field - `ao start --restore` restores `last-stop.json` without prompting; `ao start --no-restore` skips restore - **Ctrl+C performs full graceful shutdown** (same as ao stop): kills all sessions, writes last-stop state, unregisters from running.json. 10s hard timeout guarantees exit. ### ao stop - `ao stop` (no args): kills ALL sessions across ALL projects, sends SIGTERM to parent ao start process, stops dashboard, unregisters - `ao stop `: kills only that project's sessions, does NOT kill parent process or dashboard (they serve all projects) - Always loads global config (`~/.agent-orchestrator/config.yaml`) to see all projects — local config only has the cwd project - Records `LastStopState` with `otherProjects` field for cross-project session restore ### ao update - For package-manager installs, `ao update` pauses a running AO via `ao stop --yes`, runs the global package update, verifies `ao --version`, then restarts with `ao start --restore` (or `--no-restore` if requested) - Failed package-manager updates must report that AO was not updated, include actionable remediation, and restart the previous installation if AO was paused ### Dashboard sidebar - Sidebar always shows sessions from ALL projects regardless of which project page is active - `useSessionEvents` in Dashboard.tsx is called without project filter — sidebar gets unscoped sessions - Kanban board filters client-side via `projectSessions` memo ### Key invariants - `sm.list()` persists `detecting` state (not `terminated`) to disk when enrichment detects dead runtimes — terminal decisions are made only by the lifecycle manager's probe pipeline (#1735) - `deriveLegacyStatus()` maps canonical lifecycle to legacy status — new terminal reasons must be added here - Tab completions merge local config + global config to show all projects ## Cross-Platform (Windows) Compatibility AO ships on macOS, Linux, **and Windows**. All three are first-class. ### The Golden Rule > **Never write `process.platform === "win32"` in new code. Use `isWindows()` from `@aoagents/ao-core`. If you need branching the helpers don't cover, add it to `packages/core/src/platform.ts` (or one of the targeted helper modules below) — never inline at the call site.** The codebase has a deliberate set of cross-platform abstractions. Every platform helper is centrally tested by mocking `process.platform`; inline checks bypass those tests and become silent regressions. Whenever you'd type `process.platform`, stop and check the helper inventory in `docs/CROSS_PLATFORM.md` first. ### Read `docs/CROSS_PLATFORM.md` before merging if you touch any of: - Process spawning, killing, signalling, or process-tree teardown (`child_process`, `process.kill`, runtime plugins) - File paths — comparison, joining, walking, anything OS-specific - Shell commands (`exec`, `execFile`, command strings, redirections, PowerShell-vs-bash) - Network binding, sockets, anything that says `localhost` - Shell-outs to POSIX tools (`tmux`, `lsof`, `pkill`, `which`, coreutils) - Adding any new `if (process.platform === "win32")` check (it should go into `platform.ts` instead — see the Golden Rule) - Runtime / agent / workspace plugin code that runs on both `runtime-tmux` and `runtime-process` - Agent-plugin internals: `setupPathWrapperWorkspace`, `getActivityState`, `formatLaunchCommand`, `isProcessRunning`, `detect()` - The Windows pty-host pipe protocol or registry (`pty-client.ts`, `windows-pty-registry.ts`, `sweepWindowsPtyHosts`) ### Quick reference: helpers to use instead of raw platform checks All importable from `@aoagents/ao-core` unless noted: | Need | Use | |------|-----| | OS check | `isWindows()` | | Pick runtime | `getDefaultRuntime()` | | Resolve shell (PowerShell vs `/bin/sh`) | `getShell()` | | Kill process + descendants | `killProcessTree(pid, signal?)` | | Find PID listening on a port | `findPidByPort(port)` | | Default env (HOME / TMPDIR / SHELL / PATH / USER) | `getEnvDefaults()` | | Compare paths (case-insensitive on NTFS/APFS) | `pathsEqual()` / `canonicalCompareKey()` from `cli/src/lib/path-equality.ts` | | Escape shell args | `shellEscape()` | | Install agent PATH wrappers (`gh`/`git`) | `setupPathWrapperWorkspace(workspacePath)` | | Build env PATH with `~/.ao/bin` prepended | `buildAgentPath(basePath?)` | | Tail JSONL | `readLastJsonlEntry` / `readLastActivityEntry` | | Activity-state contract helpers | `checkActivityLogState`, `getActivityFallbackState`, `classifyTerminalActivity`, `recordTerminalActivity`, `appendActivityEntry` | | Windows pty-host registry (used by `ao stop`) | `registerWindowsPtyHost`, `getWindowsPtyHosts`, `unregisterWindowsPtyHost`, `clearWindowsPtyHostRegistry` | | Reap orphan pty-hosts on `ao stop` | `sweepWindowsPtyHosts()` from `@aoagents/ao-plugin-runtime-process` | | Talk to a Windows pty-host over its named pipe | `getPipePath`, `connectPtyHost`, `ptyHostSendMessage`, `ptyHostGetOutput`, `ptyHostIsAlive`, `ptyHostKill` from `@aoagents/ao-plugin-runtime-process` | | Validate user-supplied session ID before pipe/shell use | `validateSessionId()` from `@/server/tmux-utils` | | Resolve a session's Windows pipe path | `resolvePipePath()` from `@/server/tmux-utils` | | POSIX-only Ctrl+C signal forwarding | `forwardSignalsToChild()` from `cli/src/lib/shell.ts` (guard with `!isWindows()`) | | Defensive PowerShell sweep of orphan pty-hosts | `stopStaleWindowsPtyHosts(projectDir)` from `web/src/lib/windows-pty-cleanup.ts` | `docs/CROSS_PLATFORM.md` has the full helper reference with import paths, the EPERM-vs-ESRCH gotcha when probing processes (with a copyable code snippet), path case-insensitivity rules, PowerShell-vs-bash differences (`& ` call-operator, `$env:VAR`, no `/dev/null`, no `$(cat …)`, `.cmd`/`.bat`/`.exe` shim resolution via `shell: isWindows()`), the IPv6 `localhost` stall on Windows, agent-plugin Windows specifics, the test pattern for mocking `process.platform`, and a 10-point pre-merge checklist. **Run through that checklist for any non-trivial change.** ### Environment variables to know about - `AO_SHELL` — overrides `getShell()` resolution (escape hatch for Git Bash users on Windows). Args inferred from basename: `cmd` → `/c`, `bash`/`sh`/`zsh` → `-c`, anything else → `-Command`. - `AO_BASH_PATH` — used by `script-runner.ts` on Windows to locate bash before falling back to Git Bash auto-detection. WSL bash is excluded (it sees Linux paths from a Windows cwd, breaking script semantics). ## Conventions ### Code Style - **TypeScript strict mode** — no `any` types (`@typescript-eslint/no-explicit-any: error`) - **Consistent type imports** — `import type { Foo }` enforced by ESLint - **Immutable patterns** — spread operator, never mutate in place - **Prefer const** — `no-var`, `prefer-const` - **No eval** — `no-eval`, `no-implied-eval`, `no-new-func` - **Unused vars** — prefix with `_` (`argsIgnorePattern: "^_"`) ### File Organization - Components in flat `components/` directory (no nesting) - Hooks in `hooks/` with `use` prefix - Tests in `__tests__/` subdirectories - No barrel files except `core/src/index.ts` - Max 400 lines per component file ### Naming - PascalCase for components/classes - camelCase for functions/variables - `use*` for hooks, `is*`/`has*` for booleans ### Imports - `@/` alias -> `packages/web/src/` - `@aoagents/ao-core` for core imports - `workspace:*` for cross-package ### Web / Styling - Tailwind utility classes only — **no inline `style=` attributes** - CSS custom properties via `var(--color-*)` from `globals.css` `@theme` block - Dark theme must always be preserved - **No external UI component libraries** (no Radix, shadcn, etc.) - Client components marked `"use client"`; server components for pages - State: React hooks only (no Redux/Zustand) - Real-time updates: SSE via `useSessionEvents` hook (5s interval, do not change) ### Testing - Vitest + @testing-library/react - Test files: `{Module}.test.ts` or `{Component}.test.tsx` in `__tests__/` - Test files for all new components - Relaxed lint in tests: `any` and `console.log` allowed ### Commits - Conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `chore:`, `perf:`, `ci:` - Changesets for version management - gitleaks pre-commit hook — never commit secrets ## Key Files | File | Purpose | |------|---------| | `packages/core/src/types.ts` | Central type definitions (all 8 plugin interfaces) | | `packages/core/src/session-manager.ts` | Session CRUD + stale runtime reconciliation (persists runtime_lost on dead runtimes) | | `packages/core/src/lifecycle-manager.ts` | State machine + polling loop + reactions | | `packages/core/src/lifecycle-state.ts` | Canonical lifecycle → legacy status mapping (deriveLegacyStatus) | | `packages/core/src/config.ts` | YAML config loading with Zod validation | | `packages/core/src/plugin-registry.ts` | Plugin discovery and resolution | | `packages/core/src/index.ts` | Core public API (stable, do not break) | | `packages/web/src/components/Dashboard.tsx` | Main dashboard view | | `packages/web/src/components/SessionDetail.tsx` | Session detail view | | `packages/web/src/components/DirectTerminal.tsx` | xterm.js terminal with WebSocket | | `packages/web/src/components/SessionCard.tsx` | Kanban session card | | `packages/web/src/hooks/useSessionEvents.ts` | SSE consumer hook (project filter optional — sidebar uses unscoped) | | `packages/web/src/lib/types.ts` | Dashboard types | | `packages/web/src/app/globals.css` | Design tokens and base styles (full token definitions) | | `DESIGN.md` | **Design system reference** — design principles, token mapping, component patterns, anti-patterns (read this before writing any web UI) | | `agent-orchestrator.yaml` | Project-level config (user-created) | | `eslint.config.js` | ESLint flat config | | `tsconfig.base.json` | Shared TypeScript base config | | `packages/cli/src/commands/start.ts` | ao start/stop commands + Ctrl+C graceful shutdown | | `packages/cli/src/lib/running-state.ts` | RunningState + LastStopState management (register/unregister, last-stop read/write) | | `packages/web/src/components/ProjectSidebar.tsx` | Sidebar — always shows all projects' sessions | ## Skills The `skills/` directory contains reusable workflow documents for common tasks. Load them before starting work: | Skill | When to load | |-------|-------------| | [`skills/bug-triage/SKILL.md`](skills/bug-triage/SKILL.md) | Triage a bug report — investigate, search duplicates, file GitHub issues, push fix PRs | | [`skills/agent-orchestrator/SKILL.md`](skills/agent-orchestrator/SKILL.md) | Architecture and conventions for working on this codebase | | [`skills/release-notes/ao-weekly-release/SKILL.md`](skills/release-notes/ao-weekly-release/SKILL.md) | Generate weekly release notes from git history | | [`skills/social-media/SKILL.md`](skills/social-media/SKILL.md) | Social media post generation | See [`skills/README.md`](skills/README.md) for how to install skills into other coding agents (Cursor, Copilot, Codex, etc.). ## Plugin Standards ### Package Layout ``` packages/plugins/{slot}-{name}/ ├── package.json # @aoagents/ao-plugin-{slot}-{name} ├── tsconfig.json # extends ../../../tsconfig.base.json ├── src/ │ ├── index.ts # manifest + create + detect (default export) │ └── __tests__/ # vitest tests ``` ### Naming - Package: `@aoagents/ao-plugin-{slot}-{name}` (lowercase, hyphenated) - `manifest.name` must match the `{name}` suffix (e.g. package `...-runtime-tmux` -> name: `"tmux"`) - `manifest.slot` must use `as const` to preserve the literal type ### Export Contract Every plugin default-exports a `PluginModule`: ```typescript import type { PluginModule, Runtime } from "@aoagents/ao-core"; export const manifest = { name: "tmux", slot: "runtime" as const, description: "tmux session runtime", version: "0.1.0", }; export function create(config?: Record): Runtime { // Validate config here, not in individual methods // Use closure to capture validated config return { ... }; } // Optional: check if binary/dependency is available on system export function detect(): boolean { ... } export default { manifest, create, detect } satisfies PluginModule; ``` ### Config Handling - Plugin-level config comes via `create(config)` from the YAML notifier/tracker blocks - Project-level config (e.g. `agentConfig`, `trackerConfig`) is passed to individual methods - Validate in `create()`, store via closure — don't re-validate per call - Warn (don't throw) for missing optional config during plugin load - Throw with descriptive message when a required config is missing at method call time ### Error Handling - Wrap errors with `cause` for debugging: `throw new Error("msg", { cause: err })` - Return `null` for "not found" (e.g. tracker issue lookup), throw for unexpected errors - Never silently swallow errors - Use `shellEscape()` from core for all command arguments (prevent injection) ### Interface Implementation - All I/O methods return `Promise` (async-first) - Plugins are loosely coupled — communicate through Session object and Lifecycle Manager, never call other plugins directly - Implement `destroy()` / cleanup with best-effort semantics ### Core Utilities Available to Plugins ```typescript import { shellEscape, // Safe command argument escaping validateUrl, // Webhook URL validation readLastJsonlEntry, // Efficient JSONL log tail (native agent JSONL) readLastActivityEntry, // Read last AO activity JSONL entry checkActivityLogState, // Extract sticky waiting_input/blocked from AO JSONL getActivityFallbackState, // Last-resort fallback: actionable states + liveness age decay recordTerminalActivity, // Shared recordActivity impl (classify + dedup + append) classifyTerminalActivity, // Classify terminal output via detectActivity appendActivityEntry, // Low-level JSONL append setupPathWrapperWorkspace, // Install ~/.ao/bin wrappers + .ao/AGENTS.md buildAgentPath, // Prepend ~/.ao/bin to PATH normalizeAgentPermissionMode, // Normalize permission mode strings DEFAULT_READY_THRESHOLD_MS, // 5 min — ready→idle threshold DEFAULT_ACTIVE_WINDOW_MS, // 30s — active→ready window ACTIVITY_INPUT_STALENESS_MS, // Deprecated compatibility export; actionable states no longer expire by wallclock PREFERRED_GH_PATH, // /usr/local/bin/gh CI_STATUS, ACTIVITY_STATE, SESSION_STATUS, // Constants type Session, type ProjectConfig, type RuntimeHandle, } from "@aoagents/ao-core"; ``` ### Testing - Vitest in `src/__tests__/index.test.ts` - Mock external CLIs, file I/O, HTTP calls - Test manifest values, `create()` return shape, all public methods, and error paths - Use `beforeEach` to reset mocks ### Common Pitfalls - Hardcoded secrets -> use `process.env`, throw if missing - Shell injection -> use `shellEscape()` for all arguments - Large file reads -> use streaming or `readLastJsonlEntry()` - Config validation in methods -> validate once in `create()`, closure the rest ### Agent Plugin Implementation Standards All agent plugins (claude-code, codex, aider, opencode, etc.) must implement the full `Agent` interface. The dashboard depends on these methods for PR tracking, cost display, and session resume. **Required methods (all agents):** | Method | Purpose | Return `null` OK? | |--------|---------|-------------------| | `getLaunchCommand` | Shell command to start the agent | No | | `getEnvironment` | Env vars for agent process (must include `~/.ao/bin` in PATH) | No | | `detectActivity` | Terminal output classification (deprecated, but required) | No | | `getActivityState` | JSONL/API-based activity detection (min 3 states: active/ready/idle) | Yes (if no data) | | `isProcessRunning` | Check process alive via tmux TTY or PID | No | | `getSessionInfo` | Extract summary, cost, session ID from agent's data | Yes (if agent has no introspection) | **Optional methods (implement when the agent supports it):** | Method | Purpose | When to skip | |--------|---------|-------------| | `getRestoreCommand` | Resume a previous session | Agent has no resume capability (return `null`) | | `setupWorkspaceHooks` | Install metadata-update hooks (PATH wrappers or agent-native) | Never — required for dashboard PR tracking | | `postLaunchSetup` | Post-launch config (re-ensure hooks, resolve binary) | Only if no post-launch work needed | | `recordActivity` | Write terminal-derived activity to JSONL for `getActivityState` | Agent has native JSONL with full state coverage (Claude Code). Codex implements it as a safety net for when its native JSONL is missing/unparseable. | **Metadata hooks are critical.** Without `setupWorkspaceHooks`, PRs created by agents won't appear in the dashboard. Two patterns exist: - **Agent-native hooks** (Claude Code): PostToolUse hooks in `.claude/settings.json` - **PATH wrappers** (Codex, Aider, OpenCode): `~/.ao/bin/gh` and `~/.ao/bin/git` intercept commands. Call `setupPathWrapperWorkspace(workspacePath)` — it installs wrappers to `~/.ao/bin/` and writes session context to `.ao/AGENTS.md` (gitignored, does not modify tracked files). **Environment requirements:** - All agents must set `AO_SESSION_ID` and optionally `AO_ISSUE_ID` - All agents using PATH wrappers must prepend `~/.ao/bin` to PATH - Use `normalizeAgentPermissionMode` from `@aoagents/ao-core` (not a local duplicate) **Activity detection architecture:** `getActivityState` is the most critical method in the agent plugin. The dashboard, lifecycle manager, and stuck-detection all depend on it returning correct states. **Every agent plugin must produce all 6 states over its lifetime:** ``` spawning → active ↔ ready → idle → exited ↘ waiting_input / blocked ↗ ``` | State | Meaning | When | |-------|---------|------| | `active` | Agent is working right now | Activity within last 30s | | `ready` | Agent finished recently, may resume | 30s–5min since last activity | | `idle` | Agent has been quiet for a while | >5min since last activity | | `waiting_input` | Agent is blocked on user approval | Permission prompt visible | | `blocked` | Agent hit an error it can't recover from | Error state detected | | `exited` | Process is dead | `isProcessRunning` returns false | **The `getActivityState` contract — implement exactly this cascade:** ```typescript async getActivityState(session, readyThresholdMs?): Promise { // 1. PROCESS CHECK — always first if (!running) return { state: "exited", timestamp }; // 2. ACTIONABLE STATES — check for waiting_input/blocked // Source: native JSONL (Claude Code, Codex) OR AO activity JSONL (others) // These are the only states checkActivityLogState() surfaces. // If found, return immediately. // 3. NATIVE SIGNAL — agent-specific API for timestamp (preferred) // Source: agent's session list API, native JSONL timestamps, etc. // Classify by age: active (<30s) / ready (30s–threshold) / idle (>threshold) // 4. JSONL ENTRY FALLBACK — always implement this // Source: getActivityFallbackState(activityResult, activeWindowMs, threshold) // Uses the entry's detected state + entry.ts for age-based decay. // Decay only demotes (active→ready→idle), never promotes. // This is the SAFETY NET when the native signal is unavailable. // Without this, getActivityState returns null and the dashboard shows // no activity for the entire session lifetime. // 5. Return null only if there is genuinely no data at all. } ``` **Step 4 is mandatory.** If you skip the JSONL entry fallback, `getActivityState` will return `null` whenever the native API fails (binary not in PATH, API changed, session not found, timeout). The dashboard will show no activity state and stuck-detection breaks. This was a real bug in the OpenCode plugin — `findOpenCodeSession` returned null due to a session creation issue, and without the fallback, the entire active/ready/idle flow was dead. Use `getActivityFallbackState()` from core — it handles age-based decay and staleness caps correctly. **Two activity detection patterns exist:** | Pattern | Used by | How it works | |---------|---------|-------------| | **Native JSONL** | Claude Code, Codex | Agent writes its own JSONL with rich state (`permission_request`, `tool_call`, `error`, etc.). `getActivityState` reads the last entry and maps it to activity states. | | **AO Activity JSONL** | Aider, OpenCode, new agents | Agent implements `recordActivity`. Lifecycle manager calls it each poll cycle with terminal output. It calls `classifyTerminalActivity()` → `appendActivityEntry()` to write to `{workspacePath}/.ao/activity.jsonl`. `getActivityState` reads from this file. | **For agents using AO Activity JSONL (the common case for new plugins):** 1. Implement `recordActivity` — delegate to the shared `recordTerminalActivity()`: ```typescript async recordActivity(session: Session, terminalOutput: string): Promise { if (!session.workspacePath) return; await recordTerminalActivity(session.workspacePath, terminalOutput, (output) => this.detectActivity(output), ); } ``` `recordTerminalActivity` handles classification, deduplication (20s window for non-actionable states), and appending. You don't need to implement dedup yourself. 2. Implement `detectActivity` with patterns specific to the agent's terminal output: ```typescript detectActivity(terminalOutput: string): ActivityState { // Match the ACTUAL prompts/patterns the agent emits. // Test with real terminal output — don't guess patterns. // Return: "idle" | "active" | "waiting_input" | "blocked" } ``` 3. In `getActivityState`, use `checkActivityLogState()` for waiting_input/blocked, then fall back to `getActivityFallbackState()`: ```typescript // checkActivityLogState returns non-null ONLY for waiting_input/blocked. // active/idle/ready intentionally return null — use the fallback for those. const activityResult = await readLastActivityEntry(session.workspacePath); const activityState = checkActivityLogState(activityResult); if (activityState) return activityState; // ... try native signal first (session list API, git commits, etc.) ... // JSONL entry fallback (REQUIRED — do not skip) const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold); const fallback = getActivityFallbackState(activityResult, activeWindowMs, threshold); if (fallback) return fallback; ``` `getActivityFallbackState` uses the entry's detected state with age-based decay (active→ready→idle) and respects the entry state as a ceiling (never promotes idle to active). Stale waiting_input/blocked entries (>5min) decay to idle. **Required tests for `getActivityState` — all agent plugins must have these:** 1. Returns `exited` when process is not running 2. Returns `waiting_input` from JSONL when agent is at a permission prompt 3. Returns `blocked` from JSONL when agent hit an error 4. Returns `active` from native signal when agent was recently active 5. Returns `active` from JSONL entry fallback when native signal fails (fresh entry) 6. Returns `idle` from JSONL entry fallback when native signal fails (old entry with age decay) 7. Returns `null` when both native signal and JSONL are unavailable **`isProcessRunning` must:** - Support tmux runtime (TTY-based `ps` lookup with process name regex) - Support process runtime (PID signal-0 check with EPERM handling) - Match BOTH the node wrapper name AND the actual binary name (some agents install as `.agentname` with a dot prefix — the regex must handle this) - Return `false` (not `null`) on error ## Constraints - C-01: No new UI component libraries - C-02: No inline styles in new/modified code - C-04: Component files max 400 lines - C-05: Dark theme preserved (no redesign) - C-06: Next.js App Router only - C-07: No animation libraries - C-12: Test files for all new components - C-13: pnpm `workspace:*` protocol for cross-package deps - C-14: SSE 5s interval unchanged