Skip to content

Latest commit

 

History

History
644 lines (493 loc) · 32.5 KB

File metadata and controls

644 lines (493 loc) · 32.5 KB

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

# 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 <project>: 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 importsimport type { Foo } enforced by ESLint
  • Immutable patterns — spread operator, never mutate in place
  • Prefer constno-var, prefer-const
  • No evalno-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 Triage a bug report — investigate, search duplicates, file GitHub issues, push fix PRs
skills/agent-orchestrator/SKILL.md Architecture and conventions for working on this codebase
skills/release-notes/ao-weekly-release/SKILL.md Generate weekly release notes from git history
skills/social-media/SKILL.md Social media post generation

See 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<T>:

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<string, unknown>): 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<Runtime>;

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<T> (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

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:

async getActivityState(session, readyThresholdMs?): Promise<ActivityDetection | null> {
  // 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():
async recordActivity(session: Session, terminalOutput: string): Promise<void> {
  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.

  1. Implement detectActivity with patterns specific to the agent's terminal output:
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"
}
  1. In getActivityState, use checkActivityLogState() for waiting_input/blocked, then fall back to getActivityFallbackState():
// 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