npm install -g @composio/ao ā ao start <project-url-or-path>ao start. Only keep existing: --no-dashboard, --no-orchestrator, --rebuild.feat/onboarding-improvements (PR #463).add-project.ts exists only on this branch, not on main.These are already implemented in PR #463. Do NOT redo them. Verify they work.
@composio/ao-web ā private: true removed, production entry point start-all.js addedfiles fieldnode-pty made optional ā direct terminal gracefully degradesfindWebDir() shows install-specific error messages (npm vs source)sudodetectDefaultBranch() deduplicated into lib/git-utils.tsThese are standalone files with no dependencies on each other. Create all three before modifying any command files.
packages/cli/src/lib/running-state.ts~80 lines. Tracks the single running AO instance via ~/.agent-orchestrator/running.json.
// ~/.agent-orchestrator/running.json
{
"pid": 12345,
"configPath": "/Users/me/my-app/agent-orchestrator.yaml",
"port": 3000,
"startedAt": "2026-03-15T10:30:00Z",
"projects": ["my-app", "backend"]
}
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
interface RunningState {
pid: number;
configPath: string;
port: number;
startedAt: string;
projects: string[];
}
const STATE_DIR = join(homedir(), ".agent-orchestrator");
const STATE_FILE = join(STATE_DIR, "running.json");
// Register a new running instance. Call on ao start.
export function register(entry: RunningState): void
// Remove the running entry. Call on ao stop.
export function unregister(): void
// Get current running state. Returns null if not running.
// Auto-prunes if PID is dead.
export function getRunning(): RunningState | null
// Check if AO is already running. Returns the state or null.
export function isAlreadyRunning(): RunningState | null
// Internal: check if a PID is alive
function isProcessAlive(pid: number): boolean
// Use: try { process.kill(pid, 0); return true; } catch { return false; }
getRunning() always prunes stale entries (dead PIDs) before returning~/.agent-orchestrator/ directory with mkdirSync({ recursive: true }) on first writerunning.json is corrupted, log warning and treat as emptywriteFileSync (atomic enough for single-instance model)packages/cli/src/lib/caller-context.ts~40 lines. Detects who is calling the CLI and adjusts behavior.
export type CallerType = "human" | "orchestrator" | "agent";
// Detect caller type from environment
export function getCallerType(): CallerType {
if (process.env.AO_CALLER_TYPE) {
return process.env.AO_CALLER_TYPE as CallerType;
}
// If stdout is a TTY, it's likely a human
return process.stdout.isTTY ? "human" : "agent";
}
export function isHumanCaller(): boolean {
return getCallerType() === "human";
}
// Set environment variables for spawned processes
export function setCallerContext(env: Record<string, string>, opts: {
callerType: CallerType;
sessionId?: string;
projectId?: string;
configPath?: string;
port?: number;
}): void {
env.AO_CALLER_TYPE = opts.callerType;
if (opts.sessionId) env.AO_SESSION_ID = opts.sessionId;
if (opts.projectId) env.AO_PROJECT_ID = opts.projectId;
if (opts.configPath) env.AO_CONFIG_PATH = opts.configPath;
if (opts.port) env.AO_PORT = String(opts.port);
}
| Variable | Values | Set by |
|---|---|---|
AO_CALLER_TYPE | human | orchestrator | agent | Inferred or explicit |
AO_SESSION_ID | e.g. myproj-42 | Set when spawning agent sessions |
AO_PROJECT_ID | e.g. my-app | Set at start and spawn |
AO_CONFIG_PATH | Absolute path | Set at start |
AO_PORT | e.g. 3000 | Set at start |
| Action | human | orchestrator | agent |
|---|---|---|---|
| Auto-open browser | Yes | No | No |
| Colored output | Yes | Minimal | No |
| Interactive prompts | Yes (TTY) | Never | Never |
| JSON output | No | Yes | Yes |
packages/cli/src/lib/config-instruction.ts~60 lines. Outputs the config schema as a text block for agents and humans.
// Returns a formatted text block explaining the full config schema.
// Used by:
// 1. ao config-help (CLI subcommand)
// 2. generateOrchestratorPrompt() in ao-core (auto-injected into system prompt)
export function getConfigInstruction(): string {
return `
# Agent Orchestrator Config Reference
# File: agent-orchestrator.yaml
# Complete schema with all fields, valid values, and defaults.
# ... (include full YAML schema with inline comments)
# ... (include valid enum values for: runtime, agent, workspace, notifiers)
# ... (include an annotated example config)
`.trim();
}
No hardcoded runtime lists. Use the plugin registry (from PR #474) to discover available agent plugins dynamically.
detect() method. The CLI never needs to know how detection works for each runtime.
detect() to agent plugin interfaceEach agent plugin in packages/plugins/agent-*/src/index.ts must export:
export default {
name: "claude-code", // plugin name
displayName: "Claude Code", // human-readable
priority: 100, // higher = preferred
detect(): boolean { // is this runtime available?
try { execSync("which claude", { stdio: "ignore" }); return true; }
catch { return false; }
},
create() { /* existing factory */ }
};
start.tsasync function detectAgentRuntime(registry: PluginRegistry): Promise<string> {
const agents = registry.getAll("agent");
const available = agents.filter(p => p.detect());
if (available.length === 0) {
console.warn("No agent runtimes detected. Defaulting to claude-code.");
return "claude-code";
}
if (available.length === 1) return available[0].name;
// Multiple available
if (isHumanCaller()) {
// Prompt user to pick
return await promptChoice(
available.sort((a, b) => b.priority - a.priority)
.map(p => ({ name: p.displayName, value: p.name }))
);
}
// Agent: pick highest priority
return available.sort((a, b) => b.priority - a.priority)[0].name;
}
detect() + priority. Auto-discovered. Zero CLI changes.@composio/ao-plugin-agent-*)? Same thing.detect() returns false. Skipped.agentPlugins record from lib/plugins.ts as interim, but add detect() to each plugin.start.ts ā The Core ChangeThis is the biggest change. Modify packages/cli/src/commands/start.ts (currently 557 lines, target ~700 lines).
ao start with URL, project name, or no arg must continue to work exactly as before. You are ADDING new branches, not replacing existing ones.
start.ts// Add these imports at top of start.ts
import { register, unregister, isAlreadyRunning } from "../lib/running-state.js";
import { getCallerType, isHumanCaller, setCallerContext } from "../lib/caller-context.js";
import { detectEnvironment } from "../lib/detect-env.js"; // extracted from init.ts
// Agent runtime detection uses the plugin registry ā no separate import needed
import { detectProjectType, generateRulesFromTemplates } from "../lib/project-detection.js";
import { generateSessionPrefix, findConfigFile } from "@composio/ao-core";
detectEnvironment() from init.tsMove detectEnvironment() (lines 45ā102 of init.ts) into a new shared file:
Source: packages/cli/src/commands/init.ts lines 45ā102
Destination: packages/cli/src/lib/detect-env.ts
Keep the function signature identical. Just move it to a shared location so both start.ts and the deprecated init.ts wrapper can import it.
registerStartCurrently, when no config exists, start.ts throws an error:
// CURRENT (line ~494 of start.ts)
if (err.message.includes("No agent-orchestrator.yaml found")) {
console.error(chalk.red("\nNo config found. Run:"));
console.error(chalk.cyan(" ao init\n"));
}
Replace with: absorb handleAutoMode() logic from init.ts (lines 353ā475).
// NEW behavior when no config found:
// 1. Detect environment
const env = await detectEnvironment(process.cwd());
// 2. Detect agent runtime (uses plugin registry ā see step 4)
const runtime = await detectAgentRuntime(registry);
// Plugin registry handles prompting for multiple runtimes automatically
// 3. Detect project type
const projectType = detectProjectType(process.cwd());
// 4. Generate config YAML (reuse init.ts handleAutoMode logic)
// Write to ./agent-orchestrator.yaml
// Print: "ā Config auto-created with [runtime] for [project-type]"
// 5. Load the new config and continue to startup
config = loadConfig();
({ projectId, project } = resolveProject(config));
When ao start ~/other-repo is called and that path is NOT in the current config:
// In registerStart action handler, after loading config:
if (projectArg && !isRepoUrl(projectArg)) {
const resolvedPath = resolve(projectArg.replace(/^~/, homedir()));
// Check if this project is already in config
const existingProject = Object.entries(config.projects).find(
([_, p]) => resolve(p.path) === resolvedPath
);
if (existingProject) {
// Already in config ā just use it
projectId = existingProject[0];
project = existingProject[1];
} else {
// NEW PROJECT ā absorb add-project.ts logic (lines 48-148)
// 1. Detect git info (remote, default branch)
// 2. Generate session prefix (ensure uniqueness)
// 3. Detect project type + generate rules
// 4. Append to config YAML
// 5. Print: "ā Added [project] to config"
// 6. Reload config
config = loadConfig();
({ projectId, project } = resolveProject(config, basename(resolvedPath)));
}
}
Insert this check AFTER config is loaded but BEFORE runStartup():
// Check if AO is already running
const running = isAlreadyRunning();
if (running && isProcessAlive(running.pid)) {
if (isHumanCaller()) {
// Show interactive menu
console.log(chalk.cyan(`\nā¹ AO is already running.`));
console.log(` Dashboard: ${chalk.cyan(`http://localhost:${running.port}`)}`);
console.log(` PID: ${running.pid} | Up: ${formatUptime(running.startedAt)}`);
console.log(` Projects: ${running.projects.join(", ")}\n`);
// Interactive prompt (use inquirer or readline)
const choice = await promptChoice([
"Continue with the current one (open dashboard)",
"Start a new orchestrator on the same project",
"Override current config and restart",
"Quit"
]);
if (choice === "Continue with the current one (open dashboard)") {
await open(`http://localhost:${running.port}`);
process.exit(0);
} else if (choice === "Start a new orchestrator on the same project") {
// Generate unique name: projectId + random 4-char suffix
const suffix = Math.random().toString(36).slice(2, 6);
const newId = `${projectId}-${suffix}`;
// Duplicate the project config with new session prefix
const newPrefix = generateSessionPrefix(newId);
config.projects[newId] = {
...config.projects[projectId],
sessionPrefix: newPrefix,
};
// Write updated config
writeFileSync(configPath, yamlStringify(config, { indent: 2 }));
console.log(chalk.green(`ā New orchestrator "${newId}" added to config`));
// Spawn new orchestrator session (don't restart dashboard)
projectId = newId;
project = config.projects[newId];
// Continue to startup below ā runStartup will spawn the new orchestrator
} else if (choice === "Override current config and restart") {
process.kill(running.pid, "SIGTERM");
unregister();
// Continue to startup below
} else {
process.exit(0);
}
} else {
// Agent caller ā print info and exit
console.log(`AO is already running.`);
console.log(`Dashboard: http://localhost:${running.port}`);
console.log(`PID: ${running.pid}`);
console.log(`Projects: ${running.projects.join(", ")}`);
console.log(`To restart: ao stop && ao start`);
process.exit(0);
}
}
running.json after startupAt the end of runStartup(), after dashboard and orchestrator are running:
// After successful startup, register in running.json
register({
pid: process.pid,
configPath: resolve(findConfigFile() || ""),
port: config.port ?? 3000,
startedAt: new Date().toISOString(),
projects: Object.keys(config.projects),
});
registerStop to use running.jsonIn the registerStop handler:
// Replace lsof-based PID discovery with running.json lookup
const running = getRunning();
if (running) {
// Kill using running.pid instead of lsof
// After stopping, unregister
unregister();
console.log(chalk.green(`\nā Stopped AO on port ${running.port}`));
console.log(chalk.dim(` Projects: ${running.projects.join(", ")}`));
console.log(chalk.dim(` Uptime: ${formatUptime(running.startedAt)}\n`));
} else {
// Fallback to existing lsof method for backward compat
}
packages/cli/src/commands/init.ts ā thin wrapperReplace the entire 481-line file with ~15 lines:
import chalk from "chalk";
import type { Command } from "commander";
export function registerInit(program: Command): void {
program
.command("init")
.description("[deprecated] Use 'ao start' instead ā it auto-creates config on first run")
.action(async () => {
console.log(chalk.yellow(
"ā 'ao init' is deprecated. Use 'ao start' instead.\n" +
" 'ao start' auto-detects your project and creates the config.\n"
));
// Delegate to start's config-creation logic without starting
const { createConfigOnly } = await import("./start.js");
await createConfigOnly();
});
}
createConfigOnly() function from start.ts that runs just the config creation logic (step 7) without starting the dashboard/orchestrator. This is equivalent to ao start --no-dashboard --no-orchestrator.
packages/cli/src/commands/add-project.ts ā thin wrapperReplace the entire 148-line file with ~15 lines:
import chalk from "chalk";
import type { Command } from "commander";
export function registerAddProject(program: Command): void {
program
.command("add-project")
.argument("<path>", "Path to the project repository")
.description("[deprecated] Use 'ao start <path>' instead")
.action(async (projectPath: string) => {
console.log(chalk.yellow(
"ā 'ao add-project' is deprecated. Use 'ao start <path>' instead.\n"
));
// Delegate to start
const { addProjectOnly } = await import("./start.js");
await addProjectOnly(projectPath);
});
}
ao config-helpconfig-help subcommand to index.ts// In packages/cli/src/index.ts, add:
import { getConfigInstruction } from "./lib/config-instruction.js";
// After other registrations:
program
.command("config-help")
.description("Show config schema and guide for creating agent-orchestrator.yaml")
.action(() => {
console.log(getConfigInstruction());
});
ao-core System PromptIn packages/core/src/orchestrator.ts (or wherever generateOrchestratorPrompt() lives):
import { getConfigInstruction } from "@composio/ao-cli/lib/config-instruction.js";
// OR if circular dependency, copy the function to ao-core
function generateOrchestratorPrompt(config): string {
// ... existing prompt generation ...
// Append config instruction so orchestrator knows the schema
prompt += "\n\n## AO Config Reference\n" + getConfigInstruction();
return prompt;
}
ao spawnIn packages/cli/src/commands/spawn.ts, when creating a new agent session, inject the caller context:
import { setCallerContext } from "../lib/caller-context.js";
// When spawning an agent session:
const env = { ...process.env };
setCallerContext(env, {
callerType: "agent",
sessionId: sessionId,
projectId: projectId,
configPath: configPath,
port: config.port ?? 3000,
});
// Pass env to the spawned process
ao spawn ā make project arg optionalIn packages/cli/src/commands/spawn.ts:
// CURRENT
.argument("<project>", "Project ID from config")
.argument("[issue]", "Issue identifier")
// NEW
.argument("<issue>", "Issue identifier (e.g. #42, INT-1234)")
.argument("[project]", "Project ID ā auto-detected if omitted")
// At the top of the action handler:
function resolveProjectForSpawn(
config: OrchestratorConfig,
arg1: string,
arg2?: string
): { projectId: string; issueId: string } {
// Two args = explicit project + issue (backward compat)
if (arg2) return { projectId: arg1, issueId: arg2 };
// One arg = auto-detect project, arg1 is issue
const issueId = arg1;
// 1. AO_PROJECT_ID env var (set by ao start, inherited by all children)
if (process.env.AO_PROJECT_ID) {
return { projectId: process.env.AO_PROJECT_ID, issueId };
}
// 2. Single project in config
const ids = Object.keys(config.projects);
if (ids.length === 1) return { projectId: ids[0], issueId };
// 3. Ambiguous
throw new Error(
`Multiple projects in config: ${ids.join(", ")}\n` +
`Run from an orchestrator session, or specify: ao spawn <project> <issue>`
);
}
--smart flagFind and remove any references to the --smart flag in init.ts or start.ts. The owner confirmed nobody uses it.
After all steps are complete, verify these scenarios work:
| # | Scenario | Expected Result |
|---|---|---|
| 1 | ao start in a dir with no config | Auto-creates config, detects project type + runtime, starts dashboard |
| 2 | ao start in a dir with existing config | Loads config, starts normally |
| 3 | ao start <github-url> | Clones repo, auto-creates config, starts |
| 4 | ao start ~/other-repo while AO running | Adds project to config, refreshes dashboard sidebar |
| 5 | ao start while AO already running (human/TTY) | Shows interactive menu: Open / Restart / Quit |
| 6 | ao start while AO already running (agent/non-TTY) | Prints info + exit 0 |
| 7 | ao start with stale running.json (PID dead) | Prunes stale entry, starts normally |
| 8 | ao start while running, pick "New orchestrator" | Generates unique ID, adds config entry, spawns new orchestrator session. Same dashboard. |
| 9 | ao start while running, pick "Override & restart" | Kills existing, removes entry, starts fresh |
| 10 | ao stop | Stops dashboard + orchestrator + lifecycle, prints confirmation |
| 11 | ao init (deprecated) | Shows deprecation warning, creates config without starting |
| 12 | ao add-project ~/repo (deprecated) | Shows deprecation warning, adds project |
| 13 | ao config-help | Prints full config schema |
| 14 | ao start with multiple runtimes installed (human/TTY) | Prompts to pick runtime from detected plugins (no hardcoded list) |
| 15 | ao spawn #42 from orchestrator terminal | Auto-detects project via AO_PROJECT_ID, spawns session |
| 16 | ao spawn my-app #42 (explicit project) | Works same as before (backward compat) |
| 17 | New agent plugin installed (e.g. @composio/ao-plugin-agent-foo) | Auto-discovered by plugin registry on next ao start. No CLI code changes. |
| 18 | npm install -g @composio/ao && ao start | Full flow works end-to-end for npm user |
| File | Action | Lines |
|---|---|---|
lib/running-state.ts | Create | ~80 |
lib/caller-context.ts | Create | ~40 |
lib/config-instruction.ts | Create | ~60 |
packages/plugins/agent-*/src/index.ts | Edit (each plugin) | +5 each (add detect() + priority) |
lib/detect-env.ts | Create (extracted from init.ts) | ~60 |
commands/start.ts | Heavy edit | 557 ā ~700 |
commands/init.ts | Rewrite ā wrapper | 481 ā ~15 |
commands/add-project.ts | Rewrite ā wrapper | 148 ā ~15 |
index.ts | Minor edit | +10 |
commands/spawn.ts | Edit | +30 (auto-detect project, env var injection) |
| Core orchestrator prompt | Minor edit | +5 |
ao start) handles everything. 2-step onboarding for npm users.