The target onboarding experience for Agent Orchestrator is truly two commands from a fresh machine:
npm install -g @composio/ao && ao start
This does not work. Multiple bugs cause ao start to fail after
a global npm install. Beyond the bugs, there is an architectural issue: ao start
hardcodes runtime: "tmux" in the generated config, making tmux a hidden
prerequisite that breaks the "two commands" promise.
The failure chain on a fresh machine:
checkBuilt() hardcoded path fails for npm hoisted layoutruntime: "tmux", orchestrator creation fails hard if tmux is missing, kills the dashboard| # | Bug | Severity | Issue | Affects |
|---|---|---|---|---|
| 1 | checkBuilt() hardcoded single-level path |
P0 | #619 | All npm/yarn global installs |
| 2 | node-pty spawn-helper missing execute bit |
P0 | #624 | macOS + Linux npm installs |
| 3 | tmux is a hidden prerequisite — hardcoded runtime: "tmux", no auto-install, no fallback |
P0 | — | Any fresh machine without tmux |
| 4 | Terminal server crash is permanent (no restart), bookkeeping bug on restart | P1 | Related to #489 | linux-arm64 (AWS Graviton) |
preflight.ts verifies @composio/ao-core exists using a single hardcoded path:
// BEFORE: only checks one level deep
const nodeModules = resolve(webDir, "node_modules", "@composio", "ao-core");
npm hoists dependencies to a parent node_modules/.
The check always fails for global installs because ao-core lives one or more
levels up from ao-web.
packages/web/
node_modules/
@composio/ao-core -> ../../core
/usr/lib/node_modules/@composio/
ao-web/ # webDir
ao-core/ # sibling, not child
Replace with findPackageUp() — walks up directories checking
node_modules/@composio/ao-core at each level, mirroring Node's own resolution:
function findPackageUp(startDir: string, ...segments: string[]): string | null {
let dir = resolve(startDir);
while (true) {
const candidate = resolve(dir, "node_modules", ...segments);
if (existsSync(candidate)) return candidate;
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
Error messages distinguish install method:
node_modules → "npm install -g @composio/ao@latest""pnpm install && pnpm build"
node-pty@1.1.0 ships spawn-helper without the execute bit.
The monorepo's scripts/rebuild-node-pty.js fixes this via node-gyp rebuild,
but it hardcodes pnpm paths and never runs for npm global installs.
Upstream fix (microsoft/node-pty#866)
is only in 1.2.0-beta.
New packages/ao/bin/postinstall.js runs after npm install -g @composio/ao.
Uses the same findPackageUp pattern to locate node-pty, then chmod 0o755
on spawn-helper. Skips on Windows, silent on failure.
node-pty ships stable release with the upstream fix.
This was the biggest gap in the "two-step" promise. Three compounding issues:
runtime: "tmux"ao start entry paths (URL start and existing-config retries could bypass friendly remediation)On a fresh machine without tmux, the user saw:
$ ao start
✓ Config created: agent-orchestrator.yaml
⚠ tmux not found — install with: brew install tmux
...
✗ Failed to setup orchestrator: tmux: command not found
Install logic lives in two layers, each with a clear responsibility:
preflight.checkTmux() — low-level check-only guard used by
ao spawn preflight. It validates tmux -V and throws platform-specific
manual install instructions when missing (no install attempts).ensureTmux() in start.ts — interactive wrapper. Uses askYesNo()
to get user consent before running tryInstallWithAttempts() with
tmuxInstallAttempts(). Matches the existing ensureGit() pattern.
Non-interactive callers default to skip install and print manual hints.// start.ts — interactive install with user consent
async function ensureTmux(): Promise<void> {
const hasTmux = (await execSilent("tmux", ["-V"])) !== null;
if (hasTmux) return;
console.log("⚠ tmux is required for runtime \"tmux\".");
const shouldInstall = await askYesNo("Install tmux now?", true, false);
if (shouldInstall) {
const installed = await tryInstallWithAttempts(
tmuxInstallAttempts(),
async () => (await execSilent("tmux", ["-V"])) !== null,
);
if (installed) return; // ✓ tmux installed successfully
}
// Print platform-specific hints and exit
for (const hint of tmuxInstallHints()) console.log(hint);
process.exit(1);
}
start.ts centralizes tmux enforcement in runStartup(), so all
start entry paths are covered (normal start, URL start, and retries with existing config):
async function runStartup(...) {
const runtime = config.defaults?.runtime ?? "tmux";
if (runtime === "tmux") {
await ensureTmux();
}
// continue dashboard + orchestrator startup...
}
When ao start detects a missing prerequisite on a fresh machine, it prompts
the user before attempting any install. This ensures no surprises — no silent
sudo, no unexpected package manager invocations.
| Prerequisite | Required? | Prompt | Install methods (by platform) | On failure |
|---|---|---|---|---|
| tmux | Yes (for runtime: "tmux") |
Install tmux now? [Y/n] |
macOS: brew install tmuxLinux: apt-get → dnf
|
blocks — prints platform hints + exit 1 |
| git | Yes (for URL clone & add-project) | Install Git now? [Y/n] |
macOS: brew install gitLinux: apt-get → dnfWindows: winget install Git.Git
|
blocks — prints platform hints + exit 1 |
| Agent runtime (Claude Code, Codex, Aider, OpenCode) |
Yes (at least one) | Numbered menu:Choose runtime to install [1-5] |
1. Claude Code (npm i -g @anthropic-ai/claude-code)2. Codex ( npm i -g @openai/codex)3. Aider ( pipx install aider-chat)4. OpenCode ( go install ...)5. Skip |
Config generation continues — user can install later |
| GitHub CLI (gh) | No (optional) | Install GitHub CLI now? [y/N](defaults to no) |
macOS: brew install ghLinux: apt-get → dnfWindows: winget install GitHub.cli
|
Skipped silently — optional dependency |
| Docker | No | Not prompted | — | — (no runtime-docker plugin registered) |
askYesNo() before running any install command. No silent sudo or background package manager invocations."Running: brew install tmux" (via tryInstallWithAttempts()) so the user knows exactly what's executing.canPromptForInstall() returns false and askYesNo() returns its non-interactive default without prompting. Required and optional tools both default to false (skip), then print manual instructions when required tools are missing.ensureTmux(), ensureGit(), promptInstallAgentRuntime()) follow the same structure: detect → prompt → install → verify → block or skip.$ ao start
Detecting environment...
✓ Agent runtime: claude-code
✓ Config created: agent-orchestrator.yaml
⚠ tmux not found — will prompt to install at startup
⚠ tmux is required for runtime "tmux".
Install tmux now? [Y/n]: y
Running: brew install tmux
✓ tmux installed successfully
Starting dashboard on port 3000...
✓ Dashboard: http://localhost:3000
If the user declines or install fails:
Install tmux now? [Y/n]: n
✗ tmux is required but is not installed.
Install tmux manually, then re-run ao start:
brew install tmux # macOS
sudo apt install tmux # Debian/Ubuntu
sudo dnf install tmux # Fedora/RHEL
runtime: "process".
The process runtime lacks session attach, terminal panel, and message sending — core AO
features. A degraded first impression is worse than a one-line install command that gives the
full experience.
start-all.ts spawns three child processes. If a terminal server crashes,
it logs the exit and never restarts.
Auto-restart logic with correct bookkeeping:
The initial version had a bug: launch() always called children.push(child),
creating duplicate entries on restart. The cleanup() function counts
children.length and waits for exits, so stale handles would skew the alive count
and trigger the 5-second force-shutdown timer.
Fix: each process gets a slot index assigned on first launch. Restarts replace in-place:
let slotIndex = -1;
function launch(): ChildProcess {
const child = spawn(command, args, { ... });
child.on("exit", (code) => {
if (!shuttingDown && opts?.restart && code !== 0 && restarts < maxRestarts) {
restarts++;
const replacement = launch();
children[slotIndex] = replacement; // replace in-place
}
});
// Only push on first launch; restarts replace the existing slot
if (slotIndex === -1) {
slotIndex = children.length;
children.push(child);
}
return child;
}
ensureTmux() gate + interactive install prompts for git/tmux/agent runtimes (+ optional gh)Investigated but not broken:
| Suspected Blocker | Actual State |
|---|---|
| Port 3000 conflict | Already fixed — findFreePort(port + 1) auto-increments up to 100 ports |
| node-pty crash kills dashboard | Already handled — direct-terminal-ws.ts catches import failure, gracefully rejects connections |
findWebDir() in global install |
Already handled — uses require.resolve() with proper walk-up |
concurrently at runtime |
Already fixed — production uses node dist-server/start-all.js |
After this PR, npm install -g @composio/ao && ao start:
| Platform | tmux Available | tmux Not Available |
|---|---|---|
| macOS (Intel + Apple Silicon) | Works | Interactive prompt + brew install Clear error if brew missing or declined |
| Linux x64 | Works | Interactive prompt + apt/dnf install Clear error if sudo unavailable or declined |
| Linux arm64 (Graviton) | Works Direct terminal unavailable (no node-pty prebuild) |
Interactive prompt + apt/dnf install Clear error if sudo unavailable or declined |
| Windows | N/A | Clear error Guides user to WSL |
Key improvement: Missing prerequisites are handled with explicit user consent in
ao start. If install is declined or fails, the CLI prints one-line commands to run
manually, then exits clearly.
| Check | Result |
|---|---|
| TypeScript typecheck | Passes (CLI + web server) |
| ESLint | No new errors on changed files |
| Preflight tests | 15/15 pass (expanded from 13) |
| CLI tests | 222/223 pass (1 = pre-existing flaky doctor-script.test.ts) |
node_modules contains root-owned artifacts
(notably node-pty/build/* from earlier sudo installs), pnpm install
can fail with EACCES on unlink/rebuild. This is an environment ownership issue,
not a behavior regression in startup logic.
| Test | What It Verifies |
|---|---|
| pnpm layout (direct match) | findPackageUp finds ao-core in webDir/node_modules on first check |
| npm hoisted layout (walk-up) | findPackageUp misses first level, finds ao-core one level up |
| npm hint on missing deps | Global install path shows npm install -g hint |
| pnpm hint on missing deps | Monorepo path shows pnpm install && pnpm build hint |
| Missing dist | ao-core found but dist/index.js missing shows build hint |
| tmux interactive install success | When tmux -V fails initially and user-approved install succeeds, ensureTmux() passes |
| tmux interactive install declined/failure | When user declines or install attempts fail, prints platform-specific instructions and exits |
ao start, then block with clear instructions on failure.
We ask first, then try brew (macOS) and apt/dnf (Linux) only after consent. If install is
declined or fails, we exit with a clear one-line install command rather than falling back to runtime: "process".
The process runtime lacks session attach, terminal panel, and ao send —
a degraded first impression is worse than asking the user to run one command.
children array on first launch.
Restarts replace in-place (children[slotIndex] = replacement) instead of pushing,
preventing duplicate entries that would break the cleanup countdown.
spawn-helper chmod workaround will be removed once node-pty ships
a stable release with the upstream fix (microsoft/node-pty#866).