Skip to main content

zag_agent/providers/claude/
logs.rs

1use crate::session_log::{
2    BackfilledSession, HistoricalLogAdapter, LiveLogAdapter, LiveLogContext, LogCompleteness,
3    LogEventKind, LogSourceKind, SessionLogMetadata, SessionLogWriter, ToolKind,
4};
5
6/// Classify a Claude Code built-in tool name into a normalized ToolKind.
7fn tool_kind_from_name(name: &str) -> ToolKind {
8    match name {
9        "Bash" => ToolKind::Shell,
10        "Read" => ToolKind::FileRead,
11        "Write" => ToolKind::FileWrite,
12        "Edit" => ToolKind::FileEdit,
13        "Glob" | "Grep" => ToolKind::Search,
14        "Agent" => ToolKind::SubAgent,
15        "WebFetch" | "WebSearch" => ToolKind::Web,
16        "NotebookEdit" => ToolKind::Notebook,
17        _ => ToolKind::Other,
18    }
19}
20use anyhow::{Context, Result};
21use async_trait::async_trait;
22use log::info;
23use serde_json::Value;
24use std::collections::HashSet;
25use std::fs::File;
26use std::io::{BufRead, BufReader, Seek, SeekFrom};
27use std::path::{Path, PathBuf};
28
29pub struct ClaudeLiveLogAdapter {
30    ctx: LiveLogContext,
31    session_path: Option<PathBuf>,
32    offset: u64,
33    seen_keys: HashSet<String>,
34    /// Track the current provider session ID so we can detect when it changes.
35    current_provider_session_id: Option<String>,
36}
37
38pub struct ClaudeHistoricalLogAdapter;
39
40impl ClaudeLiveLogAdapter {
41    pub fn new(ctx: LiveLogContext) -> Self {
42        let current_provider_session_id = ctx.provider_session_id.clone();
43        Self {
44            ctx,
45            session_path: None,
46            offset: 0,
47            seen_keys: HashSet::new(),
48            current_provider_session_id,
49        }
50    }
51
52    /// Check if a newer session file has appeared for the same workspace.
53    /// Only called when `is_worktree` is true, since the unique workspace path
54    /// makes detection reliable.
55    fn detect_newer_session(&self) -> Option<PathBuf> {
56        let current_path = self.session_path.as_ref()?;
57        let current_modified = std::fs::metadata(current_path).ok()?.modified().ok()?;
58        let workspace = self.ctx.workspace_path.as_deref()?;
59        let projects_dir = claude_projects_dir()?;
60
61        let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
62        let entries = std::fs::read_dir(projects_dir).ok()?;
63        for project in entries.flatten() {
64            let files = match std::fs::read_dir(project.path()) {
65                Ok(files) => files,
66                Err(_) => continue,
67            };
68            for file in files.flatten() {
69                let path = file.path();
70                if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
71                    continue;
72                }
73                // Skip the file we're already tailing
74                if path == *current_path {
75                    continue;
76                }
77                let metadata = match file.metadata() {
78                    Ok(m) => m,
79                    Err(_) => continue,
80                };
81                let modified = match metadata.modified() {
82                    Ok(m) => m,
83                    Err(_) => continue,
84                };
85                // Only consider files newer than the current session file
86                if modified <= current_modified {
87                    continue;
88                }
89                if !file_contains_workspace(&path, workspace) {
90                    continue;
91                }
92                if best
93                    .as_ref()
94                    .map(|(current, _)| modified > *current)
95                    .unwrap_or(true)
96                {
97                    best = Some((modified, path));
98                }
99            }
100        }
101
102        best.map(|(_, path)| path)
103    }
104
105    fn discover_session_path(&self) -> Option<PathBuf> {
106        let projects_dir = claude_projects_dir()?;
107        if let Some(session_id) = &self.ctx.provider_session_id {
108            if let Ok(projects) = std::fs::read_dir(&projects_dir) {
109                for project in projects.flatten() {
110                    let candidate = project.path().join(format!("{}.jsonl", session_id));
111                    if candidate.exists() {
112                        return Some(candidate);
113                    }
114                }
115            }
116        }
117
118        let workspace = self.ctx.workspace_path.as_deref();
119        let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
120        if let Ok(projects) = std::fs::read_dir(projects_dir) {
121            for project in projects.flatten() {
122                let files = match std::fs::read_dir(project.path()) {
123                    Ok(files) => files,
124                    Err(_) => continue,
125                };
126                for file in files.flatten() {
127                    let path = file.path();
128                    if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
129                        continue;
130                    }
131                    let metadata = match file.metadata() {
132                        Ok(metadata) => metadata,
133                        Err(_) => continue,
134                    };
135                    let modified = match metadata.modified() {
136                        Ok(modified) => modified,
137                        Err(_) => continue,
138                    };
139                    let started_at = system_time_from_utc(self.ctx.started_at);
140                    if modified < started_at {
141                        continue;
142                    }
143                    if let Some(workspace) = workspace
144                        && !file_contains_workspace(&path, workspace)
145                    {
146                        continue;
147                    }
148                    if best
149                        .as_ref()
150                        .map(|(current, _)| modified > *current)
151                        .unwrap_or(true)
152                    {
153                        best = Some((modified, path));
154                    }
155                }
156            }
157        }
158
159        best.map(|(_, path)| path)
160    }
161}
162
163#[async_trait]
164impl LiveLogAdapter for ClaudeLiveLogAdapter {
165    async fn poll(&mut self, writer: &SessionLogWriter) -> Result<()> {
166        if self.session_path.is_none() {
167            self.session_path = self.discover_session_path();
168            if let Some(path) = &self.session_path {
169                writer.add_source_path(path.to_string_lossy().to_string())?;
170            }
171        }
172
173        // In worktree mode, check if a newer session file appeared (session clear/restart)
174        if self.ctx.is_worktree
175            && self.session_path.is_some()
176            && let Some(newer_path) = self.detect_newer_session()
177        {
178            let old_session_id = self.current_provider_session_id.clone();
179            log::info!(
180                "Session clear detected: new file {} (old: {})",
181                newer_path.display(),
182                self.session_path
183                    .as_ref()
184                    .map(|p| p.display().to_string())
185                    .unwrap_or_default()
186            );
187
188            // Reset state for the new session file
189            self.session_path = Some(newer_path.clone());
190            self.offset = 0;
191            self.seen_keys.clear();
192            self.current_provider_session_id = None;
193
194            writer.add_source_path(newer_path.to_string_lossy().to_string())?;
195            writer.emit(
196                LogSourceKind::ProviderFile,
197                LogEventKind::SessionCleared {
198                    old_session_id,
199                    new_session_id: None, // will be discovered when we read the new file
200                },
201            )?;
202        }
203
204        let Some(path) = self.session_path.as_ref() else {
205            return Ok(());
206        };
207
208        let mut file =
209            File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
210        file.seek(SeekFrom::Start(self.offset))?;
211        let mut reader = BufReader::new(file);
212        let mut buf = String::new();
213
214        loop {
215            buf.clear();
216            let bytes = reader.read_line(&mut buf)?;
217            if bytes == 0 {
218                break;
219            }
220            self.offset += bytes as u64;
221            let trimmed = buf.trim();
222            if trimmed.is_empty() {
223                continue;
224            }
225            let value: Value = match serde_json::from_str(trimmed) {
226                Ok(value) => value,
227                Err(_) => {
228                    writer.emit(
229                        LogSourceKind::ProviderFile,
230                        LogEventKind::ParseWarning {
231                            message: "Failed to parse Claude session line".to_string(),
232                            raw: Some(trimmed.to_string()),
233                        },
234                    )?;
235                    continue;
236                }
237            };
238            for event in parse_claude_value(&value, &mut self.seen_keys) {
239                writer.emit(LogSourceKind::ProviderFile, event)?;
240            }
241            if let Some(session_id) = value
242                .get("sessionId")
243                .or_else(|| value.get("session_id"))
244                .and_then(|value| value.as_str())
245            {
246                self.current_provider_session_id = Some(session_id.to_string());
247                writer.set_provider_session_id(Some(session_id.to_string()))?;
248            }
249        }
250
251        Ok(())
252    }
253}
254
255impl HistoricalLogAdapter for ClaudeHistoricalLogAdapter {
256    fn backfill(&self, _root: Option<&str>) -> Result<Vec<BackfilledSession>> {
257        let mut sessions = Vec::new();
258        let Some(projects_dir) = claude_projects_dir() else {
259            return Ok(sessions);
260        };
261
262        let projects = match std::fs::read_dir(projects_dir) {
263            Ok(projects) => projects,
264            Err(_) => return Ok(sessions),
265        };
266
267        for project in projects.flatten() {
268            let files = match std::fs::read_dir(project.path()) {
269                Ok(files) => files,
270                Err(_) => continue,
271            };
272            for file in files.flatten() {
273                let path = file.path();
274                if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
275                    continue;
276                }
277                info!("Scanning Claude history: {}", path.display());
278                if let Some(session) = backfill_session(&path)? {
279                    sessions.push(session);
280                }
281            }
282        }
283
284        Ok(sessions)
285    }
286}
287
288fn backfill_session(path: &Path) -> Result<Option<BackfilledSession>> {
289    let file = File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
290    let reader = BufReader::new(file);
291    let mut seen = HashSet::new();
292    let mut events = Vec::new();
293    let mut provider_session_id = None;
294    let mut workspace_path = None;
295
296    for line in reader.lines() {
297        let line = line?;
298        if line.trim().is_empty() {
299            continue;
300        }
301        let value: Value = match serde_json::from_str(&line) {
302            Ok(value) => value,
303            Err(_) => continue,
304        };
305        if provider_session_id.is_none() {
306            provider_session_id = value
307                .get("sessionId")
308                .or_else(|| value.get("session_id"))
309                .and_then(|value| value.as_str())
310                .map(str::to_string);
311        }
312        if workspace_path.is_none() {
313            workspace_path = value
314                .get("cwd")
315                .and_then(|value| value.as_str())
316                .map(str::to_string);
317        }
318        for event in parse_claude_value(&value, &mut seen) {
319            events.push((LogSourceKind::Backfill, event));
320        }
321    }
322
323    let Some(provider_session_id) = provider_session_id else {
324        return Ok(None);
325    };
326
327    Ok(Some(BackfilledSession {
328        metadata: SessionLogMetadata {
329            provider: "claude".to_string(),
330            wrapper_session_id: provider_session_id.clone(),
331            provider_session_id: Some(provider_session_id),
332            workspace_path,
333            command: "backfill".to_string(),
334            model: None,
335            resumed: false,
336            backfilled: true,
337        },
338        completeness: LogCompleteness::Full,
339        source_paths: vec![path.to_string_lossy().to_string()],
340        events,
341    }))
342}
343
344fn parse_claude_value(value: &Value, seen_keys: &mut HashSet<String>) -> Vec<LogEventKind> {
345    let mut events = Vec::new();
346    let Some(key) = event_key(value) else {
347        return events;
348    };
349    if !seen_keys.insert(key) {
350        return events;
351    }
352
353    match value.get("type").and_then(|value| value.as_str()) {
354        Some("user") => {
355            if let Some(content) = value
356                .get("message")
357                .and_then(|message| message.get("content"))
358                .and_then(|content| content.as_str())
359            {
360                events.push(LogEventKind::UserMessage {
361                    role: "user".to_string(),
362                    content: content.to_string(),
363                    message_id: value
364                        .get("uuid")
365                        .and_then(|uuid| uuid.as_str())
366                        .map(str::to_string),
367                });
368            } else if let Some(content) = value
369                .get("message")
370                .and_then(|message| message.get("content"))
371                .and_then(|content| content.as_array())
372            {
373                for block in content {
374                    if block.get("type").and_then(|value| value.as_str()) == Some("tool_result") {
375                        events.push(LogEventKind::ToolResult {
376                            tool_name: None,
377                            tool_kind: None, // tool_use_id could be correlated, but name isn't in result
378                            tool_id: block
379                                .get("tool_use_id")
380                                .and_then(|value| value.as_str())
381                                .map(str::to_string),
382                            success: block
383                                .get("is_error")
384                                .and_then(|value| value.as_bool())
385                                .map(|is_error| !is_error),
386                            output: block
387                                .get("content")
388                                .and_then(|value| value.as_str())
389                                .map(str::to_string),
390                            error: None,
391                            data: value.get("tool_use_result").cloned(),
392                        });
393                    }
394                }
395            }
396        }
397        Some("assistant") => {
398            if let Some(content) = value
399                .get("message")
400                .and_then(|message| message.get("content"))
401                .and_then(|content| content.as_array())
402            {
403                let message_id = value
404                    .get("message")
405                    .and_then(|message| message.get("id"))
406                    .and_then(|id| id.as_str())
407                    .map(str::to_string);
408                for block in content {
409                    match block.get("type").and_then(|value| value.as_str()) {
410                        Some("text") => events.push(LogEventKind::AssistantMessage {
411                            content: block
412                                .get("text")
413                                .and_then(|value| value.as_str())
414                                .unwrap_or_default()
415                                .to_string(),
416                            message_id: message_id.clone(),
417                        }),
418                        Some("thinking") => events.push(LogEventKind::Reasoning {
419                            content: block
420                                .get("thinking")
421                                .and_then(|value| value.as_str())
422                                .unwrap_or_default()
423                                .to_string(),
424                            message_id: message_id.clone(),
425                        }),
426                        Some("tool_use") => {
427                            let name = block
428                                .get("name")
429                                .and_then(|value| value.as_str())
430                                .unwrap_or("unknown");
431                            events.push(LogEventKind::ToolCall {
432                                tool_kind: Some(tool_kind_from_name(name)),
433                                tool_name: name.to_string(),
434                                tool_id: block
435                                    .get("id")
436                                    .and_then(|value| value.as_str())
437                                    .map(str::to_string),
438                                input: block.get("input").cloned(),
439                            });
440                        }
441                        _ => {}
442                    }
443                }
444            }
445        }
446        Some("system") => {
447            events.push(LogEventKind::ProviderStatus {
448                message: "Claude system event".to_string(),
449                data: Some(value.clone()),
450            });
451        }
452        Some("result") => {
453            if let Some(denials) = value
454                .get("permission_denials")
455                .and_then(|value| value.as_array())
456            {
457                for denial in denials {
458                    events.push(LogEventKind::Permission {
459                        tool_name: denial
460                            .get("tool_name")
461                            .and_then(|value| value.as_str())
462                            .unwrap_or("unknown")
463                            .to_string(),
464                        description: serde_json::to_string(
465                            denial.get("tool_input").unwrap_or(&Value::Null),
466                        )
467                        .unwrap_or_default(),
468                        granted: false,
469                    });
470                }
471            }
472            events.push(LogEventKind::ProviderStatus {
473                message: value
474                    .get("result")
475                    .and_then(|result| result.as_str())
476                    .unwrap_or("Claude result")
477                    .to_string(),
478                data: Some(value.clone()),
479            });
480        }
481        Some("queue-operation") | Some("last-prompt") => {
482            events.push(LogEventKind::ProviderStatus {
483                message: value
484                    .get("type")
485                    .and_then(|value| value.as_str())
486                    .unwrap_or("claude_event")
487                    .to_string(),
488                data: Some(value.clone()),
489            });
490        }
491        _ => {}
492    }
493
494    events
495}
496
497fn event_key(value: &Value) -> Option<String> {
498    value
499        .get("uuid")
500        .and_then(|uuid| uuid.as_str())
501        .map(str::to_string)
502        .or_else(|| {
503            Some(format!(
504                "{}:{}:{}",
505                value
506                    .get("timestamp")
507                    .and_then(|value| value.as_str())
508                    .unwrap_or(""),
509                value
510                    .get("type")
511                    .and_then(|value| value.as_str())
512                    .unwrap_or(""),
513                value
514                    .get("sessionId")
515                    .or_else(|| value.get("session_id"))
516                    .and_then(|value| value.as_str())
517                    .unwrap_or("")
518            ))
519        })
520}
521
522fn claude_projects_dir() -> Option<PathBuf> {
523    super::projects_dir()
524}
525
526fn file_contains_workspace(path: &Path, workspace: &str) -> bool {
527    let Ok(file) = File::open(path) else {
528        return false;
529    };
530    let reader = BufReader::new(file);
531    reader
532        .lines()
533        .take(8)
534        .flatten()
535        .any(|line| line.contains(workspace))
536}
537
538fn system_time_from_utc(ts: chrono::DateTime<chrono::Utc>) -> std::time::SystemTime {
539    std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(ts.timestamp().max(0) as u64)
540}
541
542#[cfg(test)]
543#[path = "logs_tests.rs"]
544mod tests;