1use 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
15pub const HANDLER_LIMIT: Duration = Duration::from_secs(30);
19pub const SHUTDOWN_LIMIT: Duration = Duration::from_secs(2);
22const STOP_MARGIN: Duration = Duration::from_secs(2);
25const END_MARGIN: Duration = Duration::from_millis(500);
27
28pub 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 early_delivery: false,
53 question_dialog: false,
54 transcript: Transcript::None,
55 deny_capture: true,
56 sends_pid: true,
57 stop_budget: Some(BUDGETS.stop),
59 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 fn sub_runs(&self, home: &Path, session: Option<&str>, since: Option<i64>) -> Vec<Run> {
86 session_runs(&sessions(home), session, since)
87 }
88
89 fn session_log(&self, home: &Path, session: &str, _input: &Value) -> Option<PathBuf> {
91 session_file(&sessions(home), session)
92 }
93}
94
95pub fn sessions(home: &Path) -> PathBuf {
100 super::env_dir("PI_CODING_AGENT_DIR").unwrap_or_else(|| home.join(".omp/agent")).join("sessions")
101}
102
103pub 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 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
112pub 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 if session.is_some_and(|s| s != suffix) {
126 continue;
127 }
128 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
156pub 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
167fn 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
184fn 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 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
216fn 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
238fn 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 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}