Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/agent-registry/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
pub mod detect;
pub mod registry;
pub use registry::{Agent, AgentSpec, Tier, REGISTRY, spec};
pub use registry::{headless_args, Agent, AgentSpec, Tier, REGISTRY, spec};
pub use detect::{detect_all, detect_all_with, find_binary, resolve_version, resolve_version_with, DetectedAgent, VersionRunner, RealVersionRunner, VersionCacheEntry};
31 changes: 31 additions & 0 deletions crates/agent-registry/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,22 @@ pub fn spec(agent: Agent) -> &'static AgentSpec {
.expect("every Agent variant has exactly one REGISTRY entry")
}

/// The subcommand/flags that put an agent's CLI into non-interactive "print"
/// mode, to be followed by the prompt as the final argument (e.g. `claude -p
/// "<prompt>"`, `codex exec "<prompt>"`). `None` for agents with no headless
/// invocation (editor-embedded, or simply not yet mapped).
#[allow(dead_code)]
#[must_use]
pub fn headless_args(agent: Agent) -> Option<&'static [&'static str]> {
match agent {
Agent::ClaudeCode => Some(&["-p"]),
Agent::Codex => Some(&["exec"]),
Agent::GeminiCli => Some(&["-p"]),
Agent::Opencode => Some(&["run"]),
_ => None,
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -338,4 +354,19 @@ mod tests {
assert_eq!(s.id.as_str(), s.display_name);
}
}

#[test]
fn headless_args_map_known_print_modes() {
assert_eq!(headless_args(Agent::ClaudeCode), Some(&["-p"][..]));
assert_eq!(headless_args(Agent::Codex), Some(&["exec"][..]));
assert_eq!(headless_args(Agent::GeminiCli), Some(&["-p"][..]));
assert_eq!(headless_args(Agent::Opencode), Some(&["run"][..]));
}

#[test]
fn headless_args_none_for_agents_without_a_print_mode() {
// Editor-embedded / unmapped agents have no headless invocation.
assert_eq!(headless_args(Agent::Cursor), None);
assert_eq!(headless_args(Agent::VscodeCopilot), None);
}
}
289 changes: 288 additions & 1 deletion src/agent_launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
// Finds the agent binary on PATH, maps --model/--mode to agent-native
// flags, and executes with pass-through args and inherited stdio.
use agent_registry::detect::find_binary;
use agent_registry::{AgentSpec, Tier};
use agent_registry::{headless_args, Agent, AgentSpec, Tier};
use std::io::Read;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

pub enum LaunchOutcome {
Launched,
Expand Down Expand Up @@ -97,6 +100,174 @@ pub fn run_launch_env(
}
}

/// Captured result of a headless (non-interactive) child process.
#[allow(dead_code)]
pub struct Captured {
/// True iff the child exited 0 and did not time out.
pub success: bool,
/// Everything the child wrote to stdout.
pub stdout: String,
/// True iff the child was killed for outliving the timeout.
pub timed_out: bool,
}

/// Kill `child` and everything it spawned, not just the direct process. A
/// plain `child.kill()` only signals the direct child; if that child (e.g.
/// `claude -p`, `codex exec`) has itself spawned a grandchild that inherited
/// the piped stdout fd, the grandchild can keep that pipe's write end open
/// after the direct child dies — which hangs the reader thread's
/// `read_to_string` (it blocks until every writer closes the pipe) forever,
/// defeating the timeout entirely. `run_captured` puts the child in its own
/// process group (Unix) so we can kill the whole group here.
fn kill_tree(child: &mut std::process::Child) {
#[cfg(unix)]
{
// `kill -KILL -<pid>` packs the signal and the (negative, i.e.
// process-group-targeting) pid into two separate `-`-prefixed argv
// entries. Some `kill` implementations misparse the second as
// another option rather than as the target once a signal option has
// already been consumed. `-s SIGNAME` plus a `--` end-of-options
// marker before the pid is the portable, unambiguous idiom.
let _ = Command::new("kill")
.arg("-s")
.arg("KILL")
.arg("--")
.arg(format!("-{}", child.id()))
.status();
}
#[cfg(windows)]
{
let _ = Command::new("taskkill")
.args(["/T", "/F", "/PID", &child.id().to_string()])
.status();
}
#[cfg(not(any(unix, windows)))]
{
let _ = child.kill();
}
}

/// Run `cmd` to completion, capturing stdout, and kill the child (and its
/// whole process tree) if it outlives `timeout` (reporting `timed_out`).
/// Stdout is drained on a separate thread so a child that fills the OS pipe
/// buffer can't deadlock the wait loop.
#[allow(dead_code)]
pub fn run_captured(mut cmd: Command, timeout: Duration) -> std::io::Result<Captured> {
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::null());
cmd.stdin(Stdio::null());
// Make the child the leader of a new process group so any descendants it
// spawns (which inherit the group by default) can be killed together via
// `kill_tree` — see its doc comment for why this matters.
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
let mut child = cmd.spawn()?;

