use anyhow::{Result, bail};
use std::process::{Command, Stdio};
use crate::cli::AskArgs;
pub fn run_detached(args: &AskArgs) -> Result<()> {
if args.prompt.is_none() && args.prompt_flag.is_none() && args.file.is_none() {
bail!(
"--detach needs an explicit prompt: pass one as an argument, with -p, or with -f \
(the detached run can't read this shell's stdin)"
);
}
if crate::stdin_probe::stdin_would_lose_data().unwrap_or(false) {
bail!(
"--detach cannot read piped stdin (the detached run's stdin is /dev/null); \
pass the input with -f or --prepend instead"
);
}
if !crate::doctor::claude_on_path() {
bail!(
"--detach: claude binary not found on PATH; refusing to spawn a detached run that \
would die on arrival (install claude-code: https://github.com/anthropics/claude-code)"
);
}
let (handle, mint) = resolve_handle(args)?;
let exe = std::env::current_exe()
.map_err(|e| anyhow::anyhow!("--detach: cannot locate the roba binary to re-exec: {e}"))?;
let child_args = detached_argv(std::env::args().skip(1), mint.then_some(handle.as_str()));
let mut cmd = Command::new(exe);
cmd.args(child_args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
detach_process_group(&mut cmd);
cmd.spawn()
.map_err(|e| anyhow::anyhow!("--detach: failed to spawn the detached run: {e}"))?;
if rails_nudge_needed(args) {
eprintln!(
"warning: detached run has no --max-turns / --max-budget-usd cap; nothing is watching it"
);
}
eprintln!("re-attach: roba show {handle} --wait");
println!("{handle}");
Ok(())
}
fn resolve_handle(args: &AskArgs) -> Result<(String, bool)> {
if args.fork {
bail!(
"--detach cannot be combined with --fork: the forked session's id is not known until \
the run starts, so there is no handle to print"
);
}
if let Some(id) = &args.session_id {
return Ok((id.clone(), false));
}
match &args.continue_session {
Some(Some(id)) => Ok((id.clone(), false)),
Some(None) => bail!(
"--detach with bare -c can't pre-mint a handle (the most-recent session's id isn't \
known yet); use -c=ID, --session NAME, or --session-id, or drop -c for a fresh \
detached run"
),
None => Ok((mint_uuid(), true)),
}
}
fn mint_uuid() -> String {
uuid::Uuid::new_v4().to_string()
}
fn detached_argv<I>(raw: I, inject_session_id: Option<&str>) -> Vec<String>
where
I: IntoIterator<Item = String>,
{
let mut out: Vec<String> = Vec::new();
if let Some(id) = inject_session_id {
out.push("--session-id".to_string());
out.push(id.to_string());
}
let mut past_separator = false;
for tok in raw {
if !past_separator && tok == "--" {
past_separator = true;
out.push(tok);
continue;
}
if !past_separator && tok == "--detach" {
continue; }
out.push(tok);
}
out
}
fn rails_nudge_needed(args: &AskArgs) -> bool {
args.max_turns.is_none() && args.max_budget_usd.is_none()
}
#[cfg(unix)]
fn detach_process_group(cmd: &mut Command) {
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(windows)]
fn detach_process_group(cmd: &mut Command) {
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x0000_0008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Cli;
use clap::Parser;
fn argv(args: &[&str]) -> Vec<String> {
args.iter().map(|s| s.to_string()).collect()
}
fn ask(args: &[&str]) -> AskArgs {
Cli::try_parse_from(args).unwrap().ask
}
#[test]
fn strips_detach_token() {
let out = detached_argv(argv(&["--detach", "prompt"]), None);
assert_eq!(out, vec!["prompt".to_string()]);
}
#[test]
fn strips_detach_anywhere_in_argv() {
let out = detached_argv(
argv(&["--model", "haiku", "--detach", "--writable", "prompt"]),
None,
);
assert_eq!(
out,
vec![
"--model".to_string(),
"haiku".to_string(),
"--writable".to_string(),
"prompt".to_string(),
]
);
}
#[test]
fn preserves_everything_else_verbatim() {
let raw = argv(&[
"-C",
"/repo",
"--profile",
"worker",
"-f",
"task.md",
"--trace",
"/tmp/t.jsonl",
"--detach",
]);
let out = detached_argv(raw, None);
assert_eq!(
out,
vec![
"-C",
"/repo",
"--profile",
"worker",
"-f",
"task.md",
"--trace",
"/tmp/t.jsonl",
]
.into_iter()
.map(String::from)
.collect::<Vec<_>>()
);
}
#[test]
fn injects_session_id_when_minted() {
let out = detached_argv(argv(&["--detach", "prompt"]), Some("abc-uuid"));
assert_eq!(
out,
vec![
"--session-id".to_string(),
"abc-uuid".to_string(),
"prompt".to_string(),
]
);
}
#[test]
fn does_not_inject_session_id_when_not_minted() {
let out = detached_argv(
argv(&["--detach", "--session-id", "given-uuid", "prompt"]),
None,
);
assert_eq!(
out,
vec![
"--session-id".to_string(),
"given-uuid".to_string(),
"prompt".to_string(),
]
);
}
#[test]
fn keeps_literal_detach_after_separator() {
let out = detached_argv(argv(&["--detach", "--", "--detach"]), None);
assert_eq!(out, vec!["--".to_string(), "--detach".to_string()]);
}
#[test]
fn injected_session_id_precedes_separator() {
let out = detached_argv(argv(&["--detach", "--", "literal prompt"]), Some("u"));
assert_eq!(
out,
vec![
"--session-id".to_string(),
"u".to_string(),
"--".to_string(),
"literal prompt".to_string(),
]
);
}
#[test]
fn handle_uses_given_session_id_without_minting() {
let args = ask(&[
"roba",
"--detach",
"--session-id",
"11111111-1111-4111-8111-111111111111",
"prompt",
]);
let (handle, mint) = resolve_handle(&args).unwrap();
assert_eq!(handle, "11111111-1111-4111-8111-111111111111");
assert!(!mint, "a given --session-id must not be re-minted");
}
#[test]
fn handle_mints_a_uuid_for_a_fresh_run() {
let args = ask(&["roba", "--detach", "prompt"]);
let (handle, mint) = resolve_handle(&args).unwrap();
assert!(mint, "a fresh detached run mints + injects a handle");
assert_eq!(handle.len(), 36, "got: {handle}");
assert_eq!(handle.matches('-').count(), 4, "got: {handle}");
}
#[test]
fn handle_uses_explicit_continue_id() {
let args = ask(&["roba", "--detach", "-c=session-xyz", "prompt"]);
let (handle, mint) = resolve_handle(&args).unwrap();
assert_eq!(handle, "session-xyz");
assert!(!mint);
}
#[test]
fn handle_bare_continue_errors() {
let args = ask(&["roba", "--detach", "-c", "-p", "prompt"]);
assert!(matches!(args.continue_session, Some(None)));
assert!(resolve_handle(&args).is_err());
}
#[test]
fn nudge_when_no_caps() {
let args = ask(&["roba", "--detach", "prompt"]);
assert!(rails_nudge_needed(&args));
}
#[test]
fn no_nudge_with_max_turns() {
let args = ask(&["roba", "--detach", "--max-turns", "10", "prompt"]);
assert!(!rails_nudge_needed(&args));
}
#[test]
fn no_nudge_with_max_budget() {
let args = ask(&["roba", "--detach", "--max-budget-usd", "5", "prompt"]);
assert!(!rails_nudge_needed(&args));
}
}