feat: implement ao start command for unified orchestrator startup (#42)

* 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>

* feat: add automatic metadata updates via Claude Code hooks

CRITICAL: This makes the dashboard work by auto-updating metadata when
agents run git/gh commands. Without this, PRs created by agents never
appear on the dashboard.

Changes:
- packages/core/src/claude-hooks.ts: Setup Claude hooks (settings.json + metadata-updater.sh)
- ao start: Automatically configures Claude hooks in project directory
- ao spawn: Sets AO_SESSION and AO_DATA_DIR env vars for hook script
- metadata-updater.sh: Detects gh pr create, git checkout -b, gh pr merge

How it works:
1. PostToolUse hook fires after every Bash command
2. metadata-updater.sh receives JSON with command and output
3. Pattern matches git/gh commands and updates flat metadata files
4. Dashboard reads metadata files to show PR/branch/status

The .claude directory is symlinked from main repo to worktrees so all
sessions share the same hook config.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* refactor: move hook setup to Agent plugin interface

ARCHITECTURE: Hooks setup must go through the Agent plugin interface,
not be hardcoded for Claude Code. This allows other agents (Codex,
Aider, OpenCode) to implement their own metadata update mechanisms.

Changes:
- Added Agent.setupWorkspaceHooks() method to types.ts
- Added WorkspaceHooksConfig interface
- Implemented setupWorkspaceHooks() in Claude Code plugin
- ao start: calls agent.setupWorkspaceHooks() instead of direct setup
- ao spawn: calls agent.setupWorkspaceHooks() for new worktrees
- Uses $CLAUDE_PROJECT_DIR variable for hook path (works with symlinked .claude)

Each agent plugin now implements its own hook mechanism:
- Claude Code: .claude/settings.json with PostToolUse hook
- Future: Codex, Aider, OpenCode with their own config formats

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: address Cursor Bugbot review comments

Fixes 3 issues identified by Cursor Bugbot:

1. HIGH: ao start is now truly idempotent - if orchestrator session exists,
   it skips creating the session but still proceeds with dashboard startup
   and hook configuration. This allows `ao start` to recover from dashboard
   crashes without failing.

2. MEDIUM: Dashboard orchestrator button now finds the actual running
   orchestrator session instead of always using the first project. Fallback
   to first project ID if no orchestrator is running.

3. LOW: Deduplicated findWebDir() function by moving it to shared utility
   lib/web-dir.ts. Now used by both dashboard.ts and start.ts.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: resolve ESLint errors

Fixes 4 ESLint errors identified in CI:
- Added { cause: err } to Error constructors (preserve-caught-error rule)
- Prefixed unused parameter 'config' with underscore in setupWorkspaceHooks
- Prefixed unused variable 'projectId' with underscore in stop command

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: address bugbot review comments - markdown escaping and unused module

- Fix markdown code fence escaping in orchestrator-prompt.ts (change
  `\\\`` to `\`` for proper markdown rendering)
- Remove unused claude-hooks.ts module (functionality moved to plugin)
- Clean up exports from core/src/index.ts

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: address remaining bugbot issues - metadata path, duplication, summary

HIGH severity - Fix metadata path mismatch for worker sessions:
- Add AO_PROJECT_ID environment variable in spawn.ts
- Update metadata-updater hook script to construct correct path:
  * Worker sessions: $AO_DATA_DIR/${AO_PROJECT_ID}-sessions/$AO_SESSION
  * Orchestrator: $AO_DATA_DIR/$AO_SESSION (no project ID)
- Fixes silent hook failures where PRs/branches never appeared on dashboard

LOW severity - Remove code duplication in hook setup:
- Extract setupHookInWorkspace() helper function (90 lines)
- Refactor setupWorkspaceHooks() to use helper (from 80 lines to 4)
- Refactor postLaunchSetup() to use helper (from 82 lines to 6)
- Eliminates risk of methods drifting out of sync

LOW severity - Fix misleading summary output:
- Change "Orchestrator started" to "Startup complete" when components skipped
- Only show dashboard URL when --no-dashboard NOT used
- Only show session info when --no-orchestrator NOT used
- Show "already running" status when orchestrator exists

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: address new bugbot issues - hook quoting, orchestrator link, pkill scope

HIGH severity - Fix Claude hook command quoting:
- Remove embedded double quotes from $CLAUDE_PROJECT_DIR path
- Change from '"$CLAUDE_PROJECT_DIR"/.claude/...' to '$CLAUDE_PROJECT_DIR/.claude/...'
- Prevents hook execution failures if runner treats command as literal path

MEDIUM severity - Fix orchestrator link pointing to nowhere:
- Only show "orchestrator terminal" link when session actually exists
- Remove fallback to computed/hardcoded "ao-orchestrator" ID
- Prevents 404s when user clicks link before starting orchestrator

MEDIUM severity - Fix stop command killing unrelated processes:
- Replace broad `pkill -f "next dev -p ${port}"` with targeted approach
- Use `lsof -ti :${port}` to find exact PID listening on port
- Only kill the specific process, not any process mentioning "next dev"
- Prevents accidentally killing unrelated Next.js dev servers

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: orchestrator metadata path and multi-pid dashboard stop

HIGH severity - Fix orchestrator AO_PROJECT_ID pollution:
- Orchestrator intentionally omits AO_PROJECT_ID (uses flat metadata path)
- agent.getEnvironment() adds AO_PROJECT_ID=project.name
- Object.assign() merged this in, breaking metadata hook path lookup
- Fix: delete environment.AO_PROJECT_ID after merge
- Ensures orchestrator metadata updates work correctly

MEDIUM severity - Fix stopDashboard with multiple PIDs:
- lsof -ti :PORT returns multiple PIDs (one per line) for parent+children
- Passing entire multi-line string to kill fails (can't parse newlines)
- Fix: split stdout by newlines, filter empty, pass PIDs as separate args
- Now correctly stops dashboard even when multiple Node processes exist

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: remove AO_PROJECT_ID from agent plugins to fix metadata path mismatch

The agent.getEnvironment() method was setting AO_PROJECT_ID to
config.projectConfig.name, but different callers have different metadata
path schemes:
- spawn.ts writes to project-specific directories (dataDir/{projectId}-sessions/)
- start.ts writes to flat directories for orchestrator (dataDir/)
- session-manager writes to flat directories (dataDir/)

Setting AO_PROJECT_ID in getEnvironment() caused the metadata updater hook
to look for files in the wrong location for orchestrator and session-manager
flows, breaking automatic metadata updates.

Fix: Remove AO_PROJECT_ID from all agent plugins' getEnvironment() methods
and make it the caller's responsibility to set when using project-specific
directories. Only spawn.ts sets it now.

Changes:
- Remove AO_PROJECT_ID assignment from getEnvironment() in all 4 agent plugins
  (claude-code, aider, codex, opencode)
- Update corresponding tests to expect AO_PROJECT_ID to be undefined
- Remove delete environment.AO_PROJECT_ID statement from start.ts (no longer needed)
- Add comments explaining the metadata path scheme contract

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: only run orchestrator setup when actually starting orchestrator

The orchestrator-specific setup steps (generating CLAUDE.orchestrator.md,
configuring CLAUDE.local.md, and setting up agent hooks) were executing
unconditionally, even when --no-orchestrator was passed. This caused
`ao start --no-dashboard` to fail if hook setup had errors, because the
hook setup was fatal and blocked the dashboard from starting.

Fix: Move all orchestrator setup steps inside the `if (opts?.orchestrator !== false)`
guard, and specifically inside the `else` branch (when session doesn't already exist).
Now these steps only run when we're actually creating a new orchestrator session.

This allows:
- `ao start --no-orchestrator` to start only the dashboard
- Skipping setup when orchestrator session already exists
- Setup to be non-blocking for dashboard-only mode

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: eliminate redundant getAgent call by hoisting agent declaration

The agent instance was being created twice in the orchestrator setup block:
- Once at line 237 inside the hook setup try block
- Again at line 253 for getting the launch command

This creates duplicate agent instances unnecessarily. Fixed by declaring
the agent variable before the hook setup try block, allowing it to be
reused for both hook setup and launch command generation.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: address resource leaks and undefined env variable

Fixed 4 Bugbot issues:

1. HIGH: Undefined $CLAUDE_PROJECT_DIR in hook script path
   - Changed setupWorkspaceHooks to use absolute path instead of undefined
     env variable
   - Matches approach used in postLaunchSetup for consistency

2. HIGH: Orchestrator session leaks when metadata write fails
   - Added try-catch around tmux session launch and metadata write
   - Kills tmux session if metadata write or agent launch fails
   - Prevents orphaned sessions from consuming resources

3. MEDIUM: Dashboard process leaks when orchestrator setup fails
   - Wrapped orchestrator setup in try-catch block
   - Kills dashboard child process if orchestrator setup fails
   - Prevents orphaned Next.js server from blocking future starts

4. LOW: Inconsistent indentation (fixed as side effect of restructuring)
   - Refactored error handling simplified indentation structure

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
prateek 2026-02-16 04:58:39 +05:30 committed by GitHub
parent 1cd7c519af
commit 77323cb309
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 834 additions and 128 deletions

View File

@ -1,42 +1,10 @@
import { spawn } from "node:child_process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { resolve, dirname } from "node:path";
import { resolve } from "node:path";
import { existsSync } from "node:fs";
import chalk from "chalk";
import type { Command } from "commander";
import { loadConfig } from "@composio/ao-core";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const require = createRequire(import.meta.url);
/**
* Locate the @composio/ao-web package directory.
* Uses createRequire for ESM-compatible require.resolve, with fallback
* to sibling package paths that work from both src/ and dist/.
*/
function findWebDir(): string {
// Try to resolve from node_modules first (installed as workspace dep)
try {
const pkgJson = require.resolve("@composio/ao-web/package.json");
return resolve(pkgJson, "..");
} catch {
// Fallback: sibling package in monorepo (works both from src/ and dist/)
// packages/cli/src/commands/ → packages/web
// packages/cli/dist/commands/ → packages/web
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];
}
}
import { findWebDir } from "../lib/web-dir.js";
export function registerDashboard(program: Command): void {
program

View File

@ -115,6 +115,22 @@ async function spawnSession(
}
}
// Get agent plugin (used for hooks and launch)
const agent = getAgent(config, projectId);
// Setup agent hooks for automatic metadata updates (before agent launch)
spinner.text = "Configuring agent hooks";
if (agent.setupWorkspaceHooks) {
try {
await agent.setupWorkspaceHooks(worktreePath, {
dataDir: config.dataDir,
sessionId: sessionName,
});
} catch {
// Non-fatal — continue even if hook setup fails
}
}
spinner.text = "Creating tmux session";
// Create tmux session
@ -129,6 +145,12 @@ async function spawnSession(
"-e",
`${envVar}=${sessionName}`,
"-e",
`AO_SESSION=${sessionName}`,
"-e",
`AO_PROJECT_ID=${projectId}`,
"-e",
`AO_DATA_DIR=${config.dataDir}`,
"-e",
"DIRENV_LOG_FORMAT=",
]);
@ -143,7 +165,6 @@ async function spawnSession(
}
// Start agent via plugin
const agent = getAgent(config, projectId);
const launchCmd = agent.getLaunchCommand({
sessionId: sessionName,
projectConfig: project,

View File

@ -0,0 +1,398 @@
/**
* `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);
}
});
}

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

@ -0,0 +1,40 @@
/**
* Web directory locator finds the @composio/ao-web package.
* Shared utility to avoid duplication between dashboard.ts and start.ts.
*/
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { resolve, dirname } from "node:path";
import { existsSync } from "node:fs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const require = createRequire(import.meta.url);
/**
* Locate the @composio/ao-web package directory.
* Uses createRequire for ESM-compatible require.resolve, with fallback
* to sibling package paths that work from both src/ and dist/.
*/
export function findWebDir(): string {
// Try to resolve from node_modules first (installed as workspace dep)
try {
const pkgJson = require.resolve("@composio/ao-web/package.json");
return resolve(pkgJson, "..");
} catch {
// Fallback: sibling package in monorepo (works both from src/ and dist/)
// packages/cli/src/lib/ → packages/web
// packages/cli/dist/lib/ → packages/web
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];
}
}

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

@ -207,6 +207,21 @@ export interface Agent {
/** Optional: run setup after agent is launched (e.g. configure MCP servers) */
postLaunchSetup?(session: Session): Promise<void>;
/**
* Optional: Set up agent-specific hooks/config in the workspace for automatic metadata updates.
* Called once per workspace during ao init/start and when creating new worktrees.
*
* Each agent plugin implements this for their own config format:
* - Claude Code: writes .claude/settings.json with PostToolUse hook
* - Codex: whatever config mechanism Codex uses
* - Aider: .aider.conf.yml or similar
* - OpenCode: its own config
*
* CRITICAL: The dashboard depends on metadata being auto-updated when agents
* run git/gh commands. Without this, PRs created by agents never show up.
*/
setupWorkspaceHooks?(workspacePath: string, config: WorkspaceHooksConfig): Promise<void>;
}
export interface AgentLaunchConfig {
@ -218,6 +233,13 @@ export interface AgentLaunchConfig {
model?: string;
}
export interface WorkspaceHooksConfig {
/** Data directory where session metadata files are stored */
dataDir: string;
/** Optional session ID (may not be known at ao init time) */
sessionId?: string;
}
export interface AgentSessionInfo {
/** Agent's auto-generated summary of what it's working on */
summary: string | null;

View File

@ -155,10 +155,10 @@ describe("getLaunchCommand", () => {
describe("getEnvironment", () => {
const agent = create();
it("sets AO_SESSION_ID and AO_PROJECT_ID", () => {
it("sets AO_SESSION_ID but not AO_PROJECT_ID (caller's responsibility)", () => {
const env = agent.getEnvironment(makeLaunchConfig());
expect(env["AO_SESSION_ID"]).toBe("sess-1");
expect(env["AO_PROJECT_ID"]).toBe("my-project");
expect(env["AO_PROJECT_ID"]).toBeUndefined();
});
it("sets AO_ISSUE_ID when provided", () => {

View File

@ -54,7 +54,7 @@ function createAiderAgent(): Agent {
getEnvironment(config: AgentLaunchConfig): Record<string, string> {
const env: Record<string, string> = {};
env["AO_SESSION_ID"] = config.sessionId;
env["AO_PROJECT_ID"] = config.projectConfig.name;
// NOTE: AO_PROJECT_ID is the caller's responsibility (spawn.ts sets it)
if (config.issueId) {
env["AO_ISSUE_ID"] = config.issueId;
}

View File

@ -202,10 +202,10 @@ describe("getEnvironment", () => {
expect(env["CLAUDECODE"]).toBe("");
});
it("sets AO_SESSION_ID and AO_PROJECT_ID", () => {
it("sets AO_SESSION_ID but not AO_PROJECT_ID (caller's responsibility)", () => {
const env = agent.getEnvironment(makeLaunchConfig());
expect(env["AO_SESSION_ID"]).toBe("sess-1");
expect(env["AO_PROJECT_ID"]).toBe("my-project");
expect(env["AO_PROJECT_ID"]).toBeUndefined();
});
it("sets AO_ISSUE_ID when provided", () => {

View File

@ -8,6 +8,7 @@ import {
type PluginModule,
type RuntimeHandle,
type Session,
type WorkspaceHooksConfig,
} from "@composio/ao-core";
import { execFile } from "node:child_process";
import { open, readdir, readFile, stat, writeFile, mkdir, chmod } from "node:fs/promises";
@ -71,7 +72,14 @@ if [[ -z "\${AO_SESSION:-}" ]]; then
exit 0
fi
metadata_file="$AO_DATA_DIR/$AO_SESSION"
# Construct metadata file path (supports both flat and project-specific directories)
if [[ -n "\${AO_PROJECT_ID:-}" ]]; then
# Project-specific directory (used by spawn command)
metadata_file="$AO_DATA_DIR/\${AO_PROJECT_ID}-sessions/$AO_SESSION"
else
# Flat directory (used by orchestrator)
metadata_file="$AO_DATA_DIR/$AO_SESSION"
fi
# Ensure metadata file exists
if [[ ! -f "$metadata_file" ]]; then
@ -493,6 +501,97 @@ function classifyTerminalOutput(terminalOutput: string): ActivityState {
return "active";
}
// =============================================================================
// Hook Setup Helper
// =============================================================================
/**
* Shared helper to setup PostToolUse hooks in a workspace.
* Writes metadata-updater.sh script and updates settings.json.
*
* @param workspacePath - Path to the workspace directory
* @param hookCommand - Command string for the hook (can use variables like $CLAUDE_PROJECT_DIR)
*/
async function setupHookInWorkspace(workspacePath: string, hookCommand: string): Promise<void> {
const claudeDir = join(workspacePath, ".claude");
const settingsPath = join(claudeDir, "settings.json");
const hookScriptPath = join(claudeDir, "metadata-updater.sh");
// Create .claude directory if it doesn't exist
try {
await mkdir(claudeDir, { recursive: true });
} catch {
// Directory might already exist
}
// Write the metadata updater script
await writeFile(hookScriptPath, METADATA_UPDATER_SCRIPT, "utf-8");
await chmod(hookScriptPath, 0o755); // Make executable
// Read existing settings if present
let existingSettings: Record<string, unknown> = {};
if (existsSync(settingsPath)) {
try {
const content = await readFile(settingsPath, "utf-8");
existingSettings = JSON.parse(content) as Record<string, unknown>;
} catch {
// Invalid JSON or read error — start fresh
}
}
// Merge hooks configuration
const hooks = (existingSettings["hooks"] as Record<string, unknown>) ?? {};
const postToolUse = (hooks["PostToolUse"] as Array<unknown>) ?? [];
// Check if our hook is already configured
let hookIndex = -1;
let hookDefIndex = -1;
for (let i = 0; i < postToolUse.length; i++) {
const hook = postToolUse[i];
if (typeof hook !== "object" || hook === null || Array.isArray(hook)) continue;
const h = hook as Record<string, unknown>;
const hooksList = h["hooks"];
if (!Array.isArray(hooksList)) continue;
for (let j = 0; j < hooksList.length; j++) {
const hDef = hooksList[j];
if (typeof hDef !== "object" || hDef === null || Array.isArray(hDef)) continue;
const def = hDef as Record<string, unknown>;
if (typeof def["command"] === "string" && def["command"].includes("metadata-updater.sh")) {
hookIndex = i;
hookDefIndex = j;
break;
}
}
if (hookIndex >= 0) break;
}
// Add or update our hook
if (hookIndex === -1) {
// No metadata hook exists, add it
postToolUse.push({
matcher: "Bash",
hooks: [
{
type: "command",
command: hookCommand,
timeout: 5000,
},
],
});
} else {
// Hook exists, update the command
const hook = postToolUse[hookIndex] as Record<string, unknown>;
const hooksList = hook["hooks"] as Array<Record<string, unknown>>;
hooksList[hookDefIndex]["command"] = hookCommand;
}
hooks["PostToolUse"] = postToolUse;
existingSettings["hooks"] = hooks;
// Write updated settings
await writeFile(settingsPath, JSON.stringify(existingSettings, null, 2) + "\n", "utf-8");
}
// =============================================================================
// Agent Implementation
// =============================================================================
@ -530,7 +629,12 @@ function createClaudeCodeAgent(): Agent {
// Set session info for introspection
env["AO_SESSION_ID"] = config.sessionId;
env["AO_PROJECT_ID"] = config.projectConfig.name;
// NOTE: AO_PROJECT_ID is NOT set here - it's the caller's responsibility
// to set it based on their metadata path scheme:
// - spawn.ts sets it to projectId for project-specific directories
// - start.ts omits it for orchestrator (flat directories)
// - session manager omits it (flat directories)
if (config.issueId) {
env["AO_ISSUE_ID"] = config.issueId;
@ -605,87 +709,18 @@ function createClaudeCodeAgent(): Agent {
};
},
async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
// Use absolute path for hook command (specific to this workspace)
const hookScriptPath = join(workspacePath, ".claude", "metadata-updater.sh");
await setupHookInWorkspace(workspacePath, hookScriptPath);
},
async postLaunchSetup(session: Session): Promise<void> {
if (!session.workspacePath) return;
// Path to Claude settings directory in workspace
const claudeDir = join(session.workspacePath, ".claude");
const settingsPath = join(claudeDir, "settings.json");
const hookScriptPath = join(claudeDir, "metadata-updater.sh");
// Create .claude directory if it doesn't exist
try {
await mkdir(claudeDir, { recursive: true });
} catch {
// Directory might already exist
}
// Write the metadata updater script to the workspace
await writeFile(hookScriptPath, METADATA_UPDATER_SCRIPT, "utf-8");
await chmod(hookScriptPath, 0o755); // Make executable
// Read existing settings if present
let existingSettings: Record<string, unknown> = {};
if (existsSync(settingsPath)) {
try {
const content = await readFile(settingsPath, "utf-8");
existingSettings = JSON.parse(content) as Record<string, unknown>;
} catch {
// Invalid JSON or read error — start fresh
}
}
// Merge hooks configuration
const hooks = (existingSettings["hooks"] as Record<string, unknown>) ?? {};
const postToolUse = (hooks["PostToolUse"] as Array<unknown>) ?? [];
// Check if our hook is already configured with the correct path
let hookIndex = -1;
let hookDefIndex = -1;
for (let i = 0; i < postToolUse.length; i++) {
const hook = postToolUse[i];
if (typeof hook !== "object" || hook === null || Array.isArray(hook)) continue;
const h = hook as Record<string, unknown>;
const hooksList = h["hooks"];
if (!Array.isArray(hooksList)) continue;
for (let j = 0; j < hooksList.length; j++) {
const hDef = hooksList[j];
if (typeof hDef !== "object" || hDef === null || Array.isArray(hDef)) continue;
const def = hDef as Record<string, unknown>;
if (typeof def["command"] === "string" && def["command"].includes("metadata-updater.sh")) {
hookIndex = i;
hookDefIndex = j;
break;
}
}
if (hookIndex >= 0) break;
}
// Add or update our hook to ensure it points to the current workspace
if (hookIndex === -1) {
// No metadata hook exists, add it
postToolUse.push({
matcher: "Bash",
hooks: [
{
type: "command",
command: hookScriptPath,
timeout: 5000,
},
],
});
} else {
// Hook exists, update the path to current workspace
const hook = postToolUse[hookIndex] as Record<string, unknown>;
const hooksList = hook["hooks"] as Array<Record<string, unknown>>;
hooksList[hookDefIndex]["command"] = hookScriptPath;
}
hooks["PostToolUse"] = postToolUse;
existingSettings["hooks"] = hooks;
// Write updated settings
await writeFile(settingsPath, JSON.stringify(existingSettings, null, 2) + "\n", "utf-8");
// Use absolute path for hook command (specific to this workspace)
const hookScriptPath = join(session.workspacePath, ".claude", "metadata-updater.sh");
await setupHookInWorkspace(session.workspacePath, hookScriptPath);
},
};
}

View File

@ -154,10 +154,10 @@ describe("getLaunchCommand", () => {
describe("getEnvironment", () => {
const agent = create();
it("sets AO_SESSION_ID and AO_PROJECT_ID", () => {
it("sets AO_SESSION_ID but not AO_PROJECT_ID (caller's responsibility)", () => {
const env = agent.getEnvironment(makeLaunchConfig());
expect(env["AO_SESSION_ID"]).toBe("sess-1");
expect(env["AO_PROJECT_ID"]).toBe("my-project");
expect(env["AO_PROJECT_ID"]).toBeUndefined();
});
it("sets AO_ISSUE_ID when provided", () => {

View File

@ -56,7 +56,7 @@ function createCodexAgent(): Agent {
getEnvironment(config: AgentLaunchConfig): Record<string, string> {
const env: Record<string, string> = {};
env["AO_SESSION_ID"] = config.sessionId;
env["AO_PROJECT_ID"] = config.projectConfig.name;
// NOTE: AO_PROJECT_ID is the caller's responsibility (spawn.ts sets it)
if (config.issueId) {
env["AO_ISSUE_ID"] = config.issueId;
}

View File

@ -149,10 +149,10 @@ describe("getLaunchCommand", () => {
describe("getEnvironment", () => {
const agent = create();
it("sets AO_SESSION_ID and AO_PROJECT_ID", () => {
it("sets AO_SESSION_ID but not AO_PROJECT_ID (caller's responsibility)", () => {
const env = agent.getEnvironment(makeLaunchConfig());
expect(env["AO_SESSION_ID"]).toBe("sess-1");
expect(env["AO_PROJECT_ID"]).toBe("my-project");
expect(env["AO_PROJECT_ID"]).toBeUndefined();
});
it("sets AO_ISSUE_ID when provided", () => {

View File

@ -50,7 +50,7 @@ function createOpenCodeAgent(): Agent {
getEnvironment(config: AgentLaunchConfig): Record<string, string> {
const env: Record<string, string> = {};
env["AO_SESSION_ID"] = config.sessionId;
env["AO_PROJECT_ID"] = config.projectConfig.name;
// NOTE: AO_PROJECT_ID is the caller's responsibility (spawn.ts sets it)
if (config.issueId) {
env["AO_ISSUE_ID"] = config.issueId;
}

View File

@ -12,9 +12,15 @@ 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
// Find the orchestrator session (any session ending with -orchestrator)
// Only set orchestratorId if an actual session exists (no fallback)
const orchSession = allSessions.find((s) => s.id.endsWith("-orchestrator"));
if (orchSession) orchestratorId = orchSession.id;
if (orchSession) {
orchestratorId = orchSession.id;
}
// Filter out orchestrator from worker sessions
const coreSessions = allSessions.filter((s) => !s.id.endsWith("-orchestrator"));
sessions = coreSessions.map(sessionToDashboard);