399 lines
14 KiB
TypeScript
399 lines
14 KiB
TypeScript
/**
|
|
* `ao start` and `ao stop` commands — unified orchestrator startup.
|
|
*
|
|
* Starts both the dashboard and the orchestrator agent session, generating
|
|
* CLAUDE.orchestrator.md and injecting it via CLAUDE.local.md import.
|
|
*/
|
|
|
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
import { resolve, join } from "node:path";
|
|
import chalk from "chalk";
|
|
import ora from "ora";
|
|
import type { Command } from "commander";
|
|
import {
|
|
loadConfig,
|
|
generateOrchestratorPrompt,
|
|
hasTmuxSession,
|
|
newTmuxSession,
|
|
tmuxSendKeys,
|
|
writeMetadata,
|
|
deleteMetadata,
|
|
type OrchestratorConfig,
|
|
type ProjectConfig,
|
|
} from "@composio/ao-core";
|
|
import { exec, getTmuxSessions } from "../lib/shell.js";
|
|
import { getAgent } from "../lib/plugins.js";
|
|
import { findWebDir } from "../lib/web-dir.js";
|
|
|
|
/**
|
|
* Ensure CLAUDE.orchestrator.md exists in the project directory.
|
|
* Generate it if missing or if --regenerate flag is set.
|
|
*/
|
|
function ensureOrchestratorPrompt(
|
|
projectPath: string,
|
|
config: OrchestratorConfig,
|
|
projectId: string,
|
|
project: ProjectConfig,
|
|
regenerate = false,
|
|
): void {
|
|
const promptPath = join(projectPath, "CLAUDE.orchestrator.md");
|
|
|
|
if (existsSync(promptPath) && !regenerate) {
|
|
return; // Already exists and not regenerating
|
|
}
|
|
|
|
const content = generateOrchestratorPrompt({ config, projectId, project });
|
|
writeFileSync(promptPath, content, "utf-8");
|
|
}
|
|
|
|
/**
|
|
* Ensure CLAUDE.local.md imports CLAUDE.orchestrator.md.
|
|
* This function is idempotent — multiple calls have no additional effect.
|
|
*/
|
|
function ensureOrchestratorImport(projectPath: string): void {
|
|
const localMdPath = join(projectPath, "CLAUDE.local.md");
|
|
const importLine = "@CLAUDE.orchestrator.md";
|
|
|
|
let content = "";
|
|
if (existsSync(localMdPath)) {
|
|
content = readFileSync(localMdPath, "utf-8");
|
|
}
|
|
|
|
// Check if import already exists
|
|
if (content.includes(importLine)) {
|
|
return; // Already imported
|
|
}
|
|
|
|
// Append import
|
|
if (content && !content.endsWith("\n")) {
|
|
content += "\n";
|
|
}
|
|
if (content) {
|
|
content += "\n"; // Blank line separator
|
|
}
|
|
content += `${importLine}\n`;
|
|
|
|
writeFileSync(localMdPath, content, "utf-8");
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
function startDashboard(port: number, webDir: string): ChildProcess {
|
|
const child = spawn("npx", ["next", "dev", "-p", String(port)], {
|
|
cwd: webDir,
|
|
stdio: "inherit",
|
|
detached: false,
|
|
});
|
|
|
|
child.on("error", (err) => {
|
|
console.error(chalk.red("Dashboard failed to start:"), err);
|
|
});
|
|
|
|
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("--regenerate", "Regenerate CLAUDE.orchestrator.md")
|
|
.action(
|
|
async (
|
|
projectArg?: string,
|
|
opts?: { dashboard?: boolean; orchestrator?: boolean; regenerate?: boolean },
|
|
) => {
|
|
try {
|
|
const config = loadConfig();
|
|
const { projectId, project } = resolveProject(config, projectArg);
|
|
const sessionId = `${project.sessionPrefix}-orchestrator`;
|
|
const port = config.port;
|
|
|
|
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) {
|
|
spinner.start("Starting dashboard");
|
|
const webDir = findWebDir();
|
|
if (!existsSync(resolve(webDir, "package.json"))) {
|
|
spinner.fail("Dashboard not found");
|
|
throw new Error(
|
|
"Could not find @composio/ao-web package. Run: pnpm install",
|
|
);
|
|
}
|
|
|
|
dashboardProcess = startDashboard(port, webDir);
|
|
spinner.succeed(`Dashboard starting on http://localhost:${port}`);
|
|
console.log(chalk.dim(" (Dashboard will be ready in a few seconds)\n"));
|
|
}
|
|
|
|
// Create orchestrator tmux session (unless --no-orchestrator or already exists)
|
|
if (opts?.orchestrator !== false) {
|
|
// Check if orchestrator session already exists
|
|
exists = await hasTmuxSession(sessionId);
|
|
|
|
if (exists) {
|
|
console.log(chalk.yellow(`Orchestrator session "${sessionId}" is already running (skipping creation)`));
|
|
} else {
|
|
try {
|
|
// Ensure CLAUDE.orchestrator.md exists
|
|
spinner.start("Generating orchestrator prompt");
|
|
ensureOrchestratorPrompt(
|
|
project.path,
|
|
config,
|
|
projectId,
|
|
project,
|
|
opts?.regenerate ?? false,
|
|
);
|
|
spinner.succeed("Orchestrator prompt ready");
|
|
|
|
// Ensure CLAUDE.local.md imports CLAUDE.orchestrator.md
|
|
spinner.start("Configuring CLAUDE.local.md");
|
|
ensureOrchestratorImport(project.path);
|
|
spinner.succeed("CLAUDE.local.md configured");
|
|
|
|
// Get agent instance (used for hooks and launch)
|
|
const agent = getAgent(config, projectId);
|
|
|
|
// Setup agent hooks for automatic metadata updates
|
|
spinner.start("Configuring agent hooks");
|
|
if (agent.setupWorkspaceHooks) {
|
|
await agent.setupWorkspaceHooks(project.path, { dataDir: config.dataDir });
|
|
}
|
|
spinner.succeed("Agent hooks configured");
|
|
|
|
spinner.start("Creating orchestrator session");
|
|
|
|
// Get agent launch command
|
|
const launchCmd = agent.getLaunchCommand({
|
|
sessionId,
|
|
projectConfig: project,
|
|
permissions: project.agentConfig?.permissions ?? "default",
|
|
model: project.agentConfig?.model,
|
|
});
|
|
|
|
// Determine environment variables
|
|
const envVarName = `${project.sessionPrefix.toUpperCase().replace(/[^A-Z0-9_]/g, "_")}_SESSION`;
|
|
const environment: Record<string, string> = {
|
|
[envVarName]: sessionId,
|
|
AO_SESSION: sessionId,
|
|
AO_DATA_DIR: config.dataDir,
|
|
DIRENV_LOG_FORMAT: "",
|
|
};
|
|
|
|
// Merge agent-specific environment
|
|
const agentEnv = agent.getEnvironment({
|
|
sessionId,
|
|
projectConfig: project,
|
|
permissions: project.agentConfig?.permissions ?? "default",
|
|
model: project.agentConfig?.model,
|
|
});
|
|
Object.assign(environment, agentEnv);
|
|
|
|
// NOTE: AO_PROJECT_ID is intentionally not set for orchestrator (uses flat metadata path)
|
|
|
|
// Create tmux session
|
|
await newTmuxSession({
|
|
name: sessionId,
|
|
cwd: project.path,
|
|
environment,
|
|
});
|
|
|
|
try {
|
|
// Launch agent
|
|
await tmuxSendKeys(sessionId, launchCmd, true);
|
|
|
|
spinner.succeed("Orchestrator session created");
|
|
|
|
// Write metadata
|
|
const runtimeHandle = JSON.stringify({
|
|
id: sessionId,
|
|
runtimeName: "tmux",
|
|
data: {},
|
|
});
|
|
|
|
writeMetadata(config.dataDir, sessionId, {
|
|
worktree: project.path,
|
|
branch: project.defaultBranch,
|
|
status: "working",
|
|
project: projectId,
|
|
createdAt: new Date().toISOString(),
|
|
runtimeHandle,
|
|
});
|
|
} catch (err) {
|
|
// Cleanup tmux session if metadata write or agent launch fails
|
|
try {
|
|
await exec("tmux", ["kill-session", "-t", sessionId]);
|
|
} catch {
|
|
// Best effort cleanup - session may not exist
|
|
}
|
|
throw err;
|
|
}
|
|
} 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 ${sessionId}`);
|
|
} else if (exists) {
|
|
console.log(chalk.cyan("Orchestrator:"), `already running (${sessionId})`);
|
|
}
|
|
|
|
console.log(chalk.dim(`Config: ${config.dataDir}\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;
|
|
|
|
console.log(chalk.bold(`\nStopping orchestrator for ${chalk.cyan(project.name)}\n`));
|
|
|
|
// Kill orchestrator session
|
|
const sessions = await getTmuxSessions();
|
|
if (sessions.includes(sessionId)) {
|
|
const spinner = ora("Stopping orchestrator session").start();
|
|
await exec("tmux", ["kill-session", "-t", sessionId]);
|
|
spinner.succeed("Orchestrator session stopped");
|
|
|
|
// Archive metadata
|
|
deleteMetadata(config.dataDir, sessionId, true);
|
|
} 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);
|
|
}
|
|
});
|
|
}
|