Skip to main content

zag_agent/providers/claude/
mod.rs

1// provider-updated: 2026-04-05
2pub mod logs;
3/// Claude agent implementation.
4///
5/// This module provides the Claude agent implementation, including:
6/// - Agent trait implementation for executing Claude commands
7/// - JSON output models for parsing Claude's verbose output
8/// - Conversion to unified AgentOutput format
9pub mod models;
10
11use crate::agent::{Agent, ModelSize};
12
13/// Return the Claude projects directory: `~/.claude/projects/`.
14pub fn projects_dir() -> Option<std::path::PathBuf> {
15    dirs::home_dir().map(|h| h.join(".claude/projects"))
16}
17use crate::output::AgentOutput;
18use crate::providers::common::CommonAgentState;
19use anyhow::{Context, Result};
20use async_trait::async_trait;
21use std::process::Stdio;
22use tokio::io::{AsyncBufReadExt, BufReader};
23use tokio::process::Command;
24
25pub const DEFAULT_MODEL: &str = "default";
26
27pub const AVAILABLE_MODELS: &[&str] = &[
28    "default",
29    "sonnet",
30    "sonnet-4.6",
31    "opus",
32    "opus-4.6",
33    "haiku",
34    "haiku-4.5",
35];
36
37/// Callback for streaming events. Set via `set_event_handler` to receive
38/// unified events as they arrive during non-interactive execution.
39pub type EventHandler = Box<dyn Fn(&crate::output::Event, bool) + Send + Sync>;
40
41pub struct Claude {
42    pub common: CommonAgentState,
43    pub session_id: Option<String>,
44    pub input_format: Option<String>,
45    pub verbose: bool,
46    pub json_schema: Option<String>,
47    pub event_handler: Option<EventHandler>,
48    pub replay_user_messages: bool,
49    pub include_partial_messages: bool,
50    pub mcp_config_path: Option<String>,
51}
52
53impl Claude {
54    pub fn new() -> Self {
55        Self {
56            common: CommonAgentState::new(DEFAULT_MODEL),
57            session_id: None,
58            input_format: None,
59            verbose: false,
60            json_schema: None,
61            event_handler: None,
62            replay_user_messages: false,
63            include_partial_messages: false,
64            mcp_config_path: None,
65        }
66    }
67
68    pub fn set_input_format(&mut self, format: Option<String>) {
69        self.input_format = format;
70    }
71
72    pub fn set_session_id(&mut self, session_id: String) {
73        self.session_id = Some(session_id);
74    }
75
76    pub fn set_verbose(&mut self, verbose: bool) {
77        self.verbose = verbose;
78    }
79
80    pub fn set_json_schema(&mut self, schema: Option<String>) {
81        self.json_schema = schema;
82    }
83
84    pub fn set_replay_user_messages(&mut self, replay: bool) {
85        self.replay_user_messages = replay;
86    }
87
88    pub fn set_include_partial_messages(&mut self, include: bool) {
89        self.include_partial_messages = include;
90    }
91
92    /// Set MCP server config: a JSON string (written to a temp file) or a file path.
93    pub fn set_mcp_config(&mut self, config: Option<String>) {
94        self.mcp_config_path = config.map(|c| {
95            if c.trim_start().starts_with('{') {
96                let path =
97                    std::env::temp_dir().join(format!("zag-mcp-{}.json", uuid::Uuid::new_v4()));
98                if let Err(e) = std::fs::write(&path, &c) {
99                    log::warn!("Failed to write MCP config temp file: {}", e);
100                    return c;
101                }
102                path.to_string_lossy().into_owned()
103            } else {
104                c
105            }
106        });
107    }
108
109    /// Set a callback to receive streaming events during non-interactive execution.
110    ///
111    /// The callback receives `(event, verbose)` where `verbose` indicates whether
112    /// the user requested verbose output.
113    pub fn set_event_handler(&mut self, handler: EventHandler) {
114        self.event_handler = Some(handler);
115    }
116
117    /// Build the argument list for a run/exec invocation.
118    fn build_run_args(
119        &self,
120        interactive: bool,
121        prompt: Option<&str>,
122        effective_output_format: &Option<String>,
123    ) -> Vec<String> {
124        let mut args = Vec::new();
125        let in_sandbox = self.common.sandbox.is_some();
126
127        if !interactive {
128            args.push("--print".to_string());
129
130            match effective_output_format.as_deref() {
131                Some("json") | Some("json-pretty") => {
132                    args.extend(["--verbose", "--output-format", "json"].map(String::from));
133                }
134                Some("stream-json") | None => {
135                    args.extend(["--verbose", "--output-format", "stream-json"].map(String::from));
136                }
137                Some("native-json") => {
138                    args.extend(["--verbose", "--output-format", "json"].map(String::from));
139                }
140                Some("text") => {}
141                _ => {}
142            }
143        }
144
145        // Skip --dangerously-skip-permissions in sandbox (permissions are sandbox-default)
146        if self.common.skip_permissions && !in_sandbox {
147            args.push("--dangerously-skip-permissions".to_string());
148        }
149
150        args.extend(["--model".to_string(), self.common.model.clone()]);
151
152        if interactive && let Some(session_id) = &self.session_id {
153            args.extend(["--session-id".to_string(), session_id.clone()]);
154        }
155
156        for dir in &self.common.add_dirs {
157            args.extend(["--add-dir".to_string(), dir.clone()]);
158        }
159
160        if !self.common.system_prompt.is_empty() {
161            args.extend([
162                "--append-system-prompt".to_string(),
163                self.common.system_prompt.clone(),
164            ]);
165        }
166
167        if !interactive && let Some(ref input_fmt) = self.input_format {
168            args.extend(["--input-format".to_string(), input_fmt.clone()]);
169        }
170
171        if !interactive && self.replay_user_messages {
172            args.push("--replay-user-messages".to_string());
173        }
174
175        if !interactive && self.include_partial_messages {
176            args.push("--include-partial-messages".to_string());
177        }
178
179        if let Some(ref schema) = self.json_schema {
180            args.extend(["--json-schema".to_string(), schema.clone()]);
181        }
182
183        if let Some(turns) = self.common.max_turns {
184            args.extend(["--max-turns".to_string(), turns.to_string()]);
185        }
186
187        if let Some(ref path) = self.mcp_config_path {
188            args.extend(["--mcp-config".to_string(), path.clone()]);
189        }
190
191        if let Some(p) = prompt {
192            args.push(p.to_string());
193        }
194
195        args
196    }
197
198    /// Build the argument list for a resume invocation.
199    fn build_resume_args(&self, session_id: Option<&str>) -> Vec<String> {
200        let mut args = Vec::new();
201        let in_sandbox = self.common.sandbox.is_some();
202
203        if let Some(id) = session_id {
204            args.extend(["--resume".to_string(), id.to_string()]);
205        } else {
206            args.push("--continue".to_string());
207        }
208
209        if self.common.skip_permissions && !in_sandbox {
210            args.push("--dangerously-skip-permissions".to_string());
211        }
212
213        args.extend(["--model".to_string(), self.common.model.clone()]);
214
215        for dir in &self.common.add_dirs {
216            args.extend(["--add-dir".to_string(), dir.clone()]);
217        }
218
219        args
220    }
221
222    /// Create a `Command` either directly or wrapped in sandbox.
223    fn make_command(&self, agent_args: Vec<String>) -> Command {
224        self.common.make_command("claude", agent_args)
225    }
226
227    /// Spawn a streaming session with piped stdin/stdout.
228    ///
229    /// Automatically configures `--input-format stream-json`, `--output-format stream-json`,
230    /// and `--replay-user-messages`. Returns a `StreamingSession` for bidirectional
231    /// communication with the agent.
232    pub fn execute_streaming(
233        &self,
234        prompt: Option<&str>,
235    ) -> Result<crate::streaming::StreamingSession> {
236        // Build args for non-interactive streaming mode
237        let mut args = Vec::new();
238        let in_sandbox = self.common.sandbox.is_some();
239
240        args.push("--print".to_string());
241        args.extend(["--verbose", "--output-format", "stream-json"].map(String::from));
242
243        if self.common.skip_permissions && !in_sandbox {
244            args.push("--dangerously-skip-permissions".to_string());
245        }
246
247        args.extend(["--model".to_string(), self.common.model.clone()]);
248
249        for dir in &self.common.add_dirs {
250            args.extend(["--add-dir".to_string(), dir.clone()]);
251        }
252
253        if !self.common.system_prompt.is_empty() {
254            args.extend([
255                "--append-system-prompt".to_string(),
256                self.common.system_prompt.clone(),
257            ]);
258        }
259
260        args.extend(["--input-format".to_string(), "stream-json".to_string()]);
261        args.push("--replay-user-messages".to_string());
262
263        if self.include_partial_messages {
264            args.push("--include-partial-messages".to_string());
265        }
266
267        if let Some(ref schema) = self.json_schema {
268            args.extend(["--json-schema".to_string(), schema.clone()]);
269        }
270
271        if let Some(p) = prompt {
272            args.push(p.to_string());
273        }
274
275        log::debug!("Claude streaming command: claude {}", args.join(" "));
276
277        let mut cmd = self.make_command(args);
278        cmd.stdin(Stdio::piped())
279            .stdout(Stdio::piped())
280            .stderr(Stdio::piped());
281
282        let child = cmd
283            .spawn()
284            .context("Failed to execute 'claude' CLI. Is it installed and in PATH?")?;
285        crate::streaming::StreamingSession::new(child)
286    }
287
288    /// Build argument list for a streaming resume invocation.
289    fn build_streaming_resume_args(&self, session_id: &str) -> Vec<String> {
290        let mut args = Vec::new();
291        let in_sandbox = self.common.sandbox.is_some();
292
293        args.push("--print".to_string());
294        args.extend(["--resume".to_string(), session_id.to_string()]);
295        args.extend(["--verbose", "--output-format", "stream-json"].map(String::from));
296
297        if self.common.skip_permissions && !in_sandbox {
298            args.push("--dangerously-skip-permissions".to_string());
299        }
300
301        args.extend(["--model".to_string(), self.common.model.clone()]);
302
303        for dir in &self.common.add_dirs {
304            args.extend(["--add-dir".to_string(), dir.clone()]);
305        }
306
307        args.extend(["--input-format".to_string(), "stream-json".to_string()]);
308        args.push("--replay-user-messages".to_string());
309
310        if self.include_partial_messages {
311            args.push("--include-partial-messages".to_string());
312        }
313
314        args
315    }
316
317    /// Spawn a streaming session that resumes an existing session.
318    ///
319    /// Combines `--resume` with `--input-format stream-json`, `--output-format stream-json`,
320    /// and `--replay-user-messages`. Returns a `StreamingSession` for bidirectional
321    /// communication with the resumed session.
322    pub fn execute_streaming_resume(
323        &self,
324        session_id: &str,
325    ) -> Result<crate::streaming::StreamingSession> {
326        let args = self.build_streaming_resume_args(session_id);
327
328        log::debug!("Claude streaming resume command: claude {}", args.join(" "));
329
330        let mut cmd = self.make_command(args);
331        cmd.stdin(Stdio::piped())
332            .stdout(Stdio::piped())
333            .stderr(Stdio::piped());
334
335        let child = cmd
336            .spawn()
337            .context("Failed to execute 'claude' CLI. Is it installed and in PATH?")?;
338        crate::streaming::StreamingSession::new(child)
339    }
340
341    async fn execute(
342        &self,
343        interactive: bool,
344        prompt: Option<&str>,
345    ) -> Result<Option<AgentOutput>> {
346        // When capture_output is set (e.g. by auto-selector), use "json" format
347        // so stdout is piped and parsed into AgentOutput
348        let effective_output_format =
349            if self.common.capture_output && self.common.output_format.is_none() {
350                Some("json".to_string())
351            } else {
352                self.common.output_format.clone()
353            };
354
355        // Determine if we should capture structured output
356        // Default to streaming unified output when no format is specified in print mode
357        let capture_json = !interactive
358            && effective_output_format
359                .as_ref()
360                .is_none_or(|f| f == "json" || f == "json-pretty" || f == "stream-json");
361
362        let agent_args = self.build_run_args(interactive, prompt, &effective_output_format);
363        log::debug!("Claude command: claude {}", agent_args.join(" "));
364        if !self.common.system_prompt.is_empty() {
365            log::debug!("Claude system prompt: {}", self.common.system_prompt);
366        }
367        if let Some(p) = prompt {
368            log::debug!("Claude user prompt: {}", p);
369        }
370        log::debug!(
371            "Claude mode: interactive={}, capture_json={}, output_format={:?}",
372            interactive,
373            capture_json,
374            effective_output_format
375        );
376        let mut cmd = self.make_command(agent_args);
377
378        // Check if we should pass through native JSON without conversion
379        let is_native_json = effective_output_format.as_deref() == Some("native-json");
380
381        if interactive {
382            // Interactive mode - inherit all stdio
383            cmd.stdin(Stdio::inherit())
384                .stdout(Stdio::inherit())
385                .stderr(Stdio::inherit());
386
387            let status = cmd
388                .status()
389                .await
390                .context("Failed to execute 'claude' CLI. Is it installed and in PATH?")?;
391            if !status.success() {
392                return Err(crate::process::ProcessError {
393                    exit_code: status.code(),
394                    stderr: String::new(),
395                    agent_name: "Claude".to_string(),
396                }
397                .into());
398            }
399            Ok(None)
400        } else if is_native_json {
401            // Native JSON mode - pass through Claude's raw JSON output, capture stderr
402            cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit());
403
404            crate::process::run_with_captured_stderr(&mut cmd).await?;
405            Ok(None)
406        } else if capture_json {
407            let output_format = effective_output_format.as_deref();
408            let is_streaming = output_format == Some("stream-json") || output_format.is_none();
409
410            if is_streaming {
411                // For stream-json or default (None), stream output and convert to unified format
412                cmd.stdin(Stdio::inherit());
413                cmd.stdout(Stdio::piped());
414
415                let mut child = crate::process::spawn_with_captured_stderr(&mut cmd).await?;
416                let stdout = child
417                    .stdout
418                    .take()
419                    .ok_or_else(|| anyhow::anyhow!("Failed to capture stdout"))?;
420
421                let reader = BufReader::new(stdout);
422                let mut lines = reader.lines();
423
424                // Determine output mode
425                let format_as_text = output_format.is_none(); // Default: beautiful text
426                let format_as_json = output_format == Some("stream-json"); // Explicit: unified JSON
427
428                // Stream each line, dispatching via event_handler if set
429                while let Some(line) = lines.next_line().await? {
430                    if format_as_text || format_as_json {
431                        match serde_json::from_str::<models::ClaudeEvent>(&line) {
432                            Ok(claude_event) => {
433                                if let Some(unified_event) =
434                                    convert_claude_event_to_unified(&claude_event)
435                                {
436                                    if let Some(ref handler) = self.event_handler {
437                                        handler(&unified_event, self.verbose);
438                                    }
439                                }
440                            }
441                            Err(e) => {
442                                log::debug!(
443                                    "Failed to parse streaming Claude event: {}. Line: {}",
444                                    e,
445                                    crate::truncate_str(&line, 200)
446                                );
447                            }
448                        }
449                    }
450                }
451
452                // Signal end of streaming to handler
453                if let Some(ref handler) = self.event_handler {
454                    // Send a Result event to signal completion
455                    handler(
456                        &crate::output::Event::Result {
457                            success: true,
458                            message: None,
459                            duration_ms: None,
460                            num_turns: None,
461                        },
462                        self.verbose,
463                    );
464                }
465
466                crate::process::wait_with_stderr(child).await?;
467
468                // Return None to indicate output was streamed directly
469                Ok(None)
470            } else {
471                // For json/json-pretty, capture all output then parse
472                cmd.stdin(Stdio::inherit());
473                cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
474
475                let output = cmd.output().await?;
476
477                crate::process::handle_output(&output, "Claude")?;
478
479                // Parse JSON output
480                let json_str = String::from_utf8(output.stdout)?;
481                log::debug!("Parsing Claude JSON output ({} bytes)", json_str.len());
482                let claude_output: models::ClaudeOutput =
483                    serde_json::from_str(&json_str).map_err(|e| {
484                        log::debug!(
485                            "Failed to parse Claude JSON output: {}. First 500 chars: {}",
486                            e,
487                            crate::truncate_str(&json_str, 500)
488                        );
489                        anyhow::anyhow!("Failed to parse Claude JSON output: {}", e)
490                    })?;
491                log::debug!("Parsed {} Claude events successfully", claude_output.len());
492
493                // Convert to unified AgentOutput
494                let agent_output: AgentOutput =
495                    models::claude_output_to_agent_output(claude_output);
496                Ok(Some(agent_output))
497            }
498        } else {
499            // Explicit text mode - inherit stdout, capture stderr
500            cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit());
501
502            crate::process::run_with_captured_stderr(&mut cmd).await?;
503            Ok(None)
504        }
505    }
506}
507
508/// Convert a single Claude event to a unified event format.
509/// Returns None if the event doesn't map to a user-visible unified event.
510fn convert_claude_event_to_unified(event: &models::ClaudeEvent) -> Option<crate::output::Event> {
511    use crate::output::{
512        ContentBlock as UnifiedContentBlock, Event as UnifiedEvent, ToolResult,
513        Usage as UnifiedUsage,
514    };
515    use models::ClaudeEvent;
516
517    match event {
518        ClaudeEvent::System {
519            model, tools, cwd, ..
520        } => {
521            let mut metadata = std::collections::HashMap::new();
522            if let Some(cwd_val) = cwd {
523                metadata.insert("cwd".to_string(), serde_json::json!(cwd_val));
524            }
525
526            Some(UnifiedEvent::Init {
527                model: model.clone(),
528                tools: tools.clone(),
529                working_directory: cwd.clone(),
530                metadata,
531            })
532        }
533
534        ClaudeEvent::Assistant {
535            message,
536            parent_tool_use_id,
537            ..
538        } => {
539            // Convert content blocks
540            let content: Vec<UnifiedContentBlock> = message
541                .content
542                .iter()
543                .filter_map(|block| match block {
544                    models::ContentBlock::Text { text } => {
545                        Some(UnifiedContentBlock::Text { text: text.clone() })
546                    }
547                    models::ContentBlock::ToolUse { id, name, input } => {
548                        Some(UnifiedContentBlock::ToolUse {
549                            id: id.clone(),
550                            name: name.clone(),
551                            input: input.clone(),
552                        })
553                    }
554                    models::ContentBlock::Thinking { .. } => None,
555                })
556                .collect();
557
558            // Convert usage
559            let usage = Some(UnifiedUsage {
560                input_tokens: message.usage.input_tokens,
561                output_tokens: message.usage.output_tokens,
562                cache_read_tokens: Some(message.usage.cache_read_input_tokens),
563                cache_creation_tokens: Some(message.usage.cache_creation_input_tokens),
564                web_search_requests: message
565                    .usage
566                    .server_tool_use
567                    .as_ref()
568                    .map(|s| s.web_search_requests),
569                web_fetch_requests: message
570                    .usage
571                    .server_tool_use
572                    .as_ref()
573                    .map(|s| s.web_fetch_requests),
574            });
575
576            Some(UnifiedEvent::AssistantMessage {
577                content,
578                usage,
579                parent_tool_use_id: parent_tool_use_id.clone(),
580            })
581        }
582
583        ClaudeEvent::User {
584            message,
585            tool_use_result,
586            parent_tool_use_id,
587            ..
588        } => {
589            // For streaming, we can't easily look up tool names from previous events
590            // So we'll use "unknown" for the tool name in streaming mode
591            // Find the first tool_result block (skip text and other blocks)
592            let first_tool_result = message.content.iter().find_map(|b| {
593                if let models::UserContentBlock::ToolResult {
594                    tool_use_id,
595                    content,
596                    is_error,
597                } = b
598                {
599                    Some((tool_use_id, content, is_error))
600                } else {
601                    None
602                }
603            });
604
605            if let Some((tool_use_id, content, is_error)) = first_tool_result {
606                let tool_result = ToolResult {
607                    success: !is_error,
608                    output: if !is_error {
609                        Some(content.clone())
610                    } else {
611                        None
612                    },
613                    error: if *is_error {
614                        Some(content.clone())
615                    } else {
616                        None
617                    },
618                    data: tool_use_result.clone(),
619                };
620
621                Some(UnifiedEvent::ToolExecution {
622                    tool_name: "unknown".to_string(),
623                    tool_id: tool_use_id.clone(),
624                    input: serde_json::Value::Null,
625                    result: tool_result,
626                    parent_tool_use_id: parent_tool_use_id.clone(),
627                })
628            } else {
629                // Check for text content (replayed user messages via --replay-user-messages)
630                let text_blocks: Vec<UnifiedContentBlock> = message
631                    .content
632                    .iter()
633                    .filter_map(|b| {
634                        if let models::UserContentBlock::Text { text } = b {
635                            Some(UnifiedContentBlock::Text { text: text.clone() })
636                        } else {
637                            None
638                        }
639                    })
640                    .collect();
641
642                if !text_blocks.is_empty() {
643                    Some(UnifiedEvent::UserMessage {
644                        content: text_blocks,
645                    })
646                } else {
647                    None
648                }
649            }
650        }
651
652        ClaudeEvent::Other => {
653            log::debug!("Skipping unknown Claude event type during streaming conversion");
654            None
655        }
656
657        ClaudeEvent::Result {
658            is_error,
659            result,
660            duration_ms,
661            num_turns,
662            ..
663        } => Some(UnifiedEvent::Result {
664            success: !is_error,
665            message: Some(result.clone()),
666            duration_ms: Some(*duration_ms),
667            num_turns: Some(*num_turns),
668        }),
669    }
670}
671
672#[cfg(test)]
673#[path = "claude_tests.rs"]
674mod tests;
675
676impl Default for Claude {
677    fn default() -> Self {
678        Self::new()
679    }
680}
681
682#[async_trait]
683impl Agent for Claude {
684    fn name(&self) -> &str {
685        "claude"
686    }
687
688    fn default_model() -> &'static str {
689        DEFAULT_MODEL
690    }
691
692    fn model_for_size(size: ModelSize) -> &'static str {
693        match size {
694            ModelSize::Small => "haiku",
695            ModelSize::Medium => "sonnet",
696            ModelSize::Large => "default",
697        }
698    }
699
700    fn available_models() -> &'static [&'static str] {
701        AVAILABLE_MODELS
702    }
703
704    crate::providers::common::impl_common_agent_setters!();
705
706    fn set_skip_permissions(&mut self, skip: bool) {
707        self.common.skip_permissions = skip;
708    }
709
710    crate::providers::common::impl_as_any!();
711
712    async fn run(&self, prompt: Option<&str>) -> Result<Option<AgentOutput>> {
713        self.execute(false, prompt).await
714    }
715
716    async fn run_interactive(&self, prompt: Option<&str>) -> Result<()> {
717        self.execute(true, prompt).await?;
718        Ok(())
719    }
720
721    async fn run_resume(&self, session_id: Option<&str>, _last: bool) -> Result<()> {
722        let agent_args = self.build_resume_args(session_id);
723        let mut cmd = self.make_command(agent_args);
724
725        cmd.stdin(Stdio::inherit())
726            .stdout(Stdio::inherit())
727            .stderr(Stdio::inherit());
728
729        let status = cmd
730            .status()
731            .await
732            .context("Failed to execute 'claude' CLI. Is it installed and in PATH?")?;
733        if !status.success() {
734            return Err(crate::process::ProcessError {
735                exit_code: status.code(),
736                stderr: String::new(),
737                agent_name: "Claude".to_string(),
738            }
739            .into());
740        }
741        Ok(())
742    }
743
744    async fn run_resume_with_prompt(
745        &self,
746        session_id: &str,
747        prompt: &str,
748    ) -> Result<Option<AgentOutput>> {
749        log::debug!(
750            "Claude resume with prompt: session={}, prompt={}",
751            session_id,
752            prompt
753        );
754        let in_sandbox = self.common.sandbox.is_some();
755        let mut args = vec!["--print".to_string()];
756        args.extend(["--resume".to_string(), session_id.to_string()]);
757        args.extend(["--verbose", "--output-format", "json"].map(String::from));
758
759        if self.common.skip_permissions && !in_sandbox {
760            args.push("--dangerously-skip-permissions".to_string());
761        }
762
763        args.extend(["--model".to_string(), self.common.model.clone()]);
764
765        for dir in &self.common.add_dirs {
766            args.extend(["--add-dir".to_string(), dir.clone()]);
767        }
768
769        if let Some(ref schema) = self.json_schema {
770            args.extend(["--json-schema".to_string(), schema.clone()]);
771        }
772
773        args.push(prompt.to_string());
774
775        let mut cmd = self.make_command(args);
776
777        cmd.stdin(Stdio::inherit());
778        cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
779
780        let output = cmd.output().await?;
781
782        crate::process::handle_output(&output, "Claude")?;
783
784        // Parse JSON output
785        let json_str = String::from_utf8(output.stdout)?;
786        log::debug!(
787            "Parsing Claude resume JSON output ({} bytes)",
788            json_str.len()
789        );
790        let claude_output: models::ClaudeOutput = serde_json::from_str(&json_str)
791            .map_err(|e| anyhow::anyhow!("Failed to parse Claude resume JSON output: {}", e))?;
792
793        let agent_output: AgentOutput = models::claude_output_to_agent_output(claude_output);
794        Ok(Some(agent_output))
795    }
796
797    async fn cleanup(&self) -> Result<()> {
798        Ok(())
799    }
800}