Skip to main content

devflow_core/agents/
claude.rs

1//! Claude Code agent adapter.
2//!
3//! Launches `claude -p "<prompt>"` in non-interactive mode with structured
4//! JSON output. Claude runs headless — no trust dialogs, no user prompts.
5
6use super::AgentAdapter;
7
8pub struct ClaudeAgent;
9
10impl AgentAdapter for ClaudeAgent {
11    fn name(&self) -> &'static str {
12        "Claude Code"
13    }
14
15    fn exec_command(
16        &self,
17        _phase: u32,
18        prompt: &str,
19        _extra_writable_roots: &[std::path::PathBuf],
20    ) -> (&'static str, Vec<String>) {
21        (
22            "claude",
23            vec![
24                "-p".into(),
25                prompt.to_string(),
26                "--output-format".into(),
27                "json".into(),
28                "--dangerously-skip-permissions".into(),
29            ],
30        )
31    }
32
33    fn completion_signal_detected(&self, _output: &str) -> bool {
34        // Claude exits cleanly when done; monitor detects exit via kill -0.
35        false
36    }
37}
38
39impl ClaudeAgent {
40    /// Build the resume relaunch command for a confirmed checkpoint
41    /// auto-decide (D-03/D-04, 28-03). NOT a trait method — `--resume` is a
42    /// Claude-CLI-specific, documented feature with no equivalent on
43    /// `AgentAdapter` (D-05: Claude-only, no Codex/OpenCode accommodation,
44    /// `AgentAdapter` itself is untouched).
45    ///
46    /// Argv order (RESEARCH.md § "Architecture Patterns / Pattern 4",
47    /// confirmed): the print flag, the instruction, the resume flag
48    /// immediately followed by the session id (so the id is parsed as the
49    /// flag's value, not a positional argument), the output-format flag with
50    /// its JSON value, and the permission-bypass flag.
51    ///
52    /// **Pitfall 1 (RESEARCH.md, T-28-02) — load-bearing, do not "clean up":**
53    /// a `claude --resume`d session restores NEITHER the permission mode NOR
54    /// the output format from the original launch. Both are re-passed here
55    /// explicitly even though they look redundant with `exec_command`'s
56    /// launch above. Omitting either reintroduces the exact headless hang
57    /// this phase exists to close: the resumed session halts on a
58    /// permission prompt with no operator present to answer it, and the
59    /// prompt is not guaranteed to even reach the captured stdout.
60    /// `resume_command_includes_permission_bypass` is the named regression
61    /// test guarding this specifically — do not delete it as "obviously
62    /// redundant" with `claude_wraps_prompt_in_noninteractive_flags` above;
63    /// it guards a DIFFERENT command construction path.
64    pub fn exec_resume_command(session_id: &str, instruction: &str) -> (&'static str, Vec<String>) {
65        (
66            "claude",
67            vec![
68                "-p".into(),
69                instruction.to_string(),
70                "--resume".into(),
71                session_id.to_string(),
72                "--output-format".into(),
73                "json".into(),
74                "--dangerously-skip-permissions".into(),
75            ],
76        )
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn resume_command_names_claude_program() {
86        let (program, _args) = ClaudeAgent::exec_resume_command("sess", "instr");
87        assert_eq!(program, "claude");
88    }
89
90    #[test]
91    fn resume_command_carries_print_flag_and_instruction() {
92        let (_program, args) = ClaudeAgent::exec_resume_command("sess", "do the thing");
93        assert!(args.iter().any(|a| a == "-p"));
94        assert!(args.iter().any(|a| a == "do the thing"));
95    }
96
97    #[test]
98    fn resume_command_resume_flag_immediately_precedes_session_id() {
99        let (_program, args) = ClaudeAgent::exec_resume_command("sess-abc", "instr");
100        let resume_idx = args
101            .iter()
102            .position(|a| a == "--resume")
103            .expect("--resume flag must be present");
104        assert_eq!(
105            args.get(resume_idx + 1).map(String::as_str),
106            Some("sess-abc"),
107            "the session id must immediately follow --resume so it is parsed \
108             as the flag's value, not a positional argument: {args:?}"
109        );
110    }
111
112    /// Pitfall 1 (RESEARCH.md, T-28-02): the single highest-consequence
113    /// regression this phase can ship is a resume relaunch that omits either
114    /// the permission-bypass flag or the JSON output-format flag — a resumed
115    /// Claude session restores neither, so omitting them reintroduces a
116    /// silent headless hang on a permission prompt nobody can answer.
117    #[test]
118    fn resume_command_includes_permission_bypass() {
119        let (program, args) = ClaudeAgent::exec_resume_command("sess-123", "do the thing");
120        assert_eq!(program, "claude");
121        assert!(
122            args.iter().any(|a| a == "--dangerously-skip-permissions"),
123            "a resumed Claude session restores neither the permission mode \
124             nor the output format (RESEARCH Pitfall 1) — omitting this flag \
125             reintroduces a silent headless hang with nobody able to answer \
126             the resulting permission prompt: {args:?}"
127        );
128        assert!(
129            args.windows(2)
130                .any(|w| w[0] == "--output-format" && w[1] == "json"),
131            "the JSON output-format flag must also be re-passed explicitly, \
132             for the same reason as the permission-bypass flag: {args:?}"
133        );
134    }
135}