feat: implement ao start command for unified orchestrator startup

Adds `ao start` and `ao stop` commands to unify orchestrator and
dashboard startup. Key features:

- Generates CLAUDE.orchestrator.md with project-specific context
- Auto-imports orchestrator prompt via CLAUDE.local.md
- Creates orchestrator tmux session with agent
- Starts Next.js dashboard server
- Supports --no-dashboard, --no-orchestrator, --regenerate flags
- Idempotent operation (safe to run multiple times)
- Computes orchestrator ID from config (not session search)
- Dashboard button always visible for orchestrator terminal

Components:
- packages/core/src/orchestrator-prompt.ts: Prompt generator
- packages/cli/src/commands/start.ts: Start/stop commands
- Modified exports and dashboard UI for orchestrator support

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Prateek 2026-02-15 11:14:27 +05:30
parent de662dc042
commit 3ca65a271c
6 changed files with 603 additions and 11 deletions

View File

@ -0,0 +1,375 @@
/**
* `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, dirname } from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
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";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const require = createRequire(import.meta.url);
/**
* Locate the @composio/ao-web package directory.
* Reuses the logic from dashboard.ts.
*/
function findWebDir(): string {
try {
const pkgJson = require.resolve("@composio/ao-web/package.json");
return resolve(pkgJson, "..");
} catch {
const candidates = [
resolve(__dirname, "../../../web"),
resolve(__dirname, "../../../../packages/web"),
];
for (const candidate of candidates) {
if (existsSync(resolve(candidate, "package.json"))) {
return candidate;
}
}
return candidates[0];
}
}
/**
* 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 pkill to find and kill the Next.js dev server.
* Best effort if it fails, just warn the user.
*/
async function stopDashboard(port: number): Promise<void> {
try {
await exec("pkill", ["-f", `next dev -p ${port}`]);
console.log(chalk.green("Dashboard stopped"));
} 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`));
// Check if orchestrator session already exists
const exists = await hasTmuxSession(sessionId);
if (exists && opts?.orchestrator !== false) {
console.log(chalk.yellow(`Orchestrator session "${sessionId}" is already running.`));
console.log(chalk.dim(` Attach: tmux attach -t ${sessionId}`));
console.log(chalk.dim(` Dashboard: http://localhost:${port}\n`));
return;
}
// Ensure CLAUDE.orchestrator.md exists
const spinner = ora("Generating orchestrator prompt").start();
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");
try {
ensureOrchestratorImport(project.path);
spinner.succeed("CLAUDE.local.md configured");
} catch (err) {
spinner.fail("Could not write CLAUDE.local.md");
throw new Error(
`Failed to update CLAUDE.local.md: ${err instanceof Error ? err.message : String(err)}`,
);
}
// Start dashboard (unless --no-dashboard)
let dashboardProcess: ChildProcess | null = null;
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)
if (opts?.orchestrator !== false && !exists) {
spinner.start("Creating orchestrator session");
// Get agent launch command
const agent = getAgent(config, projectId);
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,
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);
// Create tmux session
await newTmuxSession({
name: sessionId,
cwd: project.path,
environment,
});
// 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,
});
}
// Print summary
console.log(chalk.bold.green("\n✓ Orchestrator started\n"));
console.log(chalk.cyan("Dashboard:"), `http://localhost:${port}`);
console.log(chalk.cyan("Session:"), `tmux attach -t ${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, 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);
}
});
}

View File

@ -9,6 +9,7 @@ import { registerSend } from "./commands/send.js";
import { registerReviewCheck } from "./commands/review-check.js";
import { registerDashboard } from "./commands/dashboard.js";
import { registerOpen } from "./commands/open.js";
import { registerStart, registerStop } from "./commands/start.js";
const program = new Command();
@ -18,6 +19,8 @@ program
.version("0.1.0");
registerInit(program);
registerStart(program);
registerStop(program);
registerStatus(program);
registerSpawn(program);
registerBatchSpawn(program);

View File

@ -48,5 +48,9 @@ export type { LifecycleManagerDeps } from "./lifecycle-manager.js";
export { buildPrompt, BASE_AGENT_PROMPT } from "./prompt-builder.js";
export type { PromptBuildConfig } from "./prompt-builder.js";
// Orchestrator prompt — generates CLAUDE.orchestrator.md
export { generateOrchestratorPrompt } from "./orchestrator-prompt.js";
export type { OrchestratorPromptConfig } from "./orchestrator-prompt.js";
// Shared utilities
export { shellEscape, escapeAppleScript, validateUrl } from "./utils.js";

View File

@ -0,0 +1,209 @@
/**
* Orchestrator Prompt Generator generates CLAUDE.orchestrator.md content.
*
* This file is imported into CLAUDE.local.md (gitignored) in the main checkout
* to provide orchestrator-specific context when the orchestrator agent runs.
*/
import type { OrchestratorConfig, ProjectConfig } from "./types.js";
export interface OrchestratorPromptConfig {
config: OrchestratorConfig;
projectId: string;
project: ProjectConfig;
}
/**
* Generate markdown content for CLAUDE.orchestrator.md.
* Provides orchestrator agent with context about available commands,
* session management workflows, and project configuration.
*/
export function generateOrchestratorPrompt(opts: OrchestratorPromptConfig): string {
const { config, projectId, project } = opts;
const sections: string[] = [];
// Header
sections.push(`# CLAUDE.orchestrator.md - ${project.name} Orchestrator
You are the **orchestrator agent** for the ${project.name} project.
Your role is to coordinate and manage worker agent sessions. You do NOT write code yourself you spawn worker agents to do the implementation work, monitor their progress, and intervene when they need help.`);
// Project Info
sections.push(`## Project Info
- **Name**: ${project.name}
- **Repository**: ${project.repo}
- **Default Branch**: ${project.defaultBranch}
- **Session Prefix**: ${project.sessionPrefix}
- **Local Path**: ${project.path}
- **Data Directory**: ${config.dataDir}
- **Worktree Directory**: ${config.worktreeDir}
- **Dashboard Port**: ${config.port}`);
// Quick Start
sections.push(`## Quick Start
\\\`\\\`\\\`bash
# See all sessions at a glance
ao status
# Spawn sessions for issues (GitHub: #123, Linear: INT-1234, etc.)
ao spawn ${projectId} INT-1234
ao batch-spawn ${projectId} INT-1 INT-2 INT-3
# List sessions
ao session ls -p ${projectId}
# Send message to a session
ao send ${project.sessionPrefix}-1 "Your message here"
# Kill a session
ao session kill ${project.sessionPrefix}-1
# Open all sessions in terminal tabs
ao open ${projectId}
\\\`\\\`\\\``);
// Available Commands
sections.push(`## Available Commands
| Command | Description |
|---------|-------------|
| \`ao status\` | Show all sessions with PR/CI/review status |
| \`ao spawn <project> [issue]\` | Spawn a single worker agent session |
| \`ao batch-spawn <project> <issues...>\` | Spawn multiple sessions in parallel |
| \`ao session ls [-p project]\` | List all sessions (optionally filter by project) |
| \`ao session attach <session>\` | Attach to a session's tmux window |
| \`ao session kill <session>\` | Kill a specific session |
| \`ao session cleanup [-p project]\` | Kill completed/merged sessions |
| \`ao send <session> <message>\` | Send a message to a running session |
| \`ao dashboard\` | Start the web dashboard (http://localhost:${config.port}) |
| \`ao open <project>\` | Open all project sessions in terminal tabs |`);
// Session Management
sections.push(`## Session Management
### Spawning Sessions
When you spawn a session:
1. A git worktree is created from \`${project.defaultBranch}\`
2. A feature branch is created (e.g., \`feat/INT-1234\`)
3. A tmux session is started (e.g., \`${project.sessionPrefix}-1\`)
4. The agent is launched with context about the issue
5. Metadata is written to \`${config.dataDir}\`
### Monitoring Progress
Use \`ao status\` to see:
- Current session status (working, pr_open, review_pending, etc.)
- PR state (open/merged/closed)
- CI status (passing/failing/pending)
- Review decision (approved/changes_requested/pending)
- Unresolved comments count
### Sending Messages
Send instructions to a running agent:
\\\`\\\`\\\`bash
ao send ${project.sessionPrefix}-1 "Please address the review comments on your PR"
\\\`\\\`\\\`
### Cleanup
Remove completed sessions:
\\\`\\\`\\\`bash
ao session cleanup -p ${projectId} # Kill sessions where PR is merged or issue is closed
\\\`\\\`\\\``);
// Dashboard
sections.push(`## Dashboard
The web dashboard runs at **http://localhost:${config.port}**.
Features:
- Live session cards with activity status
- PR table with CI checks and review state
- Attention zones (merge ready, needs response, working, done)
- One-click actions (send message, kill, merge PR)
- Real-time updates via Server-Sent Events`);
// Reactions (if configured)
if (project.reactions && Object.keys(project.reactions).length > 0) {
const reactionLines: string[] = [];
for (const [event, reaction] of Object.entries(project.reactions)) {
if (reaction.auto && reaction.action === "send-to-agent") {
reactionLines.push(`- **${event}**: Auto-sends instruction to agent (retries: ${reaction.retries ?? "none"}, escalates after: ${reaction.escalateAfter ?? "never"})`);
} else if (reaction.auto && reaction.action === "notify") {
reactionLines.push(`- **${event}**: Notifies human (priority: ${reaction.priority ?? "info"})`);
}
}
if (reactionLines.length > 0) {
sections.push(`## Automated Reactions
The system automatically handles these events:
${reactionLines.join("\n")}`);
}
}
// Workflows
sections.push(`## Common Workflows
### Bulk Issue Processing
1. Get list of issues from tracker (GitHub/Linear/etc.)
2. Use \`ao batch-spawn\` to spawn sessions for each issue
3. Monitor with \`ao status\` or the dashboard
4. Agents will fetch, implement, test, PR, and respond to reviews
5. Use \`ao session cleanup\` when PRs are merged
### Handling Stuck Agents
1. Check \`ao status\` for sessions in "stuck" or "needs_input" state
2. Attach with \`ao session attach <session>\` to see what they're doing
3. Send clarification or instructions with \`ao send <session> '...'\`
4. Or kill and respawn with fresh context if needed
### PR Review Flow
1. Agent creates PR and pushes
2. CI runs automatically
3. If CI fails: reaction auto-sends fix instructions to agent
4. If reviewers request changes: reaction auto-sends comments to agent
5. When approved + green: notify human to merge (unless auto-merge enabled)
### Manual Intervention
When an agent needs human judgment:
1. You'll get a notification (desktop/slack/webhook)
2. Check the dashboard or \`ao status\` for details
3. Attach to the session if needed: \`ao session attach <session>\`
4. Send instructions: \`ao send <session> '...'\`
5. Or handle it yourself (merge PR, close issue, etc.)`);
// Tips
sections.push(`## Tips
1. **Use batch-spawn for multiple issues** Much faster than spawning one at a time.
2. **Check status before spawning** Avoid creating duplicate sessions for issues already being worked on.
3. **Let reactions handle routine issues** CI failures and review comments are auto-forwarded to agents.
4. **Trust the metadata** Session files in \`${config.dataDir}\` track branch, PR, status, etc.
5. **Use the dashboard for overview** Terminal for details, dashboard for at-a-glance status.
6. **Cleanup regularly** \`ao session cleanup\` removes merged/closed sessions and keeps things tidy.
7. **Monitor the event log** Check \`${config.dataDir}/events.jsonl\` for full system activity history.
8. **Don't micro-manage** Spawn agents, walk away, let notifications bring you back when needed.`);
// Project-specific rules (if any)
if (project.orchestratorRules) {
sections.push(`## Project-Specific Rules
${project.orchestratorRules}`);
}
return sections.join("\n\n");
}

