diff --git a/CLAUDE.md b/CLAUDE.md index a43c555f7..275ed01f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,11 @@ # CLAUDE.md — Agent Orchestrator +## Quick Start + +- **Adding a plugin?** → [Plugin Development](#plugin-development) +- **Modifying core types?** → Read `packages/core/src/types.ts` first, then [Architecture](#architecture-deep-dive) +- **First contribution?** → Read [What This Is](#what-this-is), [Key Files](#key-files), [Monorepo Tools](#monorepo-tools) + ## What This Is Open-source system for orchestrating parallel AI coding agents. Agent-agnostic (Claude Code, Codex, Aider), runtime-agnostic (tmux, docker, k8s), tracker-agnostic (GitHub, Linear, Jira). Manages session lifecycle, tracks PR/CI/review state, auto-handles routine issues (CI failures, review comments), pushes notifications to humans only when needed. @@ -14,44 +20,273 @@ TypeScript (ESM), Node 20+, pnpm workspaces. Next.js 15 (App Router) + Tailwind. 8 plugin slots — every abstraction is swappable: -| Slot | Interface | Default Plugin | -| --------- | ----------- | -------------- | -| Runtime | `Runtime` | tmux | -| Agent | `Agent` | claude-code | -| Workspace | `Workspace` | worktree | -| Tracker | `Tracker` | github | -| SCM | `SCM` | github | -| Notifier | `Notifier` | desktop | -| Terminal | `Terminal` | iterm2 | -| Lifecycle | (core) | — | +| Slot | Interface | Default Plugin | Purpose | +| --------- | ----------- | -------------- | --------------------------------- | +| Runtime | `Runtime` | tmux | Where sessions execute | +| Agent | `Agent` | claude-code | AI coding tool adapter | +| Workspace | `Workspace` | worktree | Code isolation (worktree, clone) | +| Tracker | `Tracker` | github | Issue tracking (GitHub, Linear) | +| SCM | `SCM` | github | PR/CI/reviews | +| Notifier | `Notifier` | desktop | Push notifications | +| Terminal | `Terminal` | iterm2 | Human interaction UI | +| Lifecycle | (core) | — | State machine + reactions (core) | **All interfaces defined in `packages/core/src/types.ts` — read this file first.** -## Directory Structure +## Key Files -``` -packages/ - core/ — @agent-orchestrator/core (types, config, services) - cli/ — @agent-orchestrator/cli (the `ao` command) - web/ — @agent-orchestrator/web (Next.js dashboard) - plugins/ - runtime-{tmux,process}/ - agent-{claude-code,codex,aider,opencode}/ - workspace-{worktree,clone}/ - tracker-{github,linear}/ - scm-github/ - notifier-{desktop,slack,composio,webhook}/ - terminal-{iterm2,web}/ +1. **`packages/core/src/types.ts`** — source of truth for all interfaces +2. **`packages/core/src/services/session-manager.ts`** — session CRUD + spawn logic +3. **`packages/core/src/services/lifecycle-manager.ts`** — state machine + reactions +4. **`packages/core/src/services/plugin-registry.ts`** — plugin discovery + loading +5. **`agent-orchestrator.yaml.example`** — config format + +## Looking for X? + +| You want to... | Look here | +| ---------------------------------- | ------------------------------------------------------------ | +| Add a new plugin | `packages/plugins/`, follow `notifier-desktop` pattern | +| Add a field to Session | `packages/core/src/types.ts` → `Session` interface | +| Add an event type | `packages/core/src/types.ts` → `EventType` union | +| Modify spawn logic | `packages/core/src/services/session-manager.ts` → `spawn()` | +| Modify state machine | `packages/core/src/services/lifecycle-manager.ts` | +| Add a CLI command | `packages/cli/src/commands/` | +| Modify web dashboard | `packages/web/src/` | +| Add a reaction | `packages/core/src/services/lifecycle-manager.ts` → handlers | +| Test a plugin | `packages/plugins//src/__tests__/` | +| Modify config schema | `packages/core/src/config.ts` → Zod schemas | + +## Monorepo Tools + +```bash +# Install all dependencies +pnpm install + +# Build all packages +pnpm build + +# Build one package (builds dependencies automatically) +pnpm --filter @agent-orchestrator/core build + +# Run all tests +pnpm test + +# Run tests in one package +pnpm --filter @agent-orchestrator/core test + +# Run tests in watch mode +pnpm --filter @agent-orchestrator/core test -- --watch + +# Add a dependency to a package +pnpm --filter @agent-orchestrator/core add + +# Lint all +pnpm lint + +# Typecheck all +pnpm typecheck + +# Before committing (MUST pass) +pnpm lint && pnpm typecheck ``` -## Key Files (Read These First) +## Common Tasks -1. `packages/core/src/types.ts` — all interfaces (Runtime, Agent, Workspace, Tracker, SCM, Notifier, Terminal) -2. `agent-orchestrator.yaml.example` — config format -3. Plugin examples: - - `packages/plugins/runtime-tmux/src/index.ts` — Runtime implementation - - `packages/plugins/agent-claude-code/src/index.ts` — Agent implementation -4. This file (CLAUDE.md) — code conventions +### Adding a New Plugin + +1. **Create plugin directory**: `packages/plugins/-/` + ```bash + mkdir -p packages/plugins/runtime-docker/src + cd packages/plugins/runtime-docker + ``` + +2. **Create package.json**: + ```json + { + "name": "@agent-orchestrator/plugin-runtime-docker", + "version": "0.1.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@agent-orchestrator/core": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.7.3" + } + } + ``` + +3. **Create tsconfig.json** (copy from another plugin) + +4. **Implement plugin** in `src/index.ts`: + ```typescript + import type { PluginModule, Runtime } from "@agent-orchestrator/core"; + + export const manifest = { + name: "docker", + slot: "runtime" as const, + description: "Runtime plugin: Docker containers", + version: "0.1.0", + }; + + export function create(): Runtime { + return { + name: "docker", + async create(config) { /* ... */ }, + async destroy(handle) { /* ... */ }, + async sendMessage(handle, message) { /* ... */ }, + async getOutput(handle, lines) { /* ... */ }, + async isAlive(handle) { /* ... */ }, + }; + } + + export default { manifest, create } satisfies PluginModule; + ``` + +5. **Build and test**: + ```bash + pnpm --filter @agent-orchestrator/plugin-runtime-docker build + pnpm --filter @agent-orchestrator/plugin-runtime-docker test + ``` + +6. **Register in core** (if built-in): `packages/core/src/services/plugin-registry.ts` → `loadBuiltins()` + +### Adding a Field to Session + +1. **Update Session interface**: `packages/core/src/types.ts` + ```typescript + export interface Session { + // ... existing fields + newField: string | null; // Add your field + } + ``` + +2. **Update SessionManager**: `packages/core/src/services/session-manager.ts` + - Initialize the field in `spawn()` + - Update metadata read/write if needed + +3. **Update web dashboard** (if displayed): `packages/web/src/components/` + +4. **Rebuild core**: + ```bash + pnpm --filter @agent-orchestrator/core build + ``` + +### Adding an Event Type + +1. **Add to EventType union**: `packages/core/src/types.ts` + ```typescript + export type EventType = + | "session.spawned" + // ... existing events + | "your.new_event"; // Add here + ``` + +2. **Emit the event**: In the relevant service, use `eventEmitter.emit()` + +3. **Add reaction handler** (optional): `packages/core/src/services/lifecycle-manager.ts` + +## Plugin Development + +### The Plugin Pattern + +Every plugin exports: +- **`manifest`** — metadata (name, slot, description, version) +- **`create()`** — factory function that returns the interface implementation +- **`default export`** — `{ manifest, create } satisfies PluginModule` + +**Why `satisfies`?** Compile-time type checking. Using `const plugin = { ... }; export default plugin;` loses type safety. + +### Simplest Example: notifier-desktop + +See `packages/plugins/notifier-desktop/src/index.ts` — ~150 lines, implements `Notifier` interface, uses `osascript` (macOS) or `notify-send` (Linux). + +### Most Complete Example: agent-claude-code + +See `packages/plugins/agent-claude-code/src/index.ts` — implements `Agent` interface, includes: +- Process detection (ps, TTY lookup) +- JSONL parsing (session info extraction) +- Activity classification (terminal output patterns) +- Post-launch setup (hook injection) + +### Testing Plugins + +1. **Create test file**: `packages/plugins//src/__tests__/index.test.ts` +2. **Mock dependencies**: Use vitest `vi.mock()` for `child_process`, `fs`, etc. +3. **Test edge cases**: timeouts, corrupted data, missing files, concurrent access + +Example structure: +```typescript +import { describe, it, expect, vi } from "vitest"; +import { create } from "../index.js"; + +describe("my-plugin", () => { + it("should handle timeout", async () => { + const plugin = create(); + // ... test timeout scenario + }); +}); +``` + +## Architecture Deep Dive + +### Data Flow: Spawn → Execute + +``` +CLI: ao spawn my-app issue-42 + ↓ +SessionManager.spawn() + ↓ reads config + ↓ generates prompt via Tracker.generatePrompt() + ↓ +Workspace.create() → creates worktree/clone + ↓ +Agent.getLaunchCommand() → builds command +Agent.getEnvironment() → sets env vars + ↓ +Runtime.create() → starts session (tmux/docker/k8s) + ↓ sends launch command + ↓ +Agent starts executing in workspace + ↓ +LifecycleManager polls → detects state changes + ↓ +Reactions trigger (CI failures, review comments) + ↓ +Notifier.notify() → pushes to human +``` + +### State Machine: Session Lifecycle + +``` +spawning + ↓ agent starts +working + ↓ PR created +pr_open + ↓ CI fails → ci_failed (reaction: send fix prompt) + ↓ CI passes + review pending → review_pending + ↓ changes requested → changes_requested (reaction: send review comments) + ↓ approved + CI passing → approved (reaction: notify human) + ↓ ready to merge → mergeable (reaction: notify or auto-merge) + ↓ merged +merged + ↓ cleanup +(session killed) +``` + +### Key Abstractions + +- **Session** — a running agent instance (state, metadata, runtime handle) +- **RuntimeHandle** — opaque handle to communicate with a session (tmux session name, container ID, pod name) +- **PluginModule** — what every plugin exports (`manifest` + `create()`) +- **OrchestratorEvent** — events emitted by lifecycle manager (session.spawned, pr.created, ci.failing, etc.) +- **ReactionConfig** — rules for auto-responding to events (send-to-agent, notify, auto-merge) ## TypeScript Conventions (MUST follow) @@ -65,50 +300,107 @@ packages/ - **Prefer `const`** — `let` only when reassignment needed, never `var` - **Semicolons, double quotes, 2-space indent** — enforced by Prettier -## Plugin Pattern (MUST follow) - -Every plugin exports a `PluginModule` with inline `satisfies` for compile-time type checking: - -```typescript -import type { PluginModule, Runtime } from "@agent-orchestrator/core"; - -export const manifest = { - name: "tmux", - slot: "runtime" as const, - description: "Runtime plugin: tmux sessions", - version: "0.1.0", -}; - -export function create(): Runtime { - return { - name: "tmux", - async create(config) { /* ... */ }, - async destroy(handle) { /* ... */ }, - // ... implement interface methods - }; -} - -export default { manifest, create } satisfies PluginModule; -``` - -**Do NOT** use `const plugin = { ... }; export default plugin;` — always inline `satisfies`. - ## Shell Command Execution (MUST follow — security critical) -- **Always use `execFile`** (or `spawn`) — NEVER `exec` (shell injection risk) -- **Always add timeouts** — `{ timeout: 30_000 }` for external commands -- **Never interpolate user input** — pass as array args, not string template -- **Do NOT use `JSON.stringify` for shell escaping** — not a shell escaping function +**Always use `execFile` or `spawn`, NEVER `exec`** +### Why? exec is vulnerable to shell injection + +**Exploit example:** ```typescript -// GOOD +// VULNERABLE +import { exec } from "node:child_process"; +const branchName = "feat/add-feature; rm -rf /"; // malicious input +exec(`git checkout ${branchName}`); // executes: git checkout feat/add-feature; rm -rf / +``` + +**Safe:** +```typescript +// SAFE import { execFile } from "node:child_process"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); -const { stdout } = await execFileAsync("git", ["branch", "--show-current"], { timeout: 30_000 }); -// BAD — shell injection risk -exec(`git checkout ${branchName}`); // branchName could contain ; rm -rf / +const branchName = "feat/add-feature; rm -rf /"; // malicious input +await execFileAsync("git", ["checkout", branchName], { timeout: 30_000 }); +// git receives the string literally, no shell interpretation +``` + +**Rules:** +- **Always use `execFile`** (or `spawn`) — args as array, bypasses shell +- **Always add timeouts** — `{ timeout: 30_000 }` for external commands +- **Never interpolate user input** — pass as array args, not string template +- **Do NOT use `JSON.stringify` for shell escaping** — it doesn't escape `$`, backticks, `$()` + +## Common Mistakes with Examples + +### 1. Missing `.js` extension in imports + +```typescript +// BAD — runtime error with ESM +import { foo } from "./bar"; + +// GOOD +import { foo } from "./bar.js"; +``` + +**Why:** ESM requires explicit file extensions. Node won't auto-resolve. + +### 2. Unsafe type casting + +```typescript +// BAD — crashes on unexpected data +const data = JSON.parse(input) as MyType; +data.requiredField.toUpperCase(); // TypeError if field is missing + +// GOOD — validate before using +const parsed: unknown = JSON.parse(input); +if ( + typeof parsed === "object" && + parsed !== null && + "requiredField" in parsed && + typeof parsed.requiredField === "string" +) { + const data = parsed as MyType; + data.requiredField.toUpperCase(); // safe +} +``` + +### 3. `export default plugin` without `satisfies` + +```typescript +// BAD — loses type checking +const plugin = { manifest, create }; +export default plugin; // no compile-time verification + +// GOOD — compile-time type checking +export default { manifest, create } satisfies PluginModule; +``` + +### 4. Using `on("exit")` instead of `once("exit")` + +```typescript +// BAD — handler called multiple times if event emits multiple times +process.on("exit", cleanup); + +// GOOD — handler called once +process.once("exit", cleanup); +``` + +### 5. Forgetting cleanup on disconnect + +```typescript +// BAD — interval keeps running after session dies +const interval = setInterval(poll, 1000); + +// GOOD — cleanup on destroy +const interval = setInterval(poll, 1000); +return { + // ... interface methods + async destroy() { + clearInterval(interval); + }, +}; ``` ## Error Handling @@ -125,45 +417,28 @@ exec(`git checkout ${branchName}`); // branchName could contain ; rm -rf / - Types/Interfaces: `PascalCase` - Functions/variables: `camelCase` - Constants: `UPPER_SNAKE_CASE` (only true constants: env vars, regex patterns) -- Test files: `*.test.ts` (co-located or in `__tests__/`) - -## Commands - -```bash -pnpm install # install deps -pnpm build # build all packages -pnpm typecheck # typecheck -pnpm lint # ESLint check -pnpm lint:fix # ESLint auto-fix -pnpm format # Prettier format -pnpm format:check # Prettier check (CI) -pnpm test # run tests - -# Before committing -pnpm lint && pnpm typecheck -``` - -## Common Mistakes to Avoid - -- Using `exec` instead of `execFile` — security vulnerability -- Using `JSON.stringify` for shell escaping — does not escape `$`, backticks, `$()` -- Missing `.js` extension in local imports — runtime error with ESM -- Using bare `"fs"` instead of `"node:fs"` — inconsistent -- Casting with `as unknown as T` — bypasses type safety, crashes on bad data -- `export default plugin` without `satisfies PluginModule` — loses type checking -- Interpolating user input into shell commands, AppleScript, or GraphQL queries -- Forgetting to clean up setInterval/setTimeout on disconnect/destroy -- Using `on("exit")` instead of `once("exit")` for one-time handlers +- Test files: `*.test.ts` (co-located in `__tests__/`) ## Config Config loaded from `agent-orchestrator.yaml` (see `agent-orchestrator.yaml.example`). Paths support `~` expansion. Validated with Zod at load time. Per-project overrides for plugins and reactions. -## Design Decisions +## Design Decisions (The "Why") 1. **Stateless orchestrator** — no database, flat metadata files + event log + - **Why:** Debuggability (cat metadata file), no database dependency, survives crashes + 2. **Plugins implement interfaces** — pure implementation of interface from `types.ts` + - **Why:** Swappability (tmux → docker), testability (mock plugins), extensibility + 3. **Push notifications** — Notifier is primary human interface, not dashboard -4. **Two-tier event handling** — auto-handle routine issues, notify human when judgment needed -5. **Backwards-compatible metadata** — flat key=value files + - **Why:** Human doesn't poll. Spawn agents, walk away, get notified when needed. + +4. **Two-tier event handling** — auto-handle routine issues (CI, reviews), notify human when judgment needed + - **Why:** Reduce noise, scale to many agents, only interrupt human for decisions + +5. **Flat key=value metadata files** — `branch=feat/foo` not JSON + - **Why:** Backwards-compatible with bash scripts, easy to parse/debug + 6. **Security first** — `execFile` not `exec`, validate all external input + - **Why:** Orchestrator runs user-provided code. Shell injection is real threat. diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 000000000..de0841236 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,174 @@ +# @agent-orchestrator/core + +Core services, types, and configuration for the Agent Orchestrator system. + +## What's Here + +- **`src/types.ts`** — All TypeScript interfaces (Runtime, Agent, Workspace, Tracker, SCM, Notifier, Terminal, Session, events) +- **`src/services/`** — Core services (SessionManager, LifecycleManager, PluginRegistry) +- **`src/config.ts`** — Configuration loading + Zod schemas +- **`src/utils/`** — Shared utilities (shell escaping, metadata parsing, etc.) + +## Key Files + +### `src/types.ts` — The Source of Truth + +Every interface the system uses is defined here. If you're working on any part of the orchestrator, start by reading this file. + +**Main interfaces:** +- `Runtime` — where sessions execute (tmux, docker, k8s) +- `Agent` — AI coding tool adapter (claude-code, codex, aider) +- `Workspace` — code isolation (worktree, clone) +- `Tracker` — issue tracking (GitHub Issues, Linear) +- `SCM` — PR/CI/reviews (GitHub, GitLab) +- `Notifier` — push notifications (desktop, Slack, webhook) +- `Terminal` — human interaction UI (iTerm2, web) +- `Session` — running agent instance (state, metadata, handles) +- `OrchestratorEvent` — events emitted by lifecycle manager +- `PluginModule` — what every plugin exports + +### `src/services/session-manager.ts` — Session CRUD + +Handles session lifecycle: +- `spawn(config)` — create new session (workspace + runtime + agent) +- `list(projectId?)` — list all sessions +- `get(sessionId)` — get session details +- `kill(sessionId)` — terminate session +- `cleanup(projectId?)` — kill completed/merged sessions +- `send(sessionId, message)` — send message to agent + +**Data flow in `spawn()`:** +1. Load project config +2. Generate prompt via `Tracker.generatePrompt()` +3. Create workspace via `Workspace.create()` +4. Build launch command via `Agent.getLaunchCommand()` +5. Create runtime session via `Runtime.create()` +6. Send launch command +7. Run `Agent.postLaunchSetup()` (optional) +8. Write metadata file +9. Return Session object + +### `src/services/lifecycle-manager.ts` — State Machine + Reactions + +Polls sessions, detects state changes, triggers reactions: + +**State machine:** +``` +spawning → working → pr_open → ci_failed/review_pending/approved → mergeable → merged +``` + +**Reactions:** +- `ci-failed` → send fix prompt to agent +- `changes-requested` → send review comments to agent +- `approved-and-green` → notify human (or auto-merge) +- `agent-stuck` → notify human + +**Polling loop:** +1. For each session: check if agent is processing (`Agent.isProcessing()`) +2. If PR exists: check CI status (`SCM.getCISummary()`), review state (`SCM.getReviewDecision()`) +3. Update session status based on state +4. Trigger reactions if state changed +5. Emit events + +### `src/services/plugin-registry.ts` — Plugin Discovery + Loading + +Loads plugins and provides access to them: +- `register(plugin, config?)` — register a plugin instance +- `get(slot, name)` — get plugin by slot + name +- `list(slot)` — list all plugins for a slot +- `loadBuiltins(config?)` — load built-in plugins (runtime-tmux, agent-claude-code, etc.) +- `loadFromConfig(config)` — load plugins from config (npm packages, local paths) + +**Built-in plugins** (loaded by default): +- runtime-tmux, runtime-process +- agent-claude-code, agent-codex, agent-aider, agent-opencode +- workspace-worktree, workspace-clone +- tracker-github, tracker-linear +- scm-github +- notifier-desktop, notifier-slack, notifier-composio, notifier-webhook +- terminal-iterm2, terminal-web + +### `src/config.ts` — Configuration Loading + +Loads and validates `agent-orchestrator.yaml`: + +**Main config sections:** +- `dataDir` — where session metadata lives (~/.agent-orchestrator) +- `worktreeDir` — where workspaces are created (~/.worktrees) +- `port` — web dashboard port (default 3000) +- `defaults` — default plugins (runtime, agent, workspace, notifiers) +- `projects` — per-project config (repo, path, branch, symlinks, reactions, agentRules) +- `notifiers` — notification channel config (Slack webhooks, etc.) +- `notificationRouting` — which notifiers get which priority events +- `reactions` — auto-response config (ci-failed, changes-requested, approved-and-green, etc.) + +**Zod schemas** validate all config at load time. + +## Common Tasks + +### Adding a Field to Session + +1. Edit `src/types.ts` → `Session` interface +2. Edit `src/services/session-manager.ts` → initialize field in `spawn()` +3. Rebuild: `pnpm --filter @agent-orchestrator/core build` + +### Adding an Event Type + +1. Edit `src/types.ts` → `EventType` union +2. Emit the event: `eventEmitter.emit()` in relevant service +3. Add reaction handler (optional): `src/services/lifecycle-manager.ts` + +### Adding a Reaction + +1. Edit `src/services/lifecycle-manager.ts` → add handler function +2. Wire it up in the polling loop +3. Add config schema in `src/config.ts` if new reaction type + +## Testing + +```bash +# Run all core tests +pnpm --filter @agent-orchestrator/core test + +# Run in watch mode +pnpm --filter @agent-orchestrator/core test -- --watch + +# Run specific test +pnpm --filter @agent-orchestrator/core test -- session-manager.test.ts +``` + +Tests are in `src/__tests__/`: +- `session-manager.test.ts` — session CRUD, spawn, cleanup +- `lifecycle-manager.test.ts` — state machine, reactions +- `plugin-registry.test.ts` — plugin loading, resolution +- `tmux.test.ts` — tmux utility functions (not a plugin test) +- `prompt-builder.test.ts` — prompt generation utilities + +## Building + +```bash +# Build core +pnpm --filter @agent-orchestrator/core build + +# Typecheck +pnpm --filter @agent-orchestrator/core typecheck +``` + +This package is a dependency of all other packages. Build it first if working on the codebase. + +## Architecture Notes + +**Why flat metadata files?** +- Debuggability: `cat ~/.agent-orchestrator/my-app-3` shows full state +- No database dependency (survives crashes, easy to inspect) +- Backwards-compatible with bash script orchestrator + +**Why polling instead of webhooks?** +- Simpler (no webhook setup, no ngrok for local dev) +- Works offline (CI/review state is fetched, not pushed) +- Survives orchestrator restarts (no missed events) + +**Why plugin slots?** +- Swappability: use tmux locally, docker in CI, k8s in prod +- Testability: mock plugins for tests +- Extensibility: users can add custom plugins (e.g., company-specific notifier) diff --git a/packages/plugins/runtime-tmux/README.md b/packages/plugins/runtime-tmux/README.md new file mode 100644 index 000000000..cf5fec5a2 --- /dev/null +++ b/packages/plugins/runtime-tmux/README.md @@ -0,0 +1,158 @@ +# @agent-orchestrator/plugin-runtime-tmux + +Runtime plugin for executing agent sessions in tmux. + +## What This Does + +Creates isolated tmux sessions for each agent. Each session runs in a separate tmux session with: +- Working directory set to workspace path +- Environment variables from config +- Agent launch command executed automatically + +## How It Works + +### Creating a Session + +```typescript +const handle = await runtime.create({ + sessionId: "my-app-3", + workspacePath: "/Users/dev/.worktrees/my-app/my-app-3", + launchCommand: "claude -p 'Fix bug in auth module'", + environment: { + AO_SESSION_ID: "my-app-3", + AO_PROJECT_ID: "my-app", + }, +}); +``` + +**What happens:** +1. Validates `sessionId` (only alphanumeric, dash, underscore allowed) +2. Creates detached tmux session: `tmux new-session -d -s my-app-3 -c /path/to/workspace` +3. Sets environment variables: `tmux ... -e KEY=VALUE` +4. Sends launch command: `tmux send-keys -t my-app-3 "claude -p '...'" Enter` +5. Returns RuntimeHandle with tmux session name + +### Sending Messages + +```typescript +await runtime.sendMessage(handle, "Fix the test failure in auth.test.ts"); +``` + +**What happens:** +1. Clears partial input: `tmux send-keys -t my-app-3 C-u` +2. For short messages (<200 chars, no newlines): sends directly with `-l` flag (literal mode) +3. For long/multiline messages: writes to temp file → `tmux load-buffer` → `tmux paste-buffer` +4. Waits 300ms (let tmux process the text) +5. Sends Enter: `tmux send-keys -t my-app-3 Enter` + +**Why the complexity?** +- `send-keys` without `-l` interprets special strings ("Enter", "Space") as key names +- Long strings can overflow tmux's command buffer +- Multiline strings need special handling + +### Getting Output + +```typescript +const output = await runtime.getOutput(handle, 50); // last 50 lines +``` + +Uses `tmux capture-pane -t my-app-3 -p -S -50` to capture terminal buffer. + +### Checking if Alive + +```typescript +const alive = await runtime.isAlive(handle); +``` + +Uses `tmux has-session -t my-app-3` (exit code 0 = exists, 1 = doesn't exist). + +### Destroying + +```typescript +await runtime.destroy(handle); +``` + +Kills tmux session: `tmux kill-session -t my-app-3` (ignores errors if already dead). + +## Attaching to Sessions + +For Terminal plugins (iTerm2, web): + +```typescript +const attachInfo = await runtime.getAttachInfo(handle); +// Returns: { type: "tmux", target: "my-app-3", command: "tmux attach -t my-app-3" } +``` + +## Security + +**Session ID validation:** +```typescript +const SAFE_SESSION_ID = /^[a-zA-Z0-9_-]+$/; +``` + +Only allows safe characters. Prevents shell injection via session name (used in tmux commands). + +## Error Handling + +- **Session creation fails** → cleans up (kills session) before throwing +- **Message send fails** → throws (caller should handle) +- **Session already dead** → `destroy()` silently succeeds (idempotent) + +## Metrics + +```typescript +const metrics = await runtime.getMetrics(handle); +// Returns: { uptimeMs: 123456 } +``` + +Tracks uptime (stored in RuntimeHandle.data.createdAt). + +## Testing + +This plugin is tested indirectly via `packages/core/src/__tests__/tmux.test.ts` (utility functions) and integration tests. + +To test manually: +```bash +# Start a test session +tmux new-session -d -s test-session -c /tmp +tmux send-keys -t test-session "echo hello" Enter + +# Capture output +tmux capture-pane -t test-session -p + +# Kill session +tmux kill-session -t test-session +``` + +## Common Issues + +### tmux not installed +If tmux is not in PATH, all operations fail. Install via: +- macOS: `brew install tmux` +- Linux: `apt-get install tmux` or `yum install tmux` + +### Session name conflicts +If a session with the same ID already exists, `create()` fails. The orchestrator should ensure unique session IDs. + +### Detached sessions persist after orchestrator crashes +tmux sessions keep running even if the orchestrator dies. Use `tmux list-sessions` to find orphans, `tmux kill-session -t ` to clean up. + +## Limitations + +- **macOS/Linux only** — tmux is not available on Windows (use WSL) +- **No Windows native support** — use runtime-process instead on Windows +- **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) + +## Architecture Notes + +**Why tmux over raw processes?** +- Sessions persist across orchestrator restarts +- Easy to attach for debugging: `tmux attach -t session-name` +- Terminal emulation (colors, ANSI codes work) +- Works well with interactive AI tools (Claude Code, Aider) + +**Why detached mode?** +- Orchestrator doesn't block waiting for agent +- Multiple agents can run in parallel +- Humans can attach later without interrupting agent