import chalk from "chalk"; import ora from "ora"; import type { Command } from "commander"; import { resolve } from "node:path"; import { loadConfig, recordActivityEvent, resolveSpawnTarget, TERMINAL_STATUSES, type OrchestratorConfig, type PreflightContext, } from "@aoagents/ao-core"; import { DEFAULT_PORT } from "../lib/constants.js"; import { exec } from "../lib/shell.js"; import { banner } from "../lib/format.js"; import { getPluginRegistry, getSessionManager } from "../lib/create-session-manager.js"; import { findProjectForDirectory } from "../lib/project-resolution.js"; import { getRunning } from "../lib/running-state.js"; import { projectSessionUrl } from "../lib/routes.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()); const matchedProjectId = findProjectForDirectory(config.projects, cwd); if (matchedProjectId) { return matchedProjectId; } throw new Error( `Multiple projects configured. Specify one: ${projectIds.join(", ")}\n` + `Or run from within a project directory.`, ); } /** * Non-throwing variant — returns null when the project can't be resolved * unambiguously. Used to feed `resolveSpawnTarget`'s fallback parameter so * the prefix/no-prefix and issue/no-issue paths share one code path. */ function tryAutoDetectProject(config: OrchestratorConfig): string | null { try { return autoDetectProject(config); } catch { return null; } } /** * Resolve the project + issue from a single optional CLI argument. * * Single source of truth for the four cases: * - `ao spawn` → auto-detect project, no issue * - `ao spawn 42` → auto-detect project, issue=42 * - `ao spawn xid/42` → prefix match, issue=42 * - `ao spawn x402-identity/42` → exact projectId match, issue=42 * * Throws (via autoDetectProject) when the project can't be resolved — the * caller wraps in one try/catch instead of duplicating it across branches. */ function resolveProjectAndIssue( config: OrchestratorConfig, issue: string | undefined, ): { projectId: string; issueId?: string } { const fallback = tryAutoDetectProject(config); if (issue) { const target = resolveSpawnTarget(config.projects, issue, fallback ?? undefined); if (target) return { projectId: target.projectId, issueId: target.issueId }; autoDetectProject(config); // throws with the real error message throw new Error("unreachable"); } if (!fallback) { autoDetectProject(config); // throws throw new Error("unreachable"); } return { projectId: fallback }; } interface SpawnClaimOptions { claimPr?: string; assignOnGithub?: boolean; } /** * Lifecycle polling runs in-process inside the long-lived `ao start` process. * `ao spawn` is a one-shot CLI — it can't start polling in its own process * (the interval would keep the CLI alive forever and duplicate work). * * Refuse to spawn if no `ao start` is running, or if the running instance is * not polling this project. Without an active daemon, sessions get worktrees * and tmux panes but no lifecycle reactions (CI-failure routing, review * comments, revive transitions, event log). That silent blackout is a * worse failure mode than creating no session at all — so fail fast with * an actionable error. */ async function ensureAOPollingProject(projectId: string): Promise { const running = await getRunning(); if (!running) { throw new Error( `AO is not running — lifecycle polling is inactive. Run \`ao start\` before spawning sessions so they get CI/review routing and state advancement.`, ); } if (!running.projects.includes(projectId)) { throw new Error( `The running AO instance (pid ${running.pid}) is not polling project "${projectId}". Run \`ao start ${projectId}\` before spawning so sessions get tracked.`, ); } } /** * Run pre-flight checks for a project once, before any sessions are spawned. * * Iterates the plugins selected for this spawn and calls each one's optional * `preflight()`. Plugins own their own prerequisites (binary present, auth * configured, etc.) so this CLI helper does not need to know which plugin * needs which tool. Adding a new runtime/tracker/scm plugin only requires * the plugin to declare its own preflight — no edits here. * * Collects every plugin's failure rather than aborting at the first one, so a * user with multiple broken prerequisites (e.g. tmux missing AND gh logged * out) sees both errors in a single run instead of fixing one and re-invoking. */ async function runSpawnPreflight( config: OrchestratorConfig, projectId: string, options?: SpawnClaimOptions, ): Promise { const project = config.projects[projectId]; if (!project) return; const ctx: PreflightContext = { project, intent: { role: "worker", willClaimExistingPR: !!options?.claimPr, }, }; const registry = await getPluginRegistry(config); // DefaultPluginsSchema (config.ts) defaults runtime/agent/workspace via // .default(), so these are guaranteed strings — no literal fallback needed. const runtimeName = project.runtime ?? config.defaults.runtime; const agentName = project.agent ?? config.defaults.agent; const workspaceName = project.workspace ?? config.defaults.workspace; const trackerName = project.tracker?.plugin; const scmName = project.scm?.plugin; // Only iterate plugins that the spawn will actually exercise. SCM is // skipped unless the user passed --claim-pr; otherwise an unconfigured // gh auth would block spawns that don't touch PRs. const candidates: Array = [ registry.get("runtime", runtimeName), registry.get("agent", agentName), registry.get("workspace", workspaceName), trackerName ? registry.get("tracker", trackerName) : null, options?.claimPr && scmName ? registry.get("scm", scmName) : null, ]; const errors: Error[] = []; for (const plugin of candidates) { const preflight = (plugin as { preflight?: (ctx: PreflightContext) => Promise } | null) ?.preflight; if (!preflight) continue; try { await preflight.call(plugin, ctx); } catch (err) { errors.push(err instanceof Error ? err : new Error(String(err))); } } if (errors.length === 1) throw errors[0]; if (errors.length > 1) { throw new Error( `${errors.length} preflight checks failed:\n` + errors.map((e, i) => ` ${i + 1}. ${e.message}`).join("\n"), ); } } async function spawnSession( config: OrchestratorConfig, projectId: string, issueId?: string, openTab?: boolean, agent?: string, claimOptions?: SpawnClaimOptions, prompt?: string, ): Promise { const spinner = ora("Creating session").start(); try { const sm = await getSessionManager(config); spinner.text = "Spawning session via core"; // Validate and sanitize prompt (strip newlines to prevent metadata injection) const sanitizedPrompt = prompt?.replace(/[\r\n]/g, " ").trim() || undefined; if (sanitizedPrompt && sanitizedPrompt.length > 4096) { throw new Error("Prompt must be at most 4096 characters"); } recordActivityEvent({ projectId, source: "cli", kind: "cli.spawn_invoked", level: "info", summary: `ao spawn invoked${issueId ? ` for issue ${issueId}` : ""}`, data: { issueId: issueId ?? null, agent: agent ?? null, hasPrompt: !!sanitizedPrompt, claimPr: claimOptions?.claimPr ?? null, }, }); const session = await sm.spawn({ projectId, issueId, agent, prompt: sanitizedPrompt, }); let claimedPrUrl: string | null = null; if (claimOptions?.claimPr) { spinner.text = `Claiming PR ${claimOptions.claimPr}`; try { const claimResult = await sm.claimPR(session.id, claimOptions.claimPr, { assignOnGithub: claimOptions.assignOnGithub, }); claimedPrUrl = claimResult.pr.url; } catch (err) { throw new Error( `Session ${session.id} was created, but failed to claim PR ${claimOptions.claimPr}: ${err instanceof Error ? err.message : String(err)}`, { cause: err }, ); } } const issueLabel = issueId ? ` for issue #${issueId}` : ""; const claimLabel = claimedPrUrl ? ` (claimed ${claimedPrUrl})` : ""; const port = config.port ?? DEFAULT_PORT; spinner.succeed(`Session ${chalk.green(session.id)} spawned${issueLabel}${claimLabel}`); console.log(` View: ${chalk.dim(projectSessionUrl(port, projectId, session.id))}`); // Open terminal tab if requested if (openTab) { try { const tmuxTarget = session.runtimeHandle?.id ?? session.id; await exec("open-iterm-tab", [tmuxTarget]); } catch { // Terminal plugin not available } } // Output for scripting console.log(`SESSION=${session.id}`); } catch (err) { spinner.fail("Failed to create or initialize session"); recordActivityEvent({ projectId, source: "cli", kind: "cli.spawn_failed", level: "error", summary: `ao spawn failed${issueId ? ` for issue ${issueId}` : ""}`, data: { issueId: issueId ?? null, agent: agent ?? null, errorMessage: err instanceof Error ? err.message : String(err), }, }); throw err; } } export function registerSpawn(program: Command): void { program .command("spawn") .description("Spawn a single agent session") .argument( "[issue]", "Issue identifier. Accepts bare ids (42, INT-100) or prefixed forms (x402-identity/42, xid/42) to target a specific project by id or sessionPrefix.", ) .allowExcessArguments() .option("--open", "Open session in terminal tab") .option("--agent ", "Override the agent plugin (e.g. codex, claude-code)") .option("--claim-pr ", "Immediately claim an existing PR for the spawned session") .option("--assign-on-github", "Assign the claimed PR to the authenticated GitHub user") .option( "--prompt ", "Initial prompt/instructions for the agent (use instead of an issue)", ) .action( async ( issue: string | undefined, opts: { open?: boolean; agent?: string; claimPr?: string; assignOnGithub?: boolean; prompt?: string; }, command: Command, ) => { if (command.args.length > 1) { console.error( chalk.red( `✗ \`ao spawn\` accepts at most 1 argument, but ${command.args.length} were provided.\n\n` + `Use:\n` + ` ao spawn [issue]`, ), ); process.exit(1); } const config = loadConfig(); let projectId: string; let issueId: string | undefined; try { ({ projectId, issueId } = resolveProjectAndIssue(config, issue)); } catch (err) { console.error(chalk.red(err instanceof Error ? err.message : String(err))); process.exit(1); } if (!opts.claimPr && opts.assignOnGithub) { console.error(chalk.red("--assign-on-github requires --claim-pr on `ao spawn`.")); process.exit(1); } const claimOptions: SpawnClaimOptions = { claimPr: opts.claimPr, assignOnGithub: opts.assignOnGithub, }; try { await runSpawnPreflight(config, projectId, claimOptions); await ensureAOPollingProject(projectId); } catch (err) { recordActivityEvent({ projectId, source: "cli", kind: "cli.spawn_failed", level: "error", summary: `ao spawn preflight failed${issueId ? ` for issue ${issueId}` : ""}`, data: { issueId: issueId ?? null, agent: opts.agent ?? null, claimPr: claimOptions.claimPr ?? null, errorMessage: err instanceof Error ? err.message : String(err), }, }); console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`)); process.exit(1); } try { await spawnSession( config, projectId, issueId, opts.open, opts.agent, claimOptions, opts.prompt, ); } catch (err) { console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`)); process.exit(1); } }, ); } export function registerBatchSpawn(program: Command): void { program .command("batch-spawn") .description("Spawn sessions for multiple issues with duplicate detection") .argument( "", "Issue identifiers. Accepts bare ids or prefixed forms (x402-identity/42, xid/42); mixed projects are grouped automatically.", ) .option("--open", "Open sessions in terminal tabs") .action(async (issues: string[], opts: { open?: boolean }) => { const config = loadConfig(); // Resolve each issue to its target project. Issues without a prefix fall // back to auto-detection; prefixed issues route to the matched project. let fallbackProjectId: string | null = null; const needsFallback = issues.some( (issue) => resolveSpawnTarget(config.projects, issue) === null, ); if (needsFallback) { try { fallbackProjectId = autoDetectProject(config); } catch (err) { console.error(chalk.red(err instanceof Error ? err.message : String(err))); process.exit(1); } } // Group issues by resolved project so each group preflights once. const groups = new Map>(); for (const issue of issues) { const target = resolveSpawnTarget(config.projects, issue, fallbackProjectId ?? undefined); if (!target) { console.error(chalk.red(`Could not resolve project for issue: ${issue}`)); process.exit(1); } if (!config.projects[target.projectId]) { console.error( chalk.red( `Unknown project: ${target.projectId}\nAvailable: ${Object.keys(config.projects).join(", ")}`, ), ); process.exit(1); } if (!groups.has(target.projectId)) groups.set(target.projectId, []); groups.get(target.projectId)!.push({ original: issue, resolved: target.issueId }); } console.log(banner("BATCH SESSION SPAWNER")); console.log(); for (const [pid, items] of groups) { console.log(` ${chalk.bold(pid)}: ${items.map((i) => i.original).join(", ")}`); } console.log(); const created: Array<{ session: string; issue: string }> = []; const skipped: Array<{ issue: string; existing: string }> = []; const failed: Array<{ issue: string; error: string }> = []; const sm = await getSessionManager(config); for (const [groupProjectId, items] of groups) { // Pre-flight once per project group so a missing prerequisite fails fast. try { await runSpawnPreflight(config, groupProjectId); await ensureAOPollingProject(groupProjectId); } catch (err) { recordActivityEvent({ projectId: groupProjectId, source: "cli", kind: "cli.spawn_failed", level: "error", summary: `batch-spawn preflight failed for group`, data: { batchSize: items.length, errorMessage: err instanceof Error ? err.message : String(err), }, }); console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`)); process.exit(1); } // Load existing sessions once per group (exclude terminal sessions so // merged/completed sessions don't block respawning a reopened issue). const existingSessions = await sm.list(groupProjectId); const existingIssueMap = new Map( existingSessions .filter((s) => s.issueId && !TERMINAL_STATUSES.has(s.status)) .map((s) => [s.issueId!.toLowerCase(), s.id]), ); const spawnedIssues = new Set(); for (const { original, resolved } of items) { if (spawnedIssues.has(resolved.toLowerCase())) { console.log(chalk.yellow(` Skip ${original} — duplicate in this batch`)); skipped.push({ issue: original, existing: "(this batch)" }); continue; } const existingSessionId = existingIssueMap.get(resolved.toLowerCase()); if (existingSessionId) { console.log( chalk.yellow(` Skip ${original} — already has session ${existingSessionId}`), ); skipped.push({ issue: original, existing: existingSessionId }); continue; } try { const session = await sm.spawn({ projectId: groupProjectId, issueId: resolved }); created.push({ session: session.id, issue: original }); spawnedIssues.add(resolved.toLowerCase()); console.log(chalk.green(` Created ${session.id} for ${original}`)); if (opts.open) { try { const tmuxTarget = session.runtimeHandle?.id ?? session.id; await exec("open-iterm-tab", [tmuxTarget]); } catch { // best effort } } } catch (err) { failed.push({ issue: original, error: err instanceof Error ? err.message : String(err), }); console.log( chalk.red( ` Failed ${original} — ${err instanceof Error ? err.message : String(err)}`, ), ); } } } console.log(); if (created.length > 0) { console.log(chalk.green(`Created ${created.length} sessions:`)); for (const item of created) console.log(` ${item.session} ← ${item.issue}`); } if (skipped.length > 0) { console.log(chalk.yellow(`Skipped ${skipped.length} issues:`)); for (const item of skipped) console.log(` ${item.issue} (existing: ${item.existing})`); } if (failed.length > 0) { console.log(chalk.red(`Failed ${failed.length} issues:`)); for (const item of failed) console.log(` ${item.issue}: ${item.error}`); } console.log(); }); }