1use std::path::PathBuf;
4use std::thread::sleep;
5
6use uuid::Uuid;
7
8use crate::backend::{self, Backend, SpawnOpts, CLAUDE_PERMISSION_MODES};
9use crate::commands::{APPROVE_DELAY, DEFAULT_HEIGHT, DEFAULT_WIDTH, TRUST_POLL_ATTEMPTS, TRUST_POLL_INTERVAL};
10use crate::detect::pane;
11use crate::error::{Error, Result};
12use crate::session::{self, Session};
13use crate::tmux;
14
15#[derive(Debug, Clone)]
17pub struct SpawnArgs {
18 pub cwd: Option<PathBuf>,
19 pub session_id: Option<String>,
20 pub permission_mode: Option<String>,
21 pub name: Option<String>,
22 pub backend: String,
23 pub auto_accept: bool,
25 pub bypass_permissions: bool,
27 pub yolo: bool,
30 pub trust: bool,
32 pub width: u16,
33 pub height: u16,
34}
35
36impl Default for SpawnArgs {
37 fn default() -> Self {
38 SpawnArgs {
39 cwd: None,
40 session_id: None,
41 permission_mode: None,
42 name: None,
43 backend: "claude".to_string(),
44 auto_accept: false,
45 bypass_permissions: false,
46 yolo: false,
47 trust: false,
48 width: DEFAULT_WIDTH,
49 height: DEFAULT_HEIGHT,
50 }
51 }
52}
53
54pub fn run(args: SpawnArgs) -> Result<Session> {
55 let backend = backend::resolve(&args.backend)?;
56 let (permission_mode, dangerous) = resolve_posture(&args)?;
57
58 let cwd = resolve_cwd(args.cwd)?;
59 let session_id = match args.session_id {
60 Some(id) => validate_uuid(id)?,
61 None => Uuid::new_v4().to_string(),
62 };
63 let name = args.name.unwrap_or_else(|| default_name(&cwd, &session_id));
64 session::validate_name(&name)?;
66
67 let session = Session {
70 jsonl_path: session::jsonl_path(&cwd, &session_id)?,
71 name,
72 backend: backend.name().to_string(),
73 cwd,
74 permission_mode: permission_mode.clone(),
75 created: session::now_epoch(),
76 session_id: session_id.clone(),
77 };
78
79 let command = backend.spawn_command(&SpawnOpts {
80 session_id,
81 permission_mode,
82 dangerous,
83 });
84 tmux::new_session(&session.name, args.width, args.height, &session.cwd, &command)?;
85
86 if let Err(e) = post_spawn(&session, args.trust || args.yolo, backend.as_ref()) {
90 let _ = tmux::kill_session(&session.name);
91 return Err(e);
92 }
93 Ok(session)
94}
95
96fn post_spawn(session: &Session, trust: bool, backend: &dyn Backend) -> Result<()> {
98 if trust {
99 clear_trust_gate(&session.name, backend)?;
100 }
101 session.save()
102}
103
104fn resolve_cwd(cwd: Option<PathBuf>) -> Result<String> {
106 let path = match cwd {
107 Some(p) => p,
108 None => std::env::current_dir().map_err(|e| Error::io(".", e))?,
109 };
110 let abs = std::fs::canonicalize(&path).unwrap_or(path);
111 Ok(abs.to_string_lossy().into_owned())
112}
113
114fn resolve_posture(args: &SpawnArgs) -> Result<(Option<String>, bool)> {
117 let specified = [
118 args.permission_mode.is_some(),
119 args.auto_accept,
120 args.bypass_permissions,
121 args.yolo,
122 ]
123 .iter()
124 .filter(|&&set| set)
125 .count();
126 if specified > 1 {
127 return Err(Error::ConflictingPermissionFlags);
128 }
129
130 if args.yolo {
131 return Ok((None, true));
132 }
133 if args.bypass_permissions {
134 return Ok((Some("bypassPermissions".to_string()), false));
135 }
136 if args.auto_accept {
137 return Ok((Some("acceptEdits".to_string()), false));
138 }
139 if let Some(mode) = &args.permission_mode {
140 if !CLAUDE_PERMISSION_MODES.contains(&mode.as_str()) {
141 return Err(Error::InvalidPermissionMode(
142 mode.clone(),
143 format!("{CLAUDE_PERMISSION_MODES:?}"),
144 ));
145 }
146 return Ok((Some(mode.clone()), false));
147 }
148 Ok((None, false))
149}
150
151fn validate_uuid(id: String) -> Result<String> {
152 Uuid::parse_str(&id).map_err(|e| Error::InvalidSessionId(id.clone(), e.to_string()))?;
153 Ok(id)
154}
155
156fn clear_trust_gate(name: &str, backend: &dyn Backend) -> Result<bool> {
162 const MIN_POLLS_BEFORE_READY: u32 = 4;
165 for attempt in 0..TRUST_POLL_ATTEMPTS {
166 sleep(TRUST_POLL_INTERVAL);
167 let captured = tmux::capture_pane(name)?;
168 if pane::contains_any(&captured, backend.trust_markers()) {
169 tmux::send_literal(name, "1")?;
170 sleep(APPROVE_DELAY);
171 tmux::send_key(name, "Enter")?;
172 return Ok(true);
173 }
174 if attempt >= MIN_POLLS_BEFORE_READY && !captured.trim().is_empty() {
175 return Ok(false);
176 }
177 }
178 Ok(false)
179}
180
181fn default_name(cwd: &str, session_id: &str) -> String {
183 let base = cwd.rsplit('/').find(|s| !s.is_empty()).unwrap_or("agent");
184 let short = &session_id[..session_id.len().min(8)];
185 format!("csd-{base}-{short}")
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 fn args(f: impl FnOnce(&mut SpawnArgs)) -> SpawnArgs {
193 let mut a = SpawnArgs::default();
194 f(&mut a);
195 a
196 }
197
198 #[test]
199 fn posture_flags_map_to_modes() {
200 assert_eq!(resolve_posture(&SpawnArgs::default()).unwrap(), (None, false));
201 assert_eq!(
202 resolve_posture(&args(|a| a.auto_accept = true)).unwrap(),
203 (Some("acceptEdits".into()), false)
204 );
205 assert_eq!(
206 resolve_posture(&args(|a| a.bypass_permissions = true)).unwrap(),
207 (Some("bypassPermissions".into()), false)
208 );
209 assert_eq!(resolve_posture(&args(|a| a.yolo = true)).unwrap(), (None, true));
210 assert_eq!(
211 resolve_posture(&args(|a| a.permission_mode = Some("plan".into()))).unwrap(),
212 (Some("plan".into()), false)
213 );
214 }
215
216 #[test]
217 fn rejects_conflicting_and_invalid_postures() {
218 assert!(resolve_posture(&args(|a| {
219 a.yolo = true;
220 a.bypass_permissions = true;
221 }))
222 .is_err());
223 assert!(resolve_posture(&args(|a| a.permission_mode = Some("nope".into()))).is_err());
224 }
225}