* feat(release): weekly release train — channels, onboarding, dashboard banner, cron Implements the full release pipeline described in release-process.html (supersedes #1525, which only had the workflow scaffolding). A. Release infrastructure — .github/workflows/canary.yml triggers on a cron ('30 17 * * 5,6,0,1,2', i.e. 23:00 IST Fri–Tue) plus workflow_dispatch, without the stale-SHA guard or the merged-PR-comment step from #1525 (cron has no merged-PR context). release.yml uses changesets/action. .changeset/config.json adds the snapshot template and moves the private @aoagents/ao-web to ignore[]. B. Channel awareness (packages/cli/src/lib/update-check.ts) — new updateChannel field in the global-config Zod schema (stable | nightly | manual; defaults to manual so existing users see no surprise installs). fetchLatestVersion now reads dist-tags[channel] from the registry; isVersionOutdated compares prerelease segments numerically + lexically so SHA-suffixed nightlies sort correctly. maybeShowUpdateNotice and scheduleBackgroundRefresh skip entirely on manual. C. Active-session guard (packages/cli/src/commands/update.ts) — before any handle*Update proceeds, sm.list() filters for working/idle/ needs_input/stuck and refuses with `N session(s) active. Run `ao stop` first.` instead of auto-stopping (per the design doc: surprise-killing user work is worse than refusing). D. Soft auto-install + onboarding — handleNpmUpdate skips the confirm prompt on stable/nightly. New packages/cli/src/lib/update-channel- onboarding.ts prompts once on the first `ao start` after this lands; ask-once gate keyed on the absence of updateChannel in the global config; dismissal persists `manual`. New `ao config set updateChannel <value>` command (also handles installMethod). E. Dashboard banner — packages/web/src/app/api/version/route.ts reads the same cache file the CLI writes (~/.cache/ao/update-check.json, XDG-aware) and rejects cache entries from a different channel. packages/web/src/app/api/update/route.ts duplicates the active-session guard so the dashboard can return a structured 409. New UpdateBanner component wired into Dashboard.tsx — Tailwind only, var(--color-*) tokens, dismissible per-version via localStorage, deferred fetch so it doesn't shift the call order in existing dashboard tests. F. Bun + Homebrew detection (update-check.ts) — new classifiers for ~/.bun/install/global/ (auto-installs `bun add -g @aoagents/ao@<channel>`) and /Cellar/ao/ (notice-only — `brew upgrade ao`, never auto-install because brew owns the symlinks). New installMethod override field in the global config to pin detection when path heuristics fail. Tests: +155 (B/C/F unit, onboarding ask-once gate, /api/version + /api/update, UpdateBanner visibility/dismiss/click). pnpm test, pnpm typecheck, pnpm lint all green for the changes; the same 10 pre-existing test failures observed on main are still present (all environment-dependent: ~/.cache/ao state, codex binary install, /private path canonicalization on macOS). Closes #1525 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(release-train): CI failures + Greptile review feedback CI fixes: - Web /api/update spawn ENOENT — attach `child.on("error", ...)` so the asynchronous spawn-error event from a missing `ao` binary doesn't bubble up as an unhandled error and crash vitest. The route already returns 202 before the error fires; on real installs the user sees "no version change" if the install fails. - start.test.ts pollution — runStartup calls `maybePromptForUpdateChannel`, which (with isHumanCaller mocked to true) writes to the real ~/.agent-orchestrator/config.yaml on the CI runner via persistUpdateChannel. Subsequent tests then load that newly-created (empty-projects) config and report "No projects configured" instead of the expected "project not found". Fix: stub `update-channel-onboarding.js` in start.test.ts so runStartup is a no-op for the channel prompt. Review feedback: - (P1) `runtime: "tmux"` hardcoded default in `persistUpdateChannel` and `loadOrInit` would lock Windows users into a non-functional tmux config when they dismiss the channel prompt. Both now use `getDefaultRuntime()`, matching `makeEmptyGlobalConfig` in core's global-config.ts. - (P2) `hasChosenUpdateChannel` JSDoc inverted — the second "True when" bullet actually described the False case. Rewritten with separate True/False sections that match the implementation. - (P2) `isVersionOutdated` was duplicated between the CLI and the dashboard /api/version route. Moved to a new shared module `packages/core/src/version-compare.ts`, exported from `@aoagents/ao-core`, consumed by both CLI (re-exports as `isVersionOutdated`) and the web route directly. Added 14 unit tests in core for the canonical implementation. Defensive: `maybePromptForUpdateChannel` now validates the prompt result via `UpdateChannelSchema.safeParse` before persisting — never writes `undefined` or an unrecognized string to disk. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(release-train): Windows spawn + dismiss-while-blocked review feedback - (P1) `ao update` silently never ran on Windows because `spawn("ao", ...)` doesn't consult PATHEXT, so npm's `ao.cmd` shim wasn't found and the async ENOENT was swallowed by the error handler. Add `shell: isWindows()` + `windowsHide: true` per the cross-platform guide. - (P1) Dismiss button was inert when the banner was in the `blocked` (409) or `error` phase — `setDismissedFor` set the localStorage flag but the hide condition required `phase === "idle"`, so the banner stayed pinned until reload. `handleDismiss` now resets phase to idle (and clears the error message) so the existing condition fires. Added a regression test covering dismiss from the 409 path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(release-train): runNpmInstall on Windows — shell:true so PATHEXT resolves npm.cmd (P1) The dashboard /api/update spawn got `shell: isWindows()` + `windowsHide: true` in 9f29131d, but `runNpmInstall` in the CLI's `ao update` command was still missing the same fix. On Windows, `spawn("npm", ...)` without a shell wrapper doesn't consult PATHEXT, so npm/pnpm/bun's `*.cmd` shims never resolve and the install silently ENOENTs. Mirror the fix into runNpmInstall — it's the single spawn site behind every non-git, non-homebrew install path (npm-global, pnpm-global, bun-global, unknown), so this one change covers all four install methods. Tests: - Mock `isWindows` from @aoagents/ao-core so the spawn options can be inspected per-platform. - Assert `shell: true, windowsHide: true, stdio: "inherit"` on Windows. - Assert `shell: false` on macOS / Linux. - Parametrize over pnpm-global / bun-global to confirm the same options flow through every npm-style install command. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(release-train): /api/version reads cached.isOutdated for git installs (P1) The dashboard banner never appeared for git-installed users because `/api/version` ran `isVersionOutdated(current, "origin/main")`, and `parseVersion("origin/main")` produces NaN parts that the early-exit guard catches with `return false`. Git installs cache `latestVersion` as a git ref (not a semver) and a precomputed `isOutdated` flag from `git fetch + merge-base`; the CLI special-cases this in `update-check.ts`. Mirror the same pattern here: cached.installMethod === "git" ? cached.isOutdated === true : isVersionOutdated(current, latest) Also extend the local CacheData with `installMethod?: string` and `isOutdated?: boolean` so the new branch type-checks. Kept as `string` rather than importing the CLI's `InstallMethod` type — the literal "git" compare is the only thing that matters here, and the web package shouldn't take a dep on @aoagents/ao-cli. Two new tests cover the git-install path: one asserts isOutdated=true is trusted from the cache, the other asserts isOutdated=false (current with origin) is trusted too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(release-train): must-fix #3+#4 — global-config layout + git-only flag guard #3 — ensureNoActiveSessions now consults loadGlobalConfig() first as a quick "any projects registered?" check, then routes through loadConfig(globalPath) only when the registry actually has projects (loadConfig dispatches to buildEffectiveConfigFromGlobalConfigPath when given the canonical global path — see packages/core/src/config.ts). Defends against AO_GLOBAL_CONFIG override to a non-canonical path. Three new tests cover: registered-projects path fires the guard correctly; empty registry returns early without building a SessionManager; missing global file returns early without even reading it. #4 — Restored the rejection of git-only flags on non-git installs. Users copy/pasting `ao update --skip-smoke` from older docs would silently no-op on npm/pnpm/bun installs. Now exits non-zero with: "--skip-smoke only applies to git installs (current install: npm-global)." Test it.each across npm/pnpm/bun/homebrew/unknown plus a positive test that git installs still accept the flag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(release-train): #2 should-fix — channel-switch prompt When a stable user runs `ao config set updateChannel nightly` and then `ao update`, isVersionOutdated(0.5.0, 0.5.0-nightly-abc) returns false (per semver, prerelease < stable on equal base). The old code printed "Already on latest nightly" and exited without installing — confusing, because the install command we'd run is genuinely a different dist-tag. Fix: snapshot the previously-cached channel BEFORE forcing a refresh, then detect a switch via `previousChannel !== activeChannel && !info.isOutdated`. On switch: - Don't take the "already on latest" early-return. - Print a yellow "Channel switch detected: was X, now Y." notice. - Force a confirm prompt regardless of stable/nightly soft-install, defaulting to "no" (channel-switch should be explicit). Manual users still see their normal prompt. Onboarding copy now includes one line about channel switches: "switching later prompts before installing the other channel's build." 4 new tests: explicit switch fires the prompt + installs on yes; declines on no; same-channel doesn't fire (back to "Already on latest"); first-ever update with no previous cache doesn't fire either. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(release-train): polish — drop setTimeout, dedup defaults, share cache, dedup export #5 — UpdateBanner no longer wraps its mount fetch in setTimeout(0). Production code shouldn't bend to test mock ordering. Instead, the two brittle Dashboard tests that relied on `mockImplementationOnce` queue ordering now route by URL via `mockImplementation`, and the cadence test asserts "no other endpoints were touched" instead of "no fetch was touched at all". Also added a deliberate "no interval / re-fetch" comment per #6. #7 — Promoted core's `makeEmptyGlobalConfig` to the public `createDefaultGlobalConfig` (kept the internal alias for back-compat). Both the CLI's `persistUpdateChannel` and `loadOrInit` (in `ao config`) now call it instead of inlining the same defaults block. Single source of truth. #8 — New `packages/core/src/update-cache.ts` exports `getUpdateCheckCachePath`, `readUpdateCheckCacheRaw`, and `getInstalledAoVersion`. The CLI's `update-check.ts` keeps its richer install-method/channel/git-rev validation but now delegates path resolution and version lookup to core. The dashboard's `/api/version` route drops its duplicated `getCachePath`/`readCache`/`getCurrentVersion` and consumes from core directly. Cache layout is one file, not two. #9 — Removed the duplicate `export { isManualOnlyInstall }` from `update.ts` (also dropped the unused import). The canonical export lives in `update-check.ts`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(release-train): cosmetic — workflow rename note, design tokens, changeset trim #1 release.yml: added a comment above `workflows: [CI]` warning that GitHub matches by name (not filename) and silently no-ops on mismatch — so a rename of ci.yml's `name:` field would mean releases stop triggering. #10 UpdateBanner: replaced text-[13px] / text-[12px] with text-sm / text-xs to match the dashboard's chrome scale. #6 Banner refresh: noted in the existing useEffect comment that we don't re-fetch — re-evaluate if "user kept tab open for days, missed an update" becomes a real complaint. #11 .changeset/release-train.md: dropped @aoagents/ao-web from the version bump list. The package is `private: true` and in changeset's ignore[], so listing it was cosmetic and would just clutter the eventual release notes with a non-published artifact. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(release-train): illegalcall review — global guard, channel scoping, publishable web (#1, PRRT_kwDORPZUAc6BHFDf) — `ensureNoActiveSessions` now ALWAYS loads from the canonical global config, never from project-local. The previous code preferred `loadConfig()` (local search-upward) when run inside a repo, which made `sm.list()` enumerate only that project's sessions — active work in other registered projects would be missed and the install would proceed. New regression test asserts that a session in `other-project` blocks the update even when invoked from `this-project`'s cwd. Existing global-config tests retained. (#2, PRRT_kwDORPZUAc6BHFOl) + (#4, PRRT_kwDORPZUAc6BHFon) — Reverted the `private: true` on @aoagents/ao-web. Because @aoagents/ao-cli has a workspace:* runtime dep on it (for `findWebDir()`/dashboard files), pnpm rewrites the dep on publish to a literal version — keeping ao-web private would make `npm install -g @aoagents/ao` fail. Restored ao-web to the changeset linked group, removed it from `ignore[]`, restored the release- train changeset entry, added publishConfig + repository metadata. New `scripts/check-publishable-deps.mjs` walks every package and asserts that no publishable package has a workspace:* runtime dep on a `private: true` package. Wired into both release.yml and canary.yml before the publish step so any future regression is caught at CI rather than at the user's `npm install`. Verified the script catches the inverse condition. (#3, PRRT_kwDORPZUAc6BHFaV) — `readCachedUpdateInfo` now treats a missing `data.channel` as a miss when an explicit channel is provided. Previous logic only rejected when both data.channel and channel were set AND differed, so a legacy cache entry (pre-channel-scoping) could keep returning stale stable state to a user who had since switched to nightly until the 24h TTL expired. Existing fixtures bumped to include `channel` where the test exercises the checkForUpdate / maybeShowUpdateNotice path; new regression test exercises the legacy-no-channel case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(release-train): SHA-suffix nightly compare — never miss a banner (P1, PRRT_kwDORPZUAc6BJLrW) Git SHAs are uniformly-random hex, so the old `comparePrereleaseSegments` lexical fallback gave the wrong answer ~50% of the time on snapshot tags. Concretely: user installs `0.5.0-nightly-f00d123`, CI publishes `0.5.0-nightly-0dead01`, and `'f' < '0'` returns false → banner never shows. Fix: when two prerelease segments are both non-numeric and differ, treat the left side as older (return -1). The cache layer always carries the registry's CURRENT dist-tag, so any non-numeric mismatch on the same base means the installed copy is behind by construction. Numeric ordering (`rc.1 < rc.2`) and numeric-vs-non-numeric (`0.5.0-1 < 0.5.0-alpha`) are unchanged. Tradeoff: a user who manually installed `0.5.0-beta` while the registry only publishes `0.5.0-alpha` would see a spurious banner. AO's release pipeline only emits SHA-suffixed nightly prereleases, so the scenario doesn't occur in practice — documented in the function's JSDoc. Updated two misleadingly-named tests ("orders SHA-suffixed nightlies lexically") that had been asserting the buggy behavior; new tests cover the specific case from the review (`nightly-f00d123` vs `nightly-0dead01`) and preserve the numeric-ordering invariant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(release-train): multi-project active-session proof + changeset note (#1, PRRT_kwDORPZUAc6BHFDf follow-up) Dhruv asked for proof — not a comment — that loadConfig(globalPath) actually enumerates across all registered projects, not just the cwd's. New test in update.test.ts seeds proj-a and proj-b in the global config, places one active session in each (one "working", one "needs_input"), and asserts the refusal stderr lists BOTH session ids AND the total count says "2 sessions active". The test is specifically named so it shows up in `vitest run -t "Dhruv proof"`. Verified `pnpm changeset version` locally — @aoagents/ao-web, ao-cli, and ao all bump to 0.7.0 together via the linked group, confirming the install-404 class of bug is gone. Also updated the release-train changeset to drop the stale "moves the private @aoagents/ao-web to ignore" line — that contradicts the current state (ao-web is publishable and in the linked group). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(release-train): Ashish P1+P2 — dashboard banner now actually installs P1 — Dashboard banner click was a no-op for npm users. POST /api/update spawns `ao update` with `stdio: "ignore"`, which makes `isTTY()` return false in the child. The old handleNpmUpdate hit the non-TTY branch ("Run: ...") and exited without installing. Banner returned 202 "started"; nothing actually happened. Fix (Ashish's option c, with an env-var bridge): - /api/update spawns with `AO_NON_INTERACTIVE_INSTALL=1` on the env. - handleNpmUpdate computes `interactive = isTTY() && !isApiInvoked()`. - Restructured so the early-return only fires for non-TTY + non-API (piped output): we still print "Run: ..." for that case, matching the old contract. API-invoked path now actually runs runNpmInstall, skipping the confirm prompt (would hang the detached child forever). Three new CLI tests: - AO_NON_INTERACTIVE_INSTALL=1 → spawn invoked even with isTTY=false. - The piped-output case (no env var, no TTY) still prints "Run: ...". - Active-session guard still fires in the API-invoked path (defense in depth — the route's own guard isn't single point of trust). P2 — First nightly opt-in stuck on "Already on latest stable". Repro: user on stable 0.5.0, runs `ao config set updateChannel nightly`, runs `ao update`. previousChannel was undefined, isOutdated was false (semver: prerelease < stable on equal base), so the early return fired and the install never ran. Fix: new `isFirstChannelOptIn` branch — `previousChannel === undefined && info.currentVersion !== info.latestVersion && !info.isOutdated`. Force the same prompt path the channel-switch case uses (default=no, explicit consent). Confirmed install path covered by a new test that mirrors the repro exactly. The pre-existing "no previous cache → no prompt" test asserted the OLD buggy behavior; rewritten to assert the canonical case (no prior cache AND versions match → still "Already on latest", no prompt). P2 — Dashboard /api/version legacy cache. Same class as Dhruv #3, this time on the web side. Old code: const cacheMatchesChannel = !cache?.channel || cache.channel === channel; A legacy entry without `channel` would short-circuit `!cache?.channel` and serve stale latestVersion. Fixed to require `cache.channel === channel` explicitly. New regression test seeds a no-channel entry and asserts { latest: null, isOutdated: false, checkedAt: null }. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(release-train): Dhruv edge-case — running.json is the live source of truth (PRRT_kwDORPZUAc6BUIUK) The active-session guard previously short-circuited on empty global config, which missed the case where: - User runs `ao start` from a repo with a local agent-orchestrator.yaml and no global registration. - running.json lists that project as currently being polled. - Sessions live on disk under ~/.agent-orchestrator/{hash}-{projectId}/. In that state, `loadGlobalConfig().projects` is empty so the old early return fired and `ao update` would proceed while a daemon was actively supervising the user's in-flight work. Fix: consult `getRunning()` BEFORE falling back to the global registry. When running.json reports projects, trust its configPath (could be a local project yaml OR the canonical global path — `loadConfig` dispatches on shape) and build the SessionManager from there. The global fallback is now the no-daemon-running case, where on-disk sessions get reconciled by SessionManager enrichment. Three new tests in update.test.ts: - `refuses when sessions exist in a locally-registered project not in global config (Dhruv edge-case)` — seeds running.json with a local-only project + working session, asserts refusal + that loadConfig was called with running.configPath (NOT the global path). - `returns true (allows update) when running.json is gone and global is empty` — covers the genuinely-safe case. - `trusts running.json over an inconsistent global config` — when both signals exist, the live one (running.json) wins and loadGlobalConfig is never consulted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(release-train): shift canary cron 23:00 → 23:30 IST Schedule moves to 23:30 IST = 18:00 UTC. Cron expression changes from `30 17 * * 5,6,0,1,2` to `0 18 * * 5,6,0,1,2`. Same DOW window (Fri,Sat,Sun,Mon,Tue) so the bake window (Wed–Thu) is unaffected. Files touched (all consistent): - .github/workflows/canary.yml — cron expression + comment block - .changeset/release-train.md — schedule string in feature description - CONTRIBUTING.md — "Testing your changes" callout Verified `grep -rn '23:00 IST|17:30 UTC|"30 17'` returns zero matches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| __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 on Unix,process/ ConPTY via node-pty on Windows, 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 layered worker prompt via
buildPrompt()intosystemPrompt+taskPrompt - Persist
systemPromptFilefor the session and, for OpenCode workers, writeOPENCODE_CONFIG - 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.
Worker sessions keep persistent instructions in the prompt file. OpenCode workers consume that file through OPENCODE_CONFIG, while OpenCode orchestrators continue to project their system prompt into workspace AGENTS.md.
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-cursor, agent-kimicode, 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 on Linux/macOS,
process(ConPTY) on Windows, docker in CI, k8s in prod — same agent/workspace stack across all of them - Testability: mock plugins for tests
- Extensibility: users can add custom plugins (e.g., company-specific notifier)