use crate::client::Client;
use autofork_core::config::Paths;
use autofork_core::protocol::{RequestBody, WakeFork};
use std::collections::HashMap;
use std::io::Read;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
const FORK_TIMEOUT_SECS: u64 = 1800;
static HARNESS_BIN: std::sync::OnceLock<Option<std::path::PathBuf>> = std::sync::OnceLock::new();
pub fn set_harness_bin(bin: Option<std::path::PathBuf>) {
let _ = HARNESS_BIN.set(bin);
}
fn harness_bin() -> Option<String> {
HARNESS_BIN
.get()
.and_then(|b| b.as_ref())
.map(|p| p.to_string_lossy().into_owned())
}
fn claude_bin() -> String {
std::env::var("AUTOFORK_CLAUDE_BIN")
.ok()
.or_else(harness_bin)
.unwrap_or_else(|| "claude".to_string())
}
fn fork_timeout() -> Duration {
let secs = std::env::var("AUTOFORK_CLAUDE_FORK_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(FORK_TIMEOUT_SECS);
Duration::from_secs(secs)
}
#[derive(Default)]
pub struct RunResult {
pub report: Option<String>,
pub wake_block: Option<String>,
}
static RUNNING: AtomicBool = AtomicBool::new(false);
pub fn watch_harness(harness: Option<autofork_core::harness::Harness>) {
let Some(harness) = harness else { return };
std::thread::spawn(move || loop {
std::thread::sleep(Duration::from_secs(5));
if !harness.alive() && !RUNNING.load(Ordering::SeqCst) {
std::process::exit(0);
}
});
}
pub fn execute_wake(
paths: &Paths,
session_id: &str,
resume_target: &str,
cwd: &std::path::Path,
forks: Vec<WakeFork>,
reports: &mut HashMap<String, String>,
) -> Vec<String> {
RUNNING.store(true, Ordering::SeqCst);
let mut handles = Vec::new();
for spec in forks {
let paths = Paths::new(paths.base.clone());
let session_id = session_id.to_string();
let resume_target = resume_target.to_string();
let cwd = cwd.to_path_buf();
let mut carried = String::new();
for pred in &spec.after {
if let Some(r) = reports.get(pred) {
carried.push_str(&format!(
"\n\nThis fork runs after '{pred}'; its report follows so you can build on it:\n{r}"
));
}
}
if spec.chain {
if let Some(prev) = reports.get(&spec.name) {
carried.push_str(&format!(
"\n\nYour previous run's report (not yet seen by the parent session):\n{prev}"
));
}
}
let name = spec.name.clone();
let h = std::thread::spawn(move || {
run_one(
&paths,
&session_id,
&resume_target,
&cwd,
spec,
&carried,
true,
)
});
handles.push((name, h));
}
let mut wake_blocks = Vec::new();
for (name, h) in handles {
let Ok(result) = h.join() else { continue };
if let Some(report) = result.report {
reports.insert(name, report);
}
if let Some(block) = result.wake_block {
wake_blocks.push(block);
}
}
RUNNING.store(false, Ordering::SeqCst);
wake_blocks
}
fn run_one(
paths: &Paths,
session_id: &str,
resume_target: &str,
cwd: &std::path::Path,
spec: WakeFork,
carried: &str,
can_wake: bool,
) -> RunResult {
let run_ref = format!("hl:{}", crate::codex::uuid_v4());
send(
paths,
RequestBody::ForkSpawned {
session_id: session_id.to_string(),
fork: spec.name.clone(),
run_ref: run_ref.clone(),
},
);
let spool_key = resume_target.to_string();
let prompt = format!("{}{}", spec.prompt, carried);
let mut candidates: Vec<Option<String>> = Vec::new();
match &spec.model {
Some(m) => {
candidates.push(Some(m.clone()));
candidates.extend(spec.model_fallbacks.iter().cloned().map(Some));
}
None => candidates.push(None),
}
let mut status = "failed";
let mut report = String::new();
for (i, model) in candidates.iter().enumerate() {
let (st, rep) = run_attempt(
session_id,
resume_target,
cwd,
&spec,
&prompt,
model.as_deref(),
);
status = st;
report = rep;
if status == "completed" {
break;
}
if i + 1 < candidates.len() {
eprintln!(
"[headless] fork '{}' failed on model {:?}; retrying on {:?}",
spec.name,
model,
candidates[i + 1]
);
}
}
finish_run(
paths, session_id, &spool_key, spec, run_ref, status, report, can_wake,
)
}
fn run_attempt(
session_id: &str,
resume_target: &str,
cwd: &std::path::Path,
spec: &WakeFork,
prompt: &str,
model: Option<&str>,
) -> (&'static str, String) {
let mut cmd = Command::new(claude_bin());
cmd.arg("-p")
.arg("--resume")
.arg(resume_target)
.arg("--fork-session")
.arg("--output-format")
.arg("json");
if let Some(m) = model {
cmd.arg("--model").arg(m);
}
cmd.arg("--permission-mode")
.arg(spec.mode.as_deref().unwrap_or("acceptEdits"));
if std::env::var_os("AUTOFORK_FORK_HOOKS").is_none() {
cmd.arg("--settings").arg(r#"{"disableAllHooks":true}"#);
}
cmd.arg(prompt)
.current_dir(cwd)
.env("AUTOFORK_FORK", "1")
.env("AUTOFORK_SESSION_ID", session_id)
.env("AUTOFORK_FORK_NAME", &spec.name)
.env("AUTOFORK_TRIGGER", &spec.trigger)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
}
match cmd.spawn() {
Ok(mut child) => {
let mut out = String::new();
let deadline = std::time::Instant::now() + fork_timeout();
let mut stdout = child.stdout.take();
let reader = std::thread::spawn(move || {
let mut s = String::new();
if let Some(o) = stdout.as_mut() {
let _ = o.read_to_string(&mut s);
}
s
});
let exited = loop {
match child.try_wait() {
Ok(Some(st)) => break Some(st),
Ok(None) if std::time::Instant::now() > deadline => {
let _ = child.kill();
break None;
}
Ok(None) => std::thread::sleep(Duration::from_millis(500)),
Err(_) => break None,
}
};
out.push_str(&reader.join().unwrap_or_default());
let parsed: Option<serde_json::Value> = serde_json::from_str(out.trim()).ok();
let ok = exited.map(|s| s.success()).unwrap_or(false)
&& parsed
.as_ref()
.map(|v| v["is_error"] != serde_json::Value::Bool(true))
.unwrap_or(false);
let text = parsed
.and_then(|v| v["result"].as_str().map(str::to_string))
.unwrap_or_default();
(if ok { "completed" } else { "failed" }, text)
}
Err(e) => {
eprintln!("[headless] fork '{}' spawn failed: {e}", spec.name);
("failed", String::new())
}
}
}
#[allow(clippy::too_many_arguments)]
fn finish_run(
paths: &Paths,
session_id: &str,
spool_key: &str,
spec: WakeFork,
run_ref: String,
status: &'static str,
mut report: String,
can_wake: bool,
) -> RunResult {
report = report.trim().to_string();
let chain_next =
status == "completed" && spec.chain && autofork_core::wake::wants_continue(&report);
if chain_next {
report = autofork_core::wake::strip_continue(&report);
}
let body = if status == "completed" {
if report.is_empty() {
"(the fork finished without a report)".to_string()
} else {
report.clone()
}
} else {
format!(
"(the fork run failed{})",
if report.is_empty() {
String::new()
} else {
format!("; its last message:\n{report}")
}
)
};
let block = autofork_core::wake::report_block(&spec.name, &spec.trigger, status, &body);
let wake_block = (chain_next && can_wake).then(|| block.clone());
if wake_block.is_none() {
send(
paths,
RequestBody::SpoolReport {
session_id: spool_key.to_string(),
fork: spec.name.clone(),
text: block,
},
);
}
send(
paths,
RequestBody::ForkCompleted {
session_id: session_id.to_string(),
fork: spec.name.clone(),
run_ref,
status: status.to_string(),
cont: chain_next.then_some(true),
},
);
RunResult {
report: (status == "completed" && !report.is_empty()).then_some(report),
wake_block,
}
}
fn send(paths: &Paths, body: RequestBody) {
if let Ok(mut client) = Client::connect_or_spawn(paths, Duration::from_secs(5)) {
let _ = client.request(body);
}
}
#[allow(clippy::too_many_arguments)]
pub fn spawn_final_runner(
paths: &Paths,
client: &str,
session_id: &str,
resume_target: &str,
cwd: &std::path::Path,
parent_model: Option<&str>,
parent_permission_mode: Option<&str>,
harness_bin: Option<&std::path::Path>,
specs: &[WakeFork],
) {
if specs.is_empty() {
return;
}
let Ok(exe) = std::env::current_exe() else {
return;
};
let tmp = paths.base.join("tmp");
let _ = std::fs::create_dir_all(&tmp);
let specs_path = tmp.join(format!("final-{}.json", crate::codex::uuid_v4()));
let Ok(json) = serde_json::to_string(specs) else {
return;
};
if std::fs::write(&specs_path, json).is_err() {
return;
}
let log_path = paths.base.join("logs/final-run.log");
if let Some(parent) = log_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let Ok(log) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
else {
return;
};
let Ok(log2) = log.try_clone() else { return };
let mut cmd = Command::new(exe);
cmd.arg("final-run")
.arg("--client")
.arg(client)
.arg("--session")
.arg(session_id)
.arg("--resume-target")
.arg(resume_target)
.arg("--cwd")
.arg(cwd)
.arg("--specs")
.arg(&specs_path)
.stdin(Stdio::null())
.stdout(Stdio::from(log))
.stderr(Stdio::from(log2));
if let Some(m) = parent_model {
cmd.arg("--model").arg(m);
}
if let Some(m) = parent_permission_mode {
cmd.arg("--permission-mode").arg(m);
}
if let Some(b) = harness_bin {
cmd.arg("--bin").arg(b);
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
}
let _ = cmd.spawn();
}
#[allow(clippy::too_many_arguments)]
pub fn run_final(
paths: &Paths,
client: &str,
session_id: &str,
resume_target: &str,
cwd: &std::path::Path,
parent_model: Option<&str>,
parent_permission_mode: Option<&str>,
specs: Vec<WakeFork>,
) {
let mut reports: HashMap<String, String> = HashMap::new();
for spec in specs {
let mut carried = String::new();
for pred in &spec.after {
if let Some(r) = reports.get(pred) {
carried.push_str(&format!(
"\n\nThis fork runs after '{pred}'; its report follows so you can build on it:\n{r}"
));
}
}
let name = spec.name.clone();
let report = match client {
"codex" => crate::codex::run_final_codex(
paths,
session_id,
cwd,
parent_model,
parent_permission_mode,
spec,
&carried,
),
"opencode" => run_final_opencode(paths, session_id, cwd, spec, &carried),
_ => run_one(paths, session_id, resume_target, cwd, spec, &carried, false).report,
};
if let Some(r) = report {
reports.insert(name, r);
}
}
}
fn opencode_run_args(
session_id: &str,
model: Option<&str>,
mode: Option<&str>,
title: &str,
) -> Vec<String> {
let mut args = vec![
"run".to_string(),
"-s".to_string(),
session_id.to_string(),
"--fork".to_string(),
"--title".to_string(),
title.to_string(),
];
if let Some(m) = model {
args.push("-m".to_string());
args.push(m.to_string());
}
if let Some(agent) = mode {
args.push("--agent".to_string());
args.push(agent.to_string());
}
args.push("--auto".to_string());
args
}
fn run_final_opencode(
paths: &Paths,
session_id: &str,
cwd: &std::path::Path,
spec: WakeFork,
carried: &str,
) -> Option<String> {
let run_ref = format!("fr:{}", crate::codex::uuid_v4());
send(
paths,
RequestBody::ForkSpawned {
session_id: session_id.to_string(),
fork: spec.name.clone(),
run_ref: run_ref.clone(),
},
);
let prompt = format!("{}{}", spec.prompt, carried);
let mut candidates: Vec<Option<String>> = Vec::new();
match &spec.model {
Some(m) => {
candidates.push(Some(m.clone()));
candidates.extend(spec.model_fallbacks.iter().cloned().map(Some));
}
None => candidates.push(None),
}
let opencode_bin = std::env::var("AUTOFORK_OPENCODE_BIN")
.ok()
.or_else(harness_bin)
.unwrap_or_else(|| "opencode".to_string());
let mut status = "failed";
let mut report = String::new();
for model in &candidates {
let mut cmd = Command::new(&opencode_bin);
cmd.args(opencode_run_args(
session_id,
model.as_deref(),
spec.mode.as_deref(),
&format!("autofork/{} ({})", spec.name, spec.trigger),
));
cmd.arg(&prompt)
.current_dir(cwd)
.env("AUTOFORK_FORK", "1")
.env("AUTOFORK_SESSION_ID", session_id)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let out = cmd.output();
match out {
Ok(o) if o.status.success() => {
status = "completed";
report = String::from_utf8_lossy(&o.stdout).trim().to_string();
break;
}
_ => status = "failed",
}
}
send(
paths,
RequestBody::ForkCompleted {
session_id: session_id.to_string(),
fork: spec.name.clone(),
run_ref,
status: status.to_string(),
cont: None,
},
);
(status == "completed" && !report.is_empty()).then_some(report)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn opencode_fork_runs_can_use_tools() {
let args = opencode_run_args("ses_1", None, None, "autofork/review (idle)");
assert_eq!(
args,
[
"run",
"-s",
"ses_1",
"--fork",
"--title",
"autofork/review (idle)",
"--auto"
]
);
}
#[test]
fn opencode_fork_runs_honor_model_and_mode() {
let args = opencode_run_args(
"ses_1",
Some("anthropic/claude-haiku-4-5"),
Some("plan"),
"autofork/review (idle)",
);
assert_eq!(
args,
[
"run",
"-s",
"ses_1",
"--fork",
"--title",
"autofork/review (idle)",
"-m",
"anthropic/claude-haiku-4-5",
"--agent",
"plan",
"--auto",
]
);
}
}