Skip to main content

csd/commands/
spawn.rs

1//! `csd spawn` — start a detached interactive agent on the subscription seat (PoC §2.1).
2
3use 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/// Parsed `spawn` inputs (mirrors the CLI; cwd/session_id default lazily).
16#[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    /// Convenience for `--permission-mode acceptEdits`.
24    pub auto_accept: bool,
25    /// Convenience for `--permission-mode bypassPermissions` (skip all permission checks).
26    pub bypass_permissions: bool,
27    /// claude's `--dangerously-skip-permissions` (skip permission checks); also implies `trust`,
28    /// since that flag does NOT clear the separate folder-trust gate.
29    pub yolo: bool,
30    /// Auto-clear the one-time folder-trust gate so the session becomes immediately driveable.
31    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    // Reject hostile names before they become a tmux session name or a sidecar filename.
65    session::validate_name(&name)?;
66
67    // Assemble the full identity up front so post-spawn steps take a single value, and so a bad
68    // jsonl path fails before we ever start a session.
69    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    // Anything that fails after the session is live must not leave an orphaned, untracked session.
87    // `--yolo` is the zero-friction posture, so it also clears the (separate) folder-trust gate —
88    // `--dangerously-skip-permissions` skips permission checks but NOT the trust prompt.
89    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
96/// Clear the trust gate (if requested) and persist the sidecar, now that the session is live.
97fn 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
104/// Default cwd is the current dir; make it absolute so the transcript slug matches `claude`'s.
105fn 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
114/// Resolve the permission posture into `(permission_mode, dangerous)`. The four posture flags are
115/// mutually exclusive; `--yolo` uses claude's standalone skip flag, the rest map to a mode.
116fn 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
156/// Watch for the one-time "trust this folder?" startup gate and answer it (option 1 = trust).
157///
158/// Returns `true` if a gate was found and cleared. On a folder `claude` already trusts the gate
159/// never appears; we break as soon as the pane renders any non-trust content so trusted dirs don't
160/// pay the full window.
161fn clear_trust_gate(name: &str, backend: &dyn Backend) -> Result<bool> {
162    // Give the gate time to render before treating other content as "already trusted" — the first
163    // frames can show the workspace header a beat before the trust question appears.
164    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
181/// `csd-<cwd-basename>-<first 8 of uuid>` — readable and collision-resistant.
182fn 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}