diff --git a/.changeset/config.json b/.changeset/config.json index bf16dccb5..aafa3cbb7 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -30,8 +30,5 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [ - "@composio/ao-web", - "@composio/ao-integration-tests" - ] + "ignore": ["@composio/ao-web", "@composio/ao-integration-tests"] } diff --git a/CLAUDE.md b/CLAUDE.md index 4fef73d9c..9319ee225 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,8 +82,12 @@ export const manifest = { export function create(): Runtime { return { name: "tmux", - async create(config) { /* ... */ }, - async destroy(handle) { /* ... */ }, + async create(config) { + /* ... */ + }, + async destroy(handle) { + /* ... */ + }, // ... implement interface methods }; } diff --git a/DASHBOARD_FIXES_SUMMARY.md b/DASHBOARD_FIXES_SUMMARY.md index c02bb5c5b..7de65c8fc 100644 --- a/DASHBOARD_FIXES_SUMMARY.md +++ b/DASHBOARD_FIXES_SUMMARY.md @@ -1,17 +1,22 @@ # Dashboard Rate Limit & Enrichment Fixes - Summary ## Problem + The web dashboard was making ~84 GitHub API calls per refresh (6 calls × 14 sessions with PRs), quickly exhausting the GitHub GraphQL rate limit (5000 points/hour). When rate-limited, PR enrichment failed silently, showing misleading default data ("+0 -0", "CI failing", "needs review" for everything). ## Fixes Implemented ### 1. Added Missing SessionStatus Values ✅ + **File**: `packages/core/src/types.ts` + - Added `"done"` and `"terminated"` to the `SessionStatus` type union - These values were used in metadata files but missing from the type definition ### 2. Fixed getAttentionLevel for Terminal Sessions ✅ + **File**: `packages/web/src/lib/types.ts` + - Updated `getAttentionLevel()` to return `"done"` for sessions with: - `status === "done"` - `status === "terminated"` @@ -20,46 +25,59 @@ The web dashboard was making ~84 GitHub API calls per refresh (6 calls × 14 ses - Prevents terminal sessions from being classified as "working" or "pending" ### 3. Skip Enrichment for Terminal Sessions ✅ + **File**: `packages/web/src/app/page.tsx` + - Added logic to skip PR enrichment for sessions with terminal statuses: - `"merged"`, `"killed"`, `"cleanup"`, `"done"`, `"terminated"` - Also skips enrichment if PR state is already `"merged"` or `"closed"` - **Impact**: Reduces API calls by ~50% for typical session mix ### 4. Added Simple In-Memory Cache ✅ + **File**: `packages/web/src/lib/cache.ts` (NEW) + - Implemented `TTLCache` class with 60-second TTL - Caches PR enrichment data by key: `owner/repo#123` - Automatically evicts stale entries on `get()` - **Impact**: Reduces API calls by ~90% for repeated page refreshes ### 5. Updated serialize.ts to Use Cache ✅ + **File**: `packages/web/src/lib/serialize.ts` + - `enrichSessionPR()` now checks cache first before hitting SCM API - Caches successful enrichment results for 60 seconds - Does NOT cache failed enrichment attempts ### 6. Graceful Rate Limit Error Handling ✅ + **File**: `packages/web/src/lib/serialize.ts` + - Detects when all API calls fail (likely rate limit) - Sets explicit blocker: `"API rate limited or unavailable"` - Logs error to console for debugging - Does NOT cache failed attempts (prevents stale error states) ### 7. Improved Default Values ✅ + **File**: `packages/web/src/lib/serialize.ts` + - `basicPRToDashboard()` now uses explicit blocker: `"Data not loaded"` - Default `ciStatus: "none"` is neutral (not "failing") - Default `reviewDecision: "none"` is neutral (not "changes_requested") - Prevents misleading UI before enrichment completes ### 8. Comprehensive Test Coverage ✅ + **Files**: + - `packages/web/src/lib/__tests__/cache.test.ts` (NEW) - 9 tests - `packages/web/src/lib/__tests__/types.test.ts` (NEW) - 29 tests - `packages/web/src/lib/__tests__/serialize.test.ts` (NEW) - 16 tests **Test Coverage**: + - TTL cache behavior (get, set, expiry, clear) - `getAttentionLevel()` for all status combinations - `basicPRToDashboard()` default values @@ -71,7 +89,9 @@ The web dashboard was making ~84 GitHub API calls per refresh (6 calls × 14 ses - Terminal session classification ### 9. Added Orchestrator Terminal Button ✅ + **File**: `packages/web/src/components/Dashboard.tsx` + - Added "orchestrator terminal" link in header next to ClientTimestamp - Styled with hover effects matching the rest of the UI - Links to `/sessions/orchestrator` @@ -79,18 +99,21 @@ The web dashboard was making ~84 GitHub API calls per refresh (6 calls × 14 ses ## Verification ### Build Status ✅ + ```bash pnpm build # ✓ All packages build successfully ``` ### ESLint Status ✅ + ```bash pnpm eslint # ✓ No linting errors ``` ### Test Status ✅ + ```bash pnpm --filter @agent-orchestrator/web test # ✓ 140 passed (143 total, 3 pre-existing failures in components.test.tsx) @@ -103,12 +126,14 @@ pnpm --filter @agent-orchestrator/web test ## Performance Impact ### Before + - **API calls per refresh**: ~84 calls (6 × 14 sessions) - **Rate limit exhaustion**: Every ~60 refreshes (5000 points ÷ 84 ≈ 60) - **Recovery time**: 1 hour (rate limit reset) - **User experience**: Garbage data when rate-limited ### After + - **API calls per refresh**: ~7-10 calls (only non-terminal, non-cached sessions) - **Rate limit exhaustion**: Every ~500+ refreshes (10× improvement) - **Recovery time**: Same (1 hour), but happens 10× less often @@ -135,6 +160,7 @@ pnpm --filter @agent-orchestrator/web test ## Files Changed ### Modified + - `packages/core/src/types.ts` - Added "done" and "terminated" to SessionStatus - `packages/web/src/lib/types.ts` - Fixed getAttentionLevel for terminal sessions - `packages/web/src/app/page.tsx` - Skip enrichment for terminal sessions @@ -142,6 +168,7 @@ pnpm --filter @agent-orchestrator/web test - `packages/web/src/components/Dashboard.tsx` - Added orchestrator terminal button ### Created + - `packages/web/src/lib/cache.ts` - TTL cache implementation - `packages/web/src/lib/__tests__/cache.test.ts` - Cache tests - `packages/web/src/lib/__tests__/types.test.ts` - Attention level tests diff --git a/README.md b/README.md index b3b2a91f8..2b249fa64 100644 --- a/README.md +++ b/README.md @@ -1,225 +1,239 @@ # Agent Orchestrator -Open-source system for orchestrating parallel AI coding agents. Agent-agnostic, runtime-agnostic, tracker-agnostic. - -**Core principle: Push, not pull.** Spawn agents, walk away, get notified when your judgment is needed. - -## Features - -- **8 plugin slots** — Runtime (tmux, docker, k8s), Agent (Claude Code, Codex, Aider), Workspace (worktree, clone), Tracker (GitHub, Linear), SCM (GitHub), Notifier (desktop, Slack), Terminal (iTerm2, web), Lifecycle (core) -- **Agent-agnostic** — Works with Claude Code, Codex, Aider, Goose, or custom agents -- **Runtime-agnostic** — Run in tmux (local), Docker, Kubernetes, SSH, or E2B -- **Tracker-agnostic** — GitHub Issues, Linear, Jira (extensible) -- **Auto-reactions** — CI failures, review comments, merge conflicts → auto-handled -- **Push notifications** — Desktop, Slack, Discord, Webhook, Email -- **Web dashboard** — Real-time session monitoring with SSE -- **TypeScript** — Strict types, ESM modules, Zod validation +Orchestrate parallel AI coding agents across any runtime, any repo, any issue tracker. ## Quick Start ```bash -# Install dependencies -pnpm install +git clone https://github.com/ComposioHQ/agent-orchestrator.git +cd agent-orchestrator && bash scripts/setup.sh +cd ~/your-project && ao init --auto && ao start +``` -# Build all packages -pnpm build +**That's it!** Dashboard opens at http://localhost:3000 -# Copy and configure -cp agent-orchestrator.yaml.example agent-orchestrator.yaml -# Edit agent-orchestrator.yaml with your settings (see Configuration section) +## What Is This? -# Start the web dashboard -cd packages/web -pnpm dev +Agent Orchestrator spawns and manages multiple AI coding agents working in parallel on your repository. Each agent works in isolation (separate worktrees), handles its own PR lifecycle, and auto-responds to CI failures and review comments. -# Or use the CLI -pnpm --filter @composio/ao-cli build -./packages/cli/bin/ao.js start +**Key benefits:** +- 🚀 **10-30x productivity** - Work on 10+ issues simultaneously +- 🤖 **Human-in-the-loop** - Agents notify you when judgment needed, not for routine work +- 🔌 **Fully pluggable** - Swap any component (runtime, agent, tracker, SCM) +- 📊 **Real-time dashboard** - Monitor all agents from one place + +**Built itself:** This project was built using itself (399 commits, 34 PRs, 63 hours of dog-fooding). + +## Features + +- **Agent-agnostic**: Claude Code, Codex, Aider, or bring your own +- **Runtime-agnostic**: tmux, Docker, Kubernetes, or custom +- **Tracker-agnostic**: GitHub Issues, Linear, Jira, or custom +- **Auto-reactions**: CI failures, review comments, merge conflicts → handled automatically +- **Notifications**: Desktop, Slack, Composio, or webhook - only when you're needed +- **Live terminal**: See agents working in real-time through browser + +## Architecture + +8 plugin slots - every abstraction is swappable: + +| Slot | Interface | Default | Alternatives | +|-----------|-------------|-----------|-----------------------| +| Runtime | `Runtime` | tmux | docker, k8s, process | +| Agent | `Agent` | claude-code | codex, aider, opencode | +| Workspace | `Workspace` | worktree | clone | +| Tracker | `Tracker` | github | linear, jira | +| SCM | `SCM` | github | (gitlab, bitbucket) | +| Notifier | `Notifier` | desktop | slack, composio, webhook | +| Terminal | `Terminal` | iterm2 | web | +| Lifecycle | core | — | — | + +## Installation + +### Prerequisites + +- Node 20+ +- Git 2.25+ +- tmux (for tmux runtime) +- gh CLI (for GitHub integration) + +### Setup + +```bash +# Clone and run setup +git clone https://github.com/ComposioHQ/agent-orchestrator.git +cd agent-orchestrator +bash scripts/setup.sh +``` + +The setup script: +- Installs dependencies with pnpm +- Builds all packages +- Rebuilds node-pty from source (fixes terminal issues) +- Links `ao` CLI globally + +### Initialize Your Project + +```bash +cd ~/your-project +ao init --auto # Auto-detects project type, generates config +ao start # Launches orchestrator + dashboard +``` + +**Auto-detection:** +- Git repo and remote +- Project type (languages, frameworks, test runners) +- Generates custom agent rules based on your stack + +## Usage + +### Spawn Agents + +```bash +# Spawn agent for a GitHub issue +ao spawn my-project 123 + +# Spawn for a Linear issue +ao spawn my-project INT-1234 + +# Spawn without issue (ad-hoc work) +ao spawn my-project +``` + +### Monitor Progress + +```bash +# Command-line dashboard +ao status + +# Web dashboard +open http://localhost:3000 +``` + +### Manage Sessions + +```bash +# List all sessions +ao session ls + +# Send message to agent +ao send "Fix the linting errors" + +# Kill session +ao session kill +``` + +### Auto-Reactions + +Configure reactions for common scenarios: + +```yaml +reactions: + ci-failed: + auto: true + action: send-to-agent + retries: 3 + + changes-requested: + auto: true + action: send-to-agent + escalateAfter: 1h + + approved-and-green: + auto: true + action: auto-merge ``` ## Configuration -Agent Orchestrator reads `agent-orchestrator.yaml` from your working directory. - -### Minimal Example +Basic config (`agent-orchestrator.yaml`): ```yaml -# Paths dataDir: ~/.agent-orchestrator worktreeDir: ~/.worktrees port: 3000 -# Projects +defaults: + runtime: tmux + agent: claude-code + workspace: worktree + notifiers: [desktop] + projects: my-app: - repo: org/my-app + repo: owner/my-app path: ~/my-app defaultBranch: main + agentRules: | + Always run tests before pushing. + Use conventional commits. + Write clear commit messages. ``` -### Using Secrets Securely +See `agent-orchestrator.yaml.example` for full reference. -**⚠️ NEVER commit real secrets to git!** +## Examples -Use environment variables for all tokens and API keys: +See `examples/` directory for: +- `simple-github.yaml` - Minimal GitHub Issues setup +- `linear-team.yaml` - Linear integration +- `multi-project.yaml` - Multiple repos +- `auto-merge.yaml` - Aggressive automation -```yaml -notifiers: - slack: - plugin: slack - webhookUrl: ${SLACK_WEBHOOK_URL} # Reference env var - -projects: - my-app: - tracker: - plugin: linear - apiKey: ${LINEAR_API_KEY} # Reference env var -``` - -Then set in your shell: -```bash -export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..." -export LINEAR_API_KEY="lin_api_..." -export GITHUB_TOKEN="ghp_..." -``` - -See [SECURITY.md](./SECURITY.md) for best practices. - -### Full Example - -See [`agent-orchestrator.yaml.example`](./agent-orchestrator.yaml.example) for all options. - -## Commands +## Development ```bash -# Development -pnpm install # Install dependencies -pnpm build # Build all packages -pnpm dev # Start web dashboard (dev mode) -pnpm test # Run tests - -# Code quality -pnpm lint # Check linting -pnpm lint:fix # Auto-fix linting -pnpm format # Format with Prettier -pnpm typecheck # TypeScript type checking - -# Package management -pnpm clean # Clean build artifacts +pnpm install +pnpm build +pnpm dev # Start web dev server ``` -## Architecture - -### Plugin Slots - -Every abstraction is swappable: - -| Slot | Interface | Default Plugins | -| --------- | ----------- | --------------- | -| Runtime | `Runtime` | tmux, process, docker, kubernetes, ssh, e2b | -| Agent | `Agent` | claude-code, codex, aider, goose, opencode | -| Workspace | `Workspace` | worktree, clone | -| Tracker | `Tracker` | github, linear | -| SCM | `SCM` | github | -| Notifier | `Notifier` | desktop, slack, composio, webhook | -| Terminal | `Terminal` | iterm2, web | -| Lifecycle | (core) | — | - -All interfaces defined in [`packages/core/src/types.ts`](./packages/core/src/types.ts). - -### Directory Structure +### Project Structure ``` packages/ - core/ — @composio/ao-core (types, config, services) - cli/ — @composio/ao-cli (the `ao` command) - web/ — @composio/ao-web (Next.js dashboard) + core/ - Core types and services + cli/ - ao command-line tool + web/ - Next.js dashboard plugins/ - runtime-{tmux,process,docker,kubernetes,ssh,e2b}/ - agent-{claude-code,codex,aider,goose,opencode}/ - workspace-{worktree,clone}/ - tracker-{github,linear}/ - scm-github/ - notifier-{desktop,slack,composio,webhook}/ - terminal-{iterm2,web}/ - integration-tests/ + runtime-*/ - Runtime plugins + agent-*/ - Agent plugins + workspace-*/ - Workspace plugins + tracker-*/ - Tracker plugins + scm-*/ - SCM plugins + notifier-*/ - Notifier plugins + terminal-*/ - Terminal plugins ``` -## Security +## Troubleshooting -🔒 **This repository uses automated secret scanning** to prevent accidental commits of API keys, tokens, and other secrets. +See [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for common issues and solutions. -### For Developers +**Most common:** +- Terminal not working → node-pty rebuild (automatic via postinstall hook) +- Port in use → Kill existing server or change port in config +- Config not found → Run `ao init` from your project directory -- **Pre-commit hook** — Scans staged files before every commit -- **CI pipeline** — Scans full git history on every push/PR -- **Gitleaks** — Industry-standard secret detection +## Philosophy -Before committing: -```bash -# Scan current files -gitleaks detect --no-git +**Push, not pull:** Spawn agents, walk away, get notified only when your judgment is needed. -# Scan staged files (automatic in pre-commit hook) -gitleaks protect --staged -``` - -### For Users - -- **Use environment variables** for all secrets -- **Never hardcode** tokens in config files -- Store `agent-orchestrator.yaml` securely (it's in `.gitignore`) -- Rotate tokens regularly - -See [SECURITY.md](./SECURITY.md) for detailed security practices and how to report vulnerabilities. - -## Required Secrets - -Depending on which features you use, you may need: - -| Service | Environment Variable | Where to Get | -|---------|---------------------|--------------| -| GitHub | `GITHUB_TOKEN` | https://github.com/settings/tokens | -| Linear | `LINEAR_API_KEY` | https://linear.app/settings/api | -| Slack | `SLACK_WEBHOOK_URL` | https://api.slack.com/messaging/webhooks | -| Anthropic | `ANTHROPIC_API_KEY` | https://console.anthropic.com/ | +- Stateless orchestrator (filesystem > database) +- Plugin everything (no vendor lock-in) +- Amplify judgment, don't bypass it +- Auto-handle routine, escalate complex decisions ## Contributing -1. Fork the repository -2. Create a feature branch -3. Make your changes -4. Run tests: `pnpm test` -5. Run linting: `pnpm lint && pnpm typecheck` -6. Commit (pre-commit hook will scan for secrets) -7. Open a pull request - -See [CLAUDE.md](./CLAUDE.md) for code conventions and architecture details. +Contributions welcome! See `CLAUDE.md` for code conventions and architecture details. ## License -MIT — see [LICENSE](./LICENSE) file. +MIT -## Responsible Disclosure +## Links -If you discover a security vulnerability, please report it to security@composio.dev. See [SECURITY.md](./SECURITY.md) for details. - -## Tech Stack - -- **TypeScript** (ESM modules, strict mode) -- **Node 20+** -- **pnpm** workspaces -- **Next.js 15** (App Router) + Tailwind -- **Commander.js** CLI -- **YAML + Zod** config -- **Server-Sent Events** for real-time -- **ESLint + Prettier** -- **vitest** for testing - -## Resources - -- **Documentation**: See individual package READMEs -- **Core types**: [`packages/core/src/types.ts`](./packages/core/src/types.ts) -- **Example config**: [`agent-orchestrator.yaml.example`](./agent-orchestrator.yaml.example) -- **Security policy**: [SECURITY.md](./SECURITY.md) -- **Code conventions**: [CLAUDE.md](./CLAUDE.md) +- [Setup Guide](SETUP.md) - Detailed setup and configuration +- [Examples](examples/) - Config templates for common use cases +- [CLAUDE.md](CLAUDE.md) - Code conventions and architecture +- [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - Common issues and fixes diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 000000000..34c22d14c --- /dev/null +++ b/SETUP.md @@ -0,0 +1,794 @@ +# Agent Orchestrator Setup Guide + +Comprehensive guide to installing, configuring, and troubleshooting Agent Orchestrator. + +## Prerequisites + +### Required + +- **Node.js 20+** - Runtime for the orchestrator and CLI + + ```bash + node --version # Should be v20.0.0 or higher + ``` + +- **Git 2.25+** - For repository management and worktrees + + ```bash + git --version + ``` + +- **tmux** (for tmux runtime) - Terminal multiplexer for session management + + ```bash + tmux -V + + # Install on macOS + brew install tmux + + # Install on Ubuntu/Debian + sudo apt install tmux + + # Install on Fedora/RHEL + sudo dnf install tmux + ``` + +- **GitHub CLI** (for GitHub integration) - Required for PR creation, issue management + + ```bash + gh --version + + # Install on macOS + brew install gh + + # Install on Linux + # See: https://github.com/cli/cli/blob/trunk/docs/install_linux.md + ``` + +### Optional + +- **Linear API Key** - If using Linear for issue tracking + - Get it from: https://linear.app/settings/api + - Set environment variable: `export LINEAR_API_KEY="lin_api_..."` + +- **Slack Webhook** - If using Slack notifications + - Create incoming webhook: https://api.slack.com/messaging/webhooks + - Set environment variable: `export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..."` + +## Installation + +### Build from Source (Current Method) + +The package is not yet published to npm. Install by building from source: + +```bash +# Clone the repository +git clone https://github.com/ComposioHQ/agent-orchestrator +cd agent-orchestrator + +# Install dependencies (requires pnpm) +pnpm install + +# Build all packages +pnpm build + +# Link CLI globally +npm link -g packages/cli + +# Verify +ao --version +``` + +> **Coming soon:** `npm install -g @composio/ao-cli` once published to npm. + +**If you don't have pnpm:** + +```bash +npm install -g pnpm +``` + +## First-Time Configuration + +### Quick Setup with `ao init` + +The easiest way to get started: + +```bash +cd ~/your-repo +ao init +``` + +The wizard will prompt you for: + +1. **Data directory** - Where to store session metadata (default: `~/.agent-orchestrator`) +2. **Worktree directory** - Where to create isolated workspaces (default: `~/.worktrees`) +3. **Dashboard port** - Web interface port (default: `3000`) +4. **Runtime plugin** - Session runtime (default: `tmux`) +5. **Agent plugin** - AI coding assistant (default: `claude-code`) +6. **Workspace plugin** - Workspace isolation method (default: `worktree`) +7. **Notifiers** - Notification channels (default: `desktop`) +8. **Project ID** - Short name for your project +9. **GitHub repo** - Repository in `owner/repo` format +10. **Local path** - Path to your repository +11. **Default branch** - Main branch name (usually `main` or `master`) + +### What `ao init` Detects Automatically + +The wizard is smart and tries to help: + +- **Git repository** - Detects if you're in a git repo +- **GitHub remote** - Parses `owner/repo` from git remote +- **Current branch** - Suggests default branch from git +- **API keys** - Checks for `LINEAR_API_KEY` in environment +- **GitHub auth** - Verifies `gh` CLI authentication status +- **tmux availability** - Warns if tmux is not installed + +### Manual Configuration + +If you prefer to write the config manually: + +```bash +cp agent-orchestrator.yaml.example agent-orchestrator.yaml +nano agent-orchestrator.yaml +``` + +Or start from an example: + +```bash +cp examples/simple-github.yaml agent-orchestrator.yaml +nano agent-orchestrator.yaml +``` + +## Configuration Reference + +### Minimal Configuration + +The absolute minimum needed: + +```yaml +dataDir: ~/.agent-orchestrator +worktreeDir: ~/.worktrees +port: 3000 + +projects: + my-app: + repo: owner/my-app + path: ~/my-app + defaultBranch: main +``` + +### Full Configuration Schema + +See [agent-orchestrator.yaml.example](./agent-orchestrator.yaml.example) for a fully commented example with all options. + +### Plugin Slots + +Agent Orchestrator has 8 plugin slots. All are swappable: + +| Slot | Purpose | Default | Alternatives | +| ------------- | -------------------- | ------------- | ----------------------------------------------- | +| **Runtime** | How sessions run | `tmux` | `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 | +| **SCM** | Source control | `github` | GitLab, Bitbucket (future) | +| **Notifier** | Notifications | `desktop` | `slack`, `discord`, `webhook`, `email` | +| **Terminal** | Terminal integration | `iterm2` | `web`, custom | +| **Lifecycle** | Session lifecycle | (core) | Non-pluggable | + +### Reactions + +Reactions are auto-responses to events. Configure how the orchestrator handles common scenarios: + +#### CI Failed + +```yaml +reactions: + ci-failed: + auto: true # Enable auto-handling + action: send-to-agent # Send failure logs to agent + retries: 2 # Retry up to 2 times + escalateAfter: 2 # Notify human after 2 failures +``` + +#### Changes Requested (Review Comments) + +```yaml +reactions: + changes-requested: + auto: true + action: send-to-agent + escalateAfter: 30m # Notify human if not resolved in 30 minutes +``` + +#### Approved and Green (Auto-merge) + +```yaml +reactions: + approved-and-green: + auto: true # Enable auto-merge + action: auto-merge # Merge when approved + CI passes + priority: action # Notification priority +``` + +**Warning:** Only enable auto-merge if you trust your CI pipeline and agents! + +#### Agent Stuck + +```yaml +reactions: + agent-stuck: + threshold: 10m # Consider stuck after 10 minutes of inactivity + action: notify + priority: urgent +``` + +### Notification Routing + +Route notifications by priority: + +```yaml +notificationRouting: + urgent: [desktop, slack] # Agent stuck, needs input, errored + action: [desktop, slack] # PR ready to merge + warning: [slack] # Auto-fix failed + info: [slack] # Summary, all done +``` + +### Agent Rules + +Inline rules included in every agent prompt: + +```yaml +projects: + my-app: + agentRules: | + Always run tests before pushing. + Use conventional commits (feat:, fix:, chore:). + Link issue numbers in commit messages. +``` + +Or reference an external file: + +```yaml +projects: + my-app: + agentRulesFile: .agent-rules.md +``` + +### Per-Project Overrides + +Override defaults per project: + +```yaml +projects: + frontend: + runtime: tmux + agent: claude-code + workspace: worktree + + backend: + runtime: docker # Use Docker for backend + agent: codex # Use Codex instead of Claude +``` + +## Integration Guides + +### GitHub Issues + +**Authentication:** + +```bash +gh auth login +``` + +**Required scopes:** + +- `repo` - Full repository access +- `read:org` - Read organization membership (for team mentions) + +**Verification:** + +```bash +gh auth status +``` + +### Linear + +**Setup:** + +1. Get your API key: https://linear.app/settings/api +2. Add to environment: + + ```bash + echo 'export LINEAR_API_KEY="lin_api_..."' >> ~/.zshrc + source ~/.zshrc + ``` + +3. Find your team ID: + - Go to https://linear.app/settings/api + - Click "Create new key" or use existing key + - Team ID is visible in your Linear workspace URL or via API + +4. Configure in `agent-orchestrator.yaml`: + ```yaml + projects: + my-app: + tracker: + plugin: linear + teamId: "your-team-id" + ``` + +**Verification:** + +```bash +echo $LINEAR_API_KEY # Should print your key +``` + +### Slack + +**Setup:** + +1. Create incoming webhook: https://api.slack.com/messaging/webhooks +2. Add to environment: + + ```bash + echo 'export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..."' >> ~/.zshrc + source ~/.zshrc + ``` + +3. Configure in `agent-orchestrator.yaml`: + ```yaml + notifiers: + slack: + plugin: slack + webhook: ${SLACK_WEBHOOK_URL} + channel: "#agent-updates" + ``` + +**Verification:** + +```bash +# Send test message +curl -X POST -H 'Content-type: application/json' \ + --data '{"text":"Agent Orchestrator test"}' \ + $SLACK_WEBHOOK_URL +``` + +### Custom Trackers + +To add a custom tracker (Jira, Asana, etc.), create a plugin: + +1. See plugin examples in `packages/plugins/tracker-*/` +2. Implement the `Tracker` interface from `@composio/ao-core` +3. Register your plugin in the config + +See [CLAUDE.md](./CLAUDE.md) for plugin development guidelines. + +## Troubleshooting + +### "No agent-orchestrator.yaml found" + +**Problem:** The orchestrator can't find your config file. + +**Solution:** + +```bash +# Run init wizard +ao init + +# Or copy an example +cp examples/simple-github.yaml agent-orchestrator.yaml +``` + +### "tmux not found" + +**Problem:** tmux is not installed (required for tmux runtime). + +**Solution:** + +```bash +# macOS +brew install tmux + +# Ubuntu/Debian +sudo apt install tmux + +# Fedora/RHEL +sudo dnf install tmux +``` + +### "gh auth failed" + +**Problem:** GitHub CLI is not authenticated. + +**Solution:** + +```bash +gh auth login + +# Select: +# - GitHub.com (not Enterprise) +# - HTTPS (recommended) +# - Authenticate with browser +# - Include repo scope +``` + +**Verify:** + +```bash +gh auth status +``` + +### "LINEAR_API_KEY not found" + +**Problem:** Linear API key is not set in environment. + +**Solution:** + +```bash +# Get your key from: https://linear.app/settings/api + +# Add to shell profile +echo 'export LINEAR_API_KEY="lin_api_..."' >> ~/.zshrc +source ~/.zshrc + +# Verify +echo $LINEAR_API_KEY +``` + +### "Port 3000 already in use" + +**Problem:** Another service is using port 3000. + +**Solution:** + +```bash +# Change port in agent-orchestrator.yaml +port: 3001 + +# Or find and kill the process using port 3000 +lsof -ti:3000 | xargs kill +``` + +### "Workspace creation failed" + +**Problem:** Orchestrator can't create worktrees or clones. + +**Solution:** + +```bash +# Check worktreeDir permissions +ls -la ~/.worktrees + +# Create directory if missing +mkdir -p ~/.worktrees + +# Check disk space +df -h +``` + +### "Session not found" + +**Problem:** Session ID doesn't exist or was already destroyed. + +**Solution:** + +```bash +# List active sessions +ao session ls + +# Check status dashboard +ao status +``` + +### "Agent not responding" + +**Problem:** Agent session is stuck or frozen. + +**Solution:** + +```bash +# Check session status +ao status + +# Attach to session to investigate +ao open + +# Send message to agent +ao send "Please report your current status" + +# Kill and respawn if necessary +ao session kill +ao spawn +``` + +### "Permission denied" when spawning + +**Problem:** Agent doesn't have permissions for git operations. + +**Solution:** + +```bash +# Check SSH keys are added +ssh -T git@github.com + +# Add SSH key if needed +ssh-add ~/.ssh/id_ed25519 + +# Or use HTTPS and authenticate gh CLI +gh auth login +``` + +### "YAML parse error" + +**Problem:** Syntax error in `agent-orchestrator.yaml`. + +**Solution:** + +```bash +# Validate YAML syntax online: https://www.yamllint.com/ + +# Common issues: +# - Incorrect indentation (use 2 spaces, not tabs) +# - Missing quotes around strings with special characters +# - Typo in field names +``` + +### "Node version too old" + +**Problem:** Node.js version is below 20. + +**Solution:** + +```bash +# Check version +node --version + +# Upgrade with nvm (recommended) +nvm install 20 +nvm use 20 +nvm alias default 20 + +# Or download from: https://nodejs.org/ +``` + +## Advanced Configuration + +### Multi-Project Setup + +Manage multiple repositories: + +```yaml +projects: + frontend: + repo: org/frontend + path: ~/frontend + sessionPrefix: fe + + backend: + repo: org/backend + path: ~/backend + sessionPrefix: api + + mobile: + repo: org/mobile + path: ~/mobile + sessionPrefix: mob +``` + +See [examples/multi-project.yaml](./examples/multi-project.yaml) for full example. + +### Custom Plugin Development + +Create custom plugins for: + +- Different runtimes (Docker, Kubernetes, SSH, cloud VMs) +- Different agents (custom AI assistants) +- Different trackers (Jira, Asana, custom systems) +- Different notifiers (email, webhooks, custom integrations) + +See [CLAUDE.md](./CLAUDE.md) for plugin development guidelines. + +### Docker Runtime + +Run agents in Docker containers: + +```yaml +defaults: + runtime: docker + +# Plugin will use official images or build from Dockerfile +``` + +### Kubernetes Runtime + +Run agents in Kubernetes pods: + +```yaml +defaults: + runtime: kubernetes + +# Requires kubectl configured with cluster access +``` + +### Custom Notifiers + +Send notifications to custom webhooks: + +```yaml +notifiers: + webhook: + plugin: webhook + url: https://your-service.com/webhook + method: POST + headers: + Authorization: "Bearer ${WEBHOOK_TOKEN}" +``` + +## FAQ + +### What's a session? + +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 metadata (branch, PR, status) +- Its own event log + +Sessions are ephemeral — they're created for an issue and destroyed when merged. + +### What's a worktree vs clone? + +**Worktree** (default): + +- Shares `.git` directory with main repo +- Fast to create (no cloning) +- Efficient disk usage +- Best for local development + +**Clone**: + +- Full independent repository clone +- Slower to create +- More disk space +- Better for isolation, remote work + +### How do reactions work? + +Reactions are event handlers that run automatically: + +1. Event occurs (CI fails, review comment added, PR approved) +2. Orchestrator checks reaction config +3. If `auto: true`, performs the action automatically +4. If escalation threshold reached, notifies human + +Actions can be: + +- `send-to-agent` - Forward event to agent to handle +- `auto-merge` - Merge PR automatically +- `notify` - Send notification to human + +### When should I enable auto-merge? + +Enable auto-merge if: + +- ✅ You have comprehensive CI/CD tests +- ✅ You require code review approval +- ✅ You trust your agents to write correct code +- ✅ You want maximum automation + +Don't enable auto-merge if: + +- ❌ You have incomplete test coverage +- ❌ You want manual review of every change +- ❌ You're still evaluating agent quality +- ❌ You work on critical systems (finance, healthcare, etc.) + +Start with `auto: false` and enable after building confidence. + +### How do I add custom agent rules? + +**Inline:** + +```yaml +projects: + my-app: + agentRules: | + Always run tests before pushing. + Use conventional commits. +``` + +**External file:** + +```yaml +projects: + my-app: + agentRulesFile: .agent-rules.md +``` + +Rules are included in every agent prompt for that project. + +### Can I use multiple trackers? + +Yes! Different projects can use different trackers: + +```yaml +projects: + frontend: + tracker: + plugin: github + + backend: + tracker: + plugin: linear + teamId: "..." +``` + +### How do I monitor agent progress? + +Three ways: + +1. **Dashboard** - `ao start` then visit http://localhost:3000 +2. **CLI status** - `ao status` (text-based dashboard) +3. **Attach to session** - `ao open ` (live terminal) + +### What if an agent gets stuck? + +```bash +# Check status +ao status + +# Send message +ao send "What's your current status?" + +# Attach to investigate +ao open + +# Kill and respawn if necessary +ao session kill +ao spawn +``` + +Agents also send "stuck" notifications automatically after inactivity threshold. + +### How do I clean up old sessions? + +```bash +# List all sessions +ao session ls + +# Kill specific session +ao session kill + +# Cleanup script (example) +ao session ls --json | jq -r '.[] | select(.status == "merged") | .id' | xargs -I{} ao session kill {} +``` + +### Can I run multiple orchestrators? + +Yes! Each orchestrator instance should have: + +- Different data directory (`dataDir`) +- Different port (`port`) +- Different config file + +Useful for: + +- Separating projects +- Different teams +- Testing new configs + +## Next Steps + +1. **Run `ao init`** - Create your first config +2. **Spawn an agent** - `ao spawn my-app ISSUE-123` +3. **Monitor progress** - `ao status` or dashboard +4. **Read [CLAUDE.md](./CLAUDE.md)** - Code conventions and architecture +5. **Explore examples** - See [examples/](./examples/) for more configs +6. **Join the community** - Report issues, share configs, contribute plugins + +--- + +**Need help?** Open an issue at: https://github.com/ComposioHQ/agent-orchestrator/issues diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md new file mode 100644 index 000000000..ddfa2d61a --- /dev/null +++ b/TROUBLESHOOTING.md @@ -0,0 +1,55 @@ +# Troubleshooting + +## DirectTerminal: posix_spawnp failed error + +**Symptom**: Terminal in browser shows "Connected" but blank. WebSocket logs show: +``` +[DirectTerminal] Failed to spawn PTY: Error: posix_spawnp failed. +``` + +**Root Cause**: node-pty prebuilt binaries are incompatible with your system. + +**Fix**: Rebuild node-pty from source: + +```bash +# From the repository root +cd node_modules/.pnpm/node-pty@1.1.0/node_modules/node-pty +npx node-gyp rebuild +``` + +**Verification**: +```bash +# Test node-pty works +node -e "const pty = require('./node_modules/.pnpm/node-pty@1.1.0/node_modules/node-pty'); \ + const shell = pty.spawn('/bin/zsh', [], {name: 'xterm-256color', cols: 80, rows: 24, \ + cwd: process.env.HOME, env: process.env}); \ + shell.onData((d) => console.log('✅ OK')); \ + setTimeout(() => process.exit(0), 1000);" +``` + +**When this happens**: +- After `pnpm install` (uses cached prebuilts) +- After copying the repo to a new location +- On some macOS configurations with Homebrew Node + +**Permanent fix**: The postinstall hook automatically rebuilds node-pty: +```bash +pnpm install # Automatically rebuilds node-pty via postinstall hook +``` + +If you need to manually rebuild: +```bash +cd node_modules/.pnpm/node-pty@1.1.0/node_modules/node-pty +npx node-gyp rebuild +``` + +## Other Issues + +### Config file not found + +**Symptom**: API returns 500 with "No agent-orchestrator.yaml found" + +**Fix**: Ensure config exists in the directory where you run `ao start`, or symlink it: +```bash +ln -s /path/to/agent-orchestrator.yaml packages/web/agent-orchestrator.yaml +``` diff --git a/eslint.config.js b/eslint.config.js index fd2c968f5..c30ff0f15 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -85,4 +85,20 @@ export default tseslint.config( "no-console": "off", // Next.js uses console for server logs }, }, + + // Scripts directory - Node.js environment + { + files: ["scripts/**/*.js", "scripts/**/*.mjs"], + languageOptions: { + globals: { + console: "readonly", + process: "readonly", + __dirname: "readonly", + __filename: "readonly", + }, + }, + rules: { + "no-console": "off", // Scripts use console for output + }, + }, ); diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 000000000..6fb7e34ee --- /dev/null +++ b/examples/README.md @@ -0,0 +1,113 @@ +# Agent Orchestrator Config Examples + +This directory contains example configurations for common use cases. + +## Quick Start + +Copy an example and customize: + +```bash +cp examples/simple-github.yaml agent-orchestrator.yaml +nano agent-orchestrator.yaml # edit as needed +ao spawn my-app ISSUE-123 +``` + +## Examples + +### [simple-github.yaml](./simple-github.yaml) + +**Minimal setup with GitHub Issues** + +Perfect for getting started. Just specify your repo and you're ready to spawn agents. + +Use this if: + +- You're working on a single GitHub repository +- You want to use GitHub Issues for task tracking +- You want the simplest possible setup + +### [linear-team.yaml](./linear-team.yaml) + +**Linear integration** + +Integrates with Linear for issue tracking. Requires `LINEAR_API_KEY` environment variable. + +Use this if: + +- Your team uses Linear for project management +- You want agents to update Linear ticket status +- You need custom agent rules per project + +### [multi-project.yaml](./multi-project.yaml) + +**Multiple repos with different trackers** + +Shows how to manage multiple projects with different trackers and notification routing. + +Use this if: + +- You're managing multiple repositories +- Different projects use different trackers (GitHub Issues vs Linear) +- You want Slack notifications in addition to desktop +- You need different rules per project + +### [auto-merge.yaml](./auto-merge.yaml) + +**Aggressive automation with auto-merge** + +Automatically merges approved PRs with passing CI. Auto-retries CI failures and review comments. + +Use this if: + +- You trust your agents and CI pipeline +- You want maximum automation +- You want agents to handle routine failures autonomously +- You want escalation only when agents get stuck + +### [codex-integration.yaml](./codex-integration.yaml) + +**Using Codex instead of Claude Code** + +Shows how to use a different AI agent (Codex) instead of the default Claude Code. + +Use this if: + +- You prefer GPT-4/Codex over Claude +- You need agent-specific configuration +- You're evaluating different AI coding assistants + +## Configuration Tips + +1. **Start simple** - Use `simple-github.yaml` as a starting point +2. **Add complexity incrementally** - Enable features as you need them +3. **Test with one project first** - Get comfortable before adding multiple projects +4. **Review defaults** - Most sensible defaults are already configured +5. **Use environment variables** - Store API keys in env vars, not config files + +## Environment Variables + +These environment variables are commonly used: + +```bash +# Linear integration +export LINEAR_API_KEY="lin_api_..." + +# Slack notifications +export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..." + +# GitHub (usually set by gh CLI) +# export GITHUB_TOKEN="ghp_..." +``` + +Add these to your shell profile (`~/.zshrc` or `~/.bashrc`) to persist them. + +## Next Steps + +After copying an example: + +1. **Edit the config** - Update repo paths, team IDs, etc. +2. **Validate** - Run `ao start` to check for config errors +3. **Spawn an agent** - Try `ao spawn project-id ISSUE-123` +4. **Monitor** - Use `ao status` or open the dashboard at http://localhost:3000 + +See [SETUP.md](../SETUP.md) for detailed configuration reference and troubleshooting. diff --git a/examples/auto-merge.yaml b/examples/auto-merge.yaml new file mode 100644 index 000000000..445bf4b11 --- /dev/null +++ b/examples/auto-merge.yaml @@ -0,0 +1,38 @@ +# Aggressive automation with auto-merge +# Automatically merges approved PRs with passing CI + +dataDir: ~/.agent-orchestrator +worktreeDir: ~/.worktrees +port: 3000 + +projects: + my-app: + repo: owner/my-app + path: ~/my-app + defaultBranch: main + + # Enable auto-merge for this project + reactions: + approved-and-green: + auto: true # Automatically merge when PR is approved and CI passes + action: auto-merge + +# Global reactions +reactions: + # Auto-retry CI failures up to 3 times + ci-failed: + auto: true + action: send-to-agent + retries: 3 + + # Auto-address review comments + changes-requested: + auto: true + action: send-to-agent + escalateAfter: 1h # Notify human if not resolved in 1 hour + + # Notify when agent is stuck + agent-stuck: + threshold: 10m + action: notify + priority: urgent diff --git a/examples/codex-integration.yaml b/examples/codex-integration.yaml new file mode 100644 index 000000000..a44c17700 --- /dev/null +++ b/examples/codex-integration.yaml @@ -0,0 +1,27 @@ +# Using Codex instead of Claude Code +# Demonstrates using a different AI agent + +dataDir: ~/.agent-orchestrator +worktreeDir: ~/.worktrees +port: 3000 + +defaults: + agent: codex # Use Codex instead of Claude Code + runtime: tmux + workspace: worktree + +projects: + my-app: + repo: owner/my-app + path: ~/my-app + defaultBranch: main + + # Codex-specific configuration + agentConfig: + model: gpt-4 + permissions: default + + agentRules: | + Write clean, well-documented code. + Follow project conventions. + Run tests before pushing. diff --git a/examples/linear-team.yaml b/examples/linear-team.yaml new file mode 100644 index 000000000..a7d3152d8 --- /dev/null +++ b/examples/linear-team.yaml @@ -0,0 +1,23 @@ +# Linear integration with custom team +# Requires LINEAR_API_KEY environment variable + +dataDir: ~/.agent-orchestrator +worktreeDir: ~/.worktrees +port: 3000 + +projects: + my-app: + repo: owner/my-app + path: ~/my-app + defaultBranch: main + + # Linear tracker integration + tracker: + plugin: linear + teamId: "2a6e9b1b-19cd-4e30-b5bd-7b34dc491c7e" + + # Custom rules for agents + agentRules: | + Always link Linear tickets in commit messages. + Run tests before pushing. + Use conventional commits (feat:, fix:, chore:). diff --git a/examples/multi-project.yaml b/examples/multi-project.yaml new file mode 100644 index 000000000..31db276e9 --- /dev/null +++ b/examples/multi-project.yaml @@ -0,0 +1,58 @@ +# Managing multiple projects with different trackers +# Shows how to configure multiple repos with different settings + +dataDir: ~/.agent-orchestrator +worktreeDir: ~/.worktrees +port: 3000 + +defaults: + runtime: tmux + agent: claude-code + workspace: worktree + notifiers: [desktop, slack] + +projects: + frontend: + name: Frontend + repo: org/frontend + path: ~/frontend + defaultBranch: main + sessionPrefix: fe + + tracker: + plugin: github + + agentRules: | + Use TypeScript strict mode. + Follow React best practices. + Always run `pnpm test` before pushing. + + backend: + name: Backend API + repo: org/backend + path: ~/backend + defaultBranch: main + sessionPrefix: api + + tracker: + plugin: linear + teamId: "your-team-id" + + agentRules: | + All endpoints require auth middleware. + Add OpenAPI docs for new routes. + Run `pnpm test` and `pnpm lint` before pushing. + +# Slack notifications (requires SLACK_WEBHOOK_URL) +notifiers: + slack: + plugin: slack + webhook: ${SLACK_WEBHOOK_URL} + channel: "#agent-updates" + +# Route notifications by priority +notificationRouting: + urgent: [desktop, slack] + action: [desktop, slack] + warning: [slack] + info: [slack] diff --git a/examples/simple-github.yaml b/examples/simple-github.yaml new file mode 100644 index 000000000..7d58b8ef5 --- /dev/null +++ b/examples/simple-github.yaml @@ -0,0 +1,12 @@ +# Minimal setup for a single GitHub repo with GitHub Issues +# Perfect for getting started quickly + +dataDir: ~/.agent-orchestrator +worktreeDir: ~/.worktrees +port: 3000 + +projects: + my-app: + repo: owner/my-app + path: ~/my-app + defaultBranch: main diff --git a/package.json b/package.json index b82db78a5..70aaa7220 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "changeset": "changeset", "version-packages": "changeset version", "release": "pnpm -r --filter '!@composio/ao-web' build && changeset publish", - "prepare": "husky" + "prepare": "husky", + "postinstall": "node scripts/rebuild-node-pty.js" }, "devDependencies": { "@changesets/cli": "^2.29.8", diff --git a/packages/cli/__tests__/commands/open.test.ts b/packages/cli/__tests__/commands/open.test.ts index 6a4c1d5e5..e05481233 100644 --- a/packages/cli/__tests__/commands/open.test.ts +++ b/packages/cli/__tests__/commands/open.test.ts @@ -141,9 +141,9 @@ describe("open command", () => { return null; }); - await expect( - program.parseAsync(["node", "test", "open", "nonexistent"]), - ).rejects.toThrow("process.exit(1)"); + await expect(program.parseAsync(["node", "test", "open", "nonexistent"])).rejects.toThrow( + "process.exit(1)", + ); }); it("passes --new-window flag to open-iterm-tab", async () => { diff --git a/packages/cli/__tests__/commands/send.test.ts b/packages/cli/__tests__/commands/send.test.ts index 3c58d420e..9d5e18eeb 100644 --- a/packages/cli/__tests__/commands/send.test.ts +++ b/packages/cli/__tests__/commands/send.test.ts @@ -155,7 +155,13 @@ describe("send command", () => { await program.parseAsync(["node", "test", "send", "--no-wait", "my-session", "urgent"]); // Should have sent the message without waiting - expect(mockExec).toHaveBeenCalledWith("tmux", ["send-keys", "-t", "my-session", "-l", "urgent"]); + expect(mockExec).toHaveBeenCalledWith("tmux", [ + "send-keys", + "-t", + "my-session", + "-l", + "urgent", + ]); }); it("detects queued message state", async () => { @@ -213,7 +219,13 @@ describe("send command", () => { await program.parseAsync(["node", "test", "send", "my-session", "short", "msg"]); - expect(mockExec).toHaveBeenCalledWith("tmux", ["send-keys", "-t", "my-session", "-l", "short msg"]); + expect(mockExec).toHaveBeenCalledWith("tmux", [ + "send-keys", + "-t", + "my-session", + "-l", + "short msg", + ]); }); it("clears partial input before sending", async () => { diff --git a/packages/cli/__tests__/commands/session.test.ts b/packages/cli/__tests__/commands/session.test.ts index 3402593c1..a342e9f25 100644 --- a/packages/cli/__tests__/commands/session.test.ts +++ b/packages/cli/__tests__/commands/session.test.ts @@ -310,7 +310,10 @@ describe("session cleanup", () => { await program.parseAsync(["node", "test", "session", "cleanup"]); const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n"); - const errOutput = vi.mocked(console.error).mock.calls.map((c) => String(c[0])).join("\n"); + const errOutput = vi + .mocked(console.error) + .mock.calls.map((c) => String(c[0])) + .join("\n"); // First session fails but loop continues to second expect(errOutput).toContain("Failed to kill app-1"); expect(output).toContain("Killing app-2"); diff --git a/packages/cli/__tests__/commands/spawn.test.ts b/packages/cli/__tests__/commands/spawn.test.ts index 9b9df9af3..4f86431b4 100644 --- a/packages/cli/__tests__/commands/spawn.test.ts +++ b/packages/cli/__tests__/commands/spawn.test.ts @@ -239,10 +239,7 @@ describe("spawn command", () => { // Prompt is sent via core tmuxSendKeys (handles multi-line via load-buffer) const { tmuxSendKeys } = await import("@composio/ao-core"); - expect(tmuxSendKeys).toHaveBeenCalledWith( - "app-1", - expect.stringContaining("INT-100"), - ); + expect(tmuxSendKeys).toHaveBeenCalledWith("app-1", expect.stringContaining("INT-100")); }); it("outputs SESSION= for scripting", async () => { diff --git a/packages/cli/__tests__/commands/status.test.ts b/packages/cli/__tests__/commands/status.test.ts index 93a0f4caa..da3521421 100644 --- a/packages/cli/__tests__/commands/status.test.ts +++ b/packages/cli/__tests__/commands/status.test.ts @@ -3,7 +3,16 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -const { mockTmux, mockGit, mockConfigRef, mockIntrospect, mockDetectPR, mockGetCISummary, mockGetReviewDecision, mockGetPendingComments } = vi.hoisted(() => ({ +const { + mockTmux, + mockGit, + mockConfigRef, + mockIntrospect, + mockDetectPR, + mockGetCISummary, + mockGetReviewDecision, + mockGetPendingComments, +} = vi.hoisted(() => ({ mockTmux: vi.fn(), mockGit: vi.fn(), mockConfigRef: { current: null as Record | null }, @@ -59,7 +68,13 @@ vi.mock("../../src/lib/plugins.js", () => ({ getAutomatedComments: vi.fn().mockResolvedValue([]), getCIChecks: vi.fn().mockResolvedValue([]), getReviews: vi.fn().mockResolvedValue([]), - getMergeability: vi.fn().mockResolvedValue({ mergeable: true, ciPassing: true, approved: false, noConflicts: true, blockers: [] }), + getMergeability: vi.fn().mockResolvedValue({ + mergeable: true, + ciPassing: true, + approved: false, + noConflicts: true, + blockers: [], + }), getPRState: vi.fn().mockResolvedValue("open"), mergePR: vi.fn(), closePR: vi.fn(), @@ -291,8 +306,22 @@ describe("status command", () => { mockGetCISummary.mockResolvedValue("passing"); mockGetReviewDecision.mockResolvedValue("approved"); mockGetPendingComments.mockResolvedValue([ - { id: "1", author: "reviewer", body: "fix this", isResolved: false, createdAt: new Date(), url: "" }, - { id: "2", author: "reviewer2", body: "fix that", isResolved: false, createdAt: new Date(), url: "" }, + { + id: "1", + author: "reviewer", + body: "fix this", + isResolved: false, + createdAt: new Date(), + url: "", + }, + { + id: "2", + author: "reviewer2", + body: "fix that", + isResolved: false, + createdAt: new Date(), + url: "", + }, ]); await program.parseAsync(["node", "test", "status"]); @@ -344,10 +373,7 @@ describe("status command", () => { it("handles SCM errors gracefully", async () => { const sessionDir = join(tmpDir, "my-app-sessions"); mkdirSync(sessionDir, { recursive: true }); - writeFileSync( - join(sessionDir, "app-1"), - "worktree=/tmp/wt\nbranch=feat/err\nstatus=working\n", - ); + writeFileSync(join(sessionDir, "app-1"), "worktree=/tmp/wt\nbranch=feat/err\nstatus=working\n"); mockTmux.mockImplementation(async (...args: string[]) => { if (args[0] === "list-sessions") return "app-1"; diff --git a/packages/cli/__tests__/lib/shell.test.ts b/packages/cli/__tests__/lib/shell.test.ts index 5698e6361..a541e164b 100644 --- a/packages/cli/__tests__/lib/shell.test.ts +++ b/packages/cli/__tests__/lib/shell.test.ts @@ -8,7 +8,15 @@ vi.mock("node:child_process", () => ({ execFile: mockExecFile, })); -import { exec, execSilent, tmux, git, gh, getTmuxSessions, getTmuxActivity } from "../../src/lib/shell.js"; +import { + exec, + execSilent, + tmux, + git, + gh, + getTmuxSessions, + getTmuxActivity, +} from "../../src/lib/shell.js"; beforeEach(() => { mockExecFile.mockReset(); @@ -16,7 +24,12 @@ beforeEach(() => { function mockSuccess(stdout: string, stderr = ""): void { mockExecFile.mockImplementation( - (_cmd: string, _args: string[], _opts: unknown, cb: (err: null, result: { stdout: string; stderr: string }) => void) => { + ( + _cmd: string, + _args: string[], + _opts: unknown, + cb: (err: null, result: { stdout: string; stderr: string }) => void, + ) => { cb(null, { stdout, stderr }); }, ); diff --git a/packages/cli/package.json b/packages/cli/package.json index 7cdd77285..47d671fe5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -9,7 +9,8 @@ }, "main": "dist/index.js", "files": [ - "dist" + "dist", + "templates" ], "repository": { "type": "git", diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 19f480742..4989d6042 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -28,8 +28,7 @@ export function registerDashboard(program: Command): void { if (!existsSync(resolve(webDir, "package.json"))) { console.error( chalk.red( - "Could not find @composio/ao-web package.\n" + - "Ensure it is installed: pnpm install", + "Could not find @composio/ao-web package.\n" + "Ensure it is installed: pnpm install", ), ); process.exit(1); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 6262b6dbc..92c5fac80 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,9 +1,16 @@ import { createInterface } from "node:readline/promises"; import { writeFileSync, existsSync } from "node:fs"; -import { resolve } from "node:path"; +import { resolve, basename } from "node:path"; +import { cwd } from "node:process"; import { stringify as yamlStringify } from "yaml"; import chalk from "chalk"; import type { Command } from "commander"; +import { git, gh, execSilent } from "../lib/shell.js"; +import { + detectProjectType, + generateRulesFromTemplates, + formatProjectTypeForDisplay, +} from "../lib/project-detection.js"; async function prompt( rl: ReturnType, @@ -15,12 +22,132 @@ async function prompt( return answer.trim() || defaultValue || ""; } +interface EnvironmentInfo { + isGitRepo: boolean; + gitRemote: string | null; + ownerRepo: string | null; + currentBranch: string | null; + defaultBranch: string | null; + hasTmux: boolean; + hasGh: boolean; + ghAuthed: boolean; + hasLinearKey: boolean; + hasSlackWebhook: boolean; +} + +async function detectDefaultBranch( + workingDir: string, + ownerRepo: string | null, +): Promise { + // Method 1: Try to get from git symbolic-ref (most reliable) + const symbolicRef = await git(["symbolic-ref", "refs/remotes/origin/HEAD"], workingDir); + if (symbolicRef) { + // Output: refs/remotes/origin/main + const match = symbolicRef.match(/refs\/remotes\/origin\/(.+)$/); + if (match) { + return match[1]; + } + } + + // Method 2: Try GitHub API via gh CLI + if (ownerRepo) { + const ghResult = await gh([ + "repo", + "view", + ownerRepo, + "--json", + "defaultBranchRef", + "-q", + ".defaultBranchRef.name", + ]); + if (ghResult) { + return ghResult; + } + } + + // Method 3: Check which common branch exists locally + const commonBranches = ["main", "master", "next", "develop"]; + for (const branch of commonBranches) { + const exists = await git(["rev-parse", "--verify", `origin/${branch}`], workingDir); + if (exists) { + return branch; + } + } + + // Fallback: return "main" as a reasonable default + return "main"; +} + +async function detectEnvironment(workingDir: string): Promise { + // Check if in git repo + const isGitRepo = (await git(["rev-parse", "--git-dir"], workingDir)) !== null; + + // Get git remote + let gitRemote: string | null = null; + let ownerRepo: string | null = null; + if (isGitRepo) { + gitRemote = await git(["remote", "get-url", "origin"], workingDir); + if (gitRemote) { + // Parse owner/repo from remote + // Examples: + // git@github.com:owner/repo.git + // https://github.com/owner/repo.git + const match = gitRemote.match(/github\.com[:/]([^/]+\/[^/]+?)(\.git)?$/); + if (match) { + ownerRepo = match[1]; + } + } + } + + // Get current branch (for display only, NOT for defaultBranch) + const currentBranch = isGitRepo ? await git(["branch", "--show-current"], workingDir) : null; + + // Detect the actual default branch (main/master/next) + const defaultBranch = isGitRepo ? await detectDefaultBranch(workingDir, ownerRepo) : null; + + // Check for tmux (direct invocation more portable than 'which') + const hasTmux = (await execSilent("tmux", ["-V"])) !== null; + + // Check for gh CLI (direct invocation more portable than 'which') + const hasGh = (await execSilent("gh", ["--version"])) !== null; + + // Check gh auth status (rely on exit code, not output string) + let ghAuthed = false; + if (hasGh) { + const authStatus = await gh(["auth", "status"]); + // gh() returns null on error, non-null on success + ghAuthed = authStatus !== null; + } + + // Check for API keys in environment + const hasLinearKey = !!process.env["LINEAR_API_KEY"]; + const hasSlackWebhook = !!process.env["SLACK_WEBHOOK_URL"]; + + return { + isGitRepo, + gitRemote, + ownerRepo, + currentBranch, + defaultBranch, + hasTmux, + hasGh, + ghAuthed, + hasLinearKey, + hasSlackWebhook, + }; +} + export function registerInit(program: Command): void { program .command("init") .description("Interactive setup wizard — creates agent-orchestrator.yaml") .option("-o, --output ", "Output file path", "agent-orchestrator.yaml") - .action(async (opts: { output: string }) => { + .option("--auto", "Auto-generate config with sensible defaults (no prompts)") + .option( + "--smart", + "Analyze project and generate custom rules (requires --auto, uses AI if available)", + ) + .action(async (opts: { output: string; auto?: boolean; smart?: boolean }) => { const outputPath = resolve(opts.output); if (existsSync(outputPath)) { @@ -29,7 +156,66 @@ export function registerInit(program: Command): void { process.exit(1); } + // Validate --smart requires --auto + if (opts.smart && !opts.auto) { + console.error(chalk.red("Error: --smart requires --auto")); + console.log(chalk.dim("Use: ao init --auto --smart")); + process.exit(1); + } + + // Handle --auto mode + if (opts.auto) { + await handleAutoMode(outputPath, opts.smart || false); + return; + } + console.log(chalk.bold.cyan("\n Agent Orchestrator — Setup Wizard\n")); + console.log(chalk.dim(" Detecting environment...\n")); + + const workingDir = cwd(); + const env = await detectEnvironment(workingDir); + + // Show detection results + if (env.isGitRepo) { + console.log(chalk.green(" ✓ Git repository detected")); + if (env.ownerRepo) { + console.log(chalk.dim(` Remote: ${env.ownerRepo}`)); + } + if (env.currentBranch) { + console.log(chalk.dim(` Branch: ${env.currentBranch}`)); + } + } else { + console.log(chalk.dim(" ○ Not in a git repository")); + } + + if (env.hasTmux) { + console.log(chalk.green(" ✓ tmux available")); + } else { + console.log(chalk.yellow(" ⚠ tmux not found")); + console.log(chalk.dim(" Install with: brew install tmux")); + } + + if (env.hasGh) { + if (env.ghAuthed) { + console.log(chalk.green(" ✓ GitHub CLI authenticated")); + } else { + console.log(chalk.yellow(" ⚠ GitHub CLI not authenticated")); + console.log(chalk.dim(" Run: gh auth login")); + } + } else { + console.log(chalk.yellow(" ⚠ GitHub CLI not found")); + console.log(chalk.dim(" Install with: brew install gh")); + } + + if (env.hasLinearKey) { + console.log(chalk.green(" ✓ LINEAR_API_KEY detected")); + } + + if (env.hasSlackWebhook) { + console.log(chalk.green(" ✓ SLACK_WEBHOOK_URL detected")); + } + + console.log(); const rl = createInterface({ input: process.stdin, @@ -37,6 +223,8 @@ export function registerInit(program: Command): void { }); try { + // Basic config + console.log(chalk.bold(" Configuration\n")); const dataDir = await prompt( rl, "Data directory (session metadata)", @@ -46,7 +234,7 @@ export function registerInit(program: Command): void { const portStr = await prompt(rl, "Dashboard port", "3000"); const port = parseInt(portStr, 10); if (isNaN(port) || port < 1 || port > 65535) { - console.error(chalk.red("Invalid port number. Must be 1-65535.")); + console.error(chalk.red("\nInvalid port number. Must be 1-65535.")); rl.close(); process.exit(1); } @@ -58,14 +246,19 @@ export function registerInit(program: Command): void { const workspace = await prompt(rl, "Workspace (worktree, clone)", "worktree"); const notifiersStr = await prompt( rl, - "Notifiers (comma-separated: desktop, slack, webhook)", + "Notifiers (comma-separated: desktop, slack)", "desktop", ); const notifiers = notifiersStr.split(",").map((s) => s.trim()); // First project console.log(chalk.bold("\n First Project\n")); - const projectId = await prompt(rl, "Project ID (short name, e.g. my-app)", ""); + const defaultProjectId = env.isGitRepo ? basename(workingDir) : ""; + const projectId = await prompt( + rl, + "Project ID (short name, e.g. my-app)", + defaultProjectId, + ); const config: Record = { dataDir, @@ -75,25 +268,239 @@ export function registerInit(program: Command): void { projects: {} as Record, }; + let projectPath = ""; if (projectId) { - const repo = await prompt(rl, "GitHub repo (owner/repo)", ""); - const path = await prompt(rl, "Local path to repo", `~/${projectId}`); - const defaultBranch = await prompt(rl, "Default branch", "main"); + const repo = await prompt(rl, "GitHub repo (owner/repo)", env.ownerRepo || ""); + projectPath = await prompt( + rl, + "Local path to repo", + env.isGitRepo ? workingDir : `~/${projectId}`, + ); + const defaultBranch = await prompt(rl, "Default branch", env.defaultBranch || "main"); - (config.projects as Record)[projectId] = { + // Ask about tracker + console.log(chalk.bold("\n Issue Tracker\n")); + if (env.hasLinearKey) { + console.log(chalk.dim(" (LINEAR_API_KEY detected)\n")); + } + const tracker = await prompt( + rl, + "Tracker (github, linear, none)", + env.hasLinearKey ? "linear" : "github", + ); + + const projectConfig: Record = { repo, - path, + path: projectPath, defaultBranch, }; + + if (tracker === "linear") { + if (!env.hasLinearKey) { + console.log(chalk.yellow("\nWarning: LINEAR_API_KEY not found in environment")); + console.log(chalk.dim("Set it in your shell profile or .env file")); + console.log(chalk.dim("Get your key at: https://linear.app/settings/api\n")); + } + + const teamId = await prompt(rl, "Linear team ID (find at linear.app/settings/api)", ""); + if (teamId) { + projectConfig.tracker = { plugin: "linear", teamId }; + } + } else if (tracker === "none") { + // Don't add tracker config + } else { + // Default to github (no explicit config needed) + } + + (config.projects as Record)[projectId] = projectConfig; } const yamlContent = yamlStringify(config, { indent: 2 }); writeFileSync(outputPath, yamlContent); - console.log(chalk.green(`\nConfig written to ${outputPath}`)); - console.log(chalk.dim("Edit the file to add more projects or customize reactions.\n")); + // Validation checks + console.log(chalk.bold("\n Validating Setup...\n")); + + const checks = [ + { name: "Git", pass: (await execSilent("git", ["--version"])) !== null }, + { name: "tmux", pass: env.hasTmux }, + { name: "GitHub CLI", pass: env.hasGh }, + ...(projectPath + ? [ + { + name: "Repo path exists", + pass: existsSync(projectPath.replace(/^~/, process.env.HOME || "")), + }, + ] + : []), + ]; + + for (const { name, pass } of checks) { + if (pass) { + console.log(chalk.green(` ✓ ${name}`)); + } else { + console.log(chalk.yellow(` ⚠ ${name} not found`)); + } + } + + // Success message and next steps + console.log(chalk.green(`\n✓ Config written to ${outputPath}\n`)); + console.log(chalk.bold("Next steps:\n")); + console.log(" 1. Review and edit the config:"); + console.log(chalk.cyan(` nano ${outputPath}\n`)); + + if (projectId) { + console.log(" 2. Spawn your first agent:"); + console.log(chalk.cyan(` ao spawn ${projectId} ISSUE-123\n`)); + } else { + console.log(" 2. Add a project to the config:"); + console.log(chalk.cyan(` nano ${outputPath}\n`)); + } + + console.log(" 3. Monitor progress:"); + console.log(chalk.cyan(" ao status\n")); + console.log(" 4. Open dashboard:"); + console.log(chalk.cyan(" ao start\n")); + console.log(chalk.dim("See SETUP.md for detailed configuration options.\n")); + + if (!env.hasTmux) { + console.log(chalk.yellow("Note: tmux is required for the default runtime.")); + console.log(chalk.dim("Install with: brew install tmux\n")); + } + + if (!env.ghAuthed && env.hasGh) { + console.log(chalk.yellow("Note: Authenticate GitHub CLI for full functionality.")); + console.log(chalk.dim("Run: gh auth login\n")); + } } finally { rl.close(); } }); } + +async function handleAutoMode(outputPath: string, smart: boolean): Promise { + const workingDir = cwd(); + + console.log(chalk.bold.cyan("\n Agent Orchestrator — Auto Setup\n")); + + if (smart) { + console.log(chalk.dim(" 🤖 Analyzing your project...\n")); + } else { + console.log(chalk.dim(" 🚀 Auto-generating config with smart defaults...\n")); + } + + // Detect environment + const env = await detectEnvironment(workingDir); + + // Detect project type + const projectType = detectProjectType(workingDir); + + // Show detection results + if (env.isGitRepo) { + console.log(chalk.green(" ✓ Git repository detected")); + if (env.ownerRepo) { + console.log(chalk.dim(` Remote: ${env.ownerRepo}`)); + } + if (env.currentBranch) { + console.log(chalk.dim(` Branch: ${env.currentBranch}`)); + } + } + + if (projectType.languages.length > 0 || projectType.frameworks.length > 0) { + console.log(chalk.green(" ✓ Project type detected")); + const formattedType = formatProjectTypeForDisplay(projectType); + formattedType.split("\n").forEach((line) => { + console.log(chalk.dim(` ${line}`)); + }); + } + + console.log(); + + // Generate agent rules + if (smart) { + // TODO: Implement AI-powered rule generation in future PR + console.log(chalk.yellow(" ⚠ AI-powered rule generation not yet implemented")); + console.log(chalk.dim(" Using template-based rules for now...\n")); + } + + const agentRules = generateRulesFromTemplates(projectType); + + // Build config with smart defaults + const projectId = env.isGitRepo ? basename(workingDir) : "my-project"; + const repo = env.ownerRepo || "owner/repo"; + const hasPlaceholderRepo = repo === "owner/repo"; + const path = env.isGitRepo ? workingDir : `~/${projectId}`; + const defaultBranch = env.defaultBranch || "main"; + + const config: Record = { + dataDir: "~/.agent-orchestrator", + worktreeDir: "~/.worktrees", + port: 3000, + defaults: { + runtime: "tmux", + agent: "claude-code", + workspace: "worktree", + notifiers: ["desktop"], + }, + projects: { + [projectId]: { + repo, + path, + defaultBranch, + agentRules, + }, + }, + }; + + // Write config + const yamlContent = yamlStringify(config, { indent: 2 }); + writeFileSync(outputPath, yamlContent); + + // Show success message + console.log(chalk.green(`✓ Config written to ${outputPath}\n`)); + + // Warn if placeholder repo value is used + if (hasPlaceholderRepo) { + console.log(chalk.yellow("⚠ Warning: Could not detect GitHub repository")); + console.log(chalk.dim(" Update the 'repo' field in the config before spawning agents\n")); + } + + // Show generated rules + if (agentRules) { + console.log(chalk.bold("Generated agent rules:\n")); + console.log(chalk.dim("---")); + agentRules.split("\n").forEach((line) => { + console.log(chalk.dim(`${line}`)); + }); + console.log(chalk.dim("---\n")); + } + + // Show next steps + console.log(chalk.bold("Next steps:\n")); + + if (hasPlaceholderRepo) { + console.log(" 1. Edit config and update 'repo' field:"); + console.log(chalk.cyan(` nano ${outputPath}\n`)); + console.log(" 2. Spawn your first agent:"); + console.log(chalk.cyan(` ao spawn ${projectId} ISSUE-123\n`)); + console.log(" 3. Monitor progress:"); + console.log(chalk.cyan(" ao status\n")); + } else { + console.log(" 1. Review and customize (optional):"); + console.log(chalk.cyan(` nano ${outputPath}\n`)); + console.log(" 2. Spawn your first agent:"); + console.log(chalk.cyan(` ao spawn ${projectId} ISSUE-123\n`)); + console.log(" 3. Monitor progress:"); + console.log(chalk.cyan(" ao status\n")); + } + + // Show warnings + if (!env.hasTmux) { + console.log(chalk.yellow("⚠ tmux not found - install with: brew install tmux")); + } + if (!env.ghAuthed && env.hasGh) { + console.log(chalk.yellow("⚠ GitHub CLI not authenticated - run: gh auth login")); + } + + console.log(); +} diff --git a/packages/cli/src/commands/review-check.ts b/packages/cli/src/commands/review-check.ts index a11f6ca8a..ad94084f9 100644 --- a/packages/cli/src/commands/review-check.ts +++ b/packages/cli/src/commands/review-check.ts @@ -26,12 +26,18 @@ async function checkPRReviews( const query = "query($owner:String!,$name:String!,$pr:Int!){repository(owner:$owner,name:$name){pullRequest(number:$pr){reviewDecision reviewThreads(first:100){nodes{isResolved}}}}}"; const result = await gh([ - "api", "graphql", - "-f", `query=${query}`, - "-f", `owner=${owner}`, - "-f", `name=${name}`, - "-F", `pr=${prNumber}`, - "--jq", ".data.repository.pullRequest", + "api", + "graphql", + "-f", + `query=${query}`, + "-f", + `owner=${owner}`, + "-f", + `name=${name}`, + "-F", + `pr=${prNumber}`, + "--jq", + ".data.repository.pullRequest", ]); if (!result) { diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index db754d8d5..6f03d0c9a 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -3,7 +3,13 @@ import { join } from "node:path"; import chalk from "chalk"; import ora from "ora"; import type { Command } from "commander"; -import { loadConfig, buildPrompt, tmuxSendKeys, type OrchestratorConfig, type ProjectConfig } from "@composio/ao-core"; +import { + loadConfig, + buildPrompt, + tmuxSendKeys, + type OrchestratorConfig, + type ProjectConfig, +} from "@composio/ao-core"; import { exec, git, getTmuxSessions } from "../lib/shell.js"; import { getSessionDir, writeMetadata, findSessionForIssue } from "../lib/metadata.js"; import { banner } from "../lib/format.js"; diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 46158c2b3..09eb1105c 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -189,9 +189,7 @@ export function registerStart(program: Command): void { const webDir = findWebDir(); if (!existsSync(resolve(webDir, "package.json"))) { spinner.fail("Dashboard not found"); - throw new Error( - "Could not find @composio/ao-web package. Run: pnpm install", - ); + throw new Error("Could not find @composio/ao-web package. Run: pnpm install"); } dashboardProcess = startDashboard(port, webDir); @@ -205,7 +203,11 @@ export function registerStart(program: Command): void { exists = await hasTmuxSession(sessionId); if (exists) { - console.log(chalk.yellow(`Orchestrator session "${sessionId}" is already running (skipping creation)`)); + console.log( + chalk.yellow( + `Orchestrator session "${sessionId}" is already running (skipping creation)`, + ), + ); } else { try { // Ensure CLAUDE.orchestrator.md exists diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 0b460556d..f115a300a 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -211,7 +211,8 @@ function printTableHeader(): void { padCol("Activity", COL.activity) + "Age"; console.log(chalk.dim(` ${hdr}`)); - const totalWidth = COL.session + COL.branch + COL.pr + COL.ci + COL.review + COL.threads + COL.activity + 3; + const totalWidth = + COL.session + COL.branch + COL.pr + COL.ci + COL.review + COL.threads + COL.activity + 3; console.log(chalk.dim(` ${"─".repeat(totalWidth)}`)); } @@ -307,9 +308,7 @@ export function registerStatus(program: Command): void { // Gather all session info in parallel const infoPromises = projectSessions .sort() - .map((session) => - gatherSessionInfo(session, sessionDir, agent, scm, projectConfig), - ); + .map((session) => gatherSessionInfo(session, sessionDir, agent, scm, projectConfig)); const sessionInfos = await Promise.all(infoPromises); for (const info of sessionInfos) { diff --git a/packages/cli/src/lib/project-detection.ts b/packages/cli/src/lib/project-detection.ts new file mode 100644 index 000000000..df04c4367 --- /dev/null +++ b/packages/cli/src/lib/project-detection.ts @@ -0,0 +1,239 @@ +import { readFileSync, existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export interface ProjectType { + languages: string[]; + frameworks: string[]; + tools: string[]; + testFramework?: string; + packageManager?: string; +} + +export function detectProjectType(dir: string): ProjectType { + const hasFile = (name: string) => existsSync(join(dir, name)); + const readJson = (name: string) => { + try { + return JSON.parse(readFileSync(join(dir, name), "utf-8")); + } catch { + return null; + } + }; + + const type: ProjectType = { + languages: [], + frameworks: [], + tools: [], + }; + + // JavaScript/TypeScript detection + if (hasFile("package.json")) { + const pkg = readJson("package.json"); + + if ( + hasFile("tsconfig.json") || + hasFile("tsconfig.base.json") || + pkg?.devDependencies?.typescript + ) { + type.languages.push("typescript"); + } else { + type.languages.push("javascript"); + } + + // Detect frameworks + if (pkg?.dependencies?.react || pkg?.devDependencies?.react) { + type.frameworks.push("react"); + } + if (pkg?.dependencies?.next || pkg?.devDependencies?.next) { + type.frameworks.push("nextjs"); + } + if (pkg?.dependencies?.vue || pkg?.devDependencies?.vue) { + type.frameworks.push("vue"); + } + if (pkg?.dependencies?.express || pkg?.devDependencies?.express) { + type.frameworks.push("express"); + } + + // Detect test framework + if (pkg?.devDependencies?.vitest) { + type.testFramework = "vitest"; + } else if (pkg?.devDependencies?.jest) { + type.testFramework = "jest"; + } else if (pkg?.devDependencies?.mocha) { + type.testFramework = "mocha"; + } + + // Detect package manager + if (hasFile("pnpm-lock.yaml") || hasFile("pnpm-workspace.yaml")) { + type.packageManager = "pnpm"; + if (hasFile("pnpm-workspace.yaml")) { + type.tools.push("pnpm-workspaces"); + } + } else if (hasFile("yarn.lock")) { + type.packageManager = "yarn"; + } else if (hasFile("package-lock.json")) { + type.packageManager = "npm"; + } + } + + // Python detection + if (hasFile("pyproject.toml") || hasFile("requirements.txt") || hasFile("setup.py")) { + type.languages.push("python"); + + if (hasFile("pyproject.toml")) { + type.tools.push("pyproject"); + } + + // Detect Python frameworks (check both files but avoid duplicates) + const reqFiles = ["requirements.txt", "pyproject.toml"]; + const addFramework = (framework: string) => { + if (!type.frameworks.includes(framework)) { + type.frameworks.push(framework); + } + }; + + for (const file of reqFiles) { + if (hasFile(file)) { + const content = readFileSync(join(dir, file), "utf-8").toLowerCase(); + if (content.includes("fastapi")) addFramework("fastapi"); + if (content.includes("django")) addFramework("django"); + if (content.includes("flask")) addFramework("flask"); + if (content.includes("pytest") && !type.testFramework) { + type.testFramework = "pytest"; + } + } + } + } + + // Go detection + if (hasFile("go.mod")) { + type.languages.push("go"); + } + + // Rust detection + if (hasFile("Cargo.toml")) { + type.languages.push("rust"); + } + + return type; +} + +export function generateRulesFromTemplates(projectType: ProjectType): string { + const templatesDir = join(__dirname, "../..", "templates", "rules"); + const rules: string[] = []; + + // Always include base rules (if available) + const basePath = join(templatesDir, "base.md"); + if (existsSync(basePath)) { + const baseRules = readFileSync(basePath, "utf-8"); + rules.push(baseRules.trim()); + } + + // Add language-specific rules + for (const lang of projectType.languages) { + const templatePath = join(templatesDir, `${lang}.md`); + if (existsSync(templatePath)) { + const langRules = readFileSync(templatePath, "utf-8"); + rules.push(langRules.trim()); + } + } + + // Add framework-specific rules + for (const framework of projectType.frameworks) { + const templatePath = join(templatesDir, `${framework}.md`); + if (existsSync(templatePath)) { + const frameworkRules = readFileSync(templatePath, "utf-8"); + rules.push(frameworkRules.trim()); + } + } + + // Add tool-specific rules + for (const tool of projectType.tools) { + const templatePath = join(templatesDir, `${tool}.md`); + if (existsSync(templatePath)) { + const toolRules = readFileSync(templatePath, "utf-8"); + rules.push(toolRules.trim()); + } + } + + // Add test commands based on detected tools + const testCommands = generateTestCommands(projectType); + if (testCommands) { + rules.push(testCommands); + } + + return rules.join("\n\n"); +} + +function generateTestCommands(projectType: ProjectType): string { + const commands: string[] = []; + + if ( + projectType.languages.includes("typescript") || + projectType.languages.includes("javascript") + ) { + const pm = projectType.packageManager || "npm"; + + commands.push(`Before pushing, run these commands:`); + + if (projectType.tools.includes("pnpm-workspaces")) { + commands.push(`- ${pm} build (build all packages)`); + commands.push(`- ${pm} typecheck (type check all packages)`); + commands.push(`- ${pm} lint (or ${pm} lint:fix to auto-fix)`); + if (projectType.testFramework) { + commands.push(`- ${pm} test (run all tests)`); + } + } else { + if (projectType.languages.includes("typescript")) { + commands.push(`- ${pm} run typecheck`); + } + commands.push(`- ${pm} run lint`); + if (projectType.testFramework) { + commands.push(`- ${pm} test`); + } + } + } else if (projectType.languages.includes("python")) { + commands.push(`Before pushing, run these commands:`); + if (projectType.testFramework === "pytest") { + commands.push(`- pytest (run tests)`); + } + commands.push(`- black . (format code)`); + commands.push(`- mypy . (type checking)`); + } else if (projectType.languages.includes("go")) { + commands.push(`Before pushing, run these commands:`); + commands.push(`- go test ./... (run tests)`); + commands.push(`- go vet ./... (check for issues)`); + commands.push(`- gofmt -w . (format code)`); + } + + return commands.length > 0 ? commands.join("\n") : ""; +} + +export function formatProjectTypeForDisplay(projectType: ProjectType): string { + const parts: string[] = []; + + if (projectType.languages.length > 0) { + parts.push(`Languages: ${projectType.languages.join(", ")}`); + } + + if (projectType.frameworks.length > 0) { + parts.push(`Frameworks: ${projectType.frameworks.join(", ")}`); + } + + if (projectType.packageManager) { + parts.push(`Package Manager: ${projectType.packageManager}`); + } + + if (projectType.testFramework) { + parts.push(`Test Framework: ${projectType.testFramework}`); + } + + if (projectType.tools.length > 0) { + parts.push(`Tools: ${projectType.tools.join(", ")}`); + } + + return parts.join("\n"); +} diff --git a/packages/cli/templates/rules/base.md b/packages/cli/templates/rules/base.md new file mode 100644 index 000000000..f374f7b87 --- /dev/null +++ b/packages/cli/templates/rules/base.md @@ -0,0 +1,4 @@ +Always run tests before pushing. +Use conventional commits (feat:, fix:, chore:, docs:, refactor:, test:). +Link issue numbers in commit messages. +Write clear commit messages that explain WHY, not just WHAT. diff --git a/packages/cli/templates/rules/go.md b/packages/cli/templates/rules/go.md new file mode 100644 index 000000000..4e03189b9 --- /dev/null +++ b/packages/cli/templates/rules/go.md @@ -0,0 +1,8 @@ +Follow Go best practices: + +- Use gofmt for formatting +- Handle all errors explicitly, don't ignore them +- Use defer for cleanup operations +- Keep interfaces small and focused +- Use meaningful variable names (not single letters except in loops) +- Follow effective Go patterns diff --git a/packages/cli/templates/rules/javascript.md b/packages/cli/templates/rules/javascript.md new file mode 100644 index 000000000..fd6223beb --- /dev/null +++ b/packages/cli/templates/rules/javascript.md @@ -0,0 +1,4 @@ +Use modern JavaScript (ES6+). +Prefer const over let, never use var. +Use async/await over raw Promises. +Use template literals for string interpolation. diff --git a/packages/cli/templates/rules/nextjs.md b/packages/cli/templates/rules/nextjs.md new file mode 100644 index 000000000..3955f70e5 --- /dev/null +++ b/packages/cli/templates/rules/nextjs.md @@ -0,0 +1,7 @@ +Follow Next.js best practices: + +- Use App Router (not Pages Router) for new features +- Use Server Components by default, Client Components only when needed +- Use proper data fetching patterns (async Server Components) +- Optimize images with next/image +- Use proper caching strategies diff --git a/packages/cli/templates/rules/pnpm-workspaces.md b/packages/cli/templates/rules/pnpm-workspaces.md new file mode 100644 index 000000000..a09d0a432 --- /dev/null +++ b/packages/cli/templates/rules/pnpm-workspaces.md @@ -0,0 +1,4 @@ +This is a pnpm monorepo with workspaces. +Run commands from root with pnpm -r (recursive) or from specific package directories. +Before pushing: pnpm build && pnpm typecheck && pnpm lint && pnpm test. +Always build packages before running dependent packages. diff --git a/packages/cli/templates/rules/python.md b/packages/cli/templates/rules/python.md new file mode 100644 index 000000000..6a932d5df --- /dev/null +++ b/packages/cli/templates/rules/python.md @@ -0,0 +1,8 @@ +Follow Python best practices: + +- Use type hints for function parameters and return values +- Follow PEP 8 style guide +- Use f-strings for string formatting +- Use context managers (with statements) for resource management +- Write docstrings for classes and functions +- Prefer composition over inheritance diff --git a/packages/cli/templates/rules/react.md b/packages/cli/templates/rules/react.md new file mode 100644 index 000000000..674133308 --- /dev/null +++ b/packages/cli/templates/rules/react.md @@ -0,0 +1,8 @@ +Use React best practices: + +- Use functional components with hooks, not class components +- Keep components small and focused (single responsibility) +- Use composition over inheritance +- Lift state up when multiple components need it +- Use TypeScript for prop types (if using TypeScript) +- Follow hooks rules (no conditionals, no loops, no nested functions) diff --git a/packages/cli/templates/rules/typescript.md b/packages/cli/templates/rules/typescript.md new file mode 100644 index 000000000..fef05ef15 --- /dev/null +++ b/packages/cli/templates/rules/typescript.md @@ -0,0 +1,6 @@ +Use TypeScript strict mode. +Use ESM modules with .js extensions in imports (e.g., import { foo } from "./bar.js"). +Use node: prefix for built-in modules (e.g., import { readFile } from "node:fs"). +Prefer const over let, never use var. +Use type imports for type-only imports: import type { Foo } from "./bar.js". +No any types - use unknown with type guards instead. diff --git a/packages/core/README.md b/packages/core/README.md index ffa18c5b7..20f939790 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -16,6 +16,7 @@ Core services, types, and configuration for the Agent Orchestrator system. 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) @@ -30,6 +31,7 @@ Every interface the system uses is defined here. If you're working on any part o ### `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 @@ -38,6 +40,7 @@ Handles session lifecycle: - `send(sessionId, message)` — send message to agent **Data flow in `spawn()`:** + 1. Load project config 2. **Validate issue exists** via `Tracker.getIssue()` (if issueId provided, fails-fast if not found) 3. Reserve session ID @@ -57,17 +60,20 @@ Handles session lifecycle: 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 @@ -77,6 +83,7 @@ spawning → working → pr_open → ci_failed/review_pending/approved → merge ### `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 @@ -84,6 +91,7 @@ Loads plugins and provides access to them: - `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 @@ -97,6 +105,7 @@ Loads plugins and provides access to them: 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) @@ -142,6 +151,7 @@ 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 @@ -163,16 +173,19 @@ This package is a dependency of all other packages. Build it first if working on ## 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/core/src/__tests__/metadata.test.ts b/packages/core/src/__tests__/metadata.test.ts index d92f081f9..452804329 100644 --- a/packages/core/src/__tests__/metadata.test.ts +++ b/packages/core/src/__tests__/metadata.test.ts @@ -74,7 +74,7 @@ describe("writeMetadata + readMetadata", () => { issue: "https://linear.app/team/issue/INT-123", }); - const content = readFileSync(join(dataDir,"app-3"), "utf-8"); + const content = readFileSync(join(dataDir, "app-3"), "utf-8"); expect(content).toContain("worktree=/tmp/w\n"); expect(content).toContain("branch=feat/INT-123\n"); expect(content).toContain("status=working\n"); @@ -88,7 +88,7 @@ describe("writeMetadata + readMetadata", () => { status: "spawning", }); - const content = readFileSync(join(dataDir,"app-4"), "utf-8"); + const content = readFileSync(join(dataDir, "app-4"), "utf-8"); expect(content).not.toContain("issue="); expect(content).not.toContain("pr="); expect(content).not.toContain("summary="); @@ -98,7 +98,7 @@ describe("writeMetadata + readMetadata", () => { describe("readMetadataRaw", () => { it("reads arbitrary key=value pairs", () => { writeFileSync( - join(dataDir,"raw-1"), + join(dataDir, "raw-1"), "worktree=/tmp/w\nbranch=main\ncustom_key=custom_value\n", "utf-8", ); @@ -115,7 +115,7 @@ describe("readMetadataRaw", () => { it("handles comments and empty lines", () => { writeFileSync( - join(dataDir,"raw-2"), + join(dataDir, "raw-2"), "# This is a comment\n\nkey1=value1\n\n# Another comment\nkey2=value2\n", "utf-8", ); @@ -126,7 +126,7 @@ describe("readMetadataRaw", () => { it("handles values containing equals signs", () => { writeFileSync( - join(dataDir,"raw-3"), + join(dataDir, "raw-3"), 'runtimeHandle={"id":"foo","data":{"key":"val"}}\n', "utf-8", ); @@ -203,8 +203,8 @@ describe("deleteMetadata", () => { deleteMetadata(dataDir, "del-1", true); - expect(existsSync(join(dataDir,"del-1"))).toBe(false); - const archiveDir = join(dataDir,"archive"); + expect(existsSync(join(dataDir, "del-1"))).toBe(false); + const archiveDir = join(dataDir, "archive"); expect(existsSync(archiveDir)).toBe(true); const files = readdirSync(archiveDir); expect(files.length).toBe(1); @@ -220,8 +220,8 @@ describe("deleteMetadata", () => { deleteMetadata(dataDir, "del-2", false); - expect(existsSync(join(dataDir,"del-2"))).toBe(false); - expect(existsSync(join(dataDir,"archive"))).toBe(false); + expect(existsSync(join(dataDir, "del-2"))).toBe(false); + expect(existsSync(join(dataDir, "archive"))).toBe(false); }); it("is a no-op for nonexistent session", () => { @@ -242,8 +242,8 @@ describe("listMetadata", () => { it("excludes archive directory and dotfiles", () => { writeMetadata(dataDir, "app-1", { worktree: "/tmp", branch: "a", status: "s" }); - mkdirSync(join(dataDir,"archive"), { recursive: true }); - writeFileSync(join(dataDir,".hidden"), "x", "utf-8"); + mkdirSync(join(dataDir, "archive"), { recursive: true }); + writeFileSync(join(dataDir, ".hidden"), "x", "utf-8"); const list = listMetadata(dataDir); expect(list).toEqual(["app-1"]); diff --git a/packages/core/src/__tests__/plugin-integration.test.ts b/packages/core/src/__tests__/plugin-integration.test.ts index 3497cf575..bc4744830 100644 --- a/packages/core/src/__tests__/plugin-integration.test.ts +++ b/packages/core/src/__tests__/plugin-integration.test.ts @@ -219,12 +219,8 @@ describe("plugin integration", () => { const trackers = registry.list("tracker"); const scms = registry.list("scm"); - expect(trackers).toContainEqual( - expect.objectContaining({ name: "github", slot: "tracker" }), - ); - expect(scms).toContainEqual( - expect.objectContaining({ name: "github", slot: "scm" }), - ); + expect(trackers).toContainEqual(expect.objectContaining({ name: "github", slot: "tracker" })); + expect(scms).toContainEqual(expect.objectContaining({ name: "github", slot: "scm" })); }); it("registry.get returns correct plugin instances by slot+name", () => { @@ -449,9 +445,7 @@ describe("plugin integration", () => { // 1. getPRState → open mockGh({ state: "OPEN" }); // 2. getCISummary → failing (pr checks returns array of checks with correct field names) - mockGh([ - { name: "lint", state: "FAILURE", link: "", startedAt: "", completedAt: "" }, - ]); + mockGh([{ name: "lint", state: "FAILURE", link: "", startedAt: "", completedAt: "" }]); await lm.check("app-1"); @@ -505,9 +499,7 @@ describe("plugin integration", () => { // 1. getPRState → open mockGh({ state: "OPEN" }); // 2. getCISummary → passing (using correct field names: state and link) - mockGh([ - { name: "lint", state: "SUCCESS", link: "", startedAt: "", completedAt: "" }, - ]); + mockGh([{ name: "lint", state: "SUCCESS", link: "", startedAt: "", completedAt: "" }]); // 3. getReviewDecision (gh pr view with reviewDecision) mockGh({ reviewDecision: "CHANGES_REQUESTED" }); diff --git a/packages/core/src/__tests__/plugin-registry.test.ts b/packages/core/src/__tests__/plugin-registry.test.ts index f328b01e6..48b001175 100644 --- a/packages/core/src/__tests__/plugin-registry.test.ts +++ b/packages/core/src/__tests__/plugin-registry.test.ts @@ -6,10 +6,7 @@ import type { PluginModule, PluginManifest, OrchestratorConfig } from "../types. // Helpers // --------------------------------------------------------------------------- -function makePlugin( - slot: PluginManifest["slot"], - name: string, -): PluginModule { +function makePlugin(slot: PluginManifest["slot"], name: string): PluginModule { return { manifest: { name, @@ -24,9 +21,7 @@ function makePlugin( }; } -function makeOrchestratorConfig( - overrides?: Partial, -): OrchestratorConfig { +function makeOrchestratorConfig(overrides?: Partial): OrchestratorConfig { return { projects: {}, ...overrides, @@ -76,10 +71,7 @@ describe("register + get", () => { registry.register(plugin, { worktreeDir: "/custom/path" }); expect(plugin.create).toHaveBeenCalledWith({ worktreeDir: "/custom/path" }); - const instance = registry.get<{ _config: Record }>( - "workspace", - "worktree", - ); + const instance = registry.get<{ _config: Record }>("workspace", "worktree"); expect(instance!._config).toEqual({ worktreeDir: "/custom/path" }); }); diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index 2d8b5cc4d..01483124d 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -273,8 +273,9 @@ describe("spawn", () => { registry: registryWithTracker, }); - await expect(sm.spawn({ projectId: "my-app", issueId: "INT-9999" })) - .rejects.toThrow("does not exist in tracker"); + await expect(sm.spawn({ projectId: "my-app", issueId: "INT-9999" })).rejects.toThrow( + "does not exist in tracker", + ); // Should not create workspace or runtime when validation fails expect(mockWorkspace.create).not.toHaveBeenCalled(); @@ -307,8 +308,9 @@ describe("spawn", () => { registry: registryWithTracker, }); - await expect(sm.spawn({ projectId: "my-app", issueId: "INT-100" })) - .rejects.toThrow("Failed to fetch issue"); + await expect(sm.spawn({ projectId: "my-app", issueId: "INT-100" })).rejects.toThrow( + "Failed to fetch issue", + ); // Should not create workspace or runtime when auth fails expect(mockWorkspace.create).not.toHaveBeenCalled(); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index ef4feac81..bc12c4724 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -62,7 +62,10 @@ const ProjectConfigSchema = z.object({ repo: z.string(), path: z.string(), defaultBranch: z.string().default("main"), - sessionPrefix: z.string().regex(/^[a-zA-Z0-9_-]+$/, "sessionPrefix must match [a-zA-Z0-9_-]+").optional(), + sessionPrefix: z + .string() + .regex(/^[a-zA-Z0-9_-]+$/, "sessionPrefix must match [a-zA-Z0-9_-]+") + .optional(), runtime: z.string().optional(), agent: z.string().optional(), workspace: z.string().optional(), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4a98f5c99..b88215086 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -53,9 +53,4 @@ export { generateOrchestratorPrompt } from "./orchestrator-prompt.js"; export type { OrchestratorPromptConfig } from "./orchestrator-prompt.js"; // Shared utilities -export { - shellEscape, - escapeAppleScript, - validateUrl, - readLastJsonlEntry, -} from "./utils.js"; +export { shellEscape, escapeAppleScript, validateUrl, readLastJsonlEntry } from "./utils.js"; diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index 22fe64c19..3e6fa3afd 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -196,10 +196,11 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan // 2. Check agent activity via terminal output + process liveness if (agent && session.runtimeHandle) { try { - const runtime = registry.get("runtime", project.runtime ?? config.defaults.runtime); - const terminalOutput = runtime - ? await runtime.getOutput(session.runtimeHandle, 10) - : ""; + const runtime = registry.get( + "runtime", + project.runtime ?? config.defaults.runtime, + ); + const terminalOutput = runtime ? await runtime.getOutput(session.runtimeHandle, 10) : ""; // Only trust detectActivity when we actually have terminal output; // empty output means the runtime probe failed, not that the agent exited. if (terminalOutput) { diff --git a/packages/core/src/orchestrator-prompt.ts b/packages/core/src/orchestrator-prompt.ts index 6b654fb63..d94d72fe8 100644 --- a/packages/core/src/orchestrator-prompt.ts +++ b/packages/core/src/orchestrator-prompt.ts @@ -133,9 +133,13 @@ Features: const reactionLines: string[] = []; for (const [event, reaction] of Object.entries(project.reactions)) { if (reaction.auto && reaction.action === "send-to-agent") { - reactionLines.push(`- **${event}**: Auto-sends instruction to agent (retries: ${reaction.retries ?? "none"}, escalates after: ${reaction.escalateAfter ?? "never"})`); + reactionLines.push( + `- **${event}**: Auto-sends instruction to agent (retries: ${reaction.retries ?? "none"}, escalates after: ${reaction.escalateAfter ?? "never"})`, + ); } else if (reaction.auto && reaction.action === "notify") { - reactionLines.push(`- **${event}**: Notifies human (priority: ${reaction.priority ?? "info"})`); + reactionLines.push( + `- **${event}**: Notifies human (priority: ${reaction.priority ?? "info"})`, + ); } } diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index d8154516e..0e67c60c5 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -118,7 +118,9 @@ function metadataToSession( const prUrl = meta["pr"]; const ghMatch = prUrl.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/); return { - number: ghMatch ? parseInt(ghMatch[3], 10) : parseInt(prUrl.match(/\/(\d+)$/)?.[1] ?? "0", 10), + number: ghMatch + ? parseInt(ghMatch[3], 10) + : parseInt(prUrl.match(/\/(\d+)$/)?.[1] ?? "0", 10), url: prUrl, title: "", owner: ghMatch?.[1] ?? "", @@ -223,8 +225,8 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { // Issue doesn't exist - fail fast with clear message throw new Error( `Issue ${spawnConfig.issueId} does not exist in tracker. ` + - `Create the issue first, then spawn with the created issue ID.`, - { cause: err } + `Create the issue first, then spawn with the created issue ID.`, + { cause: err }, ); } else { // Other error (auth, network, etc) - fail fast @@ -242,7 +244,9 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { if (reserveSessionId(config.dataDir, sessionId)) break; num++; if (attempts === 9) { - throw new Error(`Failed to reserve session ID after 10 attempts (prefix: ${project.sessionPrefix})`); + throw new Error( + `Failed to reserve session ID after 10 attempts (prefix: ${project.sessionPrefix})`, + ); } } // Reassign to satisfy TypeScript's flow analysis (not redundant from compiler's perspective) @@ -498,7 +502,8 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { if (handle) { const runtimePlugin = registry.get( "runtime", - handle.runtimeName ?? (project ? project.runtime ?? config.defaults.runtime : config.defaults.runtime), + handle.runtimeName ?? + (project ? (project.runtime ?? config.defaults.runtime) : config.defaults.runtime), ); if (runtimePlugin) { try { @@ -615,7 +620,8 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { const project = config.projects[raw["project"] ?? ""]; const runtimePlugin = registry.get( "runtime", - handle.runtimeName ?? (project ? project.runtime ?? config.defaults.runtime : config.defaults.runtime), + handle.runtimeName ?? + (project ? (project.runtime ?? config.defaults.runtime) : config.defaults.runtime), ); if (!runtimePlugin) { throw new Error(`No runtime plugin for session ${sessionId}`); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 54ffae540..7a34556fb 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -937,7 +937,8 @@ export function isIssueNotFoundError(err: unknown): boolean { // Match issue-specific not-found patterns return ( - message.includes("issue") && (message.includes("not found") || message.includes("does not exist")) || + (message.includes("issue") && + (message.includes("not found") || message.includes("does not exist"))) || message.includes("no issue found") || message.includes("could not find issue") || // GitHub: "no issue found" or "could not resolve to an Issue" diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 08c78ce1d..32cbe7b9b 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -14,10 +14,7 @@ export default defineConfig({ __dirname, "../plugins/tracker-github/src/index.ts", ), - "@composio/ao-plugin-scm-github": resolve( - __dirname, - "../plugins/scm-github/src/index.ts", - ), + "@composio/ao-plugin-scm-github": resolve(__dirname, "../plugins/scm-github/src/index.ts"), }, }, }); diff --git a/packages/integration-tests/src/agent-aider.integration.test.ts b/packages/integration-tests/src/agent-aider.integration.test.ts index 2c33a71fd..a20eaf86a 100644 --- a/packages/integration-tests/src/agent-aider.integration.test.ts +++ b/packages/integration-tests/src/agent-aider.integration.test.ts @@ -17,7 +17,12 @@ import { promisify } from "node:util"; import type { ActivityState, AgentSessionInfo } from "@composio/ao-core"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import aiderPlugin from "@composio/ao-plugin-agent-aider"; -import { isTmuxAvailable, killSessionsByPrefix, createSession, killSession } from "./helpers/tmux.js"; +import { + isTmuxAvailable, + killSessionsByPrefix, + createSession, + killSession, +} from "./helpers/tmux.js"; import { pollUntilEqual, sleep } from "./helpers/polling.js"; import { makeTmuxHandle, makeSession } from "./helpers/session-factory.js"; @@ -123,11 +128,10 @@ describe.skipIf(!canRun)("agent-aider (integration)", () => { } // Wait for agent to exit — aider with --message should exit after responding - exitedRunning = await pollUntilEqual( - () => agent.isProcessRunning(handle), - false, - { timeoutMs: 90_000, intervalMs: 2_000 }, - ); + exitedRunning = await pollUntilEqual(() => agent.isProcessRunning(handle), false, { + timeoutMs: 90_000, + intervalMs: 2_000, + }); exitedActivityState = await agent.getActivityState(session); sessionInfo = await agent.getSessionInfo(session); diff --git a/packages/integration-tests/src/agent-claude-code.integration.test.ts b/packages/integration-tests/src/agent-claude-code.integration.test.ts index e986b8ac0..2855e96ea 100644 --- a/packages/integration-tests/src/agent-claude-code.integration.test.ts +++ b/packages/integration-tests/src/agent-claude-code.integration.test.ts @@ -17,7 +17,12 @@ import { promisify } from "node:util"; import type { ActivityState, AgentSessionInfo } from "@composio/ao-core"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import claudeCodePlugin from "@composio/ao-plugin-agent-claude-code"; -import { isTmuxAvailable, killSessionsByPrefix, createSession, killSession } from "./helpers/tmux.js"; +import { + isTmuxAvailable, + killSessionsByPrefix, + createSession, + killSession, +} from "./helpers/tmux.js"; import { pollUntilEqual, sleep } from "./helpers/polling.js"; import { makeTmuxHandle, makeSession } from "./helpers/session-factory.js"; @@ -102,11 +107,10 @@ describe.skipIf(!canRun)("agent-claude-code (integration)", () => { } // Wait for agent to exit (simple task should complete within 90s) - exitedRunning = await pollUntilEqual( - () => agent.isProcessRunning(handle), - false, - { timeoutMs: 90_000, intervalMs: 2_000 }, - ); + exitedRunning = await pollUntilEqual(() => agent.isProcessRunning(handle), false, { + timeoutMs: 90_000, + intervalMs: 2_000, + }); // Capture post-exit observations exitedActivityState = await agent.getActivityState(session); diff --git a/packages/integration-tests/src/agent-codex.integration.test.ts b/packages/integration-tests/src/agent-codex.integration.test.ts index e02ed8c1d..5c41190b6 100644 --- a/packages/integration-tests/src/agent-codex.integration.test.ts +++ b/packages/integration-tests/src/agent-codex.integration.test.ts @@ -17,7 +17,12 @@ import { promisify } from "node:util"; import type { ActivityState, AgentSessionInfo } from "@composio/ao-core"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import codexPlugin from "@composio/ao-plugin-agent-codex"; -import { isTmuxAvailable, killSessionsByPrefix, createSession, killSession } from "./helpers/tmux.js"; +import { + isTmuxAvailable, + killSessionsByPrefix, + createSession, + killSession, +} from "./helpers/tmux.js"; import { pollUntilEqual, sleep } from "./helpers/polling.js"; import { makeTmuxHandle, makeSession } from "./helpers/session-factory.js"; @@ -90,11 +95,10 @@ describe.skipIf(!canRun)("agent-codex (integration)", () => { } // Wait for agent to exit - exitedRunning = await pollUntilEqual( - () => agent.isProcessRunning(handle), - false, - { timeoutMs: 90_000, intervalMs: 2_000 }, - ); + exitedRunning = await pollUntilEqual(() => agent.isProcessRunning(handle), false, { + timeoutMs: 90_000, + intervalMs: 2_000, + }); exitedActivityState = await agent.getActivityState(session); sessionInfo = await agent.getSessionInfo(session); diff --git a/packages/integration-tests/src/agent-opencode.integration.test.ts b/packages/integration-tests/src/agent-opencode.integration.test.ts index 01f8e7ef7..7fd6cabd8 100644 --- a/packages/integration-tests/src/agent-opencode.integration.test.ts +++ b/packages/integration-tests/src/agent-opencode.integration.test.ts @@ -17,7 +17,12 @@ import { promisify } from "node:util"; import type { ActivityState, AgentSessionInfo } from "@composio/ao-core"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import opencodePlugin from "@composio/ao-plugin-agent-opencode"; -import { isTmuxAvailable, killSessionsByPrefix, createSession, killSession } from "./helpers/tmux.js"; +import { + isTmuxAvailable, + killSessionsByPrefix, + createSession, + killSession, +} from "./helpers/tmux.js"; import { pollUntilEqual, sleep } from "./helpers/polling.js"; import { makeTmuxHandle, makeSession } from "./helpers/session-factory.js"; @@ -114,11 +119,10 @@ describe.skipIf(!canRun)("agent-opencode (integration)", () => { } // Wait for agent to exit - exitedRunning = await pollUntilEqual( - () => agent.isProcessRunning(handle), - false, - { timeoutMs: 90_000, intervalMs: 2_000 }, - ); + exitedRunning = await pollUntilEqual(() => agent.isProcessRunning(handle), false, { + timeoutMs: 90_000, + intervalMs: 2_000, + }); exitedActivityState = await agent.getActivityState(session); sessionInfo = await agent.getSessionInfo(session); diff --git a/packages/integration-tests/src/cli-session-ls.integration.test.ts b/packages/integration-tests/src/cli-session-ls.integration.test.ts index e5ba91674..98f7205a6 100644 --- a/packages/integration-tests/src/cli-session-ls.integration.test.ts +++ b/packages/integration-tests/src/cli-session-ls.integration.test.ts @@ -53,11 +53,9 @@ describe.skipIf(!tmuxOk)("CLI session listing (integration)", () => { }, 30_000); it("lists both sessions from tmux", async () => { - const { stdout } = await execFileAsync( - "tmux", - ["list-sessions", "-F", "#{session_name}"], - { timeout: 5_000 }, - ); + const { stdout } = await execFileAsync("tmux", ["list-sessions", "-F", "#{session_name}"], { + timeout: 5_000, + }); const sessions = stdout.trim().split("\n"); expect(sessions).toContain(session1); diff --git a/packages/integration-tests/src/cli-spawn-send-kill.integration.test.ts b/packages/integration-tests/src/cli-spawn-send-kill.integration.test.ts index 7d395d26c..3abbb2007 100644 --- a/packages/integration-tests/src/cli-spawn-send-kill.integration.test.ts +++ b/packages/integration-tests/src/cli-spawn-send-kill.integration.test.ts @@ -52,11 +52,9 @@ describe.skipIf(!tmuxOk)("CLI spawn-send-kill workflow (integration)", () => { await createSession(sessionName, "bash", tmpDir); // Verify tmux has-session succeeds - const { stdout } = await execFileAsync( - "tmux", - ["has-session", "-t", sessionName], - { timeout: 5_000 }, - ).catch(() => ({ stdout: "FAIL" })); + const { stdout } = await execFileAsync("tmux", ["has-session", "-t", sessionName], { + timeout: 5_000, + }).catch(() => ({ stdout: "FAIL" })); // has-session produces no stdout on success, throws on failure expect(stdout).not.toBe("FAIL"); @@ -85,11 +83,9 @@ describe.skipIf(!tmuxOk)("CLI spawn-send-kill workflow (integration)", () => { await killSession(sessionName); // has-session should now fail - const result = await execFileAsync( - "tmux", - ["has-session", "-t", sessionName], - { timeout: 5_000 }, - ).catch(() => "GONE"); + const result = await execFileAsync("tmux", ["has-session", "-t", sessionName], { + timeout: 5_000, + }).catch(() => "GONE"); expect(result).toBe("GONE"); }); diff --git a/packages/integration-tests/src/helpers/tmux.ts b/packages/integration-tests/src/helpers/tmux.ts index 12f4ffe70..d64a72eed 100644 --- a/packages/integration-tests/src/helpers/tmux.ts +++ b/packages/integration-tests/src/helpers/tmux.ts @@ -23,11 +23,9 @@ export async function isTmuxAvailable(): Promise { /** Kill all tmux sessions whose names match a prefix (cleanup helper). */ export async function killSessionsByPrefix(prefix: string): Promise { try { - const { stdout } = await execFileAsync( - "tmux", - ["list-sessions", "-F", "#{session_name}"], - { timeout: TIMEOUT }, - ); + const { stdout } = await execFileAsync("tmux", ["list-sessions", "-F", "#{session_name}"], { + timeout: TIMEOUT, + }); const sessions = stdout .trim() .split("\n") @@ -53,16 +51,7 @@ export async function createSession( cwd: string, env?: Record, ): Promise { - const args = [ - "new-session", - "-d", - "-s", - name, - "-x", - "200", - "-y", - "50", - ]; + const args = ["new-session", "-d", "-s", name, "-x", "200", "-y", "50"]; // Set environment variables inside the session if (env) { @@ -100,4 +89,3 @@ export async function killSession(name: string): Promise { // already gone } } - diff --git a/packages/integration-tests/src/notifier-composio.integration.test.ts b/packages/integration-tests/src/notifier-composio.integration.test.ts index 869fcc852..2fc4e56f8 100644 --- a/packages/integration-tests/src/notifier-composio.integration.test.ts +++ b/packages/integration-tests/src/notifier-composio.integration.test.ts @@ -109,9 +109,7 @@ describe("notifier-composio integration", () => { composioApiKey: "key", _clientOverride: mockClient, }); - await notifier.notify( - makeEvent({ data: { prUrl: "https://github.com/org/repo/pull/99" } }), - ); + await notifier.notify(makeEvent({ data: { prUrl: "https://github.com/org/repo/pull/99" } })); const text = mockExecuteAction.mock.calls[0][0].params.text as string; expect(text).toContain("https://github.com/org/repo/pull/99"); diff --git a/packages/integration-tests/src/notifier-desktop.integration.test.ts b/packages/integration-tests/src/notifier-desktop.integration.test.ts index e34b631e5..84048eacb 100644 --- a/packages/integration-tests/src/notifier-desktop.integration.test.ts +++ b/packages/integration-tests/src/notifier-desktop.integration.test.ts @@ -77,9 +77,7 @@ describe("notifier-desktop integration", () => { const nastySession = `test"with\\back'slash`; const nastyMessage = `msg "has" all\\the'chars`; - await notifier.notify( - makeEvent({ sessionId: nastySession, message: nastyMessage }), - ); + await notifier.notify(makeEvent({ sessionId: nastySession, message: nastyMessage })); const script = mockExecFile.mock.calls[0][1][1] as string; @@ -172,9 +170,7 @@ describe("notifier-desktop integration", () => { it("action labels with special chars are escaped in AppleScript", async () => { const notifier = desktopPlugin.create(); - const actions: NotifyAction[] = [ - { label: 'Fix "bug"', url: "https://example.com" }, - ]; + const actions: NotifyAction[] = [{ label: 'Fix "bug"', url: "https://example.com" }]; await notifier.notifyWithActions!(makeEvent(), actions); diff --git a/packages/integration-tests/src/notifier-webhook.integration.test.ts b/packages/integration-tests/src/notifier-webhook.integration.test.ts index 25a910930..72eba59f0 100644 --- a/packages/integration-tests/src/notifier-webhook.integration.test.ts +++ b/packages/integration-tests/src/notifier-webhook.integration.test.ts @@ -118,8 +118,16 @@ describe("notifier-webhook integration", () => { vi.useFakeTimers(); const fetchMock = vi .fn() - .mockResolvedValueOnce({ ok: false, status: 503, text: () => Promise.resolve("unavailable") }) - .mockResolvedValueOnce({ ok: false, status: 503, text: () => Promise.resolve("unavailable") }) + .mockResolvedValueOnce({ + ok: false, + status: 503, + text: () => Promise.resolve("unavailable"), + }) + .mockResolvedValueOnce({ + ok: false, + status: 503, + text: () => Promise.resolve("unavailable"), + }) .mockResolvedValueOnce({ ok: true }); vi.stubGlobal("fetch", fetchMock); @@ -206,7 +214,11 @@ describe("notifier-webhook integration", () => { it("429 is retried", async () => { const fetchMock = vi .fn() - .mockResolvedValueOnce({ ok: false, status: 429, text: () => Promise.resolve("rate limited") }) + .mockResolvedValueOnce({ + ok: false, + status: 429, + text: () => Promise.resolve("rate limited"), + }) .mockResolvedValueOnce({ ok: true }); vi.stubGlobal("fetch", fetchMock); diff --git a/packages/integration-tests/src/runtime-process.integration.test.ts b/packages/integration-tests/src/runtime-process.integration.test.ts index dcb04a9c7..083665303 100644 --- a/packages/integration-tests/src/runtime-process.integration.test.ts +++ b/packages/integration-tests/src/runtime-process.integration.test.ts @@ -11,7 +11,9 @@ describe("runtime-process (integration)", () => { afterAll(async () => { try { await runtime.destroy(handle); - } catch { /* best-effort cleanup */ } + } catch { + /* best-effort cleanup */ + } }, 30_000); it("creates a child process", async () => { @@ -61,9 +63,9 @@ describe("runtime-process (integration)", () => { }); it("sendMessage throws for unknown session", async () => { - await expect(runtime.sendMessage({ id: "nonexistent", runtimeName: "process", data: {} }, "hi")).rejects.toThrow( - "No process found", - ); + await expect( + runtime.sendMessage({ id: "nonexistent", runtimeName: "process", data: {} }, "hi"), + ).rejects.toThrow("No process found"); }); it("destroy kills the process", async () => { @@ -78,7 +80,9 @@ describe("runtime-process (integration)", () => { }); it("isAlive returns false for unknown session", async () => { - expect(await runtime.isAlive({ id: "nonexistent", runtimeName: "process", data: {} })).toBe(false); + expect(await runtime.isAlive({ id: "nonexistent", runtimeName: "process", data: {} })).toBe( + false, + ); }); it("destroy is idempotent", async () => { diff --git a/packages/integration-tests/src/runtime-tmux.integration.test.ts b/packages/integration-tests/src/runtime-tmux.integration.test.ts index b5d40399a..586f68679 100644 --- a/packages/integration-tests/src/runtime-tmux.integration.test.ts +++ b/packages/integration-tests/src/runtime-tmux.integration.test.ts @@ -19,7 +19,9 @@ describe.skipIf(!tmuxOk)("runtime-tmux (integration)", () => { afterAll(async () => { try { await runtime.destroy(handle); - } catch { /* best-effort cleanup */ } + } catch { + /* best-effort cleanup */ + } await killSessionsByPrefix(SESSION_PREFIX); }, 30_000); diff --git a/packages/integration-tests/src/terminal-iterm2.integration.test.ts b/packages/integration-tests/src/terminal-iterm2.integration.test.ts index 9cc816fde..4fa3b9878 100644 --- a/packages/integration-tests/src/terminal-iterm2.integration.test.ts +++ b/packages/integration-tests/src/terminal-iterm2.integration.test.ts @@ -183,10 +183,7 @@ describe("terminal-iterm2 integration", () => { it("opens tabs for each session with delay between operations", async () => { simulateOsascript("NOT_FOUND\n"); const terminal = iterm2Plugin.create(); - const sessions = [ - makeSession({ id: "s1" }), - makeSession({ id: "s2" }), - ]; + const sessions = [makeSession({ id: "s1" }), makeSession({ id: "s2" })]; const promise = terminal.openAll(sessions); @@ -203,10 +200,7 @@ describe("terminal-iterm2 integration", () => { it("skips existing sessions and opens only new ones", async () => { simulateOsascriptSequence(["FOUND\n", "NOT_FOUND\n", ""]); const terminal = iterm2Plugin.create(); - const sessions = [ - makeSession({ id: "existing" }), - makeSession({ id: "new-one" }), - ]; + const sessions = [makeSession({ id: "existing" }), makeSession({ id: "new-one" })]; const promise = terminal.openAll(sessions); await vi.advanceTimersByTimeAsync(300); diff --git a/packages/integration-tests/src/terminal-web.integration.test.ts b/packages/integration-tests/src/terminal-web.integration.test.ts index 4b5458807..db1914846 100644 --- a/packages/integration-tests/src/terminal-web.integration.test.ts +++ b/packages/integration-tests/src/terminal-web.integration.test.ts @@ -79,17 +79,10 @@ describe("terminal-web integration", () => { it("openAll logs total count and dashboard URL", async () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const terminal = webPlugin.create({ dashboardUrl: "https://dashboard.io" }); - await terminal.openAll([ - makeSession({ id: "x" }), - makeSession({ id: "y" }), - ]); + await terminal.openAll([makeSession({ id: "x" }), makeSession({ id: "y" })]); - expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining("2 sessions"), - ); - expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining("https://dashboard.io/sessions"), - ); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("2 sessions")); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("https://dashboard.io/sessions")); }); }); diff --git a/packages/integration-tests/src/tracker-linear.integration.test.ts b/packages/integration-tests/src/tracker-linear.integration.test.ts index a0aa22a07..5b5f41dd5 100644 --- a/packages/integration-tests/src/tracker-linear.integration.test.ts +++ b/packages/integration-tests/src/tracker-linear.integration.test.ts @@ -41,10 +41,7 @@ const canRun = hasCredentials && Boolean(LINEAR_TEAM_ID); * Direct GraphQL call for test setup/cleanup. * Only available when LINEAR_API_KEY is set. */ -function linearGraphQL( - query: string, - variables: Record, -): Promise { +function linearGraphQL(query: string, variables: Record): Promise { if (!LINEAR_API_KEY) { throw new Error("linearGraphQL requires LINEAR_API_KEY"); } @@ -127,8 +124,7 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { const result = await tracker.createIssue!( { title: `[AO Integration Test] ${new Date().toISOString()}`, - description: - "Automated integration test issue. Safe to delete if found lingering.", + description: "Automated integration test issue. Safe to delete if found lingering.", priority: 4, // Low }, project, @@ -167,11 +163,7 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { ); } else { // Composio-only: best-effort close via plugin - await tracker.updateIssue!( - issueIdentifier, - { state: "closed" }, - project, - ); + await tracker.updateIssue!(issueIdentifier, { state: "closed" }, project); } } catch { // Best-effort cleanup @@ -226,10 +218,7 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { }); it("listIssues includes the created issue", async () => { - const issues = await tracker.listIssues!( - { state: "open", limit: 50 }, - project, - ); + const issues = await tracker.listIssues!({ state: "open", limit: 50 }, project); const found = issues.find((i: { id: string }) => i.id === issueIdentifier); expect(found).toBeDefined(); @@ -237,11 +226,7 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { }); it("updateIssue adds a comment", async () => { - await tracker.updateIssue!( - issueIdentifier, - { comment: "Integration test comment" }, - project, - ); + await tracker.updateIssue!(issueIdentifier, { comment: "Integration test comment" }, project); // Verify the comment was added — use direct API if available, // otherwise trust the plugin didn't throw @@ -263,11 +248,7 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { }); it("updateIssue closes the issue and isCompleted reflects it", async () => { - await tracker.updateIssue!( - issueIdentifier, - { state: "closed" }, - project, - ); + await tracker.updateIssue!(issueIdentifier, { state: "closed" }, project); const completed = await tracker.isCompleted(issueIdentifier, project); expect(completed).toBe(true); @@ -277,11 +258,7 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { }); it("updateIssue reopens the issue", async () => { - await tracker.updateIssue!( - issueIdentifier, - { state: "open" }, - project, - ); + await tracker.updateIssue!(issueIdentifier, { state: "open" }, project); const completed = await tracker.isCompleted(issueIdentifier, project); expect(completed).toBe(false); diff --git a/packages/integration-tests/src/workspace-worktree.integration.test.ts b/packages/integration-tests/src/workspace-worktree.integration.test.ts index 4910e6e05..3ea94d888 100644 --- a/packages/integration-tests/src/workspace-worktree.integration.test.ts +++ b/packages/integration-tests/src/workspace-worktree.integration.test.ts @@ -57,9 +57,12 @@ describe("workspace-worktree (integration)", () => { // Clean up worktrees first (must be done before removing repo) try { await git(repoDir, "worktree", "prune"); - } catch { /* best-effort cleanup */ } + } catch { + /* best-effort cleanup */ + } if (repoDir) await rm(repoDir, { recursive: true, force: true }).catch(() => {}); - if (worktreeBaseDir) await rm(worktreeBaseDir, { recursive: true, force: true }).catch(() => {}); + if (worktreeBaseDir) + await rm(worktreeBaseDir, { recursive: true, force: true }).catch(() => {}); }, 30_000); it("creates a worktree workspace", async () => { diff --git a/packages/plugins/agent-aider/src/index.ts b/packages/plugins/agent-aider/src/index.ts index 5c40066da..772c87b5d 100644 --- a/packages/plugins/agent-aider/src/index.ts +++ b/packages/plugins/agent-aider/src/index.ts @@ -135,13 +135,11 @@ function createAiderAgent(): Agent { async isProcessRunning(handle: RuntimeHandle): Promise { try { if (handle.runtimeName === "tmux" && handle.id) { - const { stdout: ttyOut } = await execFileAsync("tmux", [ - "list-panes", - "-t", - handle.id, - "-F", - "#{pane_tty}", - ], { timeout: 30_000 }); + const { stdout: ttyOut } = await execFileAsync( + "tmux", + ["list-panes", "-t", handle.id, "-F", "#{pane_tty}"], + { timeout: 30_000 }, + ); const ttys = ttyOut .trim() .split("\n") @@ -149,7 +147,9 @@ function createAiderAgent(): Agent { .filter(Boolean); if (ttys.length === 0) return false; - const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], { timeout: 30_000 }); + const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], { + timeout: 30_000, + }); const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, ""))); const processRe = /(?:^|\/)aider(?:\s|$)/; for (const line of psOut.split("\n")) { diff --git a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts index c8b668abf..e7bea0eb5 100644 --- a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts +++ b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts @@ -4,9 +4,7 @@ import { toClaudeProjectPath } from "../index.js"; describe("Claude Code Activity Detection", () => { describe("toClaudeProjectPath", () => { it("encodes paths correctly", () => { - expect(toClaudeProjectPath("/Users/dev/.worktrees/ao")).toBe( - "Users-dev--worktrees-ao", - ); + expect(toClaudeProjectPath("/Users/dev/.worktrees/ao")).toBe("Users-dev--worktrees-ao"); }); it("strips leading slash", () => { @@ -18,9 +16,7 @@ describe("Claude Code Activity Detection", () => { }); it("handles Windows paths", () => { - expect(toClaudeProjectPath("C:\\Users\\dev\\project")).toBe( - "C-Users-dev-project", - ); + expect(toClaudeProjectPath("C:\\Users\\dev\\project")).toBe("C-Users-dev-project"); }); }); diff --git a/packages/plugins/agent-claude-code/src/index.test.ts b/packages/plugins/agent-claude-code/src/index.test.ts index e7815a321..bb58fd26d 100644 --- a/packages/plugins/agent-claude-code/src/index.test.ts +++ b/packages/plugins/agent-claude-code/src/index.test.ts @@ -4,16 +4,15 @@ import type { Session, RuntimeHandle, AgentLaunchConfig } from "@composio/ao-cor // --------------------------------------------------------------------------- // Hoisted mocks — available inside vi.mock factories // --------------------------------------------------------------------------- -const { mockExecFileAsync, mockReaddir, mockReadFile, mockStat, mockOpen, mockHomedir } = vi.hoisted( - () => ({ +const { mockExecFileAsync, mockReaddir, mockReadFile, mockStat, mockOpen, mockHomedir } = + vi.hoisted(() => ({ mockExecFileAsync: vi.fn(), mockReaddir: vi.fn(), mockReadFile: vi.fn(), mockStat: vi.fn(), mockOpen: vi.fn(), mockHomedir: vi.fn(() => "/mock/home"), - }), -); + })); vi.mock("node:child_process", () => { const fn = Object.assign((..._args: unknown[]) => {}, { @@ -360,7 +359,9 @@ describe("detectActivity", () => { }); it("returns waiting_input for bypass permissions prompt", () => { - expect(agent.detectActivity("bypass all future permissions for this session\n")).toBe("waiting_input"); + expect(agent.detectActivity("bypass all future permissions for this session\n")).toBe( + "waiting_input", + ); }); it("returns active when queued message indicator is visible", () => { @@ -382,9 +383,13 @@ describe("detectActivity", () => { it("returns waiting_input when permission prompt follows historical activity", () => { // Permission prompt at the bottom should NOT be overridden by historical // "Reading"/"Thinking" output higher in the buffer. - expect(agent.detectActivity("Reading file src/index.ts\nThinking...\nDo you want to proceed?\n")).toBe("waiting_input"); + expect( + agent.detectActivity("Reading file src/index.ts\nThinking...\nDo you want to proceed?\n"), + ).toBe("waiting_input"); expect(agent.detectActivity("Searching codebase...\n(Y)es / (N)o\n")).toBe("waiting_input"); - expect(agent.detectActivity("Writing to out.ts\nbypass all future permissions for this session\n")).toBe("waiting_input"); + expect( + agent.detectActivity("Writing to out.ts\nbypass all future permissions for this session\n"), + ).toBe("waiting_input"); }); it("returns active for non-empty output with no special patterns", () => { @@ -668,19 +673,16 @@ describe("isProcessing", () => { const agent = create(); /** Helper to create a mock file handle from `open()` */ - function mockOpenWithContent( - content: string, - mtime: Date = new Date(), - ) { + function mockOpenWithContent(content: string, mtime: Date = new Date()) { const buf = Buffer.from(content, "utf-8"); const fh = { stat: vi.fn().mockResolvedValue({ size: buf.length, mtime }), - read: vi.fn().mockImplementation( - (buffer: Buffer, offset: number, length: number, position: number) => { + read: vi + .fn() + .mockImplementation((buffer: Buffer, offset: number, length: number, position: number) => { buf.copy(buffer, offset, position, position + length); return Promise.resolve({ bytesRead: length, buffer }); - }, - ), + }), close: vi.fn().mockResolvedValue(undefined), }; mockOpen.mockResolvedValue(fh); @@ -726,20 +728,22 @@ describe("isProcessing", () => { it("returns false when last type is assistant", async () => { const now = new Date(); - const content = [ - '{"type":"user","message":{"content":"fix bug"}}', - '{"type":"assistant","message":{"content":"I fixed it"}}', - ].join("\n") + "\n"; + const content = + [ + '{"type":"user","message":{"content":"fix bug"}}', + '{"type":"assistant","message":{"content":"I fixed it"}}', + ].join("\n") + "\n"; mockOpenWithContent(content, now); expect(await agent.isProcessing(makeSession())).toBe(false); }); it("returns false when last type is system", async () => { const now = new Date(); - const content = [ - '{"type":"user","message":{"content":"hello"}}', - '{"type":"system","summary":"session started"}', - ].join("\n") + "\n"; + const content = + [ + '{"type":"user","message":{"content":"hello"}}', + '{"type":"system","summary":"session started"}', + ].join("\n") + "\n"; mockOpenWithContent(content, now); expect(await agent.isProcessing(makeSession())).toBe(false); }); @@ -753,10 +757,11 @@ describe("isProcessing", () => { it("returns true when last type is progress and file is recent", async () => { const now = new Date(); - const content = [ - '{"type":"user","message":{"content":"do it"}}', - '{"type":"progress","status":"running tool"}', - ].join("\n") + "\n"; + const content = + [ + '{"type":"user","message":{"content":"do it"}}', + '{"type":"progress","status":"running tool"}', + ].join("\n") + "\n"; mockOpenWithContent(content, now); expect(await agent.isProcessing(makeSession())).toBe(true); }); diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index 4709ac003..1d92c81fc 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -372,13 +372,11 @@ async function findClaudeProcess(handle: RuntimeHandle): Promise try { // For tmux runtime, get the pane TTY and find claude on it if (handle.runtimeName === "tmux" && handle.id) { - const { stdout: ttyOut } = await execFileAsync("tmux", [ - "list-panes", - "-t", - handle.id, - "-F", - "#{pane_tty}", - ], { timeout: 30_000 }); + const { stdout: ttyOut } = await execFileAsync( + "tmux", + ["list-panes", "-t", handle.id, "-F", "#{pane_tty}"], + { timeout: 30_000 }, + ); // Iterate all pane TTYs (multi-pane sessions) — succeed on any match const ttys = ttyOut .trim() @@ -390,7 +388,9 @@ async function findClaudeProcess(handle: RuntimeHandle): Promise // Use `args` instead of `comm` so we can match the CLI name even when // the process runs via a wrapper (e.g. node, python). `comm` would // report "node" instead of "claude" in those cases. - const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], { timeout: 30_000 }); + const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], { + timeout: 30_000, + }); const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, ""))); // Match "claude" as a word boundary — prevents false positives on // names like "claude-code" or paths that merely contain the substring. diff --git a/packages/plugins/agent-codex/src/index.ts b/packages/plugins/agent-codex/src/index.ts index b21d9bbf8..ac421240d 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -89,13 +89,11 @@ function createCodexAgent(): Agent { async isProcessRunning(handle: RuntimeHandle): Promise { try { if (handle.runtimeName === "tmux" && handle.id) { - const { stdout: ttyOut } = await execFileAsync("tmux", [ - "list-panes", - "-t", - handle.id, - "-F", - "#{pane_tty}", - ], { timeout: 30_000 }); + const { stdout: ttyOut } = await execFileAsync( + "tmux", + ["list-panes", "-t", handle.id, "-F", "#{pane_tty}"], + { timeout: 30_000 }, + ); const ttys = ttyOut .trim() .split("\n") @@ -103,7 +101,9 @@ function createCodexAgent(): Agent { .filter(Boolean); if (ttys.length === 0) return false; - const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], { timeout: 30_000 }); + const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], { + timeout: 30_000, + }); const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, ""))); const processRe = /(?:^|\/)codex(?:\s|$)/; for (const line of psOut.split("\n")) { diff --git a/packages/plugins/agent-opencode/src/index.test.ts b/packages/plugins/agent-opencode/src/index.test.ts index 2a5337186..dd5de4c97 100644 --- a/packages/plugins/agent-opencode/src/index.test.ts +++ b/packages/plugins/agent-opencode/src/index.test.ts @@ -125,9 +125,7 @@ describe("getLaunchCommand", () => { }); it("combines prompt and model", () => { - const cmd = agent.getLaunchCommand( - makeLaunchConfig({ prompt: "Go", model: "gpt-4o" }), - ); + const cmd = agent.getLaunchCommand(makeLaunchConfig({ prompt: "Go", model: "gpt-4o" })); expect(cmd).toBe("opencode run 'Go' --model 'gpt-4o'"); }); diff --git a/packages/plugins/agent-opencode/src/index.ts b/packages/plugins/agent-opencode/src/index.ts index ae4f9a726..8a60969ac 100644 --- a/packages/plugins/agent-opencode/src/index.ts +++ b/packages/plugins/agent-opencode/src/index.ts @@ -83,13 +83,11 @@ function createOpenCodeAgent(): Agent { async isProcessRunning(handle: RuntimeHandle): Promise { try { if (handle.runtimeName === "tmux" && handle.id) { - const { stdout: ttyOut } = await execFileAsync("tmux", [ - "list-panes", - "-t", - handle.id, - "-F", - "#{pane_tty}", - ], { timeout: 30_000 }); + const { stdout: ttyOut } = await execFileAsync( + "tmux", + ["list-panes", "-t", handle.id, "-F", "#{pane_tty}"], + { timeout: 30_000 }, + ); const ttys = ttyOut .trim() .split("\n") @@ -97,7 +95,9 @@ function createOpenCodeAgent(): Agent { .filter(Boolean); if (ttys.length === 0) return false; - const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], { timeout: 30_000 }); + const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], { + timeout: 30_000, + }); const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, ""))); const processRe = /(?:^|\/)opencode(?:\s|$)/; for (const line of psOut.split("\n")) { diff --git a/packages/plugins/notifier-composio/src/index.test.ts b/packages/plugins/notifier-composio/src/index.test.ts index 1f1a0cbbc..a9a0ad212 100644 --- a/packages/plugins/notifier-composio/src/index.test.ts +++ b/packages/plugins/notifier-composio/src/index.test.ts @@ -80,7 +80,9 @@ describe("notifier-composio", () => { }); it("accepts gmail as defaultApp with emailTo", () => { - expect(() => create({ composioApiKey: "k", defaultApp: "gmail", emailTo: "a@b.com" })).not.toThrow(); + expect(() => + create({ composioApiKey: "k", defaultApp: "gmail", emailTo: "a@b.com" }), + ).not.toThrow(); }); it("throws when gmail is defaultApp without emailTo", () => { @@ -125,7 +127,11 @@ describe("notifier-composio", () => { }); it("calls GMAIL_SEND_EMAIL for gmail app", async () => { - const notifier = create({ composioApiKey: "k", defaultApp: "gmail", emailTo: "test@test.com" }); + const notifier = create({ + composioApiKey: "k", + defaultApp: "gmail", + emailTo: "test@test.com", + }); await notifier.notify(makeEvent()); expect(mockExecuteAction).toHaveBeenCalledWith( @@ -192,9 +198,7 @@ describe("notifier-composio", () => { it("includes URL actions as links", async () => { const notifier = create({ composioApiKey: "k" }); - const actions: NotifyAction[] = [ - { label: "View PR", url: "https://github.com/pull/42" }, - ]; + const actions: NotifyAction[] = [{ label: "View PR", url: "https://github.com/pull/42" }]; await notifier.notifyWithActions!(makeEvent(), actions); const callArgs = mockExecuteAction.mock.calls[0][0]; @@ -203,9 +207,7 @@ describe("notifier-composio", () => { it("renders callback-only actions without URL", async () => { const notifier = create({ composioApiKey: "k" }); - const actions: NotifyAction[] = [ - { label: "Restart", callbackEndpoint: "/api/restart" }, - ]; + const actions: NotifyAction[] = [{ label: "Restart", callbackEndpoint: "/api/restart" }]; await notifier.notifyWithActions!(makeEvent(), actions); const callArgs = mockExecuteAction.mock.calls[0][0]; @@ -267,9 +269,7 @@ describe("notifier-composio", () => { }); const notifier = create({ composioApiKey: "k" }); - await expect(notifier.notify(makeEvent())).rejects.toThrow( - "unknown error", - ); + await expect(notifier.notify(makeEvent())).rejects.toThrow("unknown error"); }); }); @@ -279,9 +279,7 @@ describe("notifier-composio", () => { const notifier = create(); await notifier.notify(makeEvent()); expect(mockExecuteAction).not.toHaveBeenCalled(); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("No composioApiKey"), - ); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No composioApiKey")); warnSpy.mockRestore(); }); }); diff --git a/packages/plugins/notifier-composio/src/index.ts b/packages/plugins/notifier-composio/src/index.ts index 631f6b5a7..85f438a0b 100644 --- a/packages/plugins/notifier-composio/src/index.ts +++ b/packages/plugins/notifier-composio/src/index.ts @@ -49,9 +49,7 @@ interface ComposioToolkit { * optional peer dependency — it may or may not be installed, and its * TypeScript types may not match our internal interface exactly. */ -async function loadComposioSDK( - apiKey: string, -): Promise { +async function loadComposioSDK(apiKey: string): Promise { try { // String literal import so vitest can intercept it for mocking. // The `as unknown as …` cast is safe because we validate the shape below. @@ -81,10 +79,7 @@ async function loadComposioSDK( function formatNotifyText(event: OrchestratorEvent): string { const emoji = PRIORITY_EMOJI[event.priority]; - const parts = [ - `${emoji} *${event.type}* — ${event.sessionId}`, - event.message, - ]; + const parts = [`${emoji} *${event.type}* — ${event.sessionId}`, event.message]; const prUrl = typeof event.data.prUrl === "string" ? event.data.prUrl : undefined; if (prUrl) { @@ -161,9 +156,7 @@ export function create(config?: Record): Notifier { } if (defaultApp === "gmail" && !emailTo) { - throw new Error( - "[notifier-composio] emailTo is required when defaultApp is \"gmail\"", - ); + throw new Error('[notifier-composio] emailTo is required when defaultApp is "gmail"'); } let client: ComposioToolkit | null | undefined = clientOverride; @@ -216,9 +209,17 @@ export function create(config?: Record): Notifier { const result = await Promise.race([ actionPromise, new Promise((_, reject) => { - timeoutSignal.addEventListener("abort", () => { - reject(new Error(`[notifier-composio] Composio API call timed out after ${timeoutMs / 1000}s`)); - }, { once: true }); + timeoutSignal.addEventListener( + "abort", + () => { + reject( + new Error( + `[notifier-composio] Composio API call timed out after ${timeoutMs / 1000}s`, + ), + ); + }, + { once: true }, + ); }), ]); @@ -261,11 +262,12 @@ export function create(config?: Record): Notifier { const channel = context?.channel ?? channelId ?? channelName; const toolSlug = APP_TOOL_SLUG[defaultApp]; - const args: Record = defaultApp === "gmail" - ? { to: emailTo ?? "", subject: GMAIL_SUBJECT, body: message } - : defaultApp === "discord" - ? { content: message, ...(channel ? { channel_id: channel } : {}) } - : { text: message, ...(channel ? { channel } : {}) }; + const args: Record = + defaultApp === "gmail" + ? { to: emailTo ?? "", subject: GMAIL_SUBJECT, body: message } + : defaultApp === "discord" + ? { content: message, ...(channel ? { channel_id: channel } : {}) } + : { text: message, ...(channel ? { channel } : {}) }; await executeWithTimeout(composio, toolSlug, args); return null; diff --git a/packages/plugins/notifier-slack/src/index.ts b/packages/plugins/notifier-slack/src/index.ts index 949ec505b..f505e486b 100644 --- a/packages/plugins/notifier-slack/src/index.ts +++ b/packages/plugins/notifier-slack/src/index.ts @@ -130,7 +130,6 @@ async function postToWebhook(webhookUrl: string, payload: Record): Notifier { const webhookUrl = config?.webhookUrl as string | undefined; const defaultChannel = config?.channel as string | undefined; diff --git a/packages/plugins/notifier-webhook/src/index.ts b/packages/plugins/notifier-webhook/src/index.ts index 2c42e5781..c3e8ab47f 100644 --- a/packages/plugins/notifier-webhook/src/index.ts +++ b/packages/plugins/notifier-webhook/src/index.ts @@ -101,7 +101,6 @@ function serializeEvent(event: OrchestratorEvent): WebhookPayload["event"] { }; } - export function create(config?: Record): Notifier { const url = config?.url as string | undefined; const rawHeaders = config?.headers; diff --git a/packages/plugins/runtime-process/src/__tests__/index.test.ts b/packages/plugins/runtime-process/src/__tests__/index.test.ts index 39c73375e..803853126 100644 --- a/packages/plugins/runtime-process/src/__tests__/index.test.ts +++ b/packages/plugins/runtime-process/src/__tests__/index.test.ts @@ -129,30 +129,28 @@ describe("create()", () => { expect(handle.id).toBe("my-session"); expect(handle.runtimeName).toBe("process"); - expect(handle.data).toEqual( - expect.objectContaining({ pid: 99999 }), - ); + expect(handle.data).toEqual(expect.objectContaining({ pid: 99999 })); }); it("rejects invalid session IDs with special characters", async () => { const runtime = create(); - await expect( - runtime.create(defaultConfig({ sessionId: "bad session!!" })), - ).rejects.toThrow(/Invalid session ID/); + await expect(runtime.create(defaultConfig({ sessionId: "bad session!!" }))).rejects.toThrow( + /Invalid session ID/, + ); }); it("rejects session ID with dots", async () => { const runtime = create(); - await expect( - runtime.create(defaultConfig({ sessionId: "bad.session" })), - ).rejects.toThrow(/Invalid session ID/); + await expect(runtime.create(defaultConfig({ sessionId: "bad.session" }))).rejects.toThrow( + /Invalid session ID/, + ); }); it("rejects session ID with spaces", async () => { const runtime = create(); - await expect( - runtime.create(defaultConfig({ sessionId: "bad session" })), - ).rejects.toThrow(/Invalid session ID/); + await expect(runtime.create(defaultConfig({ sessionId: "bad session" }))).rejects.toThrow( + /Invalid session ID/, + ); }); it("accepts valid session IDs with alphanumeric, hyphens, underscores", async () => { @@ -174,9 +172,9 @@ describe("create()", () => { // Second call with same ID should throw const child2 = createMockChild(); mockSpawn.mockReturnValue(child2); - await expect( - runtime.create(defaultConfig({ sessionId: "dup-session" })), - ).rejects.toThrow(/already exists/); + await expect(runtime.create(defaultConfig({ sessionId: "dup-session" }))).rejects.toThrow( + /already exists/, + ); }); it("cleans up on spawn error", async () => { @@ -205,9 +203,9 @@ describe("create()", () => { }); const runtime = create(); - await expect( - runtime.create(defaultConfig({ sessionId: "sync-fail" })), - ).rejects.toThrow(/Failed to spawn/); + await expect(runtime.create(defaultConfig({ sessionId: "sync-fail" }))).rejects.toThrow( + /Failed to spawn/, + ); // Slot should be freed — re-create should work const child = createMockChild(); @@ -254,9 +252,7 @@ describe("destroy()", () => { it("does not throw for unknown handle (no-op)", async () => { const runtime = create(); - await expect( - runtime.destroy(makeHandle("nonexistent")), - ).resolves.toBeUndefined(); + await expect(runtime.destroy(makeHandle("nonexistent"))).resolves.toBeUndefined(); }); it("does not attempt kill if process already exited", async () => { @@ -350,17 +346,14 @@ describe("sendMessage()", () => { await runtime.sendMessage(handle, "hello world"); - expect(child.stdin.write).toHaveBeenCalledWith( - "hello world\n", - expect.any(Function), - ); + expect(child.stdin.write).toHaveBeenCalledWith("hello world\n", expect.any(Function)); }); it("throws for unknown session", async () => { const runtime = create(); - await expect( - runtime.sendMessage(makeHandle("nonexistent"), "hello"), - ).rejects.toThrow(/No process found/); + await expect(runtime.sendMessage(makeHandle("nonexistent"), "hello")).rejects.toThrow( + /No process found/, + ); }); it("throws when stdin is not writable", async () => { @@ -371,9 +364,7 @@ describe("sendMessage()", () => { const runtime = create(); const handle = await runtime.create(defaultConfig()); - await expect( - runtime.sendMessage(handle, "hello"), - ).rejects.toThrow(/stdin not writable/); + await expect(runtime.sendMessage(handle, "hello")).rejects.toThrow(/stdin not writable/); }); it("rejects when stdin.write returns an error", async () => { @@ -386,9 +377,7 @@ describe("sendMessage()", () => { const runtime = create(); const handle = await runtime.create(defaultConfig()); - await expect( - runtime.sendMessage(handle, "hello"), - ).rejects.toThrow(/write EPIPE/); + await expect(runtime.sendMessage(handle, "hello")).rejects.toThrow(/write EPIPE/); }); }); diff --git a/packages/plugins/runtime-process/src/index.ts b/packages/plugins/runtime-process/src/index.ts index 1722ea899..45376cdcf 100644 --- a/packages/plugins/runtime-process/src/index.ts +++ b/packages/plugins/runtime-process/src/index.ts @@ -260,7 +260,12 @@ export function create(): Runtime { async getAttachInfo(handle: RuntimeHandle): Promise { const entry = processes.get(handle.id); - if (!entry || !entry.process || entry.process.exitCode !== null || entry.process.signalCode !== null) { + if ( + !entry || + !entry.process || + entry.process.exitCode !== null || + entry.process.signalCode !== null + ) { return { type: "process", target: "", diff --git a/packages/plugins/runtime-tmux/README.md b/packages/plugins/runtime-tmux/README.md index cf5fec5a2..4633be4ff 100644 --- a/packages/plugins/runtime-tmux/README.md +++ b/packages/plugins/runtime-tmux/README.md @@ -5,6 +5,7 @@ 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 @@ -26,6 +27,7 @@ const handle = await runtime.create({ ``` **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` @@ -39,6 +41,7 @@ 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` @@ -46,6 +49,7 @@ await runtime.sendMessage(handle, "Fix the test failure in auth.test.ts"); 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 @@ -86,6 +90,7 @@ const attachInfo = await runtime.getAttachInfo(handle); ## Security **Session ID validation:** + ```typescript const SAFE_SESSION_ID = /^[a-zA-Z0-9_-]+$/; ``` @@ -112,6 +117,7 @@ Tracks uptime (stored in RuntimeHandle.data.createdAt). 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 @@ -127,14 +133,18 @@ 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 @@ -147,12 +157,14 @@ tmux sessions keep running even if the orchestrator dies. Use `tmux list-session ## 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 diff --git a/packages/plugins/runtime-tmux/src/__tests__/index.test.ts b/packages/plugins/runtime-tmux/src/__tests__/index.test.ts index aba6df2b3..3f42baa7e 100644 --- a/packages/plugins/runtime-tmux/src/__tests__/index.test.ts +++ b/packages/plugins/runtime-tmux/src/__tests__/index.test.ts @@ -172,11 +172,7 @@ describe("runtime.create()", () => { ).rejects.toThrow('Failed to send launch command to session "fail-session"'); // Verify kill-session was called for cleanup - expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ - "kill-session", - "-t", - "fail-session", - ]); + expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", ["kill-session", "-t", "fail-session"]); }); it("rejects invalid session IDs with special characters", async () => { @@ -235,14 +231,7 @@ describe("runtime.create()", () => { // First call should not contain -e flags const firstCallArgs = mockExecFileCustom.mock.calls[0][1] as string[]; - expect(firstCallArgs).toEqual([ - "new-session", - "-d", - "-s", - "no-env", - "-c", - "/tmp/ws", - ]); + expect(firstCallArgs).toEqual(["new-session", "-d", "-s", "no-env", "-c", "/tmp/ws"]); }); }); @@ -255,11 +244,7 @@ describe("runtime.destroy()", () => { await runtime.destroy(handle); - expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ - "kill-session", - "-t", - "destroy-test", - ]); + expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", ["kill-session", "-t", "destroy-test"]); }); it("does not throw if session is already gone", async () => { @@ -484,11 +469,7 @@ describe("runtime.isAlive()", () => { const alive = await runtime.isAlive(handle); expect(alive).toBe(true); - expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ - "has-session", - "-t", - "alive-test", - ]); + expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", ["has-session", "-t", "alive-test"]); }); it("returns false when has-session fails", async () => { diff --git a/packages/plugins/runtime-tmux/src/index.ts b/packages/plugins/runtime-tmux/src/index.ts index a060751a4..19c51cdad 100644 --- a/packages/plugins/runtime-tmux/src/index.ts +++ b/packages/plugins/runtime-tmux/src/index.ts @@ -161,5 +161,4 @@ export function create(): Runtime { }; } - export default { manifest, create } satisfies PluginModule; diff --git a/packages/plugins/scm-github/src/index.ts b/packages/plugins/scm-github/src/index.ts index 66cff0340..e47321afd 100644 --- a/packages/plugins/scm-github/src/index.ts +++ b/packages/plugins/scm-github/src/index.ts @@ -76,10 +76,7 @@ function createGitHubSCM(): SCM { return { name: "github", - async detectPR( - session: Session, - project: ProjectConfig, - ): Promise { + async detectPR(session: Session, project: ProjectConfig): Promise { if (!session.branch) return null; const parts = project.repo.split("/"); @@ -172,32 +169,13 @@ function createGitHubSCM(): SCM { }, async mergePR(pr: PRInfo, method: MergeMethod = "squash"): Promise { - const flag = - method === "rebase" - ? "--rebase" - : method === "merge" - ? "--merge" - : "--squash"; + const flag = method === "rebase" ? "--rebase" : method === "merge" ? "--merge" : "--squash"; - await gh([ - "pr", - "merge", - String(pr.number), - "--repo", - repoFlag(pr), - flag, - "--delete-branch", - ]); + await gh(["pr", "merge", String(pr.number), "--repo", repoFlag(pr), flag, "--delete-branch"]); }, async closePR(pr: PRInfo): Promise { - await gh([ - "pr", - "close", - String(pr.number), - "--repo", - repoFlag(pr), - ]); + await gh(["pr", "close", String(pr.number), "--repo", repoFlag(pr)]); }, async getCIChecks(pr: PRInfo): Promise { @@ -285,9 +263,7 @@ function createGitHubSCM(): SCM { const hasFailing = checks.some((c) => c.status === "failed"); if (hasFailing) return "failing"; - const hasPending = checks.some( - (c) => c.status === "pending" || c.status === "running", - ); + const hasPending = checks.some((c) => c.status === "pending" || c.status === "running"); if (hasPending) return "pending"; // Only report passing if at least one check actually passed @@ -416,8 +392,7 @@ function createGitHubSCM(): SCM { }; } = JSON.parse(raw); - const threads = - data.data.repository.pullRequest.reviewThreads.nodes; + const threads = data.data.repository.pullRequest.reviewThreads.nodes; return threads .filter((t) => { diff --git a/packages/plugins/scm-github/test/index.test.ts b/packages/plugins/scm-github/test/index.test.ts index 6f041883d..9eb1c2314 100644 --- a/packages/plugins/scm-github/test/index.test.ts +++ b/packages/plugins/scm-github/test/index.test.ts @@ -241,7 +241,13 @@ describe("scm-github plugin", () => { describe("getCIChecks", () => { it("maps various check states correctly", async () => { mockGh([ - { name: "build", state: "SUCCESS", link: "https://ci/1", startedAt: "2025-01-01T00:00:00Z", completedAt: "2025-01-01T00:05:00Z" }, + { + name: "build", + state: "SUCCESS", + link: "https://ci/1", + startedAt: "2025-01-01T00:00:00Z", + completedAt: "2025-01-01T00:05:00Z", + }, { name: "lint", state: "FAILURE", link: "", startedAt: "", completedAt: "" }, { name: "deploy", state: "PENDING", link: "", startedAt: "", completedAt: "" }, { name: "e2e", state: "IN_PROGRESS", link: "", startedAt: "", completedAt: "" }, @@ -264,8 +270,8 @@ describe("scm-github plugin", () => { expect(checks[5].status).toBe("skipped"); expect(checks[6].status).toBe("failed"); expect(checks[7].status).toBe("pending"); - expect(checks[8].status).toBe("failed"); // CANCELLED - expect(checks[9].status).toBe("failed"); // ACTION_REQUIRED + expect(checks[8].status).toBe("failed"); // CANCELLED + expect(checks[9].status).toBe("failed"); // ACTION_REQUIRED }); it("throws on error (fail-closed)", async () => { @@ -339,10 +345,30 @@ describe("scm-github plugin", () => { it("maps review states correctly", async () => { mockGh({ reviews: [ - { author: { login: "alice" }, state: "APPROVED", body: "LGTM", submittedAt: "2025-01-01T00:00:00Z" }, - { author: { login: "bob" }, state: "CHANGES_REQUESTED", body: "Fix this", submittedAt: "2025-01-02T00:00:00Z" }, - { author: { login: "charlie" }, state: "COMMENTED", body: "", submittedAt: "2025-01-03T00:00:00Z" }, - { author: { login: "eve" }, state: "DISMISSED", body: "", submittedAt: "2025-01-04T00:00:00Z" }, + { + author: { login: "alice" }, + state: "APPROVED", + body: "LGTM", + submittedAt: "2025-01-01T00:00:00Z", + }, + { + author: { login: "bob" }, + state: "CHANGES_REQUESTED", + body: "Fix this", + submittedAt: "2025-01-02T00:00:00Z", + }, + { + author: { login: "charlie" }, + state: "COMMENTED", + body: "", + submittedAt: "2025-01-03T00:00:00Z", + }, + { + author: { login: "eve" }, + state: "DISMISSED", + body: "", + submittedAt: "2025-01-04T00:00:00Z", + }, { author: { login: "frank" }, state: "PENDING", body: "", submittedAt: null }, ], }); @@ -363,7 +389,9 @@ describe("scm-github plugin", () => { it('defaults to "unknown" author when missing', async () => { mockGh({ - reviews: [{ author: null, state: "APPROVED", body: "", submittedAt: "2025-01-01T00:00:00Z" }], + reviews: [ + { author: null, state: "APPROVED", body: "", submittedAt: "2025-01-01T00:00:00Z" }, + ], }); const reviews = await scm.getReviews(pr); expect(reviews[0].author).toBe("unknown"); @@ -439,8 +467,26 @@ describe("scm-github plugin", () => { it("returns only unresolved non-bot comments from GraphQL", async () => { mockGh( makeGraphQLThreads([ - { isResolved: false, id: "C1", author: "alice", body: "Fix line 10", path: "src/foo.ts", line: 10, url: "https://github.com/c/1", createdAt: "2025-01-01T00:00:00Z" }, - { isResolved: true, id: "C2", author: "bob", body: "Resolved one", path: "src/bar.ts", line: 20, url: "https://github.com/c/2", createdAt: "2025-01-02T00:00:00Z" }, + { + isResolved: false, + id: "C1", + author: "alice", + body: "Fix line 10", + path: "src/foo.ts", + line: 10, + url: "https://github.com/c/1", + createdAt: "2025-01-01T00:00:00Z", + }, + { + isResolved: true, + id: "C2", + author: "bob", + body: "Resolved one", + path: "src/bar.ts", + line: 20, + url: "https://github.com/c/2", + createdAt: "2025-01-02T00:00:00Z", + }, ]), ); @@ -452,9 +498,36 @@ describe("scm-github plugin", () => { it("filters out bot comments", async () => { mockGh( makeGraphQLThreads([ - { isResolved: false, id: "C1", author: "alice", body: "Fix this", path: "a.ts", line: 1, url: "u", createdAt: "2025-01-01T00:00:00Z" }, - { isResolved: false, id: "C2", author: "cursor[bot]", body: "Bot says", path: "a.ts", line: 2, url: "u", createdAt: "2025-01-01T00:00:00Z" }, - { isResolved: false, id: "C3", author: "codecov[bot]", body: "Coverage", path: "a.ts", line: 3, url: "u", createdAt: "2025-01-01T00:00:00Z" }, + { + isResolved: false, + id: "C1", + author: "alice", + body: "Fix this", + path: "a.ts", + line: 1, + url: "u", + createdAt: "2025-01-01T00:00:00Z", + }, + { + isResolved: false, + id: "C2", + author: "cursor[bot]", + body: "Bot says", + path: "a.ts", + line: 2, + url: "u", + createdAt: "2025-01-01T00:00:00Z", + }, + { + isResolved: false, + id: "C3", + author: "codecov[bot]", + body: "Coverage", + path: "a.ts", + line: 3, + url: "u", + createdAt: "2025-01-01T00:00:00Z", + }, ]), ); @@ -471,7 +544,16 @@ describe("scm-github plugin", () => { it("handles null path and line", async () => { mockGh( makeGraphQLThreads([ - { isResolved: false, id: "C1", author: "alice", body: "General comment", path: null, line: null, url: "u", createdAt: "2025-01-01T00:00:00Z" }, + { + isResolved: false, + id: "C1", + author: "alice", + body: "General comment", + path: null, + line: null, + url: "u", + createdAt: "2025-01-01T00:00:00Z", + }, ]), ); const comments = await scm.getPendingComments(pr); @@ -485,8 +567,26 @@ describe("scm-github plugin", () => { describe("getAutomatedComments", () => { it("returns bot comments filtered from all PR comments", async () => { mockGh([ - { id: 1, user: { login: "cursor[bot]" }, body: "Found a potential issue", path: "a.ts", line: 5, original_line: null, created_at: "2025-01-01T00:00:00Z", html_url: "u1" }, - { id: 2, user: { login: "alice" }, body: "Human comment", path: "a.ts", line: 1, original_line: null, created_at: "2025-01-01T00:00:00Z", html_url: "u2" }, + { + id: 1, + user: { login: "cursor[bot]" }, + body: "Found a potential issue", + path: "a.ts", + line: 5, + original_line: null, + created_at: "2025-01-01T00:00:00Z", + html_url: "u1", + }, + { + id: 2, + user: { login: "alice" }, + body: "Human comment", + path: "a.ts", + line: 1, + original_line: null, + created_at: "2025-01-01T00:00:00Z", + html_url: "u2", + }, ]); const comments = await scm.getAutomatedComments(pr); @@ -497,9 +597,36 @@ describe("scm-github plugin", () => { it("classifies severity from body content", async () => { mockGh([ - { id: 1, user: { login: "github-actions[bot]" }, body: "Error: build failed", path: "a.ts", line: 1, original_line: null, created_at: "2025-01-01T00:00:00Z", html_url: "u" }, - { id: 2, user: { login: "github-actions[bot]" }, body: "Warning: deprecated API", path: "a.ts", line: 2, original_line: null, created_at: "2025-01-01T00:00:00Z", html_url: "u" }, - { id: 3, user: { login: "github-actions[bot]" }, body: "Deployed to staging", path: "a.ts", line: 3, original_line: null, created_at: "2025-01-01T00:00:00Z", html_url: "u" }, + { + id: 1, + user: { login: "github-actions[bot]" }, + body: "Error: build failed", + path: "a.ts", + line: 1, + original_line: null, + created_at: "2025-01-01T00:00:00Z", + html_url: "u", + }, + { + id: 2, + user: { login: "github-actions[bot]" }, + body: "Warning: deprecated API", + path: "a.ts", + line: 2, + original_line: null, + created_at: "2025-01-01T00:00:00Z", + html_url: "u", + }, + { + id: 3, + user: { login: "github-actions[bot]" }, + body: "Deployed to staging", + path: "a.ts", + line: 3, + original_line: null, + created_at: "2025-01-01T00:00:00Z", + html_url: "u", + }, ]); const comments = await scm.getAutomatedComments(pr); @@ -511,7 +638,16 @@ describe("scm-github plugin", () => { it("returns empty when no bot comments", async () => { mockGh([ - { id: 1, user: { login: "alice" }, body: "Human comment", path: "a.ts", line: 1, original_line: null, created_at: "2025-01-01T00:00:00Z", html_url: "u" }, + { + id: 1, + user: { login: "alice" }, + body: "Human comment", + path: "a.ts", + line: 1, + original_line: null, + created_at: "2025-01-01T00:00:00Z", + html_url: "u", + }, ]); const comments = await scm.getAutomatedComments(pr); @@ -525,7 +661,16 @@ describe("scm-github plugin", () => { it("uses original_line as fallback", async () => { mockGh([ - { id: 1, user: { login: "dependabot[bot]" }, body: "Suggest update", path: "a.ts", line: null, original_line: 15, created_at: "2025-01-01T00:00:00Z", html_url: "u" }, + { + id: 1, + user: { login: "dependabot[bot]" }, + body: "Suggest update", + path: "a.ts", + line: null, + original_line: 15, + created_at: "2025-01-01T00:00:00Z", + html_url: "u", + }, ]); const comments = await scm.getAutomatedComments(pr); @@ -570,7 +715,12 @@ describe("scm-github plugin", () => { // getPRState call (for open PR) mockGh({ state: "OPEN" }); // PR view - mockGh({ mergeable: "MERGEABLE", reviewDecision: "APPROVED", mergeStateStatus: "CLEAN", isDraft: false }); + mockGh({ + mergeable: "MERGEABLE", + reviewDecision: "APPROVED", + mergeStateStatus: "CLEAN", + isDraft: false, + }); // CI checks (called by getCISummary) mockGh([{ name: "build", state: "SUCCESS" }]); @@ -586,7 +736,12 @@ describe("scm-github plugin", () => { it("reports CI failures as blockers", async () => { mockGh({ state: "OPEN" }); // getPRState - mockGh({ mergeable: "MERGEABLE", reviewDecision: "APPROVED", mergeStateStatus: "UNSTABLE", isDraft: false }); + mockGh({ + mergeable: "MERGEABLE", + reviewDecision: "APPROVED", + mergeStateStatus: "UNSTABLE", + isDraft: false, + }); mockGh([{ name: "build", state: "FAILURE" }]); const result = await scm.getMergeability(pr); @@ -598,7 +753,12 @@ describe("scm-github plugin", () => { it("reports UNSTABLE merge state even when CI fetch fails", async () => { mockGh({ state: "OPEN" }); // getPRState - mockGh({ mergeable: "MERGEABLE", reviewDecision: "APPROVED", mergeStateStatus: "UNSTABLE", isDraft: false }); + mockGh({ + mergeable: "MERGEABLE", + reviewDecision: "APPROVED", + mergeStateStatus: "UNSTABLE", + isDraft: false, + }); mockGhError("rate limited"); const result = await scm.getMergeability(pr); @@ -610,7 +770,12 @@ describe("scm-github plugin", () => { it("reports changes requested as blockers", async () => { mockGh({ state: "OPEN" }); // getPRState - mockGh({ mergeable: "MERGEABLE", reviewDecision: "CHANGES_REQUESTED", mergeStateStatus: "CLEAN", isDraft: false }); + mockGh({ + mergeable: "MERGEABLE", + reviewDecision: "CHANGES_REQUESTED", + mergeStateStatus: "CLEAN", + isDraft: false, + }); mockGh([]); // no CI checks const result = await scm.getMergeability(pr); @@ -620,7 +785,12 @@ describe("scm-github plugin", () => { it("reports review required as blocker", async () => { mockGh({ state: "OPEN" }); // getPRState - mockGh({ mergeable: "MERGEABLE", reviewDecision: "REVIEW_REQUIRED", mergeStateStatus: "BLOCKED", isDraft: false }); + mockGh({ + mergeable: "MERGEABLE", + reviewDecision: "REVIEW_REQUIRED", + mergeStateStatus: "BLOCKED", + isDraft: false, + }); mockGh([]); const result = await scm.getMergeability(pr); @@ -629,7 +799,12 @@ describe("scm-github plugin", () => { it("reports merge conflicts as blockers", async () => { mockGh({ state: "OPEN" }); // getPRState - mockGh({ mergeable: "CONFLICTING", reviewDecision: "APPROVED", mergeStateStatus: "DIRTY", isDraft: false }); + mockGh({ + mergeable: "CONFLICTING", + reviewDecision: "APPROVED", + mergeStateStatus: "DIRTY", + isDraft: false, + }); mockGh([]); const result = await scm.getMergeability(pr); @@ -639,7 +814,12 @@ describe("scm-github plugin", () => { it("reports UNKNOWN mergeable as noConflicts false", async () => { mockGh({ state: "OPEN" }); // getPRState - mockGh({ mergeable: "UNKNOWN", reviewDecision: "APPROVED", mergeStateStatus: "CLEAN", isDraft: false }); + mockGh({ + mergeable: "UNKNOWN", + reviewDecision: "APPROVED", + mergeStateStatus: "CLEAN", + isDraft: false, + }); mockGh([{ name: "build", state: "SUCCESS" }]); const result = await scm.getMergeability(pr); @@ -650,7 +830,12 @@ describe("scm-github plugin", () => { it("reports draft status as blocker", async () => { mockGh({ state: "OPEN" }); // getPRState - mockGh({ mergeable: "MERGEABLE", reviewDecision: "APPROVED", mergeStateStatus: "DRAFT", isDraft: true }); + mockGh({ + mergeable: "MERGEABLE", + reviewDecision: "APPROVED", + mergeStateStatus: "DRAFT", + isDraft: true, + }); mockGh([{ name: "build", state: "SUCCESS" }]); const result = await scm.getMergeability(pr); @@ -660,7 +845,12 @@ describe("scm-github plugin", () => { it("reports multiple blockers simultaneously", async () => { mockGh({ state: "OPEN" }); // getPRState - mockGh({ mergeable: "CONFLICTING", reviewDecision: "CHANGES_REQUESTED", mergeStateStatus: "DIRTY", isDraft: true }); + mockGh({ + mergeable: "CONFLICTING", + reviewDecision: "CHANGES_REQUESTED", + mergeStateStatus: "DIRTY", + isDraft: true, + }); mockGh([{ name: "build", state: "FAILURE" }]); const result = await scm.getMergeability(pr); diff --git a/packages/plugins/tracker-github/src/index.ts b/packages/plugins/tracker-github/src/index.ts index df6ae59e4..f5b007e68 100644 --- a/packages/plugins/tracker-github/src/index.ts +++ b/packages/plugins/tracker-github/src/index.ts @@ -36,10 +36,7 @@ async function gh(args: string[]): Promise { } } -function mapState( - ghState: string, - stateReason?: string | null, -): Issue["state"] { +function mapState(ghState: string, stateReason?: string | null): Issue["state"] { const s = ghState.toUpperCase(); if (s === "CLOSED") { if (stateReason?.toUpperCase() === "NOT_PLANNED") return "cancelled"; @@ -56,10 +53,7 @@ function createGitHubTracker(): Tracker { return { name: "github", - async getIssue( - identifier: string, - project: ProjectConfig, - ): Promise { + async getIssue(identifier: string, project: ProjectConfig): Promise { const raw = await gh([ "issue", "view", @@ -92,10 +86,7 @@ function createGitHubTracker(): Tracker { }; }, - async isCompleted( - identifier: string, - project: ProjectConfig, - ): Promise { + async isCompleted(identifier: string, project: ProjectConfig): Promise { const raw = await gh([ "issue", "view", @@ -132,10 +123,7 @@ function createGitHubTracker(): Tracker { return `feat/issue-${num}`; }, - async generatePrompt( - identifier: string, - project: ProjectConfig, - ): Promise { + async generatePrompt(identifier: string, project: ProjectConfig): Promise { const issue = await this.getIssue(identifier, project); const lines = [ `You are working on GitHub issue #${issue.id}: ${issue.title}`, @@ -159,10 +147,7 @@ function createGitHubTracker(): Tracker { return lines.join("\n"); }, - async listIssues( - filters: IssueFilters, - project: ProjectConfig, - ): Promise { + async listIssues(filters: IssueFilters, project: ProjectConfig): Promise { const args = [ "issue", "list", @@ -221,21 +206,9 @@ function createGitHubTracker(): Tracker { // Handle state change — GitHub Issues only supports open/closed. // "in_progress" is not a GitHub state, so it is intentionally a no-op. if (update.state === "closed") { - await gh([ - "issue", - "close", - identifier, - "--repo", - project.repo, - ]); + await gh(["issue", "close", identifier, "--repo", project.repo]); } else if (update.state === "open") { - await gh([ - "issue", - "reopen", - identifier, - "--repo", - project.repo, - ]); + await gh(["issue", "reopen", identifier, "--repo", project.repo]); } // Handle label changes @@ -278,10 +251,7 @@ function createGitHubTracker(): Tracker { } }, - async createIssue( - input: CreateIssueInput, - project: ProjectConfig, - ): Promise { + async createIssue(input: CreateIssueInput, project: ProjectConfig): Promise { const args = [ "issue", "create", diff --git a/packages/plugins/tracker-github/test/index.test.ts b/packages/plugins/tracker-github/test/index.test.ts index bcf6ffb70..ab682a577 100644 --- a/packages/plugins/tracker-github/test/index.test.ts +++ b/packages/plugins/tracker-github/test/index.test.ts @@ -336,7 +336,11 @@ describe("tracker-github plugin", () => { it("handles multiple updates in one call", async () => { ghMock.mockResolvedValue({ stdout: "" }); - await tracker.updateIssue!("123", { state: "closed", labels: ["done"], comment: "Done!" }, project); + await tracker.updateIssue!( + "123", + { state: "closed", labels: ["done"], comment: "Done!" }, + project, + ); // Should have called gh 3 times: close + edit labels + comment expect(ghMock).toHaveBeenCalledTimes(3); }); @@ -360,7 +364,10 @@ describe("tracker-github plugin", () => { assignees: [], }); - const issue = await tracker.createIssue!({ title: "New issue", description: "Description" }, project); + const issue = await tracker.createIssue!( + { title: "New issue", description: "Description" }, + project, + ); expect(issue).toMatchObject({ id: "999", title: "New issue", state: "open" }); }); @@ -377,7 +384,10 @@ describe("tracker-github plugin", () => { assignees: [{ login: "alice" }], }); - await tracker.createIssue!({ title: "Bug", description: "Crash", labels: ["bug"], assignee: "alice" }, project); + await tracker.createIssue!( + { title: "Bug", description: "Crash", labels: ["bug"], assignee: "alice" }, + project, + ); expect(ghMock).toHaveBeenCalledWith( "gh", expect.arrayContaining(["issue", "create", "--label", "bug", "--assignee", "alice"]), diff --git a/packages/plugins/tracker-linear/src/composio-core.d.ts b/packages/plugins/tracker-linear/src/composio-core.d.ts index 806876d8f..78fdc6aff 100644 --- a/packages/plugins/tracker-linear/src/composio-core.d.ts +++ b/packages/plugins/tracker-linear/src/composio-core.d.ts @@ -10,10 +10,7 @@ declare module "@composio/core" { } interface ComposioTools { - execute( - action: string, - params: Record, - ): Promise; + execute(action: string, params: Record): Promise; } export class Composio { diff --git a/packages/plugins/tracker-linear/src/index.ts b/packages/plugins/tracker-linear/src/index.ts index 6d1753c29..5f114f3f4 100644 --- a/packages/plugins/tracker-linear/src/index.ts +++ b/packages/plugins/tracker-linear/src/index.ts @@ -28,10 +28,7 @@ import type { Composio } from "@composio/core"; * A function that sends a GraphQL query/mutation and returns the parsed data. * Both the direct Linear API and Composio SDK transports implement this. */ -type GraphQLTransport = ( - query: string, - variables?: Record, -) => Promise; +type GraphQLTransport = (query: string, variables?: Record) => Promise; // --------------------------------------------------------------------------- // Direct Linear API transport @@ -131,10 +128,7 @@ function createDirectTransport(): GraphQLTransport { type ComposioTools = Composio["tools"]; -function createComposioTransport( - apiKey: string, - entityId: string, -): GraphQLTransport { +function createComposioTransport(apiKey: string, entityId: string): GraphQLTransport { // Lazy-load the Composio client — cached as a promise so the constructor // is called only once, even under concurrent requests. let clientPromise: Promise | undefined; @@ -148,10 +142,14 @@ function createComposioTransport( return client.tools; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); - if (msg.includes("Cannot find module") || msg.includes("Cannot find package") || msg.includes("ERR_MODULE_NOT_FOUND")) { + if ( + msg.includes("Cannot find module") || + msg.includes("Cannot find package") || + msg.includes("ERR_MODULE_NOT_FOUND") + ) { throw new Error( "Composio SDK (@composio/core) is not installed. " + - "Install it with: pnpm add @composio/core", + "Install it with: pnpm add @composio/core", { cause: err }, ); } @@ -276,10 +274,7 @@ function createLinearTracker(query: GraphQLTransport): Tracker { return { name: "linear", - async getIssue( - identifier: string, - _project: ProjectConfig, - ): Promise { + async getIssue(identifier: string, _project: ProjectConfig): Promise { const data = await query<{ issue: LinearIssueNode }>( `query($id: String!) { issue(id: $id) { @@ -302,10 +297,7 @@ function createLinearTracker(query: GraphQLTransport): Tracker { }; }, - async isCompleted( - identifier: string, - _project: ProjectConfig, - ): Promise { + async isCompleted(identifier: string, _project: ProjectConfig): Promise { const data = await query<{ issue: { state: { type: string } } }>( `query($id: String!) { issue(id: $id) { @@ -348,10 +340,7 @@ function createLinearTracker(query: GraphQLTransport): Tracker { return `feat/${identifier}`; }, - async generatePrompt( - identifier: string, - project: ProjectConfig, - ): Promise { + async generatePrompt(identifier: string, project: ProjectConfig): Promise { const issue = await this.getIssue(identifier, project); const lines = [ `You are working on Linear ticket ${issue.id}: ${issue.title}`, @@ -386,10 +375,7 @@ function createLinearTracker(query: GraphQLTransport): Tracker { return lines.join("\n"); }, - async listIssues( - filters: IssueFilters, - project: ProjectConfig, - ): Promise { + async listIssues(filters: IssueFilters, project: ProjectConfig): Promise { // Build filter object using GraphQL variables to prevent injection const filter: Record = {}; const variables: Record = {}; @@ -486,14 +472,10 @@ function createLinearTracker(query: GraphQLTransport): Tracker { ? "unstarted" : "started"; - const targetState = statesData.workflowStates.nodes.find( - (s) => s.type === targetType, - ); + const targetState = statesData.workflowStates.nodes.find((s) => s.type === targetType); if (!targetState) { - throw new Error( - `No workflow state of type "${targetType}" found for team ${teamId}`, - ); + throw new Error(`No workflow state of type "${targetType}" found for team ${teamId}`); } await query( @@ -588,15 +570,10 @@ function createLinearTracker(query: GraphQLTransport): Tracker { } }, - async createIssue( - input: CreateIssueInput, - project: ProjectConfig, - ): Promise { + async createIssue(input: CreateIssueInput, project: ProjectConfig): Promise { const teamId = project.tracker?.["teamId"]; if (!teamId) { - throw new Error( - "Linear tracker requires 'teamId' in project tracker config", - ); + throw new Error("Linear tracker requires 'teamId' in project tracker config"); } const variables: Record = { diff --git a/packages/plugins/tracker-linear/test/composio-transport.test.ts b/packages/plugins/tracker-linear/test/composio-transport.test.ts index bf2e51249..817b45ae8 100644 --- a/packages/plugins/tracker-linear/test/composio-transport.test.ts +++ b/packages/plugins/tracker-linear/test/composio-transport.test.ts @@ -279,9 +279,7 @@ describe("tracker-linear Composio transport", () => { mockExecute.mockRejectedValueOnce(new Error("Network error")); const tracker = create(); - await expect(tracker.getIssue("INT-123", project)).rejects.toThrow( - "Network error", - ); + await expect(tracker.getIssue("INT-123", project)).rejects.toThrow("Network error"); }); }); @@ -324,7 +322,9 @@ describe("tracker-linear Composio transport", () => { // process the .catch() handler on timeoutPromise. Suppress the // transient unhandled rejection that vitest detects in that window. const suppressed: unknown[] = []; - const handler = (reason: unknown) => { suppressed.push(reason); }; + const handler = (reason: unknown) => { + suppressed.push(reason); + }; process.on("unhandledRejection", handler); try { @@ -338,9 +338,7 @@ describe("tracker-linear Composio transport", () => { // Advance timers past the 30s timeout await vi.advanceTimersByTimeAsync(30_001); - await expect(promise).rejects.toThrow( - "Composio Linear API request timed out after 30s", - ); + await expect(promise).rejects.toThrow("Composio Linear API request timed out after 30s"); } finally { process.removeListener("unhandledRejection", handler); vi.useRealTimers(); diff --git a/packages/plugins/tracker-linear/test/index.test.ts b/packages/plugins/tracker-linear/test/index.test.ts index 68469dd8f..65f010a1c 100644 --- a/packages/plugins/tracker-linear/test/index.test.ts +++ b/packages/plugins/tracker-linear/test/index.test.ts @@ -61,7 +61,10 @@ function mockLinearAPI(responseData: unknown, statusCode = 200) { const body = JSON.stringify({ data: responseData }); requestMock.mockImplementationOnce( - (_opts: Record, callback: (res: EventEmitter & { statusCode: number }) => void) => { + ( + _opts: Record, + callback: (res: EventEmitter & { statusCode: number }) => void, + ) => { const req = Object.assign(new EventEmitter(), { write: vi.fn(), end: vi.fn(() => { @@ -85,7 +88,10 @@ function mockLinearError(message: string) { const body = JSON.stringify({ errors: [{ message }] }); requestMock.mockImplementationOnce( - (_opts: Record, callback: (res: EventEmitter & { statusCode: number }) => void) => { + ( + _opts: Record, + callback: (res: EventEmitter & { statusCode: number }) => void, + ) => { const req = Object.assign(new EventEmitter(), { write: vi.fn(), end: vi.fn(() => { @@ -107,7 +113,10 @@ function mockLinearError(message: string) { /** Queue an HTTP-level error (non-200 status). */ function mockHTTPError(statusCode: number, body: string) { requestMock.mockImplementationOnce( - (_opts: Record, callback: (res: EventEmitter & { statusCode: number }) => void) => { + ( + _opts: Record, + callback: (res: EventEmitter & { statusCode: number }) => void, + ) => { const req = Object.assign(new EventEmitter(), { write: vi.fn(), end: vi.fn(() => { @@ -295,22 +304,16 @@ describe("tracker-linear plugin", () => { describe("issueUrl", () => { it("generates correct URL with workspace slug", () => { - expect(tracker.issueUrl("INT-123", project)).toBe( - "https://linear.app/acme/issue/INT-123", - ); + expect(tracker.issueUrl("INT-123", project)).toBe("https://linear.app/acme/issue/INT-123"); }); it("generates fallback URL without workspace slug", () => { - expect(tracker.issueUrl("INT-123", projectNoSlug)).toBe( - "https://linear.app/issue/INT-123", - ); + expect(tracker.issueUrl("INT-123", projectNoSlug)).toBe("https://linear.app/issue/INT-123"); }); it("generates fallback URL when no tracker config", () => { const noTracker: ProjectConfig = { ...project, tracker: undefined }; - expect(tracker.issueUrl("INT-123", noTracker)).toBe( - "https://linear.app/issue/INT-123", - ); + expect(tracker.issueUrl("INT-123", noTracker)).toBe("https://linear.app/issue/INT-123"); }); }); @@ -386,10 +389,7 @@ describe("tracker-linear plugin", () => { it("returns mapped issues", async () => { mockLinearAPI({ issues: { - nodes: [ - sampleIssueNode, - { ...sampleIssueNode, identifier: "INT-456", title: "Another" }, - ], + nodes: [sampleIssueNode, { ...sampleIssueNode, identifier: "INT-456", title: "Another" }], }, }); const issues = await tracker.listIssues!({}, project); @@ -543,20 +543,16 @@ describe("tracker-linear plugin", () => { }, }); - await expect( - tracker.updateIssue!("INT-123", { state: "closed" }, project), - ).rejects.toThrow('No workflow state of type "completed"'); + await expect(tracker.updateIssue!("INT-123", { state: "closed" }, project)).rejects.toThrow( + 'No workflow state of type "completed"', + ); }); it("adds a comment", async () => { mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } }); mockLinearAPI({ commentCreate: { success: true } }); - await tracker.updateIssue!( - "INT-123", - { comment: "Working on this" }, - project, - ); + await tracker.updateIssue!("INT-123", { comment: "Working on this" }, project); expect(requestMock).toHaveBeenCalledTimes(2); // Verify comment body @@ -575,11 +571,7 @@ describe("tracker-linear plugin", () => { // 4: commentCreate mockLinearAPI({ commentCreate: { success: true } }); - await tracker.updateIssue!( - "INT-123", - { state: "closed", comment: "Done!" }, - project, - ); + await tracker.updateIssue!("INT-123", { state: "closed", comment: "Done!" }, project); expect(requestMock).toHaveBeenCalledTimes(4); }); @@ -655,10 +647,7 @@ describe("tracker-linear plugin", () => { issueCreate: { success: true, issue: sampleIssueNode }, }); - await tracker.createIssue!( - { title: "Bug", description: "", priority: 1 }, - project, - ); + await tracker.createIssue!({ title: "Bug", description: "", priority: 1 }, project); const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0]; const body = JSON.parse(writeCall); @@ -749,9 +738,7 @@ describe("tracker-linear plugin", () => { // Only "bug" exists in Linear; "nonexistent" does not mockLinearAPI({ issueLabels: { - nodes: [ - { id: "label-1", name: "bug" }, - ], + nodes: [{ id: "label-1", name: "bug" }], }, }); mockLinearAPI({ issueUpdate: { success: true } }); @@ -769,9 +756,9 @@ describe("tracker-linear plugin", () => { ...project, tracker: { plugin: "linear" }, }; - await expect( - tracker.createIssue!({ title: "Bug", description: "" }, noTeam), - ).rejects.toThrow("teamId"); + await expect(tracker.createIssue!({ title: "Bug", description: "" }, noTeam)).rejects.toThrow( + "teamId", + ); }); it("handles assignee error gracefully (best-effort)", async () => { @@ -818,9 +805,7 @@ describe("tracker-linear plugin", () => { describe("linearQuery error handling", () => { it("throws on missing LINEAR_API_KEY", async () => { delete process.env["LINEAR_API_KEY"]; - await expect(tracker.getIssue("INT-123", project)).rejects.toThrow( - "LINEAR_API_KEY", - ); + await expect(tracker.getIssue("INT-123", project)).rejects.toThrow("LINEAR_API_KEY"); }); it("throws on GraphQL errors", async () => { @@ -840,7 +825,10 @@ describe("tracker-linear plugin", () => { it("throws on empty data response", async () => { const body = JSON.stringify({ data: null }); requestMock.mockImplementationOnce( - (_opts: Record, callback: (res: EventEmitter & { statusCode: number }) => void) => { + ( + _opts: Record, + callback: (res: EventEmitter & { statusCode: number }) => void, + ) => { const req = Object.assign(new EventEmitter(), { write: vi.fn(), end: vi.fn(() => { diff --git a/packages/plugins/workspace-clone/src/__tests__/index.test.ts b/packages/plugins/workspace-clone/src/__tests__/index.test.ts index 62be4069f..3dbaa6882 100644 --- a/packages/plugins/workspace-clone/src/__tests__/index.test.ts +++ b/packages/plugins/workspace-clone/src/__tests__/index.test.ts @@ -147,12 +147,9 @@ describe("workspace.create()", () => { }); // First call should be git remote get-url origin - expect(mockExecFileAsync).toHaveBeenNthCalledWith( - 1, - "git", - ["remote", "get-url", "origin"], - { cwd: "/repo/path" }, - ); + expect(mockExecFileAsync).toHaveBeenNthCalledWith(1, "git", ["remote", "get-url", "origin"], { + cwd: "/repo/path", + }); }); it("falls back to local path when remote URL lookup fails", async () => { @@ -252,12 +249,9 @@ describe("workspace.create()", () => { }); // Fourth call: plain checkout - expect(mockExecFileAsync).toHaveBeenNthCalledWith( - 4, - "git", - ["checkout", "feat/existing"], - { cwd: "/mock-home/.ao-clones/proj/sess" }, - ); + expect(mockExecFileAsync).toHaveBeenNthCalledWith(4, "git", ["checkout", "feat/existing"], { + cwd: "/mock-home/.ao-clones/proj/sess", + }); expect(info.branch).toBe("feat/existing"); }); @@ -446,12 +440,9 @@ describe("workspace.create()", () => { }); // git remote get-url should use expanded path - expect(mockExecFileAsync).toHaveBeenNthCalledWith( - 1, - "git", - ["remote", "get-url", "origin"], - { cwd: "/mock-home/my-repos/project" }, - ); + expect(mockExecFileAsync).toHaveBeenNthCalledWith(1, "git", ["remote", "get-url", "origin"], { + cwd: "/mock-home/my-repos/project", + }); }); }); @@ -527,18 +518,12 @@ describe("workspace.list()", () => { ]); // Verify git branch --show-current was called for each entry - expect(mockExecFileAsync).toHaveBeenNthCalledWith( - 1, - "git", - ["branch", "--show-current"], - { cwd: "/mock-home/.ao-clones/myproject/session-1" }, - ); - expect(mockExecFileAsync).toHaveBeenNthCalledWith( - 2, - "git", - ["branch", "--show-current"], - { cwd: "/mock-home/.ao-clones/myproject/session-2" }, - ); + expect(mockExecFileAsync).toHaveBeenNthCalledWith(1, "git", ["branch", "--show-current"], { + cwd: "/mock-home/.ao-clones/myproject/session-1", + }); + expect(mockExecFileAsync).toHaveBeenNthCalledWith(2, "git", ["branch", "--show-current"], { + cwd: "/mock-home/.ao-clones/myproject/session-2", + }); }); it("skips non-directory entries", async () => { @@ -592,17 +577,13 @@ describe("workspace.list()", () => { it("rejects invalid projectId with special characters", async () => { const workspace = create(); - await expect(workspace.list("bad/project")).rejects.toThrow( - 'Invalid projectId "bad/project"', - ); + await expect(workspace.list("bad/project")).rejects.toThrow('Invalid projectId "bad/project"'); }); it("rejects projectId with spaces", async () => { const workspace = create(); - await expect(workspace.list("bad project")).rejects.toThrow( - 'Invalid projectId "bad project"', - ); + await expect(workspace.list("bad project")).rejects.toThrow('Invalid projectId "bad project"'); }); }); diff --git a/packages/plugins/workspace-worktree/src/__tests__/index.test.ts b/packages/plugins/workspace-worktree/src/__tests__/index.test.ts index d8fa1451c..2a81ca030 100644 --- a/packages/plugins/workspace-worktree/src/__tests__/index.test.ts +++ b/packages/plugins/workspace-worktree/src/__tests__/index.test.ts @@ -148,11 +148,9 @@ describe("workspace.create()", () => { await ws.create(makeCreateConfig()); // First call: git fetch origin --quiet - expect(mockExecFileAsync).toHaveBeenCalledWith( - "git", - ["fetch", "origin", "--quiet"], - { cwd: "/repo/path" }, - ); + expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["fetch", "origin", "--quiet"], { + cwd: "/repo/path", + }); // Second call: git worktree add -b expect(mockExecFileAsync).toHaveBeenCalledWith( @@ -177,10 +175,9 @@ describe("workspace.create()", () => { await ws.create(makeCreateConfig()); - expect(mockMkdirSync).toHaveBeenCalledWith( - "/mock-home/.worktrees/myproject", - { recursive: true }, - ); + expect(mockMkdirSync).toHaveBeenCalledWith("/mock-home/.worktrees/myproject", { + recursive: true, + }); }); it("continues when fetch fails (offline)", async () => { @@ -207,21 +204,14 @@ describe("workspace.create()", () => { // Third call: worktree add without -b expect(mockExecFileAsync).toHaveBeenCalledWith( "git", - [ - "worktree", - "add", - "/mock-home/.worktrees/myproject/session-1", - "origin/main", - ], + ["worktree", "add", "/mock-home/.worktrees/myproject/session-1", "origin/main"], { cwd: "/repo/path" }, ); // Fourth call: checkout - expect(mockExecFileAsync).toHaveBeenCalledWith( - "git", - ["checkout", "feat/TEST-1"], - { cwd: "/mock-home/.worktrees/myproject/session-1" }, - ); + expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["checkout", "feat/TEST-1"], { + cwd: "/mock-home/.worktrees/myproject/session-1", + }); expect(info.branch).toBe("feat/TEST-1"); }); @@ -242,12 +232,7 @@ describe("workspace.create()", () => { // Verify cleanup was attempted expect(mockExecFileAsync).toHaveBeenCalledWith( "git", - [ - "worktree", - "remove", - "--force", - "/mock-home/.worktrees/myproject/session-1", - ], + ["worktree", "remove", "--force", "/mock-home/.worktrees/myproject/session-1"], { cwd: "/repo/path" }, ); }); @@ -280,33 +265,33 @@ describe("workspace.create()", () => { it("rejects invalid projectId", async () => { const ws = create(); - await expect( - ws.create(makeCreateConfig({ projectId: "bad/project" })), - ).rejects.toThrow('Invalid projectId "bad/project"'); + await expect(ws.create(makeCreateConfig({ projectId: "bad/project" }))).rejects.toThrow( + 'Invalid projectId "bad/project"', + ); }); it("rejects projectId with dots", async () => { const ws = create(); - await expect( - ws.create(makeCreateConfig({ projectId: "my.project" })), - ).rejects.toThrow('Invalid projectId "my.project"'); + await expect(ws.create(makeCreateConfig({ projectId: "my.project" }))).rejects.toThrow( + 'Invalid projectId "my.project"', + ); }); it("rejects invalid sessionId", async () => { const ws = create(); - await expect( - ws.create(makeCreateConfig({ sessionId: "../escape" })), - ).rejects.toThrow('Invalid sessionId "../escape"'); + await expect(ws.create(makeCreateConfig({ sessionId: "../escape" }))).rejects.toThrow( + 'Invalid sessionId "../escape"', + ); }); it("rejects sessionId with spaces", async () => { const ws = create(); - await expect( - ws.create(makeCreateConfig({ sessionId: "bad session" })), - ).rejects.toThrow('Invalid sessionId "bad session"'); + await expect(ws.create(makeCreateConfig({ sessionId: "bad session" }))).rejects.toThrow( + 'Invalid sessionId "bad session"', + ); }); it("returns correct WorkspaceInfo", async () => { @@ -338,11 +323,9 @@ describe("workspace.create()", () => { ); // fetch should use expanded path - expect(mockExecFileAsync).toHaveBeenCalledWith( - "git", - ["fetch", "origin", "--quiet"], - { cwd: "/mock-home/my-repo" }, - ); + expect(mockExecFileAsync).toHaveBeenCalledWith("git", ["fetch", "origin", "--quiet"], { + cwd: "/mock-home/my-repo", + }); }); }); @@ -380,10 +363,10 @@ describe("workspace.destroy()", () => { await ws.destroy("/mock-home/.worktrees/myproject/session-1"); - expect(mockRmSync).toHaveBeenCalledWith( - "/mock-home/.worktrees/myproject/session-1", - { recursive: true, force: true }, - ); + expect(mockRmSync).toHaveBeenCalledWith("/mock-home/.worktrees/myproject/session-1", { + recursive: true, + force: true, + }); }); it("does nothing if git fails and directory does not exist", async () => { @@ -466,9 +449,7 @@ describe("workspace.list()", () => { const ws = create(); mockExistsSync.mockReturnValueOnce(true); - mockReaddirSync.mockReturnValueOnce([ - { name: "session-1", isDirectory: () => true }, - ]); + mockReaddirSync.mockReturnValueOnce([{ name: "session-1", isDirectory: () => true }]); const porcelainOutput = [ "worktree /mock-home/.worktrees/myproject/session-1", @@ -488,9 +469,7 @@ describe("workspace.list()", () => { const ws = create(); mockExistsSync.mockReturnValueOnce(true); - mockReaddirSync.mockReturnValueOnce([ - { name: "session-1", isDirectory: () => true }, - ]); + mockReaddirSync.mockReturnValueOnce([{ name: "session-1", isDirectory: () => true }]); const porcelainOutput = [ "worktree /other/path/session-1", @@ -514,9 +493,7 @@ describe("workspace.list()", () => { const ws = create(); mockExistsSync.mockReturnValueOnce(true); - mockReaddirSync.mockReturnValueOnce([ - { name: "session-1", isDirectory: () => true }, - ]); + mockReaddirSync.mockReturnValueOnce([{ name: "session-1", isDirectory: () => true }]); mockGitError("fatal: not a git repository"); @@ -686,10 +663,9 @@ describe("workspace.postCreate()", () => { await ws.postCreate!(workspaceInfo, project); - expect(mockMkdirSync).toHaveBeenCalledWith( - "/mock-home/.worktrees/myproject/session-1/config", - { recursive: true }, - ); + expect(mockMkdirSync).toHaveBeenCalledWith("/mock-home/.worktrees/myproject/session-1/config", { + recursive: true, + }); }); it("runs postCreate commands", async () => { @@ -704,16 +680,12 @@ describe("workspace.postCreate()", () => { await ws.postCreate!(workspaceInfo, project); - expect(mockExecFileAsync).toHaveBeenCalledWith( - "sh", - ["-c", "pnpm install"], - { cwd: "/mock-home/.worktrees/myproject/session-1" }, - ); - expect(mockExecFileAsync).toHaveBeenCalledWith( - "sh", - ["-c", "pnpm build"], - { cwd: "/mock-home/.worktrees/myproject/session-1" }, - ); + expect(mockExecFileAsync).toHaveBeenCalledWith("sh", ["-c", "pnpm install"], { + cwd: "/mock-home/.worktrees/myproject/session-1", + }); + expect(mockExecFileAsync).toHaveBeenCalledWith("sh", ["-c", "pnpm build"], { + cwd: "/mock-home/.worktrees/myproject/session-1", + }); }); it("does nothing when no symlinks or postCreate configured", async () => { @@ -745,11 +717,9 @@ describe("workspace.postCreate()", () => { await ws.postCreate!(workspaceInfo, project); expect(mockSymlinkSync).toHaveBeenCalledTimes(1); - expect(mockExecFileAsync).toHaveBeenCalledWith( - "sh", - ["-c", "pnpm install"], - { cwd: "/mock-home/.worktrees/myproject/session-1" }, - ); + expect(mockExecFileAsync).toHaveBeenCalledWith("sh", ["-c", "pnpm install"], { + cwd: "/mock-home/.worktrees/myproject/session-1", + }); }); it("expands tilde in project path for symlink sources", async () => { diff --git a/packages/plugins/workspace-worktree/src/index.ts b/packages/plugins/workspace-worktree/src/index.ts index c16f8d4e5..92f80fafb 100644 --- a/packages/plugins/workspace-worktree/src/index.ts +++ b/packages/plugins/workspace-worktree/src/index.ts @@ -94,10 +94,9 @@ export function create(config?: Record): Workspace { } const checkoutMsg = checkoutErr instanceof Error ? checkoutErr.message : String(checkoutErr); - throw new Error( - `Failed to checkout branch "${cfg.branch}" in worktree: ${checkoutMsg}`, - { cause: checkoutErr }, - ); + throw new Error(`Failed to checkout branch "${cfg.branch}" in worktree: ${checkoutMsg}`, { + cause: checkoutErr, + }); } } diff --git a/packages/web/e2e/lib/browser.ts b/packages/web/e2e/lib/browser.ts index 7ba80a3c8..01c76a8bb 100644 --- a/packages/web/e2e/lib/browser.ts +++ b/packages/web/e2e/lib/browser.ts @@ -32,9 +32,10 @@ export async function captureScreenshots( const screenshotDir = join(new URL("../../", import.meta.url).pathname, "screenshots"); await mkdir(screenshotDir, { recursive: true }); - const pages: PageSpec[] = extraPaths.length > 0 - ? extraPaths.map((p) => ({ path: p, name: pathToName(p) })) - : DEFAULT_PAGES; + const pages: PageSpec[] = + extraPaths.length > 0 + ? extraPaths.map((p) => ({ path: p, name: pathToName(p) })) + : DEFAULT_PAGES; const browser = await chromium.launch({ headless: true }); const savedPaths: string[] = []; diff --git a/packages/web/e2e/lib/server.ts b/packages/web/e2e/lib/server.ts index f5688e174..c3342fdae 100644 --- a/packages/web/e2e/lib/server.ts +++ b/packages/web/e2e/lib/server.ts @@ -8,10 +8,13 @@ interface ServerHandle { function probePort(port: number): Promise { return new Promise((resolve) => { - const req = request({ hostname: "127.0.0.1", port, path: "/", method: "HEAD", timeout: 2000 }, (res) => { - res.resume(); - resolve(true); - }); + const req = request( + { hostname: "127.0.0.1", port, path: "/", method: "HEAD", timeout: 2000 }, + (res) => { + res.resume(); + resolve(true); + }, + ); req.on("error", () => resolve(false)); req.on("timeout", () => { req.destroy(); diff --git a/packages/web/e2e/screenshot.ts b/packages/web/e2e/screenshot.ts index 0421ecc88..7b145aa77 100644 --- a/packages/web/e2e/screenshot.ts +++ b/packages/web/e2e/screenshot.ts @@ -1,7 +1,12 @@ import { ensureServer } from "./lib/server.js"; import { captureScreenshots } from "./lib/browser.js"; -function parseArgs(argv: string[]): { port: number; width: number; height: number; paths: string[] } { +function parseArgs(argv: string[]): { + port: number; + width: number; + height: number; + paths: string[]; +} { const args = argv.slice(2); let port = 3333; let width = 1280; diff --git a/packages/web/server/direct-terminal-ws.ts b/packages/web/server/direct-terminal-ws.ts index 51f0c8551..1f0f00595 100644 --- a/packages/web/server/direct-terminal-ws.ts +++ b/packages/web/server/direct-terminal-ws.ts @@ -21,7 +21,10 @@ let config: ReturnType; try { config = loadConfig(); } catch (err) { - console.warn("[DirectTerminal] Could not load config, using defaults:", err instanceof Error ? err.message : String(err)); + console.warn( + "[DirectTerminal] Could not load config, using defaults:", + err instanceof Error ? err.message : String(err), + ); // Fallback to default config config = { dataDir: join(homedir(), ".ao/sessions"), @@ -43,10 +46,12 @@ const server = createServer((req, res) => { // Health check endpoint if (req.url === "/health") { res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ - active: activeSessions.size, - sessions: Array.from(activeSessions.keys()), - })); + res.end( + JSON.stringify({ + active: activeSessions.size, + sessions: Array.from(activeSessions.keys()), + }), + ); return; } diff --git a/packages/web/server/terminal-websocket.ts b/packages/web/server/terminal-websocket.ts index 7f9b724b7..05369644e 100644 --- a/packages/web/server/terminal-websocket.ts +++ b/packages/web/server/terminal-websocket.ts @@ -66,17 +66,20 @@ function waitForTtyd(port: number, sessionId: string, timeoutMs = 3000): Promise return; } - const req = request({ - hostname: "localhost", - port, - path: `/${sessionId}/`, - method: "GET", - timeout: 500, - }, (_res) => { - // Any response (even 404) means ttyd is listening - cleanup(); - resolve(); - }); + const req = request( + { + hostname: "localhost", + port, + path: `/${sessionId}/`, + method: "GET", + timeout: 500, + }, + (_res) => { + // Any response (even 404) means ttyd is listening + cleanup(); + resolve(); + }, + ); pendingReq = req; @@ -134,14 +137,23 @@ function getOrSpawnTtyd(sessionId: string): TtydInstance { console.error(`[Terminal] Failed to hide status bar for ${sessionId}:`, err.message); }); - const proc = spawn("ttyd", [ - "--writable", - "--port", String(port), - "--base-path", `/${sessionId}`, - "tmux", "attach-session", "-t", sessionId, - ], { - stdio: ["ignore", "pipe", "pipe"], - }); + const proc = spawn( + "ttyd", + [ + "--writable", + "--port", + String(port), + "--base-path", + `/${sessionId}`, + "tmux", + "attach-session", + "-t", + sessionId, + ], + { + stdio: ["ignore", "pipe", "pipe"], + }, + ); proc.stdout?.on("data", (data: Buffer) => { console.log(`[Terminal] ttyd ${sessionId}: ${data.toString().trim()}`); @@ -253,11 +265,13 @@ const server = createServer(async (req, res) => { const protocol = req.headers["x-forwarded-proto"] ?? "http"; res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ - url: `${protocol}://${host.split(":")[0]}:${instance.port}/${sessionId}/`, - port: instance.port, - sessionId, - })); + res.end( + JSON.stringify({ + url: `${protocol}://${host.split(":")[0]}:${instance.port}/${sessionId}/`, + port: instance.port, + sessionId, + }), + ); } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); console.error(`[Terminal] Failed to start terminal for ${sessionId}:`, errorMsg); @@ -270,11 +284,13 @@ const server = createServer(async (req, res) => { // GET /health if (url.pathname === "/health") { res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ - instances: Object.fromEntries( - [...instances.entries()].map(([id, inst]) => [id, { port: inst.port }]) - ), - })); + res.end( + JSON.stringify({ + instances: Object.fromEntries( + [...instances.entries()].map(([id, inst]) => [id, { port: inst.port }]), + ), + }), + ); return; } diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 870edfb61..4ba7025c9 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; -import type { Session, SessionManager, OrchestratorConfig, PluginRegistry, SCM } from "@composio/ao-core"; +import type { + Session, + SessionManager, + OrchestratorConfig, + PluginRegistry, + SCM, +} from "@composio/ao-core"; // ── Mock Data ───────────────────────────────────────────────────────── // Provides test sessions covering the key states the dashboard needs. diff --git a/packages/web/src/app/api/prs/[id]/merge/route.ts b/packages/web/src/app/api/prs/[id]/merge/route.ts index 093954298..6063f70fe 100644 --- a/packages/web/src/app/api/prs/[id]/merge/route.ts +++ b/packages/web/src/app/api/prs/[id]/merge/route.ts @@ -21,7 +21,10 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< const project = config.projects[session.projectId]; const scm = getSCM(registry, project); if (!scm) { - return NextResponse.json({ error: "No SCM plugin configured for this project" }, { status: 500 }); + return NextResponse.json( + { error: "No SCM plugin configured for this project" }, + { status: 500 }, + ); } // Validate PR is in a mergeable state diff --git a/packages/web/src/app/api/sessions/[id]/message/route.ts b/packages/web/src/app/api/sessions/[id]/message/route.ts index 268b6c4a4..b33587a0c 100644 --- a/packages/web/src/app/api/sessions/[id]/message/route.ts +++ b/packages/web/src/app/api/sessions/[id]/message/route.ts @@ -5,10 +5,7 @@ import type { Runtime } from "@composio/ao-core"; const MAX_MESSAGE_LENGTH = 10_000; -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ id: string }> }, -) { +export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { const { id } = await params; @@ -21,12 +18,9 @@ export async function POST( // Parse JSON with explicit error handling let body: Record | null; try { - body = await request.json() as Record; + body = (await request.json()) as Record; } catch { - return NextResponse.json( - { error: "Invalid JSON in request body" }, - { status: 400 }, - ); + return NextResponse.json({ error: "Invalid JSON in request body" }, { status: 400 }); } // Validate message is a non-empty string within length limit @@ -38,10 +32,7 @@ export async function POST( // Type guard: ensure message is actually a string const rawMessage = body?.message; if (typeof rawMessage !== "string") { - return NextResponse.json( - { error: "message must be a string" }, - { status: 400 }, - ); + return NextResponse.json({ error: "message must be a string" }, { status: 400 }); } // Strip control characters to prevent injection when passed to shell-based runtimes @@ -71,7 +62,10 @@ export async function POST( const runtimeName = session.runtimeHandle.runtimeName; const runtime = registry.get("runtime", runtimeName); if (!runtime) { - return NextResponse.json({ error: `Runtime plugin '${runtimeName}' not found` }, { status: 500 }); + return NextResponse.json( + { error: `Runtime plugin '${runtimeName}' not found` }, + { status: 500 }, + ); } try { @@ -82,16 +76,10 @@ export async function POST( } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); console.error("Failed to send message:", errorMsg); - return NextResponse.json( - { error: `Failed to send message: ${errorMsg}` }, - { status: 500 }, - ); + return NextResponse.json({ error: `Failed to send message: ${errorMsg}` }, { status: 500 }); } } catch (error) { console.error("Failed to send message:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 }, - ); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } diff --git a/packages/web/src/app/api/sessions/[id]/restore/route.ts b/packages/web/src/app/api/sessions/[id]/restore/route.ts index caa0b3fc5..015b34d3e 100644 --- a/packages/web/src/app/api/sessions/[id]/restore/route.ts +++ b/packages/web/src/app/api/sessions/[id]/restore/route.ts @@ -43,7 +43,11 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< branch: session.branch ?? undefined, }); - return NextResponse.json({ ok: true, sessionId: id, newSession: sessionToDashboard(newSession) }); + return NextResponse.json({ + ok: true, + sessionId: id, + newSession: sessionToDashboard(newSession), + }); } catch (err) { const msg = err instanceof Error ? err.message : "Failed to restore session"; const status = msg.includes("not found") ? 404 : 500; diff --git a/packages/web/src/app/api/sessions/[id]/route.ts b/packages/web/src/app/api/sessions/[id]/route.ts index 72093f150..d959f92a7 100644 --- a/packages/web/src/app/api/sessions/[id]/route.ts +++ b/packages/web/src/app/api/sessions/[id]/route.ts @@ -2,10 +2,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { getServices, getSCM, getTracker } from "@/lib/services"; import { sessionToDashboard, enrichSessionPR, enrichSessionIssue } from "@/lib/serialize"; -export async function GET( - _request: NextRequest, - { params }: { params: Promise<{ id: string }> }, -) { +export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { const { id } = await params; const { config, registry, sessionManager } = await getServices(); @@ -45,9 +42,6 @@ export async function GET( return NextResponse.json(dashboardSession); } catch (error) { console.error("Failed to fetch session:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 }, - ); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index 5a06060f3..2acba40e7 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -1,7 +1,12 @@ import { ACTIVITY_STATE, type Session, type ProjectConfig } from "@composio/ao-core"; import { NextResponse } from "next/server"; import { getServices, getSCM, getTracker } from "@/lib/services"; -import { sessionToDashboard, enrichSessionPR, enrichSessionIssue, computeStats } from "@/lib/serialize"; +import { + sessionToDashboard, + enrichSessionPR, + enrichSessionIssue, + computeStats, +} from "@/lib/serialize"; /** Resolve which project a session belongs to. */ function resolveProject( @@ -13,9 +18,7 @@ function resolveProject( if (direct) return direct; // Match by session prefix - const entry = Object.entries(projects).find(([, p]) => - core.id.startsWith(p.sessionPrefix), - ); + const entry = Object.entries(projects).find(([, p]) => core.id.startsWith(p.sessionPrefix)); if (entry) return entry[1]; // Fall back to first project diff --git a/packages/web/src/app/dev/terminal-test/page.tsx b/packages/web/src/app/dev/terminal-test/page.tsx index c928e9042..e9047a20b 100644 --- a/packages/web/src/app/dev/terminal-test/page.tsx +++ b/packages/web/src/app/dev/terminal-test/page.tsx @@ -53,8 +53,8 @@ function TerminalTestPageContent() { const defaultNewSession = availableSessions[1] || availableSessions[0] || "ao-orchestrator"; // Allow overriding individual sessions - const oldSessionId = oldSessionParam || (sessionParam || defaultOldSession); - const newSessionId = newSessionParam || (sessionParam || defaultNewSession); + const oldSessionId = oldSessionParam || sessionParam || defaultOldSession; + const newSessionId = newSessionParam || sessionParam || defaultNewSession; return (
@@ -66,33 +66,41 @@ function TerminalTestPageContent() {

Comparing sessions: - OLD: {oldSessionId} - NEW: {newSessionId} + + OLD: {oldSessionId} + + + NEW: {newSessionId} +

{/* The Problem */}
-

🐛 The Problem

+

+ 🐛 The Problem +

- Issue: Browser clipboard (Cmd+C/Ctrl+C) - only worked when an iTerm2 client was attached to the tmux session. + Issue: Browser clipboard + (Cmd+C/Ctrl+C) only worked when an iTerm2 client was attached to the tmux session.

- Impact: Users had to keep iTerm2 tabs open - in the background for clipboard to work in the web dashboard. + Impact: Users had to + keep iTerm2 tabs open in the background for clipboard to work in the web dashboard.

- Investigation time: 12+ hours of debugging - across tmux, ttyd, xterm.js, and macOS clipboard systems. + Investigation time: 12+ + hours of debugging across tmux, ttyd, xterm.js, and macOS clipboard systems.

{/* Root Cause */}
-

🔍 Root Cause Analysis

+

+ 🔍 Root Cause Analysis +

@@ -100,7 +108,10 @@ function TerminalTestPageContent() {

  • tmux uses OSC 52 escape sequences to synchronize clipboard with terminals
  • -
  • Format: \x1b]52;c;<base64>\x07
  • +
  • + Format:{" "} + \x1b]52;c;<base64>\x07 +
  • Terminal must support OSC 52 and have proper capabilities declared
@@ -112,17 +123,21 @@ function TerminalTestPageContent() {
  • tmux queries terminal capabilities using Device Attributes (DA/XDA)
  • - XDA query: CSI > q (also called XTVERSION) + XDA query: CSI > q (also + called XTVERSION)
  • - Terminal responds with identification string containing terminal type (e.g., "XTerm(370)", "iTerm2 ") + Terminal responds with identification string containing terminal type (e.g., + "XTerm(370)", "iTerm2 ")
  • Based on response, tmux enables features like TTYC_MS (clipboard support)
-

3. The Missing Piece

+

+ 3. The Missing Piece +

xterm.js does NOT implement XDA (Extended Device Attributes) @@ -134,7 +149,9 @@ function TerminalTestPageContent() { test.skip('CSI > Ps q - Report xterm name and version (XTVERSION)') -

  • Without XDA response, tmux doesn't recognize the terminal as clipboard-capable
  • +
  • + Without XDA response, tmux doesn't recognize the terminal as clipboard-capable +
  • tmux never emits OSC 52 sequences → clipboard doesn't work
  • @@ -150,7 +167,9 @@ function TerminalTestPageContent() { "iTerm2 "
  • tmux detects this and enables clipboard for the entire session
  • -
  • OSC 52 sequences are then sent to ALL clients, including browser (ttyd/xterm.js)
  • +
  • + OSC 52 sequences are then sent to ALL clients, including browser (ttyd/xterm.js) +
  • This is why clipboard "magically worked" when iTerm2 was attached
  • @@ -159,12 +178,17 @@ function TerminalTestPageContent() { {/* The Solution */}
    -

    ✅ The Solution

    +

    + ✅ The Solution +

    -

    DirectTerminal Implementation

    +

    + DirectTerminal Implementation +

    - Created custom terminal component that registers an XDA handler using xterm.js parser API: + Created custom terminal component that registers an XDA handler using xterm.js + parser API:

                     
    @@ -181,22 +205,31 @@ function TerminalTestPageContent() {
                 
    -

    What This Does

    +

    + What This Does +

    1. Intercepts XDA queries from tmux
    2. - Responds with XTerm(370) identification + Responds with XTerm(370){" "} + identification
    3. tmux detects "XTerm(" in response and enables TTYC_MS capability
    4. -
    5. OSC 52 sequences now flow: tmux → WebSocket → xterm.js → navigator.clipboard
    6. - Clipboard works without iTerm2! + OSC 52 sequences now flow: tmux → WebSocket → xterm.js → navigator.clipboard +
    7. +
    8. + + Clipboard works without iTerm2! +
    -

    Architecture Changes

    +

    + Architecture Changes +

    • Old: Browser → ttyd (iframe) → tmux @@ -213,7 +246,9 @@ function TerminalTestPageContent() { {/* Node Version Requirement */}
      -

      ⚠️ Node Version Requirement

      +

      + ⚠️ Node Version Requirement +

      @@ -232,7 +267,8 @@ function TerminalTestPageContent() { posix_spawnp failed

    • - Root cause: node-pty's native module (darwin-arm64 prebuild) fails to spawn processes on Node 25 + Root cause: node-pty's native module (darwin-arm64 prebuild) fails to spawn + processes on Node 25
    • No darwin-arm64 prebuilds available that work with Node 25
    • Building from source also fails with the same error
    • @@ -240,16 +276,20 @@ function TerminalTestPageContent() {
    -

    When Can We Upgrade?

    +

    + When Can We Upgrade? +

    • - Option 1: Wait for node-pty 1.2.0 stable release with Node 25+ support + Option 1: Wait for node-pty 1.2.0 stable release with Node 25+ + support
    • Option 2: Test node-pty beta versions (currently 1.2.0-beta.11)
    • - Option 3: Switch to alternative PTY library (e.g., xterm-pty, node-child-pty) + Option 3: Switch to alternative PTY library (e.g., xterm-pty, + node-child-pty)
    @@ -258,16 +298,20 @@ function TerminalTestPageContent() {

    ⚡ Testing Instructions for Upgrades

    -

    Before upgrading Node or node-pty:

    +

    + Before upgrading Node or node-pty: +

    1. Test node-pty directly:{" "} - node -e "const pty = require('node-pty'); pty.spawn('/bin/bash', [], {})" + node -e "const pty = require('node-pty'); pty.spawn('/bin/bash', [], + {})"
    2. - If no posix_spawnp failed error, proceed + If no posix_spawnp failed{" "} + error, proceed
    3. Start dev servers and open this page
    4. Test DirectTerminal: verify connection, clipboard, resize
    5. @@ -293,8 +337,11 @@ function TerminalTestPageContent() {
    {oldSessionId === newSessionId && (
    - ⚠️ Using same session for both terminals. To avoid port conflicts, use different sessions: - ?old_session=ao-orchestrator&new_session=ao-20 + ⚠️ Using same session for both terminals. To avoid port conflicts, use different + sessions: + + ?old_session=ao-orchestrator&new_session=ao-20 +
    )} @@ -317,8 +364,7 @@ function TerminalTestPageContent() { ❌ Clipboard requires iTerm2 attached
    ✅ Battle-tested (ttyd) -
    - ❌ No control over capabilities +
    ❌ No control over capabilities @@ -364,7 +410,9 @@ function TerminalTestPageContent() { {/* Debugging Journey */}
    -

    🔬 The Debugging Journey

    +

    + 🔬 The Debugging Journey +

    @@ -374,41 +422,53 @@ function TerminalTestPageContent() {
    -

    ❌ What We Tried (That Didn't Work)

    +

    + ❌ What We Tried (That Didn't Work) +

    1. - Suspected ttyd clipboard handling - Spent hours analyzing ttyd source code, checking OSC 52 - passthrough. ttyd was innocent - it passes escape sequences correctly. + Suspected ttyd clipboard handling - Spent hours analyzing ttyd + source code, checking OSC 52 passthrough. ttyd was innocent - it passes escape + sequences correctly.
    2. - Blamed xterm.js configuration - Tried every possible xterm.js option, clipboard addon - configurations, terminal type settings. None made a difference. + Blamed xterm.js configuration - Tried every possible xterm.js + option, clipboard addon configurations, terminal type settings. None made a + difference.
    3. - Investigated macOS clipboard permissions - Checked browser permissions, sandbox attributes, - navigator.clipboard API. All were correct. + Investigated macOS clipboard permissions - Checked browser + permissions, sandbox attributes, navigator.clipboard API. All were correct.
    4. - Suspected WebSocket encoding issues - Checked binary vs text mode, UTF-8 encoding, - base64 handling. All correct. + Suspected WebSocket encoding issues - Checked binary vs text + mode, UTF-8 encoding, base64 handling. All correct.
    5. - Tried force-enabling tmux clipboard - Used - set-option -s set-clipboard on in tmux.conf. Didn't help - tmux needs the terminal to declare support. + Tried force-enabling tmux clipboard - Used{" "} + + set-option -s set-clipboard on + {" "} + in tmux.conf. Didn't help - tmux needs the terminal to declare support.
    -

    💡 The Breakthrough

    +

    + 💡 The Breakthrough +

    - What finally worked: Registering an XDA (Extended Device Attributes) handler in xterm.js - using terminal.parser.registerCsiHandler() + What finally worked: Registering an XDA (Extended Device + Attributes) handler in xterm.js using{" "} + + terminal.parser.registerCsiHandler() +

                       
    -{`terminal.parser.registerCsiHandler(
    +                    {`terminal.parser.registerCsiHandler(
       { prefix: ">", final: "q" },
       () => {
         terminal.write("\\x1bP>|XTerm(370)\\x1b\\\\");
    @@ -418,35 +478,43 @@ function TerminalTestPageContent() {
                       
                     

    - This single handler made tmux recognize our terminal as clipboard-capable. Clipboard immediately started working. + This single handler made tmux recognize our terminal as clipboard-capable. + Clipboard immediately started working.

    -

    🎯 How We Finally Figured It Out

    +

    + 🎯 How We Finally Figured It Out +

    1. - Deep-dive into tmux source code - Used DeepWiki.com to analyze tmux's terminal capability - detection logic in tty-keys.c and{" "} + Deep-dive into tmux source code - Used DeepWiki.com to analyze + tmux's terminal capability detection logic in{" "} + tty-keys.c and{" "} tty.c
    2. Discovered XDA queries - Found that tmux sends{" "} - CSI > q (XTVERSION) to detect terminal type + CSI > q (XTVERSION) to + detect terminal type
    3. - Traced the "iTerm2 magic" - Realized why clipboard worked when iTerm2 was attached: - iTerm2 responds to XDA queries, enabling clipboard for the entire tmux session + Traced the "iTerm2 magic" - Realized why clipboard worked when + iTerm2 was attached: iTerm2 responds to XDA queries, enabling clipboard for the + entire tmux session
    4. - Checked xterm.js implementation - Found that XDA is marked as TODO in xterm.js tests:{" "} + Checked xterm.js implementation - Found that XDA is marked as + TODO in xterm.js tests:{" "} test.skip('CSI > Ps q - Report xterm name and version (XTVERSION)')
    5. - Implemented custom handler - Used xterm.js parser API to register our own XDA handler + Implemented custom handler - Used xterm.js parser API to register + our own XDA handler
    @@ -457,36 +525,44 @@ function TerminalTestPageContent() {
    1. - Start with tmux source code first - Instead of debugging xterm.js and ttyd, we should have - immediately checked how tmux detects terminal capabilities + Start with tmux source code first - Instead of debugging xterm.js + and ttyd, we should have immediately checked how tmux detects terminal + capabilities
    2. Monitor escape sequences - Running{" "} - tmux -vvv or using a terminal protocol analyzer - would have revealed the XDA queries being sent + tmux -vvv or using a + terminal protocol analyzer would have revealed the XDA queries being sent
    3. - Compare working vs broken scenarios - Should have captured and diffed the escape sequences - when iTerm2 was attached vs not attached earlier + Compare working vs broken scenarios - Should have captured and + diffed the escape sequences when iTerm2 was attached vs not attached earlier
    4. - Check xterm.js issues/limitations first - A GitHub search for "xterm.js XDA" or - "xterm.js device attributes" would have revealed it's unimplemented + Check xterm.js issues/limitations first - A GitHub search for + "xterm.js XDA" or "xterm.js device attributes" would have revealed it's + unimplemented
    5. - Read tmux documentation on terminal types - tmux man page mentions terminal capability - detection, but we focused on clipboard settings instead + Read tmux documentation on terminal types - tmux man page + mentions terminal capability detection, but we focused on clipboard settings + instead
    -

    📚 Key Resources

    +

    + 📚 Key Resources +

    • tmux source analysis: DeepWiki.com (Feb 15, 2026)
    • xterm.js parser API documentation
    • XTerm Control Sequences: XTVERSION / Device Attributes
    • -
    • tmux tty-keys.c: Terminal type detection logic
    • +
    • + tmux tty-keys.c: Terminal + type detection logic +
    @@ -494,22 +570,27 @@ function TerminalTestPageContent() { {/* Implementation Files */}
    -

    📁 Implementation Files

    +

    + 📁 Implementation Files +

    • packages/web/src/components/DirectTerminal.tsx - - Main component with XDA handler + {" "} + - Main component with XDA handler
    • packages/web/server/direct-terminal-ws.ts - - WebSocket server using node-pty + {" "} + - WebSocket server using node-pty
    • packages/web/src/app/dev/terminal-test/page.tsx - - This test page + {" "} + - This test page
    @@ -526,7 +607,9 @@ function TerminalTestPageContent() { export default function TerminalTestPage() { return ( - Loading...}> + Loading...} + > ); diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 72d780c38..1b92a6a0c 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -1,7 +1,12 @@ import { Dashboard } from "@/components/Dashboard"; import type { DashboardSession } from "@/lib/types"; import { getServices, getSCM, getTracker } from "@/lib/services"; -import { sessionToDashboard, enrichSessionPR, enrichSessionIssue, computeStats } from "@/lib/serialize"; +import { + sessionToDashboard, + enrichSessionPR, + enrichSessionIssue, + computeStats, +} from "@/lib/serialize"; import { prCache, prCacheKey } from "@/lib/cache"; export const dynamic = "force-dynamic"; @@ -108,5 +113,7 @@ export default async function Home() { // Config not found or services unavailable — show empty dashboard } - return ; + return ( + + ); } diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx index 64cf650e9..33ae22707 100644 --- a/packages/web/src/app/sessions/[id]/page.tsx +++ b/packages/web/src/app/sessions/[id]/page.tsx @@ -25,7 +25,7 @@ export default function SessionPage() { if (!res.ok) { throw new Error(`HTTP ${res.status}`); } - const data = await res.json() as DashboardSession; + const data = (await res.json()) as DashboardSession; setSession(data); setError(null); } catch (err) { @@ -58,9 +58,7 @@ export default function SessionPage() { if (error || !session) { return (
    -
    - {error || "Session not found"} -
    +
    {error || "Session not found"}
    ); } diff --git a/packages/web/src/app/test-direct/page.tsx b/packages/web/src/app/test-direct/page.tsx index 053768136..3d6c43672 100644 --- a/packages/web/src/app/test-direct/page.tsx +++ b/packages/web/src/app/test-direct/page.tsx @@ -39,11 +39,14 @@ function TestDirectPageContent() {

    Testing: {sessionId}

    -

    Test Steps:

    +

    + Test Steps: +

    1. Connected to tmux session: {sessionId}
    2. - Verify XDA badge shows ✓ XDA + Verify XDA badge shows{" "} + ✓ XDA
    3. Drag-select text in the terminal
    4. Press Cmd+C (macOS) or Ctrl+C (Linux/Windows)
    5. @@ -54,7 +57,9 @@ function TestDirectPageContent() {
    -

    Technical Details:

    +

    + Technical Details: +

    • Registers CSI > q (XDA) handler in xterm.js parser
    • Responds with DCS > | XTerm(370) ST sequence
    • @@ -77,7 +82,9 @@ function TestDirectPageContent() { export default function TestDirectPage() { return ( - Loading...
    }> + Loading...} + > ); diff --git a/packages/web/src/components/CIBadge.tsx b/packages/web/src/components/CIBadge.tsx index e8a056b9f..d5aef8156 100644 --- a/packages/web/src/components/CIBadge.tsx +++ b/packages/web/src/components/CIBadge.tsx @@ -60,13 +60,14 @@ interface CICheckListProps { layout?: "vertical" | "inline" | "expanded"; } -export const checkStatusIcon: Record = { - passed: { icon: "\u2713", color: "var(--color-accent-green)" }, - failed: { icon: "\u2717", color: "var(--color-accent-red)" }, - running: { icon: "\u25CF", color: "var(--color-accent-yellow)" }, - pending: { icon: "\u25CB", color: "var(--color-text-muted)" }, - skipped: { icon: "\u25CB", color: "var(--color-text-muted)" }, -}; +export const checkStatusIcon: Record = + { + passed: { icon: "\u2713", color: "var(--color-accent-green)" }, + failed: { icon: "\u2717", color: "var(--color-accent-red)" }, + running: { icon: "\u25CF", color: "var(--color-accent-yellow)" }, + pending: { icon: "\u25CB", color: "var(--color-text-muted)" }, + skipped: { icon: "\u25CB", color: "var(--color-text-muted)" }, + }; /** Sort order for CI checks: failures first, then running, pending, passed, skipped. */ export const ciCheckSortOrder: Record = { @@ -78,7 +79,9 @@ export const ciCheckSortOrder: Record = { }; export function CICheckList({ checks, layout = "vertical" }: CICheckListProps) { - const sorted = [...checks].sort((a, b) => ciCheckSortOrder[a.status] - ciCheckSortOrder[b.status]); + const sorted = [...checks].sort( + (a, b) => ciCheckSortOrder[a.status] - ciCheckSortOrder[b.status], + ); if (layout === "inline") { return ( @@ -123,7 +126,12 @@ export function CICheckList({ checks, layout = "vertical" }: CICheckListProps) { return (
    {check.url ? ( - + {inner} ) : ( diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index b7432fdae..fbee50abc 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -113,17 +113,19 @@ export function Dashboard({ sessions, stats, orchestratorId }: DashboardProps) {

    Sessions

    - {(["merge", "respond", "review", "pending", "working", "done"] as AttentionLevel[]).map((level) => ( - - ))} + {(["merge", "respond", "review", "pending", "working", "done"] as AttentionLevel[]).map( + (level) => ( + + ), + )}
    {/* PR Table */} diff --git a/packages/web/src/components/DirectTerminal.tsx b/packages/web/src/components/DirectTerminal.tsx index 27deb6524..bda6803b9 100644 --- a/packages/web/src/components/DirectTerminal.tsx +++ b/packages/web/src/components/DirectTerminal.tsx @@ -149,7 +149,8 @@ export function DirectTerminal({ sessionId, startFullscreen = false }: DirectTer }; websocket.onmessage = (event) => { - const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data); + const data = + typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data); terminal.write(data); }; @@ -232,7 +233,9 @@ export function DirectTerminal({ sessionId, startFullscreen = false }: DirectTer const expectedHeight = rect.height; // Check if container has reached target dimensions (within 10px tolerance) - const isFullscreenTarget = fullscreen ? expectedHeight > window.innerHeight - 100 : expectedHeight < 700; + const isFullscreenTarget = fullscreen + ? expectedHeight > window.innerHeight - 100 + : expectedHeight < 700; if (!isFullscreenTarget && resizeAttempts < maxAttempts) { // Container hasn't reached target size yet, try again @@ -270,8 +273,14 @@ export function DirectTerminal({ sessionId, startFullscreen = false }: DirectTer parent?.addEventListener("transitionend", handleTransitionEnd); // Backup timers in case RAF polling doesn't work - const timer1 = setTimeout(() => { resizeAttempts = 0; resizeTerminal(); }, 300); - const timer2 = setTimeout(() => { resizeAttempts = 0; resizeTerminal(); }, 600); + const timer1 = setTimeout(() => { + resizeAttempts = 0; + resizeTerminal(); + }, 300); + const timer2 = setTimeout(() => { + resizeAttempts = 0; + resizeTerminal(); + }, 600); return () => { parent?.removeEventListener("transitionend", handleTransitionEnd); @@ -281,10 +290,18 @@ export function DirectTerminal({ sessionId, startFullscreen = false }: DirectTer }, [fullscreen]); const statusColor = - status === "connected" ? "bg-[#3fb950]" : status === "error" ? "bg-[#f85149]" : "bg-[#d29922] animate-pulse"; + status === "connected" + ? "bg-[#3fb950]" + : status === "error" + ? "bg-[#f85149]" + : "bg-[#d29922] animate-pulse"; const statusText = - status === "connected" ? "Connected" : status === "error" ? error ?? "Error" : "Connecting..."; + status === "connected" + ? "Connected" + : status === "error" + ? (error ?? "Error") + : "Connecting..."; const statusTextColor = status === "connected" @@ -302,8 +319,12 @@ export function DirectTerminal({ sessionId, startFullscreen = false }: DirectTer >
    - {sessionId} - {statusText} + + {sessionId} + + + {statusText} + XDA diff --git a/packages/web/src/components/PRStatus.tsx b/packages/web/src/components/PRStatus.tsx index 662396eed..477359ad8 100644 --- a/packages/web/src/components/PRStatus.tsx +++ b/packages/web/src/components/PRStatus.tsx @@ -67,23 +67,21 @@ interface PRTableRowProps { export function PRTableRow({ pr }: PRTableRowProps) { const sizeLabel = getSizeLabel(pr.additions, pr.deletions); - const reviewLabel = - pr.isDraft - ? "draft" - : pr.reviewDecision === "approved" - ? "approved" - : pr.reviewDecision === "changes_requested" - ? "changes requested" - : "needs review"; + const reviewLabel = pr.isDraft + ? "draft" + : pr.reviewDecision === "approved" + ? "approved" + : pr.reviewDecision === "changes_requested" + ? "changes requested" + : "needs review"; - const reviewClass = - pr.isDraft - ? "text-[var(--color-text-muted)]" - : pr.reviewDecision === "approved" - ? "text-[var(--color-accent-green)]" - : pr.reviewDecision === "changes_requested" - ? "text-[var(--color-accent-red)]" - : "text-[var(--color-accent-yellow)]"; + const reviewClass = pr.isDraft + ? "text-[var(--color-text-muted)]" + : pr.reviewDecision === "approved" + ? "text-[var(--color-accent-green)]" + : pr.reviewDecision === "changes_requested" + ? "text-[var(--color-accent-red)]" + : "text-[var(--color-accent-yellow)]"; return ( diff --git a/packages/web/src/components/SessionCard.tsx b/packages/web/src/components/SessionCard.tsx index ca1a94309..7ae46bae2 100644 --- a/packages/web/src/components/SessionCard.tsx +++ b/packages/web/src/components/SessionCard.tsx @@ -347,10 +347,7 @@ function getAlerts(session: DashboardSession): Alert[] { "border-[rgba(248,81,73,0.3)] bg-[rgba(248,81,73,0.15)] text-[var(--color-accent-red)]", url: pr.url, }); - } else if ( - !pr.isDraft && - (pr.reviewDecision === "pending" || pr.reviewDecision === "none") - ) { + } else if (!pr.isDraft && (pr.reviewDecision === "pending" || pr.reviewDecision === "none")) { alerts.push({ key: "review", label: "needs review", diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index f85adb3c2..46662d123 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -58,7 +58,9 @@ function cleanBugbotComment(body: string): { title: string; description: string const title = titleMatch ? titleMatch[1].replace(/\*\*/g, "").trim() : "Comment"; // Extract description between DESCRIPTION START/END comments - const descMatch = body.match(/\s*([\s\S]*?)\s*/); + const descMatch = body.match( + /\s*([\s\S]*?)\s*/, + ); const description = descMatch ? descMatch[1].trim() : body.split("\n")[0] || "No description"; return { title, description }; @@ -409,7 +411,6 @@ function PRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) { Merged )} -
    @@ -513,9 +514,10 @@ function IssuesList({ pr }: { pr: DashboardPR }) { if (pr.ciStatus === CI_STATUS.FAILING) { const failCount = pr.ciChecks.filter((c) => c.status === "failed").length; - const text = failCount > 0 - ? `CI failing \u2014 ${failCount} check${failCount !== 1 ? "s" : ""} failed` - : "CI failing"; + const text = + failCount > 0 + ? `CI failing \u2014 ${failCount} check${failCount !== 1 ? "s" : ""} failed` + : "CI failing"; issues.push({ icon: "\u2717", color: "var(--color-accent-red)", @@ -592,4 +594,3 @@ function IssuesList({ pr }: { pr: DashboardPR }) { ); } - diff --git a/packages/web/src/components/Terminal.tsx b/packages/web/src/components/Terminal.tsx index 2010b1e69..ce9ac8a18 100644 --- a/packages/web/src/components/Terminal.tsx +++ b/packages/web/src/components/Terminal.tsx @@ -64,7 +64,7 @@ export function Terminal({ sessionId }: TerminalProps) { : "text-[var(--color-text-muted)]", )} > - {terminalUrl ? "Connected" : error ?? "Connecting..."} + {terminalUrl ? "Connected" : (error ?? "Connecting...")}