agent-orchestrator/packages/cli/src/commands/start.ts

322 lines
11 KiB
TypeScript

/**
* `ao start` and `ao stop` commands — unified orchestrator startup.
*
* Starts the dashboard and orchestrator agent session. The orchestrator prompt
* is passed to the agent via --append-system-prompt (or equivalent flag) at
* launch time — no file writing required.
*/
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import chalk from "chalk";
import ora from "ora";
import type { Command } from "commander";
import {
loadConfig,
generateOrchestratorPrompt,
type OrchestratorConfig,
type ProjectConfig,
} from "@composio/ao-core";
import { exec } from "../lib/shell.js";
import { getSessionManager } from "../lib/create-session-manager.js";
import { findWebDir, buildDashboardEnv } from "../lib/web-dir.js";
import { cleanNextCache } from "../lib/dashboard-rebuild.js";
import { preflight } from "../lib/preflight.js";
/**
* Resolve project from config.
* If projectArg is provided, use it. If only one project exists, use that.
* Otherwise, error with helpful message.
*/
function resolveProject(
config: OrchestratorConfig,
projectArg?: string,
): { projectId: string; project: ProjectConfig } {
const projectIds = Object.keys(config.projects);
if (projectIds.length === 0) {
throw new Error("No projects configured. Add a project to agent-orchestrator.yaml.");
}
// Explicit project argument
if (projectArg) {
const project = config.projects[projectArg];
if (!project) {
throw new Error(
`Project "${projectArg}" not found. Available projects:\n ${projectIds.join(", ")}`,
);
}
return { projectId: projectArg, project };
}
// Only one project — use it
if (projectIds.length === 1) {
const projectId = projectIds[0];
return { projectId, project: config.projects[projectId] };
}
// Multiple projects, no argument — error
throw new Error(
`Multiple projects configured. Specify which one to start:\n ${projectIds.map((id) => `ao start ${id}`).join("\n ")}`,
);
}
/**
* Start dashboard server in the background.
* Returns the child process handle for cleanup.
*/
async function startDashboard(
port: number,
webDir: string,
configPath: string | null,
terminalPort?: number,
directTerminalPort?: number,
): Promise<ChildProcess> {
const env = await buildDashboardEnv(port, configPath, terminalPort, directTerminalPort);
const child = spawn("pnpm", ["run", "dev"], {
cwd: webDir,
stdio: "inherit",
detached: false,
env,
});
child.on("error", (err) => {
console.error(chalk.red("Dashboard failed to start:"), err.message);
// Emit synthetic exit so callers listening on "exit" can clean up
child.emit("exit", 1, null);
});
return child;
}
/**
* Stop dashboard server.
* Uses lsof to find the process listening on the port, then kills it.
* Best effort — if it fails, just warn the user.
*/
async function stopDashboard(port: number): Promise<void> {
try {
// Find PIDs listening on the port (can be multiple: parent + children)
const { stdout } = await exec("lsof", ["-ti", `:${port}`]);
const pids = stdout
.trim()
.split("\n")
.filter((p) => p.length > 0);
if (pids.length > 0) {
// Kill all processes (pass PIDs as separate arguments)
await exec("kill", pids);
console.log(chalk.green("Dashboard stopped"));
} else {
console.log(chalk.yellow(`Dashboard not running on port ${port}`));
}
} catch {
console.log(chalk.yellow("Could not stop dashboard (may not be running)"));
}
}
export function registerStart(program: Command): void {
program
.command("start [project]")
.description("Start orchestrator agent and dashboard for a project")
.option("--no-dashboard", "Skip starting the dashboard server")
.option("--no-orchestrator", "Skip starting the orchestrator agent")
.option("--rebuild", "Clean and rebuild dashboard before starting")
.option(
"--continue",
"Continue the most recent orchestrator conversation (kills existing session first, preserves conversation history with fresh system prompt)",
)
.action(
async (
projectArg?: string,
opts?: {
dashboard?: boolean;
orchestrator?: boolean;
rebuild?: boolean;
continue?: boolean;
},
) => {
try {
const config = loadConfig();
const { projectId, project } = resolveProject(config, projectArg);
const sessionId = `${project.sessionPrefix}-orchestrator`;
const port = config.port ?? 3000;
console.log(chalk.bold(`\nStarting orchestrator for ${chalk.cyan(project.name)}\n`));
// Start dashboard (unless --no-dashboard)
const spinner = ora();
let dashboardProcess: ChildProcess | null = null;
let exists = false; // Track whether orchestrator session already exists
if (opts?.dashboard !== false) {
// Pre-flight: only check port/build when actually starting the dashboard
await preflight.checkPort(port);
const webDir = findWebDir();
if (!existsSync(resolve(webDir, "package.json"))) {
throw new Error("Could not find @composio/ao-web package. Run: pnpm install");
}
await preflight.checkBuilt(webDir);
if (opts?.rebuild) {
await cleanNextCache(webDir);
}
spinner.start("Starting dashboard");
dashboardProcess = await startDashboard(
port,
webDir,
config.configPath,
config.terminalPort,
config.directTerminalPort,
);
spinner.succeed(`Dashboard starting on http://localhost:${port}`);
console.log(chalk.dim(" (Dashboard will be ready in a few seconds)\n"));
}
// Create orchestrator session (unless --no-orchestrator or already exists)
let tmuxTarget = sessionId; // For the attach hint — updated to hash-based name after spawn
if (opts?.orchestrator !== false) {
const sm = await getSessionManager(config);
// Check if orchestrator session already exists
const existing = await sm.get(sessionId);
exists = existing !== null && existing.status !== "killed";
// --continue: kill existing session so claude --continue picks up
// the most recent conversation with a fresh system prompt
if (opts?.continue && exists) {
spinner.start("Killing existing orchestrator session for --continue");
await sm.kill(sessionId);
spinner.succeed("Existing orchestrator session killed");
exists = false;
}
if (exists) {
if (existing?.runtimeHandle?.id) {
tmuxTarget = existing.runtimeHandle.id;
}
console.log(
chalk.yellow(
`Orchestrator session "${sessionId}" is already running (skipping creation)`,
),
);
} else {
try {
spinner.start(
opts?.continue
? "Continuing orchestrator session"
: "Creating orchestrator session",
);
const systemPrompt = generateOrchestratorPrompt({ config, projectId, project });
const session = await sm.spawnOrchestrator({
projectId,
systemPrompt,
continue: opts?.continue,
});
if (session.runtimeHandle?.id) {
tmuxTarget = session.runtimeHandle.id;
}
spinner.succeed(
opts?.continue
? "Orchestrator session continued"
: "Orchestrator session created",
);
} catch (err) {
spinner.fail("Orchestrator setup failed");
// Cleanup dashboard if orchestrator setup fails
if (dashboardProcess) {
dashboardProcess.kill();
}
throw new Error(
`Failed to setup orchestrator: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err },
);
}
}
}
// Print summary based on what was actually started
console.log(chalk.bold.green("\n✓ Startup complete\n"));
if (opts?.dashboard !== false) {
console.log(chalk.cyan("Dashboard:"), `http://localhost:${port}`);
}
if (opts?.orchestrator !== false && !exists) {
console.log(chalk.cyan("Orchestrator:"), `tmux attach -t ${tmuxTarget}`);
} else if (exists) {
console.log(chalk.cyan("Orchestrator:"), `already running (${sessionId})`);
}
console.log(chalk.dim(`Config: ${config.configPath}\n`));
// Keep dashboard process alive if it was started
if (dashboardProcess) {
dashboardProcess.on("exit", (code) => {
if (code !== 0 && code !== null) {
console.error(chalk.red(`Dashboard exited with code ${code}`));
}
process.exit(code ?? 0);
});
}
} 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);
}
} else {
console.error(chalk.red("\nError:"), String(err));
}
process.exit(1);
}
},
);
}
export function registerStop(program: Command): void {
program
.command("stop [project]")
.description("Stop orchestrator agent and dashboard for a project")
.action(async (projectArg?: string) => {
try {
const config = loadConfig();
const { projectId: _projectId, project } = resolveProject(config, projectArg);
const sessionId = `${project.sessionPrefix}-orchestrator`;
const port = config.port ?? 3000;
console.log(chalk.bold(`\nStopping orchestrator for ${chalk.cyan(project.name)}\n`));
// Kill orchestrator session via SessionManager
const sm = await getSessionManager(config);
const existing = await sm.get(sessionId);
if (existing) {
const spinner = ora("Stopping orchestrator session").start();
await sm.kill(sessionId);
spinner.succeed("Orchestrator session stopped");
} else {
console.log(chalk.yellow(`Orchestrator session "${sessionId}" is not running`));
}
// Stop dashboard
await stopDashboard(port);
console.log(chalk.bold.green("\n✓ Orchestrator stopped\n"));
} catch (err) {
if (err instanceof Error) {
console.error(chalk.red("\nError:"), err.message);
} else {
console.error(chalk.red("\nError:"), String(err));
}
process.exit(1);
}
});
}