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
54/// Spawn output: the persisted session identity plus the (non-persisted) marker drift warning.
55#[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    // Reject hostile names before they become a tmux session name or a sidecar filename.
74    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    // Assemble the full identity up front so post-spawn steps take a single value, and so a bad
80    // jsonl path fails before we ever start a session.
81    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    // Anything that fails after the session is live must not leave an orphaned, untracked session.
100    // `--yolo` is the zero-friction posture, so it also clears the (separate) folder-trust gate —
101    // `--dangerously-skip-permissions` skips permission checks but NOT the trust prompt.
102    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
112/// Clear the trust gate (if requested) and persist the sidecar, now that the session is live.
113fn 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
120/// Default cwd is the current dir; make it absolute so the transcript slug matches `claude`'s.
121fn 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
130/// Resolve the permission posture into `(permission_mode, dangerous)`. The four posture flags are
131/// mutually exclusive; `--yolo` uses claude's standalone skip flag, the rest map to a mode.
132fn 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
172/// Watch for the one-time "trust this folder?" startup gate and answer it (option 1 = trust).
173///
174/// Returns `true` if a gate was found and cleared. On a folder `claude` already trusts the gate
175/// never appears; we break as soon as the pane renders any non-trust content so trusted dirs don't
176/// pay the full window.
177fn clear_trust_gate(name: &str, backend: &dyn Backend) -> Result<bool> {
178    // Give the gate time to render before treating other content as "already trusted" — the first
179    // frames can show the workspace header a beat before the trust question appears.
180    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
197/// `csd-<cwd-basename>-<first 8 of uuid>` — readable and collision-resistant.
198fn 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}