Skip to main content

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/<account_id>/claude/projects/ (per account, global)
10//!   - <home>/channels/*/identities/*/claude/projects/ and
11//!     <home>/dev/*/(*/)?identities/*/claude/projects/ (per-channel isolated —
12//!     Step 4 of docs/specs/SPEC_AGENT_IDENTITY_HISTORY_PERSISTENCE_PROTOCOL_2026_08_16.md.
13//!     Step 3 links these forward to the global location, but history written
14//!     before that fix landed, or for an account whose link creation failed
15//!     (best-effort, can fail), still has real data only under these
16//!     per-channel paths — scanned here so it stays discoverable rather than
17//!     requiring a manual filesystem search.)
18
19use std::collections::HashSet;
20use std::fs;
21use std::io::{BufRead, BufReader};
22use std::path::{Path, PathBuf};
23use std::time::UNIX_EPOCH;
24
25use super::adapter::*;
26
27/// Where Claude homes live. The homes under them — one per account, and
28/// channels — are listed again on every discovery ([`ClaudeHistoryAdapter::base_dirs`]),
29/// because they are created while srv runs: an account added or switched
30/// after srv started used to stay invisible until the next restart.
31pub struct ClaudeHistoryAdapter {
32    /// The user's home: `~/.claude` and `~/.config/claude-*`.
33    user_home: Option<PathBuf>,
34    /// `<shared>`: `providers/claude` and `identities/*`.
35    shared_dir: Option<PathBuf>,
36    /// AgentMux's home: `channels/*/identities/*` and `dev/…/identities/*`.
37    agentmux_home: Option<PathBuf>,
38}
39
40impl ClaudeHistoryAdapter {
41    /// Push `path` onto `dirs` iff it's a real directory AND its
42    /// canonical (symlink/junction-resolved) form hasn't already been
43    /// pushed — the dedup key, not `path` itself, since two different
44    /// `base_dirs` entries can be different paths to the same physical
45    /// directory (a per-channel isolated `projects/` junction and the
46    /// global location it points at). Falls back to the literal path
47    /// when canonicalization fails (e.g. a dangling/broken link) rather
48    /// than silently dropping it.
49    fn push_deduped_dir(dirs: &mut Vec<PathBuf>, seen: &mut HashSet<PathBuf>, path: PathBuf) {
50        if !path.is_dir() {
51            return;
52        }
53        let key = fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
54        if seen.insert(key) {
55            dirs.push(path);
56        }
57    }
58
59    pub fn new() -> Self {
60        // `AGENTMUX_SHARED_DIR` is exported by the launcher; fall back to
61        // ~/.agentmux/shared so discovery still works in plain/test contexts.
62        let shared_dir = std::env::var_os("AGENTMUX_SHARED_DIR")
63            .map(PathBuf::from)
64            .or_else(|| agentmux_common::data_paths::agentmux_root().ok().map(|root| root.join("shared")));
65        // `home_dir` is re-derived the same way `DataPaths::from_env()`
66        // itself derives it (AGENTMUX_HOME_OVERRIDE or the OS home dir),
67        // not read from an env var AgentMux doesn't currently export for
68        // this purpose.
69        let agentmux_home = agentmux_common::DataPaths::from_env().map(|paths| paths.home_dir);
70        Self::with_roots(dirs::home_dir(), shared_dir, agentmux_home)
71    }
72
73    pub(crate) fn with_roots(
74        user_home: Option<PathBuf>,
75        shared_dir: Option<PathBuf>,
76        agentmux_home: Option<PathBuf>,
77    ) -> Self {
78        ClaudeHistoryAdapter { user_home, shared_dir, agentmux_home }
79    }
80
81    /// Every Claude `projects/` directory under the roots, as the filesystem
82    /// stands now. Deduplicated by canonical (symlink/junction-resolved) path:
83    /// after Step 3 above, a per-channel isolated `projects/` dir may be a
84    /// junction pointing at the exact same physical location as one of the
85    /// global `<shared>/...` entries, and scanning both would surface every
86    /// session twice.
87    fn base_dirs(&self, problems: &mut Vec<String>) -> Vec<PathBuf> {
88        let mut base_dirs = Vec::new();
89        let mut seen_canonical: HashSet<PathBuf> = HashSet::new();
90
91        if let Some(home) = &self.user_home {
92            // User's personal (non-isolated) Claude sessions
93            Self::push_deduped_dir(&mut base_dirs, &mut seen_canonical, home.join(".claude").join("projects"));
94
95            // Legacy multi-account convention: ~/.config/claude-*/projects/
96            let config_dir = home.join(".config");
97            if config_dir.is_dir() {
98                if let Some(entries) = read_dir_or_note(problems, &config_dir) {
99                    for entry in entries.flatten() {
100                        let name = entry.file_name();
101                        let name_str = name.to_string_lossy();
102                        if name_str.starts_with("claude-") {
103                            Self::push_deduped_dir(&mut base_dirs, &mut seen_canonical, entry.path().join("projects"));
104                        }
105                    }
106                }
107            }
108        }
109
110        // AgentMux-ISOLATED Claude homes — where agents spawned by current builds
111        // actually write (AgentMux sets CLAUDE_CONFIG_DIR here at spawn time).
112        // Without these, the history browse misses every AgentMux agent
113        // conversation. See docs/specs/SPEC_UNIFIED_AGENT_HISTORY_STORE_2026-06-10.md.
114        //   <shared>/providers/claude/projects/              (default, account-wide)
115        //   <shared>/identities/<account_id>/claude/projects/ (per account, global)
116        if let Some(shared) = &self.shared_dir {
117            Self::push_deduped_dir(&mut base_dirs, &mut seen_canonical, shared.join("providers").join("claude").join("projects"));
118            if let Some(entries) = read_dir_or_note(problems, &shared.join("identities")) {
119                for entry in entries.flatten() {
120                    Self::push_deduped_dir(&mut base_dirs, &mut seen_canonical, entry.path().join("claude").join("projects"));
121                }
122            }
123        }
124
125        // Per-channel ISOLATED identity dirs (SPEC_AGENT_IDENTITY_HISTORY_PERSISTENCE_PROTOCOL_2026_08_16.md
126        // §4.4) — `<home>/channels/*/identities/*/claude/projects` and
127        // `<home>/dev/*/identities/*/claude/projects` (or, when a dev clone
128        // id is in play, one level deeper: `<home>/dev/*/*/identities/*/...`).
129        if let Some(agentmux_home) = &self.agentmux_home {
130            for root_name in ["channels", "dev"] {
131                Self::scan_isolated_identities_under(
132                    &agentmux_home.join(root_name),
133                    &mut base_dirs,
134                    &mut seen_canonical,
135                    2,
136                    problems,
137                );
138            }
139        }
140
141        base_dirs
142    }
143
144    /// Find every `identities/` directory up to `max_depth` levels under
145    /// `root`, and for each, every `<account_id>/claude/projects` that
146    /// exists. Bounded, not an unbounded recursive walk — channel/dev
147    /// directory layouts are shallow by construction
148    /// (`channels/<slug>/identities/`, `dev/<branch>/identities/` or
149    /// `dev/<branch>/<clone_id>/identities/`), so depth 2 covers every
150    /// known layout without risking an expensive filesystem walk if
151    /// something unexpected is nested underneath.
152    fn scan_isolated_identities_under(
153        root: &Path,
154        base_dirs: &mut Vec<PathBuf>,
155        seen: &mut HashSet<PathBuf>,
156        max_depth: u8,
157        problems: &mut Vec<String>,
158    ) {
159        let Some(entries) = read_dir_or_note(problems, root) else {
160            return;
161        };
162        for entry in entries.flatten() {
163            let path = entry.path();
164            if !path.is_dir() {
165                continue;
166            }
167            let identities = path.join("identities");
168            if identities.is_dir() {
169                if let Some(id_entries) = read_dir_or_note(problems, &identities) {
170                    for id_entry in id_entries.flatten() {
171                        Self::push_deduped_dir(base_dirs, seen, id_entry.path().join("claude").join("projects"));
172                    }
173                }
174            }
175            if max_depth > 1 {
176                Self::scan_isolated_identities_under(&path, base_dirs, seen, max_depth - 1, problems);
177            }
178        }
179    }
180
181    /// Count subagent JSONL files in a session's subagents/ directory.
182    fn count_subagents(session_dir: &Path) -> u32 {
183        let subagents_dir = session_dir.join("subagents");
184        if !subagents_dir.is_dir() {
185            return 0;
186        }
187        fs::read_dir(&subagents_dir)
188            .map(|entries| {
189                entries
190                    .flatten()
191                    .filter(|e| {
192                        let name = e.file_name();
193                        let s = name.to_string_lossy();
194                        s.starts_with("agent-") && s.ends_with(".jsonl")
195                    })
196                    .count() as u32
197            })
198            .unwrap_or(0)
199    }
200
201    /// Decode a project directory name back to a path.
202    /// e.g., "C--Users-user--claw-agentx-workspace" → "C:/Users/user/.claw/agentx-workspace"
203    /// This is lossy — real hyphens are indistinguishable from path separators.
204    fn decode_project_path(encoded: &str) -> String {
205        // Best-effort: replace leading drive pattern and path separators
206        let mut result = encoded.to_string();
207        // Restore drive letter colon: "C-" at start → "C:"
208        if result.len() >= 2 && result.as_bytes()[1] == b'-' && result.as_bytes()[0].is_ascii_uppercase() {
209            result = format!("{}:{}", &result[..1], &result[2..]);
210        }
211        // Replace remaining hyphens with forward slashes
212        result = result.replace('-', "/");
213        result
214    }
215
216    /// Parse the account id a session's absolute file path was
217    /// discovered under, if any: `.../identities/<account_id>/claude/...`
218    /// (both the always-global `<shared>/identities/...` location and the
219    /// per-channel isolated `<home>/channels/*/identities/...` /
220    /// `<home>/dev/*/(*/)?identities/...` locations share this same
221    /// `identities/<account_id>/claude/` shape — see `new()`'s scan roots).
222    /// Empty string for any other base_dir (personal `~/.claude`, the
223    /// default `<shared>/providers/claude/projects`, legacy
224    /// `~/.config/claude-*`) — those have no account to attribute to.
225    fn identity_id_from_path(path: &Path) -> String {
226        let components: Vec<String> = path
227            .components()
228            .map(|c| c.as_os_str().to_string_lossy().into_owned())
229            .collect();
230        for (i, comp) in components.iter().enumerate() {
231            if comp == "identities" && components.get(i + 2).map(String::as_str) == Some("claude") {
232                if let Some(bundle_id) = components.get(i + 1) {
233                    return bundle_id.clone();
234                }
235            }
236        }
237        String::new()
238    }
239}
240
241/// Record a directory or file discovery couldn't read. A path that no longer
242/// exists isn't one: it was deleted (or never created), so nothing in it was
243/// missed.
244fn note_unreadable(problems: &mut Vec<String>, path: &Path, e: &std::io::Error) {
245    if e.kind() != std::io::ErrorKind::NotFound {
246        problems.push(format!("{}: {e}", path.display()));
247    }
248}
249
250fn read_dir_or_note(problems: &mut Vec<String>, dir: &Path) -> Option<fs::ReadDir> {
251    fs::read_dir(dir).map_err(|e| note_unreadable(problems, dir, &e)).ok()
252}
253
254fn metadata_or_note(problems: &mut Vec<String>, file: &Path) -> Option<fs::Metadata> {
255    file.metadata().map_err(|e| note_unreadable(problems, file, &e)).ok()
256}
257
258/// One conversation message from a Claude record — a transcript line or a
259/// line of the stream-json output AgentMux keeps (`agent:<id>:current`); the
260/// two share the message shape. `None` for anything that isn't a user or
261/// assistant message with something in it (a tool-result frame, thinking only).
262pub(crate) fn message_from_entry(entry: &serde_json::Value, timestamp: i64) -> Option<HistoryMessage> {
263    let entry_type = entry.get("type").and_then(|v| v.as_str()).unwrap_or("");
264    if entry_type == "user" {
265        let content = if let Some(msg) = entry.pointer("/message/content") {
266            if let Some(text) = msg.as_str() {
267                text.to_string()
268            } else if let Some(arr) = msg.as_array() {
269                arr.iter()
270                    .filter_map(|block| {
271                        if block.get("type").and_then(|v| v.as_str()) == Some("text") {
272                            block.get("text").and_then(|v| v.as_str()).map(String::from)
273                        } else {
274                            None
275                        }
276                    })
277                    .collect::<Vec<_>>()
278                    .join("\n")
279            } else {
280                String::new()
281            }
282        } else {
283            String::new()
284        };
285
286        return (!content.is_empty()).then(|| HistoryMessage {
287            role: "user".to_string(),
288            content,
289            timestamp,
290            tool_uses: vec![],
291        });
292    } else if entry_type == "assistant" {
293        let mut text_parts = Vec::new();
294        let mut tool_uses = Vec::new();
295
296        if let Some(content_arr) = entry.pointer("/message/content").and_then(|v| v.as_array()) {
297            for block in content_arr {
298                let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
299                match block_type {
300                    "text" => {
301                        if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
302                            text_parts.push(text.to_string());
303                        }
304                    }
305                    "tool_use" => {
306                        let name = block
307                            .get("name")
308                            .and_then(|v| v.as_str())
309                            .unwrap_or("unknown")
310                            .to_string();
311                        // Summarize first argument
312                        let arg_summary = if let Some(input) = block.get("input") {
313                            if let Some(obj) = input.as_object() {
314                                // Take first key-value pair as summary
315                                obj.iter()
316                                    .next()
317                                    .map(|(k, v)| {
318                                        let val_str = if let Some(s) = v.as_str() {
319                                            s.chars().take(100).collect::<String>()
320                                        } else {
321                                            v.to_string().chars().take(100).collect::<String>()
322                                        };
323                                        format!("{}: {}", k, val_str)
324                                    })
325                                    .unwrap_or_default()
326                            } else {
327                                String::new()
328                            }
329                        } else {
330                            String::new()
331                        };
332                        tool_uses.push(ToolUseSummary {
333                            name,
334                            argument_summary: arg_summary,
335                        });
336                    }
337                    // Skip "thinking" blocks — they're internal reasoning
338                    _ => {}
339                }
340            }
341        }
342
343        let content = text_parts.join("\n");
344        return (!content.is_empty() || !tool_uses.is_empty()).then(|| HistoryMessage {
345            role: "assistant".to_string(),
346            content,
347            timestamp,
348            tool_uses,
349        });
350    }
351    None
352}
353
354impl HistoryAdapter for ClaudeHistoryAdapter {
355    fn provider(&self) -> &str {
356        "claude"
357    }
358
359    fn discover_files(&self) -> Result<Vec<DiscoveredFile>, HistoryError> {
360        self.discover().map(|found| found.files)
361    }
362
363    fn discover(&self) -> Result<Discovery, HistoryError> {
364        let mut files = Vec::new();
365        let mut problems = Vec::new();
366
367        for base_dir in &self.base_dirs(&mut problems) {
368            let entries = match fs::read_dir(base_dir) {
369                Ok(e) => e,
370                Err(e) => {
371                    note_unreadable(&mut problems, base_dir, &e);
372                    continue;
373                }
374            };
375
376            for project_entry in entries {
377                let project_entry = match project_entry {
378                    Ok(entry) => entry,
379                    Err(e) => {
380                        note_unreadable(&mut problems, base_dir, &e);
381                        continue;
382                    }
383                };
384                let project_path = project_entry.path();
385                if !project_path.is_dir() {
386                    // Top-level .jsonl files (session files at project root level)
387                    if project_path.extension().map_or(false, |e| e == "jsonl") {
388                        if let Some(meta) = metadata_or_note(&mut problems, &project_path) {
389                            let mtime = meta
390                                .modified()
391                                .ok()
392                                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
393                                .map(|d| d.as_millis() as i64)
394                                .unwrap_or(0);
395                            files.push(DiscoveredFile {
396                                file_path: project_path.to_string_lossy().into(),
397                                mtime_ms: mtime,
398                            });
399                        }
400                    }
401                    continue;
402                }
403
404                // Scan for .jsonl files inside project directories
405                // These are session directories that may also contain subagents/
406                let dir_entries = match fs::read_dir(&project_path) {
407                    Ok(e) => e,
408                    Err(e) => {
409                        note_unreadable(&mut problems, &project_path, &e);
410                        continue;
411                    }
412                };
413                for file_entry in dir_entries {
414                    let file_entry = match file_entry {
415                        Ok(entry) => entry,
416                        Err(e) => {
417                            note_unreadable(&mut problems, &project_path, &e);
418                            continue;
419                        }
420                    };
421                    let file_path = file_entry.path();
422                    if file_path.extension().map_or(false, |e| e == "jsonl") {
423                        // Skip subagent files — those are children of sessions
424                        if file_path
425                            .parent()
426                            .and_then(|p| p.file_name())
427                            .map_or(false, |n| n == "subagents")
428                        {
429                            continue;
430                        }
431                        if let Some(meta) = metadata_or_note(&mut problems, &file_path) {
432                            let mtime = meta
433                                .modified()
434                                .ok()
435                                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
436                                .map(|d| d.as_millis() as i64)
437                                .unwrap_or(0);
438                            files.push(DiscoveredFile {
439                                file_path: file_path.to_string_lossy().into(),
440                                mtime_ms: mtime,
441                            });
442                        }
443                    }
444                }
445            }
446        }
447
448        files.sort_by(|a, b| b.mtime_ms.cmp(&a.mtime_ms));
449        Ok(Discovery { files, problems })
450    }
451
452    fn extract_meta(&self, file_path: &str) -> Result<Option<SessionMeta>, HistoryError> {
453        let path = Path::new(file_path);
454        let file = fs::File::open(path)?;
455        let file_size = file.metadata()?.len();
456        let reader = BufReader::new(file);
457
458        let mut first_user_msg = String::new();
459        let mut model = "unknown".to_string();
460        let mut slug = String::new();
461        let mut cwd = String::new();
462        let mut git_branch = String::new();
463        let mut entry_count = 0u32;
464        let mut total_tokens: u64 = 0;
465        let mut first_timestamp: i64 = 0;
466        let mut last_timestamp: i64 = 0;
467        let mut starts_undated = false;
468        let mut session_id = String::new();
469
470        // Extract session_id from filename (stem)
471        if let Some(stem) = path.file_stem() {
472            session_id = stem.to_string_lossy().into();
473        }
474
475        let mut lines_iter = reader.lines();
476        let mut found_all_meta = false;
477
478        while let Some(Ok(line)) = lines_iter.next() {
479            if line.trim().is_empty() {
480                continue;
481            }
482
483            let entry: serde_json::Value = match serde_json::from_str(&line) {
484                Ok(v) => v,
485                Err(_) => continue,
486            };
487            entry_count += 1;
488
489            // Extract timestamp
490            if let Some(ts_str) = entry.get("timestamp").and_then(|v| v.as_str()) {
491                if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts_str) {
492                    let ts = dt.timestamp_millis();
493                    if first_timestamp == 0 {
494                        first_timestamp = ts;
495                    }
496                    last_timestamp = ts;
497                }
498            }
499
500            // Extract session slug
501            if slug.is_empty() {
502                if let Some(s) = entry.get("slug").and_then(|v| v.as_str()) {
503                    slug = s.to_string();
504                }
505            }
506
507            // Extract session ID from entry if available
508            if session_id.is_empty() {
509                if let Some(s) = entry.get("sessionId").and_then(|v| v.as_str()) {
510                    session_id = s.to_string();
511                }
512            }
513
514            // Extract cwd
515            if cwd.is_empty() {
516                if let Some(c) = entry.get("cwd").and_then(|v| v.as_str()) {
517                    cwd = c.to_string();
518                }
519            }
520
521            // Extract git branch
522            if git_branch.is_empty() {
523                if let Some(b) = entry.get("gitBranch").and_then(|v| v.as_str()) {
524                    git_branch = b.to_string();
525                }
526            }
527
528            let entry_type = entry.get("type").and_then(|v| v.as_str()).unwrap_or("");
529
530            // A conversation record before any dated one, itself undated:
531            // the session started no later than it, and nothing says when.
532            if first_timestamp == 0 && matches!(entry_type, "user" | "assistant") {
533                starts_undated = true;
534            }
535
536            // Extract model from first assistant entry
537            if model == "unknown" && entry_type == "assistant" {
538                if let Some(m) = entry.pointer("/message/model").and_then(|v| v.as_str()) {
539                    model = m.to_string();
540                }
541                // Accumulate tokens
542                if let Some(usage) = entry.pointer("/message/usage") {
543                    if let Some(out) = usage.get("output_tokens").and_then(|v| v.as_u64()) {
544                        total_tokens += out;
545                    }
546                }
547            } else if entry_type == "assistant" {
548                // Still accumulate tokens for non-first assistant entries
549                if let Some(usage) = entry.pointer("/message/usage") {
550                    if let Some(out) = usage.get("output_tokens").and_then(|v| v.as_u64()) {
551                        total_tokens += out;
552                    }
553                }
554            }
555
556            // Extract first user message for preview
557            if first_user_msg.is_empty() && entry_type == "user" {
558                if let Some(content) = entry.pointer("/message/content") {
559                    if let Some(text) = content.as_str() {
560                        first_user_msg = text.chars().take(200).collect();
561                    } else if let Some(arr) = content.as_array() {
562                        // Content can be an array of content blocks
563                        for block in arr {
564                            if block.get("type").and_then(|v| v.as_str()) == Some("text") {
565                                if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
566                                    first_user_msg = text.chars().take(200).collect();
567                                    break;
568                                }
569                            }
570                        }
571                    }
572                }
573            }
574
575            // Early exit: once we have all metadata fields, count remaining lines cheaply
576            if !first_user_msg.is_empty()
577                && model != "unknown"
578                && !cwd.is_empty()
579                && !slug.is_empty()
580            {
581                found_all_meta = true;
582                break;
583            }
584        }
585
586        // Count remaining lines without parsing JSON (fast)
587        if found_all_meta {
588            for remaining_line in lines_iter {
589                if let Ok(line) = remaining_line {
590                    if !line.trim().is_empty() {
591                        entry_count += 1;
592                    }
593                }
594            }
595        }
596
597        if entry_count == 0 {
598            return Ok(None);
599        }
600
601        // Fallback: decode project path from parent directory name
602        if cwd.is_empty() {
603            if let Some(parent_name) = path
604                .parent()
605                .and_then(|p| p.file_name())
606                .map(|n| n.to_string_lossy().to_string())
607            {
608                cwd = Self::decode_project_path(&parent_name);
609            }
610        }
611
612        // Count subagents
613        let subagent_count = if let Some(parent) = path.parent() {
614            let session_dir = parent.join(&session_id);
615            Self::count_subagents(&session_dir)
616        } else {
617            0
618        };
619
620        let file_meta = fs::metadata(file_path)?;
621        let modified_at = file_meta
622            .modified()
623            .ok()
624            .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
625            .map(|d| d.as_millis() as i64)
626            .unwrap_or(last_timestamp);
627
628        Ok(Some(SessionMeta {
629            session_id,
630            file_path: file_path.to_string(),
631            provider: "claude".to_string(),
632            model,
633            slug,
634            working_directory: cwd,
635            created_at: first_timestamp,
636            modified_at,
637            message_count: entry_count,
638            first_user_message: first_user_msg,
639            file_size_bytes: file_size,
640            git_branch,
641            total_tokens,
642            subagent_count,
643            identity_id: Self::identity_id_from_path(path),
644            starts_undated,
645        }))
646    }
647
648    fn parse_file(&self, file_path: &str) -> Result<Option<HistorySession>, HistoryError> {
649        // First extract meta
650        let meta = match self.extract_meta(file_path)? {
651            Some(m) => m,
652            None => return Ok(None),
653        };
654
655        // Read whole, not line by line: a record that can't be read has to be
656        // counted rather than skipped in silence, and only the raw bytes say
657        // whether the last line is finished. The CLI appends while the agent
658        // runs, so an unterminated last line is an append in progress, not a
659        // damaged record.
660        let bytes = fs::read(file_path)?;
661        let ends_with_newline = bytes.last() == Some(&b'\n');
662        let lines: Vec<&[u8]> = bytes.split(|b| *b == b'\n').collect();
663        let last = lines.len() - 1;
664        let mut messages = Vec::new();
665        let mut skipped_records = 0u32;
666
667        for (i, line) in lines.into_iter().enumerate() {
668            if line.iter().all(u8::is_ascii_whitespace) {
669                continue;
670            }
671            let entry: serde_json::Value = match serde_json::from_slice(line) {
672                Ok(v) => v,
673                Err(_) => {
674                    let in_progress = i == last && !ends_with_newline;
675                    if !in_progress {
676                        skipped_records += 1;
677                    }
678                    continue;
679                }
680            };
681
682            // Extract timestamp
683            let timestamp = entry
684                .get("timestamp")
685                .and_then(|v| v.as_str())
686                .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
687                .map(|dt| dt.timestamp_millis())
688                .unwrap_or(0);
689
690            if let Some(message) = message_from_entry(&entry, timestamp) {
691                messages.push(message);
692            }
693        }
694
695        Ok(Some(HistorySession { meta, messages, skipped_records }))
696    }
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702    use std::collections::HashSet;
703
704    const USER_LINE: &str = r#"{"type":"user","message":{"role":"user","content":"deploy the fix"},"timestamp":"2026-09-24T10:00:00Z","cwd":"/work/a"}"#;
705    const ASSISTANT_LINE: &str = r#"{"type":"assistant","message":{"role":"assistant","model":"m","content":[{"type":"text","text":"deployed"}]},"timestamp":"2026-09-24T10:00:05Z","cwd":"/work/a"}"#;
706
707    fn parse(content: &str) -> HistorySession {
708        let tmp = tempfile::TempDir::new().unwrap();
709        let file = tmp.path().join("s.jsonl");
710        fs::write(&file, content).unwrap();
711        ClaudeHistoryAdapter::with_roots(None, None, None)
712            .parse_file(&file.to_string_lossy())
713            .unwrap()
714            .unwrap()
715    }
716
717    /// A record that can't be parsed used to be skipped in silence, so a
718    /// search counted the session as fully read and could call a word that
719    /// appeared only in that record definitively absent (Codex P1 on #3693).
720    #[test]
721    fn a_malformed_record_is_counted_not_silently_skipped() {
722        let session = parse(&format!("{USER_LINE}\n{{\"type\":\"assistant\",\"mess\n{ASSISTANT_LINE}\n"));
723        assert_eq!(session.messages.len(), 2);
724        assert_eq!(session.skipped_records, 1);
725    }
726
727    /// The CLI appends while the agent runs, so a live session's last line
728    /// may be half-written when it's read. That's not corruption — counting
729    /// it would make every search that includes the searcher's own session
730    /// incomplete.
731    #[test]
732    fn an_unterminated_last_line_is_an_append_in_progress_not_corruption() {
733        let session = parse(&format!("{USER_LINE}\n{{\"type\":\"assistant\",\"message\":{{\"con"));
734        assert_eq!(session.messages.len(), 1);
735        assert_eq!(session.skipped_records, 0);
736    }
737
738    /// An account with no Claude home yet, or a root that doesn't exist, is
739    /// nothing missed — only an unreadable directory is.
740    #[test]
741    fn a_missing_directory_is_not_a_discovery_problem() {
742        let tmp = tempfile::TempDir::new().unwrap();
743        let shared = tmp.path().join("shared");
744        fs::create_dir_all(shared.join("identities").join("bundle-without-claude")).unwrap();
745        let adapter = ClaudeHistoryAdapter::with_roots(
746            Some(tmp.path().join("no-such-user-home")),
747            Some(shared),
748            Some(tmp.path().join("no-such-agentmux-home")),
749        );
750        let found = adapter.discover().unwrap();
751        assert!(found.problems.is_empty(), "{:?}", found.problems);
752    }
753
754    /// A directory that exists but can't be read may hold the session being
755    /// searched for, so it's reported rather than skipped (Codex P1 on
756    /// #3693).
757    #[cfg(unix)]
758    #[test]
759    fn an_unreadable_project_directory_is_a_discovery_problem() {
760        use std::os::unix::fs::PermissionsExt;
761        let tmp = tempfile::TempDir::new().unwrap();
762        let shared = tmp.path().join("shared");
763        let project = shared.join("providers").join("claude").join("projects").join("-work-locked");
764        fs::create_dir_all(&project).unwrap();
765        fs::set_permissions(&project, fs::Permissions::from_mode(0o000)).unwrap();
766        let readable_anyway = fs::read_dir(&project).is_ok(); // root ignores modes
767        let found = ClaudeHistoryAdapter::with_roots(None, Some(shared), None).discover().unwrap();
768        fs::set_permissions(&project, fs::Permissions::from_mode(0o755)).unwrap();
769        if readable_anyway {
770            return;
771        }
772        assert!(found.problems.iter().any(|p| p.contains("-work-locked")), "{:?}", found.problems);
773    }
774
775    /// An account's home or a channel created after the adapter is built —
776    /// an account added or switched while srv runs — must still be found.
777    /// The adapter used to list these directories once, when srv started, so
778    /// a session under a later account's home stayed invisible until a restart: on
779    /// 2026-09-24 both of an agent's account homes post-dated srv start and
780    /// SearchHistory answered "no history" with `truncated: false`.
781    #[test]
782    fn a_bundle_created_after_the_adapter_is_built_is_discovered() {
783        let tmp = tempfile::TempDir::new().unwrap();
784        let shared = tmp.path().join("shared");
785        let agentmux_home = tmp.path().join("agentmux");
786        fs::create_dir_all(&shared).unwrap();
787        fs::create_dir_all(agentmux_home.join("channels")).unwrap();
788        let adapter = ClaudeHistoryAdapter::with_roots(
789            None,
790            Some(shared.clone()),
791            Some(agentmux_home.clone()),
792        );
793
794        let global = shared.join("identities").join("late-bundle").join("claude").join("projects").join("-work");
795        let channel = agentmux_home
796            .join("channels")
797            .join("late-channel")
798            .join("identities")
799            .join("channel-bundle")
800            .join("claude")
801            .join("projects")
802            .join("-work");
803        for dir in [&global, &channel] {
804            fs::create_dir_all(dir).unwrap();
805        }
806        fs::write(global.join("in-global-bundle.jsonl"), "{}\n").unwrap();
807        fs::write(channel.join("in-channel-bundle.jsonl"), "{}\n").unwrap();
808
809        let found: Vec<String> = adapter
810            .discover_files()
811            .unwrap()
812            .into_iter()
813            .map(|f| f.file_path)
814            .collect();
815        assert!(found.iter().any(|p| p.ends_with("in-global-bundle.jsonl")), "{found:?}");
816        assert!(found.iter().any(|p| p.ends_with("in-channel-bundle.jsonl")), "{found:?}");
817    }
818
819    #[test]
820    fn push_deduped_dir_skips_a_path_that_is_not_a_real_directory() {
821        let tmp = tempfile::TempDir::new().unwrap();
822        let mut dirs = Vec::new();
823        let mut seen = HashSet::new();
824        ClaudeHistoryAdapter::push_deduped_dir(&mut dirs, &mut seen, tmp.path().join("does-not-exist"));
825        assert!(dirs.is_empty());
826    }
827
828    #[test]
829    fn push_deduped_dir_pushes_a_real_directory_once() {
830        let tmp = tempfile::TempDir::new().unwrap();
831        let dir = tmp.path().join("real");
832        std::fs::create_dir_all(&dir).unwrap();
833        let mut dirs = Vec::new();
834        let mut seen = HashSet::new();
835        ClaudeHistoryAdapter::push_deduped_dir(&mut dirs, &mut seen, dir.clone());
836        ClaudeHistoryAdapter::push_deduped_dir(&mut dirs, &mut seen, dir);
837        assert_eq!(dirs.len(), 1, "calling twice with the identical path must not duplicate it");
838    }
839
840    // The actual motivating case: after Step 3
841    // (SPEC_AGENT_IDENTITY_HISTORY_PERSISTENCE_PROTOCOL_2026_08_16.md §4.1),
842    // an isolated account home's projects/ is a junction pointing at the exact
843    // global path this adapter also scans directly — without dedup by
844    // canonical path, every session under it would appear twice.
845    #[cfg(windows)]
846    #[test]
847    fn push_deduped_dir_treats_a_junction_and_its_target_as_the_same_entry() {
848        let tmp = tempfile::TempDir::new().unwrap();
849        let target = tmp.path().join("global").join("projects");
850        let link = tmp.path().join("isolated").join("projects");
851        agentmux_common::ensure_history_link(&link, &target).unwrap();
852
853        let mut dirs = Vec::new();
854        let mut seen = HashSet::new();
855        ClaudeHistoryAdapter::push_deduped_dir(&mut dirs, &mut seen, target.clone());
856        ClaudeHistoryAdapter::push_deduped_dir(&mut dirs, &mut seen, link);
857        assert_eq!(
858            dirs.len(),
859            1,
860            "the junction and its target must be recognized as the same physical location, not scanned twice"
861        );
862    }
863
864    #[test]
865    fn scan_isolated_identities_finds_a_channel_scoped_projects_dir_at_depth_one() {
866        let tmp = tempfile::TempDir::new().unwrap();
867        let projects = tmp
868            .path()
869            .join("local-main-abc123")
870            .join("identities")
871            .join("bundle-1")
872            .join("claude")
873            .join("projects");
874        std::fs::create_dir_all(&projects).unwrap();
875
876        let mut dirs = Vec::new();
877        let mut seen = HashSet::new();
878        ClaudeHistoryAdapter::scan_isolated_identities_under(tmp.path(), &mut dirs, &mut seen, 2, &mut Vec::new());
879        assert_eq!(dirs, vec![projects]);
880    }
881
882    #[test]
883    fn scan_isolated_identities_finds_a_dev_clone_scoped_projects_dir_at_depth_two() {
884        let tmp = tempfile::TempDir::new().unwrap();
885        let projects = tmp
886            .path()
887            .join("main")
888            .join("a1b2c3d4")
889            .join("identities")
890            .join("bundle-2")
891            .join("claude")
892            .join("projects");
893        std::fs::create_dir_all(&projects).unwrap();
894
895        let mut dirs = Vec::new();
896        let mut seen = HashSet::new();
897        ClaudeHistoryAdapter::scan_isolated_identities_under(tmp.path(), &mut dirs, &mut seen, 2, &mut Vec::new());
898        assert_eq!(dirs, vec![projects]);
899    }
900
901    #[test]
902    fn scan_isolated_identities_does_not_descend_past_max_depth() {
903        let tmp = tempfile::TempDir::new().unwrap();
904        // Three levels deep -- depth 2 must not find this.
905        let projects = tmp
906            .path()
907            .join("main")
908            .join("a1b2c3d4")
909            .join("extra-nesting")
910            .join("identities")
911            .join("bundle-3")
912            .join("claude")
913            .join("projects");
914        std::fs::create_dir_all(&projects).unwrap();
915
916        let mut dirs = Vec::new();
917        let mut seen = HashSet::new();
918        ClaudeHistoryAdapter::scan_isolated_identities_under(tmp.path(), &mut dirs, &mut seen, 2, &mut Vec::new());
919        assert!(dirs.is_empty(), "must not walk past the bounded depth");
920    }
921
922    #[test]
923    fn scan_isolated_identities_ignores_directories_with_no_identities_subdir() {
924        let tmp = tempfile::TempDir::new().unwrap();
925        std::fs::create_dir_all(tmp.path().join("some-channel").join("data")).unwrap();
926
927        let mut dirs = Vec::new();
928        let mut seen = HashSet::new();
929        ClaudeHistoryAdapter::scan_isolated_identities_under(tmp.path(), &mut dirs, &mut seen, 2, &mut Vec::new());
930        assert!(dirs.is_empty());
931    }
932}