docs: document cross-platform abstractions and reflect Windows support
Adds docs/CROSS_PLATFORM.md as the canonical reference for cross-platform development: the "Golden Rule" (no raw process.platform === "win32" — use isWindows() and the helpers in platform.ts), a full inventory of every platform helper (platform.ts, path-equality, windows-pty-registry, pty-client, sweepWindowsPtyHosts, validateSessionId, resolvePipePath, setupPathWrapperWorkspace, activity-state helpers, AO_SHELL/AO_BASH_PATH), the EPERM-vs-ESRCH gotcha when probing processes, PowerShell-vs-bash differences, IPv6 localhost stalls, agent-plugin specifics, and a 10-point pre-merge checklist. Updates internal docs (CLAUDE.md, AGENTS.md, docs/ARCHITECTURE.md, docs/DEVELOPMENT.md, CONTRIBUTING.md, .github/copilot-instructions.md, .cursor/BUGBOT.md, packages/core/README.md, packages/plugins/runtime-tmux/ README.md, packages/core/src/prompts/orchestrator.md, ARCHITECTURE.md) to remove tmux-only / POSIX-only claims, point at the new doc, and (in docs/ARCHITECTURE.md) describe the Windows runtime architecture: pty-host helper, named-pipe protocol, registry, sweep, mux WS Windows branch. Updates user-facing docs (README.md, SETUP.md, docs/CLI.md) to split prerequisites by OS (no tmux on Windows), reflect that ao doctor and ao update work on Windows, and note that power.preventIdleSleep is a no-op on Linux and Windows. Updates the agent-orchestrator skill (skills/agent-orchestrator/SKILL.md and references/config.md) so it advertises Windows support, drops tmux from the required-bins list, and gives the right Windows guidance for the "spawn tmux ENOENT" error. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ea8cb406de
commit
1d8c8f75ca
|
|
@ -15,7 +15,7 @@ Agent Orchestrator is a TypeScript monorepo for managing parallel AI coding agen
|
|||
|
||||
## Review Focus
|
||||
|
||||
- **Security**: Watch for command injection (especially in shell/tmux/git commands), AppleScript injection, GraphQL injection, unsanitized user input in API routes
|
||||
- **Security**: Watch for command injection (especially in shell/tmux/git/PowerShell commands and Windows named-pipe session IDs — `validateSessionId()` should guard those), AppleScript injection, GraphQL injection, unsanitized user input in API routes
|
||||
- **Shell execution**: Prefer `execFile` over `exec` to avoid shell injection. Flag any use of `exec` or string concatenation in shell commands
|
||||
- **Plugin pattern**: Plugins must export `{ manifest, create } satisfies PluginModule<T>` with types from `@aoagents/ao-core`
|
||||
- **Type safety**: Flag `as unknown as T` casts, unguarded `JSON.parse`, and type re-declarations that should import from core
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ These are the areas where Copilot review adds the most value: issues CI cannot c
|
|||
- Core utilities exported from `@aoagents/ao-core`
|
||||
|
||||
**7. Resource cleanup.** Check that:
|
||||
- File handles, subprocesses, and tmux sessions are cleaned up on all exit paths: success, error, and early return
|
||||
- File handles, subprocesses, and runtime sessions (tmux on Unix, ConPTY pty-host processes on Windows) are cleaned up on all exit paths: success, error, and early return
|
||||
- `destroy()` methods exist and use best-effort semantics
|
||||
- There are no resource leaks in error paths
|
||||
|
||||
|
|
@ -124,7 +124,7 @@ These files have a wide blast radius and deserve extra scrutiny:
|
|||
| `packages/core/src/lifecycle-manager.ts` | State machine and polling loop with subtle state dependencies. |
|
||||
| `packages/core/src/session-manager.ts` | Session CRUD + stale runtime reconciliation. `list()` persists `runtime_lost` to disk when enrichment detects dead runtimes. Invariant violations can cause phantom `killed` or `exited` sessions. |
|
||||
| `packages/core/src/lifecycle-state.ts` | Canonical lifecycle → legacy status mapping. New terminal reasons (e.g. `runtime_lost`) must be added to `deriveLegacyStatus()`. |
|
||||
| `packages/cli/src/commands/start.ts` | ao start/stop + Ctrl+C shutdown. Cross-project scoping logic is subtle — `ao stop <project>` must not kill parent process. |
|
||||
| `packages/cli/src/commands/start.ts` | ao start/stop + Ctrl+C shutdown. Cross-project scoping logic is subtle — `ao stop <project>` must not kill parent process. On Windows, also calls `sweepWindowsPtyHosts()` to gracefully tear down detached ConPTY pty-host processes that `taskkill /T` cannot reach. |
|
||||
| `packages/core/src/config.ts` | Zod validation schema. Changes affect every `ao` command. |
|
||||
| `packages/core/src/index.ts` | Stable public API. Do not break it without deprecation. |
|
||||
| `packages/web/src/app/globals.css` | Design tokens used by 50+ components. Renaming tokens breaks the UI. |
|
||||
|
|
|
|||
10
AGENTS.md
10
AGENTS.md
|
|
@ -47,3 +47,13 @@ Full guidelines with AO-specific context: see "Working Principles" in CLAUDE.md.
|
|||
- Ctrl+C on `ao start` performs full graceful shutdown (same as `ao stop`)
|
||||
- `LastStopState` includes `otherProjects` for cross-project session restore on next `ao start`
|
||||
- Dashboard sidebar always shows ALL projects' sessions regardless of active project view
|
||||
|
||||
## Cross-Platform (Windows) Compatibility
|
||||
|
||||
AO ships on macOS, Linux, **and Windows**. All three are first-class.
|
||||
|
||||
**Golden Rule:** Never write `process.platform === "win32"` in new code. Use `isWindows()` from `@aoagents/ao-core`. If you need branching the helpers don't cover, add it to `packages/core/src/platform.ts` — never inline at the call site. Inline checks bypass the central platform-mock test pattern and become silent regressions.
|
||||
|
||||
**Read `docs/CROSS_PLATFORM.md` before merging any change that touches:** process spawning/killing/signalling, file paths, shell commands, network binding, POSIX shell-outs (`tmux`, `lsof`, etc.), runtime/agent/workspace plugins, agent-plugin internals (`setupPathWrapperWorkspace`, `getActivityState`, `formatLaunchCommand`, `isProcessRunning`, `detect()`), the Windows pty-host pipe protocol or registry, or any new `process.platform === "win32"` check.
|
||||
|
||||
That doc has the **full helper inventory** (every import path), the EPERM-vs-ESRCH gotcha when probing processes, path case-insensitivity rules, PowerShell-vs-bash differences (`& ` call-operator, `$env:VAR`, no `/dev/null`, no `$(cat …)`, `.cmd` shim resolution via `shell: isWindows()`), IPv6 `localhost` stalls on Windows, agent-plugin Windows specifics, the test pattern for mocking `process.platform`, and a 10-point pre-merge checklist. CLAUDE.md has the quick-reference helper table; CROSS_PLATFORM.md has the depth.
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ ao-1, ao-2 (agent-orchestrator)
|
|||
ss-1, ss-2 (safe-split)
|
||||
```
|
||||
|
||||
### Tmux Session Names (Globally Unique)
|
||||
### Runtime Session Names (Globally Unique)
|
||||
|
||||
```
|
||||
{hash}-{sessionPrefix}-{num}
|
||||
|
|
@ -107,6 +107,8 @@ a3b4c5d6e7f8-ao-1
|
|||
f1e2d3c4b5a6-int-1 (different checkout, no collision!)
|
||||
```
|
||||
|
||||
On Unix this is the tmux session name. On Windows (where the default runtime is `process`, not `tmux`) the same string identifies the named pipe path `\\.\pipe\ao-pty-{sessionId}` and is recorded in `~/.agent-orchestrator/windows-pty-hosts.json`.
|
||||
|
||||
### Prefix Generation (Clean Heuristic)
|
||||
|
||||
```typescript
|
||||
|
|
@ -159,7 +161,7 @@ project=integrator
|
|||
issue=INT-100
|
||||
branch=feat/INT-100
|
||||
status=working
|
||||
tmuxName=a3b4c5d6e7f8-int-1
|
||||
tmuxName=a3b4c5d6e7f8-int-1 # Unix; on Windows the runtime handle is `pipePath=\\.\pipe\ao-pty-<sessionId>` plus `ptyHostPid`
|
||||
worktree=/Users/alice/.agent-orchestrator/a3b4c5d6e7f8-integrator/worktrees/int-1
|
||||
createdAt=2026-02-17T10:30:00Z
|
||||
pr=https://github.com/ComposioHQ/integrator/pull/123
|
||||
|
|
@ -187,7 +189,7 @@ ao list integrator
|
|||
# Spawn new session
|
||||
ao spawn integrator INT-100
|
||||
|
||||
# Attach to session (orchestrator finds tmux name)
|
||||
# Attach to session (orchestrator finds the runtime handle: tmux name on Unix, named pipe on Windows)
|
||||
ao attach int-1
|
||||
|
||||
# Kill session
|
||||
|
|
|
|||
55
CLAUDE.md
55
CLAUDE.md
|
|
@ -228,6 +228,61 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
|
|||
- `deriveLegacyStatus()` maps canonical lifecycle to legacy status — new terminal reasons must be added here
|
||||
- Tab completions merge local config + global config to show all projects
|
||||
|
||||
## Cross-Platform (Windows) Compatibility
|
||||
|
||||
AO ships on macOS, Linux, **and Windows**. All three are first-class.
|
||||
|
||||
### The Golden Rule
|
||||
|
||||
> **Never write `process.platform === "win32"` in new code. Use `isWindows()` from `@aoagents/ao-core`. If you need branching the helpers don't cover, add it to `packages/core/src/platform.ts` (or one of the targeted helper modules below) — never inline at the call site.**
|
||||
|
||||
The codebase has a deliberate set of cross-platform abstractions. Every platform helper is centrally tested by mocking `process.platform`; inline checks bypass those tests and become silent regressions. Whenever you'd type `process.platform`, stop and check the helper inventory in `docs/CROSS_PLATFORM.md` first.
|
||||
|
||||
### Read `docs/CROSS_PLATFORM.md` before merging if you touch any of:
|
||||
|
||||
- Process spawning, killing, signalling, or process-tree teardown (`child_process`, `process.kill`, runtime plugins)
|
||||
- File paths — comparison, joining, walking, anything OS-specific
|
||||
- Shell commands (`exec`, `execFile`, command strings, redirections, PowerShell-vs-bash)
|
||||
- Network binding, sockets, anything that says `localhost`
|
||||
- Shell-outs to POSIX tools (`tmux`, `lsof`, `pkill`, `which`, coreutils)
|
||||
- Adding any new `if (process.platform === "win32")` check (it should go into `platform.ts` instead — see the Golden Rule)
|
||||
- Runtime / agent / workspace plugin code that runs on both `runtime-tmux` and `runtime-process`
|
||||
- Agent-plugin internals: `setupPathWrapperWorkspace`, `getActivityState`, `formatLaunchCommand`, `isProcessRunning`, `detect()`
|
||||
- The Windows pty-host pipe protocol or registry (`pty-client.ts`, `windows-pty-registry.ts`, `sweepWindowsPtyHosts`)
|
||||
|
||||
### Quick reference: helpers to use instead of raw platform checks
|
||||
|
||||
All importable from `@aoagents/ao-core` unless noted:
|
||||
|
||||
| Need | Use |
|
||||
|------|-----|
|
||||
| OS check | `isWindows()` |
|
||||
| Pick runtime | `getDefaultRuntime()` |
|
||||
| Resolve shell (PowerShell vs `/bin/sh`) | `getShell()` |
|
||||
| Kill process + descendants | `killProcessTree(pid, signal?)` |
|
||||
| Find PID listening on a port | `findPidByPort(port)` |
|
||||
| Default env (HOME / TMPDIR / SHELL / PATH / USER) | `getEnvDefaults()` |
|
||||
| Compare paths (case-insensitive on NTFS/APFS) | `pathsEqual()` / `canonicalCompareKey()` from `cli/src/lib/path-equality.ts` |
|
||||
| Escape shell args | `shellEscape()` |
|
||||
| Install agent PATH wrappers (`gh`/`git`) | `setupPathWrapperWorkspace(workspacePath)` |
|
||||
| Build env PATH with `~/.ao/bin` prepended | `buildAgentPath(basePath?)` |
|
||||
| Tail JSONL | `readLastJsonlEntry` / `readLastActivityEntry` |
|
||||
| Activity-state contract helpers | `checkActivityLogState`, `getActivityFallbackState`, `classifyTerminalActivity`, `recordTerminalActivity`, `appendActivityEntry` |
|
||||
| Windows pty-host registry (used by `ao stop`) | `registerWindowsPtyHost`, `getWindowsPtyHosts`, `unregisterWindowsPtyHost`, `clearWindowsPtyHostRegistry` |
|
||||
| Reap orphan pty-hosts on `ao stop` | `sweepWindowsPtyHosts()` from `@aoagents/ao-plugin-runtime-process` |
|
||||
| Talk to a Windows pty-host over its named pipe | `getPipePath`, `connectPtyHost`, `ptyHostSendMessage`, `ptyHostGetOutput`, `ptyHostIsAlive`, `ptyHostKill` from `@aoagents/ao-plugin-runtime-process` |
|
||||
| Validate user-supplied session ID before pipe/shell use | `validateSessionId()` from `@/server/tmux-utils` |
|
||||
| Resolve a session's Windows pipe path | `resolvePipePath()` from `@/server/tmux-utils` |
|
||||
| POSIX-only Ctrl+C signal forwarding | `forwardSignalsToChild()` from `cli/src/lib/shell.ts` (guard with `!isWindows()`) |
|
||||
| Defensive PowerShell sweep of orphan pty-hosts | `stopStaleWindowsPtyHosts(projectDir)` from `web/src/lib/windows-pty-cleanup.ts` |
|
||||
|
||||
`docs/CROSS_PLATFORM.md` has the full helper reference with import paths, the EPERM-vs-ESRCH gotcha when probing processes (with a copyable code snippet), path case-insensitivity rules, PowerShell-vs-bash differences (`& ` call-operator, `$env:VAR`, no `/dev/null`, no `$(cat …)`, `.cmd`/`.bat`/`.exe` shim resolution via `shell: isWindows()`), the IPv6 `localhost` stall on Windows, agent-plugin Windows specifics, the test pattern for mocking `process.platform`, and a 10-point pre-merge checklist. **Run through that checklist for any non-trivial change.**
|
||||
|
||||
### Environment variables to know about
|
||||
|
||||
- `AO_SHELL` — overrides `getShell()` resolution (escape hatch for Git Bash users on Windows). Args inferred from basename: `cmd` → `/c`, `bash`/`sh`/`zsh` → `-c`, anything else → `-Command`.
|
||||
- `AO_BASH_PATH` — used by `script-runner.ts` on Windows to locate bash before falling back to Git Bash auto-detection. WSL bash is excluded (it sees Linux paths from a Windows cwd, breaking script semantics).
|
||||
|
||||
## Conventions
|
||||
|
||||
### Code Style
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@ Include:
|
|||
|
||||
## Development Setup
|
||||
|
||||
**Prerequisites**: Node.js 20+, pnpm 9.15+, Git 2.25+, tmux, gh CLI
|
||||
**Prerequisites**: Node.js 20+, pnpm 9.15+, Git 2.25+, gh CLI
|
||||
|
||||
- **Unix (macOS/Linux)**: also install `tmux` — it is the default runtime.
|
||||
- **Windows**: tmux is **not** required. The default runtime on Windows is `process` (ConPTY via `node-pty`), and PowerShell is the default shell. See [docs/CROSS_PLATFORM.md](docs/CROSS_PLATFORM.md) for what's different on Windows when contributing.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ComposioHQ/agent-orchestrator.git
|
||||
|
|
|
|||
14
README.md
14
README.md
|
|
@ -23,7 +23,7 @@ Spawn parallel AI coding agents, each in its own git worktree. Agents autonomous
|
|||
|
||||
Agent Orchestrator manages fleets of AI coding agents working in parallel on your codebase. Each agent gets its own git worktree, its own branch, and its own PR. When CI fails, the agent fixes it. When reviewers leave comments, the agent addresses them. You only get pulled in when human judgment is needed.
|
||||
|
||||
**Agent-agnostic** (Claude Code, Codex, Aider) · **Runtime-agnostic** (tmux, Docker) · **Tracker-agnostic** (GitHub, Linear)
|
||||
**Agent-agnostic** (Claude Code, Codex, Aider) · **Runtime-agnostic** (tmux, ConPTY/process, Docker) · **Tracker-agnostic** (GitHub, Linear)
|
||||
|
||||
<div align="center">
|
||||
|
||||
|
|
@ -45,7 +45,9 @@ Agent Orchestrator manages fleets of AI coding agents working in parallel on you
|
|||
|
||||
## Quick Start
|
||||
|
||||
> **Prerequisites:** [Node.js 20+](https://nodejs.org), [Git 2.25+](https://git-scm.com), [tmux](https://github.com/tmux/tmux/wiki/Installing), [`gh` CLI](https://cli.github.com). Install tmux via `brew install tmux` (macOS) or `sudo apt install tmux` (Linux).
|
||||
> **Prerequisites:** [Node.js 20+](https://nodejs.org), [Git 2.25+](https://git-scm.com), [`gh` CLI](https://cli.github.com), and:
|
||||
> - **macOS / Linux:** [tmux](https://github.com/tmux/tmux/wiki/Installing) — install via `brew install tmux` or `sudo apt install tmux`.
|
||||
> - **Windows:** PowerShell 7+ recommended. tmux is **not** required — AO uses native ConPTY via the `runtime-process` plugin (the default on Windows). Set `AO_SHELL=bash` if you have Git Bash and prefer it.
|
||||
|
||||
### Install
|
||||
|
||||
|
|
@ -135,7 +137,7 @@ $schema: https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/sc
|
|||
port: 3000
|
||||
|
||||
defaults:
|
||||
runtime: tmux
|
||||
runtime: tmux # default on macOS / Linux; on Windows the default is `process` (ConPTY)
|
||||
agent: claude-code
|
||||
workspace: worktree
|
||||
notifiers: [desktop]
|
||||
|
|
@ -177,20 +179,22 @@ AO keeps your Mac awake while running, so you can access the dashboard remotely
|
|||
# agent-orchestrator.yaml
|
||||
$schema: https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/schema/config.schema.json
|
||||
power:
|
||||
preventIdleSleep: true # Default on macOS, no-op on Linux
|
||||
preventIdleSleep: true # Default on macOS; no-op on Linux and Windows
|
||||
```
|
||||
|
||||
Set to `false` if you want to allow idle sleep while AO runs.
|
||||
|
||||
**Lid-close limitation:** macOS enforces lid-close sleep at the hardware level — no userspace assertion can override it. If you need remote access while traveling with the lid closed, use [clamshell mode](https://support.apple.com/en-us/102505) (external power + display + input device).
|
||||
|
||||
**Linux / Windows:** AO does not currently hold a wake assertion on these platforms. On Linux, idle-sleep behaviour is governed by your desktop environment / `systemd-logind`; configure that directly. On Windows, set the OS power plan if remote access matters while idle.
|
||||
|
||||
## Plugin Architecture
|
||||
|
||||
Seven plugin slots. Lifecycle stays in core.
|
||||
|
||||
| Slot | Default | Alternatives |
|
||||
| --------- | ----------- | ------------------------ |
|
||||
| Runtime | tmux | process |
|
||||
| Runtime | tmux (macOS/Linux) / process (Windows) | process, docker |
|
||||
| Agent | claude-code | codex, aider, cursor, opencode, kimicode |
|
||||
| Workspace | worktree | clone |
|
||||
| Tracker | github | linear, gitlab |
|
||||
|
|
|
|||
22
SETUP.md
22
SETUP.md
|
|
@ -18,7 +18,9 @@ Comprehensive guide to installing, configuring, and troubleshooting Agent Orches
|
|||
git --version
|
||||
```
|
||||
|
||||
- **tmux** (for tmux runtime) - Terminal multiplexer for session management
|
||||
- **Terminal runtime** — varies by OS:
|
||||
|
||||
**On macOS / Linux:** `tmux` is required (it's the default runtime).
|
||||
|
||||
```bash
|
||||
tmux -V
|
||||
|
|
@ -33,6 +35,8 @@ Comprehensive guide to installing, configuring, and troubleshooting Agent Orches
|
|||
sudo dnf install tmux
|
||||
```
|
||||
|
||||
**On Windows:** tmux is **not** required. AO uses native ConPTY via the `runtime-process` plugin (the default on Windows). PowerShell 7+ is recommended; if you have Git Bash and prefer bash semantics for shell-out commands, set `AO_SHELL=bash` in your environment. WSL is not required.
|
||||
|
||||
- **GitHub CLI** (for GitHub integration) - Required for PR creation, issue management
|
||||
|
||||
```bash
|
||||
|
|
@ -147,7 +151,7 @@ If a config already exists, the new project is appended. If not, one is created
|
|||
- **Project type** — language, framework, test runner, package manager
|
||||
- **Agent runtime** — which AI agents are installed (Claude Code, Codex, Aider, OpenCode)
|
||||
- **Free port** — if configured port is busy, auto-finds the next available
|
||||
- **tmux** — warns if not installed
|
||||
- **tmux** — warns if not installed (skipped on Windows; AO uses ConPTY there and tmux is not required)
|
||||
- **GitHub CLI** — checks `gh auth status`
|
||||
|
||||
### Manual Configuration
|
||||
|
|
@ -192,7 +196,7 @@ Agent Orchestrator has 8 plugin slots. All are swappable:
|
|||
|
||||
| Slot | Purpose | Default | Alternatives |
|
||||
| ------------- | -------------------- | ------------- | ----------------------------------------------- |
|
||||
| **Runtime** | How sessions run | `tmux` | `process`, `docker`, `kubernetes`, `ssh`, `e2b` |
|
||||
| **Runtime** | How sessions run | `tmux` (macOS/Linux) / `process` (Windows; ConPTY via node-pty) | `process`, `docker`, `kubernetes`, `ssh`, `e2b` |
|
||||
| **Agent** | AI coding assistant | `claude-code` | `codex`, `aider`, `goose`, custom |
|
||||
| **Workspace** | Workspace isolation | `worktree` | `clone`, `copy` |
|
||||
| **Tracker** | Issue tracking | `github` | `linear`, `jira`, custom |
|
||||
|
|
@ -288,7 +292,7 @@ Override defaults per project:
|
|||
```yaml
|
||||
projects:
|
||||
frontend:
|
||||
runtime: tmux
|
||||
runtime: tmux # default on macOS/Linux; on Windows use `process`
|
||||
agent: claude-code
|
||||
workspace: worktree
|
||||
|
||||
|
|
@ -403,7 +407,7 @@ ao doctor
|
|||
ao doctor --fix
|
||||
```
|
||||
|
||||
`ao doctor` reports deterministic PASS/WARN/FAIL checks for PATH and launcher resolution, required binaries, tmux and GitHub CLI health, stale AO temp files, config support directories, and core build/runtime sanity. `--fix` only applies safe fixes such as creating missing AO support directories, refreshing the local launcher link, and removing stale AO temp files.
|
||||
`ao doctor` reports deterministic PASS/WARN/FAIL checks for PATH and launcher resolution, required binaries, terminal-runtime health (tmux on Unix; PowerShell / `runtime-process` on Windows), GitHub CLI health, stale AO temp files, config support directories, and core build/runtime sanity. It runs and is supported on Windows. `--fix` only applies safe fixes such as creating missing AO support directories, refreshing the local launcher link, and removing stale AO temp files.
|
||||
|
||||
### Run `ao update`
|
||||
|
||||
|
|
@ -414,7 +418,7 @@ git switch main
|
|||
ao update
|
||||
```
|
||||
|
||||
`ao update` is intentionally conservative: it requires a clean working tree on `main`, fast-forwards from `origin/main`, reinstalls dependencies, clean-rebuilds the critical core/CLI/web packages, refreshes the launcher with `npm link`, and runs CLI smoke tests. Use `ao update --skip-smoke` to stop after rebuild, or `ao update --smoke-only` to rerun just the smoke checks.
|
||||
`ao update` is intentionally conservative: it requires a clean working tree on `main`, fast-forwards from `origin/main`, reinstalls dependencies, clean-rebuilds the critical core/CLI/web packages, refreshes the launcher with `npm link`, and runs CLI smoke tests. Works on macOS, Linux, and Windows (Windows uses the bundled `ao-update.ps1` script automatically). Use `ao update --skip-smoke` to stop after rebuild, or `ao update --smoke-only` to rerun just the smoke checks.
|
||||
|
||||
### "No agent-orchestrator.yaml found"
|
||||
|
||||
|
|
@ -432,7 +436,7 @@ cp examples/simple-github.yaml agent-orchestrator.yaml
|
|||
|
||||
### "tmux not found"
|
||||
|
||||
**Problem:** tmux is not installed (required for tmux runtime).
|
||||
**Problem:** tmux is not installed (required for the tmux runtime — the default on macOS and Linux).
|
||||
|
||||
**Solution:**
|
||||
|
||||
|
|
@ -447,6 +451,8 @@ sudo apt install tmux
|
|||
sudo dnf install tmux
|
||||
```
|
||||
|
||||
**On Windows:** this error should not appear in normal use. If it does, your config has `runtime: tmux` set explicitly. Switch to `runtime: process` (or remove the override — `process` is the Windows default), and AO will use ConPTY natively without tmux.
|
||||
|
||||
### "gh auth failed"
|
||||
|
||||
**Problem:** GitHub CLI is not authenticated.
|
||||
|
|
@ -682,7 +688,7 @@ notifiers:
|
|||
A session is an isolated workspace where an agent works on a single issue. Each session has:
|
||||
|
||||
- Its own git worktree or clone
|
||||
- Its own tmux session (or Docker container, etc.)
|
||||
- Its own runtime session — a tmux session on macOS/Linux, a ConPTY pty-host process on Windows (or a Docker container, etc.)
|
||||
- Its own metadata (branch, PR, status)
|
||||
- Its own event log
|
||||
|
||||
|
|
|
|||
|
|
@ -23,11 +23,11 @@ graph TB
|
|||
|
||||
subgraph MuxServer["② WebSocket Server — :14801 (separate Node process)"]
|
||||
MuxWS["ws://host:14801/mux\nMultiplexed — two sub-channels\nover one connection"]
|
||||
TermMgr["TerminalManager\n(node-pty → tmux PTY)"]
|
||||
TermMgr["TerminalManager (Unix)\n(node-pty → tmux PTY)\n— or —\nNamed-pipe relay (Windows)\nhandleWindowsPipeMessage →\n\\\\.\\pipe\\ao-pty-{id}"]
|
||||
Broadcaster["SessionBroadcaster\n(setInterval every 3s →\nGET /api/sessions/patches)"]
|
||||
end
|
||||
|
||||
subgraph Agents["AI Agents (one tmux window each)"]
|
||||
subgraph Agents["AI Agents (one tmux window per session on Unix; one ConPTY pty-host per session on Windows)"]
|
||||
ClaudeCode["Claude Code"]
|
||||
Codex["Codex"]
|
||||
Aider["Aider"]
|
||||
|
|
@ -57,7 +57,7 @@ graph TB
|
|||
MuxWS -- "session patches\n→ useSessionEvents()\n→ useMuxSessionActivity()" --> UI
|
||||
|
||||
%% Mux auto-recovery calls back to Next.js
|
||||
TermMgr -- "① HTTP POST /api/sessions/:id/restore\n(auto-recovery when tmux dies)" --> Sessions
|
||||
TermMgr -- "① HTTP POST /api/sessions/:id/restore\n(auto-recovery when the runtime dies:\ntmux daemon on Unix, pty-host on Windows)" --> Sessions
|
||||
|
||||
%% External
|
||||
Sessions -- "REST calls" --> GitHub
|
||||
|
|
@ -110,14 +110,14 @@ sequenceDiagram
|
|||
participant XTerm as xterm.js
|
||||
participant MuxClient as MuxProvider (browser)
|
||||
participant MuxWS as WS Server :14801/mux
|
||||
participant PTY as node-pty (tmux)
|
||||
participant PTY as PTY (Unix: node-pty → tmux; Windows: named pipe → ConPTY pty-host)
|
||||
participant Next as Next.js :3000
|
||||
|
||||
MuxClient->>MuxWS: connect ws://localhost:14801/mux
|
||||
|
||||
Note over MuxClient,MuxWS: Open a terminal
|
||||
MuxClient->>MuxWS: {ch:"terminal", id:"sess-1", type:"open"}
|
||||
MuxWS->>PTY: attach tmux PTY
|
||||
MuxWS->>PTY: attach (Unix: tmux PTY; Windows: connect named pipe)
|
||||
MuxWS-->>MuxClient: {ch:"terminal", id:"sess-1", type:"opened"}
|
||||
|
||||
Note over MuxClient,MuxWS: Terminal I/O
|
||||
|
|
@ -137,7 +137,7 @@ sequenceDiagram
|
|||
Note over MuxWS,Next: Auto-recovery (session dead)
|
||||
MuxWS->>Next: POST /api/sessions/sess-1/restore
|
||||
Next-->>MuxWS: 200 OK
|
||||
MuxWS->>PTY: reattach to new tmux session
|
||||
MuxWS->>PTY: reattach (Unix: new tmux session; Windows: reopen named pipe)
|
||||
```
|
||||
|
||||
**Message types:**
|
||||
|
|
@ -182,7 +182,7 @@ graph LR
|
|||
|
||||
The CLI (`ao start`) forks two long-running processes:
|
||||
- **Next.js** on `:3000` — serves the dashboard and all REST routes
|
||||
- **Terminal WS server** on `:14801` — handles multiplexed WebSocket + PTY management + session patch polling
|
||||
- **Terminal WS server** on `:14801` — handles multiplexed WebSocket + PTY management + session patch polling. PTY transport is platform-specific: tmux via `node-pty` on Unix, named-pipe relay (`handleWindowsPipeMessage` → `\\.\pipe\ao-pty-{sessionId}`) on Windows. Both paths use the same outer mux protocol.
|
||||
|
||||
Both processes share no in-memory state; coordination happens through flat files in `~/.agent-orchestrator/` and HTTP calls from the WS server to Next.js.
|
||||
|
||||
|
|
@ -202,3 +202,132 @@ Both processes share no in-memory state; coordination happens through flat files
|
|||
| WS server restores session | HTTP POST | `:14801` → `:3000/api/sessions/:id/restore` |
|
||||
| GitHub notifies of CI / PR | HTTP POST | GitHub → `:3000/api/webhooks/github` |
|
||||
| CLI queries sessions | HTTP GET | `ao` CLI → `:3000/api/sessions` |
|
||||
|
||||
---
|
||||
|
||||
## Windows Runtime Architecture
|
||||
|
||||
On Windows the high-level component map (HTTP API, mux WS server, dashboard, flat-file storage) is identical, but the **PTY transport layer is different** because tmux is not available natively. This section describes only what's different.
|
||||
|
||||
> For the developer-facing rules of "how do I write code that works on both," see [`docs/CROSS_PLATFORM.md`](CROSS_PLATFORM.md). The section below is the architectural reference for *what was built*.
|
||||
|
||||
### Default runtime
|
||||
|
||||
`getDefaultRuntime()` from `@aoagents/ao-core` returns `"process"` on Windows and `"tmux"` everywhere else. A fresh Windows install therefore loads the `runtime-process` plugin without requiring YAML edits. Users on Unix who want the process runtime opt in via `runtime: process` in `agent-orchestrator.yaml`.
|
||||
|
||||
### The pty-host helper process
|
||||
|
||||
Because `node-pty` ConPTY sessions are tied to the lifetime of the host Node process, the orchestrator can't simply spawn ConPTY directly inside Next.js or the mux WS server: those processes restart, get killed by `taskkill /T`, etc. Instead, each AO session on Windows owns a small dedicated helper process — the **pty-host**.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph SessionWindows["AO Session (Windows)"]
|
||||
AOStart["ao start / spawn"]
|
||||
PtyHost["pty-host.cjs<br/>(detached Node child)"]
|
||||
Pipe["Named pipe<br/>\\.\pipe\ao-pty-{sessionId}"]
|
||||
ConPty["ConPTY<br/>(node-pty)"]
|
||||
Agent["Agent process<br/>(claude-code, codex, …)"]
|
||||
end
|
||||
|
||||
AOStart -- "spawn detached" --> PtyHost
|
||||
PtyHost -- "open server" --> Pipe
|
||||
PtyHost -- "spawn" --> ConPty
|
||||
ConPty -- "PTY I/O" --> Agent
|
||||
|
||||
MuxWS["Mux WS server\nhandleWindowsPipeMessage"] -- "connect (net.Socket)" --> Pipe
|
||||
Browser["Browser xterm.js"] -- "WS frames" --> MuxWS
|
||||
```
|
||||
|
||||
Implemented in `packages/plugins/runtime-process/src/pty-host.ts` (also runnable as a `.cjs` script). Key properties:
|
||||
|
||||
- Spawned `detached: true, windowsHide: true` by `runtime-process` and `unref`'d so it survives parent exit (mirrors tmux daemon behaviour).
|
||||
- Signals readiness by printing `READY:<pid>` to stdout; the spawner waits for that line (10 s timeout) before considering the session up.
|
||||
- Maintains a 1000-line rolling output buffer, ANSI-faithful, replayed to every new client connection (this is the "scrollback on attach" equivalent of `tmux attach`).
|
||||
- Intercepts `SIGTERM`/`SIGINT`/`SIGHUP`/`SIGBREAK`/`beforeExit`/`uncaughtException`/`exit` and always calls `pty.kill()` before exiting. Without this, ConPTY's `conpty_console_list_agent.exe` orphans and triggers a Windows Error Reporting dialog (`0x800700e8`).
|
||||
|
||||
### Pipe protocol
|
||||
|
||||
The pty-host exposes a small binary protocol over `\\.\pipe\ao-pty-{sessionId}`. Messages share a 5-byte header — `[1-byte type][4-byte big-endian length]` — followed by the payload.
|
||||
|
||||
| Type | Direction | Meaning |
|
||||
|------|-----------|---------|
|
||||
| `0x01` `MSG_TERMINAL_DATA` | host → client | Raw PTY output bytes |
|
||||
| `0x02` `MSG_TERMINAL_INPUT` | client → host | User keystrokes (chunked into ≤512 chars with 15 ms gaps to avoid ConPTY input-buffer truncation) |
|
||||
| `0x03` `MSG_RESIZE` | client → host | JSON `{cols, rows}` |
|
||||
| `0x04` / `0x05` `MSG_GET_OUTPUT_REQ` / `_RES` | client ↔ host | Request and return scrollback buffer |
|
||||
| `0x06` / `0x07` `MSG_STATUS_REQ` / `_RES` | client ↔ host | Liveness check (`{alive, pid, exitCode?}`) |
|
||||
| `0x08` `MSG_KILL_REQ` | client → host | Cooperative shutdown (host disposes ConPTY then exits) |
|
||||
|
||||
Client helpers in `packages/plugins/runtime-process/src/pty-client.ts`:
|
||||
- `connectPtyHost`, `ptyHostSendMessage`, `ptyHostGetOutput`, `ptyHostIsAlive`, `ptyHostKill`, plus `getPipePath(sessionId)` → `\\.\pipe\ao-pty-{sessionId}`.
|
||||
- `MessageParser` skips interleaved data frames so request/response pairs work over a busy pipe.
|
||||
|
||||
### Mux WS server: tmux vs Windows pipe relay
|
||||
|
||||
`packages/web/server/mux-websocket.ts` branches by platform:
|
||||
|
||||
- **Unix**: instantiates `TerminalManager` (node-pty → tmux PTY) and dispatches all `terminal` channel messages to it.
|
||||
- **Windows**: skips `TerminalManager` entirely and routes through `handleWindowsPipeMessage(msg, ws, winPipes, winPipeBuffers, deps)`, which maps each `(projectId, sessionId)` to a `net.Socket` connected to its pipe. `open` opens the socket, `data` writes a `0x02` framed message, `resize` writes `0x03`, `close` ends the socket. Inbound `0x01` frames are forwarded back as WebSocket `{ch:"terminal", type:"data"}` payloads; `0x07` with `alive:false` becomes `exited`.
|
||||
- The pipe path is resolved by `resolvePipePath(sessionId, projectId?)` in `packages/web/server/tmux-utils.ts`, which reads the session's metadata file (V2 layout `~/.agent-orchestrator/projects/{projectId}/sessions/{sessionId}.json`, V1 fallback) and returns the `pipePath` field that `runtime-process` wrote at spawn time.
|
||||
- `findTmux()` returns `null` on Windows; `direct-terminal-ws.ts` logs `Windows mode — using named pipe relay to PTY hosts` and starts the same WS server with no tmux dependency.
|
||||
|
||||
### Pty-host registry — `~/.agent-orchestrator/windows-pty-hosts.json`
|
||||
|
||||
Because pty-hosts run detached, `taskkill /T` on the parent ao-start process cannot reach them. To allow `ao stop` to find and clean them up, every spawned pty-host is recorded in a small JSON registry.
|
||||
|
||||
`packages/core/src/windows-pty-registry.ts`:
|
||||
- `registerWindowsPtyHost(entry)` — write/replace the entry on spawn.
|
||||
- `getWindowsPtyHosts()` — read all entries; auto-prune any whose PID is gone (probed via `process.kill(pid, 0)`, treating `EPERM` as alive).
|
||||
- `unregisterWindowsPtyHost(sessionId)` — remove on session destroy.
|
||||
- `clearWindowsPtyHostRegistry()` — wipe (for tests / recovery).
|
||||
|
||||
`sweepWindowsPtyHosts()` (in `runtime-process`) iterates the registry: for each live entry it sends a graceful `MSG_KILL_REQ` over the pipe, polls up to 500 ms for the process to exit (treating `EPERM` as still alive), then `killProcessTree(ptyHostPid, "SIGKILL")` for stragglers. It is called by `ao stop` and `ao stop --all` before tearing down the parent process.
|
||||
|
||||
### Process map (Windows variant)
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Host
|
||||
CLI["ao CLI"]
|
||||
Next["Next.js :3000"]
|
||||
MuxSrv["Terminal WS :14801"]
|
||||
Sweep["sweepWindowsPtyHosts()<br/>(called by ao stop)"]
|
||||
end
|
||||
|
||||
subgraph Sessions["Per-session pty-hosts (detached)"]
|
||||
PH1["pty-host #1<br/>\\.\pipe\ao-pty-id1"]
|
||||
PH2["pty-host #2<br/>\\.\pipe\ao-pty-id2"]
|
||||
end
|
||||
|
||||
subgraph Storage["Flat files"]
|
||||
Reg["~/.agent-orchestrator/<br/>windows-pty-hosts.json"]
|
||||
Meta["~/.agent-orchestrator/<br/>projects/{id}/sessions/*"]
|
||||
end
|
||||
|
||||
CLI -- "spawn detached" --> PH1
|
||||
CLI -- "spawn detached" --> PH2
|
||||
PH1 -- "register" --> Reg
|
||||
PH2 -- "register" --> Reg
|
||||
MuxSrv -- "resolvePipePath()<br/>reads metadata" --> Meta
|
||||
MuxSrv -- "net.Socket connect" --> PH1
|
||||
MuxSrv -- "net.Socket connect" --> PH2
|
||||
Sweep -- "MSG_KILL_REQ → killProcessTree" --> PH1
|
||||
Sweep -- "MSG_KILL_REQ → killProcessTree" --> PH2
|
||||
Sweep -- "read entries" --> Reg
|
||||
```
|
||||
|
||||
### Shell resolution
|
||||
|
||||
`getShell()` in `packages/core/src/platform.ts` is platform-aware and cached:
|
||||
|
||||
- **Unix**: `/bin/sh -c` (always; never `$SHELL` — non-interactive launches must not depend on the user's login shell).
|
||||
- **Windows** (`resolveWindowsShell`): in priority order — `AO_SHELL` env override → `pwsh` on PATH → `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe` (absolute path, robust to degraded PATH) → `powershell` on PATH → `%ComSpec%` (`cmd.exe`, last resort).
|
||||
|
||||
Args are inferred from the basename: `cmd` → `/c`, `bash`/`sh`/`zsh` → `-c`, anything PowerShell-shaped → `-Command`. `AO_SHELL` is the supported escape hatch (e.g. for Git Bash users).
|
||||
|
||||
### Other Windows-specific touch points
|
||||
|
||||
- **CLI** — `ao start` no longer detaches its dashboard child on Windows (so Ctrl+C reaches the whole console group); `forwardSignalsToChild` is Unix-only. `ao stop` calls `sweepWindowsPtyHosts()` before terminating the parent. `script-runner.ts` runs `.ps1` siblings of `.sh` scripts directly on Windows; otherwise it tries `AO_BASH_PATH` then auto-detects Git Bash (WSL bash is excluded — it sees Linux paths from a Windows cwd).
|
||||
- **Agent plugins** — `setupPathWrapperWorkspace()` generates `.cjs` + `.cmd` wrapper pairs (instead of bash scripts) for `gh`/`git` interception. `formatLaunchCommand` for codex / kimicode prepends `& ` so PowerShell parses the quoted binary path as a call expression. `agent-claude-code` ships a Node.js metadata-updater (`.cjs`) hook in place of the bash version; system-prompt files are inlined rather than `$(cat …)`-substituted.
|
||||
- **Path-equality** — `packages/cli/src/lib/path-equality.ts` (`pathsEqual`, `canonicalCompareKey`) handles NTFS case-insensitivity and drive-letter case differences when comparing project paths in `ao start`.
|
||||
- **`stopStaleWindowsPtyHosts(projectDir)`** in `packages/web/src/lib/windows-pty-cleanup.ts` is a defensive sweeper used by the dashboard to clean up orphan pty-hosts found via a PowerShell `Get-CimInstance Win32_Process` query.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ ao completion zsh # Print the zsh completion script
|
|||
|
||||
## Commands the orchestrator agent uses
|
||||
|
||||
These are primarily invoked by the orchestrator agent running inside a tmux session. You can use them manually if needed, but the orchestrator handles this automatically.
|
||||
These are primarily invoked by the orchestrator agent running inside a runtime session (a tmux window on macOS/Linux; a ConPTY pty-host on Windows). You can use them manually if needed, but the orchestrator handles this automatically.
|
||||
|
||||
```bash
|
||||
ao spawn [issue] # Spawn an agent (project auto-detected from cwd)
|
||||
|
|
@ -64,9 +64,9 @@ compinit
|
|||
With Oh My Zsh, write the generated file to `${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/ao/_ao`
|
||||
and add `ao` to the `plugins=(...)` list in `~/.zshrc`.
|
||||
|
||||
`ao doctor` checks PATH and launcher resolution, required binaries, configured plugin resolution, tmux and GitHub CLI health, config support directories, stale AO temp files, and core build/runtime sanity.
|
||||
`ao doctor` checks PATH and launcher resolution, required binaries, configured plugin resolution, terminal-runtime health (tmux on Unix; PowerShell / `runtime-process` on Windows), GitHub CLI health, config support directories, stale AO temp files, and core build/runtime sanity. Runs and is supported on macOS, Linux, and Windows.
|
||||
|
||||
`ao update` fast-forwards the local install on `main`, reinstalls dependencies, clean-rebuilds core packages, refreshes the launcher, and runs smoke tests. Use `ao update --skip-smoke` to stop after rebuild, or `ao update --smoke-only` to rerun just the smoke checks.
|
||||
`ao update` fast-forwards the local install on `main`, reinstalls dependencies, clean-rebuilds core packages, refreshes the launcher, and runs smoke tests. Works on macOS, Linux, and Windows (Windows uses the bundled `ao-update.ps1` script automatically). Use `ao update --skip-smoke` to stop after rebuild, or `ao update --smoke-only` to rerun just the smoke checks.
|
||||
|
||||
## Multi-Project Rollout
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,389 @@
|
|||
# Cross-Platform Compatibility
|
||||
|
||||
> **Read this before merging any change that touches process spawning, path handling, shell commands, network binding, file I/O, runtime/agent/workspace plugins, or anything that does platform-specific work.**
|
||||
>
|
||||
> AO ships on macOS, Linux, **and Windows**. All three are first-class — every change must keep all three working.
|
||||
|
||||
---
|
||||
|
||||
## The Golden Rule
|
||||
|
||||
> **Never write `process.platform === "win32"` in new code. Use `isWindows()` from `@aoagents/ao-core`. If you need branching the helper doesn't cover, add it to `packages/core/src/platform.ts` (or one of the targeted helpers in [the inventory](#helper-inventory)) — never inline at the call site.**
|
||||
|
||||
This isn't stylistic. The branching in `platform.ts` is centrally tested with `Object.defineProperty(process, "platform", …)` so both Windows and POSIX paths are exercised on every CI runner. Inline `process.platform` checks are invisible to that test pattern, drift out of sync, and produce the bugs that took weeks to track down on the way to shipping the Windows port.
|
||||
|
||||
If you find yourself typing `process.platform`:
|
||||
|
||||
1. Stop. Look at the [helper inventory below](#helper-inventory) — almost certainly the helper you need already exists.
|
||||
2. If it doesn't, ask: "Could a future feature also need this branch?" Almost always yes. Add a function to `platform.ts` (or the closest existing helper module) and test both branches.
|
||||
3. Only if the branch is genuinely a one-off (e.g. a single test guarding a Linux-only assertion) is an inline check acceptable, and even then prefer `isWindows()` for readability.
|
||||
|
||||
---
|
||||
|
||||
## When to read this file
|
||||
|
||||
If your change does **any** of the following, you must read the relevant section below:
|
||||
|
||||
| If you're touching… | …read |
|
||||
|---------------------|-------|
|
||||
| `process.spawn`, `child_process`, runtime plugins | [The two runtimes](#the-two-runtimes), [Process management](#process-management-gotchas) |
|
||||
| `process.kill`, signals, process-tree teardown | [Process management](#process-management-gotchas) |
|
||||
| Anything with file paths (compare, join, walk) | [Paths](#paths) |
|
||||
| Shell commands (`exec`, command strings) | [Shell](#shell) |
|
||||
| `server.listen`, sockets, `localhost` | [Networking](#networking) |
|
||||
| tmux / lsof / pkill / which / coreutils shell-outs | [POSIX-only tools](#posix-only-tools) |
|
||||
| Adding a new `if (process.platform === "win32")` | [The Golden Rule](#the-golden-rule), [Helper inventory](#helper-inventory) |
|
||||
| Agent plugins (PATH wrappers, hooks, launch commands) | [Agent plugin helpers](#agent-plugin-helpers) |
|
||||
| Activity detection / JSONL processing | [Activity-state helpers](#activity-state-helpers) |
|
||||
| Tests for any of the above | [Testing for cross-platform behaviour](#testing-for-cross-platform-behaviour) |
|
||||
| Anything else? | At minimum, the [pre-merge checklist](#pre-merge-checklist) |
|
||||
|
||||
---
|
||||
|
||||
## Helper inventory
|
||||
|
||||
Every helper you need to write Windows-safe code. **Memorise the imports — these are the building blocks.**
|
||||
|
||||
### Platform check + defaults — `packages/core/src/platform.ts`
|
||||
|
||||
```ts
|
||||
import {
|
||||
isWindows,
|
||||
getDefaultRuntime,
|
||||
getShell,
|
||||
killProcessTree,
|
||||
findPidByPort,
|
||||
getEnvDefaults,
|
||||
} from "@aoagents/ao-core";
|
||||
```
|
||||
|
||||
| Symbol | Purpose | Notes |
|
||||
|--------|---------|-------|
|
||||
| `isWindows(): boolean` | The canonical OS check. **Always use this** instead of `process.platform === "win32"`. | Constant-time. Trivially mockable in tests. |
|
||||
| `getDefaultRuntime(): "tmux" \| "process"` | Returns `"process"` on Windows, `"tmux"` elsewhere. Used by `ao start` / startup-preflight to default runtime selection. | Don't hardcode `"tmux"`. |
|
||||
| `getShell(): { cmd, args(command) }` | Resolves the shell for non-interactive command execution. POSIX → `/bin/sh -c`. Windows → priority order: `AO_SHELL` env override → `pwsh` → `powershell.exe` (absolute path, robust to degraded PATH) → `powershell` → `cmd.exe`. Cached. | Use this whenever you need to run *any* shellish string. Don't assume bash. |
|
||||
| `killProcessTree(pid, signal?)` | Kills a process and its descendants. Windows → `taskkill /T /F /PID <pid>`. POSIX → `process.kill(-pid, signal)` with direct-PID fallback. Guards `pid > 0`. | **Never write `process.kill(-pid, …)` directly.** Negative PIDs are POSIX-only. |
|
||||
| `findPidByPort(port): Promise<string \| null>` | Finds the LISTENING PID on a port. Windows → parses `netstat -ano`. POSIX → `lsof -ti :PORT -sTCP:LISTEN`. | Use this; don't shell-out yourself. |
|
||||
| `getEnvDefaults(): { HOME, SHELL, TMPDIR, PATH, USER }` | Returns platform-correct env defaults: Windows reads `USERPROFILE`/`TEMP`/`USERNAME`, POSIX reads `HOME`/`SHELL`/`TMPDIR`/`USER`. | Use instead of hardcoding `/tmp`, `~`, `$HOME`. |
|
||||
| `_resetShellCache()` | Test-only — clears the cached shell resolution. | `@internal`. |
|
||||
|
||||
### Path equality — `packages/cli/src/lib/path-equality.ts`
|
||||
|
||||
```ts
|
||||
import { pathsEqual, canonicalCompareKey } from "../../src/lib/path-equality.js";
|
||||
```
|
||||
|
||||
| Symbol | Purpose |
|
||||
|--------|---------|
|
||||
| `pathsEqual(a, b): boolean` | "Same filesystem entry" comparison. Resolves both via `realpathSync` (falls back to literal on error), then lowercases on Windows so `D:\Foo` == `d:\foo`. |
|
||||
| `canonicalCompareKey(input): string` | Stable Map/Set key for a path. Expands `~`, resolves to absolute, calls `realpathSync`, lowercases on Windows. |
|
||||
|
||||
**Rule:** never compare paths with `===`. Always go through these.
|
||||
|
||||
### Windows pty-host registry — `packages/core/src/windows-pty-registry.ts`
|
||||
|
||||
Only used by Windows runtime code, but exported from `@aoagents/ao-core` so the CLI's `ao stop` can find detached pty-hosts that `taskkill /T` cannot reach.
|
||||
|
||||
```ts
|
||||
import {
|
||||
registerWindowsPtyHost,
|
||||
unregisterWindowsPtyHost,
|
||||
getWindowsPtyHosts,
|
||||
clearWindowsPtyHostRegistry,
|
||||
} from "@aoagents/ao-core";
|
||||
```
|
||||
|
||||
| Symbol | Purpose |
|
||||
|--------|---------|
|
||||
| `registerWindowsPtyHost(entry)` | Add/replace a `{sessionId, ptyHostPid, pipePath}` entry in `~/.agent-orchestrator/windows-pty-hosts.json`. Called when `runtime-process` spawns a pty-host. |
|
||||
| `unregisterWindowsPtyHost(sessionId)` | Remove on session destroy. |
|
||||
| `getWindowsPtyHosts(): WindowsPtyHostEntry[]` | Return all entries whose PID is still alive (probes via `process.kill(pid, 0)` treating `EPERM` as alive). Auto-prunes dead ones. |
|
||||
| `clearWindowsPtyHostRegistry()` | Wipe the file (recovery / tests). |
|
||||
|
||||
### Pty-host client (Windows pipe protocol) — `packages/plugins/runtime-process/src/pty-client.ts`
|
||||
|
||||
Use these whenever you need to talk to a Windows pty-host over its named pipe. The mux WS server, `runtime-process`, and `sweepWindowsPtyHosts` all go through this module — never write to a `\\.\pipe\…` directly.
|
||||
|
||||
```ts
|
||||
import {
|
||||
getPipePath,
|
||||
connectPtyHost,
|
||||
ptyHostSendMessage,
|
||||
ptyHostGetOutput,
|
||||
ptyHostIsAlive,
|
||||
ptyHostKill,
|
||||
MessageParser,
|
||||
encodeMessage,
|
||||
} from "@aoagents/ao-plugin-runtime-process";
|
||||
```
|
||||
|
||||
| Symbol | Purpose |
|
||||
|--------|---------|
|
||||
| `getPipePath(sessionId)` | Returns `\\.\pipe\ao-pty-<sessionId>`. Don't construct the path manually. |
|
||||
| `connectPtyHost(pipePath, timeoutMs?)` | Open a `net.Socket` to the named pipe with timeout. |
|
||||
| `ptyHostSendMessage(pipePath, message)` | Send keystrokes; chunks into ≤512-char pieces with 15 ms gaps to dodge ConPTY input-buffer truncation. |
|
||||
| `ptyHostGetOutput(pipePath, lines?)` | Request scrollback buffer. Returns `""` on timeout. |
|
||||
| `ptyHostIsAlive(pipePath)` | Liveness probe; `true` ≡ pipe reachable. |
|
||||
| `ptyHostKill(pipePath)` | Cooperative shutdown (host disposes ConPTY then exits). Silently succeeds if pipe is unreachable. |
|
||||
| `MessageParser`, `encodeMessage` | Frame-protocol primitives if you're writing new pty-host integrations. |
|
||||
|
||||
### Pty-host sweep — `packages/plugins/runtime-process/src/index.ts`
|
||||
|
||||
```ts
|
||||
import { sweepWindowsPtyHosts } from "@aoagents/ao-plugin-runtime-process";
|
||||
```
|
||||
|
||||
`sweepWindowsPtyHosts(): Promise<{ attempted, gracefullyExited, forceKilled, failed }>` — iterates the registry, sends graceful `MSG_KILL_REQ`, polls up to 500 ms, then `killProcessTree` for stragglers. Called by `ao stop`. **No-op on non-Windows.**
|
||||
|
||||
The exit-poll inside this function is the canonical EPERM/ESRCH pattern — copy it whenever you probe a Windows process for liveness:
|
||||
|
||||
```ts
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
process.kill(entry.ptyHostPid, 0);
|
||||
} catch (err: unknown) {
|
||||
// EPERM = alive but unsignalable (cross-context on Windows) → fall through to force-kill.
|
||||
// ESRCH (or anything else) = process is gone → mark exited.
|
||||
if ((err as { code?: string }).code !== "EPERM") {
|
||||
exited = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
```
|
||||
|
||||
### Web-side helpers
|
||||
|
||||
```ts
|
||||
// packages/web/server/tmux-utils.ts
|
||||
import { validateSessionId, resolvePipePath } from "@/server/tmux-utils";
|
||||
|
||||
// packages/web/src/lib/windows-pty-cleanup.ts
|
||||
import { stopStaleWindowsPtyHosts } from "@/lib/windows-pty-cleanup";
|
||||
```
|
||||
|
||||
| Symbol | Purpose |
|
||||
|--------|---------|
|
||||
| `validateSessionId(id): boolean` | Charset/length guard. **Always validate any session ID before using it in a tmux command, named-pipe path, or shell argument** — these are user-controllable inputs. |
|
||||
| `resolvePipePath(sessionId, projectId?)` | Reads the session metadata file and returns the `pipePath` field stored by `runtime-process`. Returns `null` on non-Windows. Used by the mux WS server when relaying pipe traffic. |
|
||||
| `stopStaleWindowsPtyHosts(projectDir)` | Defensive sweeper. Uses a PowerShell `Get-CimInstance Win32_Process` query to find pty-hosts whose command line contains a project dir, then `taskkill`'s them. No-op on non-Windows. Use as a recovery escape hatch, not in the hot path. |
|
||||
|
||||
### Agent plugin helpers — `packages/core/src/agent-workspace-hooks.ts`
|
||||
|
||||
```ts
|
||||
import { setupPathWrapperWorkspace, buildAgentPath } from "@aoagents/ao-core";
|
||||
```
|
||||
|
||||
| Symbol | Purpose |
|
||||
|--------|---------|
|
||||
| `setupPathWrapperWorkspace(workspacePath)` | Installs `~/.ao/bin` PATH wrappers for `gh` / `git` so AO can intercept agent commands. **Cross-platform.** On Windows it generates `.cjs` + `.cmd` wrapper pairs (skipping bash); on Unix it generates the bash equivalents. Every agent plugin that uses PATH-wrapper interception (codex, kimicode, aider, opencode) must call this — never reimplement. |
|
||||
| `buildAgentPath(basePath?)` | Prepends `~/.ao/bin` to PATH using the right separator (`;` on Windows, `:` on Unix). Use when constructing the agent's env. |
|
||||
|
||||
### Activity-state helpers — `packages/core/src/activity-log.ts` and `utils.ts`
|
||||
|
||||
```ts
|
||||
import {
|
||||
appendActivityEntry,
|
||||
readLastActivityEntry,
|
||||
checkActivityLogState,
|
||||
getActivityFallbackState,
|
||||
classifyTerminalActivity,
|
||||
recordTerminalActivity,
|
||||
readLastJsonlEntry,
|
||||
} from "@aoagents/ao-core";
|
||||
```
|
||||
|
||||
`getActivityFallbackState` is **mandatory** for new agent plugins. See [the agent-plugin section in the root CLAUDE.md](../CLAUDE.md#agent-plugin-implementation-standards) for the full contract — but the relevant cross-platform note is: AO activity JSONL works the same on all platforms, so write your activity-detection logic against it, not against tmux capture-pane / ps output.
|
||||
|
||||
### Shell escaping — `packages/core/src/utils.ts`
|
||||
|
||||
```ts
|
||||
import { shellEscape } from "@aoagents/ao-core";
|
||||
```
|
||||
|
||||
`shellEscape(arg)` produces a safely-quoted argument. Always use it when interpolating any value into a shell command line, even on Windows. Windows quoting rules are messier than POSIX and the helper handles them.
|
||||
|
||||
### CLI signal forwarding — `packages/cli/src/lib/shell.ts`
|
||||
|
||||
```ts
|
||||
import { forwardSignalsToChild } from "../lib/shell.js";
|
||||
```
|
||||
|
||||
`forwardSignalsToChild(pid, child)` — call **only on POSIX** (`if (!isWindows() && pid)`). On Windows, Ctrl+C reaches the entire console group natively; explicit forwarding is harmful (double-signals).
|
||||
|
||||
### Environment variables to know
|
||||
|
||||
| Variable | Effect |
|
||||
|----------|--------|
|
||||
| `AO_SHELL` | Override `getShell()` resolution. Set to an absolute path or shell name (`pwsh`, `cmd`, `bash`, …). Args are inferred from basename. The supported escape hatch for Git Bash users on Windows. |
|
||||
| `AO_BASH_PATH` | Used by `script-runner.ts` on Windows to locate bash before falling back to Git Bash auto-detection. WSL bash is intentionally excluded. |
|
||||
|
||||
---
|
||||
|
||||
## The two runtimes
|
||||
|
||||
| Platform | Default runtime | How PTYs work |
|
||||
|----------|----------------|---------------|
|
||||
| macOS / Linux | `tmux` | Real tmux server, POSIX signals, Unix sockets |
|
||||
| Windows | `process` | `node-pty` + ConPTY, named pipes (`\\.\pipe\ao-pty-…`), pty-host helper process |
|
||||
|
||||
Pick the runtime via `getDefaultRuntime()`, never hardcode. Plugin code that runs across runtimes must handle both — for Windows that means no `tmux` shell-outs, no SIGTERM/SIGKILL group kills, no POSIX-only tools.
|
||||
|
||||
For the architectural detail of how the Windows pty-host, named-pipe protocol, and mux WS Windows branch fit together, see the **"Windows Runtime Architecture"** section at the bottom of [`docs/ARCHITECTURE.md`](ARCHITECTURE.md).
|
||||
|
||||
---
|
||||
|
||||
## Process management gotchas
|
||||
|
||||
- **`process.kill(pid, 0)` distinguishes liveness on POSIX, but on Windows it can throw `EPERM`** when the target exists in a different security context. Treat `EPERM` as *alive but unsignalable* (fall through to force-kill); only `ESRCH` (or any other code) means the process is gone. The pattern is shown in the [`sweepWindowsPtyHosts` snippet above](#pty-host-sweep--packagespluginsruntime-processsrcindexts) — copy it, don't bare-`catch`. The same pattern lives in `runtime-process` `destroy()` (around line 290) and was the bug fix that prompted this section.
|
||||
- **Never `process.kill(-pid, …)`** to kill a process group. Negative PIDs are POSIX-only and become a no-op or worse on Windows. Use `killProcessTree()`.
|
||||
- **Graceful shutdown before SIGKILL on Windows**: SIGKILL'ing the pty-host while ConPTY is mid-spawn orphans `conpty_console_list_agent.exe` and triggers a Windows Error Reporting dialog (`0x800700e8`). Send the cooperative kill (`ptyHostKill`) first, poll for exit ~500 ms, **then** `killProcessTree`.
|
||||
- **`pid <= 0` guard**: `process.kill(0, …)` signals the *current process group* on Unix. Always guard `pid > 0` before signalling.
|
||||
- **Detached children**: on Windows `ao start` does NOT detach its dashboard child (so Ctrl+C reaches the whole console group natively); on POSIX it does. Use `detached: !isWindows()` rather than always-`true` or always-`false`.
|
||||
|
||||
## Paths
|
||||
|
||||
- **Filesystem case-insensitive on Windows (NTFS) and macOS (default APFS)**, case-sensitive on Linux. `D:\Foo` and `d:\foo` are the same directory; `/foo` and `/Foo` are not. Compare paths via `pathsEqual()`, never `===`.
|
||||
- **Always use `path.join()` / `path.sep`**. Never hardcode `/` or `\` separators. Never split paths on `/` to walk segments.
|
||||
- **Drive letters and UNC paths exist.** A path can start with `C:\`, `\\?\C:\`, `\\server\share\`, or `D:`. Don't assume paths begin with `/`.
|
||||
- **Paths can contain spaces** (`C:\Program Files\…`, `C:\Users\Some Name\…`). Always quote when interpolating into shell commands; prefer `execFile` over `exec`.
|
||||
- **HOME / tmp paths differ**: use `getEnvDefaults()` rather than hardcoding `/tmp`, `~`, or `$HOME`.
|
||||
- **Drive-letter slugs**: when encoding a path as a filename slug (used by Claude Code's session-JSONL lookup), `C:\Users\dev\project` → `C--Users-dev-project`. Preserve the leading drive-letter dash; don't strip the colon-replacement.
|
||||
|
||||
## Shell
|
||||
|
||||
- **Default shell on Windows is PowerShell**, not bash. Bash syntax (`&&` chains, `$VAR`, `2>/dev/null`, here-docs) won't work in `cmd.exe` and is only partially supported by PowerShell. When you need to run *anything* shellish from Node, prefer `execFile` with explicit args; if you must use a shell, route through `getShell()`.
|
||||
- **PowerShell call operator**: a launch command that begins with a quoted absolute path needs `& ` prepended on Windows (e.g. `& "C:\path\to\bin.exe" arg1`) or PowerShell parses the quoted path as a string expression. The `agent-codex` and `agent-kimicode` plugins do this in `formatLaunchCommand`.
|
||||
- **No `/dev/null`** on Windows — use `NUL`, or just discard the stream in Node.
|
||||
- **Env vars in PowerShell**: `$env:NAME`, not `$NAME`. Line continuation is backtick (`` ` ``), not backslash.
|
||||
- **`.cmd` / `.bat` / `.exe` shims**: spawning npm-installed CLIs (e.g. `codex`, `where`) needs `shell: true` on Windows so `PATHEXT` is consulted; otherwise Node only finds extensionless executables. Pattern: `spawn(cmd, args, { shell: isWindows(), windowsHide: true })`.
|
||||
- **`windowsHide: true`** on every `spawn`/`execFile` you don't want flashing a console window.
|
||||
- **Always `shellEscape()`** any value that ends up in a shell command line, even on Windows. Windows quoting rules are tricky and the helper handles them.
|
||||
- **Avoid pipes / redirection in shell strings** — they don't behave consistently across cmd.exe / PowerShell / bash. Build the pipeline in Node with stream APIs instead.
|
||||
- **`$(cat …)` substitution** doesn't exist in PowerShell or cmd.exe. If you're inlining a file's contents into a command line, read it in Node and pass the contents as an argument (e.g. `--append-system-prompt <content>`).
|
||||
|
||||
## Networking
|
||||
|
||||
- **Bind to `127.0.0.1` explicitly, not `localhost`**, when starting local servers. On Windows `localhost` resolves to `::1` first; if the server only listens on IPv4 the client stalls ~21 s before the kernel falls back. The same problem reverses if you bind IPv6-only.
|
||||
- **Named pipes** are the Windows IPC primitive (`\\.\pipe\…`); the relay code already handles them in `mux-websocket.ts` via `handleWindowsPipeMessage`. Don't introduce Unix-socket assumptions in new code paths.
|
||||
- **Firewall prompts**: any `0.0.0.0` bind on Windows can pop a Windows Defender Firewall prompt the first time it runs. Stick to loopback unless there's a real reason.
|
||||
- **Pipe path injection**: a pipe path is constructed from a session ID; always validate that ID with `validateSessionId()` before passing to `getPipePath()` or interpolating into any system call.
|
||||
|
||||
## POSIX-only tools
|
||||
|
||||
`tmux`, `screen`, `lsof`, `pkill`, `which`, most coreutils — gone on Windows. If you need their function, either branch through `platform.ts` or use a Node API instead.
|
||||
|
||||
Examples already in `platform.ts`:
|
||||
- `findPidByPort` uses `netstat -ano` on Windows vs `lsof` elsewhere
|
||||
- `killProcessTree` uses `taskkill /T /F` vs POSIX signal-based kill
|
||||
- `getShell` resolves PowerShell on Windows vs `/bin/sh` on POSIX
|
||||
|
||||
If you find yourself reaching for a POSIX-only binary in new code, **add the Windows alternative to `platform.ts`** rather than gating the feature.
|
||||
|
||||
## Agent plugin specifics (Windows)
|
||||
|
||||
When writing or modifying an agent plugin (`packages/plugins/agent-*`), these are the patterns to follow:
|
||||
|
||||
- **Use `setupPathWrapperWorkspace`** for PATH-wrapper interception (gh / git). It auto-handles bash vs `.cmd`+`.cjs` wrappers per platform.
|
||||
- **`isProcessRunning`** must short-circuit on Windows when it would have used tmux or `ps -eo`: `if (isWindows()) return false` (or implement a real Windows check via tasklist / signal-0 with EPERM handling — never assume tmux exists).
|
||||
- **`detect()`** spawn options should be `{ shell: isWindows(), windowsHide: true }` so `.cmd` shims resolve via `PATHEXT` and no console window flashes.
|
||||
- **Stderr suppression** — the cursor plugin's `detect()` previously bled stderr to the user's console on Windows; it now uses `stdio: ['ignore', 'pipe', 'ignore']` for the probe. Match that pattern.
|
||||
- **`getCachedProcessList()`** (Claude Code) should return `""` on Windows — `ps -eo` doesn't exist.
|
||||
- **`formatLaunchCommand`**: when the binary is at a quoted absolute path, prepend `& ` on Windows so PowerShell parses it as a call.
|
||||
- **`systemPromptFile`**: instead of `$(cat <file>)` shell substitution, read the file in Node and inline as `--append-system-prompt <content>`.
|
||||
- **Codex binary resolution**: prefer `.cmd` shims (npm) over `.exe` (Cargo) on Windows; use `where.exe` (not `which`).
|
||||
|
||||
## Activity-state helpers
|
||||
|
||||
The activity-detection contract in CLAUDE.md is platform-agnostic — same JSONL on all platforms — but the inputs (terminal output) come from different runtimes. Use `recordTerminalActivity` from core (which delegates to `classifyTerminalActivity` → `appendActivityEntry`) so you don't have to think about platform.
|
||||
|
||||
The mandatory `getActivityFallbackState` step (see CLAUDE.md "Activity detection architecture") is what keeps the dashboard alive when a native agent API is unavailable — which on Windows happens more often than on Unix because more things shell-out and fail silently. Skipping it has historically broken stuck-detection on Windows.
|
||||
|
||||
---
|
||||
|
||||
## Testing for cross-platform behaviour
|
||||
|
||||
CI runs on Linux, macOS, and Windows. To make platform-specific code reviewable in a single host environment and to catch regressions even when one runner is unavailable:
|
||||
|
||||
- Any new function in `platform.ts` (or platform-branching elsewhere) must have **both** an `it.skipIf(process.platform !== "win32")` test and a POSIX test. See `packages/cli/__tests__/lib/path-equality.test.ts` for the pattern (it mocks `process.platform` via `Object.defineProperty` to exercise both branches on a single CI host).
|
||||
- For process-kill / EPERM-handling code, add a unit test that simulates `process.kill` throwing `{ code: "EPERM" }` and asserts force-kill is still attempted. The `runtime-process` test suite has examples (look for "win32 destroy when graceful shutdown times out").
|
||||
- Plugin tests that hit a tmux runtime must `skipIf(isWindows())`. Plugin tests that hit `runtime-process` should run on all platforms.
|
||||
- For path code, test mixed-case inputs and inputs with spaces.
|
||||
|
||||
Pattern for mocking platform on Linux CI:
|
||||
|
||||
```ts
|
||||
let originalPlatform: PropertyDescriptor | undefined;
|
||||
beforeEach(() => {
|
||||
originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
});
|
||||
afterEach(() => {
|
||||
if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform);
|
||||
});
|
||||
function setPlatform(p: NodeJS.Platform) {
|
||||
Object.defineProperty(process, "platform", { value: p, configurable: true });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pre-merge checklist
|
||||
|
||||
Before saying "done" on any feature, verify each of these (or mark N/A with reasoning):
|
||||
|
||||
1. **No raw `process.platform` checks** — used `isWindows()` from `@aoagents/ao-core`?
|
||||
2. **Process spawning** — used `runtime-process` (Windows) or `runtime-tmux` (POSIX) abstractions? Shell-out used `shellEscape` + `getShell` or `execFile`? `windowsHide: true` and `shell: isWindows()` for `.cmd`/`.bat` resolution?
|
||||
3. **Process killing** — distinguished `EPERM` from `ESRCH`? No negative PIDs? Used `killProcessTree`? Guarded `pid > 0`? Cooperative kill before force-kill on Windows?
|
||||
4. **Paths** — used `pathsEqual` for comparison? `path.join` for construction? No `===`, no hardcoded `/` or `\`?
|
||||
5. **Shell** — no bash-isms (`&&` chains, `$(cat)`, `$VAR`, `/dev/null`)? `& ` prefix for quoted-path PowerShell calls? Routed through `getShell()` or used `execFile`?
|
||||
6. **Networking** — explicit `127.0.0.1` instead of `localhost`? Validated session IDs before constructing pipe paths?
|
||||
7. **Runtimes** — both `runtime-tmux` and `runtime-process` paths covered? `isProcessRunning` works for tmux TTY *and* PID signal-0 *with EPERM handling*?
|
||||
8. **Agent plugins** — `setupPathWrapperWorkspace` instead of bash hooks? `getActivityFallbackState` fallback in `getActivityState`?
|
||||
9. **New platform branching** — went into `platform.ts` (or another shared helper), not inline at call sites?
|
||||
10. **Tests** — both Windows and POSIX branches covered (mock `process.platform` if you can't run on both)?
|
||||
|
||||
If you can't say "yes" or "N/A" to all ten, your change probably breaks Windows.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference: "where do I import X from?"
|
||||
|
||||
```ts
|
||||
// Platform check, runtime/shell/env defaults, process kill, port lookup
|
||||
import {
|
||||
isWindows, getDefaultRuntime, getShell,
|
||||
killProcessTree, findPidByPort, getEnvDefaults,
|
||||
shellEscape,
|
||||
setupPathWrapperWorkspace, buildAgentPath,
|
||||
registerWindowsPtyHost, unregisterWindowsPtyHost,
|
||||
getWindowsPtyHosts, clearWindowsPtyHostRegistry,
|
||||
appendActivityEntry, readLastActivityEntry,
|
||||
checkActivityLogState, getActivityFallbackState,
|
||||
classifyTerminalActivity, recordTerminalActivity,
|
||||
readLastJsonlEntry,
|
||||
} from "@aoagents/ao-core";
|
||||
|
||||
// Path comparison (CLI package)
|
||||
import { pathsEqual, canonicalCompareKey }
|
||||
from "../../src/lib/path-equality.js";
|
||||
|
||||
// Windows pty-host pipe protocol + sweep
|
||||
import {
|
||||
getPipePath, connectPtyHost, ptyHostSendMessage,
|
||||
ptyHostGetOutput, ptyHostIsAlive, ptyHostKill,
|
||||
MessageParser, encodeMessage,
|
||||
sweepWindowsPtyHosts,
|
||||
} from "@aoagents/ao-plugin-runtime-process";
|
||||
|
||||
// Web-side helpers
|
||||
import { validateSessionId, resolvePipePath }
|
||||
from "@/server/tmux-utils";
|
||||
import { stopStaleWindowsPtyHosts }
|
||||
from "@/lib/windows-pty-cleanup";
|
||||
|
||||
// CLI-only signal forwarding (POSIX only — guard with !isWindows())
|
||||
import { forwardSignalsToChild } from "../lib/shell.js";
|
||||
```
|
||||
|
||||
If a helper you need isn't in this list, that's a strong signal you should add it to `platform.ts` (or the closest existing module) rather than write platform-branching at the call site.
|
||||
|
|
@ -22,7 +22,7 @@ Every abstraction is a swappable plugin. All interfaces are defined in [`package
|
|||
|
||||
| Slot | Interface | Default | Alternatives |
|
||||
| --------- | ----------- | ------------- | ---------------------------------------- |
|
||||
| Runtime | `Runtime` | `tmux` | `process`, `docker`, `k8s`, `ssh`, `e2b` |
|
||||
| Runtime | `Runtime` | `tmux` (Unix) / `process` (Windows; ConPTY via node-pty) | `process`, `docker`, `k8s`, `ssh`, `e2b` |
|
||||
| Agent | `Agent` | `claude-code` | `codex`, `aider`, `cursor`, `kimicode`, `opencode` |
|
||||
| Workspace | `Workspace` | `worktree` | `clone` |
|
||||
| Tracker | `Tracker` | `github` | `linear` |
|
||||
|
|
@ -44,7 +44,7 @@ const dataDir = `~/.agent-orchestrator/${instanceId}`;
|
|||
This means:
|
||||
|
||||
- Multiple orchestrator checkouts on the same machine never collide
|
||||
- Session names are globally unique in tmux: `{hash}-{prefix}-{num}`
|
||||
- Runtime handles are globally unique: `{hash}-{prefix}-{num}` (tmux session name on Unix; suffix of the named pipe `\\.\pipe\ao-pty-{sessionId}` on Windows)
|
||||
- User-facing names stay clean: `ao-1`, `myapp-2`
|
||||
|
||||
### Session Lifecycle
|
||||
|
|
@ -388,8 +388,11 @@ cat ~/.agent-orchestrator/{hash}-{project}/sessions/{session-id}
|
|||
# Check API state
|
||||
curl http://localhost:3000/api/sessions/{session-id}
|
||||
|
||||
# Attach to tmux session directly
|
||||
# Attach to the runtime session directly
|
||||
# Unix:
|
||||
tmux attach -t {hash}-{prefix}-{num}
|
||||
# Windows: there's no tmux. Use the AO command, which connects to \\.\pipe\ao-pty-<sessionId>:
|
||||
ao session attach <sessionId>
|
||||
|
||||
# Enable verbose logging
|
||||
AO_LOG_LEVEL=debug ao start
|
||||
|
|
@ -469,10 +472,10 @@ Debuggability: `cat ~/.agent-orchestrator/a3b4-myapp/sessions/ao-1` shows full s
|
|||
Simpler local setup (no ngrok), survives orchestrator restarts, works offline. CI/review state is fetched, not pushed.
|
||||
|
||||
**Why plugin slots?**
|
||||
Swappability: use tmux locally, Docker in CI, Kubernetes in prod — without changing application code. Testability: mock any plugin in unit tests. Extensibility: users add company-specific plugins without forking.
|
||||
Swappability: use `process` (ConPTY) on Windows, tmux on Linux/macOS, Docker in CI, Kubernetes in prod — without changing application code. The `Runtime` interface is the layer that lets the same agent/workspace/tracker stack run across all of them. Testability: mock any plugin in unit tests. Extensibility: users add company-specific plugins without forking.
|
||||
|
||||
**Why hash-based namespacing?**
|
||||
Multiple orchestrator checkouts on the same machine don't collide in tmux or on disk. Different checkouts get different hashes; projects within the same config share a hash.
|
||||
Multiple orchestrator checkouts on the same machine don't collide at the runtime layer (tmux session names on Unix, named-pipe paths on Windows) or on disk. Different checkouts get different hashes; projects within the same config share a hash.
|
||||
|
||||
**Why ESM with `.js` extensions?**
|
||||
Node.js ESM requires explicit extensions on local imports. All packages use `"type": "module"`. Missing extensions cause runtime errors.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ Every interface the system uses is defined here. If you're working on any part o
|
|||
|
||||
**Main interfaces:**
|
||||
|
||||
- `Runtime` — where sessions execute (tmux, docker, k8s)
|
||||
- `Runtime` — where sessions execute (tmux on Unix, `process` / ConPTY via node-pty on Windows, docker, k8s)
|
||||
- `Agent` — AI coding tool adapter (claude-code, codex, aider)
|
||||
- `Workspace` — code isolation (worktree, clone)
|
||||
- `Tracker` — issue tracking (GitHub Issues, Linear)
|
||||
|
|
@ -236,6 +236,6 @@ This package is a dependency of all other packages. Build it first if working on
|
|||
|
||||
**Why plugin slots?**
|
||||
|
||||
- Swappability: use tmux locally, docker in CI, k8s in prod
|
||||
- Swappability: use tmux on Linux/macOS, `process` (ConPTY) on Windows, docker in CI, k8s in prod — same agent/workspace stack across all of them
|
||||
- Testability: mock plugins for tests
|
||||
- Extensibility: users can add custom plugins (e.g., company-specific notifier)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ Your role is to coordinate and manage worker agent sessions. You do NOT write co
|
|||
- Any code change, test run tied to implementation, git branch work, or PR takeover must be delegated to a **worker session**.
|
||||
- The orchestrator session must never own a PR. Never claim a PR into the orchestrator session, and never treat the orchestrator as the worker responsible for implementation.
|
||||
- If an investigation discovers follow-up work, either spawn a worker session or direct an existing worker session with clear instructions.
|
||||
- **Always use `ao send` to communicate with sessions** - never use raw `tmux send-keys` or `tmux capture-pane`. Direct tmux access bypasses busy detection, retry logic, and input sanitization, and breaks multi-line input for some agents (e.g. Codex).
|
||||
- **Always use `ao send` to communicate with sessions** - never bypass it by writing to the runtime layer directly (e.g. `tmux send-keys` / `tmux capture-pane` on Unix, or writing to the named pipe `\\.\pipe\ao-pty-<sessionId>` on Windows). Direct runtime access bypasses busy detection, retry logic, and input sanitization, and breaks multi-line input for some agents (e.g. Codex).
|
||||
- When a session might be busy, use `ao send --no-wait <session> <message>` to send without waiting for the session to become idle.
|
||||
|
||||
## Project Info
|
||||
|
|
@ -65,7 +65,7 @@ ao open {{projectId}}{{REPO_CONFIGURED_SECTION_END}}
|
|||
{{REPO_CONFIGURED_SECTION_START}}- `ao batch-spawn <issues...>`: Spawn multiple sessions in parallel (project auto-detected)
|
||||
{{REPO_CONFIGURED_SECTION_END}}- `ao session ls [-p project]`: List all sessions (optionally filter by project)
|
||||
{{REPO_CONFIGURED_SECTION_START}}- `ao session claim-pr <pr> [session]`: Attach an existing PR to a worker session
|
||||
{{REPO_CONFIGURED_SECTION_END}}- `ao session attach <session>`: Attach to a session's tmux window
|
||||
{{REPO_CONFIGURED_SECTION_END}}- `ao session attach <session>`: Attach to a session's terminal (a tmux window on Unix; a ConPTY pty-host on Windows)
|
||||
- `ao session kill <session>`: Kill a specific session
|
||||
- `ao session cleanup [-p project]`: Kill cleanup-eligible sessions (closed work or dead runtimes)
|
||||
- `ao send <session> <message>`: Send a message to a running session
|
||||
|
|
@ -81,7 +81,7 @@ When you spawn a session:
|
|||
|
||||
1. A git worktree is created from `{{projectDefaultBranch}}`
|
||||
2. A feature branch is created (e.g., `feat/INT-1234` for issues, `session/<id>` for prompt-driven)
|
||||
3. A tmux session is started (e.g., `{{projectSessionPrefix}}-1`)
|
||||
3. A runtime session is started (e.g., `{{projectSessionPrefix}}-1`) — tmux session on Unix, ConPTY pty-host on Windows
|
||||
4. The agent is launched with context about the issue or prompt
|
||||
5. Metadata is written to the project-specific sessions directory
|
||||
|
||||
|
|
|
|||
|
|
@ -149,8 +149,7 @@ tmux sessions keep running even if the orchestrator dies. Use `tmux list-session
|
|||
|
||||
## Limitations
|
||||
|
||||
- **macOS/Linux only** — tmux is not available on Windows (use WSL)
|
||||
- **No Windows native support** — use runtime-process instead on Windows
|
||||
- **macOS/Linux only** — tmux is not available natively on Windows. On Windows, use the `runtime-process` plugin (the default there); it provides native PTY support via ConPTY and `node-pty`. WSL is not required.
|
||||
- **Terminal buffer size** — `getOutput()` limited by tmux buffer size (default 2000 lines)
|
||||
- **No resource limits** — agents can consume unlimited CPU/memory (use docker/k8s runtimes for isolation)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
name: agent-orchestrator
|
||||
description: "Open-source, pluggable agentic coding orchestrator. Manages durable coding agents (Claude Code, Codex, OpenCode) through a simple interface — spawn agents, track progress, and let feedback loops like PR reviews and CI failures automatically route to the right agents. Use for fixing bugs, building features, working on GitHub issues, checking status, and managing agent sessions."
|
||||
metadata: {"openclaw": {"emoji": "🤖", "requires": {"bins": ["ao", "gh", "tmux"], "anyBins": ["node", "npm"], "env": ["ANTHROPIC_API_KEY"]}, "os": ["darwin", "linux"]}}
|
||||
metadata: {"openclaw": {"emoji": "🤖", "requires": {"bins": ["ao", "gh"], "anyBins": ["node", "npm"], "env": ["ANTHROPIC_API_KEY"]}, "os": ["darwin", "linux", "win32"]}}
|
||||
---
|
||||
|
||||
# Agent Orchestrator (AO)
|
||||
|
|
@ -172,6 +172,7 @@ What to know:
|
|||
| AO tools not visible to AI | Run `/ao setup` — needs `tools.profile: "full"` and `tools.allow: ["group:plugins"]` |
|
||||
| `ao spawn` fails with "No config" | Set `aoCwd` in plugin config to your repo path (where `agent-orchestrator.yaml` lives) |
|
||||
| `ao: not found` | Install AO globally or set `aoPath` in plugin config |
|
||||
| `spawn tmux ENOENT` | `brew install tmux` (macOS) or `apt install tmux` (Linux) |
|
||||
| `spawn tmux ENOENT` (macOS / Linux) | `brew install tmux` (macOS) or `apt install tmux` (Linux) |
|
||||
| `spawn tmux ENOENT` (Windows) | Your config has `runtime: tmux` set explicitly. Switch to `runtime: process` (or remove the override — `process` is the Windows default; ConPTY is used natively, no tmux required) |
|
||||
| Bot only responds in DMs | Set `channels.discord.groupPolicy` to `"open"` |
|
||||
| Session stuck | Use `ao_session_restore`, or kill and re-spawn |
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ readyThresholdMs: 300000 # Ms before "ready" session becomes "idle" (5 min)
|
|||
|
||||
```yaml
|
||||
defaults:
|
||||
runtime: tmux # tmux | process
|
||||
runtime: tmux # tmux (default on macOS/Linux) | process (default on Windows; ConPTY)
|
||||
agent: claude-code # claude-code | aider | codex | cursor | kimicode | opencode
|
||||
workspace: worktree # worktree | clone
|
||||
notifiers: # Active notifier plugins
|
||||
|
|
@ -37,7 +37,7 @@ projects:
|
|||
sessionPrefix: myapp # Session name prefix (myapp-1, myapp-2)
|
||||
|
||||
# Per-project plugin overrides (optional)
|
||||
runtime: tmux
|
||||
runtime: tmux # tmux (macOS/Linux) or process (Windows; ConPTY)
|
||||
agent: claude-code
|
||||
workspace: worktree
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue