feat: add --headless flag for unattended operation

This commit is contained in:
2026-07-27 17:17:56 -06:00
parent af5c34fde5
commit f1415067f2
5 changed files with 162 additions and 1 deletions
+15
View File
@@ -230,6 +230,10 @@ pub struct Cli {
/// Start the sandbox with a clean slate. No copied config or tokens; LLM credentials injected via sbx proxy
#[arg(long, requires = "sandbox", help_heading = "Sandbox")]
pub fresh: bool,
/// Declare that no human is present. All user-interaction tools return structured JSON instead of
/// prompting. Implies --dangerously-skip-permissions. Incompatible with REPL mode (requires a prompt).
#[arg(long, help_heading = "Sandbox")]
pub headless: bool,
/// Display information
#[arg(long, help_heading = "Diagnostics & Tools")]
pub info: bool,
@@ -490,6 +494,17 @@ mod tests {
assert!(!cli.dangerously_skip_permissions);
}
#[test]
fn parse_headless_flag() {
let cli = parse(&["--headless", "do something"]);
assert!(cli.headless);
}
#[test]
fn parse_headless_default_off() {
assert!(!parse(&[]).headless);
}
#[test]
fn parse_sync_models_flag() {
let cli = parse(&["--sync-models"]);
+47
View File
@@ -1,11 +1,13 @@
use super::{FunctionDeclaration, JsonSchema};
use crate::config::RequestContext;
use crate::supervisor::escalation::{EscalationRequest, new_escalation_id};
use crate::utils::HEADLESS;
use anyhow::{Result, anyhow, bail};
use indexmap::IndexMap;
use inquire::{Confirm, MultiSelect, Select, Text};
use serde_json::{Value, json};
use std::sync::atomic::Ordering;
use std::time::Duration;
use tokio::sync::oneshot;
@@ -136,6 +138,10 @@ pub async fn handle_user_tool(
.strip_prefix(USER_FUNCTION_PREFIX)
.unwrap_or(cmd_name);
if HEADLESS.load(Ordering::SeqCst) {
return Ok(handle_headless(action, args));
}
let depth = ctx.current_depth;
if depth == 0 {
@@ -145,6 +151,22 @@ pub async fn handle_user_tool(
}
}
fn handle_headless(action: &str, args: &Value) -> Value {
let question = args.get("question").and_then(Value::as_str).unwrap_or("");
let options: Vec<Value> = args
.get("options")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
json!({
"needs_human": true,
"action": action,
"question": question,
"options": options,
"guidance": "No human is present. Apply a sensible default or abort the task.",
})
}
fn handle_direct(action: &str, args: &Value) -> Result<Value> {
match action {
"select" => handle_direct_ask(args),
@@ -271,6 +293,31 @@ async fn handle_escalated(ctx: &RequestContext, action: &str, args: &Value) -> R
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn headless_select_returns_structured_json() {
let args = json!({"question": "pick one", "options": ["a", "b"]});
let v = handle_headless("select", &args);
assert_eq!(v["needs_human"], true);
assert_eq!(v["action"], "select");
assert_eq!(v["question"], "pick one");
assert_eq!(v["options"], json!(["a", "b"]));
assert!(v["guidance"].is_string());
}
#[test]
fn headless_confirm_returns_empty_options_when_absent() {
let args = json!({"question": "yes or no?"});
let v = handle_headless("confirm", &args);
assert_eq!(v["needs_human"], true);
assert_eq!(v["action"], "confirm");
assert_eq!(v["options"], json!([]));
}
}
fn parse_options(args: &Value) -> Result<Vec<String>> {
let raw = args
.get("options")
+16 -1
View File
@@ -24,7 +24,7 @@ use crate::client::{
use crate::config::instructions::WORKSPACE_INSTRUCTIONS_FILE_NAME;
use crate::config::{
Agent, AppConfig, AppState, CODE_ROLE, Config, EXPLAIN_SHELL_ROLE, Input, MemoryScope,
RequestContext, SHELL_ROLE, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists,
RenderMode, RequestContext, SHELL_ROLE, TEMP_SESSION_NAME, WorkingMode, ensure_parent_exists,
install_builtins, list_agents, load_env_file, macro_execute, sync_models,
};
use crate::config::{memory, paths};
@@ -49,6 +49,7 @@ use log4rs::config::{Appender, Logger, Root};
use log4rs::encode::pattern::PatternEncoder;
use oauth::OAuthProvider;
use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::{env, fs, process, sync::Arc};
#[tokio::main]
@@ -81,6 +82,16 @@ async fn main() -> Result<()> {
WorkingMode::Cmd
};
if cli.headless {
if text.is_none() && cli.file.is_empty() {
bail!("--headless requires a prompt argument; REPL mode is not supported");
}
unsafe {
env::set_var("AUTO_CONFIRM", "true");
}
HEADLESS.store(true, Ordering::SeqCst);
}
let info_flag = cli.info
|| cli.sync_models
|| cli.list_models
@@ -218,6 +229,10 @@ async fn main() -> Result<()> {
}
}
if cli.headless {
ctx.render_mode = RenderMode::Silent;
}
if let Err(err) = run(ctx, cli, text, abort_signal).await {
render_error(err);
process::exit(1);
+2
View File
@@ -33,6 +33,7 @@ use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
use is_terminal::IsTerminal;
use nu_ansi_term::Color;
use std::borrow::Cow;
use std::sync::atomic::AtomicBool;
use std::sync::{LazyLock, OnceLock};
use std::{cmp, env, path::PathBuf, process};
use syntect::highlighting::{Highlighter, Theme};
@@ -43,6 +44,7 @@ pub static CODE_BLOCK_RE: LazyLock<Regex> =
pub static THINK_TAG_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?s)^\s*<think>.*?</think>(\s*|$)").unwrap());
pub static IS_STDOUT_TERMINAL: LazyLock<bool> = LazyLock::new(|| std::io::stdout().is_terminal());
pub static HEADLESS: AtomicBool = AtomicBool::new(false);
pub static NO_COLOR: LazyLock<bool> = LazyLock::new(|| {
env::var("NO_COLOR")
.ok()