1use std::ffi::OsStr;
7use std::path::{Path, PathBuf};
8use std::time::{Duration, Instant};
9
10use serde_json::Value;
11
12use crate::runner;
13
14use super::{Budgets, CONTEXT_GUARD, Caps, DroneWake, Harness, Probe, Run, STOP_TIMEOUT_S, SubAgent, Transcript, Wiring};
15
16pub const ADAPTER: &str = include_str!("../../assets/adapters/opencode/borg.ts");
17
18pub const BUDGETS: Budgets = Budgets {
26 prompt: Duration::from_secs(5),
27 pre_tool: Duration::from_secs(2),
28 post_tool: Duration::from_secs(2),
29 stop: Duration::from_secs(STOP_TIMEOUT_S),
30 end: Duration::from_secs(STOP_TIMEOUT_S),
31};
32
33pub struct Opencode;
34
35impl Harness for Opencode {
36 fn name(&self) -> &'static str {
37 "opencode"
38 }
39
40 fn caps(&self) -> Caps {
41 Caps {
42 prompt_context: true,
43 tool_context: true,
44 early_delivery: false,
46 question_dialog: false,
47 transcript: Transcript::None,
48 deny_capture: true,
49 sends_pid: true,
50 stop_budget: Some(BUDGETS.stop),
52 context_chars: CONTEXT_GUARD,
55 session_env: None,
56 pid_env: Some("OPENCODE_PID"),
57 drone_wake: DroneWake::Plugin,
58 }
59 }
60
61 fn wiring(&self) -> Wiring {
62 Wiring::AdapterFile { source: ADAPTER }
63 }
64
65 fn budgets(&self) -> Option<Budgets> {
66 Some(BUDGETS)
67 }
68
69 fn target(&self, home: &Path, var: &dyn Fn(&str) -> Option<PathBuf>) -> PathBuf {
70 let dir = var("OPENCODE_CONFIG_DIR").or_else(|| var("XDG_CONFIG_HOME").map(|x| x.join("opencode")));
71 dir.unwrap_or_else(|| home.join(".config/opencode")).join("plugins/borg.ts")
72 }
73
74 fn probe(&self) -> Probe {
75 Probe { cli: "opencode", tested: " (adapter tested with 1.18.32, 1.19.0, 2.0.23 and 2.0.29)" }
76 }
77
78 fn sub_runs(&self, home: &Path, session: Option<&str>, since: Option<i64>) -> Vec<Run> {
82 let data = std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()).map_or_else(|| home.join(".local/share"), PathBuf::from);
83 if !data.join("opencode").is_dir() {
84 return Vec::new();
85 }
86 let here = std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir());
87 session_runs(session, since, &|id, dir| export(id, dir.unwrap_or(&here)), &db)
88 }
89}
90
91const CLI_CAP: Duration = Duration::from_secs(30);
96
97pub(crate) fn cli(v2: bool, path: Option<&OsStr>, args: &[String], dir: &Path) -> Option<Vec<u8>> {
106 let scratch = runner::Scratch::new("opencode-out")?;
107 let out = scratch.0.join("out.json");
108 let mut cmd = std::process::Command::new("sh");
109 cmd.args(["-c", r#"umask 077; out="$1"; shift; exec opencode "$@" > "$out""#, "sh"]).arg(&out).args(args);
110 cmd.current_dir(dir).env("PWD", dir).env("OPENCODE_DISABLE_AUTOUPDATE", "1");
111 if v2 {
112 let empty = scratch.0.join("config");
113 std::fs::create_dir(&empty).ok()?;
114 runner::standalone_env(&mut cmd, Some(&empty), runner::engineers_config());
115 }
116 if let Some(p) = path {
117 cmd.env("PATH", p);
118 }
119 runner::run_capped(cmd, None, CLI_CAP)?;
120 std::fs::read(&out).ok()
121}
122
123pub(crate) fn export_args(v2: bool, id: &str) -> Vec<String> {
125 let head: &[&str] = if v2 { &["session", "export", "--standalone"] } else { &["export", "--pure"] };
126 head.iter().map(|a| a.to_string()).chain(std::iter::once(id.to_string())).collect()
127}
128
129fn export(id: &str, dir: &Path) -> Option<Value> {
131 let v2 = runner::opencode() == runner::Opencode::V2;
132 cli(v2, None, &export_args(v2, id), dir).and_then(|o| serde_json::from_slice(&o).ok())
133}
134
135fn db(sql: &str) -> Option<Vec<Value>> {
138 if runner::opencode() == runner::Opencode::V2 {
139 return None;
140 }
141 let args: Vec<String> = ["db", "--pure", "--format", "json", sql].iter().map(|a| a.to_string()).collect();
142 cli(false, None, &args, &std::env::temp_dir()).and_then(|o| serde_json::from_slice(&o).ok())
143}
144
145pub type Export<'a> = &'a dyn Fn(&str, Option<&Path>) -> Option<Value>;
149pub type Query<'a> = &'a dyn Fn(&str) -> Option<Vec<Value>>;
151
152pub const CHILDREN: usize = 20;
155const SESSION_BUDGET: Duration = Duration::from_secs(120);
158
159fn session_id(s: &str) -> bool {
161 s.starts_with("ses_") && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
162}
163
164pub fn session_runs(session: Option<&str>, since: Option<i64>, export: Export, query: Query) -> Vec<Run> {
170 let roots: Vec<String> = match session {
171 Some(s) if session_id(s) => vec![s.to_string()],
172 Some(_) => Vec::new(),
173 None => {
174 let since = since.map(|ms| format!(" AND time_created >= {ms}")).unwrap_or_default();
175 let sql = format!("SELECT parent_id AS id, MIN(time_created) AS t FROM session WHERE parent_id IS NOT NULL{since} GROUP BY parent_id ORDER BY t");
176 query(&sql).unwrap_or_default().iter().filter_map(|r| r["id"].as_str().filter(|s| session_id(s)).map(String::from)).collect()
177 }
178 };
179 let mut runs: Vec<Run> = roots
180 .iter()
181 .filter_map(|id| {
182 let until = Instant::now() + SESSION_BUDGET;
183 run_of(&export(id, None)?, export, until)
184 })
185 .collect();
186 runs.sort_by_key(|r| r.started);
187 runs
188}
189
190fn parts(m: &Value) -> impl Iterator<Item = &Value> {
192 m["parts"].as_array().or(m["content"].as_array()).into_iter().flatten()
193}
194
195fn assistant(m: &Value) -> bool {
196 m["info"]["role"] == "assistant" || m["type"] == "assistant"
197}
198
199fn text(v: &Value) -> String {
200 match v {
201 Value::String(s) => s.clone(),
202 Value::Array(bs) => bs.iter().filter_map(|b| b["text"].as_str()).collect::<Vec<_>>().join("\n"),
203 _ => String::new(),
204 }
205}
206
207fn run_of(root: &Value, export: Export, until: Instant) -> Option<Run> {
210 let info = &root["info"];
211 let dir = info["directory"].as_str().or(info["location"]["directory"].as_str()).unwrap_or_default();
212 let mut agents = Vec::new();
213 let mut exported = 0;
214 for m in root["messages"].as_array().into_iter().flatten() {
215 let at = m["info"]["time"]["created"].as_i64().or(m["time"]["created"].as_i64()).unwrap_or(0);
216 for p in parts(m).filter(|p| p["type"] == "tool") {
217 let state = &p["state"];
218 let Some(child) = state["metadata"]["sessionId"].as_str().or(state["metadata"]["sessionID"].as_str()) else { continue };
219 let input = &state["input"];
220 let s = |k: &str| input[k].as_str().unwrap_or_default().to_string();
221 let started = state["time"]["start"].as_i64().or(p["time"]["created"].as_i64()).unwrap_or(at);
222 let mut a = SubAgent {
223 label: s("description"),
224 kind: input["subagent_type"].as_str().map_or_else(|| s("agent"), String::from),
225 started,
226 prompt: s("prompt"),
227 cwd: dir.to_string(),
228 ..Default::default()
229 };
230 let read = session_id(child) && exported < CHILDREN && Instant::now() < until;
231 exported += usize::from(read);
232 match read.then(|| export(child, (!dir.is_empty()).then_some(Path::new(dir)))).flatten() {
233 Some(c) => from_export(&mut a, &c),
234 None => {
236 a.work = state["output"].as_str().map_or_else(|| text(&state["content"]), String::from);
237 a.final_text = a.work.clone();
238 }
239 }
240 agents.push(a);
241 }
242 }
243 if agents.is_empty() {
244 return None;
245 }
246 agents.sort_by_key(|a| a.started);
247 let id = info["id"].as_str().unwrap_or_default().to_string();
248 let started = info["time"]["created"].as_i64().unwrap_or(agents[0].started);
249 Some(Run { session: id.clone(), id, name: info["title"].as_str().unwrap_or_default().to_string(), started, agents, loose: false })
250}
251
252fn from_export(a: &mut SubAgent, c: &Value) {
254 let ci = &c["info"];
255 if let Some(d) = ci["directory"].as_str().or(ci["location"]["directory"].as_str()) {
256 a.cwd = d.to_string();
257 }
258 for p in c["messages"].as_array().into_iter().flatten().filter(|m| assistant(m)).flat_map(parts) {
259 match p["type"].as_str() {
260 Some("tool") => a.called(&p["state"]["input"]),
261 Some("text") => a.said(p["text"].as_str().unwrap_or_default()),
262 _ => {}
263 }
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use serde_json::json;
271 use std::cell::RefCell;
272
273 fn parent(children: &[String]) -> Value {
275 let parts: Vec<Value> = children
276 .iter()
277 .map(|c| json!({"type": "tool", "state": {"input": {"description": c}, "metadata": {"sessionId": c}, "output": format!("parent's copy of {c}")}}))
278 .collect();
279 json!({"info": {"id": "ses_root", "directory": "/w/app"}, "messages": [{"info": {"role": "assistant"}, "parts": parts}]})
280 }
281
282 fn child(id: &str) -> Value {
283 json!({"info": {"directory": "/w/app"}, "messages": [{"info": {"role": "assistant"}, "parts": [{"type": "text", "text": format!("export of {id}")}]}]})
284 }
285
286 #[test]
287 fn only_valid_ids_are_exported_and_at_most_children_of_them() {
288 let asked = RefCell::new(Vec::new());
289 let export = |id: &str, _: Option<&Path>| {
290 asked.borrow_mut().push(id.to_string());
291 Some(child(id))
292 };
293 let mut ids: Vec<String> = (0..CHILDREN + 5).map(|i| format!("ses_c{i}")).collect();
294 ids.insert(0, "--help".into());
295 ids.insert(1, "ses_../x".into());
296 let run = run_of(&parent(&ids), &export, Instant::now() + SESSION_BUDGET).unwrap();
297 let asked = asked.into_inner();
298 assert_eq!(asked.len(), CHILDREN, "{asked:?}");
299 assert!(asked.iter().all(|id| session_id(id)), "{asked:?}");
300 let works: Vec<&str> = run.agents.iter().map(|a| a.work.trim()).collect();
301 assert_eq!((works[0], works[1], works[2]), ("parent's copy of --help", "parent's copy of ses_../x", "export of ses_c0"));
302 assert_eq!(works.last().copied(), Some(format!("parent's copy of ses_c{}", CHILDREN + 4).as_str()));
303 }
304
305 #[test]
306 fn past_the_sessions_deadline_children_are_read_from_the_parent() {
307 let export = |_: &str, _: Option<&Path>| -> Option<Value> { panic!("no export after the deadline") };
308 let run = run_of(&parent(&["ses_c0".into()]), &export, Instant::now()).unwrap();
309 assert_eq!(run.agents[0].work, "parent's copy of ses_c0");
310 }
311}