zag_agent/providers/claude/
logs.rs1use crate::session_log::{
2 BackfilledSession, HistoricalLogAdapter, LiveLogAdapter, LiveLogContext, LogCompleteness,
3 LogEventKind, LogSourceKind, SessionLogMetadata, SessionLogWriter, ToolKind,
4};
5
6fn 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 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 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 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 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 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 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, },
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_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;