View File

@ -12,9 +12,12 @@ export default async function Home() {
try {
const { config, registry, sessionManager } = await getServices();
const allSessions = await sessionManager.list();
// Find and filter out orchestrator sessions — they get their own button, not a card
const orchSession = allSessions.find((s) => s.id.endsWith("-orchestrator"));
if (orchSession) orchestratorId = orchSession.id;
// Compute expected orchestrator ID from config
const firstProject = Object.values(config.projects)[0];
orchestratorId = firstProject ? `${firstProject.sessionPrefix}-orchestrator` : null;
// Filter out orchestrator from worker sessions
const coreSessions = allSessions.filter((s) => !s.id.endsWith("-orchestrator"));
sessions = coreSessions.map(sessionToDashboard);

View File

@ -87,14 +87,12 @@ export function Dashboard({ sessions, stats, orchestratorId }: DashboardProps) {
<span className="text-[#7c8aff]">Agent</span> Orchestrator
</h1>
<div className="flex items-baseline gap-4">
{orchestratorId && (
<a
href={`/sessions/${encodeURIComponent(orchestratorId)}`}
className="rounded-md border border-[var(--color-border-default)] px-3 py-1 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-accent-blue)] hover:text-[var(--color-accent-blue)]"
>
orchestrator terminal
</a>
)}
<a
href={`/sessions/${encodeURIComponent(orchestratorId ?? "ao-orchestrator")}`}
className="rounded-md border border-[var(--color-border-default)] px-3 py-1 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-accent-blue)] hover:text-[var(--color-accent-blue)]"
>
orchestrator terminal
</a>
<ClientTimestamp />
</div>
</div>