Skip to main content

borg/harness/
omp.rs

1//! omp (oh-my-pi): the TS extension `adapters/omp/borg.ts`, compiled in and copied to
2//! `~/.omp/agent/extensions/borg.ts` (`$PI_CODING_AGENT_DIR/extensions`). The adapter translates omp's
3//! events into borg's canonical ones and back, sends omp's pid, and gives borg the stop's budget
4//! (`BUDGETS`, which borg's first answer tells it).
5
6use std::path::{Path, PathBuf};
7use std::time::Duration;
8
9use serde_json::Value;
10
11use super::{Budgets, CONTEXT_GUARD, Caps, DroneWake, Harness, Probe, Run, SubAgent, Transcript, Wiring};
12
13pub const ADAPTER: &str = include_str!("../../assets/adapters/omp/borg.ts");
14
15/// Protocol (omp's): how long omp waits for an extension's handler of any event but `session_shutdown`
16/// before it gives up on it (aborting `session_stop`'s signal); no setting changes it. omp 18.13.1's
17/// extensions runner (`EXTENSION_HANDLER_TIMEOUT_MS`, 30000), read 2026-10-28.
18pub const HANDLER_LIMIT: Duration = Duration::from_secs(30);
19/// Protocol (omp's): its limit for a `session_shutdown` handler (`SESSION_SHUTDOWN_HANDLER_TIMEOUT_MS`,
20/// 2000, the same runner).
21pub const SHUTDOWN_LIMIT: Duration = Duration::from_secs(2);
22/// Guard: what the extension keeps of omp's limit for itself (spawning borg, reading its answer,
23/// returning it to omp), so borg's answer arrives before omp gives up on the handler.
24const STOP_MARGIN: Duration = Duration::from_secs(2);
25/// Guard: the same at the session's end, where omp's limit leaves room for less.
26const END_MARGIN: Duration = Duration::from_millis(500);
27
28/// What the extension waits for each of borg's answers. Guards: the prompt's and a tool call's end are
29/// file appends, cut off at 5 s so a hung borg never holds the engineer's turn; before a tool call borg
30/// answers with a stat when nobody steers, cut off at 2 s. Derived: the stop and the end, omp's limits
31/// less the margins.
32pub const BUDGETS: Budgets = Budgets {
33    prompt: Duration::from_secs(5),
34    pre_tool: Duration::from_secs(2),
35    post_tool: Duration::from_secs(5),
36    stop: HANDLER_LIMIT.saturating_sub(STOP_MARGIN),
37    end: SHUTDOWN_LIMIT.saturating_sub(END_MARGIN),
38};
39
40pub struct Omp;
41
42impl Harness for Omp {
43    fn name(&self) -> &'static str {
44        "omp"
45    }
46
47    fn caps(&self) -> Caps {
48        Caps {
49            prompt_context: true,
50            tool_context: true,
51            // The channels exist (borg.ts); turning these on waits for an evaluation.
52            early_delivery: false,
53            question_dialog: false,
54            transcript: Transcript::None,
55            deny_capture: true,
56            sends_pid: true,
57            // The adapter names it too (`BORG_STOP_BUDGET_MS`), as borg's answer told it.
58            stop_budget: Some(BUDGETS.stop),
59            // omp injects a tool_result's additionalContext whole, as one developer message (18.13.1,
60            // packages/agent/src/tool-context.ts), and before_agent_start's message whole: no limit.
61            context_chars: CONTEXT_GUARD,
62            session_env: Some("PI_SESSION_ID"),
63            pid_env: None,
64            drone_wake: DroneWake::Plugin,
65        }
66    }
67
68    fn wiring(&self) -> Wiring {
69        Wiring::AdapterFile { source: ADAPTER }
70    }
71
72    fn budgets(&self) -> Option<Budgets> {
73        Some(BUDGETS)
74    }
75
76    fn target(&self, home: &Path, var: &dyn Fn(&str) -> Option<PathBuf>) -> PathBuf {
77        var("PI_CODING_AGENT_DIR").unwrap_or_else(|| home.join(".omp/agent")).join("extensions/borg.ts")
78    }
79
80    fn probe(&self) -> Probe {
81        Probe { cli: "omp", tested: " (adapter tested with 18.8.5 and 18.10.4; live mode needs 18.4.4 or newer)" }
82    }
83
84    /// Each session's task agents (`$PI_CODING_AGENT_DIR/sessions`, else `home/.omp/agent/sessions`).
85    fn sub_runs(&self, home: &Path, session: Option<&str>, since: Option<i64>) -> Vec<Run> {
86        session_runs(&sessions(home), session, since)
87    }
88
89    /// `<sessions>/<slug>/<ts>_<session>.jsonl` ([`session_file`]).
90    fn session_log(&self, home: &Path, session: &str, _input: &Value) -> Option<PathBuf> {
91        session_file(&sessions(home), session)
92    }
93}
94
95// ---------------------------------------------------------------- transcripts
96
97/// Where omp keeps its sessions, `<sessions>/<slug>/<ts>_<id>.jsonl`: `$PI_CODING_AGENT_DIR/sessions`,
98/// else `home/.omp/agent/sessions`.
99pub fn sessions(home: &Path) -> PathBuf {
100    super::env_dir("PI_CODING_AGENT_DIR").unwrap_or_else(|| home.join(".omp/agent")).join("sessions")
101}
102
103/// Main session `id`'s file under `sessions`: a `<slug>/<ts>_<id>.jsonl` whose session record names `id`.
104pub fn session_file(sessions: &Path, id: &str) -> Option<PathBuf> {
105    let suffix = format!("_{id}.jsonl");
106    let named = |f: &PathBuf| f.file_name().is_some_and(|n| n.to_string_lossy().ends_with(&suffix));
107    // The session record is the file's first line: one line read per candidate.
108    let records = |f: &PathBuf| first_line(f).is_some_and(|v| v["type"] == "session" && v["id"] == id);
109    read_dir(sessions).into_iter().filter(|p| p.is_dir()).flat_map(|slug| read_dir(&slug)).filter(|f| f.is_file() && named(f)).find(records)
110}
111
112// ---------------------------------------------------------------- sub-agent sessions
113
114/// One run per main session that has sub-agents, oldest first: of `session` only when given, from
115/// sessions that started at or after `since`. A main session is `<slug>/<ts>_<id>.jsonl`, its
116/// sub-agents' sessions `<slug>/<ts>_<id>/<Task>.jsonl` (theirs nested one directory deeper, named
117/// `<Task>.<Sub>.jsonl`).
118pub fn session_runs(sessions: &Path, session: Option<&str>, since: Option<i64>) -> Vec<Run> {
119    let mut runs = Vec::new();
120    for slug in read_dir(sessions).into_iter().filter(|p| p.is_dir()) {
121        for dir in read_dir(&slug).into_iter().filter(|p| p.is_dir()) {
122            let name = dir.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
123            let suffix = name.split_once('_').map_or(name.as_str(), |x| x.1);
124            // The dir name's suffix picks the candidates, so session end reads one main file, not all.
125            if session.is_some_and(|s| s != suffix) {
126                continue;
127            }
128            // The main file's session record names the id the adapter passes, and confirms it.
129            let main = read_lines(&dir.with_extension("jsonl"));
130            let id = main.iter().find(|v| v["type"] == "session").and_then(|v| v["id"].as_str()).map_or_else(|| suffix.to_string(), String::from);
131            if session.is_some_and(|s| s != id) {
132                continue;
133            }
134            let mut agents: Vec<SubAgent> = jsonl_under(&dir).iter().map(|f| sub_agent(f)).collect();
135            if agents.is_empty() {
136                continue;
137            }
138            agents.sort_by_key(|a| a.started);
139            let title = main.iter().find_map(|v| (v["type"] == "title").then(|| v["title"].as_str()).flatten()).unwrap_or_default().to_string();
140            let started = main.iter().find(|v| v["type"] == "session").map_or(agents[0].started, |v| ts(&v["timestamp"]));
141            if since.is_none_or(|s| started >= s) {
142                runs.push(Run { id: name, session: id, name: title, started, agents, loose: false });
143            }
144        }
145    }
146    runs.sort_by_key(|r| r.started);
147    runs
148}
149
150fn read_dir(p: &Path) -> Vec<PathBuf> {
151    let mut v: Vec<PathBuf> = std::fs::read_dir(p).into_iter().flatten().filter_map(|e| e.ok().map(|e| e.path())).collect();
152    v.sort();
153    v
154}
155
156/// The first line of `p`, parsed: an omp session file's session record.
157pub fn first_line(p: &Path) -> Option<Value> {
158    use std::io::BufRead;
159    let line = std::io::BufReader::new(std::fs::File::open(p).ok()?).lines().next()?.ok()?;
160    serde_json::from_str(&line).ok()
161}
162
163fn read_lines(p: &Path) -> Vec<Value> {
164    std::fs::read_to_string(p).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()
165}
166
167/// The `*.jsonl` files under `dir`, at any depth.
168fn jsonl_under(dir: &Path) -> Vec<PathBuf> {
169    let mut out = Vec::new();
170    for p in read_dir(dir) {
171        if p.is_dir() {
172            out.extend(jsonl_under(&p));
173        } else if p.extension().is_some_and(|x| x == "jsonl") {
174            out.push(p);
175        }
176    }
177    out
178}
179
180fn ts(v: &Value) -> i64 {
181    v.as_str().map_or(0, borg_trace::parse_ts)
182}
183
184/// One sub-agent's session file: its label is the file's stem, its kind and prompt `session_init`'s
185/// `agent` and `task`, its work every tool call's arguments and its text, its output what it
186/// yielded ([`yielded`]).
187fn sub_agent(path: &Path) -> SubAgent {
188    let label = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
189    let mut a = SubAgent { label, ..Default::default() };
190    let mut accepted = Vec::new();
191    let mut called = Vec::new();
192    for v in read_lines(path) {
193        match v["type"].as_str() {
194            Some("session") if a.cwd.is_empty() => {
195                a.cwd = v["cwd"].as_str().unwrap_or_default().to_string();
196                a.started = ts(&v["timestamp"]);
197            }
198            Some("session_init") => {
199                a.kind = v["agent"].as_str().unwrap_or_default().to_string();
200                a.prompt = v["task"].as_str().unwrap_or_default().to_string();
201                a.started = ts(&v["timestamp"]).max(a.started);
202            }
203            Some("message") => message(&mut a, &mut called, &mut accepted, &v["message"]),
204            _ => {}
205        }
206    }
207    // A transcript without results (an older omp) has its calls; a call wraps its data in `result`.
208    let unwrap = |c: Value| match c.get("result") {
209        Some(r) if r.is_object() => r.clone(),
210        _ => c,
211    };
212    a.output = yielded(if accepted.is_empty() { called.into_iter().map(unwrap).collect() } else { accepted });
213    a
214}
215
216/// One message of a sub-agent's session: an assistant's tool calls and texts are its work (its
217/// `yield` calls kept in `called`), a `yield` result omp accepted goes to `accepted` (a call that
218/// failed its schema is retried, and only the retry counts).
219fn message(a: &mut SubAgent, called: &mut Vec<Value>, accepted: &mut Vec<Value>, m: &Value) {
220    match m["role"].as_str() {
221        Some("assistant") => {
222            for b in m["content"].as_array().into_iter().flatten() {
223                match b["type"].as_str() {
224                    Some("toolCall") if b["name"] == "yield" => called.push(b["arguments"].clone()),
225                    Some("toolCall") => a.called(&b["arguments"]),
226                    Some("text") => a.said(b["text"].as_str().unwrap_or_default()),
227                    _ => {}
228                }
229            }
230        }
231        Some("toolResult") if m["toolName"] == "yield" && m["isError"] != true && m["details"]["status"] == "success" => {
232            accepted.extend(m["details"].as_object().map(|d| Value::Object(d.clone())));
233        }
234        _ => {}
235    }
236}
237
238/// One output from an agent's yields (`{type?, data}` each): a reviewer yields its findings one call
239/// each (`type: ["findings"]`), as one array, or inside one object (`{overall_correctness,
240/// explanation, findings}`); every finding is gathered into the last object's `findings`. Any other
241/// agent's output is its last data.
242fn yielded(calls: Vec<Value>) -> Option<Value> {
243    let mut findings: Vec<Value> = Vec::new();
244    let mut last: Option<Value> = None;
245    for c in calls {
246        let data = c.get("data").cloned().unwrap_or(Value::Null);
247        let typed = c["type"].as_array().is_some_and(|t| t.iter().any(|x| x == "findings"));
248        match data {
249            Value::Array(items) if typed => findings.extend(items),
250            Value::Object(_) if typed || data.get("title").is_some() && data.get("findings").is_none() => findings.push(data),
251            Value::Object(mut o) => {
252                findings.extend(o.remove("findings").and_then(|f| f.as_array().cloned()).unwrap_or_default());
253                last = Some(Value::Object(o));
254            }
255            Value::Null => {}
256            d => last = Some(d),
257        }
258    }
259    if findings.is_empty() {
260        return last;
261    }
262    let mut out = match last {
263        Some(Value::Object(o)) => o,
264        _ => serde_json::Map::new(),
265    };
266    out.insert("findings".into(), Value::Array(findings));
267    Some(Value::Object(out))
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn a_sessions_task_agents_are_one_run_nested_ones_included() {
276        let sessions = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/review_fix/omp/sessions"));
277        let runs = session_runs(sessions, Some("01a0a00d-0000-7000-8000-000000000002"), None);
278        assert_eq!(runs.len(), 1);
279        let r = &runs[0];
280        assert_eq!(
281            (r.id.as_str(), r.started),
282            ("2026-09-15T08-00-00-000Z_01a0a00d-0000-7000-8000-000000000002", borg_trace::parse_ts("2026-09-15T08:00:00.000Z"))
283        );
284        let rows: Vec<(&str, &str, &str)> = r.agents.iter().map(|a| (a.label.as_str(), a.kind.as_str(), a.prompt.as_str())).collect();
285        assert_eq!(
286            rows,
287            [("FinalReview.Scout", "scout", "Find the client code."), ("FinalReview", "reviewer", "Review the network client.")],
288            "in start order"
289        );
290        assert_eq!(r.agents[0].output, Some(serde_json::json!({"summary": "client in src/net"})), "another agent's output is its last data");
291        assert!(session_runs(sessions, None, Some(borg_trace::parse_ts("2026-09-15T00:00:00Z"))).len() == 1, "since");
292    }
293
294    #[test]
295    fn one_session_reads_only_the_main_file_its_dir_name_points_to() {
296        let root = std::env::temp_dir().join(format!("borg-omp-suffix-{}", std::process::id()));
297        let _ = std::fs::remove_dir_all(&root);
298        let slug = root.join("-w-app");
299        // `T1_aaa`'s main file claims `bbb`: a dir whose suffix is not the session is never read.
300        for (dir, id) in [("T1_aaa", "bbb"), ("T2_bbb", "bbb")] {
301            std::fs::create_dir_all(slug.join(dir)).unwrap();
302            let rec = serde_json::json!({"type": "session", "id": id, "timestamp": "2026-09-15T08:00:00.000Z", "cwd": "/w/app"});
303            std::fs::write(slug.join(format!("{dir}.jsonl")), format!("{rec}\n")).unwrap();
304            std::fs::write(slug.join(dir).join("Build.jsonl"), format!("{rec}\n")).unwrap();
305        }
306        let ids = |s: &str| session_runs(&root, Some(s), None).into_iter().map(|r| r.id).collect::<Vec<_>>();
307        assert_eq!(ids("bbb"), ["T2_bbb"]);
308        assert!(ids("aaa").is_empty(), "the main file confirms the id");
309        assert_eq!(session_runs(&root, None, None).len(), 2, "a full scan reads every one");
310        let _ = std::fs::remove_dir_all(root);
311    }
312}