fix: address all review comments, lint/format, bugbot issues

Review fixes:
- metadata: sessionId validation to prevent path traversal
- event-bus: guard appendFileSync, remove unused import
- lifecycle-manager: fix reaction retry counting (track attempts,
  clear on status transition), allow retry on send failure instead
  of immediate escalation, detect killed sessions in polling loop,
  map session.killed to agent-exited reaction
- session-manager: validate status against union, clean up runtime
  and workspace on spawn failure, kill with default plugins when
  project config is missing

Lint/format fixes:
- Remove unused imports across all test files
- Replace `Function` type with typed callback in tmux tests
- Replace require() with dynamic import() in tests
- Replace dynamic delete with object spread in metadata
- Apply prettier formatting to all files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Prateek 2026-02-13 18:07:59 +05:30
parent c49649c2c1
commit 2ecb011982
19 changed files with 1407 additions and 500 deletions

View File

@ -22,16 +22,16 @@ An open-source, agent-agnostic system for orchestrating parallel AI coding agent
8 plugin slots — every abstraction is swappable:
| Slot | Interface | Default Plugin |
|------|-----------|---------------|
| Runtime | `Runtime` | tmux |
| Agent | `Agent` | claude-code |
| Workspace | `Workspace` | worktree |
| Tracker | `Tracker` | github |
| SCM | `SCM` | github |
| Notifier | `Notifier` | desktop |
| Terminal | `Terminal` | iterm2 |
| Lifecycle | (core, not pluggable) | — |
| Slot | Interface | Default Plugin |
| --------- | --------------------- | -------------- |
| Runtime | `Runtime` | tmux |
| Agent | `Agent` | claude-code |
| Workspace | `Workspace` | worktree |
| Tracker | `Tracker` | github |
| SCM | `SCM` | github |
| Notifier | `Notifier` | desktop |
| Terminal | `Terminal` | iterm2 |
| Lifecycle | (core, not pluggable) | — |
All interfaces are defined in `packages/core/src/types.ts`. **Read this file first** — it is the source of truth for all abstractions.
@ -120,7 +120,7 @@ const { stdout } = await execFileAsync("git", ["branch", "--show-current"], { ti
// BAD — shell injection risk
import { exec } from "node:child_process";
exec(`git checkout ${branchName}`); // branchName could contain ; rm -rf /
exec(`git checkout ${branchName}`); // branchName could contain ; rm -rf /
```
### Error Handling
@ -170,6 +170,7 @@ pnpm test # run all tests
### Before Committing
Always run lint and typecheck:
```bash
pnpm lint && pnpm typecheck
```
@ -187,17 +188,17 @@ Fix any issues before pushing. CI will reject PRs that fail lint or typecheck.
The `scripts/` directory contains the original bash scripts that this TypeScript codebase replaces. Use them as behavioral specifications:
| Script | What It Specifies |
|--------|------------------|
| `claude-ao-session` | Session lifecycle (spawn, list, kill, cleanup) |
| `claude-dashboard` | Web dashboard, API, activity detection |
| `claude-batch-spawn` | Batch spawning with duplicate detection |
| `send-to-session` | Smart message delivery (busy detection, wait-for-idle) |
| `claude-status` | JSONL introspection, live branch detection |
| `claude-review-check` | PR review scanning, fix prompt generation |
| `claude-bugbot-fix` | Automated comment detection + fixes |
| `claude-session-status` | Activity classification (working/idle/blocked) |
| `get-claude-session-info` | Agent introspection (session ID, summary extraction) |
| Script | What It Specifies |
| ------------------------- | ------------------------------------------------------ |
| `claude-ao-session` | Session lifecycle (spawn, list, kill, cleanup) |
| `claude-dashboard` | Web dashboard, API, activity detection |
| `claude-batch-spawn` | Batch spawning with duplicate detection |
| `send-to-session` | Smart message delivery (busy detection, wait-for-idle) |
| `claude-status` | JSONL introspection, live branch detection |
| `claude-review-check` | PR review scanning, fix prompt generation |
| `claude-bugbot-fix` | Automated comment detection + fixes |
| `claude-session-status` | Activity classification (working/idle/blocked) |
| `get-claude-session-info` | Agent introspection (session ID, summary extraction) |
## Key Design Decisions

View File

@ -49,24 +49,25 @@ You are the **orchestrator agent** for the agent-orchestrator project. You manag
## Commands Reference
| Task | Command |
|------|---------|
| **See all sessions** | `~/claude-status` |
| **Batch spawn** | `~/claude-batch-spawn ao AO-1 AO-2 AO-3` |
| **Single spawn** | `~/claude-spawn ao AO-1` |
| **List sessions** | `~/claude-ao-session ls` |
| **Attach** | `~/claude-ao-session attach ao-1` |
| **Kill** | `~/claude-ao-session kill ao-1` |
| **Cleanup** | `~/claude-ao-session cleanup` |
| **Open all tabs** | `~/claude-open-all ao` |
| **PR review fixes** | `~/claude-review-check ao` |
| **Peek at screen** | `tmux capture-pane -t "ao-1" -p -S -30` |
| **Send message** | `~/send-to-session ao-1 "your message"` |
| Task | Command |
| ---------------------- | ------------------------------------------------------------ |
| **See all sessions** | `~/claude-status` |
| **Batch spawn** | `~/claude-batch-spawn ao AO-1 AO-2 AO-3` |
| **Single spawn** | `~/claude-spawn ao AO-1` |
| **List sessions** | `~/claude-ao-session ls` |
| **Attach** | `~/claude-ao-session attach ao-1` |
| **Kill** | `~/claude-ao-session kill ao-1` |
| **Cleanup** | `~/claude-ao-session cleanup` |
| **Open all tabs** | `~/claude-open-all ao` |
| **PR review fixes** | `~/claude-review-check ao` |
| **Peek at screen** | `tmux capture-pane -t "ao-1" -p -S -30` |
| **Send message** | `~/send-to-session ao-1 "your message"` |
| **Spawn with context** | `~/claude-spawn-with-context ao AO-1 /tmp/prompt.txt --open` |
## Typical Workflows
### Spawn Work for Linear Tickets
```bash
# 1. Check what's already running
~/claude-status
@ -79,6 +80,7 @@ You are the **orchestrator agent** for the agent-orchestrator project. You manag
```
### Check Progress
```bash
~/claude-status # Quick overview
~/claude-ao-session ls # AO sessions only
@ -86,6 +88,7 @@ tmux capture-pane -t "ao-1" -p -S -30 # Peek at session
```
### Ask a Session to Do Something
```bash
# Short message
~/send-to-session ao-1 "address the unresolved comments on your PR"
@ -98,6 +101,7 @@ PROMPT
```
### Cleanup
```bash
~/claude-ao-session cleanup # Kills sessions with merged PRs / completed tickets
~/claude-ao-session kill ao-3 # Kill specific session
@ -106,7 +110,9 @@ PROMPT
## Session Data
### Metadata Files
Each session has a flat file at `~/.ao-sessions/ao-N`:
```
worktree=/Users/equinox/.worktrees/ao/ao-1
branch=feat/AO-1
@ -116,6 +122,7 @@ pr=https://github.com/ComposioHQ/agent-orchestrator/pull/5
```
### Environment Variables (inside sessions)
- `AO_SESSION` — e.g., `ao-1`
- `LINEAR_API_KEY` — required for cleanup to check ticket status
@ -151,6 +158,7 @@ agent-orchestrator/
## Architecture
### Session Lifecycle
```
spawn → tmux session created → Claude started → working on ticket
@ -164,12 +172,15 @@ PR merged → cleanup kills session, archives metadata
```
### Activity Detection
The dashboard detects if agents are working/idle/exited by:
1. Checking Claude's JSONL session file modification time and last message type
2. Walking the process tree from tmux pane PID to find `claude` processes
3. Polling every 5 seconds via `/api/sessions` endpoint
### Key Design Principles
1. **tmux-based** — persistence, detach/attach, scriptability
2. **Flat metadata files**`key=value` format, easy to parse and update
3. **Worktree isolation** — each session gets its own git worktree
@ -195,6 +206,7 @@ The dashboard detects if agents are working/idle/exited by:
## Linear Integration
Create tickets via Rube MCP:
```
RUBE_SEARCH_TOOLS: queries=[{use_case: "create an issue in Linear"}]

View File

@ -1,6 +1,6 @@
# Architecture Design — Agent Orchestrator
*Compiled: 2026-02-13*
_Compiled: 2026-02-13_
## Core Philosophy
@ -42,18 +42,18 @@ Human only intervenes when notified. Everything else is handled.
## Nomenclature
| Term | Definition | Examples |
|------|-----------|---------|
| **Orchestrator** | The central server that manages everything | The Next.js app |
| **Project** | A configured repository to work on | `my-app`, `backend-api` |
| **Session** | A running agent instance working on a task | `my-app-1`, `my-app-2` |
| **Runtime** | Where/how the session executes | tmux, docker, k8s, process |
| **Agent** | The AI coding tool being used | claude-code, codex, aider |
| **Workspace** | Isolated code copy for a session | git worktree, clone, volume |
| **Tracker** | Issue/task tracking system | github, linear, jira |
| **SCM** | Source code management platform | github, gitlab, bitbucket |
| **Notifier** | Communication/alert channel | slack, discord, desktop, webhook |
| **Terminal** | Human interaction interface | iterm2, web terminal, none |
| Term | Definition | Examples |
| ---------------- | ------------------------------------------ | -------------------------------- |
| **Orchestrator** | The central server that manages everything | The Next.js app |
| **Project** | A configured repository to work on | `my-app`, `backend-api` |
| **Session** | A running agent instance working on a task | `my-app-1`, `my-app-2` |
| **Runtime** | Where/how the session executes | tmux, docker, k8s, process |
| **Agent** | The AI coding tool being used | claude-code, codex, aider |
| **Workspace** | Isolated code copy for a session | git worktree, clone, volume |
| **Tracker** | Issue/task tracking system | github, linear, jira |
| **SCM** | Source code management platform | github, gitlab, bitbucket |
| **Notifier** | Communication/alert channel | slack, discord, desktop, webhook |
| **Terminal** | Human interaction interface | iterm2, web terminal, none |
---
@ -124,23 +124,23 @@ interface Runtime {
}
```
| Implementation | How it works | Best for |
|---------------|-------------|----------|
| `tmux` (default) | tmux sessions + send-keys | Local development, interactive |
| `process` | Child processes + stdin/stdout | Headless, CI/CD, scripting |
| `docker` | Docker containers + exec | Isolation, reproducibility |
| `kubernetes` | K8s pods/jobs | Scale, enterprise |
| `ssh` | SSH to remote + tmux/process | Remote machines |
| `e2b` | E2B SDK (Firecracker microVMs) | Cloud sandboxes |
| `fly` | Fly.io Machines API | Cost-effective cloud |
| `modal` | Modal Sandboxes | GPU, autoscaling |
| Implementation | How it works | Best for |
| ---------------- | ------------------------------ | ------------------------------ |
| `tmux` (default) | tmux sessions + send-keys | Local development, interactive |
| `process` | Child processes + stdin/stdout | Headless, CI/CD, scripting |
| `docker` | Docker containers + exec | Isolation, reproducibility |
| `kubernetes` | K8s pods/jobs | Scale, enterprise |
| `ssh` | SSH to remote + tmux/process | Remote machines |
| `e2b` | E2B SDK (Firecracker microVMs) | Cloud sandboxes |
| `fly` | Fly.io Machines API | Cost-effective cloud |
| `modal` | Modal Sandboxes | GPU, autoscaling |
### 2. Agent — AI coding tool
```typescript
interface Agent {
readonly name: string;
readonly processName: string; // for detection
readonly processName: string; // for detection
// Launch
getLaunchCommand(session: SessionConfig, project: ProjectConfig): string;
@ -159,14 +159,14 @@ interface Agent {
}
```
| Implementation | Launch command | Activity detection |
|---------------|---------------|-------------------|
| Implementation | Launch command | Activity detection |
| ----------------------- | --------------------------------------- | -------------------------- |
| `claude-code` (default) | `claude --dangerously-skip-permissions` | JSONL mtime + process tree |
| `claude-headless` | `claude -p --output-format stream-json` | stdout parsing |
| `codex` | `codex` | Process detection |
| `aider` | `aider --no-auto-commits` | Process detection |
| `goose` | `goose session` | Process detection |
| `custom` | User-defined command | Configurable |
| `claude-headless` | `claude -p --output-format stream-json` | stdout parsing |
| `codex` | `codex` | Process detection |
| `aider` | `aider --no-auto-commits` | Process detection |
| `goose` | `goose session` | Process detection |
| `custom` | User-defined command | Configurable |
### 3. Workspace — Code isolation
@ -183,12 +183,12 @@ interface Workspace {
}
```
| Implementation | How | Tradeoff |
|---------------|-----|----------|
| `worktree` (default) | `git worktree add` | Fast, shared objects, requires same repo |
| `clone` | `git clone` | Full isolation, slower, more disk |
| `copy` | `cp -r` | No git dependency, heaviest |
| `volume` | Docker/K8s volume mounts | For container runtimes |
| Implementation | How | Tradeoff |
| -------------------- | ------------------------ | ---------------------------------------- |
| `worktree` (default) | `git worktree add` | Fast, shared objects, requires same repo |
| `clone` | `git clone` | Full isolation, slower, more disk |
| `copy` | `cp -r` | No git dependency, heaviest |
| `volume` | Docker/K8s volume mounts | For container runtimes |
### 4. Tracker — Issue/task tracking
@ -209,12 +209,12 @@ interface Tracker {
}
```
| Implementation | API | Auth |
|---------------|-----|------|
| `github` (default) | `gh` CLI | GitHub token |
| `linear` | GraphQL API | Linear API key |
| `jira` | REST API | Jira token |
| `plain` | Local files | None |
| Implementation | API | Auth |
| ------------------ | ----------- | -------------- |
| `github` (default) | `gh` CLI | GitHub token |
| `linear` | GraphQL API | Linear API key |
| `jira` | REST API | Jira token |
| `plain` | Local files | None |
### 5. SCM — Source code platform (PR, CI, Reviews)
@ -244,11 +244,11 @@ interface SCM {
}
```
| Implementation | API | Features |
|---------------|-----|----------|
| `github` (default) | `gh` CLI + REST API | Full PR/CI/review support |
| `gitlab` | REST API | MR/pipeline/review support |
| `bitbucket` | REST API | PR/pipeline support |
| Implementation | API | Features |
| ------------------ | ------------------- | -------------------------- |
| `github` (default) | `gh` CLI + REST API | Full PR/CI/review support |
| `gitlab` | REST API | MR/pipeline/review support |
| `bitbucket` | REST API | PR/pipeline support |
### 6. Notifier — THE PRIMARY INTERFACE
@ -270,19 +270,19 @@ interface Notifier {
// Notifications can include actions the human can take directly
interface NotifyAction {
label: string; // "Merge PR", "Open Dashboard", "Kill Session"
url?: string; // Deep link to dashboard action
callback?: string; // API endpoint to call
label: string; // "Merge PR", "Open Dashboard", "Kill Session"
url?: string; // Deep link to dashboard action
callback?: string; // API endpoint to call
}
```
| Implementation | Channel | Best for | Actionable? |
|---------------|---------|----------|-------------|
| `desktop` (default) | OS notifications (clickable) | Solo developer | Click → opens dashboard |
| `slack` | Slack messages with buttons | Teams | Buttons → merge, review, kill |
| `discord` | Discord messages | Communities | Links |
| `webhook` | HTTP POST | Custom integrations | Custom |
| `email` | Email digest | Async | Links |
| Implementation | Channel | Best for | Actionable? |
| ------------------- | ---------------------------- | ------------------- | ----------------------------- |
| `desktop` (default) | OS notifications (clickable) | Solo developer | Click → opens dashboard |
| `slack` | Slack messages with buttons | Teams | Buttons → merge, review, kill |
| `discord` | Discord messages | Communities | Links |
| `webhook` | HTTP POST | Custom integrations | Custom |
| `email` | Email digest | Async | Links |
**Multiple notifiers can be active simultaneously.** E.g., desktop for immediate alerts + Slack for team visibility + email for daily digest.
@ -300,17 +300,18 @@ interface Terminal {
}
```
| Implementation | How | Platform |
|---------------|-----|----------|
| `auto` (default) | Detect best available | Any |
| `iterm2` | AppleScript API | macOS |
| `web` | xterm.js in browser | Any |
| `tmux-attach` | `tmux attach` in terminal | Any with tmux |
| `none` | Headless | CI/CD |
| Implementation | How | Platform |
| ---------------- | ------------------------- | ------------- |
| `auto` (default) | Detect best available | Any |
| `iterm2` | AppleScript API | macOS |
| `web` | xterm.js in browser | Any |
| `tmux-attach` | `tmux attach` in terminal | Any with tmux |
| `none` | Headless | CI/CD |
### 8. Lifecycle Manager (Core — not pluggable)
The Lifecycle Manager is the orchestrator's brain. It:
- Polls SCM + Agent plugins on configurable intervals
- Maintains state machine per session
- Emits events on state transitions
@ -396,24 +397,24 @@ The orchestrator operates on a simple principle: handle everything you can autom
**Tier 1: Auto-handled (human never sees these)**
The orchestrator resolves these silently. The human is only notified if auto-resolution fails.
| Event | Auto-Response | Escalation |
|-------|--------------|------------|
| CI failed | Send fix prompt to agent | Notify after 2 failed attempts |
| Review comments | Send "address comments" to agent | Notify if unresolved after 30min |
| Bugbot/linter comments | Send fix prompt to agent | Notify if unresolved after 30min |
| Merge conflicts | Send "rebase" to agent | Notify if unresolved after 15min |
| Event | Auto-Response | Escalation |
| ---------------------- | -------------------------------- | -------------------------------- |
| CI failed | Send fix prompt to agent | Notify after 2 failed attempts |
| Review comments | Send "address comments" to agent | Notify if unresolved after 30min |
| Bugbot/linter comments | Send fix prompt to agent | Notify if unresolved after 30min |
| Merge conflicts | Send "rebase" to agent | Notify if unresolved after 15min |
**Tier 2: Notify human (requires human judgment)**
These always push a notification. The human's phone buzzes, Slack pings, etc.
| Event | Priority | Notification |
|-------|----------|-------------|
| **Agent needs input** (permission, question, stuck) | URGENT | "Session X needs your input" + deep link |
| **Agent errored** (crashed, unrecoverable) | URGENT | "Session X crashed" + error context |
| **PR ready to merge** (approved + CI green) | ACTION | "PR #42 ready to merge" + merge button |
| **Agent idle too long** (no PR, no progress) | WARNING | "Session X idle for 15min, may need help" |
| **Auto-fix failed** (CI fix failed 2x, comments not addressed) | WARNING | "Session X couldn't resolve CI/review — needs you" |
| **All work complete** | INFO | "All 20 sessions done. 18 PRs merged, 2 need review." |
| Event | Priority | Notification |
| -------------------------------------------------------------- | -------- | ----------------------------------------------------- |
| **Agent needs input** (permission, question, stuck) | URGENT | "Session X needs your input" + deep link |
| **Agent errored** (crashed, unrecoverable) | URGENT | "Session X crashed" + error context |
| **PR ready to merge** (approved + CI green) | ACTION | "PR #42 ready to merge" + merge button |
| **Agent idle too long** (no PR, no progress) | WARNING | "Session X idle for 15min, may need help" |
| **Auto-fix failed** (CI fix failed 2x, comments not addressed) | WARNING | "Session X couldn't resolve CI/review — needs you" |
| **All work complete** | INFO | "All 20 sessions done. 18 PRs merged, 2 need review." |
### Escalation Chains
@ -447,10 +448,10 @@ Different priorities route to different channels:
```yaml
notifications:
routing:
urgent: [desktop, slack, sms] # Agent stuck, errored, needs input
action: [desktop, slack] # PR ready to merge
warning: [slack] # Auto-fix failed, idle too long
info: [slack] # Summary, all done
urgent: [desktop, slack, sms] # Agent stuck, errored, needs input
action: [desktop, slack] # PR ready to merge
warning: [slack] # Auto-fix failed, idle too long
info: [slack] # Summary, all done
```
### Reactions (configurable auto-responses)
@ -463,7 +464,7 @@ reactions:
action: send-to-agent
message: "CI is failing. Run `gh pr checks` to see failures, fix them, and push."
retries: 2
escalate-after: 2 # notify human after 2 failed auto-fix attempts
escalate-after: 2 # notify human after 2 failed auto-fix attempts
changes-requested:
auto: true
@ -484,7 +485,7 @@ reactions:
escalate-after: 15m
approved-and-green:
auto: false # require human confirmation by default
auto: false # require human confirmation by default
action: notify
priority: action
message: "PR is ready to merge"
@ -507,10 +508,10 @@ reactions:
action: notify
priority: info
message: "All sessions complete"
include-summary: true # PRs merged, pending, failed
include-summary: true # PRs merged, pending, failed
agent-idle-no-pr:
threshold: 30m # working for 30min with no PR
threshold: 30m # working for 30min with no PR
action: notify
priority: warning
message: "Agent has been working for 30min without creating a PR"
@ -543,6 +544,7 @@ projects:
```
Everything else uses sensible defaults:
- Runtime: tmux
- Agent: claude-code
- Workspace: worktree
@ -555,9 +557,9 @@ Everything else uses sensible defaults:
```yaml
# agent-orchestrator.yaml
dataDir: ~/.agent-orchestrator # metadata storage
worktreeDir: ~/.worktrees # workspace root
port: 3000 # web dashboard port
dataDir: ~/.agent-orchestrator # metadata storage
worktreeDir: ~/.worktrees # workspace root
port: 3000 # web dashboard port
defaults:
runtime: tmux
@ -596,13 +598,13 @@ projects:
# Agent-specific config
agentConfig:
permissions: skip # --dangerously-skip-permissions
permissions: skip # --dangerously-skip-permissions
model: opus
# Reaction overrides
reactions:
approved-and-green:
auto: true # enable auto-merge for this project
auto: true # enable auto-merge for this project
# Notification channels
notifiers:
@ -634,17 +636,17 @@ reactions:
## Tech Stack
| Segment | Choice | Why |
|---------|--------|-----|
| **Core library** | TypeScript | Shared types across all packages |
| **Web + API** | Next.js 15 (App Router) | SSR + API routes in one process |
| **Styling** | Tailwind CSS | Dark theme, responsive |
| **Real-time** | Server-Sent Events | One-way push, auto-reconnect, simpler than WebSocket |
| **CLI** | TypeScript + Commander.js | Shares types with core |
| **Config** | YAML + Zod validation | Human-readable, type-safe |
| **State** | Flat metadata files + Event log (JSONL) | Stateless orchestrator, crash recovery |
| **Package manager** | pnpm workspaces | Fast, monorepo-native |
| **Distribution** | npm (`npx agent-orchestrator`) | Zero install |
| Segment | Choice | Why |
| ------------------- | --------------------------------------- | ---------------------------------------------------- |
| **Core library** | TypeScript | Shared types across all packages |
| **Web + API** | Next.js 15 (App Router) | SSR + API routes in one process |
| **Styling** | Tailwind CSS | Dark theme, responsive |
| **Real-time** | Server-Sent Events | One-way push, auto-reconnect, simpler than WebSocket |
| **CLI** | TypeScript + Commander.js | Shares types with core |
| **Config** | YAML + Zod validation | Human-readable, type-safe |
| **State** | Flat metadata files + Event log (JSONL) | Stateless orchestrator, crash recovery |
| **Package manager** | pnpm workspaces | Fast, monorepo-native |
| **Distribution** | npm (`npx agent-orchestrator`) | Zero install |
### Why TypeScript Throughout
@ -744,6 +746,7 @@ agent-orchestrator/
## Implementation Phases
### Phase 1: Foundation (Dog-food ready)
- Monorepo scaffolding
- Core types + interfaces
- Config loader
@ -758,6 +761,7 @@ agent-orchestrator/
- Reaction engine (CI failed, changes requested, agent stuck)
### Phase 2: Multi-Runtime + More Plugins
- Process runtime (headless claude -p)
- Docker runtime
- Codex + Aider agent adapters
@ -766,12 +770,14 @@ agent-orchestrator/
- Web terminal (xterm.js)
### Phase 3: Cloud + Scale
- Kubernetes runtime
- E2B / Fly.io runtimes
- Cost tracking
- Webhook-triggered spawning
### Phase 4: Team + Enterprise
- Dashboard auth
- Role-based access
- Remote session support

View File

@ -1,6 +1,6 @@
# Competitive Research — Agent Orchestration Tools
*Compiled: 2026-02-13*
_Compiled: 2026-02-13_
## Overview
@ -18,26 +18,27 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
**Architecture — MEOW Stack (Molecular Expression of Work):**
| Layer | What | How |
|-------|------|-----|
| **Beads** | Atomic work units | JSONL files tracked in Git. IDs like `gt-abc12`. Universal data/control plane. |
| **Epics** | Hierarchical collections | Organize beads into tree structures for parallel/sequential execution |
| **Molecules** | Workflow graphs | Sequenced beads with dependencies, gates, loops |
| **Protomolecules & Formulas** | Reusable templates | TOML format workflow definitions |
| Layer | What | How |
| ----------------------------- | ------------------------ | ------------------------------------------------------------------------------ |
| **Beads** | Atomic work units | JSONL files tracked in Git. IDs like `gt-abc12`. Universal data/control plane. |
| **Epics** | Hierarchical collections | Organize beads into tree structures for parallel/sequential execution |
| **Molecules** | Workflow graphs | Sequenced beads with dependencies, gates, loops |
| **Protomolecules & Formulas** | Reusable templates | TOML format workflow definitions |
**Agent Roles (7 roles, 2 scopes):**
| Role | Scope | Purpose |
|------|-------|---------|
| **Mayor** | Town | Chief AI coordinator with full workspace context |
| **Deacon** | Town | Health daemon running patrol loops |
| **Dogs** | Town | Maintenance helpers |
| **Crew** | Rig | Named, persistent agents for sustained design/review work |
| **Polecats** | Rig | Ephemeral "cattle" workers spawned for specific tasks |
| **Refinery** | Rig | Merge queue manager handling conflicts |
| **Witness** | Rig | Supervises polecats, unblocks stuck work |
| Role | Scope | Purpose |
| ------------ | ----- | --------------------------------------------------------- |
| **Mayor** | Town | Chief AI coordinator with full workspace context |
| **Deacon** | Town | Health daemon running patrol loops |
| **Dogs** | Town | Maintenance helpers |
| **Crew** | Rig | Named, persistent agents for sustained design/review work |
| **Polecats** | Rig | Ephemeral "cattle" workers spawned for specific tasks |
| **Refinery** | Rig | Merge queue manager handling conflicts |
| **Witness** | Rig | Supervises polecats, unblocks stuck work |
**Other Abstractions:**
- **Town** — Workspace directory (`~/gt/`) housing all projects
- **Rigs** — Project containers wrapping git repositories
- **Hooks** — Git worktree-based persistent storage surviving crashes
@ -47,6 +48,7 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
**Runtime Backends:** claude, gemini, codex, cursor, auggie, amp (per-rig config)
**Communication/Isolation:**
- Git worktrees for filesystem isolation per agent
- Beads/Hooks for coordination (external state, not shared context windows)
- GUPP: deterministic handoffs through version control, not LLM-judged phase gates
@ -63,12 +65,14 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
- **Closest to our current approach**
**Key Abstractions:**
- **Sessions**: Single-repo isolated branches via git worktrees + tmux sessions
- **Workspaces**: Multi-repo synchronized development contexts
- **Control Center**: Unified tmux session with windows for each context
- **Labels**: Globally unique, human-readable names
**Features:**
- `par start my-feature` — creates worktree + branch + tmux session
- `par send <label> "<command>"` — execute commands in specific sessions remotely
- `par send all "<command>"` — broadcast to all sessions
@ -87,6 +91,7 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
- **Stack**: Python, tmux, HTTP server (localhost:9889)
**Key Abstractions:**
- **Supervisor + Workers**: Hierarchical model with three coordination patterns:
- **Handoff**: Synchronous task transfer
- **Assign**: Asynchronous spawning with callback
@ -107,6 +112,7 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
- **Stack**: Rust (2024 edition), ratatui TUI, Tokio async, OpenTelemetry
**Key Abstractions:**
- **ProactiveMaster**: Orchestration core with zero shared state (message-passing channels)
- **Specialized Agent Pools**: Frontend, Backend, DevOps, QA
- **Multi-Provider Layer**: Claude Code, Aider, OpenAI Codex, custom tools
@ -125,6 +131,7 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
- **Stack**: Rust 92.8%, npm distribution
**Key Abstractions:**
- **Agent Client Protocol (ACP)**: Standardized interface across all agents
- **Process Isolation**: Each agent in its own process with UDS socket
- **Remote Access**: Interact with any agent from any terminal
@ -143,6 +150,7 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
- **Claims**: 100K+ monthly active users, 84.8% SWE-Bench solve rate
**Key Abstractions:**
- **Swarm Topologies**: mesh, hierarchical, ring, star configurations
- **Queen-Led Hierarchies**: Strategic Queens (planning), Tactical Queens (execution), Adaptive Queens (optimization)
- **8 Worker Types**: researcher, coder, analyst, tester, architect, reviewer, optimizer, documenter
@ -153,6 +161,7 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
- **Consensus Mechanisms**: Raft, Byzantine, Gossip, Weighted, Majority
**Extension System:**
- 17 integration hooks (pre-task, post-task, etc.)
- Custom workers (12 context-triggered background services)
- Plugin SDK with IPFS marketplace distribution
@ -171,6 +180,7 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
- **Stack**: Python 75.5%, TypeScript/React 22.3%, Docker, Kubernetes
**Key Abstractions:**
- **Software Agent SDK**: Composable Python library
- **Runtime/Sandbox**: Docker-based sandboxed execution environments
- **Event Stream Architecture**: Event-driven communication between backend and frontend
@ -191,12 +201,14 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
- **Stack**: Python 94.6%
**Key Abstractions:**
- **SWEEnv**: Environment manager (thin wrapper around SWE-ReX)
- **Agent**: Configured via single YAML file
- **ACI (Agent-Computer Interface)**: Custom tools installed in container
- **Deployment**: Abstraction over execution targets
**Runtime Backends (SWE-ReX):**
- Local Docker containers
- Modal (serverless compute)
- AWS Fargate (container orchestration)
@ -216,6 +228,7 @@ Agent code remains the same regardless of deployment target.
- **Stack**: Rust 58.9%, TypeScript 33.0%, Go (temporal scheduler)
**Key Abstractions:**
- **Crate architecture**: goose (core), goose-cli, goose-server, goose-mcp, mcp-client, mcp-core
- **Sessions**: Stateful autonomous execution environments
- **Recipes**: Task automation workflows
@ -233,6 +246,7 @@ Agent code remains the same regardless of deployment target.
- **Stack**: TypeScript, Node.js, esbuild
**Key Abstractions:**
- **Sequential Decision Loop**: Analysis → Planning → Execution → Monitoring → Iteration
- **Checkpoint System**: Workspace snapshots at each step for compare/restore
- **Context Attachments**: @file, @folder, @url, @problems
@ -249,6 +263,7 @@ Agent code remains the same regardless of deployment target.
- **Reached #13 on Stanford's TerminalBench**
**Key Abstractions:**
- **Orchestrator Agent**: Strategic coordinator; never touches code
- **Explorer Agent**: Read-only investigation specialist
- **Coder Agent**: Implementation specialist with write access
@ -270,6 +285,7 @@ Agent code remains the same regardless of deployment target.
- **Stack**: Python, GitHub REST API, Claude Code
**Key Abstractions:**
- **5-Phase Workflow**: Brainstorm → Document → Plan → Decompose → Execute
- **GitHub Issues as Database**: Issues store specs, comments provide audit trail
- **Epic Worktrees**: Each epic spawns a dedicated worktree
@ -283,6 +299,7 @@ Agent code remains the same regardless of deployment target.
- **Stack**: Python (Flask + Socket.IO), Vue 3 + Vite, Docker/Kubernetes
**Key Abstractions:**
- **Workflow Presets**: Default (Codex→Gemini→Claude), Quick, Thorough, Review-Only, Document
- **AI Adapters**: Standardized interfaces per agent tool
- **Session Manager**: Context across workflow steps
@ -296,6 +313,7 @@ Agent code remains the same regardless of deployment target.
- **Stack**: Claude Code plugin ecosystem
**Key Abstractions:**
- **Plugins**: 73 plugins, 112 agents, 146 skills, 79 tools
- **Progressive Disclosure Skills**: 3-tier knowledge
- **16 Workflow Orchestrators**: review, debug, feature, fullstack, research, security, migration
@ -308,23 +326,25 @@ Agent code remains the same regardless of deployment target.
### Cloud Sandbox Platforms
| Platform | Startup Time | Isolation | API Style | Cost |
|----------|-------------|-----------|-----------|------|
| **Docker (local)** | ~1-5s | Container namespace | Docker CLI/API | Free |
| **E2B** | ~200-400ms | Firecracker microVMs | Python/JS SDK | Pay-per-use |
| **Daytona** | ~27-90ms | OCI containers | Python/TS SDK + REST | Open source |
| **Modal Sandboxes** | Sub-second | gVisor containers | Python SDK | $0.03/hr |
| **Fly.io Machines** | ~200ms-1s | Firecracker microVMs | REST API | $0.02/hr |
| Platform | Startup Time | Isolation | API Style | Cost |
| ------------------- | ------------ | -------------------- | -------------------- | ----------- |
| **Docker (local)** | ~1-5s | Container namespace | Docker CLI/API | Free |
| **E2B** | ~200-400ms | Firecracker microVMs | Python/JS SDK | Pay-per-use |
| **Daytona** | ~27-90ms | OCI containers | Python/TS SDK + REST | Open source |
| **Modal Sandboxes** | Sub-second | gVisor containers | Python SDK | $0.03/hr |
| **Fly.io Machines** | ~200ms-1s | Firecracker microVMs | REST API | $0.02/hr |
### Agent-Sandbox Connection Patterns (per LangChain)
**Pattern 1: Agent IN Sandbox**
- Agent runs inside the container/VM
- Communicates outward via HTTP/WebSocket
- Pro: Direct filesystem access, mirrors local dev
- Con: API keys inside sandbox
**Pattern 2: Sandbox AS Tool**
- Agent runs on orchestrator/server
- Calls sandbox via SDK/API for code execution
- Pro: API keys secure, parallel execution
@ -332,36 +352,38 @@ Agent code remains the same regardless of deployment target.
### Communication Protocols
| Protocol | Use Case | Used By |
|----------|----------|---------|
| **REST API** | Request/response | OpenHands, Fly.io, Daytona |
| **WebSocket** | Bidirectional streaming | OpenHands, Claude Agent SDK |
| **stdio/subprocess** | Child process | Claude Agent SDK, Codex CLI, MCP |
| **tmux send-keys** | Terminal injection | Our orchestrator, Par, CAO |
| **SSE** | Server → client push | MCP remote transport |
| Protocol | Use Case | Used By |
| -------------------- | ----------------------- | -------------------------------- |
| **REST API** | Request/response | OpenHands, Fly.io, Daytona |
| **WebSocket** | Bidirectional streaming | OpenHands, Claude Agent SDK |
| **stdio/subprocess** | Child process | Claude Agent SDK, Codex CLI, MCP |
| **tmux send-keys** | Terminal injection | Our orchestrator, Par, CAO |
| **SSE** | Server → client push | MCP remote transport |
### Heartbeat / Health Detection
| Pattern | Description | Used By |
|---------|-------------|---------|
| **WebSocket ping/pong** | Periodic heartbeats | OpenHands |
| **Process polling** | Check PID alive | Claude Agent SDK |
| **tmux capture-pane** | Scrape terminal output | Our `claude-session-status` |
| **File-based signaling** | Status to shared filesystem | Our metadata files |
| **HTTP health endpoint** | `/health` or `/status` | OpenHands server |
| **JSONL mtime** | Check session file modification time | Our `claude-status` |
| Pattern | Description | Used By |
| ------------------------ | ------------------------------------ | --------------------------- |
| **WebSocket ping/pong** | Periodic heartbeats | OpenHands |
| **Process polling** | Check PID alive | Claude Agent SDK |
| **tmux capture-pane** | Scrape terminal output | Our `claude-session-status` |
| **File-based signaling** | Status to shared filesystem | Our metadata files |
| **HTTP health endpoint** | `/health` or `/status` | OpenHands server |
| **JSONL mtime** | Check session file modification time | Our `claude-status` |
---
## Key Findings & Gaps
### What Everyone Does
1. **Git worktrees** = standard isolation primitive
2. **tmux** = dominant session manager for local
3. **External state > context windows** (Beads, Context Store, GitHub Issues)
4. **MCP** = emerging extension protocol
### What Nobody Does Well (Our Opportunity)
1. **Multiple runtime backends** (tmux + Docker + cloud) with same interface
2. **Multiple agent support** with proper abstraction
3. **Human-in-the-loop optimization** (our core differentiator — everyone else optimizes for autonomous)
@ -371,6 +393,7 @@ Agent code remains the same regardless of deployment target.
7. **Full PR lifecycle management** (CI checks, review comments, merge readiness, auto-reactions)
### Best Ideas to Steal
- **Gas Town**: Git-backed state (Beads), role-based agents, crash recovery
- **OpenHands**: Event-sourced architecture, Docker/K8s runtime abstraction
- **SWE-ReX**: Clean deployment backend interface (`swe-rex[modal]`, `swe-rex[fargate]`)

View File

@ -33,11 +33,13 @@
**Must be done first. Everything else depends on it.**
Creates the monorepo scaffold and ALL type definitions. After this, every agent has:
- A package to work in (with package.json, tsconfig)
- All interfaces defined (they just implement them)
- No ambiguity about what to build
### Deliverables
1. `pnpm-workspace.yaml` + root `package.json` + `tsconfig.base.json`
2. `packages/core/src/types.ts` — ALL interfaces (Runtime, Agent, Workspace, Tracker, SCM, Notifier, Terminal, Session, Event, Config, etc.)
3. `packages/core/src/config.ts` — Zod schemas for YAML config validation
@ -54,18 +56,19 @@ Creates the monorepo scaffold and ALL type definitions. After this, every agent
## Phase 1: Parallel Implementation (7 Agents)
### Agent 1: Core Services
**Package**: `packages/core/src/`
**Branch**: `feat/core-services`
**Depends on**: Phase 0 types
**Blocked by**: Nothing after Phase 0
| File | What | Reference Script |
|------|------|-----------------|
| `metadata.ts` | Flat-file metadata read/write (key=value) | Metadata parsing in all session managers |
| `event-bus.ts` | In-process pub/sub + JSONL persistence | New (inspired by OpenHands event stream) |
| `tmux.ts` | tmux command wrappers (list, new, send-keys, capture-pane, kill) | All scripts that call tmux |
| `session-manager.ts` | Session CRUD: spawn, list, kill, cleanup, send message | `claude-ao-session` |
| `lifecycle-manager.ts` | State machine per session + reaction engine | `claude-review-check` + `claude-session-status` |
| File | What | Reference Script |
| ---------------------- | ---------------------------------------------------------------- | ----------------------------------------------- |
| `metadata.ts` | Flat-file metadata read/write (key=value) | Metadata parsing in all session managers |
| `event-bus.ts` | In-process pub/sub + JSONL persistence | New (inspired by OpenHands event stream) |
| `tmux.ts` | tmux command wrappers (list, new, send-keys, capture-pane, kill) | All scripts that call tmux |
| `session-manager.ts` | Session CRUD: spawn, list, kill, cleanup, send message | `claude-ao-session` |
| `lifecycle-manager.ts` | State machine per session + reaction engine | `claude-review-check` + `claude-session-status` |
**Key complexity**: session-manager.ts orchestrates Runtime + Agent + Workspace plugins together. lifecycle-manager.ts runs the polling loop and triggers reactions.
@ -74,17 +77,18 @@ Creates the monorepo scaffold and ALL type definitions. After this, every agent
---
### Agent 2: Runtime + Workspace Plugins
**Packages**: `packages/plugins/runtime-tmux/`, `runtime-process/`, `workspace-worktree/`, `workspace-clone/`
**Branch**: `feat/runtime-workspace-plugins`
**Depends on**: Phase 0 types only
**Blocked by**: Nothing after Phase 0
| Plugin | What | Reference |
|--------|------|-----------|
| `runtime-tmux` | Create/destroy tmux sessions, send-keys, capture-pane, alive check | `claude-ao-session` new/kill |
| `runtime-process` | Spawn child processes, stdin/stdout, signal handling | New (for headless `claude -p`) |
| `workspace-worktree` | `git worktree add/remove/list`, branch naming, symlinks | `claude-ao-session` worktree logic |
| `workspace-clone` | `git clone`, cleanup | New (for Docker/cloud runtimes) |
| Plugin | What | Reference |
| -------------------- | ------------------------------------------------------------------ | ---------------------------------- |
| `runtime-tmux` | Create/destroy tmux sessions, send-keys, capture-pane, alive check | `claude-ao-session` new/kill |
| `runtime-process` | Spawn child processes, stdin/stdout, signal handling | New (for headless `claude -p`) |
| `workspace-worktree` | `git worktree add/remove/list`, branch naming, symlinks | `claude-ao-session` worktree logic |
| `workspace-clone` | `git clone`, cleanup | New (for Docker/cloud runtimes) |
**Key complexity**: runtime-tmux must handle send-keys with proper escaping, busy detection, and the wait-for-idle pattern from `send-to-session`.
@ -93,16 +97,17 @@ Creates the monorepo scaffold and ALL type definitions. After this, every agent
---
### Agent 3: Agent Plugins
**Packages**: `packages/plugins/agent-claude-code/`, `agent-codex/`, `agent-aider/`
**Branch**: `feat/agent-plugins`
**Depends on**: Phase 0 types only
**Blocked by**: Nothing after Phase 0
| Plugin | What | Reference |
|--------|------|-----------|
| Plugin | What | Reference |
| ------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------- |
| `agent-claude-code` | Launch cmd, JSONL activity detection, process tree walk, introspection | `claude-status`, `get-claude-session-info`, `claude-session-status` |
| `agent-codex` | Launch cmd, process detection | New |
| `agent-aider` | Launch cmd, process detection | New |
| `agent-codex` | Launch cmd, process detection | New |
| `agent-aider` | Launch cmd, process detection | New |
**Key complexity**: `agent-claude-code` has the richest activity detection — reading JSONL session files, extracting summaries, walking process trees from tmux pane PID to find `claude` process, detecting working/idle/stuck/blocked states.
@ -111,16 +116,17 @@ Creates the monorepo scaffold and ALL type definitions. After this, every agent
---
### Agent 4: SCM + Tracker Plugins
**Packages**: `packages/plugins/scm-github/`, `tracker-github/`, `tracker-linear/`
**Branch**: `feat/scm-tracker-plugins`
**Depends on**: Phase 0 types only
**Blocked by**: Nothing after Phase 0
| Plugin | What | Reference |
|--------|------|-----------|
| `scm-github` | PR detection, CI checks, review comments, automated comments, merge readiness, merge | `claude-review-check`, `claude-bugbot-fix`, dashboard PR fetching |
| `tracker-github` | Issue fetch, completion check, branch naming, prompt generation | `claude-splitly-session` (GitHub Issues) |
| `tracker-linear` | Issue fetch via GraphQL, completion check, branch naming | `claude-ao-session` + `claude-integrator-session` Linear checks |
| Plugin | What | Reference |
| ---------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
| `scm-github` | PR detection, CI checks, review comments, automated comments, merge readiness, merge | `claude-review-check`, `claude-bugbot-fix`, dashboard PR fetching |
| `tracker-github` | Issue fetch, completion check, branch naming, prompt generation | `claude-splitly-session` (GitHub Issues) |
| `tracker-linear` | Issue fetch via GraphQL, completion check, branch naming | `claude-ao-session` + `claude-integrator-session` Linear checks |
**Key complexity**: `scm-github` is the largest — it covers PR state, CI checks (gh pr checks), review decision (gh pr view), inline review comments (gh api), automated bot comments (cursor[bot], bugbot), and merge readiness.
@ -129,22 +135,23 @@ Creates the monorepo scaffold and ALL type definitions. After this, every agent
---
### Agent 5: CLI
**Package**: `packages/cli/`
**Branch**: `feat/cli`
**Depends on**: Phase 0 types + core interfaces (codes against interfaces, wires up when core is ready)
**Partially blocked by**: Agent 1 (core services) for runtime testing
| Command | What | Reference Script |
|---------|------|-----------------|
| `ao init` | Interactive setup wizard → `agent-orchestrator.yaml` | New |
| `ao status` | Colored terminal table of all sessions | `claude-status` |
| `ao spawn <project> [issue]` | Spawn single session | `claude-spawn` |
| `ao batch-spawn <project> <issues...>` | Batch spawn with dedup | `claude-batch-spawn` |
| `ao session ls\|kill\|cleanup` | Session management | `claude-ao-session` ls/kill/cleanup |
| `ao send <session> <message>` | Smart message delivery | `send-to-session` |
| `ao review-check [project]` | Trigger PR review fixes | `claude-review-check` |
| `ao dashboard` | Start web server | `claude-dashboard` |
| `ao open [session\|all]` | Open terminal tabs | `claude-open-all`, `open-iterm-tab` |
| Command | What | Reference Script |
| -------------------------------------- | ---------------------------------------------------- | ----------------------------------- |
| `ao init` | Interactive setup wizard → `agent-orchestrator.yaml` | New |
| `ao status` | Colored terminal table of all sessions | `claude-status` |
| `ao spawn <project> [issue]` | Spawn single session | `claude-spawn` |
| `ao batch-spawn <project> <issues...>` | Batch spawn with dedup | `claude-batch-spawn` |
| `ao session ls\|kill\|cleanup` | Session management | `claude-ao-session` ls/kill/cleanup |
| `ao send <session> <message>` | Smart message delivery | `send-to-session` |
| `ao review-check [project]` | Trigger PR review fixes | `claude-review-check` |
| `ao dashboard` | Start web server | `claude-dashboard` |
| `ao open [session\|all]` | Open terminal tabs | `claude-open-all`, `open-iterm-tab` |
**Key complexity**: `ao status` needs rich terminal output (colors, columns, live data). `ao batch-spawn` needs duplicate detection.
@ -155,23 +162,24 @@ Creates the monorepo scaffold and ALL type definitions. After this, every agent
---
### Agent 6: Web Dashboard
**Package**: `packages/web/`
**Branch**: `feat/web-dashboard`
**Depends on**: Phase 0 types + core interfaces
**Partially blocked by**: Agent 1 (core services) for API routes
| Component | What | Reference |
|-----------|------|-----------|
| Next.js setup | App Router, Tailwind, dark theme | New |
| `GET /api/sessions` | List all sessions with full state | `claude-dashboard` /api/sessions |
| `POST /api/spawn` | Spawn new session | New |
| `POST /api/sessions/:id/send` | Send message to session | New |
| `POST /api/sessions/:id/kill` | Kill session | New |
| `POST /api/prs/:id/merge` | Merge PR | New |
| `GET /api/events` | SSE stream for real-time updates | New (replaces polling) |
| Dashboard page | Attention-prioritized session cards | `claude-dashboard` HTML |
| Session detail page | Full session info + terminal embed | New |
| Components | SessionCard, PRStatus, CIBadge, AttentionZone | `claude-dashboard` HTML |
| Component | What | Reference |
| ----------------------------- | --------------------------------------------- | -------------------------------- |
| Next.js setup | App Router, Tailwind, dark theme | New |
| `GET /api/sessions` | List all sessions with full state | `claude-dashboard` /api/sessions |
| `POST /api/spawn` | Spawn new session | New |
| `POST /api/sessions/:id/send` | Send message to session | New |
| `POST /api/sessions/:id/kill` | Kill session | New |
| `POST /api/prs/:id/merge` | Merge PR | New |
| `GET /api/events` | SSE stream for real-time updates | New (replaces polling) |
| Dashboard page | Attention-prioritized session cards | `claude-dashboard` HTML |
| Session detail page | Full session info + terminal embed | New |
| Components | SessionCard, PRStatus, CIBadge, AttentionZone | `claude-dashboard` HTML |
**Key complexity**: SSE endpoint that streams lifecycle events in real-time. Attention-zone layout. xterm.js terminal embed.
@ -182,18 +190,19 @@ Creates the monorepo scaffold and ALL type definitions. After this, every agent
---
### Agent 7: Notifier + Terminal Plugins
**Packages**: `packages/plugins/notifier-desktop/`, `notifier-slack/`, `notifier-webhook/`, `terminal-iterm2/`, `terminal-web/`
**Branch**: `feat/notifier-terminal-plugins`
**Depends on**: Phase 0 types only
**Blocked by**: Nothing after Phase 0
| Plugin | What | Reference |
|--------|------|-----------|
| `notifier-desktop` | OS notifications (node-notifier), click → deep link to dashboard | `notify-session` |
| `notifier-slack` | Slack webhook messages with action buttons | New |
| `notifier-webhook` | Generic HTTP POST | New |
| `terminal-iterm2` | AppleScript tab management, reuse existing tabs | `open-iterm-tab`, `claude-open-all` |
| `terminal-web` | xterm.js config for web-based terminal | New |
| Plugin | What | Reference |
| ------------------ | ---------------------------------------------------------------- | ----------------------------------- |
| `notifier-desktop` | OS notifications (node-notifier), click → deep link to dashboard | `notify-session` |
| `notifier-slack` | Slack webhook messages with action buttons | New |
| `notifier-webhook` | Generic HTTP POST | New |
| `terminal-iterm2` | AppleScript tab management, reuse existing tabs | `open-iterm-tab`, `claude-open-all` |
| `terminal-web` | xterm.js config for web-based terminal | New |
**Key complexity**: `notifier-desktop` needs to be cross-platform (macOS/Linux/Windows). `terminal-iterm2` has AppleScript quirks (string length limits, tab detection).
@ -225,6 +234,7 @@ Phase 2 (integration): after all agents done
### True Independence
These agents are **truly independent** after Phase 0:
- Agents 2, 3, 4, 7 implement plugin interfaces → zero inter-dependency
- Agent 1 (core) is the critical path
- Agents 5, 6 can start with mock/interface-only imports, wire later
@ -232,6 +242,7 @@ These agents are **truly independent** after Phase 0:
### Risk: Agent 1 (Core) Is the Bottleneck
If core services are delayed, CLI and Web can't fully test. Mitigations:
- Agent 1 gets the most experienced agent
- Phase 0 writes enough core scaffolding (types, config, plugin-registry) that other agents aren't waiting
- CLI and Web start with mock implementations
@ -240,15 +251,15 @@ If core services are delayed, CLI and Web can't fully test. Mitigations:
## Linear Tickets (for spawning)
| Ticket | Title | Agent |
|--------|-------|-------|
| AO-10 | Implement core services (metadata, event-bus, session-manager, lifecycle-manager) | 1 |
| AO-11 | Implement runtime + workspace plugins (tmux, process, worktree, clone) | 2 |
| AO-12 | Implement agent plugins (claude-code, codex, aider) | 3 |
| AO-13 | Implement SCM + tracker plugins (github SCM, github tracker, linear tracker) | 4 |
| AO-14 | Implement CLI (ao init, status, spawn, session, send, review-check, dashboard, open) | 5 |
| AO-15 | Implement web dashboard (Next.js, API routes, SSE, attention-zone UI, session detail) | 6 |
| AO-16 | Implement notifier + terminal plugins (desktop, slack, webhook, iterm2, web) | 7 |
| Ticket | Title | Agent |
| ------ | ------------------------------------------------------------------------------------- | ----- |
| AO-10 | Implement core services (metadata, event-bus, session-manager, lifecycle-manager) | 1 |
| AO-11 | Implement runtime + workspace plugins (tmux, process, worktree, clone) | 2 |
| AO-12 | Implement agent plugins (claude-code, codex, aider) | 3 |
| AO-13 | Implement SCM + tracker plugins (github SCM, github tracker, linear tracker) | 4 |
| AO-14 | Implement CLI (ao init, status, spawn, session, send, review-check, dashboard, open) | 5 |
| AO-15 | Implement web dashboard (Next.js, API routes, SSE, attention-zone UI, session detail) | 6 |
| AO-16 | Implement notifier + terminal plugins (desktop, slack, webhook, iterm2, web) | 7 |
## Spawning Command

View File

@ -43,7 +43,7 @@ export default tseslint.config(
"no-template-curly-in-string": "warn",
"prefer-const": "error",
"no-var": "error",
"eqeqeq": ["error", "always"],
eqeqeq: ["error", "always"],
// TypeScript
"@typescript-eslint/no-unused-vars": [
@ -51,10 +51,7 @@ export default tseslint.config(
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/consistent-type-imports": [
"error",
{ prefer: "type-imports" },
],
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-require-imports": "error",
},
@ -76,5 +73,5 @@ export default tseslint.config(
rules: {
"no-console": "off", // Next.js uses console for server logs
},
}
},
);

View File

@ -1,10 +1,10 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdirSync, rmSync, readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { createEventBus, createEvent } from "../event-bus.js";
import type { OrchestratorEvent, EventType } from "../types.js";
import type { OrchestratorEvent } from "../types.js";
let tmpDir: string;
@ -36,16 +36,37 @@ describe("createEvent", () => {
});
it("infers priority from event type", () => {
expect(createEvent("session.stuck", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("urgent");
expect(createEvent("session.needs_input", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("urgent");
expect(createEvent("session.errored", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("urgent");
expect(createEvent("review.approved", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("action");
expect(createEvent("merge.ready", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("action");
expect(createEvent("merge.completed", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("action");
expect(createEvent("ci.failing", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("warning");
expect(createEvent("review.changes_requested", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("warning");
expect(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("info");
expect(createEvent("pr.created", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("info");
expect(
createEvent("session.stuck", { sessionId: "a", projectId: "p", message: "" }).priority,
).toBe("urgent");
expect(
createEvent("session.needs_input", { sessionId: "a", projectId: "p", message: "" }).priority,
).toBe("urgent");
expect(
createEvent("session.errored", { sessionId: "a", projectId: "p", message: "" }).priority,
).toBe("urgent");
expect(
createEvent("review.approved", { sessionId: "a", projectId: "p", message: "" }).priority,
).toBe("action");
expect(
createEvent("merge.ready", { sessionId: "a", projectId: "p", message: "" }).priority,
).toBe("action");
expect(
createEvent("merge.completed", { sessionId: "a", projectId: "p", message: "" }).priority,
).toBe("action");
expect(
createEvent("ci.failing", { sessionId: "a", projectId: "p", message: "" }).priority,
).toBe("warning");
expect(
createEvent("review.changes_requested", { sessionId: "a", projectId: "p", message: "" })
.priority,
).toBe("warning");
expect(
createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }).priority,
).toBe("info");
expect(
createEvent("pr.created", { sessionId: "a", projectId: "p", message: "" }).priority,
).toBe("info");
});
it("allows explicit priority override", () => {
@ -124,7 +145,9 @@ describe("createEventBus (no persistence)", () => {
const bus = createEventBus(null);
const received: OrchestratorEvent[] = [];
bus.on("session.spawned", () => { throw new Error("boom"); });
bus.on("session.spawned", () => {
throw new Error("boom");
});
bus.on("session.spawned", (e) => received.push(e));
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
@ -206,7 +229,13 @@ describe("getHistory", () => {
const bus = createEventBus(null);
for (let i = 0; i < 10; i++) {
bus.emit(createEvent("session.spawned", { sessionId: `s-${i}`, projectId: "p", message: `msg-${i}` }));
bus.emit(
createEvent("session.spawned", {
sessionId: `s-${i}`,
projectId: "p",
message: `msg-${i}`,
}),
);
}
const result = bus.getHistory({ limit: 3 });
@ -225,7 +254,9 @@ describe("getHistory", () => {
expect(bus.getHistory({ sessionId: "a", projectId: "p1" })).toHaveLength(2);
expect(bus.getHistory({ sessionId: "a", type: "session.spawned" })).toHaveLength(2);
expect(bus.getHistory({ sessionId: "a", projectId: "p1", type: "session.spawned" })).toHaveLength(1);
expect(
bus.getHistory({ sessionId: "a", projectId: "p1", type: "session.spawned" }),
).toHaveLength(1);
});
});
@ -247,11 +278,16 @@ describe("JSONL persistence", () => {
expect(parsed.timestamp).toBeTruthy();
});
it("loads history from existing JSONL file on creation", () => {
it("loads history from existing JSONL file on creation", async () => {
const logPath = join(tmpDir, "existing.jsonl");
const event = createEvent("session.spawned", { sessionId: "old", projectId: "p", message: "old event" });
const event = createEvent("session.spawned", {
sessionId: "old",
projectId: "p",
message: "old event",
});
const serialized = JSON.stringify({ ...event, timestamp: event.timestamp.toISOString() });
require("node:fs").writeFileSync(logPath, serialized + "\n", "utf-8");
const { writeFileSync } = await import("node:fs");
writeFileSync(logPath, serialized + "\n", "utf-8");
const bus = createEventBus(logPath);
const history = bus.getHistory();

View File

@ -14,7 +14,6 @@ import type {
Runtime,
Agent,
SCM,
Notifier,
EventBus,
OrchestratorEvent,
ActivityState,

View File

@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdirSync, rmSync, readFileSync, existsSync, writeFileSync } from "node:fs";
import { mkdirSync, rmSync, readFileSync, existsSync, writeFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
@ -100,7 +100,7 @@ describe("readMetadataRaw", () => {
writeFileSync(
join(dataDir, "sessions", "raw-1"),
"worktree=/tmp/w\nbranch=main\ncustom_key=custom_value\n",
"utf-8"
"utf-8",
);
const raw = readMetadataRaw(dataDir, "raw-1");
@ -117,7 +117,7 @@ describe("readMetadataRaw", () => {
writeFileSync(
join(dataDir, "sessions", "raw-2"),
"# This is a comment\n\nkey1=value1\n\n# Another comment\nkey2=value2\n",
"utf-8"
"utf-8",
);
const raw = readMetadataRaw(dataDir, "raw-2");
@ -128,7 +128,7 @@ describe("readMetadataRaw", () => {
writeFileSync(
join(dataDir, "sessions", "raw-3"),
'runtimeHandle={"id":"foo","data":{"key":"val"}}\n',
"utf-8"
"utf-8",
);
const raw = readMetadataRaw(dataDir, "raw-3");
@ -144,7 +144,10 @@ describe("updateMetadata", () => {
status: "spawning",
});
updateMetadata(dataDir, "upd-1", { status: "working", pr: "https://github.com/org/repo/pull/1" });
updateMetadata(dataDir, "upd-1", {
status: "working",
pr: "https://github.com/org/repo/pull/1",
});
const meta = readMetadata(dataDir, "upd-1");
expect(meta!.status).toBe("working");
@ -203,7 +206,7 @@ describe("deleteMetadata", () => {
expect(existsSync(join(dataDir, "sessions", "del-1"))).toBe(false);
const archiveDir = join(dataDir, "sessions", "archive");
expect(existsSync(archiveDir)).toBe(true);
const files = require("node:fs").readdirSync(archiveDir) as string[];
const files = readdirSync(archiveDir);
expect(files.length).toBe(1);
expect(files[0]).toMatch(/^del-1_/);
});

View File

@ -1,11 +1,11 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { createSessionManager } from "../session-manager.js";
import { createEventBus } from "../event-bus.js";
import { writeMetadata, readMetadata, listMetadata } from "../metadata.js";
import { writeMetadata, readMetadata } from "../metadata.js";
import type {
OrchestratorConfig,
PluginRegistry,
@ -70,7 +70,7 @@ beforeEach(() => {
mockRegistry = {
register: vi.fn(),
get: vi.fn().mockImplementation((slot: string, name: string) => {
get: vi.fn().mockImplementation((slot: string, _name: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") return mockAgent;
if (slot === "workspace") return mockWorkspace;
@ -496,10 +496,7 @@ describe("send", () => {
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
await sm.send("app-1", "Fix the CI failures");
expect(mockRuntime.sendMessage).toHaveBeenCalledWith(
makeHandle("rt-1"),
"Fix the CI failures"
);
expect(mockRuntime.sendMessage).toHaveBeenCalledWith(makeHandle("rt-1"), "Fix the CI failures");
});
it("throws for nonexistent session", async () => {

View File

@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import * as childProcess from "node:child_process";
import {
isTmuxAvailable,
@ -18,19 +18,21 @@ vi.mock("node:child_process", () => ({
const mockExecFile = vi.mocked(childProcess.execFile);
type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void;
/** Helper to make execFile resolve with stdout. */
function mockTmuxSuccess(stdout: string) {
mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => {
(callback as Function)(null, stdout, "");
return {} as any;
(callback as ExecFileCallback)(null, stdout, "");
return {} as ReturnType<typeof childProcess.execFile>;
});
}
/** Helper to make execFile reject with an error. */
function mockTmuxError(message: string) {
mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => {
(callback as Function)(new Error(message), "", message);
return {} as any;
(callback as ExecFileCallback)(new Error(message), "", message);
return {} as ReturnType<typeof childProcess.execFile>;
});
}
@ -41,11 +43,11 @@ function mockTmuxSequence(results: Array<{ stdout?: string; error?: string }>) {
const result = results[callIndex] ?? results[results.length - 1];
callIndex++;
if (result.error) {
(callback as Function)(new Error(result.error), "", result.error);
(callback as ExecFileCallback)(new Error(result.error), "", result.error);
} else {
(callback as Function)(null, result.stdout ?? "", "");
(callback as ExecFileCallback)(null, result.stdout ?? "", "");
}
return {} as any;
return {} as ReturnType<typeof childProcess.execFile>;
});
}
@ -68,8 +70,7 @@ describe("isTmuxAvailable", () => {
describe("listSessions", () => {
it("parses tmux session list", async () => {
mockTmuxSuccess(
"app-1\tMon Jan 1 00:00:00 2025\t0\t2\n" +
"app-2\tTue Jan 2 00:00:00 2025\t1\t1\n"
"app-1\tMon Jan 1 00:00:00 2025\t0\t2\n" + "app-2\tTue Jan 2 00:00:00 2025\t1\t1\n",
);
const sessions = await listSessions();
@ -107,7 +108,7 @@ describe("hasSession", () => {
"tmux",
["has-session", "-t", "app-1"],
expect.any(Object),
expect.any(Function)
expect.any(Function),
);
});
@ -127,7 +128,7 @@ describe("newSession", () => {
"tmux",
["new-session", "-d", "-s", "test-1", "-c", "/tmp/workspace"],
expect.any(Object),
expect.any(Function)
expect.any(Function),
);
});
@ -160,12 +161,7 @@ describe("newSession", () => {
it("sends initial command after creation", async () => {
// Calls: new-session, send-keys Escape, send-keys text, send-keys Enter
mockTmuxSequence([
{ stdout: "" },
{ stdout: "" },
{ stdout: "" },
{ stdout: "" },
]);
mockTmuxSequence([{ stdout: "" }, { stdout: "" }, { stdout: "" }, { stdout: "" }]);
await newSession({ name: "test-4", cwd: "/tmp", command: "echo hello" });
@ -274,7 +270,7 @@ describe("capturePane", () => {
"tmux",
["capture-pane", "-t", "app-1", "-p", "-S", "-30"],
expect.any(Object),
expect.any(Function)
expect.any(Function),
);
});
@ -298,7 +294,7 @@ describe("killSession", () => {
"tmux",
["kill-session", "-t", "app-1"],
expect.any(Object),
expect.any(Function)
expect.any(Function),
);
});

View File

@ -88,14 +88,12 @@ const OrchestratorConfigSchema = z.object({
defaults: DefaultPluginsSchema.default({}),
projects: z.record(ProjectConfigSchema),
notifiers: z.record(NotifierConfigSchema).default({}),
notificationRouting: z
.record(z.array(z.string()))
.default({
urgent: ["desktop", "slack"],
action: ["desktop", "slack"],
warning: ["slack"],
info: ["slack"],
}),
notificationRouting: z.record(z.array(z.string())).default({
urgent: ["desktop", "slack"],
action: ["desktop", "slack"],
warning: ["slack"],
info: ["slack"],
}),
reactions: z.record(ReactionConfigSchema).default({}),
});
@ -124,9 +122,7 @@ function expandPaths(config: OrchestratorConfig): OrchestratorConfig {
}
/** Apply defaults to project configs */
function applyProjectDefaults(
config: OrchestratorConfig
): OrchestratorConfig {
function applyProjectDefaults(config: OrchestratorConfig): OrchestratorConfig {
for (const [id, project] of Object.entries(config.projects)) {
// Derive name from project ID if not set
if (!project.name) {
@ -153,10 +149,8 @@ function applyProjectDefaults(
}
/** Apply default reactions */
function applyDefaultReactions(
config: OrchestratorConfig
): OrchestratorConfig {
const defaults: Record<string, typeof config.reactions[string]> = {
function applyDefaultReactions(config: OrchestratorConfig): OrchestratorConfig {
const defaults: Record<string, (typeof config.reactions)[string]> = {
"ci-failed": {
auto: true,
action: "send-to-agent",
@ -175,15 +169,13 @@ function applyDefaultReactions(
"bugbot-comments": {
auto: true,
action: "send-to-agent",
message:
"Automated review comments found on your PR. Fix the issues flagged by the bot.",
message: "Automated review comments found on your PR. Fix the issues flagged by the bot.",
escalateAfter: "30m",
},
"merge-conflicts": {
auto: true,
action: "send-to-agent",
message:
"Your branch has merge conflicts. Rebase on the default branch and resolve them.",
message: "Your branch has merge conflicts. Rebase on the default branch and resolve them.",
escalateAfter: "15m",
},
"approved-and-green": {
@ -252,9 +244,7 @@ export function loadConfig(configPath?: string): OrchestratorConfig {
const path = configPath ?? findConfigFile();
if (!path) {
throw new Error(
"No agent-orchestrator.yaml found. Run `ao init` to create one."
);
throw new Error("No agent-orchestrator.yaml found. Run `ao init` to create one.");
}
const raw = readFileSync(path, "utf-8");

View File

@ -9,7 +9,7 @@
*/
import { appendFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { dirname } from "node:path";
import { randomUUID } from "node:crypto";
import type {
EventBus,
@ -33,7 +33,7 @@ export function createEvent(
message: string;
priority?: EventPriority;
data?: Record<string, unknown>;
}
},
): OrchestratorEvent {
return {
id: randomUUID(),
@ -52,7 +52,12 @@ function inferPriority(type: EventType): EventPriority {
if (type.includes("stuck") || type.includes("needs_input") || type.includes("errored")) {
return "urgent";
}
if (type.includes("approved") || type.includes("ready") || type.includes("merged") || type.includes("completed")) {
if (
type.includes("approved") ||
type.includes("ready") ||
type.includes("merged") ||
type.includes("completed")
) {
return "action";
}
if (type.includes("fail") || type.includes("changes_requested") || type.includes("conflicts")) {

View File

@ -40,48 +40,72 @@ function parseDuration(str: string): number {
if (!match) return 0;
const value = parseInt(match[1], 10);
switch (match[2]) {
case "s": return value * 1000;
case "m": return value * 60_000;
case "h": return value * 3_600_000;
default: return 0;
case "s":
return value * 1000;
case "m":
return value * 60_000;
case "h":
return value * 3_600_000;
default:
return 0;
}
}
/** Determine which event type corresponds to a status transition. */
function statusToEventType(
_from: SessionStatus | undefined,
to: SessionStatus
): EventType | null {
function statusToEventType(_from: SessionStatus | undefined, to: SessionStatus): EventType | null {
switch (to) {
case "working": return "session.working";
case "pr_open": return "pr.created";
case "ci_failed": return "ci.failing";
case "review_pending": return "review.pending";
case "changes_requested": return "review.changes_requested";
case "approved": return "review.approved";
case "mergeable": return "merge.ready";
case "merged": return "merge.completed";
case "needs_input": return "session.needs_input";
case "stuck": return "session.stuck";
case "errored": return "session.errored";
case "killed": return "session.killed";
default: return null;
case "working":
return "session.working";
case "pr_open":
return "pr.created";
case "ci_failed":
return "ci.failing";
case "review_pending":
return "review.pending";
case "changes_requested":
return "review.changes_requested";
case "approved":
return "review.approved";
case "mergeable":
return "merge.ready";
case "merged":
return "merge.completed";
case "needs_input":
return "session.needs_input";
case "stuck":
return "session.stuck";
case "errored":
return "session.errored";
case "killed":
return "session.killed";
default:
return null;
}
}
/** Map event type to reaction config key. */
function eventToReactionKey(eventType: EventType): string | null {
switch (eventType) {
case "ci.failing": return "ci-failed";
case "review.changes_requested": return "changes-requested";
case "automated_review.found": return "bugbot-comments";
case "merge.conflicts": return "merge-conflicts";
case "merge.ready": return "approved-and-green";
case "session.stuck": return "agent-stuck";
case "session.needs_input": return "agent-needs-input";
case "session.killed": return "agent-exited";
case "summary.all_complete": return "all-complete";
default: return null;
case "ci.failing":
return "ci-failed";
case "review.changes_requested":
return "changes-requested";
case "automated_review.found":
return "bugbot-comments";
case "merge.conflicts":
return "merge-conflicts";
case "merge.ready":
return "approved-and-green";
case "session.stuck":
return "agent-stuck";
case "session.needs_input":
return "agent-needs-input";
case "session.killed":
return "agent-exited";
case "summary.all_complete":
return "all-complete";
default:
return null;
}
}
@ -99,9 +123,7 @@ interface ReactionTracker {
}
/** Create a LifecycleManager instance. */
export function createLifecycleManager(
deps: LifecycleManagerDeps
): LifecycleManager {
export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleManager {
const { config, registry, sessionManager, eventBus } = deps;
const states = new Map<SessionId, SessionStatus>();
@ -124,13 +146,21 @@ export function createLifecycleManager(
const typeHandlers = handlers.get(event.type);
if (typeHandlers) {
for (const handler of typeHandlers) {
try { handler(event); } catch { /* ignore */ }
try {
handler(event);
} catch {
/* ignore */
}
}
}
const wildcardHandlers = handlers.get("*");
if (wildcardHandlers) {
for (const handler of wildcardHandlers) {
try { handler(event); } catch { /* ignore */ }
try {
handler(event);
} catch {
/* ignore */
}
}
}
}
@ -140,20 +170,12 @@ export function createLifecycleManager(
const project = config.projects[session.projectId];
if (!project) return session.status;
const agent = registry.get<Agent>(
"agent",
project.agent ?? config.defaults.agent
);
const scm = project.scm
? registry.get<SCM>("scm", project.scm.plugin)
: null;
const agent = registry.get<Agent>("agent", project.agent ?? config.defaults.agent);
const scm = project.scm ? registry.get<SCM>("scm", project.scm.plugin) : null;
// 1. Check if runtime is alive
if (session.runtimeHandle) {
const runtime = registry.get<Runtime>(
"runtime",
project.runtime ?? config.defaults.runtime
);
const runtime = registry.get<Runtime>("runtime", project.runtime ?? config.defaults.runtime);
if (runtime) {
const alive = await runtime.isAlive(session.runtimeHandle).catch(() => true);
if (!alive) return "killed";
@ -204,7 +226,7 @@ export function createLifecycleManager(
async function executeReaction(
event: OrchestratorEvent,
reactionKey: string,
reactionConfig: ReactionConfig
reactionConfig: ReactionConfig,
): Promise<ReactionResult> {
const trackerKey = `${event.sessionId}:${reactionKey}`;
let tracker = reactionTrackers.get(trackerKey);
@ -263,7 +285,7 @@ export function createLifecycleManager(
projectId: event.projectId,
message: `Reaction '${reactionKey}' sent message to agent`,
data: { reactionKey, attempts: tracker.attempts },
})
}),
);
return {
@ -274,13 +296,12 @@ export function createLifecycleManager(
escalated: false,
};
} catch {
// Send failed — escalate
await notifyHuman(event, reactionConfig.priority ?? "warning");
// Send failed — allow retry on next poll cycle (don't escalate immediately)
return {
reactionType: reactionKey,
success: false,
action: "send-to-agent",
escalated: true,
escalated: false,
};
}
}
@ -319,10 +340,7 @@ export function createLifecycleManager(
}
/** Send a notification to all configured notifiers. */
async function notifyHuman(
event: OrchestratorEvent,
priority: EventPriority
): Promise<void> {
async function notifyHuman(event: OrchestratorEvent, priority: EventPriority): Promise<void> {
const eventWithPriority = { ...event, priority };
const notifierNames = config.notificationRouting[priority] ?? config.defaults.notifiers;
@ -355,6 +373,15 @@ export function createLifecycleManager(
allCompleteEmitted = false;
}
// Clear reaction trackers for the old status so retries reset on state changes
const oldEventType = statusToEventType(undefined, oldStatus);
if (oldEventType) {
const oldReactionKey = eventToReactionKey(oldEventType);
if (oldReactionKey) {
reactionTrackers.delete(`${session.id}:${oldReactionKey}`);
}
}
// Emit transition event
const eventType = statusToEventType(oldStatus, newStatus);
if (eventType) {
@ -373,16 +400,10 @@ export function createLifecycleManager(
if (reactionKey) {
// Check project-specific overrides first, then global
const project = config.projects[session.projectId];
const reactionConfig =
project?.reactions?.[reactionKey] ??
config.reactions[reactionKey];
const reactionConfig = project?.reactions?.[reactionKey] ?? config.reactions[reactionKey];
if (reactionConfig && reactionConfig.auto !== false && reactionConfig.action) {
await executeReaction(
event,
reactionKey,
reactionConfig as ReactionConfig
);
await executeReaction(event, reactionKey, reactionConfig as ReactionConfig);
}
}
}
@ -401,16 +422,20 @@ export function createLifecycleManager(
try {
const sessions = await sessionManager.list();
const activeSessions = sessions.filter(
(s) => s.status !== "merged" && s.status !== "killed"
);
// Include sessions that are active OR whose status changed from what we last saw
// (e.g., list() detected a dead runtime and marked it "killed" — we need to
// process that transition even though the new status is terminal)
const sessionsToCheck = sessions.filter((s) => {
if (s.status !== "merged" && s.status !== "killed") return true;
const tracked = states.get(s.id);
return tracked !== undefined && tracked !== s.status;
});
// Poll all active sessions concurrently
await Promise.allSettled(
activeSessions.map((s) => checkSession(s))
);
// Poll all sessions concurrently
await Promise.allSettled(sessionsToCheck.map((s) => checkSession(s)));
// Check if all sessions are complete (emit only once)
const activeSessions = sessions.filter((s) => s.status !== "merged" && s.status !== "killed");
if (sessions.length > 0 && activeSessions.length === 0 && !allCompleteEmitted) {
allCompleteEmitted = true;
const event = createEvent("summary.all_complete", {

View File

@ -12,7 +12,14 @@
* issue=https://linear.app/team/issue/INT-1234
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from "node:fs";
import {
readFileSync,
writeFileSync,
existsSync,
mkdirSync,
unlinkSync,
readdirSync,
} from "node:fs";
import { join, dirname } from "node:path";
import type { SessionId, SessionMetadata } from "./types.js";
@ -37,10 +44,12 @@ function parseMetadataFile(content: string): Record<string, string> {
/** Serialize a record back to key=value format. */
function serializeMetadata(data: Record<string, string>): string {
return Object.entries(data)
.filter(([, v]) => v !== undefined && v !== "")
.map(([k, v]) => `${k}=${v}`)
.join("\n") + "\n";
return (
Object.entries(data)
.filter(([, v]) => v !== undefined && v !== "")
.map(([k, v]) => `${k}=${v}`)
.join("\n") + "\n"
);
}
/** Validate sessionId to prevent path traversal. */
@ -61,10 +70,7 @@ function metadataPath(dataDir: string, sessionId: SessionId): string {
/**
* Read metadata for a session. Returns null if the file doesn't exist.
*/
export function readMetadata(
dataDir: string,
sessionId: SessionId
): SessionMetadata | null {
export function readMetadata(dataDir: string, sessionId: SessionId): SessionMetadata | null {
const path = metadataPath(dataDir, sessionId);
if (!existsSync(path)) return null;
@ -89,7 +95,7 @@ export function readMetadata(
*/
export function readMetadataRaw(
dataDir: string,
sessionId: SessionId
sessionId: SessionId,
): Record<string, string> | null {
const path = metadataPath(dataDir, sessionId);
if (!existsSync(path)) return null;
@ -102,7 +108,7 @@ export function readMetadataRaw(
export function writeMetadata(
dataDir: string,
sessionId: SessionId,
metadata: SessionMetadata
metadata: SessionMetadata,
): void {
const path = metadataPath(dataDir, sessionId);
mkdirSync(dirname(path), { recursive: true });
@ -130,7 +136,7 @@ export function writeMetadata(
export function updateMetadata(
dataDir: string,
sessionId: SessionId,
updates: Partial<Record<string, string>>
updates: Partial<Record<string, string>>,
): void {
const path = metadataPath(dataDir, sessionId);
let existing: Record<string, string> = {};
@ -139,11 +145,12 @@ export function updateMetadata(
existing = parseMetadataFile(readFileSync(path, "utf-8"));
}
// Merge updates — delete keys set to empty string
// Merge updates — remove keys set to empty string
for (const [key, value] of Object.entries(updates)) {
if (value === undefined) continue;
if (value === "") {
delete existing[key];
const { [key]: _, ...rest } = existing;
existing = rest;
} else {
existing[key] = value;
}
@ -157,11 +164,7 @@ export function updateMetadata(
* Delete a session's metadata file.
* Optionally archive it to an `archive/` subdirectory.
*/
export function deleteMetadata(
dataDir: string,
sessionId: SessionId,
archive = true
): void {
export function deleteMetadata(dataDir: string, sessionId: SessionId, archive = true): void {
const path = metadataPath(dataDir, sessionId);
if (!existsSync(path)) return;
@ -183,7 +186,5 @@ export function listMetadata(dataDir: string): SessionId[] {
const dir = join(dataDir, "sessions");
if (!existsSync(dir)) return [];
return readdirSync(dir).filter(
(name) => name !== "archive" && !name.startsWith(".")
);
return readdirSync(dir).filter((name) => name !== "archive" && !name.startsWith("."));
}

View File

@ -29,12 +29,7 @@ import type {
PluginRegistry,
RuntimeHandle,
} from "./types.js";
import {
readMetadataRaw,
writeMetadata,
deleteMetadata,
listMetadata,
} from "./metadata.js";
import { readMetadataRaw, writeMetadata, deleteMetadata, listMetadata } from "./metadata.js";
import { createEvent } from "./event-bus.js";
/** Escape regex metacharacters in a string. */
@ -43,10 +38,7 @@ function escapeRegex(str: string): string {
}
/** Get the next session number for a project. */
function getNextSessionNumber(
existingSessions: string[],
prefix: string
): number {
function getNextSessionNumber(existingSessions: string[], prefix: string): number {
let max = 0;
const pattern = new RegExp(`^${escapeRegex(prefix)}-(\\d+)$`);
for (const name of existingSessions) {
@ -70,9 +62,19 @@ function safeJsonParse<T>(str: string): T | null {
/** Valid session statuses for validation. */
const VALID_STATUSES: ReadonlySet<string> = new Set([
"spawning", "working", "pr_open", "ci_failed", "review_pending",
"changes_requested", "approved", "mergeable", "merged",
"needs_input", "stuck", "errored", "killed",
"spawning",
"working",
"pr_open",
"ci_failed",
"review_pending",
"changes_requested",
"approved",
"mergeable",
"merged",
"needs_input",
"stuck",
"errored",
"killed",
]);
/** Validate and normalize a status string. */
@ -82,10 +84,7 @@ function validateStatus(raw: string | undefined): SessionStatus {
}
/** Reconstruct a Session object from raw metadata key=value pairs. */
function metadataToSession(
sessionId: SessionId,
meta: Record<string, string>
): Session {
function metadataToSession(sessionId: SessionId, meta: Record<string, string>): Session {
return {
id: sessionId,
projectId: meta["project"] ?? "",
@ -109,9 +108,7 @@ function metadataToSession(
runtimeHandle: meta["runtimeHandle"]
? safeJsonParse<RuntimeHandle>(meta["runtimeHandle"])
: null,
agentInfo: meta["summary"]
? { summary: meta["summary"], agentSessionId: null }
: null,
agentInfo: meta["summary"] ? { summary: meta["summary"], agentSessionId: null } : null,
createdAt: meta["createdAt"] ? new Date(meta["createdAt"]) : new Date(),
lastActivityAt: new Date(),
metadata: meta,
@ -130,24 +127,16 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
/** Resolve which plugins to use for a project. */
function resolvePlugins(project: ProjectConfig) {
const runtime = registry.get<Runtime>(
"runtime",
project.runtime ?? config.defaults.runtime
);
const agent = registry.get<Agent>(
"agent",
project.agent ?? config.defaults.agent
);
const runtime = registry.get<Runtime>("runtime", project.runtime ?? config.defaults.runtime);
const agent = registry.get<Agent>("agent", project.agent ?? config.defaults.agent);
const workspace = registry.get<Workspace>(
"workspace",
project.workspace ?? config.defaults.workspace
project.workspace ?? config.defaults.workspace,
);
const tracker = project.tracker
? registry.get<Tracker>("tracker", project.tracker.plugin)
: null;
const scm = project.scm
? registry.get<SCM>("scm", project.scm.plugin)
: null;
const scm = project.scm ? registry.get<SCM>("scm", project.scm.plugin) : null;
return { runtime, agent, workspace, tracker, scm };
}
@ -229,23 +218,16 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
} catch (err) {
// Clean up workspace if runtime creation failed
if (plugins.workspace && workspacePath !== project.path) {
try { await plugins.workspace.destroy(workspacePath); } catch { /* best effort */ }
try {
await plugins.workspace.destroy(workspacePath);
} catch {
/* best effort */
}
}
throw err;
}
// Write metadata
writeMetadata(config.dataDir, sessionId, {
worktree: workspacePath,
branch,
status: "spawning",
issue: spawnConfig.issueId,
project: spawnConfig.projectId,
createdAt: new Date().toISOString(),
runtimeHandle: JSON.stringify(handle),
});
// Post-launch setup (if agent supports it)
// Write metadata and run post-launch setup — clean up on failure
const session: Session = {
id: sessionId,
projectId: spawnConfig.projectId,
@ -262,8 +244,40 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
metadata: {},
};
if (plugins.agent.postLaunchSetup) {
await plugins.agent.postLaunchSetup(session);
try {
writeMetadata(config.dataDir, sessionId, {
worktree: workspacePath,
branch,
status: "spawning",
issue: spawnConfig.issueId,
project: spawnConfig.projectId,
createdAt: new Date().toISOString(),
runtimeHandle: JSON.stringify(handle),
});
if (plugins.agent.postLaunchSetup) {
await plugins.agent.postLaunchSetup(session);
}
} catch (err) {
// Clean up runtime and workspace on post-launch failure
try {
await plugins.runtime.destroy(handle);
} catch {
/* best effort */
}
if (plugins.workspace && workspacePath !== project.path) {
try {
await plugins.workspace.destroy(workspacePath);
} catch {
/* best effort */
}
}
try {
deleteMetadata(config.dataDir, sessionId, false);
} catch {
/* best effort */
}
throw err;
}
// Emit event
@ -273,7 +287,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
projectId: spawnConfig.projectId,
message: `Session ${sessionId} spawned${spawnConfig.issueId ? ` for ${spawnConfig.issueId}` : ""}`,
data: { branch, workspacePath, issueId: spawnConfig.issueId },
})
}),
);
return session;
@ -330,14 +344,16 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
const projectId = raw["project"] ?? "";
const project = config.projects[projectId];
// Destroy runtime
if (raw["runtimeHandle"] && project) {
const plugins = resolvePlugins(project);
if (plugins.runtime) {
const handle = safeJsonParse<RuntimeHandle>(raw["runtimeHandle"]);
if (handle) {
// Destroy runtime (attempt even without project config using default runtime)
if (raw["runtimeHandle"]) {
const handle = safeJsonParse<RuntimeHandle>(raw["runtimeHandle"]);
if (handle) {
const runtimePlugin = project
? resolvePlugins(project).runtime
: registry.get<Runtime>("runtime", config.defaults.runtime);
if (runtimePlugin) {
try {
await plugins.runtime.destroy(handle);
await runtimePlugin.destroy(handle);
} catch {
// Runtime might already be gone
}
@ -345,12 +361,14 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
}
}
// Destroy workspace
if (raw["worktree"] && project) {
const plugins = resolvePlugins(project);
if (plugins.workspace) {
// Destroy workspace (attempt even without project config using default workspace)
if (raw["worktree"]) {
const workspacePlugin = project
? resolvePlugins(project).workspace
: registry.get<Workspace>("workspace", config.defaults.workspace);
if (workspacePlugin) {
try {
await plugins.workspace.destroy(raw["worktree"]);
await workspacePlugin.destroy(raw["worktree"]);
} catch {
// Workspace might already be gone
}
@ -366,7 +384,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
sessionId,
projectId,
message: `Session ${sessionId} killed`,
})
}),
);
}
@ -400,10 +418,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
// Check if issue is completed
if (!shouldKill && session.issueId && plugins.tracker) {
try {
const completed = await plugins.tracker.isCompleted(
session.issueId,
project
);
const completed = await plugins.tracker.isCompleted(session.issueId, project);
if (completed) shouldKill = true;
} catch {
// Can't check issue — skip

View File

@ -43,7 +43,7 @@ export async function listSessions(): Promise<TmuxSessionInfo[]> {
const output = await tmux(
"list-sessions",
"-F",
"#{session_name}\t#{session_created_string}\t#{session_attached}\t#{session_windows}"
"#{session_name}\t#{session_created_string}\t#{session_attached}\t#{session_windows}",
);
return output
@ -125,7 +125,7 @@ export async function newSession(opts: NewSessionOptions): Promise<void> {
export async function sendKeys(
sessionName: string,
text: string,
pressEnter = true
pressEnter = true,
): Promise<void> {
// Clear any partial input first (matches bash reference scripts)
await tmux("send-keys", "-t", sessionName, "Escape");
@ -145,7 +145,11 @@ export async function sendKeys(
await tmux("load-buffer", "-b", bufferName, tmpFile);
await tmux("paste-buffer", "-b", bufferName, "-d", "-t", sessionName);
} finally {
try { unlinkSync(tmpFile); } catch { /* ignore cleanup errors */ }
try {
unlinkSync(tmpFile);
} catch {
/* ignore cleanup errors */
}
}
} else {
await tmux("send-keys", "-t", sessionName, text);
@ -166,18 +170,8 @@ export async function sendKeys(
* @param sessionName - tmux session name
* @param lines - Number of scrollback lines to capture (default 30)
*/
export async function capturePane(
sessionName: string,
lines = 30
): Promise<string> {
return tmux(
"capture-pane",
"-t",
sessionName,
"-p",
"-S",
`-${lines}`
);
export async function capturePane(sessionName: string, lines = 30): Promise<string> {
return tmux("capture-pane", "-t", sessionName, "-p", "-S", `-${lines}`);
}
/** Kill a tmux session. */

View File

@ -41,11 +41,11 @@ export type SessionStatus =
/** Activity state as detected by the agent plugin */
export type ActivityState =
| "active" // agent is processing (thinking, writing code)
| "idle" // agent is at prompt, waiting for input
| "active" // agent is processing (thinking, writing code)
| "idle" // agent is at prompt, waiting for input
| "waiting_input" // agent is asking a question / permission prompt
| "blocked" // agent hit an error or is stuck
| "exited"; // agent process is no longer running
| "blocked" // agent hit an error or is stuck
| "exited"; // agent process is no longer running
/** A running agent session */
export interface Session {

File diff suppressed because it is too large Load Diff