1const 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
28pub 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; }
52 eprintln!("{ASK_REFUSAL}");
53 Some(2)
54}
55
56use std::io::{Read, Write};
61use std::path::Path;
62use std::sync::{Arc, Mutex};
63use std::time::Duration;
64
65const DEFAULT_OPENCODE_TIMEOUT: Duration = Duration::from_secs(600);
69
70#[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
96fn 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
118fn 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, }
129}
130
131fn 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
139fn 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 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 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 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 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 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#[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, 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 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 let effective_message = if message.is_empty() { "hello" } else { message };
299 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 #[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 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, ¶ms, "wk"), None);
392 }
393
394 #[test]
395 fn existing_opencode_row_refuses_by_registry_lookup_alone() {
396 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, ¶ms, "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, ¶ms, "new-oc"), Some(2));
412 }
413
414 #[test]
415 fn argv_is_headless_run_bypass_with_prompt_last() {
416 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 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"); assert_eq!(tail_chars("héllo", 3), "llo"); }
461}