Skip to main content

borg/harness/
mod.rs

1//! The coding agents borg runs in ("harnesses"), each one module behind one trait.
2//!
3//! Every harness already speaks one canonical, Claude-shaped hook event and gets a Claude-shaped answer
4//! back (hook.rs `dispatch`): a TS adapter translates for omp and opencode, Claude Code's command hooks need
5//! none, and Codex's need only its patch tool turned into an edit (`Harness::normalise`).
6//! So the core differs per harness only in data: how `borg install` wires it ([`Wiring`], `target`),
7//! what reaches its model and when ([`Caps`]), where the turn's work and the drone's last message come
8//! from ([`Transcript`]), and the stop's time budget. The hook (hook.rs), install (install.rs) and
9//! every other module ask these, never the harness's name.
10//!
11//! Adding a harness: one module here, a line in [`ALL`], and an adapter where the harness has no command
12//! hooks (docs/harnesses.md has the checklist; the conformance suites prove the basics).
13
14use std::path::{Path, PathBuf};
15use std::time::Duration;
16
17use serde_json::Value;
18
19pub mod claude;
20pub mod codex;
21pub mod omp;
22pub mod opencode;
23pub mod sim;
24pub mod wiring;
25
26/// One coding agent borg can run in.
27pub trait Harness: Sync {
28    /// The CLI argument (`borg hook <name>`, `borg install <name>`), `BORG_HARNESS` and presence's `harness`.
29    fn name(&self) -> &'static str;
30    fn caps(&self) -> Caps;
31    /// What `borg install` writes.
32    fn wiring(&self) -> Wiring;
33    /// The config file `borg install` writes, under `home`; `var` reads the harness's own config-dir
34    /// variables (`--home` gives none, so nothing outside `home` is reached).
35    fn target(&self, home: &Path, var: &dyn Fn(&str) -> Option<PathBuf>) -> PathBuf;
36    /// What `borg doctor` asks of the harness's CLI.
37    fn probe(&self) -> Probe;
38    /// Left out of help, completions, `all`, status and doctor (the simulated harness).
39    fn hidden(&self) -> bool {
40        false
41    }
42    /// The native hook payload as the canonical event. Default: it is one already.
43    fn normalise(&self, raw: Value) -> Value {
44        raw
45    }
46    /// What `borg doctor` checks beyond the CLI's version line ([`Probe`]), e.g. that the harness trusts
47    /// borg's hooks; `target` is the config `borg install` writes. Default: nothing more.
48    fn checks(&self, _target: &Path) -> Vec<(Level, String)> {
49        Vec::new()
50    }
51    /// What `borg install` tells the engineer to do in the harness itself (e.g. trust the new hooks).
52    fn install_notes(&self) -> Vec<String> {
53        Vec::new()
54    }
55    /// The groups of sub-agents its sessions ran under `home` (review_fix.rs), oldest first: of
56    /// `session` only (as its end hook names it), else of every session, from runs that started at
57    /// or after `since` (epoch ms). Default: none.
58    fn sub_runs(&self, _home: &Path, _session: Option<&str>, _since: Option<i64>) -> Vec<Run> {
59        Vec::new()
60    }
61}
62
63/// One sub-agent of a [`Run`], as its transcript shows it.
64#[derive(Debug, Clone, Default)]
65pub struct SubAgent {
66    /// Its own id (a Claude Code agent id, a Codex thread id); empty where the harness gives none.
67    pub id: String,
68    /// The id of the sub-agent that started it; empty when the session's own agent did.
69    pub parent: String,
70    /// What the run calls it (`review:rust`, `MappingReview`, a task's description).
71    pub label: String,
72    /// Its workflow phase (`Review`, `Fix`); empty where the harness has none.
73    pub phase: String,
74    /// Its agent type (`reviewer`, `task`, `explore`); empty where the harness has none.
75    pub kind: String,
76    /// Epoch ms.
77    pub started: i64,
78    pub cwd: String,
79    pub prompt: String,
80    /// Every tool input and text it produced.
81    pub work: String,
82    /// Its last text to whoever started it: its answer (where a harness has no structured output, the
83    /// only place findings can be).
84    pub final_text: String,
85    /// Its structured output; None where it gave text only.
86    pub output: Option<Value>,
87}
88
89impl SubAgent {
90    /// A tool call's input, part of its work.
91    pub fn called(&mut self, input: &Value) {
92        self.work.push_str(&format!("{input}\n"));
93    }
94
95    /// A text it said: part of its work, and its final text until it says another.
96    pub fn said(&mut self, t: &str) {
97        self.work.push_str(&format!("{t}\n"));
98        self.final_text = t.to_string();
99    }
100}
101
102/// The sub-agents one session ran together: a Claude Code workflow run or a session's Agent-tool
103/// sub-agents, an omp session's task agents, an opencode session's subagents, a Codex session's
104/// spawned agents.
105#[derive(Debug, Clone, Default)]
106pub struct Run {
107    pub id: String,
108    pub session: String,
109    /// The workflow's or the session's name; may be empty.
110    pub name: String,
111    pub started: i64,
112    /// Its agents are the ones a plain session started one by one (Claude Code's Agent tool, Codex's
113    /// spawns), not one orchestrated run: the session's own agent built, so no sub-agent is the
114    /// builder, and they finish in any order, so a finding is named by its agent.
115    pub loose: bool,
116    /// In start order.
117    pub agents: Vec<SubAgent>,
118}
119
120/// What a harness can do with borg's answers, and what it sends. Each flag gates one behaviour of the
121/// core; a flag changes only with an evaluation behind it (docs/harnesses.md).
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub struct Caps {
124    /// Context returned at a prompt reaches the model.
125    pub prompt_context: bool,
126    /// Context returned after a tool call reaches the model before its next step (live mode's asks).
127    pub tool_context: bool,
128    /// Early delivery: a prompt or touch naming a lesson's scope gets that scope's lessons at once (hook.rs `early`).
129    pub early_delivery: bool,
130    /// A queen question is asked with a dialog (AskUserQuestion), else inline (queen.rs `render_delivery`).
131    pub question_dialog: bool,
132    pub transcript: Transcript,
133    /// `post_tool_failure` carries the engineer's denials, which become episodes (hook.rs
134    /// `capture_rejection`); without it the event is ignored.
135    pub deny_capture: bool,
136    /// Events carry the harness's pid, so a gone process reads as ended (presence.rs `state`); without
137    /// it an event's `pid` is dropped (hook.rs `main`).
138    pub sends_pid: bool,
139    /// The stop's budget when the event names none (`BORG_STOP_BUDGET_MS`, which an adapter sets): the
140    /// live wait, the stop's model call and the expert drone keep inside it (expert_route.rs
141    /// `stop_budget`). None: the expert drone keeps inside [`DEFAULT_STOP_BUDGET`], the model call
142    /// inside the model's own limit.
143    pub stop_budget: Option<Duration>,
144    /// The variable the harness sets to its session's id in every command its model runs; None: the
145    /// harness gives its shell no id (opencode).
146    pub session_env: Option<&'static str>,
147    /// The variable the harness sets to its own pid in every command its model runs, for a harness
148    /// whose shell has no session id: its events carry that pid (`sends_pid`), so presence names the
149    /// session (opencode's `OPENCODE_PID`, set by its CLI).
150    pub pid_env: Option<&'static str>,
151    /// How an idle session of the harness can be woken with a message.
152    pub drone_wake: DroneWake,
153}
154
155/// How an idle session of a harness is woken with a message.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum DroneWake {
158    /// Its stop asks the model to run a waiter in the background, whose end starts the next turn
159    /// (Claude Code).
160    Model,
161    /// Its adapter keeps a waiter for the session's life and wakes the session by the harness's own
162    /// path (omp `pi.sendMessage`, opencode `session.promptAsync`/`prompt`).
163    Plugin,
164    /// No hook can wake an idle session: its stop starts a waiter detached, which wakes the session
165    /// through the harness's CLI (codex `codex queue --thread`). Its hooks run as children of the harness
166    /// process, which sends no pid of its own.
167    Queue,
168}
169
170/// Where the turn's work and the drone's last message come from.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum Transcript {
173    /// The event's `transcript_path` is a Claude Code JSONL transcript.
174    Claude,
175    /// None: the event's own `last_assistant_message`, and the tool calls the hook logs (`.turn`).
176    None,
177}
178
179/// How `borg install` wires a harness.
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum Wiring {
182    /// Command hooks merged into a JSON file: one hook group per native event (native name, canonical
183    /// event), each running `<borg> hook <name> <event>`; `timeouts` gives a canonical event's hook a
184    /// `timeout` in seconds (the others get the harness's own default). `retired`: the hooks an older
185    /// borg installed and this one does not, which install and uninstall remove. `through_links`: the
186    /// file is often a symlink into a dotfiles repo, written through.
187    CommandHooks {
188        events: &'static [(&'static str, &'static str)],
189        timeouts: &'static [(&'static str, u64)],
190        retired: &'static [(&'static str, &'static str)],
191        through_links: bool,
192    },
193    /// A compiled-in adapter copied whole; a symlink is replaced, never written through, and uninstall
194    /// removes only a file with exactly this content.
195    AdapterFile { source: &'static str },
196}
197
198impl Wiring {
199    /// The adapter `borg install` copies whole ("" for command hooks).
200    pub fn adapter(self) -> &'static str {
201        match self {
202            Wiring::AdapterFile { source } => source,
203            Wiring::CommandHooks { .. } => "",
204        }
205    }
206}
207
208/// What `borg doctor` asks: the CLI whose `--version` it prints, and the versions borg was verified on.
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub struct Probe {
211    pub cli: &'static str,
212    /// Appended to the version line, e.g. " (adapter tested with …)"; empty for none.
213    pub tested: &'static str,
214}
215
216/// How bad a `borg doctor` finding is.
217#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
218pub enum Level {
219    Ok,
220    Warn,
221    Fail,
222}
223
224/// The stop's budget assumed where a harness names none: 60 s, Claude Code's default command-hook timeout
225/// before it became 600 s (claude.rs `STOP_TIMEOUT_S`), so a hook installed with no `timeout` on an older
226/// Claude Code is never cut off. A safe floor, not a measured limit: `borg install` gives the Stop hook 90 s.
227pub const DEFAULT_STOP_BUDGET: Duration = Duration::from_secs(60);
228
229/// Set (to anything) by the conformance suites: only then are the hidden harnesses found by name, so
230/// `borg install sim` and `borg hook sim …` are unknown to everyone else.
231pub const TEST_ENV: &str = "BORG_TEST_HARNESS";
232
233/// Every harness, in the order help, status and `all` list them. Adding one is a line here.
234pub static ALL: &[&dyn Harness] = &[&claude::Claude, &omp::Omp, &opencode::Opencode, &codex::Codex, &sim::Sim];
235
236/// The harness called `name`; a hidden one only under [`TEST_ENV`].
237pub fn by_name(name: &str) -> Option<&'static dyn Harness> {
238    let hidden_too = std::env::var_os(TEST_ENV).is_some();
239    ALL.iter().copied().find(|h| h.name() == name && (hidden_too || !h.hidden()))
240}
241
242/// The harnesses people see (help, completions, `all`, status, doctor).
243pub fn visible() -> impl Iterator<Item = &'static dyn Harness> {
244    ALL.iter().copied().filter(|h| !h.hidden())
245}
246
247/// `claude|omp|opencode|codex`: the visible names, for usage lines.
248pub fn names(sep: &str) -> String {
249    visible().map(|h| h.name()).collect::<Vec<_>>().join(sep)
250}
251
252/// The drone's harness in a process its hook started (`BORG_HARNESS`, `borg check`), else the first.
253pub fn current() -> &'static dyn Harness {
254    named_current().unwrap_or(ALL[0])
255}
256
257/// The drone's harness when its hook named it (`BORG_HARNESS`), else None.
258pub fn named_current() -> Option<&'static dyn Harness> {
259    std::env::var("BORG_HARNESS").ok().and_then(|n| by_name(&n))
260}
261
262/// The variables harnesses set to their session's id in every command their model runs and every hook
263/// they run (`Caps::session_env`), each with its harness, in [`ALL`]'s order: Claude Code's
264/// (`CLAUDE_CODE_SESSION_ID`) equals the hook input's `session_id` and follows `/clear`, but under
265/// `--continue` or `--resume` without an id it may be the startup id instead (its env-vars docs); omp's
266/// (`PI_SESSION_ID`) is its session manager's id, the one its extension sends; codex's
267/// (`CODEX_THREAD_ID`, 0.153.2, also inside its sandbox) is the thread id its hooks carry as
268/// `session_id` and `codex queue --thread` takes. opencode gives its shell no session id.
269pub fn session_vars() -> Vec<(&'static str, &'static str)> {
270    ALL.iter().filter_map(|h| h.caps().session_env.map(|v| (v, h.name()))).collect()
271}
272
273/// Every session id set in this process's environment (`session_vars`, trimmed, non-empty), each with
274/// its harness, in `session_vars`' order. More than one when a harness runs inside another's shell (omp
275/// in a Claude Code session): it inherits the outer one's variable too.
276pub fn env_sessions() -> Vec<(String, &'static str)> {
277    let var = |k: &str| std::env::var(k).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
278    session_vars().into_iter().filter_map(|(k, h)| var(k).map(|v| (v, h))).collect()
279}
280
281/// Every harness pid set in this process's environment (`Caps::pid_env`), each with its harness, in
282/// [`ALL`]'s order.
283pub fn env_pids() -> Vec<(u32, &'static str)> {
284    let pid = |k: &str| std::env::var(k).ok().and_then(|v| v.trim().parse::<u32>().ok());
285    ALL.iter().filter_map(|h| h.caps().pid_env.and_then(pid).map(|p| (p, h.name()))).collect()
286}
287
288/// The event's transcript, when the harness writes one and the event names it.
289pub fn transcript(caps: Caps, ev: &Value) -> Option<&str> {
290    match caps.transcript {
291        Transcript::Claude => ev.get("transcript_path").and_then(Value::as_str).filter(|t| !t.is_empty()),
292        Transcript::None => None,
293    }
294}
295
296/// Live mode: this turn's work so far, its last `cap` characters: from the transcript where the harness
297/// writes one ([`Transcript`]), else the turn's tool calls the hook logged in `turn_log`.
298pub fn work_so_far(caps: Caps, ev: &Value, turn_log: &Path, cap: usize) -> String {
299    if let Some(t) = transcript(caps, ev).filter(|t| std::fs::metadata(t).is_ok()) {
300        return claude::turn_so_far(t.as_ref(), cap);
301    }
302    let text = std::fs::read_to_string(turn_log).unwrap_or_default();
303    let n = text.chars().count();
304    text.chars().skip(n.saturating_sub(cap)).collect()
305}
306
307/// The drone's last message, whole: the event's `last_assistant_message`, else the transcript's last
308/// assistant text.
309pub fn last_message(caps: Caps, ev: &Value) -> String {
310    match given(ev) {
311        Some(t) => t.to_string(),
312        None => claude::last_assistant_full(transcript(caps, ev).unwrap_or_default().as_ref()),
313    }
314}
315
316/// The drone's plan, as the queen and the experts read it: [`last_message`], but a transcript's text
317/// only its last 2000 characters (`claude::last_assistant_text`).
318pub fn plan(caps: Caps, ev: &Value) -> String {
319    match given(ev) {
320        Some(t) => t.to_string(),
321        None => claude::last_assistant_text(transcript(caps, ev).unwrap_or_default().as_ref()),
322    }
323}
324
325/// The event's own non-blank `last_assistant_message`.
326fn given(ev: &Value) -> Option<&str> {
327    ev.get("last_assistant_message").and_then(Value::as_str).filter(|t| !t.trim().is_empty())
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    /// Today's flags, pinned: changing one is a product decision with an evaluation behind it, never a
335    /// side effect of a refactor.
336    #[test]
337    fn caps_are_todays() {
338        let row = |n: &str| {
339            let c = by_name(n).unwrap().caps();
340            (c.prompt_context, c.tool_context, c.early_delivery, c.question_dialog, c.transcript, c.deny_capture, c.sends_pid)
341        };
342        assert_eq!(row("claude"), (true, true, true, true, Transcript::Claude, true, false));
343        for n in ["omp", "opencode"] {
344            assert_eq!(row(n), (true, true, false, false, Transcript::None, true, true), "{n}");
345        }
346        assert_eq!(row("codex"), (true, true, false, false, Transcript::None, false, false));
347        // None: the expert drone keeps inside Claude Code's 60 s, the model call its own limit; codex: its
348        // stop hook's timeout.
349        let budgets = [("claude", None), ("omp", None), ("opencode", None), ("codex", Some(90)), ("sim", None)];
350        let got: Vec<(&str, Option<u64>)> = ALL.iter().map(|h| (h.name(), h.caps().stop_budget.map(|d| d.as_secs()))).collect();
351        assert_eq!(got, budgets, "every harness's stop budget");
352        let Wiring::CommandHooks { timeouts, through_links, .. } = by_name("claude").unwrap().wiring() else { panic!("claude has command hooks") };
353        assert_eq!((timeouts, through_links), (&[("stop", 90)][..], true), "a timeout on the stop only");
354    }
355
356    #[test]
357    fn names_are_unique_and_found_and_sim_is_hidden() {
358        for h in visible() {
359            assert_eq!(by_name(h.name()).map(|x| x.name()), Some(h.name()));
360        }
361        assert_eq!(ALL.iter().filter(|h| ALL.iter().filter(|x| x.name() == h.name()).count() > 1).count(), 0);
362        assert_eq!(names("|"), "claude|omp|opencode|codex");
363        assert!(ALL.iter().any(|h| h.name() == "sim" && h.hidden()));
364        // Known by name only under TEST_ENV (conformance_cli.rs runs `borg` with and without it).
365        assert_eq!(by_name("sim").is_some(), std::env::var_os(TEST_ENV).is_some());
366        assert!(by_name("nope").is_none());
367    }
368
369    #[test]
370    fn the_transcript_is_read_only_where_the_harness_writes_one() {
371        let ev = serde_json::json!({"transcript_path": "/nonexistent/t.jsonl", "last_assistant_message": ""});
372        assert_eq!(transcript(by_name("claude").unwrap().caps(), &ev), Some("/nonexistent/t.jsonl"));
373        assert_eq!(transcript(by_name("omp").unwrap().caps(), &ev), None);
374        let msg = serde_json::json!({"last_assistant_message": "done"});
375        assert_eq!(last_message(by_name("opencode").unwrap().caps(), &msg), "done");
376    }
377
378    #[test]
379    fn a_plan_from_a_transcript_is_its_tail_and_a_given_message_is_whole() {
380        let dir = std::env::temp_dir().join(format!("borg-harness-plan-{}", std::process::id()));
381        std::fs::create_dir_all(&dir).unwrap();
382        let t = dir.join("t.jsonl");
383        let long = "x".repeat(2500);
384        let line = serde_json::json!({"type": "assistant", "message": {"content": [{"type": "text", "text": long}]}});
385        std::fs::write(&t, format!("{line}\n")).unwrap();
386        let caps = by_name("claude").unwrap().caps();
387        let ev = serde_json::json!({"transcript_path": t, "last_assistant_message": ""});
388        assert_eq!((last_message(caps, &ev).len(), plan(caps, &ev).len()), (2500, 2000));
389        let given = serde_json::json!({"transcript_path": t, "last_assistant_message": long});
390        assert_eq!(plan(caps, &given).len(), 2500, "the event's own message is the plan, whole");
391        let _ = std::fs::remove_dir_all(dir);
392    }
393}