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;
10pub mod usage_limits;
11
12use crate::agent::{Agent, ModelSize};
13
14/// Return the Claude projects directory: `~/.claude/projects/`.
15pub fn projects_dir() -> Option<std::path::PathBuf> {
16    dirs::home_dir().map(|h| h.join(".claude/projects"))
17}
18use crate::output::AgentOutput;
19use crate::providers::common::CommonAgentState;
20use anyhow::{Context, Result};
21use async_trait::async_trait;
22use std::process::Stdio;
23use tokio::io::{AsyncBufReadExt, BufReader};
24use tokio::process::Command;
25
26pub const DEFAULT_MODEL: &str = "default";
27
28pub const AVAILABLE_MODELS: &[&str] = &["default", "sonnet", "opus", "haiku"];
29
30/// Env var that gates Claude's `--print` (non-interactive / exec) mode.
31///
32/// Claude CLI's `--print` now consumes API tokens for every invocation,
33/// so we require an explicit opt-in. When unset, callers should be
34/// steered toward interactive `--exit` mode (see `zag-agent::exit_mode`)
35/// which captures a result via `zag ps kill self <result>` without
36/// paying for `--print`.
37pub const ALLOW_PRINT_ENV: &str = "ZAG_CLAUDE_ALLOW_PRINT";
38
39/// Returns `true` for env-var values that count as "opted in" to
40/// `--print` mode: any non-empty string other than `0` / `false`
41/// (case-insensitive).
42///
43/// Split out from [`check_print_allowed`] so the truthiness logic can be
44/// unit-tested without mutating the process environment (which is
45/// `unsafe` in Rust 2024 and races with other parallel tests).
46pub fn allow_print_value_is_truthy(value: &str) -> bool {
47    !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false")
48}
49
50/// Steering error message returned when `--print` is gated.
51fn print_disabled_error() -> anyhow::Error {
52    anyhow::anyhow!(
53        "Claude --print mode is disabled because it consumes API tokens. \
54         Set {ALLOW_PRINT_ENV}=1 to enable it, or run interactively with \
55         `zag -p claude run --exit '<hint>' \"<prompt>\"` to capture a result via \
56         `zag ps kill self <result>` without paying for --print."
57    )
58}
59
60/// Returns `Ok(())` if `--print` mode is permitted, otherwise an error
61/// with a steering message pointing users at `--exit`.
62pub fn check_print_allowed() -> Result<()> {
63    match std::env::var(ALLOW_PRINT_ENV) {
64        Ok(ref v) if allow_print_value_is_truthy(v) => Ok(()),
65        _ => Err(print_disabled_error()),
66    }
67}
68
69/// Callback for streaming events. Set via `set_event_handler` to receive
70/// unified events as they arrive during non-interactive execution.
71pub type EventHandler = Box<dyn Fn(&crate::output::Event, bool) + Send + Sync>;
72
73pub struct Claude {
74    pub common: CommonAgentState,
75    pub session_id: Option<String>,
76    pub input_format: Option<String>,
77    pub verbose: bool,
78    pub json_schema: Option<String>,
79    pub event_handler: Option<EventHandler>,
80    pub replay_user_messages: bool,
81    pub include_partial_messages: bool,
82    pub mcp_config_path: Option<String>,
83}
84
85impl Claude {
86    pub fn new() -> Self {
87        Self {
88            common: CommonAgentState::new(DEFAULT_MODEL),
89            session_id: None,
90            input_format: None,
91            verbose: false,
92            json_schema: None,
93            event_handler: None,
94            replay_user_messages: false,
95            include_partial_messages: false,
96            mcp_config_path: None,
97        }
98    }
99
100    pub fn set_input_format(&mut self, format: Option<String>) {
101        self.input_format = format;
102    }
103
104    pub fn set_session_id(&mut self, session_id: String) {
105        self.session_id = Some(session_id);
106    }
107
108    pub fn set_verbose(&mut self, verbose: bool) {
109        self.verbose = verbose;
110    }
111
112    pub fn set_json_schema(&mut self, schema: Option<String>) {
113        self.json_schema = schema;
114    }
115
116    pub fn set_replay_user_messages(&mut self, replay: bool) {
117        self.replay_user_messages = replay;
118    }
119
120    pub fn set_include_partial_messages(&mut self, include: bool) {
121        self.include_partial_messages = include;
122    }
123
124    /// Set MCP server config: a JSON string (written to a temp file) or a file path.
125    pub fn set_mcp_config(&mut self, config: Option<String>) {
126        self.mcp_config_path = config.map(|c| {
127            if c.trim_start().starts_with('{') {
128                let path =
129                    std::env::temp_dir().join(format!("zag-mcp-{}.json", uuid::Uuid::new_v4()));
130                if let Err(e) = std::fs::write(&path, &c) {
131                    log::warn!("Failed to write MCP config temp file: {e}");
132                    return c;
133                }
134                path.to_string_lossy().into_owned()
135            } else {
136                c
137            }
138        });
139    }
140
141    /// Set a callback to receive streaming events during non-interactive execution.
142    ///
143    /// The callback receives `(event, verbose)` where `verbose` indicates whether
144    /// the user requested verbose output.
145    pub fn set_event_handler(&mut self, handler: EventHandler) {
146        self.event_handler = Some(handler);
147    }
148
149    /// Build the argument list for a run/exec invocation.
150    fn build_run_args(
151        &self,
152        interactive: bool,
153        prompt: Option<&str>,
154        effective_output_format: &Option<String>,
155    ) -> Vec<String> {
156        let mut args = Vec::new();
157        let in_sandbox = self.common.sandbox.is_some();
158
159        if !interactive {
160            args.push("--print".to_string());
161
162            match effective_output_format.as_deref() {
163                Some("json") | Some("json-pretty") => {
164                    args.extend(["--verbose", "--output-format", "json"].map(String::from));
165                }
166                Some("stream-json") | None => {
167                    args.extend(["--verbose", "--output-format", "stream-json"].map(String::from));
168                }
169                Some("native-json") => {
170                    args.extend(["--verbose", "--output-format", "json"].map(String::from));
171                }
172                Some("text") => {}
173                _ => {}
174            }
175        }
176
177        // Skip --dangerously-skip-permissions in sandbox (permissions are sandbox-default)
178        if self.common.skip_permissions && !in_sandbox {
179            args.push("--dangerously-skip-permissions".to_string());
180        }
181
182        args.extend(["--model".to_string(), self.common.model.clone()]);
183
184        if interactive && let Some(session_id) = &self.session_id {
185            args.extend(["--session-id".to_string(), session_id.clone()]);
186        }
187
188        for dir in &self.common.add_dirs {
189            args.extend(["--add-dir".to_string(), dir.clone()]);
190        }
191
192        if !self.common.system_prompt.is_empty() {
193            args.extend([
194                "--append-system-prompt".to_string(),
195                self.common.system_prompt.clone(),
196            ]);
197        }
198
199        if !interactive && let Some(ref input_fmt) = self.input_format {
200            args.extend(["--input-format".to_string(), input_fmt.clone()]);
201        }
202
203        if !interactive && self.replay_user_messages {
204            args.push("--replay-user-messages".to_string());
205        }
206
207        if !interactive && self.include_partial_messages {
208            args.push("--include-partial-messages".to_string());
209        }
210
211        if let Some(ref schema) = self.json_schema {
212            args.extend(["--json-schema".to_string(), schema.clone()]);
213        }
214
215        if let Some(turns) = self.common.max_turns {
216            args.extend(["--max-turns".to_string(), turns.to_string()]);
217        }
218
219        if let Some(ref path) = self.mcp_config_path {
220            args.extend(["--mcp-config".to_string(), path.clone()]);
221        }
222
223        if let Some(p) = prompt {
224            // Terminate option parsing before the positional prompt so
225            // prompts that start with `-` / `--` (e.g. content injected
226            // from a previous step output delimited by `---`) are not
227            // misinterpreted by clap as unknown long options.
228            args.push("--".to_string());
229            args.push(p.to_string());
230        }
231
232        args
233    }
234
235    /// Build the argument list for a resume invocation.
236    fn build_resume_args(&self, session_id: Option<&str>) -> Vec<String> {
237        let mut args = Vec::new();
238        let in_sandbox = self.common.sandbox.is_some();
239
240        if let Some(id) = session_id {
241            args.extend(["--resume".to_string(), id.to_string()]);
242        } else {
243            args.push("--continue".to_string());
244        }
245
246        if self.common.skip_permissions && !in_sandbox {
247            args.push("--dangerously-skip-permissions".to_string());
248        }
249
250        args.extend(["--model".to_string(), self.common.model.clone()]);
251
252        for dir in &self.common.add_dirs {
253            args.extend(["--add-dir".to_string(), dir.clone()]);
254        }
255
256        args
257    }
258
259    /// Create a `Command` either directly or wrapped in sandbox.
260    fn make_command(&self, agent_args: Vec<String>) -> Command {
261        self.common.make_command("claude", agent_args)
262    }
263
264    /// Spawn a streaming session with piped stdin/stdout.
265    ///
266    /// Automatically configures `--input-format stream-json`, `--output-format stream-json`,
267    /// and `--replay-user-messages`. Returns a `StreamingSession` for bidirectional
268    /// communication with the agent.
269    ///
270    /// # Mid-turn semantics
271    ///
272    /// User messages sent via `StreamingSession::send_user_message` while the
273    /// assistant is producing a response are **queued** by the Claude CLI: the
274    /// current turn runs to completion and the new message is delivered as the
275    /// next user turn. The in-flight turn is **not interrupted**. This
276    /// corresponds to `streaming_input.semantics == "queue"` in the capability
277    /// descriptor.
278    pub fn execute_streaming(
279        &self,
280        prompt: Option<&str>,
281    ) -> Result<crate::streaming::StreamingSession> {
282        check_print_allowed()?;
283        // Build args for non-interactive streaming mode
284        let mut args = Vec::new();
285        let in_sandbox = self.common.sandbox.is_some();
286
287        args.push("--print".to_string());
288        args.extend(["--verbose", "--output-format", "stream-json"].map(String::from));
289
290        if self.common.skip_permissions && !in_sandbox {
291            args.push("--dangerously-skip-permissions".to_string());
292        }
293
294        args.extend(["--model".to_string(), self.common.model.clone()]);
295
296        for dir in &self.common.add_dirs {
297            args.extend(["--add-dir".to_string(), dir.clone()]);
298        }
299
300        if !self.common.system_prompt.is_empty() {
301            args.extend([
302                "--append-system-prompt".to_string(),
303                self.common.system_prompt.clone(),
304            ]);
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        if let Some(ref schema) = self.json_schema {
315            args.extend(["--json-schema".to_string(), schema.clone()]);
316        }
317
318        if let Some(p) = prompt {
319            args.push("--".to_string());
320            args.push(p.to_string());
321        }
322
323        log::debug!("Claude streaming command: claude {}", args.join(" "));
324
325        let mut cmd = self.make_command(args);
326        cmd.stdin(Stdio::piped())
327            .stdout(Stdio::piped())
328            .stderr(Stdio::piped());
329
330        let child = cmd
331            .spawn()
332            .context("Failed to execute 'claude' CLI. Is it installed and in PATH?")?;
333        crate::streaming::StreamingSession::new(child)
334    }
335
336    /// Build argument list for a streaming resume invocation.
337    fn build_streaming_resume_args(&self, session_id: &str) -> Vec<String> {
338        let mut args = Vec::new();
339        let in_sandbox = self.common.sandbox.is_some();
340
341        args.push("--print".to_string());
342        args.extend(["--resume".to_string(), session_id.to_string()]);
343        args.extend(["--verbose", "--output-format", "stream-json"].map(String::from));
344
345        if self.common.skip_permissions && !in_sandbox {
346            args.push("--dangerously-skip-permissions".to_string());
347        }
348
349        args.extend(["--model".to_string(), self.common.model.clone()]);
350
351        for dir in &self.common.add_dirs {
352            args.extend(["--add-dir".to_string(), dir.clone()]);
353        }
354
355        args.extend(["--input-format".to_string(), "stream-json".to_string()]);
356        args.push("--replay-user-messages".to_string());
357
358        if self.include_partial_messages {
359            args.push("--include-partial-messages".to_string());
360        }
361
362        args
363    }
364
365    /// Spawn a streaming session that resumes an existing session.
366    ///
367    /// Combines `--resume` with `--input-format stream-json`, `--output-format stream-json`,
368    /// and `--replay-user-messages`. Returns a `StreamingSession` for bidirectional
369    /// communication with the resumed session.
370    ///
371    /// Mid-turn `send_user_message` calls follow the same **queue** semantics
372    /// as [`Self::execute_streaming`].
373    pub fn execute_streaming_resume(
374        &self,
375        session_id: &str,
376    ) -> Result<crate::streaming::StreamingSession> {
377        check_print_allowed()?;
378        let args = self.build_streaming_resume_args(session_id);
379
380        log::debug!("Claude streaming resume command: claude {}", args.join(" "));
381
382        let mut cmd = self.make_command(args);
383        cmd.stdin(Stdio::piped())
384            .stdout(Stdio::piped())
385            .stderr(Stdio::piped());
386
387        let child = cmd
388            .spawn()
389            .context("Failed to execute 'claude' CLI. Is it installed and in PATH?")?;
390        crate::streaming::StreamingSession::new(child)
391    }
392
393    async fn execute(
394        &self,
395        interactive: bool,
396        prompt: Option<&str>,
397    ) -> Result<Option<AgentOutput>> {
398        if !interactive {
399            check_print_allowed()?;
400        }
401        // When capture_output is set (e.g. by auto-selector), use "json" format
402        // so stdout is piped and parsed into AgentOutput
403        let effective_output_format =
404            if self.common.capture_output && self.common.output_format.is_none() {
405                Some("json".to_string())
406            } else {
407                self.common.output_format.clone()
408            };
409
410        // Determine if we should capture structured output
411        // Default to streaming unified output when no format is specified in print mode
412        let capture_json = !interactive
413            && effective_output_format
414                .as_ref()
415                .is_none_or(|f| f == "json" || f == "json-pretty" || f == "stream-json");
416
417        let agent_args = self.build_run_args(interactive, prompt, &effective_output_format);
418        log::debug!("Claude command: claude {}", agent_args.join(" "));
419        if !self.common.system_prompt.is_empty() {
420            log::debug!("Claude system prompt: {}", self.common.system_prompt);
421        }
422        if let Some(p) = prompt {
423            log::debug!("Claude user prompt: {p}");
424        }
425        log::debug!(
426            "Claude mode: interactive={interactive}, capture_json={capture_json}, output_format={effective_output_format:?}"
427        );
428        let mut cmd = self.make_command(agent_args);
429
430        // Check if we should pass through native JSON without conversion
431        let is_native_json = effective_output_format.as_deref() == Some("native-json");
432
433        if interactive {
434            // Interactive mode - inherit all stdio. Spawn / notify /
435            // wait so `on_spawn_hook` can capture the child pid before
436            // we block on the interactive session (e.g. to register it
437            // with an external process store for `zag ps kill self`).
438            cmd.stdin(Stdio::inherit())
439                .stdout(Stdio::inherit())
440                .stderr(Stdio::inherit());
441
442            let mut child = cmd
443                .spawn()
444                .context("Failed to execute 'claude' CLI. Is it installed and in PATH?")?;
445            self.common.notify_spawn(&child);
446            let status = child
447                .wait()
448                .await
449                .context("Failed waiting on 'claude' CLI")?;
450            if !status.success() {
451                return Err(crate::process::ProcessError {
452                    exit_code: status.code(),
453                    stderr: String::new(),
454                    agent_name: "Claude".to_string(),
455                }
456                .into());
457            }
458            Ok(None)
459        } else if is_native_json {
460            // Native JSON mode - pass through Claude's raw JSON output, capture stderr
461            cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit());
462
463            crate::process::run_with_captured_stderr(&mut cmd).await?;
464            Ok(None)
465        } else if capture_json {
466            let output_format = effective_output_format.as_deref();
467            let is_streaming = output_format == Some("stream-json") || output_format.is_none();
468
469            if is_streaming {
470                // For stream-json or default (None), stream output and convert to unified format
471                cmd.stdin(Stdio::inherit());
472                cmd.stdout(Stdio::piped());
473
474                let mut child = crate::process::spawn_with_captured_stderr(&mut cmd).await?;
475                let stdout = child
476                    .stdout
477                    .take()
478                    .ok_or_else(|| anyhow::anyhow!("Failed to capture stdout"))?;
479
480                let reader = BufReader::new(stdout);
481                let mut lines = reader.lines();
482
483                // Determine output mode
484                let format_as_text = output_format.is_none(); // Default: beautiful text
485                let format_as_json = output_format == Some("stream-json"); // Explicit: unified JSON
486
487                // Per-line batch path uses the stateful translator so that
488                // TurnComplete events are synthesized alongside Result.
489                let mut translator = ClaudeEventTranslator::new();
490
491                // Stream each line, dispatching via event_handler if set
492                while let Some(line) = lines.next_line().await? {
493                    if format_as_text || format_as_json {
494                        match serde_json::from_str::<models::ClaudeEvent>(&line) {
495                            Ok(claude_event) => {
496                                for unified_event in translator.translate(&claude_event) {
497                                    if let Some(ref handler) = self.event_handler {
498                                        handler(&unified_event, self.verbose);
499                                    }
500                                }
501                            }
502                            Err(e) => {
503                                log::debug!(
504                                    "Failed to parse streaming Claude event: {}. Line: {}",
505                                    e,
506                                    crate::truncate_str(&line, 200)
507                                );
508                            }
509                        }
510                    }
511                }
512
513                // Signal end of streaming to handler
514                if let Some(ref handler) = self.event_handler {
515                    // Send a Result event to signal completion
516                    handler(
517                        &crate::output::Event::Result {
518                            success: true,
519                            message: None,
520                            duration_ms: None,
521                            num_turns: None,
522                        },
523                        self.verbose,
524                    );
525                }
526
527                crate::process::wait_with_stderr(child).await?;
528
529                // Return None to indicate output was streamed directly
530                Ok(None)
531            } else {
532                // For json/json-pretty, capture all output then parse
533                cmd.stdin(Stdio::inherit());
534                cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
535
536                let output = cmd.output().await?;
537
538                crate::process::handle_output(&output, "Claude")?;
539
540                // Parse JSON output
541                let json_str = String::from_utf8(output.stdout)?;
542                log::debug!("Parsing Claude JSON output ({} bytes)", json_str.len());
543                let claude_output: models::ClaudeOutput =
544                    serde_json::from_str(&json_str).map_err(|e| {
545                        log::debug!(
546                            "Failed to parse Claude JSON output: {}. First 500 chars: {}",
547                            e,
548                            crate::truncate_str(&json_str, 500)
549                        );
550                        anyhow::anyhow!("Failed to parse Claude JSON output: {e}")
551                    })?;
552                log::debug!("Parsed {} Claude events successfully", claude_output.len());
553
554                // Log any unknown event types for diagnostics.
555                if let Ok(raw_events) = serde_json::from_str::<Vec<serde_json::Value>>(&json_str) {
556                    let known = ["system", "assistant", "user", "result"];
557                    for raw in &raw_events {
558                        if let Some(t) = raw.get("type").and_then(|v| v.as_str()) {
559                            if !known.contains(&t) {
560                                log::debug!(
561                                    "Unknown Claude event type: {:?} (first 300 chars: {})",
562                                    t,
563                                    crate::truncate_str(
564                                        &serde_json::to_string(raw).unwrap_or_default(),
565                                        300
566                                    )
567                                );
568                            }
569                        }
570                    }
571                }
572
573                // Convert to unified AgentOutput
574                let agent_output: AgentOutput =
575                    models::claude_output_to_agent_output(claude_output);
576                Ok(Some(agent_output))
577            }
578        } else {
579            // Explicit text mode - inherit stdout, capture stderr
580            cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit());
581
582            crate::process::run_with_captured_stderr(&mut cmd).await?;
583            Ok(None)
584        }
585    }
586}
587
588/// Stateful translator from Claude `stream-json` events to unified
589/// [`crate::output::Event`]s.
590///
591/// Some unified events are synthesized from cross-event state —
592/// specifically [`crate::output::Event::TurnComplete`], which carries
593/// `stop_reason` and `usage` from the *last* assistant message of a turn
594/// and is emitted immediately before the corresponding per-turn
595/// [`crate::output::Event::Result`]. This translator owns that state.
596///
597/// Stateless per-event conversion still goes through
598/// [`convert_claude_event_to_unified`]; the translator is a thin stateful
599/// wrapper on top.
600#[derive(Debug, Default)]
601pub(crate) struct ClaudeEventTranslator {
602    /// `stop_reason` from the most recent `ClaudeEvent::Assistant` in the
603    /// current turn. Consumed when `TurnComplete` is emitted.
604    pending_stop_reason: Option<String>,
605    /// `usage` from the most recent `ClaudeEvent::Assistant`.
606    pending_usage: Option<crate::output::Usage>,
607    /// Zero-based turn index within the session. Incremented after each
608    /// emitted `TurnComplete`.
609    next_turn_index: u32,
610    /// Text from the most recent assistant message, used as fallback when
611    /// `Result.result` is empty.
612    last_assistant_text: Option<String>,
613    /// Maps `tool_use_id` → `tool_name` from assistant messages so that
614    /// subsequent `ToolExecution` events (which only carry the id) can be
615    /// enriched with the correct tool name.
616    tool_name_by_id: std::collections::HashMap<String, String>,
617    /// Usage-limit event synthesized from the current assistant message text.
618    /// Drained into the translator's output list alongside the AssistantMessage.
619    pending_usage_limit: Option<crate::output::Event>,
620}
621
622impl ClaudeEventTranslator {
623    pub(crate) fn new() -> Self {
624        Self::default()
625    }
626
627    /// Translate one Claude event into zero or more unified events.
628    ///
629    /// A `ClaudeEvent::Result` expands into `[TurnComplete, Result]`; all
630    /// other events pass through [`convert_claude_event_to_unified`] and
631    /// yield at most one unified event.
632    pub(crate) fn translate(&mut self, event: &models::ClaudeEvent) -> Vec<crate::output::Event> {
633        use crate::output::{Event as UnifiedEvent, Usage as UnifiedUsage};
634
635        // Observe assistant-side turn state. Every assistant message
636        // within the current turn updates the pending stop_reason / usage;
637        // the final one wins because it is the message that actually ends
638        // the turn (its `stop_reason` will be `end_turn`, `tool_use`,
639        // `max_tokens`, or `stop_sequence`).
640        if let models::ClaudeEvent::Assistant { message, .. } = event {
641            if let Some(reason) = &message.stop_reason {
642                self.pending_stop_reason = Some(reason.clone());
643            }
644            self.pending_usage = Some(UnifiedUsage {
645                input_tokens: message.usage.input_tokens,
646                output_tokens: message.usage.output_tokens,
647                cache_read_tokens: Some(message.usage.cache_read_input_tokens),
648                cache_creation_tokens: Some(message.usage.cache_creation_input_tokens),
649                web_search_requests: message
650                    .usage
651                    .server_tool_use
652                    .as_ref()
653                    .map(|s| s.web_search_requests),
654                web_fetch_requests: message
655                    .usage
656                    .server_tool_use
657                    .as_ref()
658                    .map(|s| s.web_fetch_requests),
659            });
660
661            // Track text for fallback when Result.result is empty.
662            let text_parts: Vec<&str> = message
663                .content
664                .iter()
665                .filter_map(|b| match b {
666                    models::ContentBlock::Text { text } => Some(text.as_str()),
667                    _ => None,
668                })
669                .collect();
670            if !text_parts.is_empty() {
671                self.last_assistant_text = Some(text_parts.join("\n"));
672            }
673
674            // Scan assistant text for upstream usage-limit signals (e.g.
675            // `Claude AI usage limit reached|<epoch>`). When matched, prepend
676            // a `UsageLimitDetected` event so the relay can record the hit
677            // and arm a resume timer.
678            let cfg = crate::usage_limits::UsageLimitConfig::default();
679            for text in &text_parts {
680                if let Some(hit) = usage_limits::detect_text(text, &cfg) {
681                    self.pending_usage_limit = Some(crate::output::Event::UsageLimitDetected {
682                        provider: hit.provider.to_string(),
683                        scope: hit.scope.as_str().to_string(),
684                        reset_at: hit.reset_at.map(|t| t.to_rfc3339()),
685                        raw: Some(hit.raw),
686                    });
687                    break;
688                }
689            }
690
691            // Track tool_use_id → tool_name so ToolExecution events get the
692            // correct name instead of "unknown".
693            for block in &message.content {
694                if let models::ContentBlock::ToolUse { id, name, .. } = block {
695                    self.tool_name_by_id.insert(id.clone(), name.clone());
696                }
697            }
698        }
699
700        let unified = convert_claude_event_to_unified(event);
701        let usage_limit = self.pending_usage_limit.take();
702
703        let mut events = match unified {
704            Some(UnifiedEvent::Result {
705                success,
706                message,
707                duration_ms,
708                num_turns,
709            }) if message.as_deref() == Some("") => {
710                // Empty result — substitute last assistant text if available.
711                let fallback = self.last_assistant_text.take();
712                if fallback.is_some() {
713                    log::debug!(
714                        "Streaming Result.message is empty; using last assistant text as fallback"
715                    );
716                }
717                let result_event = UnifiedEvent::Result {
718                    success,
719                    message: fallback.or(message),
720                    duration_ms,
721                    num_turns,
722                };
723                let turn_complete = UnifiedEvent::TurnComplete {
724                    stop_reason: self.pending_stop_reason.take(),
725                    turn_index: self.next_turn_index,
726                    usage: self.pending_usage.take(),
727                };
728                self.next_turn_index = self.next_turn_index.saturating_add(1);
729                vec![turn_complete, result_event]
730            }
731            Some(UnifiedEvent::Result { .. }) => {
732                let turn_complete = UnifiedEvent::TurnComplete {
733                    stop_reason: self.pending_stop_reason.take(),
734                    turn_index: self.next_turn_index,
735                    usage: self.pending_usage.take(),
736                };
737                self.next_turn_index = self.next_turn_index.saturating_add(1);
738                vec![turn_complete, unified.unwrap()]
739            }
740            Some(UnifiedEvent::ToolExecution {
741                tool_name,
742                tool_id,
743                input,
744                result,
745                parent_tool_use_id,
746            }) => {
747                // Enrich with the real tool name if we tracked it from a
748                // prior assistant message.
749                let resolved_name = self
750                    .tool_name_by_id
751                    .get(&tool_id)
752                    .cloned()
753                    .unwrap_or(tool_name);
754                vec![UnifiedEvent::ToolExecution {
755                    tool_name: resolved_name,
756                    tool_id,
757                    input,
758                    result,
759                    parent_tool_use_id,
760                }]
761            }
762            Some(ev) => vec![ev],
763            None => Vec::new(),
764        };
765
766        if let Some(ul) = usage_limit {
767            events.push(ul);
768        }
769        events
770    }
771}
772
773/// Convert a single Claude event to a unified event format.
774/// Returns None if the event doesn't map to a user-visible unified event.
775///
776/// This is the stateless per-event converter. Callers that need
777/// cross-event synthesis (e.g. [`crate::output::Event::TurnComplete`])
778/// should use [`ClaudeEventTranslator`] instead.
779pub(crate) fn convert_claude_event_to_unified(
780    event: &models::ClaudeEvent,
781) -> Option<crate::output::Event> {
782    use crate::output::{
783        ContentBlock as UnifiedContentBlock, Event as UnifiedEvent, ToolResult,
784        Usage as UnifiedUsage,
785    };
786    use models::ClaudeEvent;
787
788    match event {
789        ClaudeEvent::System {
790            model, tools, cwd, ..
791        } => {
792            let mut metadata = std::collections::HashMap::new();
793            if let Some(cwd_val) = cwd {
794                metadata.insert("cwd".to_string(), serde_json::json!(cwd_val));
795            }
796
797            Some(UnifiedEvent::Init {
798                model: model.clone(),
799                tools: tools.clone(),
800                working_directory: cwd.clone(),
801                metadata,
802            })
803        }
804
805        ClaudeEvent::Assistant {
806            message,
807            parent_tool_use_id,
808            ..
809        } => {
810            // Convert content blocks
811            let content: Vec<UnifiedContentBlock> = message
812                .content
813                .iter()
814                .filter_map(|block| match block {
815                    models::ContentBlock::Text { text } => {
816                        Some(UnifiedContentBlock::Text { text: text.clone() })
817                    }
818                    models::ContentBlock::ToolUse { id, name, input } => {
819                        Some(UnifiedContentBlock::ToolUse {
820                            id: id.clone(),
821                            name: name.clone(),
822                            input: input.clone(),
823                        })
824                    }
825                    models::ContentBlock::Thinking { .. } | models::ContentBlock::Other => None,
826                })
827                .collect();
828
829            // Convert usage
830            let usage = Some(UnifiedUsage {
831                input_tokens: message.usage.input_tokens,
832                output_tokens: message.usage.output_tokens,
833                cache_read_tokens: Some(message.usage.cache_read_input_tokens),
834                cache_creation_tokens: Some(message.usage.cache_creation_input_tokens),
835                web_search_requests: message
836                    .usage
837                    .server_tool_use
838                    .as_ref()
839                    .map(|s| s.web_search_requests),
840                web_fetch_requests: message
841                    .usage
842                    .server_tool_use
843                    .as_ref()
844                    .map(|s| s.web_fetch_requests),
845            });
846
847            Some(UnifiedEvent::AssistantMessage {
848                content,
849                usage,
850                parent_tool_use_id: parent_tool_use_id.clone(),
851            })
852        }
853
854        ClaudeEvent::User {
855            message,
856            tool_use_result,
857            parent_tool_use_id,
858            ..
859        } => {
860            // For streaming, we can't easily look up tool names from previous events
861            // So we'll use "unknown" for the tool name in streaming mode
862            // Find the first tool_result block (skip text and other blocks)
863            let first_tool_result = message.content.iter().find_map(|b| {
864                if let models::UserContentBlock::ToolResult {
865                    tool_use_id,
866                    content,
867                    is_error,
868                } = b
869                {
870                    Some((tool_use_id, content, is_error))
871                } else {
872                    None
873                }
874            });
875
876            if let Some((tool_use_id, content, is_error)) = first_tool_result {
877                let tool_result = ToolResult {
878                    success: !is_error,
879                    output: if !is_error {
880                        Some(content.clone())
881                    } else {
882                        None
883                    },
884                    error: if *is_error {
885                        Some(content.clone())
886                    } else {
887                        None
888                    },
889                    data: tool_use_result.clone(),
890                };
891
892                Some(UnifiedEvent::ToolExecution {
893                    tool_name: "unknown".to_string(),
894                    tool_id: tool_use_id.clone(),
895                    input: serde_json::Value::Null,
896                    result: tool_result,
897                    parent_tool_use_id: parent_tool_use_id.clone(),
898                })
899            } else {
900                // Check for text content (replayed user messages via --replay-user-messages)
901                let text_blocks: Vec<UnifiedContentBlock> = message
902                    .content
903                    .iter()
904                    .filter_map(|b| {
905                        if let models::UserContentBlock::Text { text } = b {
906                            Some(UnifiedContentBlock::Text { text: text.clone() })
907                        } else {
908                            None
909                        }
910                    })
911                    .collect();
912
913                if !text_blocks.is_empty() {
914                    Some(UnifiedEvent::UserMessage {
915                        content: text_blocks,
916                    })
917                } else {
918                    None
919                }
920            }
921        }
922
923        ClaudeEvent::Other => {
924            log::debug!("Skipping unknown Claude event type during streaming conversion");
925            None
926        }
927
928        ClaudeEvent::Result {
929            is_error,
930            result,
931            duration_ms,
932            num_turns,
933            structured_output,
934            ..
935        } => {
936            // When result is empty but structured_output is present
937            // (Claude CLI with --json-schema), use the structured output.
938            let effective_result = if result.is_empty() {
939                if let Some(so) = structured_output {
940                    log::debug!("Streaming Result.result is empty; using structured_output");
941                    serde_json::to_string(so).unwrap_or_default()
942                } else {
943                    result.clone()
944                }
945            } else {
946                result.clone()
947            };
948            Some(UnifiedEvent::Result {
949                success: !is_error,
950                message: Some(effective_result),
951                duration_ms: Some(*duration_ms),
952                num_turns: Some(*num_turns),
953            })
954        }
955    }
956}
957
958#[cfg(test)]
959#[path = "claude_tests.rs"]
960mod tests;
961
962impl Default for Claude {
963    fn default() -> Self {
964        Self::new()
965    }
966}
967
968#[async_trait]
969impl Agent for Claude {
970    fn name(&self) -> &str {
971        "claude"
972    }
973
974    fn default_model() -> &'static str {
975        DEFAULT_MODEL
976    }
977
978    fn model_for_size(size: ModelSize) -> &'static str {
979        match size {
980            ModelSize::Small => "haiku",
981            ModelSize::Medium => "sonnet",
982            ModelSize::Large => "default",
983        }
984    }
985
986    fn available_models() -> &'static [&'static str] {
987        AVAILABLE_MODELS
988    }
989
990    crate::providers::common::impl_common_agent_setters!();
991
992    fn set_skip_permissions(&mut self, skip: bool) {
993        self.common.skip_permissions = skip;
994    }
995
996    crate::providers::common::impl_as_any!();
997
998    async fn run(&self, prompt: Option<&str>) -> Result<Option<AgentOutput>> {
999        self.execute(false, prompt).await
1000    }
1001
1002    async fn run_interactive(&self, prompt: Option<&str>) -> Result<()> {
1003        self.execute(true, prompt).await?;
1004        Ok(())
1005    }
1006
1007    async fn run_resume(&self, session_id: Option<&str>, _last: bool) -> Result<()> {
1008        let agent_args = self.build_resume_args(session_id);
1009        let mut cmd = self.make_command(agent_args);
1010
1011        cmd.stdin(Stdio::inherit())
1012            .stdout(Stdio::inherit())
1013            .stderr(Stdio::inherit());
1014
1015        // Spawn / notify / wait so `on_spawn_hook` can capture the pid
1016        // of the resumed interactive session before we block.
1017        let mut child = cmd
1018            .spawn()
1019            .context("Failed to execute 'claude' CLI. Is it installed and in PATH?")?;
1020        self.common.notify_spawn(&child);
1021        let status = child
1022            .wait()
1023            .await
1024            .context("Failed waiting on 'claude' CLI")?;
1025        if !status.success() {
1026            return Err(crate::process::ProcessError {
1027                exit_code: status.code(),
1028                stderr: String::new(),
1029                agent_name: "Claude".to_string(),
1030            }
1031            .into());
1032        }
1033        Ok(())
1034    }
1035
1036    async fn run_resume_with_prompt(
1037        &self,
1038        session_id: &str,
1039        prompt: &str,
1040    ) -> Result<Option<AgentOutput>> {
1041        check_print_allowed()?;
1042        log::debug!("Claude resume with prompt: session={session_id}, prompt={prompt}");
1043        let in_sandbox = self.common.sandbox.is_some();
1044        let mut args = vec!["--print".to_string()];
1045        args.extend(["--resume".to_string(), session_id.to_string()]);
1046        args.extend(["--verbose", "--output-format", "json"].map(String::from));
1047
1048        if self.common.skip_permissions && !in_sandbox {
1049            args.push("--dangerously-skip-permissions".to_string());
1050        }
1051
1052        args.extend(["--model".to_string(), self.common.model.clone()]);
1053
1054        for dir in &self.common.add_dirs {
1055            args.extend(["--add-dir".to_string(), dir.clone()]);
1056        }
1057
1058        if let Some(ref schema) = self.json_schema {
1059            args.extend(["--json-schema".to_string(), schema.clone()]);
1060        }
1061
1062        args.push("--".to_string());
1063        args.push(prompt.to_string());
1064
1065        let mut cmd = self.make_command(args);
1066
1067        cmd.stdin(Stdio::inherit());
1068        cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
1069
1070        let output = cmd.output().await?;
1071
1072        crate::process::handle_output(&output, "Claude")?;
1073
1074        // Parse JSON output
1075        let json_str = String::from_utf8(output.stdout)?;
1076        log::debug!(
1077            "Parsing Claude resume JSON output ({} bytes)",
1078            json_str.len()
1079        );
1080        let claude_output: models::ClaudeOutput = serde_json::from_str(&json_str)
1081            .map_err(|e| anyhow::anyhow!("Failed to parse Claude resume JSON output: {e}"))?;
1082
1083        let agent_output: AgentOutput = models::claude_output_to_agent_output(claude_output);
1084        Ok(Some(agent_output))
1085    }
1086
1087    async fn cleanup(&self) -> Result<()> {
1088        Ok(())
1089    }
1090}