let mut pipe = child.stdout.take().expect("stdout piped above");
let reader = std::thread::spawn(move || {
let mut buf = String::new();
let _ = pipe.read_to_string(&mut buf);
buf
});

let start = Instant::now();
let mut timed_out = false;
let status = loop {
if let Some(status) = child.try_wait()? {
break status;
}
if start.elapsed() >= timeout {
kill_tree(&mut child);
let status = child.wait()?;
timed_out = true;
break status;
}
std::thread::sleep(Duration::from_millis(20));
};

let stdout = reader.join().unwrap_or_default();
Ok(Captured {
success: status.success() && !timed_out,
stdout,
timed_out,
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Build the full argv for a headless run: `[binary, <print-mode flags…>, prompt]`.
/// `None` if the agent has no headless print mode.
#[allow(dead_code)]
pub fn headless_argv(agent: Agent, binary: &Path, prompt: &str) -> Option<Vec<String>> {
let flags = headless_args(agent)?;
let mut argv = Vec::with_capacity(flags.len() + 2);
argv.push(binary.to_string_lossy().into_owned());
argv.extend(flags.iter().map(|s| (*s).to_string()));
argv.push(prompt.to_string());
Some(argv)
}

/// Outcome of a headless (non-interactive, output-captured) agent invocation.
#[allow(dead_code)]
#[derive(Debug)]
pub enum HeadlessOutcome {
/// The agent ran and exited 0; carries captured stdout (the reply).
Ok(String),
UnknownAgent(String),
/// The agent has no non-interactive print mode.
NotHeadless(String),
/// The agent binary was not found on PATH.
NotFound(String),
/// The agent ran but failed (non-zero exit or timed out).
Failed(String),
}

/// Run an agent non-interactively with `prompt` and capture its reply, killing
/// it if it outlives `timeout`. Reuses the shared registry (binary discovery +
/// per-agent print-mode mapping) so callers don't reimplement any of it.
#[allow(dead_code)]
pub fn run_headless(
registry: &[AgentSpec],
agent: &str,
prompt: &str,
timeout: Duration,
) -> HeadlessOutcome {
let Some(spec) = registry.iter().find(|s| s.id.as_str() == agent) else {
return HeadlessOutcome::UnknownAgent(format!("unknown agent: {agent}"));
};
// Check headless support before touching PATH, so an unmapped agent reports
// NotHeadless rather than NotFound.
if headless_args(spec.id).is_none() {
return HeadlessOutcome::NotHeadless(format!(
"{} has no headless print mode",
spec.display_name
));
}
let Some(binary) = find_binary(spec.binary_names) else {
return HeadlessOutcome::NotFound(format!(
"{} not found on PATH — install it first with: agentflare agents install {agent}",
spec.binary_names.join(" / ")
));
};
let Some(argv) = headless_argv(spec.id, &binary, prompt) else {
return HeadlessOutcome::NotHeadless(format!(
"{} has no headless print mode",
spec.display_name
));
};
let mut cmd = Command::new(&argv[0]);
cmd.args(&argv[1..]);
match run_captured(cmd, timeout) {
Ok(c) if c.success => HeadlessOutcome::Ok(c.stdout),
Ok(c) if c.timed_out => {
HeadlessOutcome::Failed(format!("{} timed out after {timeout:?}", spec.display_name))
}
Ok(_) => HeadlessOutcome::Failed(format!("{} exited non-zero", spec.display_name)),
Err(e) => HeadlessOutcome::Failed(format!("failed to run {}: {e}", spec.display_name)),
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -152,4 +323,120 @@ mod tests {
_ => {}
}
}

// Process spawning is exercised via a POSIX shell; gate to Unix so the
// Windows CI (no `sh`/`sleep`) never runs it.
#[cfg(unix)]
#[test]
fn run_captured_captures_stdout() {
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("printf 'hello world'");
let out = run_captured(cmd, std::time::Duration::from_secs(5)).unwrap();
assert!(out.success);
assert!(!out.timed_out);
assert_eq!(out.stdout, "hello world");
}

#[cfg(unix)]
#[test]
fn run_captured_times_out_and_kills_the_child() {
let start = std::time::Instant::now();
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("sleep 5");
let out = run_captured(cmd, std::time::Duration::from_millis(150)).unwrap();
assert!(out.timed_out, "should report timeout");
assert!(!out.success, "a killed child is not a success");
// Must return promptly, not wait out the full 5s sleep.
assert!(start.elapsed() < std::time::Duration::from_secs(2), "did not kill promptly");
}

// Proves the fix for the "descendant outlives the direct child" hang: the
// direct child backgrounds a grandchild that inherits the piped stdout fd,
// then waits on it. If timeout only killed the direct child (the old
// `child.kill()` behavior), the grandchild would keep the pipe's write end
// open and `reader.join()` would block for the full 5s sleep. With
// process-group tree-killing, both die together and this returns promptly.
#[cfg(unix)]
#[test]
fn run_captured_times_out_and_kills_the_whole_tree() {
let start = std::time::Instant::now();
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("sleep 5 & wait");
let out = run_captured(cmd, std::time::Duration::from_millis(150)).unwrap();
assert!(out.timed_out, "should report timeout");
assert!(!out.success, "a killed child is not a success");
assert!(
start.elapsed() < std::time::Duration::from_secs(2),
"did not kill the whole tree promptly — a descendant likely kept the stdout pipe open"
);
}

fn headless_registry() -> Vec<AgentSpec> {
vec![
AgentSpec {
id: Agent::Codex,
display_name: "codex",
tier: Tier::Cli,
// A binary name that will never resolve on PATH.
binary_names: &["definitely-not-a-real-binary-xyz"],
version_args: &[],
package_manager: None,
package_name: None,
},
AgentSpec {
id: Agent::Aider, // Cli, but no headless print mode mapped
display_name: "aider",
tier: Tier::Cli,
binary_names: &["aider"],
version_args: &[],
package_manager: None,
package_name: None,
},
]
}

#[test]
fn headless_argv_puts_flags_before_prompt() {
let binary = std::path::Path::new("/usr/bin/claude");
assert_eq!(
headless_argv(Agent::ClaudeCode, binary, "hi there"),
Some(vec!["/usr/bin/claude".to_string(), "-p".to_string(), "hi there".to_string()])
);
assert_eq!(
headless_argv(Agent::Codex, std::path::Path::new("/x/codex"), "do it"),
Some(vec!["/x/codex".to_string(), "exec".to_string(), "do it".to_string()])
);
}

#[test]
fn headless_argv_none_without_print_mode() {
assert_eq!(headless_argv(Agent::Aider, std::path::Path::new("/x/aider"), "p"), None);
}

#[test]
fn run_headless_unknown_agent() {
let reg = headless_registry();
match run_headless(&reg, "nope", "hi", Duration::from_secs(1)) {
HeadlessOutcome::UnknownAgent(m) => assert!(m.contains("nope")),
other => panic!("expected UnknownAgent, got {other:?}"),
}
}

#[test]
fn run_headless_agent_without_print_mode() {
let reg = headless_registry();
match run_headless(&reg, "aider", "hi", Duration::from_secs(1)) {
HeadlessOutcome::NotHeadless(m) => assert!(m.contains("aider")),
other => panic!("expected NotHeadless, got {other:?}"),
}
}

#[test]
fn run_headless_binary_not_found() {
let reg = headless_registry();
match run_headless(&reg, "codex", "hi", Duration::from_secs(1)) {
HeadlessOutcome::NotFound(m) => assert!(m.contains("not found")),
other => panic!("expected NotFound, got {other:?}"),
}
}
}
25 changes: 25 additions & 0 deletions src/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,25 @@ pub fn cli_run(agent: &str, stage: Option<&str>, model: Option<&str>, mode: Opti
}
}

/// Headless variant of `cli_run`: run the agent non-interactively with `prompt`,
/// print its captured reply to stdout, and return a process exit code (0 on
/// success, 1 on any failure). The caller decides whether to `exit`.
pub fn cli_run_headless(agent: &str, prompt: &str, timeout: std::time::Duration) -> i32 {
match agent_launch::run_headless(agent_registry::REGISTRY, agent, prompt, timeout) {
agent_launch::HeadlessOutcome::Ok(reply) => {
print!("{reply}");
0
}
agent_launch::HeadlessOutcome::UnknownAgent(msg)
| agent_launch::HeadlessOutcome::NotHeadless(msg)
| agent_launch::HeadlessOutcome::NotFound(msg)
| agent_launch::HeadlessOutcome::Failed(msg) => {
eprintln!("error: {msg}");
1
}
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -398,4 +417,10 @@ mod tests {
assert!(json.contains("\"version\": \"7.8.9\""));
});
}

#[test]
fn cli_run_headless_unknown_agent_returns_error_code() {
let code = cli_run_headless("nope-not-an-agent", "hi", std::time::Duration::from_secs(1));
assert_eq!(code, 1);
}
}
Loading
Loading