* fix(cli): derive projectId from prefixed issue id on spawn When `ao spawn <projectId>/<issue>` is used in a multi-project config, route the spawn to the prefixed project and strip the prefix from the issue id. Previously the projectId fell back to whichever project `ao start` was running for, tagging cross-project sessions with the wrong project (and session prefix). Applies to both `ao spawn` and `ao batch-spawn`; batch-spawn errors out if issues mix multiple project prefixes. Fixes #1329 * refactor(core,cli): lift spawn target resolution into core Move the issue-prefix → project routing from the CLI into a reusable core utility, `resolveSpawnTarget(projects, issueRef, fallback?)`. - Exposes routing to any spawn entry point (CLI, web API, programmatic). - Accepts either a project id or sessionPrefix as the prefix; project id wins on collision. - `ao batch-spawn` now groups issues by resolved project and preflights once per group instead of erroring on mixed prefixes. Tests: - 8 unit tests for `resolveSpawnTarget` in core. - CLI spawn.test.ts: adds a sessionPrefix routing test (23 tests total). Refs #1329 * test(cli): cover batch-spawn grouping; tighten resolveSpawnTarget API Self-review found three gaps: - `resolveSpawnTarget` accepted `undefined` issueRef and returned `{ issueId: "" }` with a fallback project. Dead path — both callers guard against undefined. Drop it and make issueRef required. - No tests for `batch-spawn`. Added two: - routes cross-project prefixed issues to the correct project and lists each project's sessions separately - skips a prefixed issue when the target project already has an active session for it - `spawn` and `batch-spawn` help text didn't document the `<projectId>/<issue>` or `<sessionPrefix>/<issue>` forms. * fix(core): guard resolveSpawnTarget against prototype-key matches Second review pass found a latent bug: Plain JS objects inherit `__proto__`, `constructor`, `toString`, `hasOwnProperty`, etc. from Object.prototype. The previous `if (projects[prefix])` check entered the routing branch for any of these keys, mis-routing a user typing `ao spawn __proto__/42` with `projectId: "__proto__"`. Downstream session-manager would then receive a junk project object (Object.prototype itself) and fail with a non-obvious error. Fix: use `Object.prototype.hasOwnProperty.call(projects, prefix)` so only actual configured project ids match. Also: - Document the case-sensitive matching semantic explicitly. - Lock in the batch-spawn grouping contract by asserting exact `list()` and `spawn()` call counts in the cross-project test. |
||
|---|---|---|
| .. | ||
| __tests__ | ||
| src | ||
| CHANGELOG.md | ||
| README.md | ||
| package.json | ||
| rollup.config.ts | ||
| tsconfig.build.json | ||
| tsconfig.json | ||
| vitest.config.ts | ||
README.md
@aoagents/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 @aoagents/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)
@aoagents/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 "@aoagents/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 @aoagents/ao-core test
# Run in watch mode
pnpm --filter @aoagents/ao-core test -- --watch
# Run specific test
pnpm --filter @aoagents/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 @aoagents/ao-core build
# Typecheck
pnpm --filter @aoagents/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)