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

@ -23,7 +23,7 @@ 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 |
@ -170,6 +170,7 @@ pnpm test # run all tests
### Before Committing
Always run lint and typecheck:
```bash
pnpm lint && pnpm typecheck
```
@ -188,7 +189,7 @@ 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 |

View File

@ -50,7 +50,7 @@ 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` |
@ -67,6 +67,7 @@ You are the **orchestrator agent** for the agent-orchestrator project. You manag
## 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
@ -43,7 +43,7 @@ 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` |
@ -125,7 +125,7 @@ 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 |
@ -160,7 +160,7 @@ interface Agent {
```
| 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 |
@ -184,7 +184,7 @@ 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 |
@ -210,7 +210,7 @@ interface Tracker {
```
| Implementation | API | Auth |
|---------------|-----|------|
| ------------------ | ----------- | -------------- |
| `github` (default) | `gh` CLI | GitHub token |
| `linear` | GraphQL API | Linear API key |
| `jira` | REST API | Jira token |
@ -245,7 +245,7 @@ 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 |
@ -277,7 +277,7 @@ interface NotifyAction {
```
| 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 |
@ -301,7 +301,7 @@ interface Terminal {
```
| Implementation | How | Platform |
|---------------|-----|----------|
| ---------------- | ------------------------- | ------------- |
| `auto` (default) | Detect best available | Any |
| `iterm2` | AppleScript API | macOS |
| `web` | xterm.js in browser | Any |
@ -311,6 +311,7 @@ interface Terminal {
### 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
@ -397,7 +398,7 @@ The orchestrator operates on a simple principle: handle everything you can autom
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 |
@ -407,7 +408,7 @@ The orchestrator resolves these silently. The human is only notified if auto-res
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 |
@ -543,6 +544,7 @@ projects:
```
Everything else uses sensible defaults:
- Runtime: tmux
- Agent: claude-code
- Workspace: worktree
@ -635,7 +637,7 @@ 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 |
@ -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
@ -19,7 +19,7 @@ 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 |
@ -28,7 +28,7 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
**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 |
@ -38,6 +38,7 @@ Research into 16+ projects that orchestrate AI coding agents. The goal: understa
| **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
@ -309,7 +327,7 @@ 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 |
@ -319,12 +337,14 @@ Agent code remains the same regardless of deployment target.
### 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
@ -333,7 +353,7 @@ 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 |
@ -343,7 +363,7 @@ Agent code remains the same regardless of deployment target.
### 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` |
@ -356,12 +376,14 @@ Agent code remains the same regardless of deployment target.
## 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,13 +56,14 @@ 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 |
@ -74,13 +77,14 @@ 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 |
@ -93,13 +97,14 @@ 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 |
|--------|------|-----------|
| ------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------- |
| `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 |
@ -111,13 +116,14 @@ 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 |
@ -129,13 +135,14 @@ 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` |
@ -155,13 +162,14 @@ 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 |
@ -182,13 +190,14 @@ 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 |
@ -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
@ -241,7 +252,7 @@ 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 |

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,9 +88,7 @@ const OrchestratorConfigSchema = z.object({
defaults: DefaultPluginsSchema.default({}),
projects: z.record(ProjectConfigSchema),
notifiers: z.record(NotifierConfigSchema).default({}),
notificationRouting: z
.record(z.array(z.string()))
.default({
notificationRouting: z.record(z.array(z.string())).default({
urgent: ["desktop", "slack"],
action: ["desktop", "slack"],
warning: ["slack"],
@ -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)
return (
Object.entries(data)
.filter(([, v]) => v !== undefined && v !== "")
.map(([k, v]) => `${k}=${v}`)
.join("\n") + "\n";
.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,9 +244,41 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
metadata: {},
};
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
eventBus.emit(
@ -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) {
// 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. */

File diff suppressed because it is too large Load Diff