Skip to main content

csd/
backend.rs

1//! Backend abstraction. `csd` drives `claude` today and is designed to drive `codex` later
2//! (decision #49: keep the dispatch backend swappable so a forced move off the interactive REPL is
3//! a config flip, not a rewrite). Everything release-dependent — the spawn command and the
4//! capture-pane gate markers — lives behind this trait in ONE place (PoC gotcha #4).
5
6use crate::error::{Error, Result};
7
8/// Options needed to build a spawn command. cwd is handled by tmux (`-c`), not the command itself.
9#[derive(Debug, Clone)]
10pub struct SpawnOpts {
11    pub session_id: String,
12    pub permission_mode: Option<String>,
13    /// Pass claude's standalone `--dangerously-skip-permissions` (the `--yolo` posture).
14    pub dangerous: bool,
15}
16
17/// A driveable agent CLI.
18pub trait Backend {
19    /// Stable identifier persisted in the sidecar (`claude`, `codex`).
20    fn name(&self) -> &'static str;
21
22    /// The shell command tmux execs in the new session (PoC §2.1 for claude).
23    fn spawn_command(&self, opts: &SpawnOpts) -> String;
24
25    /// capture-pane substrings that indicate a plan-approval gate is on screen (PoC §3.3).
26    fn plan_markers(&self) -> &'static [&'static str];
27
28    /// capture-pane substrings that indicate a tool-permission gate is on screen.
29    fn permission_markers(&self) -> &'static [&'static str];
30
31    /// capture-pane substrings for the one-time "trust this folder?" startup gate that `claude`
32    /// shows on a directory it has not seen before. Blocks the session until answered.
33    fn trust_markers(&self) -> &'static [&'static str];
34}
35
36/// Permission modes accepted by `claude --permission-mode` (PoC §2.1).
37pub const CLAUDE_PERMISSION_MODES: &[&str] =
38    &["plan", "acceptEdits", "auto", "bypassPermissions", "default", "dontAsk"];
39
40pub struct Claude;
41
42impl Backend for Claude {
43    fn name(&self) -> &'static str {
44        "claude"
45    }
46
47    fn spawn_command(&self, opts: &SpawnOpts) -> String {
48        // Strip the nested-CLI markers so the child doesn't think it runs inside another Claude
49        // session, then pin the session id so the transcript path is known up front.
50        let mut cmd = format!(
51            "env -u CLAUDECODE -u CLAUDE_CODE_ENTRYPOINT claude --session-id {}",
52            opts.session_id
53        );
54        if let Some(mode) = &opts.permission_mode {
55            cmd.push_str(" --permission-mode ");
56            cmd.push_str(mode);
57        }
58        if opts.dangerous {
59            cmd.push_str(" --dangerously-skip-permissions");
60        }
61        cmd
62    }
63
64    fn plan_markers(&self) -> &'static [&'static str] {
65        &["Here is Claude's plan", "Would you like to proceed?"]
66    }
67
68    fn permission_markers(&self) -> &'static [&'static str] {
69        // "Do you want to proceed?" verified live on v2.1.158 (Bash/tool gate) — distinct from the
70        // plan gate's "Would you like to proceed?". The edit-gate variant is the historical wording,
71        // kept as a fallback (couldn't be exercised here — Write/Edit were allowlisted).
72        &["Do you want to proceed?", "Do you want to make this edit?"]
73    }
74
75    fn trust_markers(&self) -> &'static [&'static str] {
76        &[
77            "Is this a project you created or one you trust?",
78            "Yes, I trust this folder",
79        ]
80    }
81}
82
83/// Resolve a backend by name.
84pub fn resolve(name: &str) -> Result<Box<dyn Backend>> {
85    match name {
86        "claude" => Ok(Box::new(Claude)),
87        other => Err(Error::UnknownBackend(other.to_string())),
88    }
89}