use std::path::PathBuf;
use std::thread::sleep;
use uuid::Uuid;
use crate::backend::{self, Backend, SpawnOpts, CLAUDE_PERMISSION_MODES};
use crate::commands::{APPROVE_DELAY, DEFAULT_HEIGHT, DEFAULT_WIDTH, TRUST_POLL_ATTEMPTS, TRUST_POLL_INTERVAL};
use crate::detect::pane;
use crate::error::{Error, Result};
use crate::session::{self, Session};
use crate::tmux;
#[derive(Debug, Clone)]
pub struct SpawnArgs {
pub cwd: Option<PathBuf>,
pub session_id: Option<String>,
pub permission_mode: Option<String>,
pub name: Option<String>,
pub backend: String,
pub auto_accept: bool,
pub bypass_permissions: bool,
pub yolo: bool,
pub trust: bool,
pub width: u16,
pub height: u16,
}
impl Default for SpawnArgs {
fn default() -> Self {
SpawnArgs {
cwd: None,
session_id: None,
permission_mode: None,
name: None,
backend: "claude".to_string(),
auto_accept: false,
bypass_permissions: false,
yolo: false,
trust: false,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
}
}
}
pub fn run(args: SpawnArgs) -> Result<Session> {
let backend = backend::resolve(&args.backend)?;
let (permission_mode, dangerous) = resolve_posture(&args)?;
let cwd = resolve_cwd(args.cwd)?;
let session_id = match args.session_id {
Some(id) => validate_uuid(id)?,
None => Uuid::new_v4().to_string(),
};
let name = args.name.unwrap_or_else(|| default_name(&cwd, &session_id));
session::validate_name(&name)?;
let session = Session {
jsonl_path: session::jsonl_path(&cwd, &session_id)?,
name,
backend: backend.name().to_string(),
cwd,
permission_mode: permission_mode.clone(),
created: session::now_epoch(),
session_id: session_id.clone(),
};
let command = backend.spawn_command(&SpawnOpts {
session_id,
permission_mode,
dangerous,
});
tmux::new_session(&session.name, args.width, args.height, &session.cwd, &command)?;
if let Err(e) = post_spawn(&session, args.trust || args.yolo, backend.as_ref()) {
let _ = tmux::kill_session(&session.name);
return Err(e);
}
Ok(session)
}
fn post_spawn(session: &Session, trust: bool, backend: &dyn Backend) -> Result<()> {
if trust {
clear_trust_gate(&session.name, backend)?;
}
session.save()
}
fn resolve_cwd(cwd: Option<PathBuf>) -> Result<String> {
let path = match cwd {
Some(p) => p,
None => std::env::current_dir().map_err(|e| Error::io(".", e))?,
};
let abs = std::fs::canonicalize(&path).unwrap_or(path);
Ok(abs.to_string_lossy().into_owned())
}
fn resolve_posture(args: &SpawnArgs) -> Result<(Option<String>, bool)> {
let specified = [
args.permission_mode.is_some(),
args.auto_accept,
args.bypass_permissions,
args.yolo,
]
.iter()
.filter(|&&set| set)
.count();
if specified > 1 {
return Err(Error::ConflictingPermissionFlags);
}
if args.yolo {
return Ok((None, true));
}
if args.bypass_permissions {
return Ok((Some("bypassPermissions".to_string()), false));
}
if args.auto_accept {
return Ok((Some("acceptEdits".to_string()), false));
}
if let Some(mode) = &args.permission_mode {
if !CLAUDE_PERMISSION_MODES.contains(&mode.as_str()) {
return Err(Error::InvalidPermissionMode(
mode.clone(),
format!("{CLAUDE_PERMISSION_MODES:?}"),
));
}
return Ok((Some(mode.clone()), false));
}
Ok((None, false))
}
fn validate_uuid(id: String) -> Result<String> {
Uuid::parse_str(&id).map_err(|e| Error::InvalidSessionId(id.clone(), e.to_string()))?;
Ok(id)
}
fn clear_trust_gate(name: &str, backend: &dyn Backend) -> Result<bool> {
const MIN_POLLS_BEFORE_READY: u32 = 4;
for attempt in 0..TRUST_POLL_ATTEMPTS {
sleep(TRUST_POLL_INTERVAL);
let captured = tmux::capture_pane(name)?;
if pane::contains_any(&captured, backend.trust_markers()) {
tmux::send_literal(name, "1")?;
sleep(APPROVE_DELAY);
tmux::send_key(name, "Enter")?;
return Ok(true);
}
if attempt >= MIN_POLLS_BEFORE_READY && !captured.trim().is_empty() {
return Ok(false);
}
}
Ok(false)
}
fn default_name(cwd: &str, session_id: &str) -> String {
let base = cwd.rsplit('/').find(|s| !s.is_empty()).unwrap_or("agent");
let short = &session_id[..session_id.len().min(8)];
format!("csd-{base}-{short}")
}
#[cfg(test)]
mod tests {
use super::*;
fn args(f: impl FnOnce(&mut SpawnArgs)) -> SpawnArgs {
let mut a = SpawnArgs::default();
f(&mut a);
a
}
#[test]
fn posture_flags_map_to_modes() {
assert_eq!(resolve_posture(&SpawnArgs::default()).unwrap(), (None, false));
assert_eq!(
resolve_posture(&args(|a| a.auto_accept = true)).unwrap(),
(Some("acceptEdits".into()), false)
);
assert_eq!(
resolve_posture(&args(|a| a.bypass_permissions = true)).unwrap(),
(Some("bypassPermissions".into()), false)
);
assert_eq!(resolve_posture(&args(|a| a.yolo = true)).unwrap(), (None, true));
assert_eq!(
resolve_posture(&args(|a| a.permission_mode = Some("plan".into()))).unwrap(),
(Some("plan".into()), false)
);
}
#[test]
fn rejects_conflicting_and_invalid_postures() {
assert!(resolve_posture(&args(|a| {
a.yolo = true;
a.bypass_permissions = true;
}))
.is_err());
assert!(resolve_posture(&args(|a| a.permission_mode = Some("nope".into()))).is_err());
}
}