use std::path::PathBuf;
use std::time::{Duration, Instant};
use actix_web::web;
use bamboo_agent_core::storage::Storage as _;
use bamboo_agent_core::{Message, Role, Session, SessionKind};
use bamboo_server::app_state::AppState;
use bamboo_server::handlers::agent::execute::{handler as execute_handler, ExecuteRequest};
use bamboo_server::handlers::agent::respond::{submit_response, RespondRequest};
use bamboo_tools::permission::PermissionMode;
fn parse_permission_mode(s: &str) -> Result<PermissionMode, String> {
match s.trim().to_ascii_lowercase().replace('_', "-").as_str() {
"default" | "ask" => Ok(PermissionMode::Default),
"plan" => Ok(PermissionMode::Plan),
"accept-edits" | "edits" => Ok(PermissionMode::AcceptEdits),
"dont-ask" | "deny" => Ok(PermissionMode::DontAsk),
"bypass" | "bypass-permissions" | "yolo" => Ok(PermissionMode::BypassPermissions),
other => Err(format!(
"unknown --permission-mode '{other}' (expected: default | plan | accept-edits | dont-ask | bypass)"
)),
}
}
fn pick_option(options: &[String], needle: &str) -> String {
options
.iter()
.find(|opt| opt.eq_ignore_ascii_case(needle) || opt.to_ascii_lowercase().contains(needle))
.cloned()
.unwrap_or_else(|| needle.to_string())
}
enum HeadlessPromptAction {
Prompt,
Resolve { response: String, message: String },
Abort { message: String },
}
fn resolve_headless_permission(
is_terminal: bool,
pending: &bamboo_agent_core::PendingQuestion,
) -> HeadlessPromptAction {
if is_terminal {
return HeadlessPromptAction::Prompt;
}
let deny = pick_option(&pending.options, "deny");
let deny_is_submittable =
pending.allow_custom || pending.options.iter().any(|opt| opt == &deny);
if !deny_is_submittable {
return HeadlessPromptAction::Abort {
message: format!(
"cannot auto-resolve this permission gate in headless mode \
(no deny option among {:?} and custom answers are disabled): \"{}\". \
Pass --permission-mode bypass|accept-edits|dont-ask to run non-interactively.",
pending.options,
pending.question.trim()
),
};
}
HeadlessPromptAction::Resolve {
response: deny,
message: format!(
"no interactive approver (stdin is not a TTY): auto-denying \"{}\". \
Pass --permission-mode bypass|accept-edits|dont-ask to run non-interactively.",
pending.question.trim()
),
}
}
async fn prompt_pending_response(pending: &bamboo_agent_core::PendingQuestion) -> Option<String> {
use std::io::IsTerminal as _;
use std::io::Write as _;
use tokio::io::{AsyncBufReadExt, BufReader};
match resolve_headless_permission(std::io::stdin().is_terminal(), pending) {
HeadlessPromptAction::Resolve { response, message } => {
eprintln!("\n⏸ {message}");
return Some(response);
}
HeadlessPromptAction::Abort { message } => {
eprintln!("\n⏸ {message}");
return None;
}
HeadlessPromptAction::Prompt => {}
}
eprintln!("\n⏸ {}", pending.question.trim());
if !pending.options.is_empty() {
let opts: Vec<String> = pending
.options
.iter()
.enumerate()
.map(|(i, o)| format!("[{}] {}", i + 1, o))
.collect();
eprintln!(" options: {}", opts.join(" "));
}
eprint!(" approve? [y/N] (y=approve · n=deny · or a choice): ");
let _ = std::io::stderr().flush();
let mut line = String::new();
match BufReader::new(tokio::io::stdin())
.read_line(&mut line)
.await
{
Ok(0) | Err(_) => None,
Ok(_) => {
let trimmed = line.trim();
let answer = match trimmed.to_ascii_lowercase().as_str() {
"y" | "yes" => pick_option(&pending.options, "approve"),
"n" | "no" | "" => pick_option(&pending.options, "deny"),
_ => trimmed
.parse::<usize>()
.ok()
.and_then(|n| pending.options.get(n.wrapping_sub(1)).cloned())
.unwrap_or_else(|| trimmed.to_string()),
};
Some(answer)
}
}
}
pub struct HeadlessArgs {
pub prompt: String,
pub session: Option<String>,
pub model: Option<String>,
pub workspace: Option<PathBuf>,
pub data_dir: PathBuf,
pub permission_mode: Option<String>,
pub stream_json: bool,
pub reasoning_effort: Option<String>,
pub skill_mode: Option<String>,
pub provider: Option<String>,
}
pub async fn run(args: HeadlessArgs) -> Result<(), String> {
let state = AppState::new(args.data_dir.clone())
.await
.map_err(|e| format!("boot app state: {e}"))?;
if let Some(raw) = args.permission_mode.as_deref() {
let mode = parse_permission_mode(raw)?;
state.permission_checker.set_permission_mode(mode);
eprintln!("• permission mode: {raw}");
}
let session_id = match &args.session {
Some(id) => {
let existing = state
.storage
.load_session(id)
.await
.map_err(|e| format!("load session {id}: {e}"))?
.ok_or_else(|| format!("session '{id}' not found"))?;
if existing.kind != SessionKind::Root {
return Err(format!("session '{id}' is not a root session"));
}
id.clone()
}
None => {
let mut title: String = args.prompt.chars().take(48).collect();
if title.len() < args.prompt.len() {
title.push('…');
}
let mut session = Session::new(uuid::Uuid::new_v4().to_string(), String::new());
session.title = title;
session.workspace = args
.workspace
.clone()
.or_else(|| std::env::current_dir().ok())
.map(|w| w.to_string_lossy().into_owned());
let id = session.id.clone();
state
.storage
.save_session(&session)
.await
.map_err(|e| format!("save session: {e}"))?;
state
.session_store
.save_session(&session)
.await
.map_err(|e| format!("index session: {e}"))?;
id
}
};
let default_provider = state.config.read().await.provider.clone();
let model_selection = resolve_model_selection(&args.model, &args.provider, &default_provider)?;
let session_model_ref: Option<bamboo_domain::ProviderModelRef> = match (
model_selection.model_ref.clone(),
model_selection.provider.as_deref(),
) {
(Some(model_ref), _) => Some(model_ref),
(None, Some(provider)) => {
let config = state.config.read().await;
bamboo_engine::model_config_helper::get_default_model_for_provider(&config, provider)
.ok()
.map(|model| bamboo_domain::ProviderModelRef::new(provider, model))
}
(None, None) => None,
};
{
let mut session = state
.storage
.load_session(&session_id)
.await
.map_err(|e| format!("load session: {e}"))?
.ok_or_else(|| "session vanished".to_string())?;
session.add_message(Message::user(args.prompt.clone()));
if let Some(model_ref) = &session_model_ref {
bamboo_engine::session_app::provider_model::persist_model_ref(&mut session, model_ref);
}
state
.storage
.save_session(&session)
.await
.map_err(|e| format!("save session: {e}"))?;
let _ = state.session_store.save_session(&session).await;
}
let sender = state.get_session_event_sender(&session_id).await;
let mut events = sender.subscribe();
let reasoning_effort = match args.reasoning_effort.as_deref() {
Some(raw) => Some(
bamboo_domain::reasoning::ReasoningEffort::parse(raw).ok_or_else(|| {
format!(
"invalid --reasoning-effort '{raw}' (expected: low | medium | high | xhigh | max)"
)
})?,
),
None => None,
};
if let Some(mode) = args.skill_mode.as_deref() {
let ok = !mode.is_empty() && mode.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
if !ok {
return Err(format!(
"invalid --skill-mode '{mode}' (expected non-empty [A-Za-z0-9-], e.g. `code` or `ask`)"
));
}
}
if args.stream_json {
println!(
"{}",
serde_json::json!({
"type": "session_started",
"session_id": session_id,
"resumed": args.session.is_some(),
})
);
} else {
eprintln!("▶ session {session_id}");
}
let data = web::Data::new(state);
let response = execute_handler(
data.clone(),
web::Path::from(session_id.clone()),
web::Json(ExecuteRequest {
model: model_selection.model.clone(),
provider: model_selection.provider.clone(),
model_ref: model_selection.model_ref.clone(),
skill_mode: args.skill_mode.clone(),
reasoning_effort,
client_sync: None,
no_human_approver: true,
}),
)
.await;
if !response.status().is_success() {
return Err(format!("execute rejected: HTTP {}", response.status()));
}
let mut exit: Result<(), String> = Ok(());
let mut streamed_tokens = false;
'run: loop {
let mut saw_terminal = false;
let mut last_event = Instant::now();
let mut poll = tokio::time::interval(Duration::from_millis(400));
let started = Instant::now();
loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => {
eprintln!("\n⏹ cancelling…");
if let Some(runner) = data.agent_runners.read().await.get(&session_id) {
runner.cancel_token.cancel();
}
}
ev = events.recv() => {
match ev {
Ok(event) => {
last_event = Instant::now();
if let Ok(value) = serde_json::to_value(&event) {
if args.stream_json {
println!("{value}");
} else {
print_server_event(&value, &mut streamed_tokens);
}
match value["type"].as_str().unwrap_or("") {
"complete" | "cancelled" | "need_clarification" => {
saw_terminal = true
}
"error" => {
saw_terminal = true;
exit = Err(value["message"]
.as_str()
.unwrap_or("agent errored")
.to_string());
}
_ => {}
}
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
_ = poll.tick() => {
if started.elapsed() < Duration::from_secs(2) {
continue;
}
if saw_terminal
&& last_event.elapsed() > Duration::from_millis(1200)
&& tree_quiescent(&data, &session_id).await
{
break;
}
}
}
}
if exit.is_err() || args.stream_json {
break 'run;
}
let pending = data
.storage
.load_session(&session_id)
.await
.ok()
.flatten()
.and_then(|session| session.pending_question);
let Some(pending) = pending else { break 'run };
let Some(response) = prompt_pending_response(&pending).await else {
eprintln!("• leaving the question unanswered (headless, fail-closed)");
break 'run;
};
events = data.get_session_event_sender(&session_id).await.subscribe();
let resp = submit_response(
data.clone(),
web::Path::from(session_id.clone()),
web::Json(RespondRequest {
response,
model: model_selection.model.clone(),
provider: model_selection.provider.clone(),
model_ref: model_selection.model_ref.clone(),
reasoning_effort,
}),
)
.await;
match resp {
Ok(http) if http.status().is_success() => {}
Ok(http) => {
exit = Err(format!("respond rejected: HTTP {}", http.status()));
break 'run;
}
Err(error) => {
exit = Err(format!("respond failed: {error}"));
break 'run;
}
}
}
let final_reply = data
.storage
.load_session(&session_id)
.await
.ok()
.flatten()
.and_then(|session| {
session
.messages
.iter()
.rev()
.find(|m| matches!(m.role, Role::Assistant))
.map(|m| m.content.clone())
});
if args.stream_json {
println!(
"{}",
serde_json::json!({
"type": "result",
"session_id": session_id,
"status": if exit.is_ok() { "finished" } else { "error" },
"result": final_reply,
"error": exit.as_ref().err(),
})
);
} else {
println!();
if !streamed_tokens {
if let Some(reply) = &final_reply {
println!("{reply}");
}
}
match &exit {
Ok(()) => eprintln!("✔ finished"),
Err(e) => eprintln!("✘ {e}"),
}
eprintln!("session: {session_id}");
eprintln!("continue with: bamboo -p \"<next message>\" -s {session_id}");
}
exit
}
async fn tree_quiescent(state: &AppState, session_id: &str) -> bool {
use bamboo_server::app_state::AgentStatus;
{
let runners = state.agent_runners.read().await;
let busy = runners
.values()
.any(|r| matches!(r.status, AgentStatus::Running | AgentStatus::Pending));
if busy {
return false;
}
}
match state.storage.load_session(session_id).await {
Ok(Some(session)) => session
.agent_runtime_state
.as_ref()
.map(|s| s.waiting_for_children.is_none())
.unwrap_or(true),
_ => true,
}
}
#[derive(Default, Clone, PartialEq, Eq, Debug)]
struct ModelSelection {
provider: Option<String>,
model: Option<String>,
model_ref: Option<bamboo_domain::ProviderModelRef>,
}
fn resolve_model_selection(
model: &Option<String>,
provider: &Option<String>,
default_provider: &str,
) -> Result<ModelSelection, String> {
let cli_provider = provider.as_deref().map(str::trim).filter(|p| !p.is_empty());
let model = model.as_deref().map(str::trim).filter(|m| !m.is_empty());
let (spec_provider, bare_model) = match model {
Some(spec) => match spec.split_once(':') {
Some((p, m)) => {
let (p, m) = (p.trim(), m.trim());
if p.is_empty() || m.is_empty() {
return Err(format!("-m '{spec}' must be 'provider:model'"));
}
(Some(p.to_string()), Some(m.to_string()))
}
None => (None, Some(spec.to_string())),
},
None => (None, None),
};
let chosen_provider = match (spec_provider, cli_provider) {
(Some(a), Some(b)) if !a.eq_ignore_ascii_case(b) => {
return Err(format!(
"conflicting providers: -m specifies '{a}', --provider is '{b}' (drop one)"
));
}
(Some(a), _) => Some(a),
(None, Some(b)) => Some(b.to_string()),
(None, None) => None,
};
Ok(match (chosen_provider, bare_model) {
(provider, Some(model)) => {
let provider = provider.unwrap_or_else(|| default_provider.trim().to_string());
if provider.is_empty() {
return Err(format!(
"-m '{model}': no provider given and no default provider is configured \
(use `provider:model`, add `--provider`, or run `bamboo init`)"
));
}
ModelSelection {
provider: None,
model: Some(model.clone()),
model_ref: Some(bamboo_domain::ProviderModelRef::new(provider, model)),
}
}
(Some(provider), None) => ModelSelection {
provider: Some(provider),
model: None,
model_ref: None,
},
(None, None) => ModelSelection::default(),
})
}
fn print_server_event(value: &serde_json::Value, streamed_tokens: &mut bool) {
use std::io::Write;
match value["type"].as_str().unwrap_or("") {
"token" => {
*streamed_tokens = true;
print!("{}", value["content"].as_str().unwrap_or(""));
let _ = std::io::stdout().flush();
}
"tool_start" => eprintln!("\n⚙ {}", value["tool_name"].as_str().unwrap_or("tool")),
"tool_complete" => eprintln!("✔ tool done"),
"tool_error" => eprintln!("✘ tool error: {}", value["error"].as_str().unwrap_or("")),
"sub_agent_started" => {
eprintln!(
"\n┌ actor {} started",
value["child_session_id"].as_str().unwrap_or("?")
);
}
"sub_agent_completed" => {
eprintln!(
"└ actor {} {}",
value["child_session_id"].as_str().unwrap_or("?"),
value["status"].as_str().unwrap_or("done")
);
}
"sub_agent_event" => {
let inner = &value["event"];
match inner["type"].as_str().unwrap_or("") {
"token" => {
print!("{}", inner["content"].as_str().unwrap_or(""));
let _ = std::io::stdout().flush();
}
"tool_start" => {
eprintln!("\n│ ⚙ {}", inner["tool_name"].as_str().unwrap_or("tool"))
}
"tool_complete" => eprintln!("│ ✔ tool done"),
_ => {}
}
}
"error" => eprintln!("\n✘ {}", value["message"].as_str().unwrap_or("")),
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use bamboo_agent_core::PendingQuestion;
fn permission_gate() -> PendingQuestion {
PendingQuestion {
tool_call_id: "call-1".to_string(),
tool_name: "Bash".to_string(),
question: "The Bash tool needs approval to run `cargo check`".to_string(),
options: vec!["Approve".to_string(), "Deny".to_string()],
allow_custom: false,
source: Default::default(),
}
}
#[test]
fn non_tty_fails_closed_to_deny() {
let pending = permission_gate();
match resolve_headless_permission(false, &pending) {
HeadlessPromptAction::Resolve { response, message } => {
assert_eq!(response, "Deny");
assert_ne!(response, "Approve");
assert!(message.contains("--permission-mode"));
assert!(message.to_ascii_lowercase().contains("deny"));
}
HeadlessPromptAction::Abort { .. } => {
panic!("a gate with a real Deny option must resolve to it, not abort");
}
HeadlessPromptAction::Prompt => {
panic!("non-TTY must resolve deterministically, not prompt on stdin");
}
}
}
#[test]
fn tty_prompts_interactively() {
let pending = permission_gate();
assert!(matches!(
resolve_headless_permission(true, &pending),
HeadlessPromptAction::Prompt
));
}
#[test]
fn non_tty_deny_handles_varied_options() {
let mut pending = permission_gate();
pending.options = vec!["Allow it".to_string(), "Deny this".to_string()];
match resolve_headless_permission(false, &pending) {
HeadlessPromptAction::Resolve { response, .. } => {
assert_eq!(response, "Deny this");
}
HeadlessPromptAction::Abort { .. } => {
panic!("a gate with a deny-containing option must resolve to it, not abort")
}
HeadlessPromptAction::Prompt => panic!("expected deterministic resolve"),
}
}
#[test]
fn non_tty_approve_first_never_auto_approves() {
let mut pending = permission_gate();
pending.options = vec![
"Approve (proceed with the plan)".to_string(),
"Stay in plan mode".to_string(),
];
match resolve_headless_permission(false, &pending) {
HeadlessPromptAction::Resolve { response, .. } => assert!(
!response.to_ascii_lowercase().contains("approve"),
"fail-closed must never auto-approve; got {response:?}"
),
HeadlessPromptAction::Abort { .. } => {}
HeadlessPromptAction::Prompt => {
panic!("non-TTY must resolve deterministically, not prompt on stdin")
}
}
}
#[test]
fn non_tty_approve_first_without_deny_label_aborts_gracefully() {
let mut pending = permission_gate();
pending.question = "Ready to code? Here is the plan…".to_string();
pending.options = vec![
"Approve (proceed with the plan)".to_string(),
"Keep planning".to_string(),
"Stay in plan mode".to_string(),
];
pending.allow_custom = false;
match resolve_headless_permission(false, &pending) {
HeadlessPromptAction::Abort { message } => {
assert!(message.to_ascii_lowercase().contains("cannot auto-resolve"));
assert!(message.contains("--permission-mode"));
}
HeadlessPromptAction::Resolve { response, .. } => {
assert!(
!response.to_ascii_lowercase().contains("approve"),
"must never auto-approve; got {response:?}"
);
panic!(
"approve-first gate with no deny option must Abort gracefully, \
not route the invalid answer {response:?} through submit_response"
);
}
HeadlessPromptAction::Prompt => panic!("non-TTY must resolve deterministically"),
}
}
#[test]
fn non_tty_no_deny_label_but_custom_allowed_resolves() {
let mut pending = permission_gate();
pending.options = vec!["Approve".to_string(), "Stay in plan mode".to_string()];
pending.allow_custom = true;
match resolve_headless_permission(false, &pending) {
HeadlessPromptAction::Resolve { response, .. } => {
assert_eq!(response, "deny");
}
HeadlessPromptAction::Abort { .. } => {
panic!("custom-answer gates can submit the literal deny; must not abort")
}
HeadlessPromptAction::Prompt => panic!("non-TTY must resolve deterministically"),
}
}
fn some(s: &str) -> Option<String> {
Some(s.to_string())
}
fn model_ref(p: &str, m: &str) -> Option<bamboo_domain::ProviderModelRef> {
Some(bamboo_domain::ProviderModelRef::new(p, m))
}
#[test]
fn model_selection_colon_form() {
let sel = resolve_model_selection(&some("openai:gpt-4o"), &None, "anthropic").unwrap();
assert_eq!(sel.model_ref, model_ref("openai", "gpt-4o"));
assert_eq!(sel.model, some("gpt-4o"));
assert_eq!(sel.provider, None);
}
#[test]
fn model_selection_bare_model_uses_default_provider() {
let sel = resolve_model_selection(&some("gpt-4o"), &None, "openai").unwrap();
assert_eq!(sel.model_ref, model_ref("openai", "gpt-4o"));
assert_eq!(sel.model, some("gpt-4o"));
assert_eq!(sel.provider, None);
}
#[test]
fn model_selection_provider_flag_plus_bare_model() {
let sel = resolve_model_selection(&some("gpt-4o"), &some("openai"), "anthropic").unwrap();
assert_eq!(sel.model_ref, model_ref("openai", "gpt-4o"));
}
#[test]
fn model_selection_provider_only() {
let sel = resolve_model_selection(&None, &some("gemini"), "anthropic").unwrap();
assert_eq!(sel.model_ref, None);
assert_eq!(sel.provider, some("gemini"));
}
#[test]
fn model_selection_empty_is_default() {
let sel = resolve_model_selection(&None, &None, "anthropic").unwrap();
assert_eq!(sel, ModelSelection::default());
}
#[test]
fn model_selection_conflicting_providers_error() {
let err = resolve_model_selection(&some("openai:gpt-4o"), &some("anthropic"), "anthropic")
.unwrap_err();
assert!(err.contains("conflicting providers"), "got: {err}");
}
#[test]
fn model_selection_matching_provider_ok() {
let sel = resolve_model_selection(&some("OpenAI:gpt-4o"), &some("openai"), "x").unwrap();
assert_eq!(sel.model_ref, model_ref("OpenAI", "gpt-4o"));
}
#[test]
fn model_selection_malformed_colon_error() {
assert!(resolve_model_selection(&some("openai:"), &None, "x").is_err());
assert!(resolve_model_selection(&some(":gpt-4o"), &None, "x").is_err());
}
#[test]
fn model_selection_bare_model_no_default_errors() {
let err = resolve_model_selection(&some("gpt-4o"), &None, " ").unwrap_err();
assert!(err.contains("no provider"), "got: {err}");
}
}