Skip to main content

borg/harness/
opencode.rs

1//! opencode 1.x and 2.x: the TS plugin `adapters/opencode/borg.ts`, compiled in and copied to
2//! `~/.config/opencode/plugins/borg.ts` (`$OPENCODE_CONFIG_DIR`, `$XDG_CONFIG_HOME/opencode`). The plugin
3//! translates both ways, sends opencode's pid, emulates the stop's second round and gives borg the
4//! stop's budget (`BUDGETS`, which borg's first answer tells it).
5
6use 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
18/// What the plugin waits for each of borg's answers. opencode limits no plugin hook, so each is a guard
19/// against a hung borg holding the engineer's turn: a prompt waits 5 s and a tool result 2 s for borg's
20/// context (file appends; a later answer is kept for the next call), a tool call 2 s before it runs (a
21/// stat when nobody steers); the stop, and every call queued behind it, borg's stop timeout. The
22/// session's end, queued too, gets the same as its own `end` (the plugin's end waits `end_ms`, the other
23/// queued calls `stop_ms`): a hung end holds the session's queue and the plugin's cleanup, which ends
24/// every session.
25pub 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            // The channels exist (borg.ts); turning these on waits for an evaluation.
45            early_delivery: false,
46            question_dialog: false,
47            transcript: Transcript::None,
48            deny_capture: true,
49            sends_pid: true,
50            // The plugin names it too (`BORG_STOP_BUDGET_MS`), as borg's answer told it.
51            stop_budget: Some(BUDGETS.stop),
52            // No limit (1.19.0): a tool's output is cut before tool.execute.after, never the text the
53            // plugin appends there, and a prompt's synthetic part goes in whole. 2.x unverified here.
54            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    /// Each session's subagents, through opencode's CLI (read-only, as `borg assimilate` reads it), and
79    /// only where opencode keeps data under `home` (`$XDG_DATA_HOME/opencode`, else
80    /// `home/.local/share/opencode`). Every session: 1.x only (2.x has no query that lists them).
81    fn sub_runs(&self, home: &Path, session: Option<&str>, since: Option<i64>) -> Vec<Run> {
82        if !data_dir(home).is_dir() {
83            return Vec::new();
84        }
85        let here = std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir());
86        session_runs(session, since, &|id, dir| export(id, dir.unwrap_or(&here)), &db)
87    }
88}
89
90/// Where opencode keeps its data (its sessions are read through its CLI, never from here):
91/// `$XDG_DATA_HOME/opencode`, else `home/.local/share/opencode`.
92pub fn data_dir(home: &Path) -> PathBuf {
93    super::env_dir("XDG_DATA_HOME").unwrap_or_else(|| home.join(".local/share")).join("opencode")
94}
95
96// ---------------------------------------------------------------- the CLI
97
98/// Guard: how long one opencode CLI call (the query, a listing, one export) may take, so a hung CLI
99/// never stalls `borg assimilate` or a session's end.
100const CLI_CAP: Duration = Duration::from_secs(30);
101
102/// `opencode <args>` in `dir`, the `opencode` on `path` (None: this process's PATH), capped like a
103/// model call (a hung CLI never stalls the run). PWD names the dir too: opencode takes the project
104/// from it, not from the process cwd. 2.x (`v2`) gets a model call's env (`runner::standalone_env`):
105/// its private server would otherwise load every plugin and the project's own config in the
106/// engineer's repos. The output goes to a file in a private scratch dir: opencode exits before a
107/// full pipe drains, so a large export read through a pipe arrives cut off (seen on 1.19 at 72 KiB),
108/// while a file gets all of it. Here, beside the harness, so `borg assimilate` and the subagent reader
109/// share it without harness/ depending on assimilate.rs (arch.sh M5).
110pub(crate) fn cli(v2: bool, path: Option<&OsStr>, args: &[String], dir: &Path) -> Option<Vec<u8>> {
111    let scratch = runner::Scratch::new("opencode-out")?;
112    let out = scratch.0.join("out.json");
113    let mut cmd = std::process::Command::new("sh");
114    cmd.args(["-c", r#"umask 077; out="$1"; shift; exec opencode "$@" > "$out""#, "sh"]).arg(&out).args(args);
115    cmd.current_dir(dir).env("PWD", dir).env("OPENCODE_DISABLE_AUTOUPDATE", "1");
116    if v2 {
117        let empty = scratch.0.join("config");
118        std::fs::create_dir(&empty).ok()?;
119        runner::standalone_env(&mut cmd, Some(&empty), runner::engineers_config());
120    }
121    if let Some(p) = path {
122        cmd.env("PATH", p);
123    }
124    runner::run_capped(cmd, None, CLI_CAP)?;
125    std::fs::read(&out).ok()
126}
127
128/// The arguments that export session `id` (2.x always `--standalone`: a private server).
129pub(crate) fn export_args(v2: bool, id: &str) -> Vec<String> {
130    let head: &[&str] = if v2 { &["session", "export", "--standalone"] } else { &["export", "--pure"] };
131    head.iter().map(|a| a.to_string()).chain(std::iter::once(id.to_string())).collect()
132}
133
134/// One session's export through this machine's opencode CLI, run in `dir`.
135fn export(id: &str, dir: &Path) -> Option<Value> {
136    let v2 = runner::opencode() == runner::Opencode::V2;
137    cli(v2, None, &export_args(v2, id), dir).and_then(|o| serde_json::from_slice(&o).ok())
138}
139
140/// The rows of a read-only query of opencode 1.x's database; None on 2.x, which has no such query,
141/// or when the call fails.
142fn db(sql: &str) -> Option<Vec<Value>> {
143    if runner::opencode() == runner::Opencode::V2 {
144        return None;
145    }
146    let args: Vec<String> = ["db", "--pure", "--format", "json", sql].iter().map(|a| a.to_string()).collect();
147    cli(false, None, &args, &std::env::temp_dir()).and_then(|o| serde_json::from_slice(&o).ok())
148}
149
150// ---------------------------------------------------------------- subagents
151
152/// Exports a session by id, in the directory given (else wherever it runs).
153pub type Export<'a> = &'a dyn Fn(&str, Option<&Path>) -> Option<Value>;
154/// Runs a read-only query of opencode 1.x's database (None: no such query).
155pub type Query<'a> = &'a dyn Fn(&str) -> Option<Vec<Value>>;
156
157/// A guard, not a tuning knob: at most this many children of one session are exported, the rest read
158/// from the parent's copy of their final text.
159pub const CHILDREN: usize = 20;
160/// Guard: all of one session's exports stop after this long; the remaining children are read from the
161/// parent.
162const SESSION_BUDGET: Duration = Duration::from_secs(120);
163
164/// An opencode session id (`ses_` and alphanumerics), the only thing passed to its CLI.
165fn session_id(s: &str) -> bool {
166    s.starts_with("ses_") && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
167}
168
169/// One run per main session that started subagents, oldest first: `session` (a `ses_…` id) only, else
170/// every parent of a subagent session since `since` (1.x's query). A subagent is a child session; the
171/// parent's task tool call (1.x `task`, 2.x `subagent`) names it (`metadata.sessionId`/`sessionID`)
172/// with its description, agent and prompt, and holds its final text. Its export gives its cwd and
173/// work. opencode has no structured output: `output` stays None.
174pub fn session_runs(session: Option<&str>, since: Option<i64>, export: Export, query: Query) -> Vec<Run> {
175    let roots: Vec<String> = match session {
176        Some(s) if session_id(s) => vec![s.to_string()],
177        Some(_) => Vec::new(),
178        None => {
179            let since = since.map(|ms| format!(" AND time_created >= {ms}")).unwrap_or_default();
180            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");
181            query(&sql).unwrap_or_default().iter().filter_map(|r| r["id"].as_str().filter(|s| session_id(s)).map(String::from)).collect()
182        }
183    };
184    let mut runs: Vec<Run> = roots
185        .iter()
186        .filter_map(|id| {
187            let until = Instant::now() + SESSION_BUDGET;
188            run_of(&export(id, None)?, export, until)
189        })
190        .collect();
191    runs.sort_by_key(|r| r.started);
192    runs
193}
194
195/// A message's parts: 1.x `parts`, 2.x `content`.
196fn parts(m: &Value) -> impl Iterator<Item = &Value> {
197    m["parts"].as_array().or(m["content"].as_array()).into_iter().flatten()
198}
199
200fn assistant(m: &Value) -> bool {
201    m["info"]["role"] == "assistant" || m["type"] == "assistant"
202}
203
204fn text(v: &Value) -> String {
205    match v {
206        Value::String(s) => s.clone(),
207        Value::Array(bs) => bs.iter().filter_map(|b| b["text"].as_str()).collect::<Vec<_>>().join("\n"),
208        _ => String::new(),
209    }
210}
211
212/// The run of `root`'s children: each exported while the id is valid, fewer than `CHILDREN` were and
213/// `until` has not passed; else read from the parent's copy of its final text.
214fn run_of(root: &Value, export: Export, until: Instant) -> Option<Run> {
215    let info = &root["info"];
216    let dir = info["directory"].as_str().or(info["location"]["directory"].as_str()).unwrap_or_default();
217    let mut agents = Vec::new();
218    let mut exported = 0;
219    for m in root["messages"].as_array().into_iter().flatten() {
220        let at = m["info"]["time"]["created"].as_i64().or(m["time"]["created"].as_i64()).unwrap_or(0);
221        for p in parts(m).filter(|p| p["type"] == "tool") {
222            let state = &p["state"];
223            let Some(child) = state["metadata"]["sessionId"].as_str().or(state["metadata"]["sessionID"].as_str()) else { continue };
224            let input = &state["input"];
225            let s = |k: &str| input[k].as_str().unwrap_or_default().to_string();
226            let started = state["time"]["start"].as_i64().or(p["time"]["created"].as_i64()).unwrap_or(at);
227            let mut a = SubAgent {
228                label: s("description"),
229                kind: input["subagent_type"].as_str().map_or_else(|| s("agent"), String::from),
230                started,
231                prompt: s("prompt"),
232                cwd: dir.to_string(),
233                ..Default::default()
234            };
235            let read = session_id(child) && exported < CHILDREN && Instant::now() < until;
236            exported += usize::from(read);
237            match read.then(|| export(child, (!dir.is_empty()).then_some(Path::new(dir)))).flatten() {
238                Some(c) => from_export(&mut a, &c),
239                // Without its export (or not read), its final text as the parent saw it.
240                None => {
241                    a.work = state["output"].as_str().map_or_else(|| text(&state["content"]), String::from);
242                    a.final_text = a.work.clone();
243                }
244            }
245            agents.push(a);
246        }
247    }
248    if agents.is_empty() {
249        return None;
250    }
251    agents.sort_by_key(|a| a.started);
252    let id = info["id"].as_str().unwrap_or_default().to_string();
253    let started = info["time"]["created"].as_i64().unwrap_or(agents[0].started);
254    Some(Run { session: id.clone(), id, name: info["title"].as_str().unwrap_or_default().to_string(), started, agents, loose: false })
255}
256
257/// A child's work from its export `c`: its cwd when the export names one, and its assistant parts.
258fn from_export(a: &mut SubAgent, c: &Value) {
259    let ci = &c["info"];
260    if let Some(d) = ci["directory"].as_str().or(ci["location"]["directory"].as_str()) {
261        a.cwd = d.to_string();
262    }
263    for p in c["messages"].as_array().into_iter().flatten().filter(|m| assistant(m)).flat_map(parts) {
264        match p["type"].as_str() {
265            Some("tool") => a.called(&p["state"]["input"]),
266            Some("text") => a.said(p["text"].as_str().unwrap_or_default()),
267            _ => {}
268        }
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use serde_json::json;
276    use std::cell::RefCell;
277
278    /// A parent with one task call per child id, each with its final text.
279    fn parent(children: &[String]) -> Value {
280        let parts: Vec<Value> = children
281            .iter()
282            .map(|c| json!({"type": "tool", "state": {"input": {"description": c}, "metadata": {"sessionId": c}, "output": format!("parent's copy of {c}")}}))
283            .collect();
284        json!({"info": {"id": "ses_root", "directory": "/w/app"}, "messages": [{"info": {"role": "assistant"}, "parts": parts}]})
285    }
286
287    fn child(id: &str) -> Value {
288        json!({"info": {"directory": "/w/app"}, "messages": [{"info": {"role": "assistant"}, "parts": [{"type": "text", "text": format!("export of {id}")}]}]})
289    }
290
291    #[test]
292    fn only_valid_ids_are_exported_and_at_most_children_of_them() {
293        let asked = RefCell::new(Vec::new());
294        let export = |id: &str, _: Option<&Path>| {
295            asked.borrow_mut().push(id.to_string());
296            Some(child(id))
297        };
298        let mut ids: Vec<String> = (0..CHILDREN + 5).map(|i| format!("ses_c{i}")).collect();
299        ids.insert(0, "--help".into());
300        ids.insert(1, "ses_../x".into());
301        let run = run_of(&parent(&ids), &export, Instant::now() + SESSION_BUDGET).unwrap();
302        let asked = asked.into_inner();
303        assert_eq!(asked.len(), CHILDREN, "{asked:?}");
304        assert!(asked.iter().all(|id| session_id(id)), "{asked:?}");
305        let works: Vec<&str> = run.agents.iter().map(|a| a.work.trim()).collect();
306        assert_eq!((works[0], works[1], works[2]), ("parent's copy of --help", "parent's copy of ses_../x", "export of ses_c0"));
307        assert_eq!(works.last().copied(), Some(format!("parent's copy of ses_c{}", CHILDREN + 4).as_str()));
308    }
309
310    #[test]
311    fn past_the_sessions_deadline_children_are_read_from_the_parent() {
312        let export = |_: &str, _: Option<&Path>| -> Option<Value> { panic!("no export after the deadline") };
313        let run = run_of(&parent(&["ses_c0".into()]), &export, Instant::now()).unwrap();
314        assert_eq!(run.agents[0].work, "parent's copy of ses_c0");
315    }
316}