use std::collections::HashSet;
use std::io::{BufRead, BufReader, Read, Write};
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::claude_ask::{emit_event, validate_inputs};
use crate::paths::AgentsHome;
use crate::state::{load_registry, update_registry, RegistryEntry};
use crate::AgentStatus;
const LOCK_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_FOLLOWUP_TIMEOUT: Duration = Duration::from_secs(600);
const DEFAULT_STDERR_CAP: usize = 256 * 1024;
const KEY_SESSION: &str = "session_id";
const KEY_RESPONSE: &str = "response";
const KEY_STATS: &str = "stats";
fn raw_head(s: &str) -> String {
s.chars().take(200).collect()
}
pub fn inject_from_name(prompt: &str, from_name: &str) -> String {
format!("[from: {}]\n\n{}", from_name, prompt)
}
fn gemini_on_path(name: &str) -> bool {
std::env::var_os("PATH")
.is_some_and(|paths| std::env::split_paths(&paths).any(|p| p.join(name).is_file()))
}
fn gemini_sandbox_available() -> bool {
if cfg!(target_os = "macos") && gemini_on_path("sandbox-exec") {
return true;
}
if let Ok(sel) = std::env::var("GEMINI_SANDBOX") {
let sel = sel.trim().to_ascii_lowercase();
if (sel == "docker" || sel == "podman") && gemini_on_path(&sel) {
return std::process::Command::new(&sel)
.arg("info")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
}
}
false
}
pub fn sandbox_flag(yolo: bool, sandbox_available: Option<bool>) -> Vec<String> {
if yolo {
return vec!["--yolo".to_string()];
}
let avail = sandbox_available.unwrap_or_else(gemini_sandbox_available);
if avail {
vec![
"--approval-mode".to_string(),
"yolo".to_string(),
"--sandbox".to_string(),
]
} else {
eprintln!(
"warning: no gemini sandbox provider (sandbox-exec / docker); launching --approval-mode yolo UNSANDBOXED (still never-prompt, no hang)"
);
vec!["--approval-mode".to_string(), "yolo".to_string()]
}
}
pub fn build_argv_create(
full_prompt: &str,
yolo: bool,
session_id: Option<&str>,
model: Option<&str>,
) -> Vec<String> {
let mut argv = vec![
"gemini".to_string(),
"--skip-trust".to_string(),
"-p".to_string(),
full_prompt.to_string(),
"--output-format".to_string(),
"json".to_string(),
];
if let Some(m) = model.filter(|m| !m.is_empty()) {
argv.push("--model".to_string());
argv.push(m.to_string());
}
argv.extend(sandbox_flag(yolo, None));
if let Some(sid) = session_id {
if !sid.is_empty() {
argv.push("--session-id".to_string());
argv.push(sid.to_string());
}
}
argv
}
pub fn build_argv_resume(session_id: &str, full_prompt: &str, yolo: bool) -> Vec<String> {
let mut argv = vec![
"gemini".to_string(),
"--skip-trust".to_string(),
"-p".to_string(),
full_prompt.to_string(),
"--output-format".to_string(),
"json".to_string(),
"--resume".to_string(),
session_id.to_string(),
];
argv.extend(sandbox_flag(yolo, None));
argv
}
pub fn parse_response(stdout_text: &str) -> Result<(Option<String>, String), GeminiAskError> {
let head = raw_head(stdout_text);
if stdout_text.trim().is_empty() {
return Err(GeminiAskError::Parse { raw_head: head });
}
let parsed: serde_json::Value = match serde_json::from_str(stdout_text) {
Ok(v) => v,
Err(_) => return Err(GeminiAskError::Parse { raw_head: head }),
};
let obj = match parsed.as_object() {
Some(o) => o,
None => return Err(GeminiAskError::Parse { raw_head: head }),
};
let session_id = match obj.get(KEY_SESSION) {
None | Some(serde_json::Value::Null) => None,
Some(serde_json::Value::String(s)) => Some(s.clone()),
Some(_) => return Err(GeminiAskError::Parse { raw_head: head }),
};
if !obj.contains_key(KEY_RESPONSE) {
return Err(GeminiAskError::Parse { raw_head: head });
}
if !obj.contains_key(KEY_STATS) {
return Err(GeminiAskError::Parse { raw_head: head });
}
let reply = match obj.get(KEY_RESPONSE) {
Some(serde_json::Value::Null) => String::new(),
Some(serde_json::Value::String(s)) => s.clone(),
_ => return Err(GeminiAskError::Parse { raw_head: head }),
};
Ok((session_id, reply))
}
#[derive(Debug)]
pub enum GeminiAskError {
NotFound,
Parse { raw_head: String },
TeeOpen { message: String },
Timeout { timeout_sec: f64 },
Invocation { exit_code: i32 },
OsError { message: String },
Interrupted,
}
impl GeminiAskError {
pub fn exit_code(&self) -> i32 {
match self {
GeminiAskError::NotFound => 14,
GeminiAskError::Parse { .. } => 11,
GeminiAskError::TeeOpen { .. } => 12,
GeminiAskError::Timeout { .. } => 15,
GeminiAskError::Invocation { exit_code } => {
if *exit_code != 0 {
*exit_code
} else {
1
}
}
GeminiAskError::OsError { .. } => 1,
GeminiAskError::Interrupted => 130,
}
}
}
impl std::fmt::Display for GeminiAskError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
GeminiAskError::NotFound => write!(f, "gemini binary not found on PATH"),
GeminiAskError::Parse { raw_head } => write!(
f,
"gemini output did not parse as JSON; first {} chars: {:?}",
raw_head.chars().count(),
raw_head
),
GeminiAskError::TeeOpen { message } => {
write!(f, "gemini provider: cannot open output tee: {}", message)
}
GeminiAskError::Timeout { timeout_sec } => {
write!(f, "gemini timed out after {}s", timeout_sec)
}
GeminiAskError::Invocation { exit_code } => {
write!(f, "gemini exited {}", exit_code)
}
GeminiAskError::OsError { message } => {
write!(f, "gemini provider: OSError invoking gemini: {}", message)
}
GeminiAskError::Interrupted => {
write!(f, "gemini interrupted by SIGINT (Ctrl-C)")
}
}
}
}
impl std::error::Error for GeminiAskError {}
#[derive(Debug, Clone)]
pub struct GeminiResult {
pub exit_code: i32,
pub session_id: Option<String>,
pub last_msg: String,
pub duration_ms: u64,
}
fn open_tee(log_path: &Path) -> Result<std::fs::File, GeminiAskError> {
crate::subprocess_ask::open_tee(log_path).map_err(|e| GeminiAskError::TeeOpen {
message: e.to_string(),
})
}
fn run_gemini(
argv: &[String],
output_path: &Path,
timeout: Option<Duration>,
expect_session: bool,
popen_cwd: Option<&Path>,
agent_self: Option<&str>,
) -> Result<GeminiResult, GeminiAskError> {
use std::process::{Command, Stdio};
let started = Instant::now();
let tee_fh = open_tee(output_path)?;
let argv =
crate::spawn_gate::qos_wrap(popen_cwd.unwrap_or_else(|| Path::new(".")), argv.to_vec());
let mut cmd = Command::new(&argv[0]);
cmd.args(&argv[1..]);
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped()); if let Some(cwd) = popen_cwd {
cmd.current_dir(cwd);
}
if let Some(name) = agent_self {
cmd.env("FNO_AGENT_SELF", name);
cmd.env("FNO_AGENT_PROVIDER", "gemini");
}
unsafe {
cmd.pre_exec(|| {
libc::setpgid(0, 0);
Ok(())
});
}
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(GeminiAskError::NotFound);
}
Err(e) => {
eprintln!("gemini provider: OSError invoking gemini: {}", e);
return Err(GeminiAskError::OsError {
message: e.to_string(),
});
}
};
let pid = child.id();
let _sigint_guard = crate::subprocess_ask::SigintForwarder::install(pid);
let stdout_pipe = child.stdout.take().expect("stdout piped");
let stderr_pipe = child.stderr.take().expect("stderr piped");
let tee = Arc::new(Mutex::new(tee_fh));
let tee_stderr = tee.clone();
let stderr_handle = std::thread::spawn(move || {
let mut warned: HashSet<String> = HashSet::new();
let mut total: usize = 0;
let mut cap_hit = false;
let mut reader = BufReader::new(stderr_pipe);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line) {
Ok(0) => break, Ok(_) if cap_hit => {
continue;
}
Ok(n) => {
total += n;
if let Ok(mut guard) = tee_stderr.lock() {
if let Err(e) = guard.write_all(line.as_bytes()) {
let key = e.to_string();
if warned.insert(key) {
eprintln!("gemini provider: stderr tee write failed: {}", e);
}
} else {
let _ = guard.flush();
}
}
if total > DEFAULT_STDERR_CAP {
let marker = format!("\n[truncated at {} bytes]\n", DEFAULT_STDERR_CAP);
if let Ok(mut guard) = tee_stderr.lock() {
if let Err(e) = guard.write_all(marker.as_bytes()) {
let key = e.to_string();
if warned.insert(key) {
eprintln!("gemini provider: stderr tee write failed: {}", e);
}
} else {
let _ = guard.flush();
}
}
cap_hit = true;
}
}
Err(_) => break,
}
}
});
let mut watchdog = crate::subprocess_ask::AskWatchdog::spawn(pid, timeout);
let mut stdout_bytes: Vec<u8> = Vec::new();
{
let mut reader = stdout_pipe;
if let Err(e) = reader.read_to_end(&mut stdout_bytes) {
eprintln!("gemini provider: stdout stream read error: {}", e);
}
}
let stdout_text = String::from_utf8_lossy(&stdout_bytes).into_owned();
if !stdout_text.is_empty() {
if let Ok(mut guard) = tee.lock() {
if let Err(e) = guard.write_all(stdout_text.as_bytes()) {
eprintln!("gemini provider: tee write of stdout failed: {}", e);
} else {
if !stdout_text.ends_with('\n') {
let _ = guard.write_all(b"\n");
}
let _ = guard.flush();
}
}
}
watchdog.cancel();
let (exit_code, sigkill_escalated) =
crate::subprocess_ask::wait_with_grace(pid, &mut child, 5.0);
watchdog.join();
if stderr_handle.join().is_err() {
eprintln!("gemini provider: stderr drain thread panicked");
}
let duration_ms = started.elapsed().as_millis() as u64;
let was_timed_out = watchdog.timed_out();
if crate::subprocess_ask::ask_interrupted() {
return Err(GeminiAskError::Interrupted);
}
if was_timed_out {
return Err(GeminiAskError::Timeout {
timeout_sec: timeout.map(|d| d.as_secs_f64()).unwrap_or(0.0),
});
}
if sigkill_escalated {
return Err(GeminiAskError::Invocation { exit_code });
}
let (session_id, reply) = match parse_response(&stdout_text) {
Ok(t) => t,
Err(parse_err) => {
if exit_code != 0 {
return Err(GeminiAskError::Invocation { exit_code });
}
return Err(parse_err);
}
};
if exit_code != 0 {
return Err(GeminiAskError::Invocation { exit_code });
}
if expect_session && session_id.as_deref().map_or(true, |s| s.is_empty()) {
return Err(GeminiAskError::Parse {
raw_head: raw_head(&stdout_text),
});
}
Ok(GeminiResult {
exit_code,
session_id,
last_msg: reply,
duration_ms,
})
}
#[allow(clippy::too_many_arguments)]
pub fn gemini_create(
cwd: &Path,
prompt: &str,
from_name: &str,
yolo: bool,
output_path: &Path,
timeout: Option<Duration>,
agent_self: Option<&str>,
model: Option<&str>,
) -> Result<GeminiResult, GeminiAskError> {
let full_prompt = inject_from_name(prompt, from_name);
let eff = crate::agents_config::effective_yolo(
yolo,
crate::agents_config::headless_yolo_enabled("gemini", cwd),
);
let argv = build_argv_create(&full_prompt, eff, None, model);
run_gemini(&argv, output_path, timeout, true, Some(cwd), agent_self)
}
pub fn gemini_resume(
session_id: &str,
cwd: &Path,
prompt: &str,
from_name: &str,
yolo: bool,
output_path: &Path,
timeout: Option<Duration>,
) -> Result<GeminiResult, GeminiAskError> {
let full_prompt = inject_from_name(prompt, from_name);
let eff = crate::agents_config::effective_yolo(
yolo,
crate::agents_config::headless_yolo_enabled("gemini", cwd),
);
let argv = build_argv_resume(session_id, &full_prompt, eff);
run_gemini(&argv, output_path, timeout, false, Some(cwd), None)
}
fn derive_log_path(home: &AgentsHome, name: &str) -> PathBuf {
home.root()
.join("agents")
.join("logs")
.join(format!("{}.jsonl", name))
}
fn now_iso() -> String {
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
struct AgentLock {
_file: std::fs::File,
}
impl AgentLock {
fn acquire(home: &AgentsHome, name: &str, timeout: Duration) -> Result<Self, ()> {
let locks_dir = home.root().join("locks");
let _ = std::fs::create_dir_all(&locks_dir);
let path = locks_dir.join(format!("{}.lock", name));
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&path)
.map_err(|_| ())?;
let deadline = Instant::now() + timeout;
loop {
match file.try_lock() {
Ok(()) => return Ok(Self { _file: file }),
Err(_) => {
if Instant::now() >= deadline {
return Err(());
}
std::thread::sleep(Duration::from_millis(25));
}
}
}
}
}
impl Drop for AgentLock {
fn drop(&mut self) {
let _ = self._file.unlock();
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AskOutcome {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
impl AskOutcome {
fn ok_reply(reply: String) -> Self {
Self {
stdout: reply,
stderr: String::new(),
exit_code: 0,
}
}
fn err(msg: impl Into<String>, code: i32) -> Self {
Self {
stdout: String::new(),
stderr: format!("{}\n", msg.into()),
exit_code: code,
}
}
}
pub fn dispatch_gemini_ask(
home: &AgentsHome,
name: &str,
message: &str,
from_name: &str,
_cwd: &Path,
yolo: bool,
timeout: Option<Duration>,
) -> AskOutcome {
if let Err(msg) = validate_inputs(name, message, from_name) {
return AskOutcome::err(msg, 2);
}
let events = home.events_jsonl();
let registry_path = home.registry_json();
let _lock = match AgentLock::acquire(home, name, LOCK_ACQUIRE_TIMEOUT) {
Ok(l) => l,
Err(()) => {
emit_event(
&events,
"agent_ask_failed",
&[
("stage", "lock-timeout".into()),
("name", name.into()),
("provider", "gemini".into()),
],
);
return AskOutcome::err(
format!(
"lock timeout for agent {:?} after {:.1}s",
name,
LOCK_ACQUIRE_TIMEOUT.as_secs_f64()
),
11,
);
}
};
let registry = match load_registry(®istry_path) {
Ok(r) => r,
Err(e) => {
emit_event(
&events,
"agent_ask_failed",
&[
("stage", "registry-read".into()),
("name", name.into()),
("provider", "gemini".into()),
("error", e.to_string().into()),
],
);
return AskOutcome::err(format!("registry read failed: {}", e), 12);
}
};
let existing = registry.find(name).cloned();
match existing {
None => {
emit_event(
&events,
"agent_ask_failed",
&[
("stage", "unknown-name".into()),
("name", name.into()),
("provider", "gemini".into()),
],
);
AskOutcome::err(
format!(
"unknown agent {}; spawn it first: fno agents spawn {} --harness <harness>",
crate::claude_ask::py_repr(name),
name
),
16,
)
}
Some(entry) => dispatch_resume(
&events,
®istry_path,
name,
&entry,
message,
from_name,
yolo,
timeout,
),
}
}
#[allow(clippy::too_many_arguments)]
pub fn dispatch_gemini_once(
home: &AgentsHome,
name: &str,
message: &str,
from_name: &str,
cwd: &Path,
yolo: bool,
timeout: Option<Duration>,
model: Option<&str>,
) -> AskOutcome {
use crate::claude_ask::py_repr;
if let Err(msg) = crate::claude_ask::validate_spawn_inputs(name, from_name) {
return AskOutcome::err(msg, 2);
}
let events = home.events_jsonl();
let registry_path = home.registry_json();
let _lock = match AgentLock::acquire(home, name, LOCK_ACQUIRE_TIMEOUT) {
Ok(l) => l,
Err(()) => {
emit_event(
&events,
"agent_ask_failed",
&[
("stage", "lock-timeout".into()),
("name", name.into()),
("provider", "gemini".into()),
],
);
return AskOutcome::err(
format!(
"lock timeout for agent {} after {:.1}s",
py_repr(name),
LOCK_ACQUIRE_TIMEOUT.as_secs_f64()
),
11,
);
}
};
let registry = match load_registry(®istry_path) {
Ok(r) => r,
Err(e) => {
return AskOutcome::err(format!("registry read failed: {}", e), 12);
}
};
if registry.find(name).is_some() {
return AskOutcome::err(
format!(
"agent {} already exists; use 'fno agents rm {}' first or pick another name",
py_repr(name),
name
),
2,
);
}
let effective_message = if message.is_empty() { "hello" } else { message };
let inner = dispatch_create(
home,
&events,
®istry_path,
name,
effective_message,
from_name,
cwd,
yolo,
timeout,
model,
);
if inner.exit_code != 0 {
return inner;
}
let session_or_short_id = load_registry(®istry_path)
.ok()
.and_then(|r| r.find(name).and_then(|e| e.gemini_session_id.clone()))
.unwrap_or_default();
let teardown_err = update_registry(®istry_path, |reg| {
reg.entries.retain(|e| e.name != name);
true
})
.err();
let teardown_receipt = if let Some(e) = teardown_err {
format!(
"fno agents spawn: warning: teardown failed for {} (gemini/{}): {}. Peer leaked -- clean up via 'fno agents rm {}'\n",
py_repr(name),
session_or_short_id,
e,
name
)
} else {
format!(
"once: {} (gemini/{}) torn down\n",
name, session_or_short_id
)
};
AskOutcome {
stdout: inner.stdout,
stderr: teardown_receipt,
exit_code: 0,
}
}
#[allow(clippy::too_many_arguments)]
fn dispatch_create(
home: &AgentsHome,
events: &Path,
registry_path: &Path,
name: &str,
message: &str,
from_name: &str,
cwd: &Path,
yolo: bool,
timeout: Option<Duration>,
model: Option<&str>,
) -> AskOutcome {
let output_path = derive_log_path(home, name);
let timeout_sec = timeout.unwrap_or(DEFAULT_FOLLOWUP_TIMEOUT);
let result = match gemini_create(
cwd,
message,
from_name,
yolo,
&output_path,
Some(timeout_sec),
Some(name),
model,
) {
Ok(r) => r,
Err(e) => {
let stage = match &e {
GeminiAskError::Timeout { .. } => "gemini-timeout",
GeminiAskError::Parse { .. } => "gemini-parse",
GeminiAskError::Interrupted => "gemini-interrupted",
_ => "gemini-subprocess",
};
let exit_code = e.exit_code();
let msg = format!("{} (see {} for details)", e, output_path.display());
emit_event(
events,
"agent_ask_failed",
&[
("stage", stage.into()),
("name", name.into()),
("provider", "gemini".into()),
("returncode", exit_code.into()),
],
);
return AskOutcome::err(msg, exit_code);
}
};
let session_id = result
.session_id
.filter(|s| !s.is_empty())
.expect("gemini_create guarantees a non-empty session_id on success (expect_session=true)");
let new_entry = RegistryEntry {
name: name.to_string(),
short_id: String::new(),
legacy_provider: String::new(),
harness: Some("gemini".to_string()),
harness_session_id: Some(session_id.clone()),
cwd: cwd.to_string_lossy().to_string(),
project_root: String::new(),
session_id: None,
legacy_claude_short_id: None,
claude_session_uuid: None,
messaging_socket_path: None,
codex_session_id: None,
gemini_session_id: Some(session_id.clone()),
mcp_channel_id: None,
host_mode: None, cc_session_id: None,
status: AgentStatus::Live,
last_message_at: None,
created_at: now_iso(),
pid: None,
pid_start_time: None,
log_path: Some(output_path.to_string_lossy().to_string()),
last_reconciled_at: None,
inside_leg: None,
exited_at: None,
mux: None,
screen_state: None,
crown_level: None,
crown_scope: None,
crown_grantor: None,
};
match update_registry(registry_path, |reg| {
if reg.find(name).is_some() {
false
} else {
reg.entries.push(new_entry.clone());
true
}
}) {
Ok(true) => {}
Ok(false) => {
emit_event(
events,
"agent_ask_failed",
&[
("stage", "name-collision".into()),
("name", name.into()),
("provider", "gemini".into()),
("gemini_session_id", session_id.clone().into()),
],
);
return AskOutcome::err(
format!(
"agent {:?} already exists (registered concurrently); orphaned gemini session: {:?}",
name, session_id
),
12,
);
}
Err(e) => {
emit_event(
events,
"agent_ask_failed",
&[
("stage", "registry-write".into()),
("name", name.into()),
("provider", "gemini".into()),
("gemini_session_id", session_id.clone().into()),
],
);
return AskOutcome::err(
format!(
"registry write failed: {}. orphaned gemini session: gemini sessions \
persist on disk; clean up via 'gemini --delete-session <index>' if \
desired (--list-sessions to find the index)",
e
),
12,
);
}
}
emit_event(
events,
"agent_ask_done",
&[
("stage", "dispatch".into()),
("name", name.into()),
("provider", "gemini".into()),
("gemini_session_id", session_id.clone().into()),
("duration_ms", (result.duration_ms).into()),
("yolo", yolo.into()),
],
);
AskOutcome::ok_reply(result.last_msg)
}
#[allow(clippy::too_many_arguments)]
fn dispatch_resume(
events: &Path,
registry_path: &Path,
name: &str,
entry: &RegistryEntry,
message: &str,
from_name: &str,
yolo: bool,
timeout: Option<Duration>,
) -> AskOutcome {
let session_id = match entry.gemini_session_id.as_deref() {
Some(s) if !s.is_empty() => s.to_string(),
_ => {
return AskOutcome::err(
format!(
"registry entry {:?} has no gemini_session_id; cannot follow up. \
Remove with 'fno agents rm {}' and recreate.",
name, name
),
11,
);
}
};
emit_event(
events,
"agent_followup_started",
&[
("name", name.into()),
("provider", "gemini".into()),
("gemini_session_id", session_id.clone().into()),
("yolo", yolo.into()),
],
);
let log_path = match entry.log_path.as_deref() {
Some(p) if !p.is_empty() => PathBuf::from(p),
_ => {
return AskOutcome::err(
format!(
"registry entry {:?} has empty log_path; run 'fno agents rm {}' and recreate.",
name, name
),
11,
);
}
};
let registered_cwd = match entry.cwd.as_str() {
"" => {
return AskOutcome::err(
format!(
"registry entry {:?} has empty cwd; gemini sessions are cwd-pinned and \
resume cannot proceed. Run 'fno agents rm {}' and recreate.",
name, name
),
11,
);
}
c => PathBuf::from(c),
};
let timeout_sec = timeout.unwrap_or(DEFAULT_FOLLOWUP_TIMEOUT);
let result = match gemini_resume(
&session_id,
®istered_cwd,
message,
from_name,
yolo,
&log_path,
Some(timeout_sec),
) {
Ok(r) => r,
Err(e) => {
let stage = match &e {
GeminiAskError::Timeout { .. } => "gemini-timeout",
GeminiAskError::Parse { .. } => "gemini-parse",
GeminiAskError::Interrupted => "gemini-interrupted",
_ => "gemini-subprocess",
};
let exit_code = e.exit_code();
emit_event(
events,
"agent_followup_failed",
&[
("stage", stage.into()),
("name", name.into()),
("gemini_session_id", session_id.clone().into()),
("returncode", exit_code.into()),
],
);
let msg = format!(
"{} (see {} for details). If the session was deleted (e.g. \
'gemini --delete-session'), run 'fno agents rm {}' then re-ask.",
e,
log_path.display(),
name
);
return AskOutcome::err(msg, exit_code);
}
};
if let Err(e) = update_registry(registry_path, |reg| {
if let Some(en) = reg.find_mut(name) {
en.status = AgentStatus::Live;
en.last_message_at = Some(now_iso());
}
}) {
emit_event(
events,
"agent_followup_failed",
&[
("stage", "registry-write".into()),
("name", name.into()),
("gemini_session_id", session_id.clone().into()),
("error", e.to_string().into()),
("error_type", "RegistryWriteError".into()),
],
);
return AskOutcome::err(
format!(
"registry write failed: {}. NOTE: message was already delivered; do not retry.",
e
),
12,
);
}
emit_event(
events,
"agent_followup_done",
&[
("stage", "followup".into()),
("name", name.into()),
("provider", "gemini".into()),
("gemini_session_id", session_id.clone().into()),
(
"reply_chars",
(result.last_msg.chars().count() as u64).into(),
),
("yolo", yolo.into()),
],
);
AskOutcome::ok_reply(result.last_msg)
}
pub fn maybe_run_gemini_ask(
home: &AgentsHome,
params: &serde_json::Value,
name: &str,
) -> Option<i32> {
let provider_param = params.get("provider").and_then(|v| v.as_str());
let registry = match load_registry(&home.registry_json()) {
Ok(r) => r,
Err(e) => {
eprintln!(
"fno-agents: cannot read agents registry at {:?}: {}",
home.registry_json(),
e
);
return Some(12);
}
};
let existing_provider = registry.find(name).map(|e| e.harness_name().to_string());
if let (Some(ep), Some(pp)) = (existing_provider.as_deref(), provider_param) {
if ep == "gemini" && pp != "gemini" {
eprintln!(
"fno-agents: agent {:?} already exists with provider 'gemini'; \
refusing to override with --provider {}",
name, pp
);
return Some(2);
}
}
let resolved = existing_provider.as_deref().or(provider_param);
if resolved != Some("gemini") {
return None; }
let message = params.get("message").and_then(|v| v.as_str()).unwrap_or("");
let from_name = params
.get("from_name")
.and_then(|v| v.as_str())
.unwrap_or("fno");
let cwd = crate::subprocess_ask::resolve_ask_cwd(params.get("cwd").and_then(|v| v.as_str()));
let timeout = params
.get("timeout")
.and_then(|v| v.as_u64())
.map(Duration::from_secs);
let yolo = params
.get("yolo")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let outcome = dispatch_gemini_ask(home, name, message, from_name, &cwd, yolo, timeout);
if !outcome.stderr.is_empty() {
eprint!("{}", outcome.stderr);
}
if !outcome.stdout.is_empty() {
print!("{}", outcome.stdout);
}
Some(outcome.exit_code)
}
#[cfg(test)]
mod sandbox_posture_tests {
use super::sandbox_flag;
#[test]
fn full_yolo_is_bare_yolo() {
assert_eq!(sandbox_flag(true, Some(true)), vec!["--yolo".to_string()]);
assert_eq!(sandbox_flag(true, Some(false)), vec!["--yolo".to_string()]);
}
#[test]
fn bounded_with_provider_is_yolo_plus_sandbox() {
assert_eq!(
sandbox_flag(false, Some(true)),
vec![
"--approval-mode".to_string(),
"yolo".to_string(),
"--sandbox".to_string()
]
);
}
#[test]
fn bounded_without_provider_falls_back_never_prompting() {
let fb = sandbox_flag(false, Some(false));
assert_eq!(fb, vec!["--approval-mode".to_string(), "yolo".to_string()]);
assert!(!fb.iter().any(|t| t == "default" || t == "auto_edit"));
assert!(!fb.contains(&"--sandbox".to_string()));
}
}