agentmux_srv\backend\history/
claude_adapter.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Claude Code history adapter.
5//! Scans for session JSONL files across both the user's global Claude homes and
6//! the AgentMux-isolated homes that current agents actually write to:
7//!   - ~/.claude/projects/ and ~/.config/claude-*/projects/ (global / legacy)
8//!   - <AGENTMUX_SHARED_DIR>/providers/claude/projects/ (default isolated home)
9//!   - <AGENTMUX_SHARED_DIR>/identities/<bundle_id>/claude/projects/ (per-identity)
10
11use std::fs;
12use std::io::{BufRead, BufReader};
13use std::path::{Path, PathBuf};
14use std::time::UNIX_EPOCH;
15
16use super::adapter::*;
17
18pub struct ClaudeHistoryAdapter {
19    /// All base directories to scan for project folders.
20    base_dirs: Vec<PathBuf>,
21}
22
23impl ClaudeHistoryAdapter {
24    pub fn new() -> Self {
25        let mut base_dirs = Vec::new();
26
27        if let Some(home) = dirs::home_dir() {
28            // User's personal (non-isolated) Claude sessions
29            let personal = home.join(".claude").join("projects");
30            if personal.is_dir() {
31                base_dirs.push(personal);
32            }
33
34            // Legacy multi-account convention: ~/.config/claude-*/projects/
35            let config_dir = home.join(".config");
36            if config_dir.is_dir() {
37                if let Ok(entries) = fs::read_dir(&config_dir) {
38                    for entry in entries.flatten() {
39                        let name = entry.file_name();
40                        let name_str = name.to_string_lossy();
41                        if name_str.starts_with("claude-") {
42                            let projects = entry.path().join("projects");
43                            if projects.is_dir() {
44                                base_dirs.push(projects);
45                            }
46                        }
47                    }
48                }
49            }
50        }
51
52        // AgentMux-ISOLATED Claude homes — where agents spawned by current builds
53        // actually write (AgentMux sets CLAUDE_CONFIG_DIR here at spawn time).
54        // Without these, the history browse misses every AgentMux agent
55        // conversation. See docs/specs/SPEC_UNIFIED_AGENT_HISTORY_STORE_2026-06-10.md.
56        //   <shared>/providers/claude/projects/              (default, account-wide)
57        //   <shared>/identities/<bundle_id>/claude/projects/ (per-identity bundles)
58        // `AGENTMUX_SHARED_DIR` is exported by the launcher; fall back to
59        // ~/.agentmux/shared so discovery still works in plain/test contexts.
60        let shared_dir = std::env::var_os("AGENTMUX_SHARED_DIR")
61            .map(PathBuf::from)
62            .or_else(|| dirs::home_dir().map(|h| h.join(".agentmux").join("shared")));
63        if let Some(shared) = shared_dir {
64            let default_projects = shared.join("providers").join("claude").join("projects");
65            if default_projects.is_dir() {
66                base_dirs.push(default_projects);
67            }
68            if let Ok(entries) = fs::read_dir(shared.join("identities")) {
69                for entry in entries.flatten() {
70                    let projects = entry.path().join("claude").join("projects");
71                    if projects.is_dir() {
72                        base_dirs.push(projects);
73                    }
74                }
75            }
76        }
77
78        ClaudeHistoryAdapter { base_dirs }
79    }
80
81    /// Count subagent JSONL files in a session's subagents/ directory.
82    fn count_subagents(session_dir: &Path) -> u32 {
83        let subagents_dir = session_dir.join("subagents");
84        if !subagents_dir.is_dir() {
85            return 0;
86        }
87        fs::read_dir(&subagents_dir)
88            .map(|entries| {
89                entries
90                    .flatten()
91                    .filter(|e| {
92                        let name = e.file_name();
93                        let s = name.to_string_lossy();
94                        s.starts_with("agent-") && s.ends_with(".jsonl")
95                    })
96                    .count() as u32
97            })
98            .unwrap_or(0)
99    }
100
101    /// Decode a project directory name back to a path.
102    /// e.g., "C--Users-asafe--claw-agentx-workspace" → "C:/Users/asafe/.claw/agentx-workspace"
103    /// This is lossy — real hyphens are indistinguishable from path separators.
104    fn decode_project_path(encoded: &str) -> String {
105        // Best-effort: replace leading drive pattern and path separators
106        let mut result = encoded.to_string();
107        // Restore drive letter colon: "C-" at start → "C:"
108        if result.len() >= 2 && result.as_bytes()[1] == b'-' && result.as_bytes()[0].is_ascii_uppercase() {
109            result = format!("{}:{}", &result[..1], &result[2..]);
110        }
111        // Replace remaining hyphens with forward slashes
112        result = result.replace('-', "/");
113        result
114    }
115}
116
117impl HistoryAdapter for ClaudeHistoryAdapter {
118    fn provider(&self) -> &str {
119        "claude"
120    }
121
122    fn discover_files(&self) -> Result<Vec<DiscoveredFile>, HistoryError> {
123        let mut files = Vec::new();
124
125        for base_dir in &self.base_dirs {
126            let entries = match fs::read_dir(base_dir) {
127                Ok(e) => e,
128                Err(_) => continue,
129            };
130
131            for project_entry in entries.flatten() {
132                let project_path = project_entry.path();
133                if !project_path.is_dir() {
134                    // Top-level .jsonl files (session files at project root level)
135                    if project_path.extension().map_or(false, |e| e == "jsonl") {
136                        if let Ok(meta) = project_path.metadata() {
137                            let mtime = meta
138                                .modified()
139                                .ok()
140                                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
141                                .map(|d| d.as_millis() as i64)
142                                .unwrap_or(0);
143                            files.push(DiscoveredFile {
144                                file_path: project_path.to_string_lossy().into(),
145                                mtime_ms: mtime,
146                            });
147                        }
148                    }
149                    continue;
150                }
151
152                // Scan for .jsonl files inside project directories
153                // These are session directories that may also contain subagents/
154                let dir_entries = match fs::read_dir(&project_path) {
155                    Ok(e) => e,
156                    Err(_) => continue,
157                };
158                for file_entry in dir_entries.flatten() {
159                    let file_path = file_entry.path();
160                    if file_path.extension().map_or(false, |e| e == "jsonl") {
161                        // Skip subagent files — those are children of sessions
162                        if file_path
163                            .parent()
164                            .and_then(|p| p.file_name())
165                            .map_or(false, |n| n == "subagents")
166                        {
167                            continue;
168                        }
169                        if let Ok(meta) = file_path.metadata() {
170                            let mtime = meta
171                                .modified()
172                                .ok()
173                                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
174                                .map(|d| d.as_millis() as i64)
175                                .unwrap_or(0);
176                            files.push(DiscoveredFile {
177                                file_path: file_path.to_string_lossy().into(),
178                                mtime_ms: mtime,
179                            });
180                        }
181                    }
182                }
183            }
184        }
185
186        files.sort_by(|a, b| b.mtime_ms.cmp(&a.mtime_ms));
187        Ok(files)
188    }
189
190    fn extract_meta(&self, file_path: &str) -> Result<Option<SessionMeta>, HistoryError> {
191        let path = Path::new(file_path);
192        let file = fs::File::open(path)?;
193        let file_size = file.metadata()?.len();
194        let reader = BufReader::new(file);
195
196        let mut first_user_msg = String::new();
197        let mut model = "unknown".to_string();
198        let mut slug = String::new();
199        let mut cwd = String::new();
200        let mut git_branch = String::new();
201        let mut entry_count = 0u32;
202        let mut total_tokens: u64 = 0;
203        let mut first_timestamp: i64 = 0;
204        let mut last_timestamp: i64 = 0;
205        let mut session_id = String::new();
206
207        // Extract session_id from filename (stem)
208        if let Some(stem) = path.file_stem() {
209            session_id = stem.to_string_lossy().into();
210        }
211
212        let mut lines_iter = reader.lines();
213        let mut found_all_meta = false;
214
215        while let Some(Ok(line)) = lines_iter.next() {
216            if line.trim().is_empty() {
217                continue;
218            }
219
220            let entry: serde_json::Value = match serde_json::from_str(&line) {
221                Ok(v) => v,
222                Err(_) => continue,
223            };
224            entry_count += 1;
225
226            // Extract timestamp
227            if let Some(ts_str) = entry.get("timestamp").and_then(|v| v.as_str()) {
228                if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts_str) {
229                    let ts = dt.timestamp_millis();
230                    if first_timestamp == 0 {
231                        first_timestamp = ts;
232                    }
233                    last_timestamp = ts;
234                }
235            }
236
237            // Extract session slug
238            if slug.is_empty() {
239                if let Some(s) = entry.get("slug").and_then(|v| v.as_str()) {
240                    slug = s.to_string();
241                }
242            }
243
244            // Extract session ID from entry if available
245            if session_id.is_empty() {
246                if let Some(s) = entry.get("sessionId").and_then(|v| v.as_str()) {
247                    session_id = s.to_string();
248                }
249            }
250
251            // Extract cwd
252            if cwd.is_empty() {
253                if let Some(c) = entry.get("cwd").and_then(|v| v.as_str()) {
254                    cwd = c.to_string();
255                }
256            }
257
258            // Extract git branch
259            if git_branch.is_empty() {
260                if let Some(b) = entry.get("gitBranch").and_then(|v| v.as_str()) {
261                    git_branch = b.to_string();
262                }
263            }
264
265            let entry_type = entry.get("type").and_then(|v| v.as_str()).unwrap_or("");
266
267            // Extract model from first assistant entry
268            if model == "unknown" && entry_type == "assistant" {
269                if let Some(m) = entry.pointer("/message/model").and_then(|v| v.as_str()) {
270                    model = m.to_string();
271                }
272                // Accumulate tokens
273                if let Some(usage) = entry.pointer("/message/usage") {
274                    if let Some(out) = usage.get("output_tokens").and_then(|v| v.as_u64()) {
275                        total_tokens += out;
276                    }
277                }
278            } else if entry_type == "assistant" {
279                // Still accumulate tokens for non-first assistant entries
280                if let Some(usage) = entry.pointer("/message/usage") {
281                    if let Some(out) = usage.get("output_tokens").and_then(|v| v.as_u64()) {
282                        total_tokens += out;
283                    }
284                }
285            }
286
287            // Extract first user message for preview
288            if first_user_msg.is_empty() && entry_type == "user" {
289                if let Some(content) = entry.pointer("/message/content") {
290                    if let Some(text) = content.as_str() {
291                        first_user_msg = text.chars().take(200).collect();
292                    } else if let Some(arr) = content.as_array() {
293                        // Content can be an array of content blocks
294                        for block in arr {
295                            if block.get("type").and_then(|v| v.as_str()) == Some("text") {
296                                if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
297                                    first_user_msg = text.chars().take(200).collect();
298                                    break;
299                                }
300                            }
301                        }
302                    }
303                }
304            }
305
306            // Early exit: once we have all metadata fields, count remaining lines cheaply
307            if !first_user_msg.is_empty()
308                && model != "unknown"
309                && !cwd.is_empty()
310                && !slug.is_empty()
311            {
312                found_all_meta = true;
313                break;
314            }
315        }
316
317        // Count remaining lines without parsing JSON (fast)
318        if found_all_meta {
319            for remaining_line in lines_iter {
320                if let Ok(line) = remaining_line {
321                    if !line.trim().is_empty() {
322                        entry_count += 1;
323                    }
324                }
325            }
326        }
327
328        if entry_count == 0 {
329            return Ok(None);
330        }
331
332        // Fallback: decode project path from parent directory name
333        if cwd.is_empty() {
334            if let Some(parent_name) = path
335                .parent()
336                .and_then(|p| p.file_name())
337                .map(|n| n.to_string_lossy().to_string())
338            {
339                cwd = Self::decode_project_path(&parent_name);
340            }
341        }
342
343        // Count subagents
344        let subagent_count = if let Some(parent) = path.parent() {
345            let session_dir = parent.join(&session_id);
346            Self::count_subagents(&session_dir)
347        } else {
348            0
349        };
350
351        let file_meta = fs::metadata(file_path)?;
352        let modified_at = file_meta
353            .modified()
354            .ok()
355            .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
356            .map(|d| d.as_millis() as i64)
357            .unwrap_or(last_timestamp);
358
359        Ok(Some(SessionMeta {
360            session_id,
361            file_path: file_path.to_string(),
362            provider: "claude".to_string(),
363            model,
364            slug,
365            working_directory: cwd,
366            created_at: first_timestamp,
367            modified_at,
368            message_count: entry_count,
369            first_user_message: first_user_msg,
370            file_size_bytes: file_size,
371            git_branch,
372            total_tokens,
373            subagent_count,
374        }))
375    }
376
377    fn parse_file(&self, file_path: &str) -> Result<Option<HistorySession>, HistoryError> {
378        // First extract meta
379        let meta = match self.extract_meta(file_path)? {
380            Some(m) => m,
381            None => return Ok(None),
382        };
383
384        let file = fs::File::open(file_path)?;
385        let reader = BufReader::new(file);
386        let mut messages = Vec::new();
387
388        for line in reader.lines() {
389            let line = match line {
390                Ok(l) => l,
391                Err(_) => continue,
392            };
393            if line.trim().is_empty() {
394                continue;
395            }
396
397            let entry: serde_json::Value = match serde_json::from_str(&line) {
398                Ok(v) => v,
399                Err(_) => continue,
400            };
401
402            let entry_type = entry.get("type").and_then(|v| v.as_str()).unwrap_or("");
403
404            // Extract timestamp
405            let timestamp = entry
406                .get("timestamp")
407                .and_then(|v| v.as_str())
408                .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
409                .map(|dt| dt.timestamp_millis())
410                .unwrap_or(0);
411
412            if entry_type == "user" {
413                let content = if let Some(msg) = entry.pointer("/message/content") {
414                    if let Some(text) = msg.as_str() {
415                        text.to_string()
416                    } else if let Some(arr) = msg.as_array() {
417                        arr.iter()
418                            .filter_map(|block| {
419                                if block.get("type").and_then(|v| v.as_str()) == Some("text") {
420                                    block.get("text").and_then(|v| v.as_str()).map(String::from)
421                                } else {
422                                    None
423                                }
424                            })
425                            .collect::<Vec<_>>()
426                            .join("\n")
427                    } else {
428                        String::new()
429                    }
430                } else {
431                    String::new()
432                };
433
434                if !content.is_empty() {
435                    messages.push(HistoryMessage {
436                        role: "user".to_string(),
437                        content,
438                        timestamp,
439                        tool_uses: vec![],
440                    });
441                }
442            } else if entry_type == "assistant" {
443                let mut text_parts = Vec::new();
444                let mut tool_uses = Vec::new();
445
446                if let Some(content_arr) = entry.pointer("/message/content").and_then(|v| v.as_array()) {
447                    for block in content_arr {
448                        let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
449                        match block_type {
450                            "text" => {
451                                if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
452                                    text_parts.push(text.to_string());
453                                }
454                            }
455                            "tool_use" => {
456                                let name = block
457                                    .get("name")
458                                    .and_then(|v| v.as_str())
459                                    .unwrap_or("unknown")
460                                    .to_string();
461                                // Summarize first argument
462                                let arg_summary = if let Some(input) = block.get("input") {
463                                    if let Some(obj) = input.as_object() {
464                                        // Take first key-value pair as summary
465                                        obj.iter()
466                                            .next()
467                                            .map(|(k, v)| {
468                                                let val_str = if let Some(s) = v.as_str() {
469                                                    s.chars().take(100).collect::<String>()
470                                                } else {
471                                                    v.to_string().chars().take(100).collect::<String>()
472                                                };
473                                                format!("{}: {}", k, val_str)
474                                            })
475                                            .unwrap_or_default()
476                                    } else {
477                                        String::new()
478                                    }
479                                } else {
480                                    String::new()
481                                };
482                                tool_uses.push(ToolUseSummary {
483                                    name,
484                                    argument_summary: arg_summary,
485                                });
486                            }
487                            // Skip "thinking" blocks — they're internal reasoning
488                            _ => {}
489                        }
490                    }
491                }
492
493                let content = text_parts.join("\n");
494                if !content.is_empty() || !tool_uses.is_empty() {
495                    messages.push(HistoryMessage {
496                        role: "assistant".to_string(),
497                        content,
498                        timestamp,
499                        tool_uses,
500                    });
501                }
502            }
503        }
504
505        Ok(Some(HistorySession { meta, messages }))
506    }
507}