agent-orchestrator/docs/design-npm-global-install-f...

1066 lines
37 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Design: True Two-Step Setup Fixes — Agent Orchestrator</title>
<style>
:root {
--bg: #0d1117;
--surface: #161b22;
--border: #30363d;
--text: #e6edf3;
--text-muted: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--red: #f85149;
--yellow: #d29922;
--orange: #db6d28;
--code-bg: #1c2128;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
padding: 40px 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
}
h1 {
font-size: 2rem;
margin-bottom: 8px;
border-bottom: 1px solid var(--border);
padding-bottom: 16px;
}
.meta {
color: var(--text-muted);
font-size: 0.9rem;
margin-bottom: 32px;
}
.meta span {
margin-right: 24px;
}
h2 {
font-size: 1.4rem;
margin-top: 40px;
margin-bottom: 16px;
color: var(--accent);
}
h3 {
font-size: 1.1rem;
margin-top: 28px;
margin-bottom: 12px;
}
p {
margin-bottom: 12px;
}
ul, ol {
margin: 8px 0 16px 24px;
}
li {
margin-bottom: 6px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0 24px;
font-size: 0.9rem;
}
th {
background: var(--surface);
text-align: left;
padding: 10px 14px;
border: 1px solid var(--border);
font-weight: 600;
}
td {
padding: 10px 14px;
border: 1px solid var(--border);
vertical-align: top;
}
tr:nth-child(even) td {
background: rgba(22, 27, 34, 0.5);
}
code {
background: var(--code-bg);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.85em;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
}
pre {
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
overflow-x: auto;
margin: 12px 0 20px;
font-size: 0.85rem;
line-height: 1.5;
}
pre code {
background: none;
padding: 0;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-p0 { background: rgba(248, 81, 73, 0.2); color: var(--red); }
.badge-p1 { background: rgba(210, 153, 34, 0.2); color: var(--yellow); }
.badge-p2 { background: rgba(219, 109, 40, 0.2); color: var(--orange); }
.badge-fixed { background: rgba(63, 185, 80, 0.2); color: var(--green); }
.before-after {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin: 16px 0 24px;
}
.before-after > div {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.before-after h4 {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 10px;
}
.before-after .before h4 { color: var(--red); }
.before-after .after h4 { color: var(--green); }
.callout {
background: var(--surface);
border-left: 3px solid var(--accent);
border-radius: 0 8px 8px 0;
padding: 14px 18px;
margin: 16px 0 20px;
font-size: 0.9rem;
}
.callout-warning {
border-left-color: var(--yellow);
}
.callout-warning::before {
content: "Note: ";
color: var(--yellow);
font-weight: 600;
}
.callout-decision {
border-left-color: var(--green);
}
.callout-decision::before {
content: "Decision: ";
color: var(--green);
font-weight: 600;
}
.files-changed {
list-style: none;
padding: 0;
}
.files-changed li {
color: var(--text-muted);
font-size: 0.85rem;
padding: 2px 0;
}
.files-changed li::before {
content: "~";
color: var(--yellow);
margin-right: 8px;
}
.files-changed li.new::before {
content: "+";
color: var(--green);
}
.section-divider {
border: none;
border-top: 1px solid var(--border);
margin: 40px 0;
}
.flow-diagram {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
margin: 16px 0 24px;
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 0.85rem;
line-height: 1.8;
white-space: pre;
overflow-x: auto;
}
/* Visual flowchart styles */
.flowchart {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
margin: 24px 0;
padding: 24px 16px;
}
.flow-node {
padding: 10px 20px;
border-radius: 8px;
text-align: center;
font-size: 0.85rem;
font-family: 'SFMono-Regular', Consolas, monospace;
max-width: 420px;
width: 100%;
}
.flow-cmd {
background: rgba(88, 166, 255, 0.15);
border: 2px solid var(--accent);
color: var(--accent);
font-weight: 600;
}
.flow-action {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
}
.flow-decision {
background: rgba(210, 153, 34, 0.1);
border: 2px solid var(--yellow);
color: var(--yellow);
transform: rotate(0deg);
padding: 8px 16px;
font-weight: 600;
}
.flow-success {
background: rgba(63, 185, 80, 0.15);
border: 2px solid var(--green);
color: var(--green);
font-weight: 600;
}
.flow-fail {
background: rgba(248, 81, 73, 0.12);
border: 2px solid var(--red);
color: var(--red);
font-weight: 600;
}
.flow-arrow {
display: flex;
flex-direction: column;
align-items: center;
color: var(--text-muted);
font-size: 0.75rem;
line-height: 1;
}
.flow-arrow::before {
content: "│";
font-size: 1rem;
}
.flow-arrow::after {
content: "▼";
font-size: 0.7rem;
margin-top: -2px;
}
.flow-arrow-label {
color: var(--text-muted);
font-size: 0.75rem;
font-style: italic;
margin: 2px 0;
}
.flow-branch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
width: 100%;
max-width: 500px;
}
.flow-branch-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
}
.flow-branch-label {
font-size: 0.75rem;
font-weight: 600;
padding: 4px 10px;
border-radius: 10px;
margin-bottom: 4px;
}
.flow-branch-yes { background: rgba(63, 185, 80, 0.15); color: var(--green); }
.flow-branch-no { background: rgba(248, 81, 73, 0.12); color: var(--red); }
.flow-section-label {
color: var(--text-muted);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 4px;
}
</style>
</head>
<body>
<div class="container">
<h1>True Two-Step Setup Fixes</h1>
<div class="meta">
<span>PR #638</span>
<span>Issues: #619, #624</span>
<span>Status: Open</span>
<span>Date: 2026-03-24</span>
</div>
<!-- ================================================================ -->
<h2>1. Problem Statement</h2>
<p>
The target onboarding experience for Agent Orchestrator is <strong>truly two commands
from a fresh machine</strong>:
</p>
<pre><code>npm install -g @composio/ao && ao start</code></pre>
<p>
<strong>This does not work.</strong> Multiple bugs cause <code>ao start</code> to fail after
a global npm install. Beyond the bugs, there is an architectural issue: <code>ao start</code>
hardcodes <code>runtime: "tmux"</code> in the generated config, making tmux a hidden
prerequisite that breaks the "two commands" promise.
</p>
<p>The failure chain on a fresh machine:</p>
<ol>
<li><strong>"Dependencies not installed"</strong> &mdash; <code>checkBuilt()</code> hardcoded path fails for npm hoisted layout</li>
<li><strong>"posix_spawnp: Operation not permitted"</strong> &mdash; node-pty spawn-helper missing execute bit</li>
<li><strong>tmux is a hidden prerequisite</strong> &mdash; config always sets <code>runtime: "tmux"</code>, orchestrator creation fails hard if tmux is missing, kills the dashboard</li>
<li><strong>Terminal server crash is permanent</strong> &mdash; no auto-restart after crash</li>
</ol>
<h3>Two-Command Setup Workflow</h3>
<!-- Command 1 -->
<div class="flowchart">
<div class="flow-section-label">COMMAND 1</div>
<div class="flow-node flow-cmd">npm install -g @composio/ao</div>
<div class="flow-arrow"></div>
<div class="flow-node flow-action">postinstall.js runs</div>
<div class="flow-arrow"></div>
<div class="flow-node flow-action">find node-pty via walk-up resolver</div>
<div class="flow-arrow"></div>
<div class="flow-node flow-action">chmod spawn-helper (+x)<br><small style="color:var(--text-muted)">non-fatal on failure</small></div>
<div class="flow-arrow"></div>
<div class="flow-node flow-success">Install complete</div>
</div>
<!-- Command 2 -->
<div class="flowchart">
<div class="flow-section-label">COMMAND 2</div>
<div class="flow-node flow-cmd">ao start<br><small style="color:var(--text-muted)">normal | URL | existing config</small></div>
<div class="flow-arrow"></div>
<div class="flow-node flow-action">load/create config<br><small style="color:var(--text-muted)">defaults.runtime = "tmux"</small></div>
<div class="flow-arrow"></div>
<div class="flow-node flow-action">runStartup()</div>
<div class="flow-arrow"></div>
<!-- tmux check -->
<div class="flow-node flow-decision">tmux -V &mdash; installed?</div>
<div class="flow-branch">
<div class="flow-branch-col">
<div class="flow-branch-label flow-branch-yes">YES</div>
<div class="flow-arrow"></div>
<div class="flow-node flow-success" style="font-size:0.8rem">continue &darr;</div>
</div>
<div class="flow-branch-col">
<div class="flow-branch-label flow-branch-no">NO</div>
<div class="flow-arrow"></div>
<div class="flow-node flow-decision" style="font-size:0.8rem">askYesNo("Install tmux?")</div>
</div>
</div>
<!-- askYesNo branch -->
<div style="width:100%;max-width:500px">
<div class="flow-branch" style="margin-left:50%">
<div class="flow-branch-col">
<div class="flow-branch-label flow-branch-yes">YES</div>
<div class="flow-arrow"></div>
<div class="flow-node flow-action" style="font-size:0.8rem">tryInstallWithAttempts()<br><small style="color:var(--text-muted)">logs each cmd before running</small></div>
<div class="flow-arrow"></div>
<div class="flow-node flow-decision" style="font-size:0.8rem">verify tmux -V</div>
<div class="flow-branch" style="max-width:100%">
<div class="flow-branch-col">
<div class="flow-branch-label flow-branch-yes" style="font-size:0.65rem">OK</div>
<div class="flow-arrow"></div>
<div class="flow-node flow-success" style="font-size:0.75rem">continue &darr;</div>
</div>
<div class="flow-branch-col">
<div class="flow-branch-label flow-branch-no" style="font-size:0.65rem">FAIL</div>
<div class="flow-arrow"></div>
<div class="flow-node flow-fail" style="font-size:0.75rem">print hints<br>exit 1</div>
</div>
</div>
</div>
<div class="flow-branch-col">
<div class="flow-branch-label flow-branch-no">NO</div>
<div class="flow-arrow"></div>
<div class="flow-node flow-fail" style="font-size:0.8rem">print platform install hints<br>exit 1</div>
</div>
</div>
</div>
<div style="margin-top:16px"></div>
<div class="flow-arrow"></div>
<div class="flow-node flow-action">preflight.checkBuilt()<br><small style="color:var(--text-muted)">findPackageUp(@composio/ao-core)</small></div>
<div class="flow-arrow"></div>
<div class="flow-node flow-action">start dashboard<br><small style="color:var(--text-muted)">Next.js + terminal servers (auto-restart)</small></div>
<div class="flow-arrow"></div>
<div class="flow-node flow-action">create orchestrator session</div>
<div class="flow-arrow"></div>
<div class="flow-node flow-action">open dashboard URL</div>
<div class="flow-arrow"></div>
<div class="flow-node flow-success">SUCCESS</div>
</div>
<hr class="section-divider">
<!-- ================================================================ -->
<h2>2. Bug Analysis</h2>
<table>
<thead>
<tr><th>#</th><th>Bug</th><th>Severity</th><th>Issue</th><th>Affects</th></tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td><code>checkBuilt()</code> hardcoded single-level path</td>
<td><span class="badge badge-p0">P0</span></td>
<td><a href="https://github.com/ComposioHQ/agent-orchestrator/issues/619">#619</a></td>
<td>All npm/yarn global installs</td>
</tr>
<tr>
<td>2</td>
<td>node-pty <code>spawn-helper</code> missing execute bit</td>
<td><span class="badge badge-p0">P0</span></td>
<td><a href="https://github.com/ComposioHQ/agent-orchestrator/issues/624">#624</a></td>
<td>macOS + Linux npm installs</td>
</tr>
<tr>
<td>3</td>
<td>tmux is a hidden prerequisite &mdash; hardcoded <code>runtime: "tmux"</code>, no auto-install, no fallback</td>
<td><span class="badge badge-p0">P0</span></td>
<td>&mdash;</td>
<td>Any fresh machine without tmux</td>
</tr>
<tr>
<td>4</td>
<td>Terminal server crash is permanent (no restart), bookkeeping bug on restart</td>
<td><span class="badge badge-p1">P1</span></td>
<td>Related to <a href="https://github.com/ComposioHQ/agent-orchestrator/issues/489">#489</a></td>
<td>linux-arm64 (AWS Graviton)</td>
</tr>
</tbody>
</table>
<hr class="section-divider">
<!-- ================================================================ -->
<h2>3. Fix 1 &mdash; checkBuilt() Walk-Up Resolution</h2>
<h3>Root Cause</h3>
<p>
<code>preflight.ts</code> verifies <code>@composio/ao-core</code> exists using a single hardcoded path:
</p>
<pre><code>// BEFORE: only checks one level deep
const nodeModules = resolve(webDir, "node_modules", "@composio", "ao-core");</code></pre>
<p>
npm <strong>hoists</strong> dependencies to a parent <code>node_modules/</code>.
The check always fails for global installs because <code>ao-core</code> lives one or more
levels up from <code>ao-web</code>.
</p>
<div class="before-after">
<div class="before">
<h4>pnpm layout (works)</h4>
<pre><code>packages/web/
node_modules/
@composio/ao-core -> ../../core</code></pre>
</div>
<div class="after">
<h4>npm global layout (broken)</h4>
<pre><code>/usr/lib/node_modules/@composio/
ao-web/ # webDir
ao-core/ # sibling, not child</code></pre>
</div>
</div>
<h3>Fix</h3>
<p>
Replace with <code>findPackageUp()</code> &mdash; walks up directories checking
<code>node_modules/@composio/ao-core</code> at each level, mirroring Node's own resolution:
</p>
<pre><code>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;
}</code></pre>
<p>Error messages distinguish install method:</p>
<ul>
<li>Path contains <code>node_modules</code> &rarr; <code>"npm install -g @composio/ao@latest"</code></li>
<li>Otherwise &rarr; <code>"pnpm install && pnpm build"</code></li>
</ul>
<hr class="section-divider">
<!-- ================================================================ -->
<h2>4. Fix 2 &mdash; node-pty spawn-helper Postinstall</h2>
<h3>Root Cause</h3>
<p>
<code>node-pty@1.1.0</code> ships <code>spawn-helper</code> without the execute bit.
The monorepo's <code>scripts/rebuild-node-pty.js</code> fixes this via <code>node-gyp rebuild</code>,
but it hardcodes pnpm paths and never runs for npm global installs.
Upstream fix (<a href="https://github.com/microsoft/node-pty/pull/866">microsoft/node-pty#866</a>)
is only in <code>1.2.0-beta</code>.
</p>
<h3>Fix</h3>
<p>
New <code>packages/ao/bin/postinstall.js</code> runs after <code>npm install -g @composio/ao</code>.
Uses the same <code>findPackageUp</code> pattern to locate node-pty, then <code>chmod 0o755</code>
on <code>spawn-helper</code>. Skips on Windows, silent on failure.
</p>
<div class="callout callout-warning">
Temporary workaround. Remove when <code>node-pty</code> ships stable release with the upstream fix.
</div>
<hr class="section-divider">
<!-- ================================================================ -->
<h2>5. Fix 3 &mdash; Startup Preflight: tmux + Interactive Prerequisites</h2>
<h3>Root Cause</h3>
<p>
This was the biggest gap in the "two-step" promise. Three compounding issues:
</p>
<ul>
<li>Config generation hardcodes <code>runtime: "tmux"</code></li>
<li>Orchestrator creation fails hard if tmux runtime fails, then kills the dashboard</li>
<li>tmux checks were not enforced consistently across all <code>ao start</code> entry paths (URL start and existing-config retries could bypass friendly remediation)</li>
</ul>
<p>On a fresh machine without tmux, the user saw:</p>
<pre><code>$ ao start
✓ Config created: agent-orchestrator.yaml
⚠ tmux not found — install with: brew install tmux
...
✗ Failed to setup orchestrator: tmux: command not found</code></pre>
<h3>Fix: Centralized Startup Gate with User Consent</h3>
<div class="flowchart">
<div class="flow-node flow-cmd">ao start<br><small style="color:var(--text-muted)">normal | URL | existing config</small></div>
<div class="flow-arrow"></div>
<div class="flow-node flow-action">runStartup()</div>
<div class="flow-arrow"></div>
<div class="flow-node flow-action">ensureTmux()</div>
<div class="flow-arrow"></div>
<div class="flow-node flow-decision">tmux -V &mdash; installed?</div>
<div class="flow-branch">
<div class="flow-branch-col">
<div class="flow-branch-label flow-branch-yes">YES</div>
<div class="flow-arrow"></div>
<div class="flow-node flow-success" style="font-size:0.8rem">continue startup</div>
</div>
<div class="flow-branch-col">
<div class="flow-branch-label flow-branch-no">NO</div>
<div class="flow-arrow"></div>
<div class="flow-node flow-decision" style="font-size:0.8rem">askYesNo("Install?")</div>
<div class="flow-branch" style="max-width:100%">
<div class="flow-branch-col">
<div class="flow-branch-label flow-branch-yes" style="font-size:0.65rem">YES</div>
<div class="flow-arrow"></div>
<div class="flow-node flow-action" style="font-size:0.75rem">tryInstallWithAttempts()<br><small>logs each cmd</small></div>
<div class="flow-arrow"></div>
<div class="flow-node flow-decision" style="font-size:0.75rem">verify?</div>
<div class="flow-branch" style="max-width:100%">
<div class="flow-branch-col">
<div class="flow-branch-label flow-branch-yes" style="font-size:0.6rem">OK</div>
<div class="flow-arrow"></div>
<div class="flow-node flow-success" style="font-size:0.7rem;padding:6px">continue</div>
</div>
<div class="flow-branch-col">
<div class="flow-branch-label flow-branch-no" style="font-size:0.6rem">FAIL</div>
<div class="flow-arrow"></div>
<div class="flow-node flow-fail" style="font-size:0.7rem;padding:6px">exit 1</div>
</div>
</div>
</div>
<div class="flow-branch-col">
<div class="flow-branch-label flow-branch-no" style="font-size:0.65rem">NO</div>
<div class="flow-arrow"></div>
<div class="flow-node flow-fail" style="font-size:0.75rem">print hints<br>exit 1</div>
</div>
</div>
</div>
</div>
</div>
<h3>Two-Layer Install Architecture</h3>
<p>
Install logic lives in two layers, each with a clear responsibility:
</p>
<ul>
<li><strong><code>preflight.checkTmux()</code></strong> &mdash; low-level check-only guard used by
<code>ao spawn</code> preflight. It validates <code>tmux -V</code> and throws platform-specific
manual install instructions when missing (no install attempts).</li>
<li><strong><code>ensureTmux()</code> in start.ts</strong> &mdash; interactive wrapper. Uses <code>askYesNo()</code>
to get user consent before running <code>tryInstallWithAttempts()</code> with
<code>tmuxInstallAttempts()</code>. Matches the existing <code>ensureGit()</code> pattern.
Non-interactive callers default to skip install and print manual hints.</li>
</ul>
<pre><code>// start.ts — interactive install with user consent
async function ensureTmux(): Promise&lt;void&gt; {
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 () =&gt; (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);
}</code></pre>
<h3>All-Path Enforcement</h3>
<p>
<code>start.ts</code> centralizes tmux enforcement in <code>runStartup()</code>, so all
start entry paths are covered (normal start, URL start, and retries with existing config):
</p>
<pre><code>async function runStartup(...) {
const runtime = config.defaults?.runtime ?? "tmux";
if (runtime === "tmux") {
await ensureTmux();
}
// continue dashboard + orchestrator startup...
}</code></pre>
<h3>Interactive Prerequisite Prompts (TTY)</h3>
<p>
When <code>ao start</code> detects a missing prerequisite on a fresh machine, it prompts
the user before attempting any install. This ensures no surprises &mdash; no silent
<code>sudo</code>, no unexpected package manager invocations.
</p>
<h4>Prerequisite Matrix</h4>
<table>
<thead>
<tr>
<th>Prerequisite</th>
<th>Required?</th>
<th>Prompt</th>
<th>Install methods (by platform)</th>
<th>On failure</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>tmux</strong></td>
<td>Yes <small>(for <code>runtime: "tmux"</code>)</small></td>
<td><code>Install tmux now? [Y/n]</code></td>
<td>
macOS: <code>brew install tmux</code><br>
Linux: <code>apt-get</code> &rarr; <code>dnf</code>
</td>
<td><span class="badge badge-p0" style="font-size:0.7rem">blocks</span> &mdash; prints platform hints + exit 1</td>
</tr>
<tr>
<td><strong>git</strong></td>
<td>Yes <small>(for URL clone &amp; add-project)</small></td>
<td><code>Install Git now? [Y/n]</code></td>
<td>
macOS: <code>brew install git</code><br>
Linux: <code>apt-get</code> &rarr; <code>dnf</code><br>
Windows: <code>winget install Git.Git</code>
</td>
<td><span class="badge badge-p0" style="font-size:0.7rem">blocks</span> &mdash; prints platform hints + exit 1</td>
</tr>
<tr>
<td><strong>Agent runtime</strong><br><small>(Claude Code, Codex, Aider, OpenCode)</small></td>
<td>Yes <small>(at least one)</small></td>
<td>Numbered menu:<br><code>Choose runtime to install [1-5]</code></td>
<td>
1. Claude Code (<code>npm i -g @anthropic-ai/claude-code</code>)<br>
2. Codex (<code>npm i -g @openai/codex</code>)<br>
3. Aider (<code>pipx install aider-chat</code>)<br>
4. OpenCode (<code>go install ...</code>)<br>
5. Skip
</td>
<td>Config generation continues &mdash; user can install later</td>
</tr>
<tr>
<td><strong>GitHub CLI (gh)</strong></td>
<td>No <small>(optional)</small></td>
<td><code>Install GitHub CLI now? [y/N]</code><br><small>(defaults to <strong>no</strong>)</small></td>
<td>
macOS: <code>brew install gh</code><br>
Linux: <code>apt-get</code> &rarr; <code>dnf</code><br>
Windows: <code>winget install GitHub.cli</code>
</td>
<td>Skipped silently &mdash; optional dependency</td>
</tr>
<tr>
<td><strong>Docker</strong></td>
<td>No</td>
<td><em>Not prompted</em></td>
<td>&mdash;</td>
<td>&mdash; <small>(no <code>runtime-docker</code> plugin registered)</small></td>
</tr>
</tbody>
</table>
<h4>Key Behaviors</h4>
<ul>
<li><strong>Always asks first</strong> &mdash; uses <code>askYesNo()</code> before running any install command. No silent <code>sudo</code> or background package manager invocations.</li>
<li><strong>Logs each command</strong> &mdash; shows <code>"Running: brew install tmux"</code> (via <code>tryInstallWithAttempts()</code>) so the user knows exactly what's executing.</li>
<li><strong>Blocks on required failures</strong> &mdash; for tmux and git, prints platform-specific manual install instructions and exits rather than silently degrading the experience.</li>
<li><strong>Non-interactive fallback</strong> &mdash; when not a TTY (CI, piped scripts, agent callers), <code>canPromptForInstall()</code> returns false and <code>askYesNo()</code> returns its non-interactive default without prompting. Required and optional tools both default to <code>false</code> (skip), then print manual instructions when required tools are missing.</li>
<li><strong>Consistent pattern</strong> &mdash; all prerequisite helpers (<code>ensureTmux()</code>, <code>ensureGit()</code>, <code>promptInstallAgentRuntime()</code>) follow the same structure: detect &rarr; prompt &rarr; install &rarr; verify &rarr; block or skip.</li>
</ul>
<h4>Example: Fresh Machine Flow</h4>
<pre><code>$ 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</code></pre>
<p>If the user declines or install fails:</p>
<pre><code> 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</code></pre>
<div class="callout callout-decision">
We chose to block and show clear instructions rather than fall back to <code>runtime: "process"</code>.
The process runtime lacks session attach, terminal panel, and message sending &mdash; core AO
features. A degraded first impression is worse than a one-line install command that gives the
full experience.
</div>
<hr class="section-divider">
<!-- ================================================================ -->
<h2>6. Fix 4 &mdash; Terminal Server Crash Recovery</h2>
<h3>Root Cause</h3>
<p>
<code>start-all.ts</code> spawns three child processes. If a terminal server crashes,
it logs the exit and never restarts.
</p>
<h3>Fix</h3>
<p>
Auto-restart logic with <strong>correct bookkeeping</strong>:
</p>
<ul>
<li>Terminal servers restart up to <strong>3 times</strong> on non-zero exit</li>
<li>Next.js is <strong>not</strong> restarted</li>
<li>No restart during graceful shutdown</li>
</ul>
<h3>Bookkeeping: Slot-Based Child Tracking</h3>
<p>
The initial version had a bug: <code>launch()</code> always called <code>children.push(child)</code>,
creating duplicate entries on restart. The <code>cleanup()</code> function counts
<code>children.length</code> and waits for exits, so stale handles would skew the alive count
and trigger the 5-second force-shutdown timer.
</p>
<p>Fix: each process gets a <strong>slot index</strong> assigned on first launch. Restarts replace in-place:</p>
<pre><code>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;
}</code></pre>
<hr class="section-divider">
<!-- ================================================================ -->
<h2>7. Files Changed</h2>
<ul class="files-changed">
<li>packages/cli/src/lib/preflight.ts &mdash; walk-up resolver, check-only tmux preflight + platform messages</li>
<li>packages/cli/src/commands/start.ts &mdash; centralized <code>ensureTmux()</code> gate + interactive install prompts for git/tmux/agent runtimes (+ optional gh)</li>
<li>packages/cli/src/lib/detect-agent.ts &mdash; shared runtime selection with optional pre-detected list (avoids redundant plugin scan)</li>
<li class="new">packages/ao/bin/postinstall.js &mdash; node-pty spawn-helper chmod</li>
<li>packages/ao/package.json &mdash; postinstall script registration</li>
<li>packages/web/server/start-all.ts &mdash; restart logic with slot-based bookkeeping</li>
<li>eslint.config.js &mdash; packages/ao/bin globals override</li>
<li>packages/cli/__tests__/lib/preflight.test.ts &mdash; expanded tests</li>
</ul>
<hr class="section-divider">
<!-- ================================================================ -->
<h2>8. What Was Already Working</h2>
<p>Investigated but not broken:</p>
<table>
<thead>
<tr><th>Suspected Blocker</th><th>Actual State</th></tr>
</thead>
<tbody>
<tr>
<td>Port 3000 conflict</td>
<td><span class="badge badge-fixed">Already fixed</span> &mdash; <code>findFreePort(port + 1)</code> auto-increments up to 100 ports</td>
</tr>
<tr>
<td>node-pty crash kills dashboard</td>
<td><span class="badge badge-fixed">Already handled</span> &mdash; <code>direct-terminal-ws.ts</code> catches import failure, gracefully rejects connections</td>
</tr>
<tr>
<td><code>findWebDir()</code> in global install</td>
<td><span class="badge badge-fixed">Already handled</span> &mdash; uses <code>require.resolve()</code> with proper walk-up</td>
</tr>
<tr>
<td><code>concurrently</code> at runtime</td>
<td><span class="badge badge-fixed">Already fixed</span> &mdash; production uses <code>node dist-server/start-all.js</code></td>
</tr>
</tbody>
</table>
<hr class="section-divider">
<!-- ================================================================ -->
<h2>9. Post-Merge: True Two-Step Matrix</h2>
<p>After this PR, <code>npm install -g @composio/ao && ao start</code>:</p>
<table>
<thead>
<tr><th>Platform</th><th>tmux Available</th><th>tmux Not Available</th></tr>
</thead>
<tbody>
<tr>
<td>macOS (Intel + Apple Silicon)</td>
<td><span class="badge badge-fixed">Works</span></td>
<td><span class="badge badge-fixed">Interactive prompt + brew install</span><br>Clear error if brew missing or declined</td>
</tr>
<tr>
<td>Linux x64</td>
<td><span class="badge badge-fixed">Works</span></td>
<td><span class="badge badge-fixed">Interactive prompt + apt/dnf install</span><br>Clear error if sudo unavailable or declined</td>
</tr>
<tr>
<td>Linux arm64 (Graviton)</td>
<td><span class="badge badge-fixed">Works</span><br>Direct terminal unavailable (no node-pty prebuild)</td>
<td><span class="badge badge-fixed">Interactive prompt + apt/dnf install</span><br>Clear error if sudo unavailable or declined</td>
</tr>
<tr>
<td>Windows</td>
<td>N/A</td>
<td><span class="badge badge-p2">Clear error</span><br>Guides user to WSL</td>
</tr>
</tbody>
</table>
<p>
<strong>Key improvement:</strong> Missing prerequisites are handled with explicit user consent in
<code>ao start</code>. If install is declined or fails, the CLI prints one-line commands to run
manually, then exits clearly.
</p>
<hr class="section-divider">
<!-- ================================================================ -->
<h2>10. Testing</h2>
<table>
<thead>
<tr><th>Check</th><th>Result</th></tr>
</thead>
<tbody>
<tr><td>TypeScript typecheck</td><td>Passes (CLI + web server)</td></tr>
<tr><td>ESLint</td><td>No new errors on changed files</td></tr>
<tr><td>Preflight tests</td><td>15/15 pass (expanded from 13)</td></tr>
<tr><td>CLI tests</td><td>222/223 pass (1 = pre-existing flaky doctor-script.test.ts)</td></tr>
</tbody>
</table>
<div class="callout callout-warning">
Local validation caveat: if <code>node_modules</code> contains root-owned artifacts
(notably <code>node-pty/build/*</code> from earlier sudo installs), <code>pnpm install</code>
can fail with <code>EACCES</code> on unlink/rebuild. This is an environment ownership issue,
not a behavior regression in startup logic.
</div>
<h3>New/Updated Test Cases</h3>
<table>
<thead>
<tr><th>Test</th><th>What It Verifies</th></tr>
</thead>
<tbody>
<tr>
<td>pnpm layout (direct match)</td>
<td><code>findPackageUp</code> finds ao-core in webDir/node_modules on first check</td>
</tr>
<tr>
<td>npm hoisted layout (walk-up)</td>
<td><code>findPackageUp</code> misses first level, finds ao-core one level up</td>
</tr>
<tr>
<td>npm hint on missing deps</td>
<td>Global install path shows <code>npm install -g</code> hint</td>
</tr>
<tr>
<td>pnpm hint on missing deps</td>
<td>Monorepo path shows <code>pnpm install && pnpm build</code> hint</td>
</tr>
<tr>
<td>Missing dist</td>
<td>ao-core found but <code>dist/index.js</code> missing shows build hint</td>
</tr>
<tr>
<td>tmux interactive install success</td>
<td>When tmux -V fails initially and user-approved install succeeds, <code>ensureTmux()</code> passes</td>
</tr>
<tr>
<td>tmux interactive install declined/failure</td>
<td>When user declines or install attempts fail, prints platform-specific instructions and exits</td>
</tr>
</tbody>
</table>
<hr class="section-divider">
<!-- ================================================================ -->
<h2>11. Design Decisions</h2>
<div class="callout callout-decision">
<strong>Prompted tmux install in <code>ao start</code>, then block with clear instructions on failure.</strong>
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 <code>runtime: "process"</code>.
The process runtime lacks session attach, terminal panel, and <code>ao send</code> &mdash;
a degraded first impression is worse than asking the user to run one command.
</div>
<div class="callout callout-decision">
<strong>Slot-based restart bookkeeping.</strong>
Each child process gets a fixed slot index in the <code>children</code> array on first launch.
Restarts replace in-place (<code>children[slotIndex] = replacement</code>) instead of pushing,
preventing duplicate entries that would break the cleanup countdown.
</div>
<div class="callout callout-decision">
<strong>Postinstall is temporary.</strong>
The <code>spawn-helper</code> chmod workaround will be removed once <code>node-pty</code> ships
a stable release with the upstream fix (microsoft/node-pty#866).
</div>
</div>
</body>
</html>