From 3ca65a271ceb8f6e3f9bdcd76eb9b9af8f8cee3f Mon Sep 17 00:00:00 2001 From: Prateek Date: Sun, 15 Feb 2026 11:14:27 +0530 Subject: [PATCH] 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 --- packages/cli/src/commands/start.ts | 375 ++++++++++++++++++++++ packages/cli/src/index.ts | 3 + packages/core/src/index.ts | 4 + packages/core/src/orchestrator-prompt.ts | 209 ++++++++++++ packages/web/src/app/page.tsx | 9 +- packages/web/src/components/Dashboard.tsx | 14 +- 6 files changed, 603 insertions(+), 11 deletions(-) create mode 100644 packages/cli/src/commands/start.ts create mode 100644 packages/core/src/orchestrator-prompt.ts diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts new file mode 100644 index 000000000..598b5f437 --- /dev/null +++ b/packages/cli/src/commands/start.ts @@ -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 { + 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 = { + [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); + } + }); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 2e6691fd1..b8b0b5a75 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 994915d24..0bca88817 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/orchestrator-prompt.ts b/packages/core/src/orchestrator-prompt.ts new file mode 100644 index 000000000..d39bc667f --- /dev/null +++ b/packages/core/src/orchestrator-prompt.ts @@ -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 [issue]\` | Spawn a single worker agent session | +| \`ao batch-spawn \` | Spawn multiple sessions in parallel | +| \`ao session ls [-p project]\` | List all sessions (optionally filter by project) | +| \`ao session attach \` | Attach to a session's tmux window | +| \`ao session kill \` | Kill a specific session | +| \`ao session cleanup [-p project]\` | Kill completed/merged sessions | +| \`ao send \` | Send a message to a running session | +| \`ao dashboard\` | Start the web dashboard (http://localhost:${config.port}) | +| \`ao open \` | 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 \` to see what they're doing +3. Send clarification or instructions with \`ao send '...'\` +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 \` +4. Send instructions: \`ao send '...'\` +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"); +} diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 488ffabe6..e0f1d2b92 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -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); diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index 01e618a64..0005e0d6d 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -87,14 +87,12 @@ export function Dashboard({ sessions, stats, orchestratorId }: DashboardProps) { Agent Orchestrator