Simplify onboarding: absorb init + add-project into ao start

Reduces onboarding to `npm install -g @composio/ao && ao start`.

- Absorb init and add-project logic into `ao start` with auto-config
  creation, environment detection, and project type detection
- Add single-instance tracking via running.json with already-running
  interactive menu (human) and info+exit (agent)
- Add caller context detection (human/orchestrator/agent) via TTY and
  AO_CALLER_TYPE env var
- Add plugin-based agent runtime detection — no hardcoded binary paths,
  each plugin exports detect() and displayName
- Simplify `ao spawn` to just `ao spawn <issue>` with auto-detected
  project (backward compat: `ao spawn <project> <issue>` still works)
- Set AO_CALLER_TYPE, AO_PROJECT_ID, AO_CONFIG_PATH, AO_PORT env vars
  on all spawned sessions (worker, orchestrator, restore)
- Add `ao config-help` subcommand for config schema reference
- Deprecate `ao init` to thin wrapper, fully remove `ao add-project`
- Register/unregister in running.json on start/stop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
suraj-markup 2026-03-17 01:17:33 +05:30
parent 0dfc75f592
commit f3f81dad84
18 changed files with 1008 additions and 654 deletions

View File

@ -1,148 +0,0 @@
import { readFileSync, writeFileSync } from "node:fs";
import { resolve, basename } from "node:path";
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
import chalk from "chalk";
import type { Command } from "commander";
import { generateSessionPrefix, findConfigFile } from "@composio/ao-core";
import { git, execSilent } from "../lib/shell.js";
import { detectDefaultBranch } from "../lib/git-utils.js";
import {
detectProjectType,
generateRulesFromTemplates,
formatProjectTypeForDisplay,
} from "../lib/project-detection.js";
export function registerAddProject(program: Command): void {
program
.command("add-project")
.description("Add a project to the existing agent-orchestrator.yaml config")
.argument("<path>", "Path to the project repository")
.option("--no-rules", "Skip generating agent rules")
.option("-c, --config <path>", "Path to agent-orchestrator.yaml (auto-detected if omitted)")
.action(async (projectPath: string, opts: { rules: boolean; config?: string }) => {
// Resolve the project path
const resolvedPath = resolve(projectPath.replace(/^~/, process.env.HOME || ""));
// Find existing config
const configPath = opts.config ? resolve(opts.config) : findConfigFile();
if (!configPath) {
console.error(chalk.red("No agent-orchestrator.yaml found."));
console.log(chalk.dim("Run `ao init` first to create a config, then add projects to it."));
process.exit(1);
}
console.log(chalk.bold.cyan("\n Agent Orchestrator — Add Project\n"));
// Load and parse existing config
const rawYaml = readFileSync(configPath, "utf-8");
const config = yamlParse(rawYaml);
if (!config.projects) {
config.projects = {};
}
// Derive project ID from directory name
const projectId = basename(resolvedPath);
// Check if project already exists
if (config.projects[projectId]) {
console.error(chalk.red(`Project "${projectId}" already exists in ${configPath}`));
process.exit(1);
}
// Detect git info
console.log(chalk.dim(" Detecting project info...\n"));
const isGitRepo = (await git(["rev-parse", "--git-dir"], resolvedPath)) !== null;
if (!isGitRepo) {
console.error(chalk.red(`"${resolvedPath}" is not a git repository.`));
process.exit(1);
}
// Get remote
let ownerRepo: string | null = null;
const gitRemote = await git(["remote", "get-url", "origin"], resolvedPath);
if (gitRemote) {
const match = gitRemote.match(/github\.com[:/]([^/]+\/[^/]+?)(\.git)?$/);
if (match) ownerRepo = match[1];
}
// Detect default branch
const defaultBranch = await detectDefaultBranch(resolvedPath, ownerRepo);
// Generate session prefix and ensure uniqueness
let prefix = generateSessionPrefix(projectId);
const existingPrefixes = new Set(
Object.values(config.projects as Record<string, Record<string, unknown>>).map(
(p) =>
(p.sessionPrefix as string) || generateSessionPrefix(basename(p.path as string)),
),
);
if (existingPrefixes.has(prefix)) {
// Append a number to make it unique
let i = 2;
while (existingPrefixes.has(`${prefix}${i}`)) i++;
prefix = `${prefix}${i}`;
}
// Detect project type and generate rules
const projectType = detectProjectType(resolvedPath);
// Show what was detected
console.log(chalk.green(` ✓ Git repository`));
if (ownerRepo) {
console.log(chalk.dim(` Remote: ${ownerRepo}`));
} else {
console.log(chalk.yellow(` ⚠ Could not detect GitHub remote`));
}
console.log(chalk.dim(` Default branch: ${defaultBranch}`));
console.log(chalk.dim(` Session prefix: ${prefix}`));
if (projectType.languages.length > 0 || projectType.frameworks.length > 0) {
console.log(chalk.green(" ✓ Project type detected"));
const formattedType = formatProjectTypeForDisplay(projectType);
formattedType.split("\n").forEach((line) => {
console.log(chalk.dim(` ${line}`));
});
}
console.log();
// Build project config
const projectConfig: Record<string, unknown> = {
repo: ownerRepo || "owner/repo",
path: resolvedPath,
defaultBranch,
sessionPrefix: prefix,
};
if (opts.rules) {
const agentRules = generateRulesFromTemplates(projectType);
if (agentRules) {
projectConfig.agentRules = agentRules;
}
}
// Add to config and write
config.projects[projectId] = projectConfig;
const updatedYaml = yamlStringify(config, { indent: 2 });
writeFileSync(configPath, updatedYaml);
console.log(chalk.green(`✓ Added "${projectId}" to ${configPath}\n`));
if (!ownerRepo) {
console.log(chalk.yellow("⚠ Could not detect GitHub remote."));
console.log(chalk.dim(` Update the 'repo' field in the config before spawning agents.\n`));
}
console.log(chalk.bold("Next steps:\n"));
console.log(chalk.dim(` Run these from the directory where ${configPath} lives:\n`));
console.log(` 1. Start (or restart) the orchestrator:`);
console.log(chalk.cyan(` ao start\n`));
console.log(` 2. Spawn an agent for this project:`);
console.log(chalk.cyan(` ao spawn ${projectId} <issue-number>\n`));
console.log(` Want to add another project?`);
console.log(chalk.cyan(` ao add-project ~/path/to/repo\n`));
});
}

View File

