* feat: standardize agent plugins with shared hooks, activity JSONL, and CLAUDE.md - Add CLAUDE.md with full project context and agent plugin implementation standards - Extract shared PATH-wrapper metadata hooks into @composio/ao-core (agent-workspace-hooks.ts) - Backfill Aider + OpenCode: setupWorkspaceHooks, postLaunchSetup, getSessionInfo, getRestoreCommand - Add recordActivity method to Agent interface for terminal-derived JSONL writing - Create activity-log.ts in core: appendActivityEntry, readLastActivityEntry - Lifecycle manager calls recordActivity before getActivityState for agents that implement it - Upgrade detectActivity in Aider/OpenCode with real terminal prompt/permission patterns - Upgrade Codex getActivityState to parse JSONL entry types (6 states, up from 2) - Replace duplicated normalizePermissionMode with shared normalizeAgentPermissionMode from core - Remove ~200 lines of duplicated shell wrapper code from Codex plugin - Add git wrapper detection for existing branch switches (parity with Claude Code hooks) - 484 tests passing across all 4 agent plugins * chore: unignore CLAUDE.md and AGENTS.md, slim down AGENTS.md to pointer - Remove CLAUDE.md and AGENTS.md from .gitignore (both should be tracked) - Slim AGENTS.md from 96 to 30 lines — commands, TL;DR, key files - Full context now lives in CLAUDE.md; AGENTS.md points there * fix: eliminate redundant double file read in readLastActivityEntry Remove the readLastJsonlEntry call that was only used as a null-check, then immediately discarded before re-reading the same file from scratch. Now performs a single open + stat + tail-read per poll cycle. * fix: remove duplicate case-insensitive regex in OpenCode detectActivity The \(y\)es.*\(n\)o pattern with /i flag was identical to the preceding \(Y\)es.*\(N\)o/i check — remove the redundant line. * fix: use zero-initialized buffer and slice to bytesRead in readLastActivityEntry Replace Buffer.allocUnsafe with Buffer.alloc and slice the result to actual bytesRead, preventing uninitialized heap data from being parsed if the file shrinks between stat() and read(). * fix: add staleness cap for waiting_input/blocked and deduplicate recordActivity - Add ACTIVITY_INPUT_STALENESS_MS (5 min) cap so stale waiting_input/blocked entries don't keep sessions stuck in needs_input on the dashboard forever. - Extract checkActivityLogState() into core — shared by aider, opencode, codex. - Extract classifyTerminalActivity() into core — deduplicates the identical recordActivity logic across all three plugins. * fix: prioritize native Codex JSONL over AO activity log in getActivityState Reorder detection so Codex's native 6-state JSONL (approval_request, error, tool_call, etc.) is checked first. AO activity JSONL from terminal parsing is now a fallback only for waiting_input/blocked states that the native JSONL may not have captured. Previously the AO log was always fresh (written every poll cycle by recordActivity) and shadowed the richer native detection entirely. * fix: restrict AO activity JSONL to waiting_input/blocked only checkActivityLogState now only returns results for waiting_input/blocked states. Non-critical states (active/ready/idle) return null, forcing callers to fall through to their native signals (git commits, chat history, OpenCode API, Codex native JSONL). This prevents the lifecycle manager's recordActivity writes (which refresh mtime every poll cycle) from shadowing richer detection methods and breaking stuck-detection. * fix: prevent stale idle timestamp in aider and skip flags in git wrapper - Remove Aider's fallback that returned idle with activityResult.modifiedAt (always fresh due to recordActivity writes). Now returns null when no git commits or chat history are found, letting the lifecycle manager handle stuck-detection correctly. - Fix git wrapper catch-all case to skip flag arguments (e.g. -B) and look at $3 for the actual branch name. * fix: add mtime fallback for empty Codex JSONL and remove unused exports - When the native Codex session file exists but readLastJsonlEntry returns null (empty/unparseable), fall back to stat-based mtime detection instead of losing activity detection entirely. - Remove unused exports getActivityLogPath and ACTIVITY_INPUT_STALENESS_MS from @composio/ao-core barrel — they are only used internally. * fix: opencode activity state detection and CLAUDE.md agent plugin standards - Fix session ID capture to handle both session_id (snake_case) and sessionID (camelCase) from OpenCode 1.3.x JSON responses - Replace broken --command true with -- noop for session creation (true is not a valid OpenCode command since 1.3.x) - Add JSONL mtime fallback in getActivityState so active/ready/idle states work even when findOpenCodeSession returns null - Rewrite CLAUDE.md activity detection section with the full getActivityState contract, mandatory JSONL mtime fallback pattern, and 8 required tests every agent plugin must implement * fix: opencode --command true flag and activity JSONL mtime staleness - Fix opencode getLaunchCommand to use `--command true` instead of `-- noop` (aligns with test expectations and opencode CLI docs) - Fix checkActivityLogState to use entry.ts instead of file mtime for staleness checking — recordActivity refreshes mtime every poll cycle, which prevented stale waiting_input/blocked entries from being detected - Fix opencode getActivityState fallback to use entry state directly instead of re-deriving from mtime, which always returned "active" because recordActivity refreshes the file every cycle - Update tests to reflect new entry-state-based fallback behavior * fix: deduplicate recordActivity writes and restore mtime-based fallback recordActivity was writing to the JSONL every poll cycle (~30s), which kept refreshing the file mtime and prevented the JSONL mtime fallback in getActivityState from ever reaching "ready" or "idle". Fix: skip writes when the state hasn't changed and the last entry is <20s old. This keeps mtime fresh during active work (writes every 20-30s, within the 30s activeWindow) but lets it age naturally when the agent goes quiet. Also restores the mtime-based age classification in the JSONL fallback (active/ready/idle by mtime age) instead of returning the entry state directly, which was always "active" since that's what recordActivity writes. Applied to both OpenCode and Aider plugins. Updated CLAUDE.md with the dedup pattern and rationale. * fix: align integration tests with opencode `-- noop` launch command Update 9 test expectations from `--command true` to `-- noop` to match the reverted getLaunchCommand implementation. * fix: add JSONL mtime fallback to Aider getActivityState When git commits and chat history are both unavailable (e.g. early session startup), Aider's getActivityState now falls back to the AO activity JSONL mtime for active/ready/idle classification — matching OpenCode's existing step 3 fallback. Previously it returned null, leaving the dashboard with no activity signal. * fix: add write deduplication to Codex recordActivity Add the same dedup logic that Aider and OpenCode already have — skip writes when the state hasn't changed and the last entry is recent (<20s). Prevents unbounded file growth and stale mtime refreshes. * refactor: extract shared recordTerminalActivity into core Move the duplicated recordActivity logic (classify + dedup + append) from all three plugins into a shared `recordTerminalActivity` function in `@composio/ao-core/activity-log`. Each plugin's `recordActivity` is now a thin wrapper that delegates to the shared function. Add core tests for classifyTerminalActivity, checkActivityLogState, and recordTerminalActivity (10 tests). * fix: validate JSONL entry fields, handle invalid dates, consistent operators - Validate required fields (ts, state, source) before casting parsed JSON to ActivityLogEntry — prevents malformed entries from propagating - Guard against invalid Date parsing in checkActivityLogState — returns null instead of comparing against NaN - Use <= instead of < in Aider chat-history threshold comparisons to match OpenCode and Aider's own JSONL fallback path * fix: use --command true for opencode run, validate sed key parameter - Replace `-- noop` with `--command true` in opencode getLaunchCommand so the bootstrap uses a valid command - Validate metadata key against [a-zA-Z0-9_-]+ in the git wrapper's update_ao_metadata to prevent sed metacharacter injection * fix: extract DEFAULT_ACTIVE_WINDOW_MS constant, clarify Codex recordActivity - Extract magic number 30_000 into DEFAULT_ACTIVE_WINDOW_MS constant in core types, used by Aider and OpenCode for active/ready thresholds - Clarify in CLAUDE.md that Codex implements recordActivity as a safety net for when its native JSONL is missing/unparseable, not redundantly * fix: add JSONL mtime fallback to Codex getActivityState When native Codex session file is missing but AO JSONL has data, derive active/ready/idle from JSONL mtime instead of returning null. Matches the fallback pattern already in Aider (step 4) and OpenCode (step 3). * fix: validate ActivityState and source values when parsing JSONL entries Validate that `state` is one of the known ActivityState values and `source` is "terminal" or "native" before constructing the entry. Construct the entry explicitly instead of using unsafe double cast. * fix: wrap getOutput + recordActivity in try-catch to protect getActivityState If runtime.getOutput() throws (e.g. tmux unresponsive), the error previously propagated to the outer catch, skipping getActivityState entirely. Now the entire recordActivity preamble is wrapped in its own try-catch so getActivityState always runs. * chore: add .ao/ to gitignore * chore: clean up gitignore comment * test: add coverage for activity-log and agent-workspace-hooks - activity-log: test readLastActivityEntry (missing file), invalid entry.ts in checkActivityLogState, blocked state path - agent-workspace-hooks: test buildAgentPath (dedup, defaults, ordering), setupPathWrapperWorkspace (create/skip wrappers, AGENTS.md create/skip) Raises diff coverage from 39% toward 80% threshold. * test: add real file I/O tests for readLastActivityEntry and recordTerminalActivity Test readLastActivityEntry with actual temp files: valid entries, empty file, invalid JSON, invalid state, missing fields, multi-line. Test recordTerminalActivity dedup logic and actionable state bypass. * fix: add activeWindow threshold to Codex native JSONL state detection Action entries (tool_call, user_input, exec_command) now use the 30s active window: <=30s is "active", 30s-5min is "ready", >5min is "idle". Previously these skipped "ready" entirely, going straight from "active" to "idle" at the 5min threshold. * fix: add activeWindow threshold to Claude Code native JSONL state detection Same fix as Codex — action entries (user, tool_use, progress) now use the 30s active window for consistent 3-state classification across all agent plugins: <=30s active, 30s-5min ready, >5min idle. * fix: handle truncated JSONL, add exited state, fix session lookup and dedup window - readLastActivityEntry: increase tail buffer to 4KB, skip truncated first line when reading from offset, try lines from end on parse error - Add "exited" to valid ActivityState set in JSONL validation - findOpenCodeSession: pick most recently updated session when multiple title matches exist, preventing stale session binding - Increase dedup window from 20s to 60s so mtime can age past the 30s active window between writes, allowing "ready" state to be reached * fix: use entry state for JSONL fallback, write AGENTS.md to .ao/, revert dedup to 20s - Replace mtime-based active/ready/idle derivation in all 3 plugin fallbacks with direct entry.state + entry.ts usage. This eliminates the fundamental conflict between write deduplication and mtime freshness — the entry already has the correct detected state. - Revert dedup window to 20s (purely I/O optimization, no longer affects state detection since mtime is not used) - Write AO session context to .ao/AGENTS.md (gitignored) instead of modifying the repo-tracked AGENTS.md, preventing dirty worktree state * fix: reorder Codex fallback chain so AO JSONL is checked before stat mtime When native JSONL exists but can't be parsed, the stat() fallback previously returned early, skipping AO JSONL waiting_input/blocked detection and the ready state. Now it falls through to AO JSONL first, then uses stat mtime as a last resort with proper 3-state classification. * fix: re-validate canonicalized ao_dir against trusted roots After resolving symlinks with pwd -P, re-check real_ao_dir against the trusted root allowlist. Prevents paths like /tmp/../../home/user from passing the pre-canonicalization check then escaping to arbitrary directories after symlink resolution. * fix: update tests for .ao/AGENTS.md location and remove unused vi import - Update Codex setupWorkspaceHooks tests to expect .ao/AGENTS.md instead of workspace root AGENTS.md - Remove unused vi import from activity-log test (lint error) * fix: add age-based decay to JSONL entry fallback via getActivityFallbackState Extract getActivityFallbackState in core — reclassifies entry state based on entry.ts age (active→ready→idle) so old entries don't stay as "active" forever when recordActivity stops being called. All three plugins now use this shared helper for their JSONL fallback paths. * fix: apply staleness cap to actionable states in getActivityFallbackState Stale waiting_input/blocked entries (older than ACTIVITY_INPUT_STALENESS_MS) are now treated as idle in the fallback, preventing them from bypassing the staleness filtering in checkActivityLogState. * fix: respect entry state as ceiling in getActivityFallbackState Age-based decay can only demote (active→ready→idle), never promote. A fresh "idle" entry stays "idle" instead of being reclassified as "active" — the detected state from terminal output is authoritative. * docs: update CLAUDE.md to match current activity detection implementation - Replace inline recordActivity dedup example with recordTerminalActivity delegation - Replace mtime-based fallback example with getActivityFallbackState - Update step 4 description: entry state + age-based decay, not mtime - Add new core exports to utilities section - Document .ao/AGENTS.md location and setupPathWrapperWorkspace - Update required test list for entry-based fallback * fix: skip metadata helper rewrite when version marker matches Move ao-metadata-helper.sh write inside the needsUpdate check so it's only rewritten when wrapper scripts are outdated, not on every call. * fix: update Codex test for metadata helper skip when version matches Metadata helper is now inside the needsUpdate check, so when the version marker matches, no wrapper writes occur (including helper). |
||
|---|---|---|
| .. | ||
| __tests__ | ||
| src | ||
| CHANGELOG.md | ||
| README.md | ||
| package.json | ||
| tsconfig.build.json | ||
| tsconfig.json | ||
| vitest.config.ts | ||
README.md
@composio/ao-core
Core services, types, and configuration for the Agent Orchestrator system.
What's Here
src/types.ts— All TypeScript interfaces (Runtime, Agent, Workspace, Tracker, SCM, Notifier, Terminal, Session, events)src/services/— Core services (SessionManager, LifecycleManager, PluginRegistry)src/config.ts— Configuration loading + Zod schemassrc/utils/— Shared utilities (shell escaping, metadata parsing, etc.)
Key Files
src/types.ts — The Source of Truth
Every interface the system uses is defined here. If you're working on any part of the orchestrator, start by reading this file.
Main interfaces:
Runtime— where sessions execute (tmux, docker, k8s)Agent— AI coding tool adapter (claude-code, codex, aider)Workspace— code isolation (worktree, clone)Tracker— issue tracking (GitHub Issues, Linear)SCM— PR/CI/reviews (GitHub, GitLab)Notifier— push notifications (desktop, Slack, webhook)Terminal— human interaction UI (iTerm2, web)Session— running agent instance (state, metadata, handles)OrchestratorEvent— events emitted by lifecycle managerPluginModule— what every plugin exports
src/services/session-manager.ts — Session CRUD
Handles session lifecycle:
spawn(config)— create new session (workspace + runtime + agent)list(projectId?)— list all sessionsget(sessionId)— get session detailskill(sessionId)— terminate sessioncleanup(projectId?)— kill completed/merged sessionssend(sessionId, message)— send message to agent
Data flow in spawn():
- Load project config
- Validate issue exists via
Tracker.getIssue()(if issueId provided, fails-fast if not found) - Reserve session ID
- Determine branch name
- Create workspace via
Workspace.create() - Generate prompt via
Tracker.generatePrompt() - Build launch command via
Agent.getLaunchCommand() - Create runtime session via
Runtime.create() - Run
Agent.postLaunchSetup()(optional) - Write metadata file
- Return Session object
Note: If issue validation fails (not found, auth error), spawn fails before creating any resources (no workspace, no runtime, no session ID). This prevents spawning sessions with broken issue references.
src/services/lifecycle-manager.ts — State Machine + Reactions
Polls sessions, detects state changes, triggers reactions:
State machine:
spawning → working → pr_open → ci_failed/review_pending/approved → mergeable → merged
Reactions:
ci-failed→ send fix prompt to agentchanges-requested→ send review comments to agentapproved-and-green→ notify human (or auto-merge)agent-stuck→ notify human
Polling loop:
- For each session: check agent activity state (
Agent.getActivityState()) - If PR exists: check CI status (
SCM.getCISummary()), review state (SCM.getReviewDecision()) - Update session status based on state
- Trigger reactions if state changed
- Emit events
src/services/plugin-registry.ts — Plugin Discovery + Loading
Loads plugins and provides access to them:
register(plugin, config?)— register a plugin instanceget<T>(slot, name)— get plugin by slot + namelist(slot)— list all plugins for a slotloadBuiltins(config?)— load built-in plugins (runtime-tmux, agent-claude-code, etc.)loadFromConfig(config)— load built-ins today; external plugin descriptors are the marketplace extension point
Built-in plugins (loaded by default):
- runtime-tmux, runtime-process
- agent-claude-code, agent-codex, agent-aider, agent-opencode
- workspace-worktree, workspace-clone
- tracker-github, tracker-linear, tracker-gitlab
- scm-github, scm-gitlab
- notifier-desktop, notifier-discord, notifier-slack, notifier-composio, notifier-openclaw, notifier-webhook
- terminal-iterm2, terminal-web
src/config.ts — Configuration Loading
Loads and validates agent-orchestrator.yaml:
Main config sections:
- Runtime data paths are auto-derived from the config location under
~/.agent-orchestrator/{hash}-{projectId}/ port— web dashboard port (default 3000, set different values for multiple projects)terminalPort— terminal WebSocket port (auto-detected if not set)directTerminalPort— direct terminal WebSocket port (auto-detected if not set)defaults— default plugins (runtime, agent, workspace, notifiers)plugins— installer-managed external plugin descriptors (registry, npm, or local)projects— per-project config (repo, path, branch, symlinks, reactions, agentRules)notifiers— notification channel config (Slack webhooks, etc.)notificationRouting— which notifiers get which priority eventsreactions— auto-response config (ci-failed, changes-requested, approved-and-green, etc.)
Zod schemas validate all config at load time.
Common Tasks
Adding a Field to Session
- Edit
src/types.ts→Sessioninterface - Edit
src/services/session-manager.ts→ initialize field inspawn() - Rebuild:
pnpm --filter @composio/ao-core build
Adding an Event Type
- Edit
src/types.ts→EventTypeunion - Emit the event:
eventEmitter.emit()in relevant service - Add reaction handler (optional):
src/services/lifecycle-manager.ts
Adding a Reaction
- Edit
src/services/lifecycle-manager.ts→ add handler function - Wire it up in the polling loop
- Add config schema in
src/config.tsif new reaction type
Feedback Tools (v1)
@composio/ao-core exports two structured feedback tool contracts:
bug_reportimprovement_suggestion
Both share the same required input fields:
titlebodyevidence(array of strings)sessionsourceconfidence(0..1)
Example:
import { FEEDBACK_TOOL_NAMES, FeedbackReportStore, getFeedbackReportsDir } from "@composio/ao-core";
const reportsDir = getFeedbackReportsDir(configPath, projectPath);
const store = new FeedbackReportStore(reportsDir);
const saved = store.persist(FEEDBACK_TOOL_NAMES.BUG_REPORT, {
title: "SSO login loop",
body: "Google SSO redirects back to /login repeatedly.",
evidence: ["trace_id=abc123", "screenshot: login-loop.png"],
session: "ao-22",
source: "agent",
confidence: 0.84,
});
Storage format:
- Reports are persisted under
~/.agent-orchestrator/{hash}-{projectId}/feedback-reports - Each report is a typed key=value file (
report_<timestamp>_<id>.kv) for easy inspection - A deterministic dedupe key (
sha256, 16 hex chars) is generated from normalized tool+content
Migration notes:
- No migration needed for existing AO installs
- The
feedback-reportsdirectory is created lazily on first persisted report
Testing
# Run all core tests
pnpm --filter @composio/ao-core test
# Run in watch mode
pnpm --filter @composio/ao-core test -- --watch
# Run specific test
pnpm --filter @composio/ao-core test -- session-manager.test.ts
Tests are in src/__tests__/:
session-manager.test.ts— session CRUD, spawn, cleanuplifecycle-manager.test.ts— state machine, reactionsplugin-registry.test.ts— plugin loading, resolutiontmux.test.ts— tmux utility functions (not a plugin test)prompt-builder.test.ts— prompt generation utilities
Building
# Build core
pnpm --filter @composio/ao-core build
# Typecheck
pnpm --filter @composio/ao-core typecheck
This package is a dependency of all other packages. Build it first if working on the codebase.
Architecture Notes
Why flat metadata files?
- Debuggability:
cat ~/.agent-orchestrator/<hash>-my-app/sessions/app-3shows full state - No database dependency (survives crashes, easy to inspect)
- Backwards-compatible with bash script orchestrator
Why polling instead of webhooks?
- Simpler (no webhook setup, no ngrok for local dev)
- Works offline (CI/review state is fetched, not pushed)
- Survives orchestrator restarts (no missed events)
Why plugin slots?
- Swappability: use tmux locally, docker in CI, k8s in prod
- Testability: mock plugins for tests
- Extensibility: users can add custom plugins (e.g., company-specific notifier)