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
54#[derive(Debug, serde::Serialize)]
56pub struct SpawnResult {
57 #[serde(flatten)]
58 pub session: Session,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub marker_warning: Option<String>,
61}
62
63pub fn run(args: SpawnArgs) -> Result<SpawnResult> {
64 let backend = backend::resolve(&args.backend)?;
65 let (permission_mode, dangerous) = resolve_posture(&args)?;
66
67 let cwd = resolve_cwd(args.cwd)?;
68 let session_id = match args.session_id {
69 Some(id) => validate_uuid(id)?,
70 None => Uuid::new_v4().to_string(),
71 };
72 let name = args.name.unwrap_or_else(|| default_name(&cwd, &session_id));
73 session::validate_name(&name)?;
75
76 let backend_version = backend.installed_version();
77 let marker_warning = backend::marker_warning(backend.as_ref(), backend_version.as_deref());
78
79 let session = Session {
82 jsonl_path: session::jsonl_path(&cwd, &session_id)?,
83 name,
84 backend: backend.name().to_string(),
85 cwd,
86 permission_mode: permission_mode.clone(),
87 backend_version,
88 created: session::now_epoch(),
89 session_id: session_id.clone(),
90 };
91
92 let command = backend.spawn_command(&SpawnOpts {
93 session_id,
94 permission_mode,
95 dangerous,
96 });
97 tmux::new_session(&session.name, args.width, args.height, &session.cwd, &command)?;
98
99 if let Err(e) = post_spawn(&session, args.trust || args.yolo, backend.as_ref()) {
103 let _ = tmux::kill_session(&session.name);
104 return Err(e);
105 }
106 Ok(SpawnResult {
107 session,
108 marker_warning,
109 })
110}
111
112fn post_spawn(session: &Session, trust: bool, backend: &dyn Backend) -> Result<()> {
114 if trust {
115 clear_trust_gate(&session.name, backend)?;
116 }
117 session.save()
118}
119
120fn resolve_cwd(cwd: Option<PathBuf>) -> Result<String> {
122 let path = match cwd {
123 Some(p) => p,
124 None => std::env::current_dir().map_err(|e| Error::io(".", e))?,
125 };
126 let abs = std::fs::canonicalize(&path).unwrap_or(path);
127 Ok(abs.to_string_lossy().into_owned())
128}
129
130fn resolve_posture(args: &SpawnArgs) -> Result<(Option<String>, bool)> {
133 let specified = [
134 args.permission_mode.is_some(),
135 args.auto_accept,
136 args.bypass_permissions,
137 args.yolo,
138 ]
139 .iter()
140 .filter(|&&set| set)
141 .count();
142 if specified > 1 {
143 return Err(Error::ConflictingPermissionFlags);
144 }
145
146 if args.yolo {
147 return Ok((None, true));
148 }
149 if args.bypass_permissions {
150 return Ok((Some("bypassPermissions".to_string()), false));
151 }
152 if args.auto_accept {
153 return Ok((Some("acceptEdits".to_string()), false));
154 }
155 if let Some(mode) = &args.permission_mode {
156 if !CLAUDE_PERMISSION_MODES.contains(&mode.as_str()) {
157 return Err(Error::InvalidPermissionMode(
158 mode.clone(),
159 format!("{CLAUDE_PERMISSION_MODES:?}"),
160 ));
161 }
162 return Ok((Some(mode.clone()), false));
163 }
164 Ok((None, false))
165}
166
167fn validate_uuid(id: String) -> Result<String> {
168 Uuid::parse_str(&id).map_err(|e| Error::InvalidSessionId(id.clone(), e.to_string()))?;
169 Ok(id)
170}
171
172fn clear_trust_gate(name: &str, backend: &dyn Backend) -> Result<bool> {
178 const MIN_POLLS_BEFORE_READY: u32 = 4;
181 for attempt in 0..TRUST_POLL_ATTEMPTS {
182 sleep(TRUST_POLL_INTERVAL);
183 let captured = tmux::capture_pane(name)?;
184 if pane::contains_any(&captured, backend.trust_markers()) {
185 tmux::send_literal(name, "1")?;
186 sleep(APPROVE_DELAY);
187 tmux::send_key(name, "Enter")?;
188 return Ok(true);
189 }
190 if attempt >= MIN_POLLS_BEFORE_READY && !captured.trim().is_empty() {
191 return Ok(false);
192 }
193 }
194 Ok(false)
195}
196
197fn default_name(cwd: &str, session_id: &str) -> String {
199 let base = cwd.rsplit('/').find(|s| !s.is_empty()).unwrap_or("agent");
200 let short = &session_id[..session_id.len().min(8)];
201 format!("csd-{base}-{short}")
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 fn args(f: impl FnOnce(&mut SpawnArgs)) -> SpawnArgs {
209 let mut a = SpawnArgs::default();
210 f(&mut a);
211 a
212 }
213
214 #[test]
215 fn posture_flags_map_to_modes() {
216 assert_eq!(resolve_posture(&SpawnArgs::default()).unwrap(), (None, false));
217 assert_eq!(
218 resolve_posture(&args(|a| a.auto_accept = true)).unwrap(),
219 (Some("acceptEdits".into()), false)
220 );
221 assert_eq!(
222 resolve_posture(&args(|a| a.bypass_permissions = true)).unwrap(),
223 (Some("bypassPermissions".into()), false)
224 );
225 assert_eq!(resolve_posture(&args(|a| a.yolo = true)).unwrap(), (None, true));
226 assert_eq!(
227 resolve_posture(&args(|a| a.permission_mode = Some("plan".into()))).unwrap(),
228 (Some("plan".into()), false)
229 );
230 }
231
232 #[test]
233 fn rejects_conflicting_and_invalid_postures() {
234 assert!(resolve_posture(&args(|a| {
235 a.yolo = true;
236 a.bypass_permissions = true;
237 }))
238 .is_err());
239 assert!(resolve_posture(&args(|a| a.permission_mode = Some("nope".into()))).is_err());
240 }
241}