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}