@ -1,481 +1,18 @@
import { createInterface } from "node:readline/promises";
import { writeFileSync, existsSync } from "node:fs";
import { resolve, basename } from "node:path";
import { cwd } from "node:process";
import { stringify as yamlStringify } from "yaml";
import chalk from "chalk";
import type { Command } from "commander";
import { generateSessionPrefix } from "@composio/ao-core";
import { git, gh, execSilent } from "../lib/shell.js";
import { detectDefaultBranch } from "../lib/git-utils.js";
import { findFreePort, MAX_PORT_SCAN } from "../lib/web-dir.js";
import {
detectProjectType,
generateRulesFromTemplates,
formatProjectTypeForDisplay,
} from "../lib/project-detection.js";
const DEFAULT_PORT = 3000;
async function prompt(
rl: ReturnType<typeof createInterface>,
question: string,
defaultValue?: string,
): Promise<string> {
const suffix = defaultValue ? ` ${chalk.dim(`(${defaultValue})`)}` : "";
const answer = await rl.question(`${question}${suffix}: `);
return answer.trim() || defaultValue || "";
}
interface EnvironmentInfo {
isGitRepo: boolean;
gitRemote: string | null;
ownerRepo: string | null;
currentBranch: string | null;
defaultBranch: string | null;
hasTmux: boolean;
hasGh: boolean;
ghAuthed: boolean;
hasLinearKey: boolean;
hasSlackWebhook: boolean;
}
// detectDefaultBranch is shared — see packages/cli/src/lib/git-utils.ts
async function detectEnvironment(workingDir: string): Promise<EnvironmentInfo> {
// Check if in git repo
const isGitRepo = (await git(["rev-parse", "--git-dir"], workingDir)) !== null;
// Get git remote
let gitRemote: string | null = null;
let ownerRepo: string | null = null;
if (isGitRepo) {
gitRemote = await git(["remote", "get-url", "origin"], workingDir);
if (gitRemote) {
// Parse owner/repo from remote
// Examples:
// git@github.com:owner/repo.git
// https://github.com/owner/repo.git
const match = gitRemote.match(/github\.com[:/]([^/]+\/[^/]+?)(\.git)?$/);
if (match) {
ownerRepo = match[1];
}
}
}
// Get current branch (for display only, NOT for defaultBranch)
const currentBranch = isGitRepo ? await git(["branch", "--show-current"], workingDir) : null;
// Detect the actual default branch (main/master/next)
const defaultBranch = isGitRepo ? await detectDefaultBranch(workingDir, ownerRepo) : null;
// Check for tmux (direct invocation more portable than 'which')
const hasTmux = (await execSilent("tmux", ["-V"])) !== null;
// Check for gh CLI (direct invocation more portable than 'which')
const hasGh = (await execSilent("gh", ["--version"])) !== null;
// Check gh auth status (rely on exit code, not output string)
let ghAuthed = false;
if (hasGh) {
const authStatus = await gh(["auth", "status"]);
// gh() returns null on error, non-null on success
ghAuthed = authStatus !== null;
}
// Check for API keys in environment
const hasLinearKey = !!process.env["LINEAR_API_KEY"];
const hasSlackWebhook = !!process.env["SLACK_WEBHOOK_URL"];
return {
isGitRepo,
gitRemote,
ownerRepo,
currentBranch,
defaultBranch,
hasTmux,
hasGh,
ghAuthed,
hasLinearKey,
hasSlackWebhook,
};
}
export function registerInit(program: Command): void {
program
.command("init")
.description("Auto-detect project and create agent-orchestrator.yaml")
.option("-o, --output <path>", "Output file path", "agent-orchestrator.yaml")
.option("-i, --interactive", "Run the full interactive setup wizard instead of auto-detection")
.action(async (opts: { output: string; interactive?: boolean }) => {
const outputPath = resolve(opts.output);
if (existsSync(outputPath)) {
console.log(chalk.yellow(`Config already exists: ${outputPath}`));
console.log("Delete it first or specify a different path with --output.");
process.exit(1);
}
// Default: auto mode. Use --interactive for the wizard.
if (!opts.interactive) {
await handleAutoMode(outputPath, false);
return;
}
console.log(chalk.bold.cyan("\n Agent Orchestrator — Setup Wizard\n"));
console.log(chalk.dim(" Detecting environment...\n"));
const workingDir = cwd();
const env = await detectEnvironment(workingDir);
// Show detection results
if (env.isGitRepo) {
console.log(chalk.green(" ✓ Git repository detected"));
if (env.ownerRepo) {
console.log(chalk.dim(` Remote: ${env.ownerRepo}`));
}
if (env.currentBranch) {
console.log(chalk.dim(` Branch: ${env.currentBranch}`));
}
} else {
console.log(chalk.dim(" ○ Not in a git repository"));
}
if (env.hasTmux) {
console.log(chalk.green(" ✓ tmux available"));
} else {
console.log(chalk.yellow(" ⚠ tmux not found"));
console.log(chalk.dim(" Install with: brew install tmux"));
}
if (env.hasGh) {
if (env.ghAuthed) {
console.log(chalk.green(" ✓ GitHub CLI authenticated"));
} else {
console.log(chalk.yellow(" ⚠ GitHub CLI not authenticated"));
console.log(chalk.dim(" Run: gh auth login"));
}
} else {
console.log(chalk.yellow(" ⚠ GitHub CLI not found"));
console.log(chalk.dim(" Install with: brew install gh"));
}
if (env.hasLinearKey) {
console.log(chalk.green(" ✓ LINEAR_API_KEY detected"));
}
if (env.hasSlackWebhook) {
console.log(chalk.green(" ✓ SLACK_WEBHOOK_URL detected"));
}
console.log();
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
try {
// Basic config
console.log(chalk.bold(" Configuration\n"));
const dataDir = await prompt(
rl,
"Data directory (session metadata)",
"~/.agent-orchestrator",
);
const worktreeDir = await prompt(rl, "Worktree directory", "~/.worktrees");
const freePort = await findFreePort(DEFAULT_PORT);
if (freePort === null) {
console.log(
chalk.yellow(
`\n⚠ No free port found in range ${DEFAULT_PORT}${DEFAULT_PORT + MAX_PORT_SCAN - 1}.`,
),
);
console.log(chalk.dim(" Please specify a port manually.\n"));
} else if (freePort !== DEFAULT_PORT) {
console.log(
chalk.yellow(`\n⚠ Port ${DEFAULT_PORT} is busy — suggesting ${freePort} instead.`),
);
console.log(chalk.dim(" Press Enter to accept, or type a different port.\n"));
}
const portStr = await prompt(rl, "Dashboard port", String(freePort ?? DEFAULT_PORT));
const port = parseInt(portStr, 10);
if (isNaN(port) || port < 1 || port > 65535) {
console.error(chalk.red("\nInvalid port number. Must be 1-65535."));
rl.close();
process.exit(1);
}
// Default plugins
console.log(chalk.bold("\n Default Plugins\n"));
const runtime = await prompt(rl, "Runtime (tmux, process)", "tmux");
const agent = await prompt(
rl,
"Agent (claude-code, codex, aider, opencode)",
"claude-code",
);
const workspace = await prompt(rl, "Workspace (worktree, clone)", "worktree");
const notifiersStr = await prompt(
rl,
"Notifiers (comma-separated: desktop, slack)",
"desktop",
);
const notifiers = notifiersStr.split(",").map((s) => s.trim());
// First project
console.log(chalk.bold("\n First Project\n"));
const defaultProjectId = env.isGitRepo ? basename(workingDir) : "";
const projectId = await prompt(
rl,
"Project ID (short name, e.g. my-app)",
defaultProjectId,
);
const config: Record<string, unknown> = {
dataDir,
worktreeDir,
port,
defaults: { runtime, agent, workspace, notifiers },
projects: {} as Record<string, unknown>,
};
let projectPath = "";
if (projectId) {
const repo = await prompt(rl, "GitHub repo (owner/repo)", env.ownerRepo || "");
projectPath = await prompt(
rl,
"Local path to repo",
env.isGitRepo ? workingDir : `~/${projectId}`,
);
const defaultBranch = await prompt(rl, "Default branch", env.defaultBranch || "main");
// Ask about tracker
console.log(chalk.bold("\n Issue Tracker\n"));
if (env.hasLinearKey) {
console.log(chalk.dim(" (LINEAR_API_KEY detected)\n"));
}
const tracker = await prompt(
rl,
"Tracker (github, linear, none)",
env.hasLinearKey ? "linear" : "github",
);
const projectConfig: Record<string, unknown> = {
name: projectId,
sessionPrefix: generateSessionPrefix(projectId),
repo,
path: projectPath,
defaultBranch,
};
if (tracker === "linear") {
if (!env.hasLinearKey) {
console.log(chalk.yellow("\nWarning: LINEAR_API_KEY not found in environment"));
console.log(chalk.dim("Set it in your shell profile or .env file"));
console.log(chalk.dim("Get your key at: https://linear.app/settings/api\n"));
}
const teamId = await prompt(rl, "Linear team ID (find at linear.app/settings/api)", "");
if (teamId) {
projectConfig.tracker = { plugin: "linear", teamId };
}
} else if (tracker === "none") {
// Don't add tracker config
} else {
// Default to github (no explicit config needed)
}
(config.projects as Record<string, unknown>)[projectId] = projectConfig;
}
const yamlContent = yamlStringify(config, { indent: 2 });
writeFileSync(outputPath, yamlContent);
// Validation checks
console.log(chalk.bold("\n Validating Setup...\n"));
const checks = [
{ name: "Git", pass: (await execSilent("git", ["--version"])) !== null },
{ name: "tmux", pass: env.hasTmux },
{ name: "GitHub CLI", pass: env.hasGh },
...(projectPath
? [
{
name: "Repo path exists",
pass: existsSync(projectPath.replace(/^~/, process.env.HOME || "")),
},
]
: []),
];
for (const { name, pass } of checks) {
if (pass) {
console.log(chalk.green(`${name}`));
} else {
console.log(chalk.yellow(`${name} not found`));
}
}
// Success message and next steps
console.log(chalk.green(`\n✓ Config written to ${outputPath}\n`));
console.log(chalk.bold("Next steps:\n"));
console.log(chalk.dim(` Run the following commands from this directory (${workingDir}):\n`));
console.log(" 1. Start orchestrator + dashboard:");
console.log(chalk.cyan(" ao start\n"));
if (projectId) {
console.log(" 2. Spawn agent sessions:");
console.log(chalk.cyan(` ao spawn ${projectId} ISSUE-123\n`));
console.log(" Want to add more projects?");
console.log(chalk.cyan(" ao add-project ~/path/to/another-repo\n"));
} else {
console.log(" 2. Add a project to the config:");
console.log(chalk.cyan(` ao add-project ~/path/to/your-repo\n`));
}
console.log(chalk.dim("See SETUP.md for detailed configuration options.\n"));
if (!env.hasTmux) {
console.log(chalk.yellow("Note: tmux is required for the default runtime."));
console.log(chalk.dim("Install with: brew install tmux\n"));
}
if (!env.ghAuthed && env.hasGh) {
console.log(chalk.yellow("Note: Authenticate GitHub CLI for full functionality."));
console.log(chalk.dim("Run: gh auth login\n"));
}
} finally {
rl.close();
}
.description("[deprecated] Use 'ao start' instead — it auto-creates config on first run")
.action(async () => {
console.log(
chalk.yellow(
"⚠ 'ao init' is deprecated. Use 'ao start' instead.\n" +
" 'ao start' auto-detects your project and creates the config.\n",
),
);
const { createConfigOnly } = await import("./start.js");
await createConfigOnly();
});
}
async function handleAutoMode(outputPath: string, _smart: boolean): Promise<void> {
const workingDir = cwd();
console.log(chalk.bold.cyan("\n Agent Orchestrator — Auto Setup\n"));
console.log(chalk.dim(" Detecting project and generating config...\n"));
// Detect environment
const env = await detectEnvironment(workingDir);
// Detect project type
const projectType = detectProjectType(workingDir);
// Show detection results
if (env.isGitRepo) {
console.log(chalk.green(" ✓ Git repository detected"));
if (env.ownerRepo) {
console.log(chalk.dim(` Remote: ${env.ownerRepo}`));
}
if (env.currentBranch) {
console.log(chalk.dim(` Branch: ${env.currentBranch}`));
}
}
if (projectType.languages.length > 0 || projectType.frameworks.length > 0) {
console.log(chalk.green(" ✓ Project type detected"));
const formattedType = formatProjectTypeForDisplay(projectType);
formattedType.split("\n").forEach((line) => {
console.log(chalk.dim(` ${line}`));
});
}
console.log();
const agentRules = generateRulesFromTemplates(projectType);
// Build config with smart defaults
const projectId = env.isGitRepo ? basename(workingDir) : "my-project";
const repo = env.ownerRepo || "owner/repo";
const hasPlaceholderRepo = repo === "owner/repo";
const path = env.isGitRepo ? workingDir : `~/${projectId}`;
const defaultBranch = env.defaultBranch || "main";
const port = await findFreePort(DEFAULT_PORT);
if (port === null) {
console.log(
chalk.yellow(
` ⚠ No free port found in range ${DEFAULT_PORT}${DEFAULT_PORT + MAX_PORT_SCAN - 1}.`,
),
);
console.log(chalk.dim(" Set the port manually in the config before running ao start.\n"));
} else if (port !== DEFAULT_PORT) {
console.log(chalk.yellow(` ⚠ Port ${DEFAULT_PORT} is busy — using ${port} instead.`));
}
const config: Record<string, unknown> = {
dataDir: "~/.agent-orchestrator",
worktreeDir: "~/.worktrees",
port: port ?? DEFAULT_PORT,
defaults: {
runtime: "tmux",
agent: "claude-code",
workspace: "worktree",
notifiers: ["desktop"],
},
projects: {
[projectId]: {
name: projectId,
sessionPrefix: generateSessionPrefix(projectId),
repo,
path,
defaultBranch,
agentRules,
},
},
};
// Write config
const yamlContent = yamlStringify(config, { indent: 2 });
writeFileSync(outputPath, yamlContent);
// Show success message
console.log(chalk.green(`✓ Config written to ${outputPath}\n`));
// Warn if placeholder repo value is used
if (hasPlaceholderRepo) {
console.log(chalk.yellow("⚠ Warning: Could not detect GitHub repository"));
console.log(chalk.dim(" Update the 'repo' field in the config before spawning agents\n"));
}
// Show generated rules
if (agentRules) {
console.log(chalk.bold("Generated agent rules:\n"));
console.log(chalk.dim("---"));
agentRules.split("\n").forEach((line) => {
console.log(chalk.dim(`${line}`));
});
console.log(chalk.dim("---\n"));
}
// Show next steps
console.log(chalk.bold("Next steps:\n"));
console.log(chalk.dim(` Run the following commands from this directory (${workingDir}):\n`));
if (hasPlaceholderRepo) {
console.log(" 1. Edit config and update 'repo' field:");
console.log(chalk.cyan(` nano ${outputPath}\n`));
console.log(" 2. Start orchestrator + dashboard:");
console.log(chalk.cyan(" ao start\n"));
console.log(" 3. Spawn agent sessions:");
console.log(chalk.cyan(` ao spawn ${projectId} ISSUE-123\n`));
} else {
console.log(" 1. Start orchestrator + dashboard:");
console.log(chalk.cyan(" ao start\n"));
console.log(" 2. Spawn agent sessions:");
console.log(chalk.cyan(` ao spawn ${projectId} ISSUE-123\n`));
console.log(" Want to add more projects?");
console.log(chalk.cyan(" ao add-project ~/path/to/another-repo\n"));
}
// Show warnings
if (!env.hasTmux) {
console.log(chalk.yellow("⚠ tmux not found - install with: brew install tmux"));
}
if (!env.ghAuthed && env.hasGh) {
console.log(chalk.yellow("⚠ GitHub CLI not authenticated - run: gh auth login"));
}
console.log();
}

View File

@ -1,6 +1,7 @@
import chalk from "chalk";
import ora from "ora";
import type { Command } from "commander";
import { resolve } from "node:path";
import {
loadConfig,
decompose,
@ -18,6 +19,41 @@ import { getSessionManager } from "../lib/create-session-manager.js";
import { ensureLifecycleWorker } from "../lib/lifecycle-service.js";
import { preflight } from "../lib/preflight.js";
/**
* Auto-detect the project ID from the config.
* - If only one project exists, use it.
* - If multiple projects exist, match cwd against project paths.
* - Falls back to AO_PROJECT_ID env var (set when called from an agent session).
*/
function autoDetectProject(config: OrchestratorConfig): string {
const projectIds = Object.keys(config.projects);
if (projectIds.length === 0) {
throw new Error("No projects configured. Run 'ao start' first.");
}
if (projectIds.length === 1) {
return projectIds[0];
}
// Try AO_PROJECT_ID env var (set by AO when spawning agent sessions)
const envProject = process.env.AO_PROJECT_ID;
if (envProject && config.projects[envProject]) {
return envProject;
}
// Try matching cwd to a project path
const cwd = resolve(process.cwd());
for (const [id, project] of Object.entries(config.projects)) {
if (project.path && resolve(project.path) === cwd) {
return id;
}
}
throw new Error(
`Multiple projects configured. Specify one: ${projectIds.join(", ")}\n` +
`Or run from within a project directory.`,
);
}
interface SpawnClaimOptions {
claimPr?: string;
assignOnGithub?: boolean;
@ -122,8 +158,8 @@ export function registerSpawn(program: Command): void {
program
.command("spawn")
.description("Spawn a single agent session")
.argument("<project>", "Project ID from config")
.argument("[issue]", "Issue identifier (e.g. INT-1234, #42) - must exist in tracker")
.argument("[first]", "Issue identifier, or project ID when two args given")
.argument("[second]", "Issue identifier when first arg is project ID")
.option("--open", "Open session in terminal tab")
.option("--agent <name>", "Override the agent plugin (e.g. codex, claude-code)")
.option("--claim-pr <pr>", "Immediately claim an existing PR for the spawned session")
@ -132,8 +168,8 @@ export function registerSpawn(program: Command): void {
.option("--max-depth <n>", "Max decomposition depth (default: 3)")
.action(
async (
projectId: string,
issueId: string | undefined,
first: string | undefined,
second: string | undefined,
opts: {
open?: boolean;
agent?: string;
@ -144,6 +180,36 @@ export function registerSpawn(program: Command): void {
},
) => {
const config = loadConfig();
let projectId: string;
let issueId: string | undefined;
if (first && second) {
// Two args: ao spawn <project> <issue> (backward compat)
projectId = first;
issueId = second;
} else if (first && config.projects[first]) {
// Single arg that matches a project ID: treat as project, no issue
projectId = first;
issueId = undefined;
} else if (first) {
// Single arg that's not a project ID: treat as issue, auto-detect project
issueId = first;
try {
projectId = autoDetectProject(config);
} catch (err) {
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
process.exit(1);
}
} else {
// No args: auto-detect project, no issue
try {
projectId = autoDetectProject(config);
} catch (err) {
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
process.exit(1);
}
}
if (!config.projects[projectId]) {
console.error(
chalk.red(
@ -232,11 +298,28 @@ export function registerBatchSpawn(program: Command): void {
program
.command("batch-spawn")
.description("Spawn sessions for multiple issues with duplicate detection")
.argument("<project>", "Project ID from config")
.argument("<issues...>", "Issue identifiers")
.argument("<args...>", "Issue identifiers (optionally prefixed with project ID)")
.option("--open", "Open sessions in terminal tabs")
.action(async (projectId: string, issues: string[], opts: { open?: boolean }) => {
.action(async (args: string[], opts: { open?: boolean }) => {
const config = loadConfig();
let projectId: string;
let issues: string[];
// If first arg matches a project ID, treat it as project + remaining as issues
// Otherwise all args are issues and project is auto-detected
if (args.length >= 2 && config.projects[args[0]]) {
projectId = args[0];
issues = args.slice(1);
} else {
issues = args;
try {
projectId = autoDetectProject(config);
} catch (err) {
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
process.exit(1);
}
}
if (!config.projects[projectId]) {
console.error(
chalk.red(

View File

@ -11,13 +11,16 @@
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { resolve, basename } from "node:path";
import { cwd } from "node:process";
import chalk from "chalk";
import ora from "ora";
import type { Command } from "commander";
import {
loadConfig,
generateOrchestratorPrompt,
generateSessionPrefix,
findConfigFile,
isRepoUrl,
parseRepoUrl,
resolveCloneTarget,
@ -29,6 +32,7 @@ import {
type ProjectConfig,
type ParsedRepoUrl,
} from "@composio/ao-core";
import { stringify as yamlStringify } from "yaml";
import { exec, execSilent } from "../lib/shell.js";
import { getSessionManager } from "../lib/create-session-manager.js";
import { ensureLifecycleWorker, stopLifecycleWorker } from "../lib/lifecycle-service.js";
@ -42,6 +46,15 @@ import {
} from "../lib/web-dir.js";
import { cleanNextCache } from "../lib/dashboard-rebuild.js";
import { preflight } from "../lib/preflight.js";
import { register, unregister, isAlreadyRunning, getRunning } from "../lib/running-state.js";
import { getCallerType, isHumanCaller } from "../lib/caller-context.js";
import { detectEnvironment } from "../lib/detect-env.js";
import { detectAgentRuntime } from "../lib/detect-agent.js";
import {
detectProjectType,
generateRulesFromTemplates,
formatProjectTypeForDisplay,
} from "../lib/project-detection.js";
const DEFAULT_PORT = 3000;
@ -220,6 +233,213 @@ async function handleUrlStart(
return { config: loadConfig(configPath), parsed, autoGenerated: true };
}
/**
* Auto-create agent-orchestrator.yaml when no config exists.
* Detects environment, project type, and generates config with smart defaults.
* Returns the loaded config.
*/
async function autoCreateConfig(workingDir: string): Promise<OrchestratorConfig> {
console.log(chalk.bold.cyan("\n Agent Orchestrator — First Run Setup\n"));
console.log(chalk.dim(" Detecting project and generating config...\n"));
const env = await detectEnvironment(workingDir);
const projectType = detectProjectType(workingDir);
// Show detection results
if (env.isGitRepo) {
console.log(chalk.green(" ✓ Git repository detected"));
if (env.ownerRepo) {
console.log(chalk.dim(` Remote: ${env.ownerRepo}`));
}
if (env.currentBranch) {
console.log(chalk.dim(` Branch: ${env.currentBranch}`));
}
}
if (projectType.languages.length > 0 || projectType.frameworks.length > 0) {
console.log(chalk.green(" ✓ Project type detected"));
const formattedType = formatProjectTypeForDisplay(projectType);
formattedType.split("\n").forEach((line) => {
console.log(chalk.dim(` ${line}`));
});
}
console.log();
const agentRules = generateRulesFromTemplates(projectType);
// Build config with smart defaults
const projectId = env.isGitRepo ? basename(workingDir) : "my-project";
const repo = env.ownerRepo || "owner/repo";
const path = env.isGitRepo ? workingDir : `~/${projectId}`;
const defaultBranch = env.defaultBranch || "main";
// Detect available agent runtimes via plugin registry
const agent = await detectAgentRuntime();
console.log(chalk.green(` ✓ Agent runtime: ${agent}`));
const port = await findFreePort(DEFAULT_PORT);
if (port !== null && port !== DEFAULT_PORT) {
console.log(chalk.yellow(` ⚠ Port ${DEFAULT_PORT} is busy — using ${port} instead.`));
}
const config: Record<string, unknown> = {
port: port ?? DEFAULT_PORT,
defaults: {
runtime: "tmux",
agent,
workspace: "worktree",
notifiers: ["desktop"],
},
projects: {
[projectId]: {
name: projectId,
sessionPrefix: generateSessionPrefix(projectId),
repo,
path,
defaultBranch,
...(agentRules ? { agentRules } : {}),
},
},
};
const outputPath = resolve(workingDir, "agent-orchestrator.yaml");
const yamlContent = yamlStringify(config, { indent: 2 });
writeFileSync(outputPath, yamlContent);
console.log(chalk.green(`✓ Config created: ${outputPath}\n`));
if (repo === "owner/repo") {
console.log(chalk.yellow("⚠ Could not detect GitHub remote."));
console.log(chalk.dim(" Update the 'repo' field in the config before spawning agents.\n"));
}
if (!env.hasTmux) {
console.log(chalk.yellow("⚠ tmux not found — install with: brew install tmux"));
}
if (!env.ghAuthed && env.hasGh) {
console.log(chalk.yellow("⚠ GitHub CLI not authenticated — run: gh auth login"));
}
return loadConfig(outputPath);
}
/**
* Add a new project to an existing config.
* Detects git info, project type, generates rules, appends to config YAML.
* Returns the project ID that was added.
*/
async function addProjectToConfig(
config: OrchestratorConfig,
projectPath: string,
): Promise<string> {
const { readFileSync } = await import("node:fs");
const { parse: yamlParse } = await import("yaml");
const { git } = await import("../lib/shell.js");
const { detectDefaultBranch } = await import("../lib/git-utils.js");
const resolvedPath = resolve(projectPath.replace(/^~/, process.env["HOME"] || ""));
const projectId = basename(resolvedPath);
console.log(chalk.dim(`\n Adding project "${projectId}"...\n`));
// Validate git repo
const isGitRepo = (await git(["rev-parse", "--git-dir"], resolvedPath)) !== null;
if (!isGitRepo) {
throw new Error(`"${resolvedPath}" is not a git repository.`);
}
// Detect git remote
let ownerRepo: string | null = null;
const gitRemote = await git(["remote", "get-url", "origin"], resolvedPath);
if (gitRemote) {
const match = gitRemote.match(/github\.com[:/]([^/]+\/[^/]+?)(\.git)?$/);
if (match) ownerRepo = match[1];
}
const defaultBranch = await detectDefaultBranch(resolvedPath, ownerRepo);
// Generate unique session prefix
let prefix = generateSessionPrefix(projectId);
const existingPrefixes = new Set(
Object.values(config.projects).map(
(p) => p.sessionPrefix || generateSessionPrefix(basename(p.path)),
),
);
if (existingPrefixes.has(prefix)) {
let i = 2;
while (existingPrefixes.has(`${prefix}${i}`)) i++;
prefix = `${prefix}${i}`;
}
// Detect project type and generate rules
const projectType = detectProjectType(resolvedPath);
const agentRules = generateRulesFromTemplates(projectType);
// Show what was detected
console.log(chalk.green(` ✓ Git repository`));
if (ownerRepo) {
console.log(chalk.dim(` Remote: ${ownerRepo}`));
}
console.log(chalk.dim(` Default branch: ${defaultBranch}`));
console.log(chalk.dim(` Session prefix: ${prefix}`));
if (projectType.languages.length > 0 || projectType.frameworks.length > 0) {
console.log(chalk.green(" ✓ Project type detected"));
const formattedType = formatProjectTypeForDisplay(projectType);
formattedType.split("\n").forEach((line) => {
console.log(chalk.dim(` ${line}`));
});
}
// Load raw YAML, append project, rewrite
const rawYaml = readFileSync(config.configPath, "utf-8");
const rawConfig = yamlParse(rawYaml);
if (!rawConfig.projects) rawConfig.projects = {};
rawConfig.projects[projectId] = {
name: projectId,
repo: ownerRepo || "owner/repo",
path: resolvedPath,
defaultBranch,
sessionPrefix: prefix,
...(agentRules ? { agentRules } : {}),
};
writeFileSync(config.configPath, yamlStringify(rawConfig, { indent: 2 }));
console.log(chalk.green(`\n✓ Added "${projectId}" to ${config.configPath}\n`));
if (!ownerRepo) {
console.log(chalk.yellow("⚠ Could not detect GitHub remote."));
console.log(chalk.dim(" Update the 'repo' field in the config before spawning agents.\n"));
}
return projectId;
}
/**
* Create config without starting dashboard/orchestrator.
* Used by deprecated `ao init` wrapper.
*/
export async function createConfigOnly(): Promise<void> {
await autoCreateConfig(cwd());
}
/**
* Add a project without starting dashboard/orchestrator.
* Used by deprecated `ao add-project` wrapper.
*/
export async function addProjectOnly(projectPath: string): Promise<void> {
const configPath = findConfigFile();
if (!configPath) {
console.error(chalk.red("No agent-orchestrator.yaml found."));
console.log(chalk.dim("Run `ao start` first to create a config.\n"));
process.exit(1);
}
const config = loadConfig(configPath);
await addProjectToConfig(config, projectPath);
}
/**
* Start dashboard server in the background.
* Returns the child process handle for cleanup.
@ -456,7 +676,7 @@ export function registerStart(program: Command): void {
program
.command("start [project]")
.description(
"Start orchestrator agent and dashboard for a project (or pass a repo URL to onboard)",
"Start orchestrator agent and dashboard (auto-creates config on first run, adds projects by path/URL)",
)
.option("--no-dashboard", "Skip starting the dashboard server")
.option("--no-orchestrator", "Skip starting the orchestrator agent")
@ -474,27 +694,153 @@ export function registerStart(program: Command): void {
let config: OrchestratorConfig;
let projectId: string;
let project: ProjectConfig;
// Detect URL argument — run onboarding flow
if (projectArg && isRepoUrl(projectArg)) {
// ── URL argument: clone + auto-config + start ──
console.log(chalk.bold.cyan("\n Agent Orchestrator — Quick Start\n"));
const result = await handleUrlStart(projectArg);
config = result.config;
({ projectId, project } = resolveProjectByRepo(config, result.parsed));
} else if (projectArg && isLocalPath(projectArg)) {
// ── Path argument: add project if new, then start ──
const resolvedPath = resolve(projectArg.replace(/^~/, process.env["HOME"] || ""));
// Try to load existing config
let configPath: string | undefined;
try {
configPath = findConfigFile() ?? undefined;
} catch {
// No config found — create one first
}
if (!configPath) {
// No config anywhere — auto-create in cwd, then add the path as project
config = await autoCreateConfig(cwd());
// If the path is different from cwd, add it as a second project
if (resolve(cwd()) !== resolvedPath) {
const addedId = await addProjectToConfig(config, resolvedPath);
config = loadConfig(config.configPath);
projectId = addedId;
project = config.projects[projectId];
} else {
({ projectId, project } = resolveProject(config));
}
} else {
config = loadConfig(configPath);
// Check if project is already in config (match by path)
const existingEntry = Object.entries(config.projects).find(
([, p]) => resolve(p.path.replace(/^~/, process.env["HOME"] || "")) === resolvedPath,
);
if (existingEntry) {
// Already in config — just start it
projectId = existingEntry[0];
project = existingEntry[1];
} else {
// New project — add it to config
const addedId = await addProjectToConfig(config, resolvedPath);
config = loadConfig(config.configPath);
projectId = addedId;
project = config.projects[projectId];
}
}
} else {
// Normal flow — load existing config
config = loadConfig();
// ── No arg or project ID: load config or auto-create ──
let loadedConfig: OrchestratorConfig | null = null;
try {
loadedConfig = loadConfig();
} catch (err) {
if (err instanceof Error && err.message.includes("No agent-orchestrator.yaml found")) {
// First run — auto-create config
loadedConfig = await autoCreateConfig(cwd());
} else {
throw err;
}
}
config = loadedConfig;
({ projectId, project } = resolveProject(config, projectArg));
}
// ── Already-running detection (Step 9) ──
const running = isAlreadyRunning();
if (running) {
if (isHumanCaller()) {
console.log(chalk.cyan(`\n AO is already running.`));
console.log(` Dashboard: ${chalk.cyan(`http://localhost:${running.port}`)}`);
console.log(` PID: ${running.pid} | Up since: ${running.startedAt}`);
console.log(` Projects: ${running.projects.join(", ")}\n`);
// Interactive menu
const { createInterface } = await import("node:readline/promises");
const rl = createInterface({ input: process.stdin, output: process.stdout });
console.log(" 1. Open dashboard (keep current)");
console.log(" 2. Start new orchestrator on this project");
console.log(" 3. Override — restart everything");
console.log(" 4. Quit\n");
const choice = await rl.question(" Choice [1-4]: ");
rl.close();
if (choice.trim() === "1") {
const url = `http://localhost:${running.port}`;
const [cmd, args]: [string, string[]] =
process.platform === "win32"
? ["cmd.exe", ["/c", "start", "", url]]
: [process.platform === "linux" ? "xdg-open" : "open", [url]];
spawn(cmd, args, { stdio: "ignore" });
process.exit(0);
} else if (choice.trim() === "2") {
// Generate unique orchestrator: same project, new session
const suffix = Math.random().toString(36).slice(2, 6);
const newId = `${projectId}-${suffix}`;
const newPrefix = generateSessionPrefix(newId);
const { readFileSync } = await import("node:fs");
const { parse: yamlParse } = await import("yaml");
const rawYaml = readFileSync(config.configPath, "utf-8");
const rawConfig = yamlParse(rawYaml);
rawConfig.projects[newId] = {
...rawConfig.projects[projectId],
sessionPrefix: newPrefix,
};
writeFileSync(config.configPath, yamlStringify(rawConfig, { indent: 2 }));
console.log(chalk.green(`\n✓ New orchestrator "${newId}" added to config\n`));
config = loadConfig(config.configPath);
projectId = newId;
project = config.projects[newId];
// Continue to startup below
} else if (choice.trim() === "3") {
process.kill(running.pid, "SIGTERM");
unregister();
console.log(chalk.yellow("\n Stopped existing instance. Restarting...\n"));
// Continue to startup below
} else {
process.exit(0);
}
} else {
// Agent/non-TTY caller — print info and exit
console.log(`AO is already running.`);
console.log(`Dashboard: http://localhost:${running.port}`);
console.log(`PID: ${running.pid}`);
console.log(`Projects: ${running.projects.join(", ")}`);
console.log(`To restart: ao stop && ao start`);
process.exit(0);
}
}
await runStartup(config, projectId, project, opts);
// ── Register in running.json (Step 10) ──
register({
pid: process.pid,
configPath: config.configPath,
port: config.port ?? DEFAULT_PORT,
startedAt: new Date().toISOString(),
projects: Object.keys(config.projects),
});
} catch (err) {
if (err instanceof Error) {
if (err.message.includes("No agent-orchestrator.yaml found")) {
console.error(chalk.red("\nNo config found. Run:"));
console.error(chalk.cyan(" ao init\n"));
} else {
console.error(chalk.red("\nError:"), err.message);
}
console.error(chalk.red("\nError:"), err.message);
} else {
console.error(chalk.red("\nError:"), String(err));
}
@ -504,15 +850,49 @@ export function registerStart(program: Command): void {
);
}
/**
* Check if arg looks like a local path (not a project ID).
* Paths contain / or ~ or . at the start.
*/
function isLocalPath(arg: string): boolean {
return arg.startsWith("/") || arg.startsWith("~") || arg.startsWith("./") || arg.startsWith("..");
}
export function registerStop(program: Command): void {
program
.command("stop [project]")
.description("Stop orchestrator agent and dashboard for a project")
.description("Stop orchestrator agent and dashboard")
.option("--keep-session", "Keep mapped OpenCode session after stopping")
.option("--purge-session", "Delete mapped OpenCode session when stopping")
.option("--all", "Stop all running AO instances")
.action(
async (projectArg?: string, opts: { keepSession?: boolean; purgeSession?: boolean } = {}) => {
async (
projectArg?: string,
opts: { keepSession?: boolean; purgeSession?: boolean; all?: boolean } = {},
) => {
try {
// Check running.json first
const running = getRunning();
if (opts.all) {
// --all: kill via running.json if available, then fallback to config
if (running) {
try {
process.kill(running.pid, "SIGTERM");
} catch {
// Already dead
}
unregister();
console.log(
chalk.green(`\n✓ Stopped AO on port ${running.port}`),
);
console.log(chalk.dim(` Projects: ${running.projects.join(", ")}\n`));
} else {
console.log(chalk.yellow("No running AO instance found in running.json."));
}
return;
}
const config = loadConfig();
const { projectId: _projectId, project } = resolveProject(config, projectArg);
const sessionId = `${project.sessionPrefix}-orchestrator`;
@ -540,10 +920,26 @@ export function registerStop(program: Command): void {
console.log(chalk.yellow("Lifecycle worker not running"));
}
// Stop dashboard
await stopDashboard(port);
// Stop dashboard — use running.json PID if available, fallback to lsof
if (running) {
try {
process.kill(running.pid, "SIGTERM");
} catch {
// Already dead
}
unregister();
console.log(chalk.green("Dashboard stopped (via running.json)"));
} else {
await stopDashboard(port);
}
console.log(chalk.bold.green("\n✓ Orchestrator stopped\n"));
console.log(
chalk.dim(` Uptime: since ${running?.startedAt ?? "unknown"}`),
);
console.log(
chalk.dim(` Projects: ${Object.keys(config.projects).join(", ")}\n`),
);
} catch (err) {
if (err instanceof Error) {
console.error(chalk.red("\nError:"), err.message);

View File

@ -14,7 +14,7 @@ import { registerLifecycleWorker } from "./commands/lifecycle-worker.js";
import { registerVerify } from "./commands/verify.js";
import { registerDoctor } from "./commands/doctor.js";
import { registerUpdate } from "./commands/update.js";
import { registerAddProject } from "./commands/add-project.js";
import { getConfigInstruction } from "./lib/config-instruction.js";
const program = new Command();
@ -38,6 +38,12 @@ registerLifecycleWorker(program);
registerVerify(program);
registerDoctor(program);
registerUpdate(program);
registerAddProject(program);
program
.command("config-help")
.description("Show config schema and guide for creating agent-orchestrator.yaml")
.action(() => {
console.log(getConfigInstruction());
});
program.parse();

View File

@ -0,0 +1,45 @@
export type CallerType = "human" | "orchestrator" | "agent";
export interface CallerContextOpts {
callerType: CallerType;
sessionId?: string;
projectId?: string;
configPath?: string;
port?: number;
}
/**
* Detect who is calling the CLI.
* - If AO_CALLER_TYPE is set, trust it.
* - Otherwise, if stdout is a TTY, it's a human.
* - Non-TTY defaults to "agent".
*/
export function getCallerType(): CallerType {
const env = process.env["AO_CALLER_TYPE"];
if (env === "orchestrator" || env === "agent" || env === "human") {
return env;
}
return process.stdout.isTTY ? "human" : "agent";
}
/**
* Returns true if the caller is a human (interactive terminal).
*/
export function isHumanCaller(): boolean {
return getCallerType() === "human";
}
/**
* Inject AO context environment variables into an env record.
* Used when spawning orchestrator/agent sessions so they know their context.
*/
export function setCallerContext(
env: Record<string, string>,
opts: CallerContextOpts,
): void {
env["AO_CALLER_TYPE"] = opts.callerType;
if (opts.sessionId) env["AO_SESSION_ID"] = opts.sessionId;
if (opts.projectId) env["AO_PROJECT_ID"] = opts.projectId;
if (opts.configPath) env["AO_CONFIG_PATH"] = opts.configPath;
if (opts.port != null) env["AO_PORT"] = String(opts.port);
}

View File

@ -0,0 +1,128 @@
/**
* Returns the complete AO config schema as formatted text.
* Used by `ao config-help` and injected into orchestrator system prompts.
*/
export function getConfigInstruction(): string {
return `
# Agent Orchestrator Config Reference
# File: agent-orchestrator.yaml
# Top-level settings
port: 3000 # Dashboard port (default: 3000, auto-finds free port if busy)
terminalPort: 3001 # Terminal WebSocket port (default: 3001)
directTerminalPort: 3003 # Direct terminal WebSocket port (default: 3003)
readyThresholdMs: 300000 # Ms before "ready" session becomes "idle" (default: 5 min)
# Default plugins
# These apply to all projects unless overridden per-project.
defaults:
runtime: tmux # tmux | process
agent: claude-code # claude-code | aider | codex | opencode
workspace: worktree # worktree | clone
notifiers: # List of active notifier plugins
- desktop # desktop | slack | webhook | composio | openclaw
orchestrator:
agent: claude-code # Agent for orchestrator sessions (optional override)
worker:
agent: claude-code # Agent for worker sessions (optional override)
# Projects
# Each key is a project ID (typically the repo directory name).
projects:
my-app:
name: My App # Display name
repo: owner/repo # GitHub "owner/repo" format
path: ~/code/my-app # Local path to the repo
defaultBranch: main # main | master | next | develop
sessionPrefix: myapp # Prefix for session names (e.g. myapp-1, myapp-2)
# Per-project plugin overrides (optional)
runtime: tmux # Override default runtime
agent: claude-code # Override default agent
workspace: worktree # Override default workspace
# Agent configuration (optional)
agentConfig:
permissions: auto # auto | manual agent permission mode
model: claude-sonnet-4-20250514
# Agent rules (optional)
agentRules: | # Inline rules passed to every agent prompt
Always run tests before committing.
Use conventional commits.
agentRulesFile: .ao-rules # Or point to a file (relative to project path)
orchestratorRules: | # Rules for the orchestrator agent
# Orchestrator session strategy (optional)
# Controls what happens to the orchestrator session on restart.
orchestratorSessionStrategy: reuse
# Options: reuse | delete | ignore | delete-new | ignore-new | kill-previous
# Workspace setup (optional)
symlinks: # Files/dirs to symlink into workspaces
- .env
- node_modules
postCreate: # Commands to run after workspace creation
- pnpm install
# Issue tracker (optional)
tracker:
plugin: github # github | linear | gitlab
# Linear-specific:
# teamId: TEAM-123
# projectId: PROJECT-456
# SCM configuration (optional, usually auto-detected)
scm:
plugin: github # github | gitlab
# Task decomposition (optional)
decomposer:
enabled: false # Auto-decompose backlog issues
maxDepth: 3 # Max recursion depth
model: claude-sonnet-4-20250514
requireApproval: true # Require human approval before executing
# Per-project reaction overrides (optional)
# reactions:
# ci-failure:
# enabled: true
# Notification channels (optional)
notifiers:
desktop:
plugin: desktop
slack:
plugin: slack
# Requires SLACK_WEBHOOK_URL env var
webhook:
plugin: webhook
# url: https://example.com/hook
# Notification routing (optional)
# Route notifications by priority level.
notificationRouting:
critical:
- desktop
- slack
high:
- desktop
low:
- desktop
# Available plugins
#
# Agent: claude-code, aider, codex, opencode
# Runtime: tmux, process
# Workspace: worktree, clone
# SCM: github, gitlab
# Tracker: github, linear, gitlab
# Notifier: desktop, slack, webhook, composio, openclaw
# Terminal: iterm2, web
`.trim();
}

View File

@ -0,0 +1,92 @@
/**
* Agent runtime detection discovers available agent runtimes via plugin detect() methods.
*
* No hardcoded binary paths relies entirely on each plugin's detect() export.
*/
import type { PluginModule } from "@composio/ao-core";
import { isHumanCaller } from "./caller-context.js";
export interface DetectedAgent {
name: string;
displayName: string;
}
/** Known agent plugins — package name mapping. */
const AGENT_PLUGINS: Array<{ name: string; pkg: string }> = [
{ name: "claude-code", pkg: "@composio/ao-plugin-agent-claude-code" },
{ name: "aider", pkg: "@composio/ao-plugin-agent-aider" },
{ name: "codex", pkg: "@composio/ao-plugin-agent-codex" },
{ name: "opencode", pkg: "@composio/ao-plugin-agent-opencode" },
];
/**
* Discover which agent runtimes are available on this system.
* Imports each agent plugin and calls its detect() method.
*/
export async function detectAvailableAgents(): Promise<DetectedAgent[]> {
const available: DetectedAgent[] = [];
for (const { name, pkg } of AGENT_PLUGINS) {
try {
const mod = (await import(pkg)) as PluginModule;
if (typeof mod.detect === "function" && mod.detect()) {
available.push({
name,
displayName: mod.manifest?.displayName ?? name,
});
}
} catch {
// Plugin not installed or import failed — skip
}
}
return available;
}
/**
* Select the agent runtime to use for config generation.
*
* - No agents detected default to "claude-code"
* - One agent available auto-select it
* - Multiple agents available + human caller prompt to pick
* - Multiple agents available + non-human pick first (claude-code if available)
*/
export async function detectAgentRuntime(): Promise<string> {
const available = await detectAvailableAgents();
if (available.length === 0) {
return "claude-code";
}
if (available.length === 1) {
return available[0].name;
}
// Multiple agents available
if (!isHumanCaller()) {
// Non-interactive: prefer claude-code if available, else first
return available.find((a) => a.name === "claude-code")?.name ?? available[0].name;
}
// Interactive: prompt human to pick using node:readline (no external deps)
const { createInterface } = await import("node:readline/promises");
const rl = createInterface({ input: process.stdin, output: process.stdout });
console.log("\n Multiple agent runtimes detected:\n");
available.forEach((a, i) => {
console.log(` ${i + 1}. ${a.displayName} (${a.name})`);
});
console.log();
const answer = await rl.question(` Choose default agent [1-${available.length}]: `);
rl.close();
const idx = parseInt(answer.trim(), 10) - 1;
if (idx >= 0 && idx < available.length) {
return available[idx].name;
}
// Invalid input — default to first
return available[0].name;
}

View File

@ -0,0 +1,69 @@
import { git, gh, execSilent } from "./shell.js";
import { detectDefaultBranch } from "./git-utils.js";
export interface EnvironmentInfo {
isGitRepo: boolean;
gitRemote: string | null;
ownerRepo: string | null;
currentBranch: string | null;
defaultBranch: string | null;
hasTmux: boolean;
hasGh: boolean;
ghAuthed: boolean;
hasLinearKey: boolean;
hasSlackWebhook: boolean;
}
export async function detectEnvironment(workingDir: string): Promise<EnvironmentInfo> {
// Check if in git repo
const isGitRepo = (await git(["rev-parse", "--git-dir"], workingDir)) !== null;
// Get git remote
let gitRemote: string | null = null;
let ownerRepo: string | null = null;
if (isGitRepo) {
gitRemote = await git(["remote", "get-url", "origin"], workingDir);
if (gitRemote) {
const match = gitRemote.match(/github\.com[:/]([^/]+\/[^/]+?)(\.git)?$/);
if (match) {
ownerRepo = match[1];
}
}
}
// Get current branch (for display only, NOT for defaultBranch)
const currentBranch = isGitRepo ? await git(["branch", "--show-current"], workingDir) : null;
// Detect the actual default branch (main/master/next)
const defaultBranch = isGitRepo ? await detectDefaultBranch(workingDir, ownerRepo) : null;
// Check for tmux (direct invocation more portable than 'which')
const hasTmux = (await execSilent("tmux", ["-V"])) !== null;
// Check for gh CLI (direct invocation more portable than 'which')
const hasGh = (await execSilent("gh", ["--version"])) !== null;
// Check gh auth status (rely on exit code, not output string)
let ghAuthed = false;
if (hasGh) {
const authStatus = await gh(["auth", "status"]);
ghAuthed = authStatus !== null;
}
// Check for API keys in environment
const hasLinearKey = !!process.env["LINEAR_API_KEY"];
const hasSlackWebhook = !!process.env["SLACK_WEBHOOK_URL"];
return {
isGitRepo,
gitRemote,
ownerRepo,
currentBranch,
defaultBranch,
hasTmux,
hasGh,
ghAuthed,
hasLinearKey,
hasSlackWebhook,
};
}

View File

@ -0,0 +1,87 @@
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
export interface RunningState {
pid: number;
configPath: string;
port: number;
startedAt: string;
projects: string[];
}
const STATE_DIR = join(homedir(), ".agent-orchestrator");
const STATE_FILE = join(STATE_DIR, "running.json");
function ensureDir(): void {
mkdirSync(STATE_DIR, { recursive: true });
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function readState(): RunningState | null {
try {
const raw = readFileSync(STATE_FILE, "utf-8");
const state = JSON.parse(raw) as RunningState;
if (!state || typeof state.pid !== "number") return null;
return state;
} catch {
return null;
}
}
function writeState(state: RunningState | null): void {
ensureDir();
if (state === null) {
writeFileSync(STATE_FILE, "null", "utf-8");
} else {
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), "utf-8");
}
}
/**
* Register the current AO instance as running.
* Prunes any stale entry first.
*/
export function register(entry: RunningState): void {
writeState(entry);
}
/**
* Unregister the running AO instance.
*/
export function unregister(): void {
writeState(null);
}
/**
* Get the currently running AO instance, if any.
* Auto-prunes stale entries (dead PIDs).
*/
export function getRunning(): RunningState | null {
const state = readState();
if (!state) return null;
if (!isProcessAlive(state.pid)) {
// Stale entry — process is dead, clean up
writeState(null);
return null;
}
return state;
}
/**
* Check if AO is already running.
* Returns the running state if alive, null otherwise.
*/
export function isAlreadyRunning(): RunningState | null {
return getRunning();
}

View File

@ -78,6 +78,7 @@ export type {
export { generateOrchestratorPrompt } from "./orchestrator-prompt.js";
export type { OrchestratorPromptConfig } from "./orchestrator-prompt.js";
// Global pause constants and utilities
export {
GLOBAL_PAUSE_UNTIL_KEY,

View File

@ -54,9 +54,9 @@ Your role is to coordinate and manage worker agent sessions. You do NOT write co
ao status
# Spawn sessions for issues (GitHub: #123, Linear: INT-1234, etc.)
ao spawn ${projectId} INT-1234
ao spawn ${projectId} --claim-pr 123
ao batch-spawn ${projectId} INT-1 INT-2 INT-3
ao spawn INT-1234
ao spawn --claim-pr 123
ao batch-spawn INT-1 INT-2 INT-3
# List sessions
ao session ls -p ${projectId}
@ -80,8 +80,8 @@ ao open ${projectId}
| Command | Description |
|---------|-------------|
| \`ao status\` | Show all sessions with PR/CI/review status |
| \`ao spawn <project> [issue] [--claim-pr <pr>]\` | Spawn a worker session, optionally attached to an existing PR |
| \`ao batch-spawn <project> <issues...>\` | Spawn multiple sessions in parallel |
| \`ao spawn [issue] [--claim-pr <pr>] [-p project]\` | Spawn a worker session, optionally attached to an existing PR |
| \`ao batch-spawn <issues...> [-p project]\` | Spawn multiple sessions in parallel |
| \`ao session ls [-p project]\` | List all sessions (optionally filter by project) |
| \`ao session claim-pr <pr> [session]\` | Attach an existing PR to a worker session |
| \`ao session attach <session>\` | Attach to a session's tmux window |

View File

@ -1070,6 +1070,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
AO_DATA_DIR: sessionsDir, // Pass sessions directory (not root dataDir)
AO_SESSION_NAME: sessionId, // User-facing session name
...(tmuxName && { AO_TMUX_NAME: tmuxName }), // Tmux session name if using new arch
AO_CALLER_TYPE: "agent",
AO_PROJECT_ID: spawnConfig.projectId,
AO_CONFIG_PATH: config.configPath,
...(config.port != null && { AO_PORT: String(config.port) }),
},
});
} catch (err) {
@ -1365,6 +1369,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
AO_DATA_DIR: sessionsDir,
AO_SESSION_NAME: sessionId,
...(tmuxName && { AO_TMUX_NAME: tmuxName }),
AO_CALLER_TYPE: "orchestrator",
AO_PROJECT_ID: orchestratorConfig.projectId,
AO_CONFIG_PATH: config.configPath,
...(config.port != null && { AO_PORT: String(config.port) }),
},
});
@ -2371,6 +2379,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
AO_DATA_DIR: sessionsDir,
AO_SESSION_NAME: sessionId,
...(tmuxName && { AO_TMUX_NAME: tmuxName }),
AO_CALLER_TYPE: "agent",
...(projectId && { AO_PROJECT_ID: projectId }),
AO_CONFIG_PATH: config.configPath,
...(config.port != null && { AO_PORT: String(config.port) }),
},
});

View File

@ -1113,12 +1113,18 @@ export interface PluginManifest {
/** Version */
version: string;
/** Human-readable display name (e.g. "Claude Code") */
displayName?: string;
}
/** What a plugin module must export */
export interface PluginModule<T = unknown> {
manifest: PluginManifest;
create(config?: Record<string, unknown>): T;
/** Optional: detect whether this plugin's runtime/binary is available on the system. */
detect?(): boolean;
}
// =============================================================================

View File

@ -70,6 +70,7 @@ export const manifest = {
slot: "agent" as const,
description: "Agent plugin: Aider",
version: "0.1.0",
displayName: "Aider",
};
// =============================================================================
@ -222,4 +223,13 @@ export function create(): Agent {
return createAiderAgent();
}
export default { manifest, create } satisfies PluginModule<Agent>;
export function detect(): boolean {
try {
require("node:child_process").execSync("which aider", { stdio: "ignore" });
return true;
} catch {
return false;
}
}
export default { manifest, create, detect } satisfies PluginModule<Agent>;

View File

@ -184,6 +184,7 @@ export const manifest = {
slot: "agent" as const,
description: "Agent plugin: Claude Code CLI",
version: "0.1.0",
displayName: "Claude Code",
};
// =============================================================================
@ -840,4 +841,13 @@ export function create(): Agent {
return createClaudeCodeAgent();
}
export default { manifest, create } satisfies PluginModule<Agent>;
export function detect(): boolean {
try {
require("node:child_process").execSync("which claude", { stdio: "ignore" });
return true;
} catch {
return false;
}
}
export default { manifest, create, detect } satisfies PluginModule<Agent>;

View File

@ -69,6 +69,7 @@ export const manifest = {
slot: "agent" as const,
description: "Agent plugin: OpenAI Codex CLI",
version: "0.1.1",
displayName: "OpenAI Codex",
};
// =============================================================================
@ -873,4 +874,13 @@ export type {
ApprovalDecision,
} from "./app-server-client.js";
export default { manifest, create } satisfies PluginModule<Agent>;
export function detect(): boolean {
try {
require("node:child_process").execSync("which codex", { stdio: "ignore" });
return true;
} catch {
return false;
}
}
export default { manifest, create, detect } satisfies PluginModule<Agent>;

View File

@ -146,6 +146,7 @@ export const manifest = {
slot: "agent" as const,
description: "Agent plugin: OpenCode",
version: "0.1.0",
displayName: "OpenCode",
};
// =============================================================================
@ -358,4 +359,13 @@ export function create(): Agent {
return createOpenCodeAgent();
}
export default { manifest, create } satisfies PluginModule<Agent>;
export function detect(): boolean {
try {
require("node:child_process").execSync("which opencode", { stdio: "ignore" });
return true;
} catch {
return false;
}
}
export default { manifest, create, detect } satisfies PluginModule<Agent>;