const ASK_REFUSAL: &str = "fno-agents: opencode has no stateful 'ask' resume \
(pane-hosted, no client-side dispatch); drive the pane directly with \
'fno mux pane send <pane> --session <session> --text <prompt>'. \
--text only fills the composer, it does not submit: append the TUI's submit key \
(a trailing \\r, or a SECOND send of $'\\t' once the payload is large enough to \
render as a pasted block) or the prompt sits there unsent.";
pub fn maybe_run_opencode_ask(
home: &crate::paths::AgentsHome,
params: &serde_json::Value,
name: &str,
) -> Option<i32> {
let provider_param = params.get("provider").and_then(|v| v.as_str());
let registry = match crate::state::load_registry(&home.registry_json()) {
Ok(r) => r,
Err(e) => {
eprintln!(
"fno-agents: cannot read agents registry at {:?}: {}",
home.registry_json(),
e
);
return Some(12);
}
};
let existing_provider = registry.find(name).map(|e| e.harness_name());
let resolved = existing_provider.or(provider_param);
if resolved != Some("opencode") {
return None; }
eprintln!("{ASK_REFUSAL}");
Some(2)
}
use std::io::{Read, Write};
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::time::Duration;
const DEFAULT_OPENCODE_TIMEOUT: Duration = Duration::from_secs(600);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AskOutcome {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
impl AskOutcome {
fn ok_reply(reply: String) -> Self {
Self {
stdout: reply,
stderr: String::new(),
exit_code: 0,
}
}
fn err(msg: impl Into<String>, code: i32) -> Self {
Self {
stdout: String::new(),
stderr: format!("{}\n", msg.into()),
exit_code: code,
}
}
}
fn build_opencode_argv(prompt: &str, model: Option<&str>) -> Vec<String> {
let mut argv = vec![
"opencode".to_string(),
"run".to_string(),
"--dangerously-skip-permissions".to_string(),
];
if let Some(m) = model {
argv.push("--model".to_string());
argv.push(m.to_string());
}
argv.extend(crate::provider::opencode_run_tail(prompt));
argv
}
fn tail_chars(s: &str, n: usize) -> &str {
if n == 0 {
return "";
}
match s.char_indices().rev().nth(n - 1) {
Some((idx, _)) => &s[idx..],
None => s, }
}
fn derive_log_path(home: &crate::paths::AgentsHome, name: &str) -> std::path::PathBuf {
home.root()
.join("agents")
.join("logs")
.join(format!("{}.jsonl", name))
}
fn run_opencode(
argv: &[String],
output_path: &Path,
timeout: Option<Duration>,
cwd: &Path,
agent_self: &str,
) -> Result<String, (i32, String)> {
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
let tee = crate::subprocess_ask::open_tee(output_path).ok();
let argv = crate::spawn_gate::qos_wrap(cwd, argv.to_vec());
let mut cmd = Command::new(&argv[0]);
cmd.args(&argv[1..]);
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.current_dir(cwd);
cmd.env("FNO_AGENT_SELF", agent_self);
cmd.env("FNO_AGENT_PROVIDER", "opencode");
unsafe {
cmd.pre_exec(|| {
libc::setpgid(0, 0);
Ok(())
});
}
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err((13, "opencode binary not found on PATH".to_string()));
}
Err(e) => return Err((2, format!("OSError invoking opencode: {}", e))),
};
let pid = child.id();
let _sigint_guard = crate::subprocess_ask::SigintForwarder::install(pid);
let mut stdout_pipe = child.stdout.take().expect("stdout piped");
let stderr_pipe = child.stderr.take().expect("stderr piped");
let stderr_buf: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
let cap = stderr_buf.clone();
let stderr_handle = std::thread::spawn(move || {
let mut r = stderr_pipe;
let mut s = String::new();
let _ = r.read_to_string(&mut s);
if let Ok(mut g) = cap.lock() {
*g = s;
}
});
let mut watchdog = crate::subprocess_ask::AskWatchdog::spawn(pid, timeout);
let mut stdout_bytes: Vec<u8> = Vec::new();
let _ = stdout_pipe.read_to_end(&mut stdout_bytes);
let stdout_text = String::from_utf8_lossy(&stdout_bytes).into_owned();
if let Some(mut fh) = tee {
let _ = fh.write_all(stdout_text.as_bytes());
}
watchdog.cancel();
let (exit_code, sigkill_escalated) =
crate::subprocess_ask::wait_with_grace(pid, &mut child, 5.0);
watchdog.join();
let _ = stderr_handle.join();
let stderr_text = stderr_buf.lock().map(|s| s.clone()).unwrap_or_default();
if crate::subprocess_ask::ask_interrupted() {
return Err((130, "interrupted".to_string()));
}
if watchdog.timed_out() || sigkill_escalated {
return Err((
12,
format!(
"opencode run timed out after {:.0}s",
timeout.unwrap_or(DEFAULT_OPENCODE_TIMEOUT).as_secs_f64()
),
));
}
if exit_code != 0 {
return Err((
exit_code,
format!(
"opencode run exited {}: {}",
exit_code,
tail_chars(stderr_text.trim(), 400)
),
));
}
Ok(stdout_text)
}
#[allow(clippy::too_many_arguments)]
pub fn dispatch_opencode_once(
home: &crate::paths::AgentsHome,
name: &str,
message: &str,
from_name: &str,
cwd: &Path,
_yolo: bool, timeout: Option<Duration>,
model: Option<&str>,
) -> AskOutcome {
use crate::claude_ask::{emit_event, py_repr, validate_spawn_inputs};
if let Err(msg) = validate_spawn_inputs(name, from_name) {
return AskOutcome::err(msg, 2);
}
let events = home.events_jsonl();
let registry = match crate::state::load_registry(&home.registry_json()) {
Ok(r) => r,
Err(e) => {
emit_event(
&events,
"agent_ask_failed",
&[
("stage", "registry-read".into()),
("name", name.into()),
("provider", "opencode".into()),
("error", e.to_string().into()),
],
);
return AskOutcome::err(format!("registry read failed: {}", e), 12);
}
};
if registry.find(name).is_some() {
return AskOutcome::err(
format!(
"agent {} already exists; use 'fno agents rm {}' first or pick another name",
py_repr(name),
name
),
2,
);
}
let effective_message = if message.is_empty() { "hello" } else { message };
let full_prompt = if effective_message.starts_with('/') {
effective_message.to_string()
} else {
format!("[from: {}]\n\n{}", from_name, effective_message)
};
let argv = build_opencode_argv(&full_prompt, model);
let log_path = derive_log_path(home, name);
if let Some(parent) = log_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let eff_timeout = timeout.or(Some(DEFAULT_OPENCODE_TIMEOUT));
match run_opencode(&argv, &log_path, eff_timeout, cwd, name) {
Ok(reply) => AskOutcome::ok_reply(reply),
Err((code, msg)) => {
emit_event(
&events,
"agent_ask_failed",
&[
("stage", "opencode-once".into()),
("name", name.into()),
("provider", "opencode".into()),
("error", msg.clone().into()),
],
);
AskOutcome::err(msg, code)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn home(dir: &std::path::Path) -> crate::paths::AgentsHome {
crate::paths::AgentsHome::at(dir.to_path_buf())
}
#[test]
fn ask_refusal_names_the_submit_step() {
assert!(ASK_REFUSAL.contains("mux pane send"), "keeps the pointer");
assert!(
ASK_REFUSAL.contains("does not submit"),
"names that --text alone leaves the prompt unsent"
);
assert!(ASK_REFUSAL.contains(r"\r"), "names the plain submit key");
assert!(
ASK_REFUSAL.contains(r"\t"),
"names the pasted-block submit key"
);
}
fn write_registry_row(home: &crate::paths::AgentsHome, name: &str, provider: &str) {
std::fs::create_dir_all(home.registry_json().parent().unwrap()).unwrap();
let body = serde_json::json!({
"schema_version": crate::state::REGISTRY_SCHEMA_VERSION,
"agents": [{
"name": name,
"provider": provider,
"cwd": "/x",
"status": "live",
"created_at": "2026-01-01T00:00:00Z",
}],
});
std::fs::write(home.registry_json(), body.to_string()).unwrap();
}
fn write_empty_registry(home: &crate::paths::AgentsHome) {
std::fs::create_dir_all(home.registry_json().parent().unwrap()).unwrap();
let body = serde_json::json!({
"schema_version": crate::state::REGISTRY_SCHEMA_VERSION,
"agents": [],
});
std::fs::write(home.registry_json(), body.to_string()).unwrap();
}
#[test]
fn non_opencode_target_falls_through() {
let dir = tempfile::tempdir().unwrap();
let h = home(dir.path());
write_empty_registry(&h);
let params = serde_json::json!({"provider": "codex"});
assert_eq!(maybe_run_opencode_ask(&h, ¶ms, "wk"), None);
}
#[test]
fn existing_opencode_row_refuses_by_registry_lookup_alone() {
let dir = tempfile::tempdir().unwrap();
let h = home(dir.path());
write_registry_row(&h, "oc", "opencode");
let params = serde_json::json!({});
assert_eq!(maybe_run_opencode_ask(&h, ¶ms, "oc"), Some(2));
}
#[test]
fn provider_flag_alone_also_refuses() {
let dir = tempfile::tempdir().unwrap();
let h = home(dir.path());
write_empty_registry(&h);
let params = serde_json::json!({"provider": "opencode"});
assert_eq!(maybe_run_opencode_ask(&h, ¶ms, "new-oc"), Some(2));
}
#[test]
fn argv_is_headless_run_bypass_with_prompt_last() {
assert_eq!(
build_opencode_argv("do X", None),
vec!["opencode", "run", "--dangerously-skip-permissions", "do X"]
);
}
#[test]
fn argv_routes_footnote_slash_command_via_command_flag() {
assert_eq!(
build_opencode_argv("/fno:target no-merge x-abcd", None),
vec![
"opencode",
"run",
"--dangerously-skip-permissions",
"--command",
"fno:target",
"no-merge x-abcd"
]
);
}
#[test]
fn argv_threads_model_before_prompt() {
assert_eq!(
build_opencode_argv("m", Some("anthropic/claude-x")),
vec![
"opencode",
"run",
"--dangerously-skip-permissions",
"--model",
"anthropic/claude-x",
"m"
]
);
}
#[test]
fn tail_chars_is_utf8_safe_and_bounded() {
assert_eq!(tail_chars("abcdef", 3), "def");
assert_eq!(tail_chars("ab", 5), "ab"); assert_eq!(tail_chars("héllo", 3), "llo"); }
}