Architecture
How harness works internally — adapters, process management, and the event pipeline
Data flow
TaskConfig → AgentRunner::build_args() → spawn subprocess → read stdout lines
↓
parse_*_line() (per-adapter, returns Vec<Event>)
↓
stamp timestamps → EventStream (unified)
↓ ↓
stdout/file session logKey abstractions
AgentKind
Enum of supported agents: Claude, OpenCode, Codex, Cursor. Each variant knows its binary candidates, display name, and parse aliases.
TaskConfig
Everything needed to run a task: prompt, agent, working directory, model, permissions, output format, timeouts, system prompt overrides, extra args, and environment variables.
PermissionMode
Two modes:
- FullAccess (default) — auto-approve everything
- ReadOnly — plan/read-only mode, agent cannot make changes
Event
The unified event enum with 7 variants, all including timestamp_ms. See Event Stream for the full format.
AgentRunner trait
Each adapter implements:
build_args()— translate TaskConfig to CLI argumentsbuild_env()— extra environment variablesbinary_path()— resolve which binary to userun()— spawn and return an EventStreamcapabilities()— return AgentCapabilitiesversion()— get agent version stringvalidate_config()— check config against capabilities
spawn_and_stream()
Shared subprocess scaffolding: spawns the child in a new process group, reads stdout line-by-line, passes each line to the adapter's parser (which returns Vec<Result<Event>>), auto-stamps timestamps, collects stderr (capped at 64KB), kills child on drop.
ModelRegistry
Model name → per-agent model ID mapping. Three layers merged in priority order: project config > cached registry > builtin registry.
SessionLogger
Tees events to NDJSON files at ~/.local/share/harness/sessions/ with metadata sidecar files.
Crate layout
src/
├── main.rs # CLI entry point (clap)
├── lib.rs # Public API: run_task(), available_agents()
├── config.rs # Core types: AgentKind, PermissionMode, TaskConfig
├── error.rs # Error enum (thiserror)
├── event.rs # Unified event types (7 variants, serde tagged enum)
├── models.rs # Model registry types
├── registry.rs # Fetch + cache layer for model registry
├── runner.rs # AgentRunner trait + binary resolution
├── process.rs # spawn_and_stream() + ChildGuard
├── normalize.rs # Text normalization utilities
├── settings.rs # Config file support (harness.toml + legacy)
├── logger.rs # Session NDJSON logging
└── agents/
├── mod.rs # create_runner() factory
├── claude.rs # Claude Code adapter
├── codex.rs # Codex adapter
├── cursor.rs # Cursor adapter
└── opencode.rs # OpenCode adapterProcess safety
Agent subprocesses are managed with safety guarantees:
- ChildGuard wraps the child PID with a
Dropimplementation .process_group(0)on spawn so the agent and its children form a killable group- SIGTERM to the process group on cleanup, SIGKILL after 2s grace period
- Guard wrapped in
Arcinside the stream so dropping the stream kills the child - stderr buffer capped at 64KB to prevent memory exhaustion
Binary resolution
When looking for an agent binary:
- Check explicit path from CLI flag or config
- Check project config for agent-specific binary path
- Check legacy settings
- Try all
binary_candidates()via PATH lookup - Error if none found
Adapter pattern
Each agent adapter has a parse_*_line() function that converts one line of native output into Vec<Result<Event>> — a single line can produce multiple events (e.g. Codex file changes emit both ToolStart and ToolEnd).
The adapters handle different JSON schemas:
- Claude: NDJSON with
typefield (system, assistant, stream_event, result) - Codex: JSONL with
typefield (thread.started, item.created, thread.completed) - OpenCode: Variable JSON shapes with fallback parsing
- Cursor: NDJSON with nested
*ToolCallkeys