# 01 — Architecture ## Cargo workspace layout ``` ai-harness/ ├── Cargo.toml # [workspace] members, workspace.dependencies, lints ├── crates/ │ ├── harness-core/ # domain types, event bus, permission engine, session engine, │ │ # agent loop, agent registry, job board, config, storage, auth.json │ ├── harness-providers/ # Provider impls: copilot, openai, anthropic; auth flows; models.dev │ ├── harness-tools/ # built-in tools: bash, read, edit, write, glob, grep, todo, webfetch, task │ ├── harness-mcp/ # rmcp stdio client → Tool adapters │ ├── harness-lsp/ # LSP client pool + diagnostics service │ ├── harness-app/ # composition root: builds Engine, registers providers/tools/mcp/lsp │ └── harness-tui/ # binary `harness`: ratatui frontend over EngineHandle + event bus └── assets/ ├── agents/*.md # bundled default agent definitions (embedded via include_str!) ├── prompts/*.md # system prompt fragments (tool usage rules, environment header) └── models-snapshot.json # compiled fallback of models.dev/api.json ``` ### Boundary rationale - **`harness-core` owns the vocabulary** (`Session`/`Message`/`Part`, `AppEvent`, `LlmEvent`, `Tool` trait, `Provider` trait, `Rule`/`Ruleset`) **and the engine** (run loop, stream processor, permission service, storage actor, job board). Everything depends inward on core; core depends on nothing else in the workspace. This is the crate a future `harness-server` wraps: `AppEvent` is `Serialize`, so an SSE endpoint is `bus.subscribe() → serde_json → SSE frames`, and `EngineHandle` is the future HTTP API surface (prompt, abort, permission reply, list sessions, login). - **Feature crates never depend on each other.** Tools reach LSP through a core trait (`DiagnosticsSource`, see [09-integrations.md](09-integrations.md)), keeping `harness-tools` decoupled from `harness-lsp`. - **`harness-app` is the only crate that knows about everything.** It reads config, constructs the `Engine`, registers built-ins. `harness-tui` today and `harness-server` tomorrow both depend on `harness-app` and get an identical engine. - **`harness-tui` contains zero business logic** — it reads `AppEvent`s and calls `EngineHandle` methods. This enforces the "TUI is a client" discipline opencode gets from its HTTP split, without the HTTP. Config and storage live inside `harness-core` (modules `config`, `store`) rather than separate crates — they're consumed only by core and app; splitting creates dependency churn for no benefit at this size. ### `harness-core` module map ``` src/ types/ # ids.rs, session.rs, message.rs, part.rs, model.rs (ModelRef, ModelInfo, TokenUsage) event.rs # AppEvent + EventBus (broadcast wrapper) llm.rs # LlmEvent, LlmRequest, FinishReason, ProviderError, Provider trait tool.rs # Tool trait, ToolCtx, ToolOutput, ToolRegistry, truncation helper permission/ # rule.rs (evaluate), service.rs (pending asks, oneshots) agent/ # def.rs (AgentDef), registry.rs (bundled + override loading), frontmatter.rs engine/ # engine.rs (Engine, EngineHandle, RunHandle), loop.rs (outer loop), # processor.rs (stream state machine), retry.rs, doomloop.rs, # compaction.rs (seam), system.rs (system prompt assembly), jobs.rs (JobBoard) config/ # schema.rs (serde), load.rs (discovery + merge + interpolation), markdown.rs store/ # schema.sql, actor.rs (rusqlite thread + mpsc), api.rs (typed async facade) auth.rs # auth.json read/write, mode 0600 ``` ## Dependencies | Crate | Why | |---|---| | `tokio` (full) | async runtime; `sync::{broadcast, mpsc, oneshot, Mutex}`, `process`, `select!` | | `tokio-util` | `CancellationToken` (abort tree: session run → tool → subagent via `child_token()`) | | `futures`, `async-stream` | `Stream` combinators; `try_stream!` to write SSE decoders as readable generators | | `reqwest` (rustls-tls, stream, json, gzip) | all HTTP; `bytes_stream()` feeds SSE decoding | | `eventsource-stream` | SSE frame parsing over any byte stream (CRLF, multi-line `data:`, comments); mature, tiny | | `serde`, `serde_json` | everything; `RawValue` for pass-through tool schemas | | `schemars` | derive JSON Schema for tool parameter structs (single source of truth for tool inputs) | | `serde_yaml_ng` | YAML frontmatter of agent/command/skill markdown (`serde_yaml` proper is archived) | | `jsonc-parser` | JSONC config files (comments + trailing commas, like opencode) | | `rusqlite` (bundled) | storage; runs on a dedicated thread behind an mpsc actor — simpler than sqlx for a single-writer embedded DB | | `ratatui` + `crossterm` (event-stream) | TUI; `crossterm::event::EventStream` integrates with `tokio::select!` | | `tui-textarea` | multi-line prompt input widget (cursor, kill-ring) | | `pulldown-cmark` | markdown parsing for the chat renderer (we write the ratatui renderer) | | `rmcp` (client, transport-child-process) | official Rust MCP SDK, stdio client | | `lsp-types` | LSP protocol types; JSON-RPC framing is ~150 lines hand-rolled (client crates are immature) | | `ignore` + `globset` | glob tool (gitignore-aware walking) and permission wildcard matching | | `grep-searcher`, `grep-regex`, `grep-matcher` | ripgrep's engine as a library — no external `rg` binary dependency | | `similar` | unified diffs for edit/write permission metadata and part records | | `ulid` | sortable ids for sessions/messages/parts (chronological `ORDER BY id`) | | `thiserror` / `anyhow` | error enums in libs; `anyhow` only in binaries | | `tracing` + `tracing-subscriber` + `tracing-appender` | logging to file (the TUI owns the terminal) | | `dirs` | XDG paths (`~/.config/ai-harness`, `~/.local/share/ai-harness`, `~/.cache/ai-harness`) | | `htmd` (or `html2text`) | webfetch HTML → markdown | | `shell-words` | tokenize bash commands for permission pattern building | | `insta` (dev) | snapshot tests for decoders and TUI TestBackend renders | | `wiremock` (dev) | HTTP mocking for provider/auth tests | Deliberately avoided: actor frameworks, generic middleware, `sqlx` (compile-time SQL is overkill for 5 tables). Native `async fn` in traits where object safety allows; `#[async_trait]` for `Tool`/`Provider` since `dyn` is needed. ## Domain model ```rust // types/ids.rs — newtypes over ULID strings pub struct SessionId(pub String); // "ses_01H..." pub struct MessageId(pub String); pub struct PartId(pub String); // types/session.rs pub struct Session { pub id: SessionId, pub parent_id: Option, pub depth: u8, // 0 = root; computed at creation from parent pub title: String, pub agent: String, pub model: ModelRef, // { provider_id, model_id } pub usage: TokenUsage, // running totals pub cost: f64, pub extra_rules: Ruleset, // session-scoped "always allow" rules pub created_at: i64, pub updated_at: i64, } pub struct TokenUsage { pub input: u64, pub output: u64, pub reasoning: u64, pub cache_read: u64, pub cache_write: u64 } // types/message.rs pub enum Role { User, Assistant } pub struct Message { pub id: MessageId, pub session_id: SessionId, pub role: Role, pub model: Option, pub agent: Option, pub usage: TokenUsage, pub cost: f64, pub finished: Option, // None while streaming pub error: Option, // aborted | provider error | output-length pub created_at: i64, } // types/part.rs — discriminated union, serde(tag = "type") pub struct Part { pub id: PartId, pub message_id: MessageId, pub session_id: SessionId, pub idx: u32, pub body: PartBody } pub enum PartBody { Text { text: String, synthetic: bool }, // synthetic = injected (job board, reminders) Reasoning { text: String, signature: Option }, Tool { call_id: String, name: String, state: ToolState }, StepStart, StepFinish{ usage: TokenUsage, cost: f64, reason: FinishReason }, Subtask { child_session: SessionId, agent: String, description: String, background: bool }, Compaction{ replaces_up_to: MessageId, summary: String }, // v1.5 } pub enum ToolState { Pending { partial_input: String }, // streaming JSON args Running { input: serde_json::Value, title: Option, metadata: serde_json::Value }, Completed { input: serde_json::Value, title: String, output: String, metadata: serde_json::Value, duration_ms: u64 }, Error { input: serde_json::Value, error: String }, } ``` ```rust // llm.rs — normalized provider stream pub enum LlmEvent { TextStart { id: String }, TextDelta { id: String, text: String }, TextEnd { id: String }, ReasoningStart { id: String }, ReasoningDelta { id: String, text: String }, ReasoningEnd { id: String, signature: Option }, ToolInputStart { call_id: String, name: String }, ToolInputDelta { call_id: String, json: String }, ToolCall { call_id: String, name: String, input: serde_json::Value }, // args complete Finish { reason: FinishReason, usage: TokenUsage }, } pub enum FinishReason { Stop, ToolCalls, Length, ContentFilter, Unknown(String) } pub type LlmEventStream = Pin> + Send>>; pub enum ProviderError { RateLimited { retry_after: Option }, Overloaded, // 5xx / "overloaded_error" ContextOverflow, // NEVER retried Auth(String), // triggers token refresh path Http { status: u16, body: String }, Network(String), Decode(String), Cancelled, } ``` ```rust // tool.rs #[async_trait] pub trait Tool: Send + Sync { fn name(&self) -> &str; fn description(&self) -> &str; fn parameters(&self) -> serde_json::Value; // JSON Schema (schemars) async fn execute(&self, input: serde_json::Value, ctx: ToolCtx) -> Result; } pub struct ToolCtx { pub session_id: SessionId, pub message_id: MessageId, pub call_id: String, pub agent: Arc, pub cwd: PathBuf, pub worktree: PathBuf, pub cancel: CancellationToken, // linked to run token pub ask: PermissionHandle, // async ask(AskInput) -> Result<(), Denied|Rejected> pub metadata: MetadataSink, // set(Value) — live-updates Running ToolState + emits PartUpdated pub engine: EngineHandle, // needed by the task tool; cheap Arc clone } pub struct ToolOutput { pub title: String, pub output: String, pub metadata: serde_json::Value } ``` ```rust // event.rs — serializable so a future server can pipe it to SSE unchanged #[derive(Clone, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum AppEvent { SessionCreated { session: Session }, SessionUpdated { session: Session }, MessageCreated { message: Message }, MessageUpdated { message: Message }, PartUpdated { part: Part }, // full-part upsert (simple, correct) PartDelta { part_id: PartId, message_id: MessageId, delta: String }, // cheap text streaming RunStarted { session_id: SessionId }, RunFinished { session_id: SessionId, outcome: RunOutcome }, PermissionAsked { request: PermissionRequest }, // {id, session_id, permission, patterns, metadata} PermissionResolved{ id: String }, JobUpdated { job: JobRecord }, AuthPrompt { provider: String, user_code: String, verification_uri: String }, ServerNotice{ level: Level, text: String }, } pub struct EventBus(broadcast::Sender); // capacity 1024; subscribers tolerate Lagged ``` ## Channel topology ``` Provider SSE ──(Stream)──▶ Processor ──▶ StorageTx (mpsc, oneshot replies) │ └──▶ EventBus (broadcast) ──▶ TUI event loop └─▶ (future) SSE handler Tool.execute ── ctx.ask() ──▶ PermissionService.pending: HashMap> TUI keypress ── EngineHandle::permission_reply(id, Once|Always|Reject) ──▶ oneshot ──▶ tool resumes EngineHandle::abort(session) ──▶ RunHandle.cancel.cancel() ──▶ token tree (tools, subagents, HTTP) Background subagent: tokio::spawn(run_loop(child)) → completion → JobBoard.update → AppEvent::JobUpdated ```