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:
parent
0dfc75f592
commit
f3f81dad84
|
|
@ -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`));
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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) }),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Reference in New Issue