Skip to main content

fno_agents/
opencode_ask.rs

1//! Client interceptor for the `ask` verb on an opencode target (x-51f6), plus
2//! the headless one-shot dispatch (`dispatch_opencode_once`).
3//!
4//! opencode is hosted two ways: an interactive PTY pane (the `ask` resume path,
5//! which stays refused - no stateful client-side resume in v1) and a headless
6//! one-shot `opencode run --dangerously-skip-permissions "<prompt>"` (this module's `dispatch_opencode_once`,
7//! substrate `headless`). The one-shot is STATELESS like agy: plain-text stdout,
8//! no session id minted here, no registry row, no `--continue` resume from this
9//! path. It reuses the shared `subprocess_ask` primitives (stdin `/dev/null`,
10//! watchdog, process-group SIGINT) rather than duplicating agy's error taxonomy.
11//!
12//! `ask` (resume-by-name) still refuses: it names the real limitation instead of
13//! falling through to `bin/client.rs`'s `unresolvable_ask_exit` "provider is
14//! required for new agent" text (wrong - the agent exists - and a dead end).
15//! Mirrors [`crate::agy_ask::maybe_run_agy_ask`]'s shape.
16
17/// The refusal text. A pointer that stops at `--text <prompt>` is a remedy that
18/// fails when followed literally: the bytes land in the composer and the command
19/// still exits 0, so the sender reads "delivered" for a prompt that was never
20/// submitted. Naming the submit key is the load-bearing half.
21const ASK_REFUSAL: &str = "fno-agents: opencode has no stateful 'ask' resume \
22    (pane-hosted, no client-side dispatch); drive the pane directly with \
23    'fno mux pane send <pane> --session <session> --text <prompt>'. \
24    --text only fills the composer, it does not submit: append the TUI's submit key \
25    (a trailing \\r, or a SECOND send of $'\\t' once the payload is large enough to \
26    render as a pasted block) or the prompt sits there unsent.";
27
28/// Returns `None` for a non-opencode target (fall through to the next
29/// provider's ask hook), or `Some(2)` after printing the refusal.
30pub fn maybe_run_opencode_ask(
31    home: &crate::paths::AgentsHome,
32    params: &serde_json::Value,
33    name: &str,
34) -> Option<i32> {
35    let provider_param = params.get("provider").and_then(|v| v.as_str());
36    let registry = match crate::state::load_registry(&home.registry_json()) {
37        Ok(r) => r,
38        Err(e) => {
39            eprintln!(
40                "fno-agents: cannot read agents registry at {:?}: {}",
41                home.registry_json(),
42                e
43            );
44            return Some(12);
45        }
46    };
47    let existing_provider = registry.find(name).map(|e| e.harness_name());
48    let resolved = existing_provider.or(provider_param);
49    if resolved != Some("opencode") {
50        return None; // not an opencode target; fall through
51    }
52    eprintln!("{ASK_REFUSAL}");
53    Some(2)
54}
55
56// ===========================================================================
57// Headless one-shot dispatch (`opencode run`) - stateless, plain-text
58// ===========================================================================
59
60use std::io::{Read, Write};
61use std::path::Path;
62use std::sync::{Arc, Mutex};
63use std::time::Duration;
64
65/// Default one-shot timeout; the outer watchdog bounds a headless `opencode run`
66/// so a hang can't wedge the caller (a full /target run is long, so this is
67/// generous). Caller may override via `timeout`.
68const DEFAULT_OPENCODE_TIMEOUT: Duration = Duration::from_secs(600);
69
70/// Stdout/stderr/exit triple returned to the client (mirror of the sibling
71/// provider `AskOutcome`s; each module owns its own nominal type).
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct AskOutcome {
74    pub stdout: String,
75    pub stderr: String,
76    pub exit_code: i32,
77}
78
79impl AskOutcome {
80    fn ok_reply(reply: String) -> Self {
81        Self {
82            stdout: reply,
83            stderr: String::new(),
84            exit_code: 0,
85        }
86    }
87    fn err(msg: impl Into<String>, code: i32) -> Self {
88        Self {
89            stdout: String::new(),
90            stderr: format!("{}\n", msg.into()),
91            exit_code: code,
92        }
93    }
94}
95
96/// `opencode run --dangerously-skip-permissions [--model <m>] <tail>` - the
97/// headless one-shot argv (matches `OpencodeProvider::create_argv`). The bypass
98/// flag auto-approves permissions so an unattended worker never wedges on an
99/// approval prompt; confirmed against opencode v1.14.50's `run --help` (the
100/// docs' `--auto` is stale - the shipped binary renamed it). The trailing argv
101/// is built by `opencode_run_tail`: a footnote slash command rides `--command`
102/// (opencode expands the plugin command), a prose prompt stays the message
103/// positional (x-de43 / codex P1).
104fn build_opencode_argv(prompt: &str, model: Option<&str>) -> Vec<String> {
105    let mut argv = vec![
106        "opencode".to_string(),
107        "run".to_string(),
108        "--dangerously-skip-permissions".to_string(),
109    ];
110    if let Some(m) = model {
111        argv.push("--model".to_string());
112        argv.push(m.to_string());
113    }
114    argv.extend(crate::provider::opencode_run_tail(prompt));
115    argv
116}
117
118/// Last `n` characters of `s` (UTF-8 safe; forensic tail for a failed run).
119/// Walks from the end for the start byte offset instead of collecting every
120/// char into a Vec, so a large stderr blob costs O(n), not O(len).
121fn tail_chars(s: &str, n: usize) -> &str {
122    if n == 0 {
123        return "";
124    }
125    match s.char_indices().rev().nth(n - 1) {
126        Some((idx, _)) => &s[idx..],
127        None => s, // fewer than n chars
128    }
129}
130
131/// Stable per-agent log path (mirror of the sibling one-shots).
132fn derive_log_path(home: &crate::paths::AgentsHome, name: &str) -> std::path::PathBuf {
133    home.root()
134        .join("agents")
135        .join("logs")
136        .join(format!("{}.jsonl", name))
137}
138
139/// Drive one `opencode run` subprocess: stdin `/dev/null` (never block on a
140/// non-TTS input wait), stdout captured as plain text, stderr drained on a
141/// thread (bounded pipe), the whole call bounded by the shared watchdog. Lean
142/// mirror of `agy_ask::run_agy` - same shape, no elaborate stderr taxonomy: a
143/// non-zero exit surfaces the stderr tail, a hang maps to the timeout code.
144///
145/// Returns the plain-text reply on a clean exit, or `(exit_code, message)`.
146fn run_opencode(
147    argv: &[String],
148    output_path: &Path,
149    timeout: Option<Duration>,
150    cwd: &Path,
151    agent_self: &str,
152) -> Result<String, (i32, String)> {
153    use std::os::unix::process::CommandExt;
154    use std::process::{Command, Stdio};
155
156    let tee = crate::subprocess_ask::open_tee(output_path).ok();
157    // QoS: exec-wrap at background priority (identity when worker_qos=off).
158    let argv = crate::spawn_gate::qos_wrap(cwd, argv.to_vec());
159    let mut cmd = Command::new(&argv[0]);
160    cmd.args(&argv[1..]);
161    cmd.stdin(Stdio::null());
162    cmd.stdout(Stdio::piped());
163    cmd.stderr(Stdio::piped());
164    cmd.current_dir(cwd);
165    cmd.env("FNO_AGENT_SELF", agent_self);
166    cmd.env("FNO_AGENT_PROVIDER", "opencode");
167    // Own process group so SIGTERM/SIGKILL/SIGINT reach opencode's subshells.
168    unsafe {
169        cmd.pre_exec(|| {
170            libc::setpgid(0, 0);
171            Ok(())
172        });
173    }
174
175    let mut child = match cmd.spawn() {
176        Ok(c) => c,
177        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
178            return Err((13, "opencode binary not found on PATH".to_string()));
179        }
180        Err(e) => return Err((2, format!("OSError invoking opencode: {}", e))),
181    };
182    let pid = child.id();
183    // Forward operator Ctrl-C to opencode's process group for this call. RAII
184    // guard held for the whole function - do NOT discard to `_`.
185    let _sigint_guard = crate::subprocess_ask::SigintForwarder::install(pid);
186
187    let mut stdout_pipe = child.stdout.take().expect("stdout piped");
188    let stderr_pipe = child.stderr.take().expect("stderr piped");
189
190    // Drain stderr on a thread so a chatty stream can't deadlock the pipe while
191    // we block on stdout; captured (unbounded is fine - opencode is not agy-loopy,
192    // and a real runaway is bounded by the watchdog killing the process).
193    let stderr_buf: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
194    let cap = stderr_buf.clone();
195    let stderr_handle = std::thread::spawn(move || {
196        let mut r = stderr_pipe;
197        let mut s = String::new();
198        let _ = r.read_to_string(&mut s);
199        if let Ok(mut g) = cap.lock() {
200            *g = s;
201        }
202    });
203
204    let mut watchdog = crate::subprocess_ask::AskWatchdog::spawn(pid, timeout);
205    let mut stdout_bytes: Vec<u8> = Vec::new();
206    let _ = stdout_pipe.read_to_end(&mut stdout_bytes);
207    let stdout_text = String::from_utf8_lossy(&stdout_bytes).into_owned();
208    if let Some(mut fh) = tee {
209        let _ = fh.write_all(stdout_text.as_bytes());
210    }
211
212    watchdog.cancel();
213    let (exit_code, sigkill_escalated) =
214        crate::subprocess_ask::wait_with_grace(pid, &mut child, 5.0);
215    watchdog.join();
216    let _ = stderr_handle.join();
217    let stderr_text = stderr_buf.lock().map(|s| s.clone()).unwrap_or_default();
218
219    // Operator Ctrl-C wins over every other classification.
220    if crate::subprocess_ask::ask_interrupted() {
221        return Err((130, "interrupted".to_string()));
222    }
223    if watchdog.timed_out() || sigkill_escalated {
224        return Err((
225            12,
226            format!(
227                "opencode run timed out after {:.0}s",
228                timeout.unwrap_or(DEFAULT_OPENCODE_TIMEOUT).as_secs_f64()
229            ),
230        ));
231    }
232    if exit_code != 0 {
233        return Err((
234            exit_code,
235            format!(
236                "opencode run exited {}: {}",
237                exit_code,
238                tail_chars(stderr_text.trim(), 400)
239            ),
240        ));
241    }
242    Ok(stdout_text)
243}
244
245/// Orchestrate one opencode `spawn --substrate headless`: validate, fail-closed
246/// registry + name-collision check, then run `opencode run` and return the
247/// reply. STATELESS by design - opencode's own `--session`/`--continue` resume is
248/// a pane/interactive concern, not this one-shot; `name` labels the log + events.
249#[allow(clippy::too_many_arguments)]
250pub fn dispatch_opencode_once(
251    home: &crate::paths::AgentsHome,
252    name: &str,
253    message: &str,
254    from_name: &str,
255    cwd: &Path,
256    _yolo: bool, // opencode uses --auto for permission bypass; yolo is a no-op (agy parity)
257    timeout: Option<Duration>,
258    model: Option<&str>,
259) -> AskOutcome {
260    use crate::claude_ask::{emit_event, py_repr, validate_spawn_inputs};
261
262    if let Err(msg) = validate_spawn_inputs(name, from_name) {
263        return AskOutcome::err(msg, 2);
264    }
265    let events = home.events_jsonl();
266
267    // Authoritative registry read, fail-closed (the caller's collision check is
268    // advisory `unwrap_or_default`); re-check the name collision under this read.
269    let registry = match crate::state::load_registry(&home.registry_json()) {
270        Ok(r) => r,
271        Err(e) => {
272            emit_event(
273                &events,
274                "agent_ask_failed",
275                &[
276                    ("stage", "registry-read".into()),
277                    ("name", name.into()),
278                    ("provider", "opencode".into()),
279                    ("error", e.to_string().into()),
280                ],
281            );
282            return AskOutcome::err(format!("registry read failed: {}", e), 12);
283        }
284    };
285    if registry.find(name).is_some() {
286        return AskOutcome::err(
287            format!(
288                "agent {} already exists; use 'fno agents rm {}' first or pick another name",
289                py_repr(name),
290                name
291            ),
292            2,
293        );
294    }
295
296    // spawn allows an empty initial message; default to "hello" (Python parity -
297    // only the empty string, not whitespace).
298    let effective_message = if message.is_empty() { "hello" } else { message };
299    // A footnote slash command (`/fno:verb ...`) is a command dispatch, not a
300    // conversational message: send it WITHOUT the `[from:]` envelope so it rides
301    // `opencode run --command` (an envelope would demote it to prose no-op). A
302    // prose message keeps the courtesy envelope (x-de43).
303    let full_prompt = if effective_message.starts_with('/') {
304        effective_message.to_string()
305    } else {
306        format!("[from: {}]\n\n{}", from_name, effective_message)
307    };
308    let argv = build_opencode_argv(&full_prompt, model);
309    let log_path = derive_log_path(home, name);
310    if let Some(parent) = log_path.parent() {
311        let _ = std::fs::create_dir_all(parent);
312    }
313    let eff_timeout = timeout.or(Some(DEFAULT_OPENCODE_TIMEOUT));
314
315    match run_opencode(&argv, &log_path, eff_timeout, cwd, name) {
316        Ok(reply) => AskOutcome::ok_reply(reply),
317        Err((code, msg)) => {
318            emit_event(
319                &events,
320                "agent_ask_failed",
321                &[
322                    ("stage", "opencode-once".into()),
323                    ("name", name.into()),
324                    ("provider", "opencode".into()),
325                    ("error", msg.clone().into()),
326                ],
327            );
328            AskOutcome::err(msg, code)
329        }
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    fn home(dir: &std::path::Path) -> crate::paths::AgentsHome {
338        crate::paths::AgentsHome::at(dir.to_path_buf())
339    }
340
341    // The pointer sends the caller to `pane send`, which does not submit. If the
342    // submit step ever gets edited back out, the remedy silently starts failing
343    // for anyone who follows it literally.
344    #[test]
345    fn ask_refusal_names_the_submit_step() {
346        assert!(ASK_REFUSAL.contains("mux pane send"), "keeps the pointer");
347        assert!(
348            ASK_REFUSAL.contains("does not submit"),
349            "names that --text alone leaves the prompt unsent"
350        );
351        assert!(ASK_REFUSAL.contains(r"\r"), "names the plain submit key");
352        assert!(
353            ASK_REFUSAL.contains(r"\t"),
354            "names the pasted-block submit key"
355        );
356    }
357
358    // Raw JSON (not a typed RegistryEntry) so this test only names the fields
359    // it cares about; every other field has a `#[serde(default)]` on the real
360    // struct and deserializes fine without them.
361    fn write_registry_row(home: &crate::paths::AgentsHome, name: &str, provider: &str) {
362        std::fs::create_dir_all(home.registry_json().parent().unwrap()).unwrap();
363        let body = serde_json::json!({
364            "schema_version": crate::state::REGISTRY_SCHEMA_VERSION,
365            "agents": [{
366                "name": name,
367                "provider": provider,
368                "cwd": "/x",
369                "status": "live",
370                "created_at": "2026-01-01T00:00:00Z",
371            }],
372        });
373        std::fs::write(home.registry_json(), body.to_string()).unwrap();
374    }
375
376    fn write_empty_registry(home: &crate::paths::AgentsHome) {
377        std::fs::create_dir_all(home.registry_json().parent().unwrap()).unwrap();
378        let body = serde_json::json!({
379            "schema_version": crate::state::REGISTRY_SCHEMA_VERSION,
380            "agents": [],
381        });
382        std::fs::write(home.registry_json(), body.to_string()).unwrap();
383    }
384
385    #[test]
386    fn non_opencode_target_falls_through() {
387        let dir = tempfile::tempdir().unwrap();
388        let h = home(dir.path());
389        write_empty_registry(&h);
390        let params = serde_json::json!({"provider": "codex"});
391        assert_eq!(maybe_run_opencode_ask(&h, &params, "wk"), None);
392    }
393
394    #[test]
395    fn existing_opencode_row_refuses_by_registry_lookup_alone() {
396        // No --provider flag needed: the registry lookup resolves it, exactly
397        // like the "agent already exists" case the finding named.
398        let dir = tempfile::tempdir().unwrap();
399        let h = home(dir.path());
400        write_registry_row(&h, "oc", "opencode");
401        let params = serde_json::json!({});
402        assert_eq!(maybe_run_opencode_ask(&h, &params, "oc"), Some(2));
403    }
404
405    #[test]
406    fn provider_flag_alone_also_refuses() {
407        let dir = tempfile::tempdir().unwrap();
408        let h = home(dir.path());
409        write_empty_registry(&h);
410        let params = serde_json::json!({"provider": "opencode"});
411        assert_eq!(maybe_run_opencode_ask(&h, &params, "new-oc"), Some(2));
412    }
413
414    #[test]
415    fn argv_is_headless_run_bypass_with_prompt_last() {
416        // Matches OpencodeProvider::create_argv (confirmed vs opencode v1.14.50).
417        assert_eq!(
418            build_opencode_argv("do X", None),
419            vec!["opencode", "run", "--dangerously-skip-permissions", "do X"]
420        );
421    }
422
423    #[test]
424    fn argv_routes_footnote_slash_command_via_command_flag() {
425        // A rendered `/fno:verb` rides `--command` so opencode expands the plugin
426        // command instead of running it as prose (x-de43 / codex P1).
427        assert_eq!(
428            build_opencode_argv("/fno:target no-merge x-abcd", None),
429            vec![
430                "opencode",
431                "run",
432                "--dangerously-skip-permissions",
433                "--command",
434                "fno:target",
435                "no-merge x-abcd"
436            ]
437        );
438    }
439
440    #[test]
441    fn argv_threads_model_before_prompt() {
442        assert_eq!(
443            build_opencode_argv("m", Some("anthropic/claude-x")),
444            vec![
445                "opencode",
446                "run",
447                "--dangerously-skip-permissions",
448                "--model",
449                "anthropic/claude-x",
450                "m"
451            ]
452        );
453    }
454
455    #[test]
456    fn tail_chars_is_utf8_safe_and_bounded() {
457        assert_eq!(tail_chars("abcdef", 3), "def");
458        assert_eq!(tail_chars("ab", 5), "ab"); // fewer than n -> whole string
459        assert_eq!(tail_chars("héllo", 3), "llo"); // never splits a codepoint
460    }
461}