Skip to main content

mj_controller/
import.rs

1//! Import native harness sessions into Hel's durable archive format.
2//
3
4mod deepseek;
5mod muse;
6#[cfg(test)]
7mod native_tests;
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::fs;
11use std::io::{BufRead, BufReader};
12use std::path::{Path, PathBuf};
13use std::process::Command;
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::time::{Duration, SystemTime};
16
17use anyhow::{Context, Result, bail, ensure};
18use chrono::{DateTime, Utc};
19use rayon::prelude::*;
20use serde_json::{Value, json};
21
22use crate::setup::{GithubRepository, github_repository_from_origin};
23use mj_checkpoint::archive::{
24    ArchiveInput, BundleManifest, GitCollectionSpec, GitHistoryMode, GitSnapshotProgress,
25    SystemGit, TargetManifest, collect_git_snapshot_with_progress, write_archive_atomic,
26};
27use mj_checkpoint::checkpoint::{collect_import_native_artifacts, collect_native_artifacts};
28
29use mj_core::config::{
30    Config, HarnessKind, ProjectBundle, ProjectRepository, TargetTemplate, validate_id,
31};
32use mj_core::local_git::main_worktree_root;
33use mj_core::remote_git::resolve_repository;
34use mj_core::state::{
35    CheckpointMetadata, SessionRecord, SessionState, State, harness_session_title, new_session_id,
36    normalize_session_title,
37};
38use mj_transcript::projection::canonical_session_from_materialized;
39
40use crate::targets::ProcessExecutor;
41use mj_core::relay::{SequencedEvent, WorkerEvent, strip_hidden_prompt_context};
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum ClaudeSessionSelection {
45    NativeSessionId(String),
46    Latest,
47}
48
49pub type CodexSessionSelection = ClaudeSessionSelection;
50pub type KimiSessionSelection = ClaudeSessionSelection;
51pub type GrokSessionSelection = ClaudeSessionSelection;
52
53#[derive(Debug, Clone)]
54pub struct LocatedClaudeSession {
55    pub native_session_id: String,
56    pub jsonl_path: PathBuf,
57    pub modified_at: SystemTime,
58    pub title: String,
59    pub cwd: PathBuf,
60    pub git_branch: String,
61    pub size_bytes: u64,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum CodexHistoryMode {
66    Legacy,
67    Paginated,
68}
69
70/// Grok Build's conversation of record inside a session directory.
71const CHAT_HISTORY: &str = "chat_history.jsonl";
72
73pub const CODEX_LEGACY_IMPORT_ISSUE: &str = "Legacy Codex history cannot be imported. Run codex migrate-rollouts --apply, then reopen \
74     this dialog.";
75
76impl CodexHistoryMode {
77    pub fn import_issue(self) -> Option<&'static str> {
78        match self {
79            Self::Legacy => Some(CODEX_LEGACY_IMPORT_ISSUE),
80            Self::Paginated => None,
81        }
82    }
83}
84
85#[derive(Debug, Clone)]
86pub struct LocatedCodexSession {
87    pub native_session_id: String,
88    pub jsonl_path: PathBuf,
89    pub modified_at: SystemTime,
90    pub title: String,
91    pub cwd: PathBuf,
92    pub git_branch: String,
93    pub size_bytes: u64,
94    pub history_mode: CodexHistoryMode,
95    /// Archived inside Codex itself. Hel mirrors that one way: the row is
96    /// hidden by default and never written back to Codex.
97    pub natively_archived: bool,
98}
99
100#[derive(Debug, Clone)]
101pub struct LocatedKimiSession {
102    pub native_session_id: String,
103    pub session_path: PathBuf,
104    pub modified_at: SystemTime,
105    pub title: String,
106    pub cwd: PathBuf,
107    pub git_branch: String,
108    pub size_bytes: u64,
109}
110
111/// Grok Build keeps one directory per session, like Kimi Code.
112pub type LocatedGrokSession = LocatedKimiSession;
113
114#[derive(Debug, Clone)]
115pub struct SessionScanProgress<T> {
116    pub scanned: usize,
117    pub total: usize,
118    pub session: Option<T>,
119}
120
121#[derive(Debug)]
122struct FileScanCandidate {
123    path: PathBuf,
124    modified_at: SystemTime,
125    size_bytes: u64,
126}
127
128#[derive(Debug)]
129struct KimiScanCandidate {
130    native_session_id: String,
131    session_path: PathBuf,
132    modified_at: SystemTime,
133    title: String,
134    cwd: PathBuf,
135}
136
137#[derive(Debug)]
138struct CodexSessionMetadata {
139    id: String,
140    cwd: PathBuf,
141    git_branch: String,
142    history_mode: CodexHistoryMode,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct ClaudeTranscript {
147    pub cwd: PathBuf,
148    /// Files reliably reported as edited by the native harness.
149    pub edited_paths: Vec<PathBuf>,
150    pub events: Vec<SequencedEvent>,
151}
152
153pub type CodexTranscript = ClaudeTranscript;
154pub type KimiTranscript = ClaudeTranscript;
155pub type GrokTranscript = ClaudeTranscript;
156
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub enum BundleResolution {
159    Existing(String),
160    /// The caller must ask the user before adding this to their config.
161    Synthesized {
162        id: String,
163        bundle: ProjectBundle,
164    },
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct SessionEditTargets {
169    pub git_roots: Vec<PathBuf>,
170    /// Git roots under a temporary directory. They are throwaway workspaces
171    /// rather than project repositories, so the import omits them.
172    pub scratch_git_roots: Vec<PathBuf>,
173    pub non_git_dirs: Vec<PathBuf>,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct ImportSafetyIssues {
178    pub dirty_git_roots: Vec<(PathBuf, String)>,
179    pub omitted_non_git_dirs: Vec<PathBuf>,
180    pub scratch_git_roots: Vec<PathBuf>,
181    pub has_untracked_files: bool,
182}
183
184pub fn import_safety_issues(targets: &SessionEditTargets) -> Result<ImportSafetyIssues> {
185    let mut dirty_git_roots = Vec::new();
186    let mut has_untracked_files = false;
187    for root in &targets.git_roots {
188        let output = Command::new("git")
189            .args(["status", "--porcelain=v1", "--untracked-files=normal"])
190            .current_dir(root)
191            .output()
192            .with_context(|| format!("inspect Git status in {}", root.display()))?;
193        ensure!(
194            output.status.success(),
195            "could not inspect Git status in {}",
196            root.display()
197        );
198        let (tracked, untracked) = String::from_utf8_lossy(&output.stdout).lines().fold(
199            (0_usize, 0_usize),
200            |(tracked, untracked), line| {
201                if line.starts_with("??") {
202                    (tracked, untracked + 1)
203                } else {
204                    (tracked + 1, untracked)
205                }
206            },
207        );
208        has_untracked_files |= untracked > 0;
209        if tracked + untracked > 0 {
210            let mut parts = Vec::new();
211            if tracked > 0 {
212                parts.push(format!(
213                    "{tracked} tracked change{}",
214                    if tracked == 1 { "" } else { "s" }
215                ));
216            }
217            if untracked > 0 {
218                parts.push(format!(
219                    "{untracked} untracked path{}",
220                    if untracked == 1 { "" } else { "s" }
221                ));
222            }
223            dirty_git_roots.push((root.clone(), parts.join(" ยท ")));
224        }
225    }
226    Ok(ImportSafetyIssues {
227        dirty_git_roots,
228        omitted_non_git_dirs: targets.non_git_dirs.clone(),
229        scratch_git_roots: targets.scratch_git_roots.clone(),
230        has_untracked_files,
231    })
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct ImportedClaudeSession {
236    pub session_id: String,
237    pub native_session_id: String,
238    pub source_jsonl: PathBuf,
239    pub source_cwd: PathBuf,
240    pub bundle_id: String,
241    pub archive_path: PathBuf,
242}
243
244pub type ImportedCodexSession = ImportedClaudeSession;
245pub type ImportedKimiSession = ImportedClaudeSession;
246pub type ImportedGrokSession = ImportedClaudeSession;
247
248#[derive(Debug, Clone, PartialEq, Eq)]
249pub enum ImportArchiveProgress {
250    Repository {
251        current: usize,
252        total: usize,
253        id: String,
254    },
255    UntrackedFile {
256        repository_id: String,
257        current: usize,
258        total: usize,
259        path: PathBuf,
260    },
261    WritingArchive,
262}
263
264pub struct ImportControl<'a> {
265    pub cancelled: &'a AtomicBool,
266    pub progress: &'a (dyn Fn(ImportArchiveProgress) + Sync),
267    pub include_untracked: bool,
268}
269
270impl ImportControl<'_> {
271    fn check_cancelled(&self) -> Result<()> {
272        ensure!(!self.cancelled.load(Ordering::Acquire), "import cancelled");
273        Ok(())
274    }
275
276    fn report(&self, progress: ImportArchiveProgress) -> Result<()> {
277        self.check_cancelled()?;
278        (self.progress)(progress);
279        Ok(())
280    }
281}
282
283pub struct ClaudeImportRequest<'a> {
284    pub claude_home: &'a Path,
285    pub source: &'a LocatedClaudeSession,
286    pub transcript: &'a ClaudeTranscript,
287    pub bundle_id: &'a str,
288    pub profile_id: Option<&'a str>,
289    pub title: Option<&'a str>,
290    pub archive_directory: &'a Path,
291}
292
293pub struct CodexImportRequest<'a> {
294    pub codex_home: &'a Path,
295    pub source: &'a LocatedCodexSession,
296    pub transcript: &'a CodexTranscript,
297    pub bundle_id: &'a str,
298    pub profile_id: Option<&'a str>,
299    pub title: Option<&'a str>,
300    pub archive_directory: &'a Path,
301}
302
303pub struct KimiImportRequest<'a> {
304    pub kimi_home: &'a Path,
305    pub source: &'a LocatedKimiSession,
306    pub transcript: &'a KimiTranscript,
307    pub bundle_id: &'a str,
308    pub profile_id: Option<&'a str>,
309    pub title: Option<&'a str>,
310    pub archive_directory: &'a Path,
311}
312
313pub struct GrokImportRequest<'a> {
314    pub grok_home: &'a Path,
315    pub source: &'a LocatedGrokSession,
316    pub transcript: &'a GrokTranscript,
317    pub bundle_id: &'a str,
318    pub profile_id: Option<&'a str>,
319    pub title: Option<&'a str>,
320    pub archive_directory: &'a Path,
321}
322
323/// Resolve a harness's configuration home without ever modifying it.
324///
325/// The environment override wins; otherwise the harness's default directory
326/// beneath the user's home is used, the same pair `mj setup` discovers.
327pub fn harness_config_home(kind: HarnessKind) -> Result<PathBuf> {
328    let name = kind.display_name();
329    let home = std::env::var_os(kind.home_env())
330        .map(|path| kind.home_from_environment(path))
331        .or_else(|| dirs::home_dir().map(|home| home.join(kind.default_home_leaf())))
332        .with_context(|| format!("cannot determine {name} home; set {}", kind.home_env()))?;
333    ensure!(
334        home.is_dir(),
335        "{name} home is not a directory: {}",
336        home.display()
337    );
338    Ok(home)
339}
340
341/// Resolve the Claude configuration home without ever modifying it.
342pub fn claude_config_home() -> Result<PathBuf> {
343    harness_config_home(HarnessKind::Claude)
344}
345
346/// Resolve the Codex configuration home without ever modifying it.
347pub fn codex_config_home() -> Result<PathBuf> {
348    harness_config_home(HarnessKind::Codex)
349}
350
351/// Resolve the Kimi Code configuration home without ever modifying it.
352pub fn kimi_config_home() -> Result<PathBuf> {
353    harness_config_home(HarnessKind::Kimi)
354}
355
356/// Resolve the Grok Build configuration home without ever modifying it.
357pub fn grok_config_home() -> Result<PathBuf> {
358    harness_config_home(HarnessKind::Grok)
359}
360
361/// One native session located on disk, normalized across harnesses: the id
362/// `session/load` takes and the file or directory its transcript is read from.
363#[derive(Debug, Clone, PartialEq, Eq)]
364pub struct LocatedNativeSession {
365    pub native_session_id: String,
366    pub source_path: PathBuf,
367}
368
369/// One native session as a picker lists it, normalized across harnesses.
370#[derive(Debug, Clone)]
371pub struct NativeSessionListing {
372    pub native_session_id: String,
373    pub title: String,
374    pub modified_at: SystemTime,
375    pub git_branch: String,
376    pub size_bytes: u64,
377    pub cwd: PathBuf,
378    /// Why this session cannot be imported, when it cannot be.
379    pub unavailable_reason: Option<&'static str>,
380    /// Archived inside the harness itself. Only Codex reports this today.
381    pub natively_archived: bool,
382}
383
384/// Locate one native session for any harness.
385pub fn locate_native_session(
386    harness: HarnessKind,
387    home: &Path,
388    selection: &ClaudeSessionSelection,
389) -> Result<LocatedNativeSession> {
390    let (native_session_id, source_path) = match harness {
391        HarnessKind::Muse => {
392            return muse::locate(&mj_checkpoint::native::muse_sessions_root(home)?, selection);
393        }
394        HarnessKind::Codex => {
395            let located = locate_codex_session(home, selection)?;
396            (located.native_session_id, located.jsonl_path)
397        }
398        HarnessKind::Claude => {
399            let located = locate_claude_session(home, selection)?;
400            (located.native_session_id, located.jsonl_path)
401        }
402        HarnessKind::Kimi => {
403            let located = locate_kimi_session(home, selection)?;
404            (located.native_session_id, located.session_path)
405        }
406        HarnessKind::Grok => {
407            let located = locate_grok_session(home, selection)?;
408            (located.native_session_id, located.session_path)
409        }
410        HarnessKind::Deepseek => return deepseek::locate(home, selection),
411        HarnessKind::Zcode => bail!("ZCode native-session import is not yet supported"),
412    };
413    Ok(LocatedNativeSession {
414        native_session_id,
415        source_path,
416    })
417}
418
419/// Project one native session into the canonical transcript, for any harness.
420pub fn read_native_transcript(
421    harness: HarnessKind,
422    source_path: &Path,
423) -> Result<ClaudeTranscript> {
424    match harness {
425        HarnessKind::Muse => muse::read_transcript(source_path),
426        HarnessKind::Codex => read_codex_transcript(source_path),
427        HarnessKind::Claude => read_claude_transcript(source_path),
428        HarnessKind::Kimi => read_kimi_transcript(source_path),
429        HarnessKind::Grok => read_grok_transcript(source_path),
430        HarnessKind::Deepseek => deepseek::read_transcript(source_path),
431        HarnessKind::Zcode => bail!("ZCode native-session import is not yet supported"),
432    }
433}
434
435/// Scan a harness home newest first, reporting after every candidate.
436pub fn scan_native_sessions(
437    harness: HarnessKind,
438    home: &Path,
439    mut report: impl FnMut(SessionScanProgress<NativeSessionListing>),
440) -> Result<()> {
441    let mut forward = |scanned, total, session| {
442        report(SessionScanProgress {
443            scanned,
444            total,
445            session,
446        });
447    };
448    match harness {
449        HarnessKind::Muse => muse::scan(
450            &mj_checkpoint::native::muse_sessions_root(home)?,
451            |progress| {
452                forward(progress.scanned, progress.total, progress.session);
453            },
454        ),
455        HarnessKind::Codex => scan_codex_sessions(home, |progress| {
456            let session = progress.session.map(|session| NativeSessionListing {
457                unavailable_reason: session.history_mode.import_issue(),
458                native_session_id: session.native_session_id,
459                title: session.title,
460                modified_at: session.modified_at,
461                git_branch: session.git_branch,
462                size_bytes: session.size_bytes,
463                cwd: session.cwd,
464                natively_archived: session.natively_archived,
465            });
466            forward(progress.scanned, progress.total, session);
467        }),
468        HarnessKind::Claude => scan_claude_sessions(home, |progress| {
469            let session = progress.session.map(|session| NativeSessionListing {
470                native_session_id: session.native_session_id,
471                title: session.title,
472                modified_at: session.modified_at,
473                git_branch: session.git_branch,
474                size_bytes: session.size_bytes,
475                cwd: session.cwd,
476                unavailable_reason: None,
477                natively_archived: false,
478            });
479            forward(progress.scanned, progress.total, session);
480        }),
481        HarnessKind::Kimi => scan_kimi_sessions(home, |progress| {
482            let session = progress.session.map(|session| NativeSessionListing {
483                native_session_id: session.native_session_id,
484                title: session.title,
485                modified_at: session.modified_at,
486                git_branch: session.git_branch,
487                size_bytes: session.size_bytes,
488                cwd: session.cwd,
489                unavailable_reason: None,
490                natively_archived: false,
491            });
492            forward(progress.scanned, progress.total, session);
493        }),
494        HarnessKind::Grok => scan_grok_sessions(home, |progress| {
495            let session = progress.session.map(|session| NativeSessionListing {
496                native_session_id: session.native_session_id,
497                title: session.title,
498                modified_at: session.modified_at,
499                git_branch: session.git_branch,
500                size_bytes: session.size_bytes,
501                cwd: session.cwd,
502                unavailable_reason: None,
503                natively_archived: false,
504            });
505            forward(progress.scanned, progress.total, session);
506        }),
507        HarnessKind::Deepseek => deepseek::scan(home, |progress| {
508            forward(progress.scanned, progress.total, progress.session);
509        }),
510        HarnessKind::Zcode => Ok(()),
511    }
512}
513
514/// Locate a Codex rollout exposed by its native interactive resume picker.
515pub fn locate_codex_session(
516    home: &Path,
517    selection: &CodexSessionSelection,
518) -> Result<LocatedCodexSession> {
519    let mut listed = list_codex_sessions(home)?;
520    // `--latest` follows Codex's own default view, which hides what the user
521    // archived there. Asking for an id by name still finds it.
522    if matches!(selection, CodexSessionSelection::Latest) {
523        listed.retain(|session| !session.natively_archived);
524    }
525    if let CodexSessionSelection::NativeSessionId(session_id) = selection
526        && !listed
527            .iter()
528            .any(|session| session.native_session_id == *session_id)
529    {
530        return locate_unindexed_codex_session(home, session_id);
531    }
532    select_jsonl_session(listed, selection, "Codex")
533}
534
535fn locate_unindexed_codex_session(home: &Path, session_id: &str) -> Result<LocatedCodexSession> {
536    validate_id("Codex session", session_id)?;
537    let mut requested = BTreeMap::new();
538    requested.insert(session_id.to_owned(), session_id.to_owned());
539    let mut candidates = Vec::new();
540    let root = home.join("sessions");
541    if root.is_dir() {
542        collect_codex_candidate_paths(&root, &requested, &mut candidates)?;
543    }
544    let titles = codex_native_titles(home)?;
545    let mut matches = Vec::new();
546    for candidate in candidates {
547        let Some(metadata) = codex_session_metadata(&candidate.path)? else {
548            continue;
549        };
550        if metadata.id == session_id {
551            matches.push(LocatedCodexSession {
552                natively_archived: false,
553                title: titles
554                    .get(session_id)
555                    .cloned()
556                    .unwrap_or_else(|| session_id.to_owned()),
557                native_session_id: metadata.id,
558                jsonl_path: candidate.path,
559                modified_at: candidate.modified_at,
560                cwd: metadata.cwd,
561                git_branch: metadata.git_branch,
562                size_bytes: candidate.size_bytes,
563                history_mode: metadata.history_mode,
564            });
565        }
566    }
567    select_jsonl_session(
568        matches,
569        &CodexSessionSelection::NativeSessionId(session_id.to_owned()),
570        "Codex",
571    )
572}
573
574/// List native Codex sessions newest first.
575pub fn list_codex_sessions(home: &Path) -> Result<Vec<LocatedCodexSession>> {
576    let mut sessions = Vec::new();
577    scan_codex_sessions(home, |progress| {
578        if let Some(session) = progress.session {
579            sessions.push(session);
580        }
581    })?;
582    Ok(sessions)
583}
584
585/// Scan native Codex sessions newest first, reporting after every candidate file.
586pub fn scan_codex_sessions(
587    home: &Path,
588    mut report: impl FnMut(SessionScanProgress<LocatedCodexSession>),
589) -> Result<()> {
590    if let Some(sessions) = codex_indexed_sessions(home)? {
591        let total = sessions.len();
592        report(SessionScanProgress {
593            scanned: 0,
594            total,
595            session: None,
596        });
597        for (index, session) in sessions.into_iter().enumerate() {
598            report(SessionScanProgress {
599                scanned: index + 1,
600                total,
601                session: Some(session),
602            });
603        }
604        return Ok(());
605    }
606
607    // Native Codex only indexes threads with a non-empty preview/name. Its
608    // history and session-name index provide the same compact set of IDs,
609    // avoiding an expensive parse of every exec and subagent rollout.
610    let titles = codex_native_titles(home)?;
611    let mut candidates = Vec::new();
612    let root = home.join("sessions");
613    if root.is_dir() {
614        collect_codex_candidate_paths(&root, &titles, &mut candidates)?;
615    }
616    candidates.sort_by(|left, right| {
617        right
618            .modified_at
619            .cmp(&left.modified_at)
620            .then_with(|| right.path.cmp(&left.path))
621    });
622    let total = candidates.len();
623    report(SessionScanProgress {
624        scanned: 0,
625        total,
626        session: None,
627    });
628    for (index, candidate) in candidates.into_iter().enumerate() {
629        let session = codex_session_metadata(&candidate.path)?.map(|metadata| {
630            let session_id = metadata.id;
631            LocatedCodexSession {
632                natively_archived: false,
633                title: titles
634                    .get(&session_id)
635                    .cloned()
636                    .unwrap_or_else(|| session_id.clone()),
637                native_session_id: session_id,
638                jsonl_path: candidate.path,
639                modified_at: candidate.modified_at,
640                cwd: metadata.cwd,
641                git_branch: metadata.git_branch,
642                size_bytes: candidate.size_bytes,
643                history_mode: metadata.history_mode,
644            }
645        });
646        report(SessionScanProgress {
647            scanned: index + 1,
648            total,
649            session,
650        });
651    }
652    Ok(())
653}
654
655fn codex_indexed_sessions(home: &Path) -> Result<Option<Vec<LocatedCodexSession>>> {
656    let database = home.join("state_5.sqlite");
657    if !database.is_file() {
658        return Ok(None);
659    }
660    let connection = rusqlite::Connection::open_with_flags(
661        database,
662        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
663    )?;
664    let has_history_mode = connection
665        .prepare("SELECT history_mode FROM threads LIMIT 0")
666        .is_ok();
667    let history_mode_column = if has_history_mode {
668        "history_mode"
669    } else {
670        "'legacy'"
671    };
672    // Codex's own archived threads are listed too, flagged rather than
673    // filtered: the resume dialog hides them until "show archived" is on, and
674    // Hel never writes this database back.
675    let query = format!(
676        "SELECT id, rollout_path, updated_at, COALESCE(NULLIF(name, ''), NULLIF(title, ''), id), cwd, \
677         COALESCE(NULLIF(git_branch, ''), 'HEAD'), {history_mode_column}, archived \
678         FROM threads \
679         WHERE source IN ('cli', 'vscode') \
680           AND preview <> '' \
681           AND rollout_path IS NOT NULL \
682         ORDER BY updated_at DESC, id DESC"
683    );
684    let Ok(mut statement) = connection.prepare(&query) else {
685        return Ok(None);
686    };
687    let rows = statement.query_map([], |row| {
688        Ok((
689            row.get::<_, String>(0)?,
690            row.get::<_, String>(1)?,
691            row.get::<_, i64>(2)?,
692            row.get::<_, String>(3)?,
693            row.get::<_, String>(4)?,
694            row.get::<_, String>(5)?,
695            row.get::<_, String>(6)?,
696            row.get::<_, bool>(7)?,
697        ))
698    })?;
699    let mut sessions = Vec::new();
700    for row in rows {
701        let (session_id, path, updated_at, title, cwd, git_branch, history_mode, natively_archived) =
702            row?;
703        let path = PathBuf::from(path);
704        if validate_id("Codex session", &session_id).is_err() || updated_at.is_negative() {
705            continue;
706        }
707        let Ok(metadata) = fs::symlink_metadata(&path) else {
708            continue;
709        };
710        if metadata.file_type().is_symlink() || !metadata.is_file() {
711            continue;
712        }
713        sessions.push(LocatedCodexSession {
714            native_session_id: session_id.clone(),
715            jsonl_path: path,
716            modified_at: SystemTime::UNIX_EPOCH + Duration::from_secs(updated_at as u64),
717            title: normalize_session_title(&title).unwrap_or(session_id),
718            cwd: PathBuf::from(cwd),
719            git_branch,
720            size_bytes: metadata.len(),
721            history_mode: parse_codex_history_mode(&history_mode)?,
722            natively_archived,
723        });
724    }
725    Ok(Some(sessions))
726}
727
728fn collect_codex_candidate_paths(
729    root: &Path,
730    native_titles: &BTreeMap<String, String>,
731    candidates: &mut Vec<FileScanCandidate>,
732) -> Result<()> {
733    for entry in fs::read_dir(root)? {
734        let entry = entry?;
735        let path = entry.path();
736        let metadata = fs::symlink_metadata(&path)?;
737        if metadata.file_type().is_symlink() {
738            continue;
739        }
740        if metadata.is_dir() {
741            collect_codex_candidate_paths(&path, native_titles, candidates)?;
742            continue;
743        }
744        if !metadata.is_file() || path.extension().and_then(|value| value.to_str()) != Some("jsonl")
745        {
746            continue;
747        }
748        if let Some(session_id) = codex_rollout_id_from_path(&path)
749            && !native_titles.contains_key(session_id)
750        {
751            continue;
752        }
753        candidates.push(FileScanCandidate {
754            path,
755            modified_at: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
756            size_bytes: metadata.len(),
757        });
758    }
759    Ok(())
760}
761
762fn codex_rollout_id_from_path(path: &Path) -> Option<&str> {
763    let stem = path.file_stem()?.to_str()?;
764    let id = stem.get(stem.len().checked_sub(36)?..)?;
765    (id.as_bytes().get(8) == Some(&b'-')
766        && id.as_bytes().get(13) == Some(&b'-')
767        && id.as_bytes().get(18) == Some(&b'-')
768        && id.as_bytes().get(23) == Some(&b'-'))
769    .then_some(id)
770}
771
772fn codex_session_metadata(path: &Path) -> Result<Option<CodexSessionMetadata>> {
773    let file =
774        fs::File::open(path).with_context(|| format!("open Codex session {}", path.display()))?;
775    let mut reader = BufReader::new(file);
776    let mut line = String::new();
777    for _ in 0..8 {
778        line.clear();
779        if reader.read_line(&mut line)? == 0 {
780            break;
781        }
782        let record: Value = serde_json::from_str(&line)
783            .with_context(|| format!("parse Codex session {}", path.display()))?;
784        if record.get("type").and_then(Value::as_str) != Some("session_meta") {
785            continue;
786        }
787        if !codex_source_is_interactive(record.pointer("/payload/source")) {
788            return Ok(None);
789        }
790        // Ephemeral Codex threads normally have no rollout path at all. Keep
791        // this defensive check so a future writer cannot expose one here.
792        if record
793            .pointer("/payload/ephemeral")
794            .and_then(Value::as_bool)
795            == Some(true)
796        {
797            return Ok(None);
798        }
799        // Codex ACP loads a rollout by its payload `id`, which is also the
800        // UUID embedded in the rollout filename. `session_id` can name a
801        // parent thread and therefore is not necessarily resumable itself.
802        let id = record
803            .pointer("/payload/id")
804            .or_else(|| record.pointer("/payload/session_id"))
805            .and_then(Value::as_str)
806            .filter(|id| !id.is_empty())
807            .map(ToOwned::to_owned);
808        if let Some(id) = id {
809            validate_id("Codex session", &id)?;
810            let cwd = record
811                .pointer("/payload/cwd")
812                .and_then(Value::as_str)
813                .filter(|cwd| !cwd.trim().is_empty())
814                .map(PathBuf::from)
815                .unwrap_or_default();
816            let git_branch = record
817                .pointer("/payload/git/branch")
818                .and_then(Value::as_str)
819                .filter(|branch| !branch.trim().is_empty())
820                .unwrap_or("HEAD")
821                .to_owned();
822            let history_mode = record
823                .pointer("/payload/history_mode")
824                .and_then(Value::as_str)
825                .map(parse_codex_history_mode)
826                .transpose()?
827                .unwrap_or(CodexHistoryMode::Legacy);
828            return Ok(Some(CodexSessionMetadata {
829                id,
830                cwd,
831                git_branch,
832                history_mode,
833            }));
834        }
835    }
836    Ok(None)
837}
838
839fn parse_codex_history_mode(value: &str) -> Result<CodexHistoryMode> {
840    match value {
841        "legacy" => Ok(CodexHistoryMode::Legacy),
842        "paginated" => Ok(CodexHistoryMode::Paginated),
843        other => bail!("unsupported Codex history mode {other:?}"),
844    }
845}
846
847fn codex_source_is_interactive(source: Option<&Value>) -> bool {
848    match source {
849        // Older rollouts predate the source field and came from the TUI.
850        None => true,
851        Some(Value::String(source)) => matches!(source.as_str(), "cli" | "vscode"),
852        // Structured sources identify subagents. Other unexpected shapes are
853        // not sessions offered by the normal interactive resume picker.
854        Some(_) => false,
855    }
856}
857
858fn codex_native_titles(home: &Path) -> Result<BTreeMap<String, String>> {
859    let mut titles = BTreeMap::new();
860    // Older Codex stores use history only as their compact interactive-session
861    // index. Keep those IDs discoverable, but do not turn prompt text into a
862    // session name.
863    let history = home.join("history.jsonl");
864    if history.is_file() {
865        for line in BufReader::new(fs::File::open(&history)?).lines() {
866            let record: Value = serde_json::from_str(&line?)?;
867            if let (Some(session_id), Some(text)) = (
868                record.get("session_id").and_then(Value::as_str),
869                record.get("text").and_then(Value::as_str),
870            ) && !text.trim().is_empty()
871            {
872                titles
873                    .entry(session_id.to_owned())
874                    .or_insert_with(|| session_id.to_owned());
875            }
876        }
877    }
878    let index = home.join("session_index.jsonl");
879    if index.is_file() {
880        for line in BufReader::new(fs::File::open(&index)?).lines() {
881            let record: Value = serde_json::from_str(&line?)?;
882            if let (Some(session_id), Some(title)) = (
883                record.get("id").and_then(Value::as_str),
884                record.get("thread_name").and_then(Value::as_str),
885            ) && let Some(title) = normalize_session_title(title)
886            {
887                titles.insert(session_id.to_owned(), title);
888            }
889        }
890    }
891    Ok(titles)
892}
893
894/// Locate a Kimi session directory. Its on-disk `session_<uuid>` name is the
895/// native identifier required by Kimi ACP's `session/load`.
896pub fn locate_kimi_session(
897    home: &Path,
898    selection: &KimiSessionSelection,
899) -> Result<LocatedKimiSession> {
900    let candidates = list_kimi_sessions(home)?;
901    let sessions = home.join("sessions");
902    match selection {
903        KimiSessionSelection::NativeSessionId(native_session_id) => candidates
904            .into_iter()
905            .find(|candidate| candidate.native_session_id == *native_session_id)
906            .with_context(|| {
907                format!(
908                    "Kimi session {native_session_id:?} was not found under {}",
909                    sessions.display()
910                )
911            }),
912        KimiSessionSelection::Latest => candidates
913            .into_iter()
914            .next()
915            .context("no Kimi session directories were found"),
916    }
917}
918
919/// List native Kimi sessions newest first.
920pub fn list_kimi_sessions(home: &Path) -> Result<Vec<LocatedKimiSession>> {
921    let mut sessions = Vec::new();
922    scan_kimi_sessions(home, |progress| {
923        if let Some(session) = progress.session {
924            sessions.push(session);
925        }
926    })?;
927    Ok(sessions)
928}
929
930/// Scan native Kimi sessions newest first, reporting after every candidate directory.
931pub fn scan_kimi_sessions(
932    home: &Path,
933    mut report: impl FnMut(SessionScanProgress<LocatedKimiSession>),
934) -> Result<()> {
935    let sessions = home.join("sessions");
936    ensure!(
937        sessions.is_dir(),
938        "Kimi sessions directory is missing: {}",
939        sessions.display()
940    );
941    let mut candidates = kimi_indexed_candidates(home, &sessions)?;
942    candidates.sort_by(|left, right| {
943        right
944            .modified_at
945            .cmp(&left.modified_at)
946            .then_with(|| right.session_path.cmp(&left.session_path))
947    });
948    let total = candidates.len();
949    report(SessionScanProgress {
950        scanned: 0,
951        total,
952        session: None,
953    });
954    for (index, candidate) in candidates.into_iter().enumerate() {
955        let size_bytes = directory_size(&candidate.session_path)?;
956        let native_session_id = candidate.native_session_id;
957        let session = LocatedKimiSession {
958            title: candidate.title,
959            native_session_id,
960            session_path: candidate.session_path,
961            modified_at: candidate.modified_at,
962            git_branch: git_branch_or_head(&candidate.cwd),
963            size_bytes,
964            cwd: candidate.cwd,
965        };
966        report(SessionScanProgress {
967            scanned: index + 1,
968            total,
969            session: Some(session),
970        });
971    }
972    Ok(())
973}
974
975mod grok;
976
977#[cfg(test)]
978use grok::grok_decode_cwd_dirname;
979pub use grok::{list_grok_sessions, locate_grok_session, read_grok_transcript, scan_grok_sessions};
980
981fn kimi_indexed_candidates(home: &Path, sessions: &Path) -> Result<Vec<KimiScanCandidate>> {
982    let index_path = home.join("session_index.jsonl");
983    if !index_path.is_file() {
984        return Ok(Vec::new());
985    }
986
987    let mut indexed = BTreeMap::<String, (PathBuf, PathBuf)>::new();
988    for line in BufReader::new(fs::File::open(&index_path)?).lines() {
989        let Ok(record) = serde_json::from_str::<Value>(&line?) else {
990            continue;
991        };
992        let Some(session_id) = record
993            .get("sessionId")
994            .and_then(Value::as_str)
995            .filter(|session_id| !session_id.is_empty())
996        else {
997            continue;
998        };
999        if record.get("deleted").and_then(Value::as_bool) == Some(true) {
1000            indexed.remove(session_id);
1001            continue;
1002        }
1003        let (Some(session_path), Some(work_dir)) = (
1004            record
1005                .get("sessionDir")
1006                .and_then(Value::as_str)
1007                .map(PathBuf::from),
1008            record
1009                .get("workDir")
1010                .and_then(Value::as_str)
1011                .map(PathBuf::from),
1012        ) else {
1013            continue;
1014        };
1015        if validate_id("Kimi session", session_id).is_err()
1016            || !session_path.is_absolute()
1017            || session_path.file_name().and_then(|name| name.to_str()) != Some(session_id)
1018        {
1019            continue;
1020        }
1021        indexed.insert(session_id.to_owned(), (session_path, work_dir));
1022    }
1023
1024    let sessions = sessions.canonicalize()?;
1025    let mut candidates = Vec::new();
1026    for (native_session_id, (session_path, indexed_work_dir)) in indexed {
1027        let Ok(metadata) = fs::symlink_metadata(&session_path) else {
1028            continue;
1029        };
1030        if metadata.file_type().is_symlink() || !metadata.is_dir() {
1031            continue;
1032        }
1033        let Ok(canonical_session_path) = session_path.canonicalize() else {
1034            continue;
1035        };
1036        if !canonical_session_path.starts_with(&sessions) {
1037            continue;
1038        }
1039        let Some((title, cwd, archived)) =
1040            kimi_state_listing_metadata(&canonical_session_path, &indexed_work_dir)?
1041        else {
1042            continue;
1043        };
1044        if archived {
1045            continue;
1046        }
1047        candidates.push(KimiScanCandidate {
1048            native_session_id,
1049            modified_at: kimi_session_modified_at(&canonical_session_path, &metadata),
1050            session_path: canonical_session_path,
1051            title,
1052            cwd,
1053        });
1054    }
1055    Ok(candidates)
1056}
1057
1058fn kimi_state_listing_metadata(
1059    session_path: &Path,
1060    indexed_work_dir: &Path,
1061) -> Result<Option<(String, PathBuf, bool)>> {
1062    let state_path = session_path.join("state.json");
1063    let state = if state_path.is_file() {
1064        match serde_json::from_slice::<Value>(&fs::read(&state_path)?) {
1065            Ok(state) => state,
1066            Err(_) => return Ok(None),
1067        }
1068    } else {
1069        Value::Object(Default::default())
1070    };
1071    let string = |key: &str| {
1072        state
1073            .get(key)
1074            .and_then(Value::as_str)
1075            .filter(|value| !value.trim().is_empty())
1076    };
1077    let title = if state.get("isCustomTitle").is_some_and(Value::is_boolean) {
1078        string("title")
1079    } else {
1080        string("customTitle").or_else(|| string("title"))
1081    }
1082    .and_then(normalize_session_title);
1083    let cwd = string("workDir")
1084        .or_else(|| string("cwd"))
1085        .map(PathBuf::from)
1086        .filter(|cwd| cwd.is_absolute())
1087        .or_else(|| {
1088            indexed_work_dir
1089                .is_absolute()
1090                .then(|| indexed_work_dir.to_path_buf())
1091        })
1092        .unwrap_or_default();
1093    let title = title.unwrap_or_else(|| {
1094        session_path
1095            .file_name()
1096            .and_then(|name| name.to_str())
1097            .unwrap_or("Untitled session")
1098            .to_owned()
1099    });
1100    Ok(Some((
1101        title,
1102        cwd,
1103        state
1104            .get("archived")
1105            .and_then(Value::as_bool)
1106            .unwrap_or(false),
1107    )))
1108}
1109
1110fn kimi_session_modified_at(session_path: &Path, metadata: &fs::Metadata) -> SystemTime {
1111    let mut modified_at = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
1112    let mut consider = |path: &Path| {
1113        if let Ok(modified) = fs::metadata(path).and_then(|metadata| metadata.modified()) {
1114            modified_at = modified_at.max(modified);
1115        }
1116    };
1117    consider(&session_path.join("state.json"));
1118    consider(&session_path.join("wire.jsonl"));
1119    let agents = session_path.join("agents");
1120    if let Ok(entries) = fs::read_dir(agents) {
1121        for entry in entries.flatten() {
1122            consider(&entry.path().join("wire.jsonl"));
1123        }
1124    }
1125    modified_at
1126}
1127
1128fn select_jsonl_session(
1129    candidates: Vec<LocatedCodexSession>,
1130    selection: &CodexSessionSelection,
1131    harness: &str,
1132) -> Result<LocatedCodexSession> {
1133    match selection {
1134        CodexSessionSelection::NativeSessionId(native_session_id) => {
1135            validate_id(&format!("{harness} session"), native_session_id)?;
1136            candidates
1137                .into_iter()
1138                .filter(|candidate| candidate.native_session_id == *native_session_id)
1139                .max_by(|left, right| {
1140                    left.modified_at
1141                        .cmp(&right.modified_at)
1142                        .then_with(|| left.jsonl_path.cmp(&right.jsonl_path))
1143                })
1144                .with_context(|| format!("{harness} session {native_session_id:?} was not found"))
1145        }
1146        CodexSessionSelection::Latest => candidates
1147            .into_iter()
1148            .max_by(|left, right| {
1149                left.modified_at
1150                    .cmp(&right.modified_at)
1151                    .then_with(|| left.jsonl_path.cmp(&right.jsonl_path))
1152            })
1153            .context("no session JSONL files were found"),
1154    }
1155}
1156
1157/// Locate one native Claude rollout. `Latest` compares modified time across
1158/// every immediate project directory, exactly as Claude's layout requires.
1159pub fn locate_claude_session(
1160    home: &Path,
1161    selection: &ClaudeSessionSelection,
1162) -> Result<LocatedClaudeSession> {
1163    let candidates = list_claude_sessions(home)?;
1164    let projects = home.join("projects");
1165    match selection {
1166        ClaudeSessionSelection::NativeSessionId(native_session_id) => {
1167            validate_id("Claude session", native_session_id)?;
1168            let mut matches = candidates
1169                .into_iter()
1170                .filter(|candidate| candidate.native_session_id == *native_session_id)
1171                .collect::<Vec<_>>();
1172            if matches.is_empty() {
1173                matches = locate_unlisted_claude_sessions(home, native_session_id)?;
1174            }
1175            match matches.len() {
1176                0 => bail!(
1177                    "Claude session {native_session_id:?} was not found under {}",
1178                    projects.display()
1179                ),
1180                1 => Ok(matches.remove(0)),
1181                _ => bail!(
1182                    "Claude session {native_session_id:?} occurs in multiple project directories"
1183                ),
1184            }
1185        }
1186        ClaudeSessionSelection::Latest => candidates
1187            .into_iter()
1188            .next()
1189            .context("no Claude session JSONL files were found"),
1190    }
1191}
1192
1193fn locate_unlisted_claude_sessions(
1194    home: &Path,
1195    native_session_id: &str,
1196) -> Result<Vec<LocatedClaudeSession>> {
1197    let projects = home.join("projects");
1198    let mut matches = Vec::new();
1199    for project in fs::read_dir(&projects)? {
1200        let project = project?;
1201        let project_path = project.path();
1202        let project_metadata = fs::symlink_metadata(&project_path)?;
1203        if project_metadata.file_type().is_symlink() || !project_metadata.is_dir() {
1204            continue;
1205        }
1206        let path = project_path.join(format!("{native_session_id}.jsonl"));
1207        let Ok(metadata) = fs::symlink_metadata(&path) else {
1208            continue;
1209        };
1210        if metadata.file_type().is_symlink() || !metadata.is_file() {
1211            continue;
1212        }
1213        let Some((title, cwd, git_branch)) = claude_native_metadata(&path)? else {
1214            continue;
1215        };
1216        matches.push(LocatedClaudeSession {
1217            native_session_id: native_session_id.to_owned(),
1218            jsonl_path: path,
1219            modified_at: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
1220            title,
1221            cwd,
1222            git_branch,
1223            size_bytes: metadata.len(),
1224        });
1225    }
1226    Ok(matches)
1227}
1228
1229/// List native Claude sessions newest first.
1230pub fn list_claude_sessions(home: &Path) -> Result<Vec<LocatedClaudeSession>> {
1231    let mut sessions = Vec::new();
1232    scan_claude_sessions(home, |progress| {
1233        if let Some(session) = progress.session {
1234            sessions.push(session);
1235        }
1236    })?;
1237    Ok(sessions)
1238}
1239
1240/// Scan native Claude sessions newest first, reporting after every candidate file.
1241pub fn scan_claude_sessions(
1242    home: &Path,
1243    mut report: impl FnMut(SessionScanProgress<LocatedClaudeSession>),
1244) -> Result<()> {
1245    let projects = home.join("projects");
1246    ensure!(
1247        projects.is_dir(),
1248        "Claude projects directory is missing: {}",
1249        projects.display()
1250    );
1251    let mut candidates = Vec::new();
1252    for project in fs::read_dir(&projects)
1253        .with_context(|| format!("read Claude projects directory {}", projects.display()))?
1254    {
1255        let project = project?;
1256        let project_path = project.path();
1257        let project_metadata = fs::symlink_metadata(&project_path)?;
1258        if project_metadata.file_type().is_symlink() || !project_metadata.is_dir() {
1259            continue;
1260        }
1261        for entry in fs::read_dir(&project_path)? {
1262            let entry = entry?;
1263            let path = entry.path();
1264            let metadata = fs::symlink_metadata(&path)?;
1265            if metadata.file_type().is_symlink() || !metadata.is_file() {
1266                continue;
1267            }
1268            let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
1269                continue;
1270            };
1271            let Some(session_id) = name.strip_suffix(".jsonl") else {
1272                continue;
1273            };
1274            if session_id.is_empty() {
1275                continue;
1276            }
1277            candidates.push(FileScanCandidate {
1278                path,
1279                modified_at: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
1280                size_bytes: metadata.len(),
1281            });
1282        }
1283    }
1284
1285    candidates.sort_by(|left, right| {
1286        right
1287            .modified_at
1288            .cmp(&left.modified_at)
1289            .then_with(|| right.path.cmp(&left.path))
1290    });
1291    let total = candidates.len();
1292    report(SessionScanProgress {
1293        scanned: 0,
1294        total,
1295        session: None,
1296    });
1297    let mut visible = 0_usize;
1298    for (index, candidate) in candidates.into_iter().enumerate() {
1299        if visible == 50 {
1300            report(SessionScanProgress {
1301                scanned: index + 1,
1302                total,
1303                session: None,
1304            });
1305            continue;
1306        }
1307        let session_id = candidate
1308            .path
1309            .file_name()
1310            .and_then(|name| name.to_str())
1311            .and_then(|name| name.strip_suffix(".jsonl"))
1312            .expect("Claude candidates were validated during enumeration")
1313            .to_owned();
1314        let metadata = match claude_native_metadata(&candidate.path) {
1315            Ok(Some(metadata)) => metadata,
1316            Ok(None) => {
1317                report(SessionScanProgress {
1318                    scanned: index + 1,
1319                    total,
1320                    session: None,
1321                });
1322                continue;
1323            }
1324            Err(_) => (session_id.clone(), PathBuf::new(), "HEAD".to_owned()),
1325        };
1326        let (title, cwd, git_branch) = metadata;
1327        visible += 1;
1328        report(SessionScanProgress {
1329            scanned: index + 1,
1330            total,
1331            session: Some(LocatedClaudeSession {
1332                native_session_id: session_id,
1333                jsonl_path: candidate.path,
1334                modified_at: candidate.modified_at,
1335                title,
1336                cwd,
1337                git_branch,
1338                size_bytes: candidate.size_bytes,
1339            }),
1340        });
1341    }
1342    Ok(())
1343}
1344
1345fn claude_native_metadata(path: &Path) -> Result<Option<(String, PathBuf, String)>> {
1346    let mut custom_title = None;
1347    let mut agent_name = None;
1348    let mut ai_title = None;
1349    let mut cwd = None;
1350    let mut git_branch = None;
1351    let mut entrypoint = None;
1352    let mut filtered = false;
1353    for line in BufReader::new(fs::File::open(path)?).lines() {
1354        let record: Value = serde_json::from_str(&line?)?;
1355        if record
1356            .get("isSidechain")
1357            .and_then(Value::as_bool)
1358            .unwrap_or(false)
1359            || record
1360                .get("teamName")
1361                .and_then(Value::as_str)
1362                .is_some_and(|name| !name.trim().is_empty())
1363            || record.get("sessionKind").and_then(Value::as_str) == Some("daemon-worker")
1364        {
1365            filtered = true;
1366        }
1367        if entrypoint.is_none() {
1368            entrypoint = record
1369                .get("entrypoint")
1370                .and_then(Value::as_str)
1371                .filter(|entrypoint| !entrypoint.trim().is_empty())
1372                .map(str::to_owned);
1373        }
1374        if cwd.is_none() {
1375            cwd = record
1376                .get("cwd")
1377                .and_then(Value::as_str)
1378                .filter(|cwd| !cwd.trim().is_empty())
1379                .map(PathBuf::from);
1380        }
1381        if git_branch.is_none() {
1382            git_branch = record
1383                .get("gitBranch")
1384                .and_then(Value::as_str)
1385                .filter(|branch| !branch.trim().is_empty())
1386                .map(str::to_owned);
1387        }
1388        match record.get("type").and_then(Value::as_str) {
1389            Some("custom-title") => {
1390                if let Some(native_title) = record
1391                    .get("customTitle")
1392                    .and_then(Value::as_str)
1393                    .filter(|title| !title.trim().is_empty())
1394                {
1395                    custom_title = normalize_session_title(native_title);
1396                }
1397            }
1398            Some("ai-title") => {
1399                if let Some(native_title) = record
1400                    .get("aiTitle")
1401                    .and_then(Value::as_str)
1402                    .filter(|title| !title.trim().is_empty())
1403                {
1404                    ai_title = normalize_session_title(native_title);
1405                }
1406            }
1407            Some("agent-name") => {
1408                agent_name = record
1409                    .get("agentName")
1410                    .and_then(Value::as_str)
1411                    .filter(|title| !title.trim().is_empty())
1412                    .and_then(normalize_session_title);
1413            }
1414            Some("user") => {
1415                let content = record.pointer("/message/content").and_then(Value::as_str);
1416                if content
1417                    .is_some_and(|content| content.contains("<command-name>/loop</command-name>"))
1418                {
1419                    filtered = true;
1420                }
1421            }
1422            _ => {}
1423        }
1424    }
1425    // Claude's native resume picker is for interactive CLI conversations. In
1426    // particular, its print/SDK entrypoints include the tiny rollouts created
1427    // by `claude -p /usage`, which must not displace real sessions here.
1428    if filtered || entrypoint.as_deref().is_some_and(|value| value != "cli") {
1429        return Ok(None);
1430    }
1431    let cwd = cwd.with_context(|| format!("Claude session {} has no cwd", path.display()))?;
1432    Ok(Some((
1433        custom_title
1434            .or(agent_name)
1435            .or(ai_title)
1436            .unwrap_or_else(|| "Untitled session".into()),
1437        cwd,
1438        git_branch.unwrap_or_else(|| "HEAD".into()),
1439    )))
1440}
1441
1442fn git_branch_or_head(cwd: &Path) -> String {
1443    if cwd.as_os_str().is_empty() {
1444        return "HEAD".into();
1445    }
1446    git_optional_text(cwd, ["branch", "--show-current"])
1447        .ok()
1448        .flatten()
1449        .filter(|branch| !branch.is_empty())
1450        .unwrap_or_else(|| "HEAD".into())
1451}
1452
1453fn directory_size(path: &Path) -> Result<u64> {
1454    let mut size = 0_u64;
1455    for entry in fs::read_dir(path)? {
1456        let entry = entry?;
1457        let metadata = fs::symlink_metadata(entry.path())?;
1458        if metadata.is_file() {
1459            size = size.saturating_add(metadata.len());
1460        } else if metadata.is_dir() && !metadata.file_type().is_symlink() {
1461            size = size.saturating_add(directory_size(&entry.path())?);
1462        }
1463    }
1464    Ok(size)
1465}
1466
1467/// Read the native JSONL only far enough to recover a transcript suitable for
1468/// Hel's chat view. Full tool traffic and reasoning remain in the copied
1469/// native rollout, not in this lossy projection.
1470pub fn read_claude_transcript(path: &Path) -> Result<ClaudeTranscript> {
1471    let body = fs::read_to_string(path)
1472        .with_context(|| format!("read Claude session {}", path.display()))?;
1473    let mut cwd = None;
1474    let mut events = Vec::new();
1475    let mut saw_raw_user = false;
1476
1477    for (index, line) in body.lines().enumerate() {
1478        if line.trim().is_empty() {
1479            continue;
1480        }
1481        let record: Value = serde_json::from_str(line).with_context(|| {
1482            format!("parse Claude session {} line {}", path.display(), index + 1)
1483        })?;
1484        let recorded_at_ms = native_recorded_at_ms(&record);
1485        if cwd.is_none() {
1486            cwd = record
1487                .get("cwd")
1488                .and_then(Value::as_str)
1489                .filter(|cwd| !cwd.trim().is_empty())
1490                .map(PathBuf::from);
1491        }
1492        if record.get("isMeta").and_then(Value::as_bool) == Some(true)
1493            || record.get("isSidechain").and_then(Value::as_bool) == Some(true)
1494        {
1495            continue;
1496        }
1497        let compaction_boundary = record.get("type").and_then(Value::as_str) == Some("system")
1498            && matches!(
1499                record.get("subtype").and_then(Value::as_str),
1500                Some("compact_boundary" | "compaction")
1501            );
1502        let compaction_summary = record
1503            .get("isCompactSummary")
1504            .or_else(|| record.pointer("/message/isCompactSummary"))
1505            .and_then(Value::as_bool)
1506            == Some(true);
1507        if compaction_boundary || compaction_summary {
1508            ensure!(
1509                saw_raw_user,
1510                "Claude session contains a compaction artifact before recoverable raw history"
1511            );
1512            continue;
1513        }
1514        match record.get("type").and_then(Value::as_str) {
1515            Some("user") => {
1516                let Some(text) = record
1517                    .pointer("/message/content")
1518                    .and_then(Value::as_str)
1519                    .map(strip_hidden_prompt_context)
1520                    .filter(|text| !text.trim().is_empty())
1521                else {
1522                    continue;
1523                };
1524                let request_id = format!("import-{}", events.len() + 1);
1525                push_event(
1526                    &mut events,
1527                    recorded_at_ms,
1528                    WorkerEvent::PromptAccepted {
1529                        request_id,
1530                        text: text.to_owned(),
1531                        attachments: Vec::new(),
1532                    },
1533                );
1534                saw_raw_user = true;
1535            }
1536            Some("assistant") => {
1537                let Some(content) = record.pointer("/message/content").and_then(Value::as_array)
1538                else {
1539                    continue;
1540                };
1541                for block in content {
1542                    let Some(text) = block
1543                        .get("text")
1544                        .and_then(Value::as_str)
1545                        .filter(|text| !text.is_empty())
1546                    else {
1547                        continue;
1548                    };
1549                    if block.get("type").and_then(Value::as_str) != Some("text") {
1550                        continue;
1551                    }
1552                    push_event(
1553                        &mut events,
1554                        recorded_at_ms,
1555                        WorkerEvent::Adapter {
1556                            kind: "session_update".into(),
1557                            payload: json!({
1558                                "type": "session_update",
1559                                "update": {
1560                                    "sessionUpdate": "agent_message_chunk",
1561                                    "content": {"type": "text", "text": text},
1562                                },
1563                            }),
1564                        },
1565                    );
1566                }
1567                // Claude marks a completed model response independently of
1568                // its text/tool blocks. Preserve that lifecycle boundary so
1569                // the restored durable worker is idle and accepts the next
1570                // user prompt instead of treating the imported turn as live.
1571                if matches!(
1572                    record
1573                        .pointer("/message/stop_reason")
1574                        .and_then(Value::as_str),
1575                    Some("end_turn" | "stop_sequence")
1576                ) {
1577                    push_event(&mut events, recorded_at_ms, WorkerEvent::TurnCompleted);
1578                }
1579            }
1580            _ => {}
1581        }
1582    }
1583
1584    let cwd = cwd.context("Claude session does not declare its original cwd")?;
1585    ensure!(
1586        cwd.is_absolute(),
1587        "Claude session cwd is not absolute: {}",
1588        cwd.display()
1589    );
1590    finalize_import_event_times(&mut events, path)?;
1591    let edited_paths = claude_edited_paths(path)?;
1592    Ok(ClaudeTranscript {
1593        cwd,
1594        edited_paths,
1595        events,
1596    })
1597}
1598
1599/// Project a Codex rollout into the canonical transcript used by Hel chat.
1600pub fn read_codex_transcript(path: &Path) -> Result<CodexTranscript> {
1601    let body = fs::read_to_string(path)
1602        .with_context(|| format!("read Codex session {}", path.display()))?;
1603    let mut cwd = None;
1604    let mut history_mode = None;
1605    let mut events = Vec::new();
1606    let mut edited_paths = BTreeSet::new();
1607    let mut saw_user = false;
1608    for (index, line) in body.lines().enumerate() {
1609        if line.trim().is_empty() {
1610            continue;
1611        }
1612        let record: Value = serde_json::from_str(line).with_context(|| {
1613            format!("parse Codex session {} line {}", path.display(), index + 1)
1614        })?;
1615        let recorded_at_ms = native_recorded_at_ms(&record);
1616        if record.get("type").and_then(Value::as_str) == Some("session_meta") {
1617            if cwd.is_none() {
1618                cwd = record
1619                    .pointer("/payload/cwd")
1620                    .and_then(Value::as_str)
1621                    .filter(|cwd| !cwd.trim().is_empty())
1622                    .map(PathBuf::from);
1623            }
1624            if history_mode.is_none() {
1625                history_mode = Some(
1626                    record
1627                        .pointer("/payload/history_mode")
1628                        .and_then(Value::as_str)
1629                        .map(parse_codex_history_mode)
1630                        .transpose()?
1631                        .unwrap_or(CodexHistoryMode::Legacy),
1632                );
1633            }
1634            continue;
1635        }
1636        if record.get("type").and_then(Value::as_str) != Some("event_msg") {
1637            continue;
1638        }
1639        if record.pointer("/payload/type").and_then(Value::as_str) == Some("item_completed")
1640            && record.pointer("/payload/item/type").and_then(Value::as_str) == Some("FileChange")
1641            && record
1642                .pointer("/payload/item/status")
1643                .and_then(Value::as_str)
1644                == Some("completed")
1645            && let Some(changes) = record
1646                .pointer("/payload/item/changes")
1647                .and_then(Value::as_object)
1648        {
1649            edited_paths.extend(changes.keys().map(PathBuf::from));
1650        }
1651        match record.pointer("/payload/type").and_then(Value::as_str) {
1652            Some("item_completed")
1653                if record.pointer("/payload/item/type").and_then(Value::as_str)
1654                    == Some("UserMessage") =>
1655            {
1656                let Some(text) = codex_completed_item_text(&record) else {
1657                    continue;
1658                };
1659                let text = strip_hidden_prompt_context(&text);
1660                if text.trim().is_empty() {
1661                    continue;
1662                }
1663                finish_imported_turn(&mut events, None);
1664                let request_id = format!("import-{}", events.len() + 1);
1665                push_event(
1666                    &mut events,
1667                    recorded_at_ms,
1668                    WorkerEvent::PromptAccepted {
1669                        request_id,
1670                        text: text.to_owned(),
1671                        attachments: Vec::new(),
1672                    },
1673                );
1674                saw_user = true;
1675            }
1676            Some("item_completed")
1677                if record.pointer("/payload/item/type").and_then(Value::as_str)
1678                    == Some("AgentMessage") =>
1679            {
1680                let Some(text) = codex_completed_item_text(&record) else {
1681                    continue;
1682                };
1683                push_event(
1684                    &mut events,
1685                    recorded_at_ms,
1686                    WorkerEvent::Adapter {
1687                        kind: "session_update".into(),
1688                        payload: json!({
1689                            "type": "session_update",
1690                            "update": {
1691                                "sessionUpdate": "agent_message_chunk",
1692                                "content": {"type": "text", "text": text},
1693                            },
1694                        }),
1695                    },
1696                );
1697            }
1698            Some("turn_complete" | "turn_aborted") => {
1699                finish_imported_turn(&mut events, recorded_at_ms)
1700            }
1701            _ => {}
1702        }
1703    }
1704    ensure!(
1705        history_mode == Some(CodexHistoryMode::Paginated),
1706        "{CODEX_LEGACY_IMPORT_ISSUE}"
1707    );
1708    ensure!(
1709        saw_user,
1710        "Codex paginated session contains no importable user messages"
1711    );
1712    finish_imported_turn(&mut events, None);
1713    let cwd = cwd.context("Codex session does not declare its original cwd")?;
1714    ensure!(
1715        cwd.is_absolute(),
1716        "Codex session cwd is not absolute: {}",
1717        cwd.display()
1718    );
1719    finalize_import_event_times(&mut events, path)?;
1720    Ok(CodexTranscript {
1721        cwd,
1722        edited_paths: edited_paths.into_iter().collect(),
1723        events,
1724    })
1725}
1726
1727fn codex_completed_item_text(record: &Value) -> Option<String> {
1728    let parts = record
1729        .pointer("/payload/item/content")?
1730        .as_array()?
1731        .iter()
1732        .filter_map(|part| part.get("text").and_then(Value::as_str))
1733        .filter(|text| !text.is_empty())
1734        .collect::<Vec<_>>();
1735    (!parts.is_empty()).then(|| parts.join("\n"))
1736}
1737
1738/// Project a Kimi session directory. The main wire stream contains prompts and
1739/// generated text; tool traffic and thought blocks stay only in native files.
1740pub fn read_kimi_transcript(session_path: &Path) -> Result<KimiTranscript> {
1741    let state_path = session_path.join("state.json");
1742    let state: Value = serde_json::from_slice(&fs::read(&state_path)?)
1743        .with_context(|| format!("parse Kimi session state {}", state_path.display()))?;
1744    let cwd = state
1745        .get("workDir")
1746        .or_else(|| state.get("cwd"))
1747        .and_then(Value::as_str)
1748        .filter(|cwd| !cwd.trim().is_empty())
1749        .map(PathBuf::from)
1750        .context("Kimi session state does not declare workDir or cwd")?;
1751    ensure!(
1752        cwd.is_absolute(),
1753        "Kimi session workDir is not absolute: {}",
1754        cwd.display()
1755    );
1756    let wire_path = session_path.join("agents/main/wire.jsonl");
1757    let body = fs::read_to_string(&wire_path)
1758        .with_context(|| format!("read Kimi wire stream {}", wire_path.display()))?;
1759    let mut events = Vec::new();
1760    let mut saw_raw_user = false;
1761    for (index, line) in body.lines().enumerate() {
1762        if line.trim().is_empty() {
1763            continue;
1764        }
1765        let record: Value = serde_json::from_str(line).with_context(|| {
1766            format!(
1767                "parse Kimi wire stream {} line {}",
1768                wire_path.display(),
1769                index + 1
1770            )
1771        })?;
1772        let recorded_at_ms = native_recorded_at_ms(&record);
1773        if matches!(
1774            record.get("type").and_then(Value::as_str),
1775            Some("context.compaction" | "context.compacted" | "compaction")
1776        ) {
1777            ensure!(
1778                saw_raw_user,
1779                "Kimi session contains a compaction artifact before recoverable raw history"
1780            );
1781            continue;
1782        }
1783        match record.get("type").and_then(Value::as_str) {
1784            Some("turn.prompt" | "turn.steer")
1785                if record.pointer("/origin/kind").and_then(Value::as_str) == Some("user") =>
1786            {
1787                finish_imported_turn(&mut events, None);
1788                let text = record
1789                    .pointer("/input")
1790                    .and_then(Value::as_array)
1791                    .into_iter()
1792                    .flatten()
1793                    .filter(|part| part.get("type").and_then(Value::as_str) == Some("text"))
1794                    .filter_map(|part| part.get("text").and_then(Value::as_str))
1795                    .filter(|text| !text.trim().is_empty())
1796                    .collect::<Vec<_>>()
1797                    .join("\n");
1798                let text = strip_hidden_prompt_context(&text);
1799                if !text.trim().is_empty() {
1800                    let request_id = format!("import-{}", events.len() + 1);
1801                    push_event(
1802                        &mut events,
1803                        recorded_at_ms,
1804                        WorkerEvent::PromptAccepted {
1805                            request_id,
1806                            text: text.to_owned(),
1807                            attachments: Vec::new(),
1808                        },
1809                    );
1810                    saw_raw_user = true;
1811                }
1812            }
1813            Some("context.append_loop_event")
1814                if record.pointer("/event/type").and_then(Value::as_str)
1815                    == Some("content.part")
1816                    && record.pointer("/event/part/type").and_then(Value::as_str)
1817                        == Some("text") =>
1818            {
1819                let Some(text) = record
1820                    .pointer("/event/part/text")
1821                    .and_then(Value::as_str)
1822                    .filter(|text| !text.is_empty())
1823                else {
1824                    continue;
1825                };
1826                push_event(
1827                    &mut events,
1828                    recorded_at_ms,
1829                    WorkerEvent::Adapter {
1830                        kind: "session_update".into(),
1831                        payload: json!({
1832                            "type": "session_update",
1833                            "update": {
1834                                "sessionUpdate": "agent_message_chunk",
1835                                "content": {"type": "text", "text": text},
1836                            },
1837                        }),
1838                    },
1839                );
1840            }
1841            _ => {}
1842        }
1843    }
1844    finish_imported_turn(&mut events, None);
1845    finalize_import_event_times(&mut events, &wire_path)?;
1846    let edited_paths = kimi_edited_paths(session_path)?;
1847    Ok(KimiTranscript {
1848        cwd,
1849        edited_paths,
1850        events,
1851    })
1852}
1853
1854fn claude_edited_paths(path: &Path) -> Result<Vec<PathBuf>> {
1855    let mut files = vec![path.to_path_buf()];
1856    if let (Some(parent), Some(session_id)) = (
1857        path.parent(),
1858        path.file_stem().and_then(|value| value.to_str()),
1859    ) {
1860        let subagents = parent.join(session_id).join("subagents");
1861        if subagents.is_dir() {
1862            collect_files_named(&subagents, "jsonl", &mut files)?;
1863        }
1864    }
1865    let mut edited = BTreeSet::new();
1866    for file in files {
1867        let body = fs::read_to_string(&file)?;
1868        let mut calls = BTreeMap::<String, PathBuf>::new();
1869        let mut completed = BTreeSet::new();
1870        for line in body.lines().filter(|line| !line.trim().is_empty()) {
1871            let record: Value = serde_json::from_str(line)?;
1872            if record.get("type").and_then(Value::as_str) == Some("file-history-delta") {
1873                let Some(tracking) = record.get("trackingPath").and_then(Value::as_str) else {
1874                    continue;
1875                };
1876                let tracking = PathBuf::from(tracking);
1877                let path = if tracking.is_absolute() {
1878                    tracking
1879                } else if let Some(parent) = record
1880                    .pointer("/backup/realParentDir")
1881                    .and_then(Value::as_str)
1882                {
1883                    PathBuf::from(parent).join(
1884                        tracking
1885                            .file_name()
1886                            .expect("non-empty tracking path has a file name"),
1887                    )
1888                } else {
1889                    tracking
1890                };
1891                edited.insert(path);
1892            }
1893            if record.get("type").and_then(Value::as_str) == Some("assistant") {
1894                for block in record
1895                    .pointer("/message/content")
1896                    .and_then(Value::as_array)
1897                    .into_iter()
1898                    .flatten()
1899                {
1900                    if block.get("type").and_then(Value::as_str) != Some("tool_use")
1901                        || !matches!(
1902                            block.get("name").and_then(Value::as_str),
1903                            Some("Edit" | "Write" | "NotebookEdit")
1904                        )
1905                    {
1906                        continue;
1907                    }
1908                    let Some(id) = block.get("id").and_then(Value::as_str) else {
1909                        continue;
1910                    };
1911                    if let Some(path) = block
1912                        .pointer("/input/file_path")
1913                        .or_else(|| block.pointer("/input/notebook_path"))
1914                        .or_else(|| block.pointer("/input/path"))
1915                        .and_then(Value::as_str)
1916                    {
1917                        calls.insert(id.to_owned(), PathBuf::from(path));
1918                    }
1919                }
1920            }
1921            if record.get("type").and_then(Value::as_str) == Some("user") {
1922                for block in record
1923                    .pointer("/message/content")
1924                    .and_then(Value::as_array)
1925                    .into_iter()
1926                    .flatten()
1927                {
1928                    if block.get("type").and_then(Value::as_str) == Some("tool_result")
1929                        && block.get("is_error").and_then(Value::as_bool) != Some(true)
1930                        && let Some(id) = block.get("tool_use_id").and_then(Value::as_str)
1931                    {
1932                        completed.insert(id.to_owned());
1933                    }
1934                }
1935            }
1936        }
1937        edited.extend(
1938            calls
1939                .into_iter()
1940                .filter(|(id, _)| completed.contains(id))
1941                .map(|(_, path)| path),
1942        );
1943    }
1944    Ok(edited.into_iter().collect())
1945}
1946
1947fn kimi_edited_paths(session_path: &Path) -> Result<Vec<PathBuf>> {
1948    let agents = session_path.join("agents");
1949    if !agents.is_dir() {
1950        return Ok(Vec::new());
1951    }
1952    let mut files = Vec::new();
1953    collect_files_named(&agents, "jsonl", &mut files)?;
1954    let mut edited = BTreeSet::new();
1955    for file in files {
1956        let body = fs::read_to_string(file)?;
1957        let mut calls = BTreeMap::<String, PathBuf>::new();
1958        let mut completed = BTreeSet::new();
1959        for line in body.lines().filter(|line| !line.trim().is_empty()) {
1960            let record: Value = serde_json::from_str(line)?;
1961            if record.get("type").and_then(Value::as_str) != Some("context.append_loop_event") {
1962                continue;
1963            }
1964            let event = &record["event"];
1965            if event.get("type").and_then(Value::as_str) == Some("tool.call")
1966                && matches!(
1967                    event.get("name").and_then(Value::as_str),
1968                    Some("Edit" | "Write")
1969                )
1970                && let (Some(id), Some(path)) = (
1971                    event.get("toolCallId").and_then(Value::as_str),
1972                    event
1973                        .pointer("/args/path")
1974                        .or_else(|| event.pointer("/args/file_path"))
1975                        .and_then(Value::as_str),
1976                )
1977            {
1978                calls.insert(id.to_owned(), PathBuf::from(path));
1979            }
1980            if event.get("type").and_then(Value::as_str) == Some("tool.result")
1981                && event.pointer("/result/isError").and_then(Value::as_bool) != Some(true)
1982                && let Some(id) = event.get("toolCallId").and_then(Value::as_str)
1983            {
1984                completed.insert(id.to_owned());
1985            }
1986        }
1987        edited.extend(
1988            calls
1989                .into_iter()
1990                .filter(|(id, _)| completed.contains(id))
1991                .map(|(_, path)| path),
1992        );
1993    }
1994    Ok(edited.into_iter().collect())
1995}
1996
1997fn collect_files_named(root: &Path, extension: &str, output: &mut Vec<PathBuf>) -> Result<()> {
1998    for entry in fs::read_dir(root)? {
1999        let entry = entry?;
2000        let path = entry.path();
2001        let metadata = fs::symlink_metadata(&path)?;
2002        if metadata.file_type().is_symlink() {
2003            continue;
2004        }
2005        if metadata.is_dir() {
2006            collect_files_named(&path, extension, output)?;
2007        } else if metadata.is_file()
2008            && path.extension().and_then(|value| value.to_str()) == Some(extension)
2009        {
2010            output.push(path);
2011        }
2012    }
2013    Ok(())
2014}
2015
2016fn finish_imported_turn(events: &mut Vec<SequencedEvent>, recorded_at_ms: Option<i64>) {
2017    if !events.is_empty()
2018        && !matches!(
2019            events.last().map(|event| &event.event),
2020            Some(WorkerEvent::TurnCompleted)
2021        )
2022    {
2023        push_event(events, recorded_at_ms, WorkerEvent::TurnCompleted);
2024    }
2025}
2026
2027fn push_event(events: &mut Vec<SequencedEvent>, recorded_at_ms: Option<i64>, event: WorkerEvent) {
2028    events.push(SequencedEvent {
2029        seq: events.len() as u64 + 1,
2030        recorded_at_ms,
2031        request_id: None,
2032        event,
2033    });
2034}
2035
2036fn native_recorded_at_ms(record: &Value) -> Option<i64> {
2037    record
2038        .get("timestamp")
2039        .or_else(|| record.get("time"))
2040        .and_then(Value::as_str)
2041        .and_then(|timestamp| DateTime::parse_from_rfc3339(timestamp).ok())
2042        .map(|timestamp| timestamp.timestamp_millis())
2043}
2044
2045/// Native streams predate Hel's durable event clock in some harness versions.
2046/// Preserve their record timestamps when available; otherwise use the source
2047/// artifact's modification time. Clamping regressions keeps the imported
2048/// sequence and its activity watermark monotonic even if the native clock
2049/// moved backwards while the session was being recorded.
2050fn finalize_import_event_times(events: &mut [SequencedEvent], source_path: &Path) -> Result<()> {
2051    let Some(first) = events.first() else {
2052        return Ok(());
2053    };
2054    let mut last_recorded_at_ms = match events.iter().find_map(|event| event.recorded_at_ms) {
2055        Some(recorded_at_ms) => recorded_at_ms,
2056        None => DateTime::<Utc>::from(
2057            fs::metadata(source_path)
2058                .with_context(|| format!("stat import source {}", source_path.display()))?
2059                .modified()
2060                .with_context(|| format!("read import source mtime {}", source_path.display()))?,
2061        )
2062        .timestamp_millis(),
2063    };
2064    last_recorded_at_ms = first
2065        .recorded_at_ms
2066        .unwrap_or(last_recorded_at_ms)
2067        .max(last_recorded_at_ms);
2068    for event in events {
2069        last_recorded_at_ms = event
2070            .recorded_at_ms
2071            .unwrap_or(last_recorded_at_ms)
2072            .max(last_recorded_at_ms);
2073        event.recorded_at_ms = Some(last_recorded_at_ms);
2074    }
2075    Ok(())
2076}
2077
2078pub fn session_edit_targets(
2079    transcript: &ClaudeTranscript,
2080    profile_home: &Path,
2081) -> Result<SessionEditTargets> {
2082    session_edit_targets_with_scratch_prefixes(transcript, profile_home, &scratch_prefixes())
2083}
2084
2085/// Directories whose repositories are throwaway workspaces rather than
2086/// projects. A session that writes into one of them is still anchored on its
2087/// own repository.
2088fn scratch_prefixes() -> Vec<PathBuf> {
2089    let mut prefixes = Vec::new();
2090    let mut remember = |path: PathBuf| {
2091        let path = fs::canonicalize(&path).unwrap_or(path);
2092        if !prefixes.contains(&path) {
2093            prefixes.push(path);
2094        }
2095    };
2096    remember(std::env::temp_dir());
2097    for literal in ["/tmp", "/var/tmp", "/dev/shm"] {
2098        remember(PathBuf::from(literal));
2099    }
2100    prefixes
2101}
2102
2103fn session_edit_targets_with_scratch_prefixes(
2104    transcript: &ClaudeTranscript,
2105    profile_home: &Path,
2106    scratch_prefixes: &[PathBuf],
2107) -> Result<SessionEditTargets> {
2108    let profile_home =
2109        fs::canonicalize(profile_home).unwrap_or_else(|_| profile_home.to_path_buf());
2110    let mut paths = transcript
2111        .edited_paths
2112        .iter()
2113        .map(|path| {
2114            if path.is_absolute() {
2115                path.clone()
2116            } else {
2117                transcript.cwd.join(path)
2118            }
2119        })
2120        .filter(|path| {
2121            let comparable = canonicalize_existing_ancestor(path);
2122            !comparable.starts_with(&profile_home)
2123        })
2124        .collect::<Vec<_>>();
2125    if paths.is_empty() {
2126        paths.push(transcript.cwd.clone());
2127    }
2128
2129    let cwd_root = git_root_for_path(&transcript.cwd)?.with_context(|| {
2130        format!(
2131            "session cwd is not in a usable Git worktree: {}",
2132            transcript.cwd.display()
2133        )
2134    })?;
2135    // The session's own repository is authoritative even when it lives under a
2136    // temporary directory.
2137    let mut git_roots = BTreeSet::from([cwd_root.clone()]);
2138    let mut scratch_git_roots = BTreeSet::new();
2139    let mut non_git_dirs = BTreeSet::new();
2140    for path in paths {
2141        if let Some(root) = git_root_for_path(&path)? {
2142            if root != cwd_root && is_scratch_root(&root, scratch_prefixes) {
2143                scratch_git_roots.insert(root);
2144            } else {
2145                git_roots.insert(root);
2146            }
2147        } else {
2148            non_git_dirs.insert(edited_directory(&path));
2149        }
2150    }
2151    Ok(SessionEditTargets {
2152        git_roots: git_roots.into_iter().collect(),
2153        scratch_git_roots: scratch_git_roots.into_iter().collect(),
2154        non_git_dirs: non_git_dirs.into_iter().collect(),
2155    })
2156}
2157
2158fn is_scratch_root(root: &Path, scratch_prefixes: &[PathBuf]) -> bool {
2159    scratch_prefixes
2160        .iter()
2161        .any(|prefix| root.starts_with(prefix))
2162}
2163
2164fn canonicalize_existing_ancestor(path: &Path) -> PathBuf {
2165    let mut existing = path;
2166    let mut suffix = Vec::new();
2167    loop {
2168        if let Ok(mut canonical) = fs::canonicalize(existing) {
2169            for component in suffix.iter().rev() {
2170                canonical.push(component);
2171            }
2172            return canonical;
2173        }
2174        let Some(name) = existing.file_name() else {
2175            return path.to_path_buf();
2176        };
2177        suffix.push(name.to_os_string());
2178        let Some(parent) = existing.parent() else {
2179            return path.to_path_buf();
2180        };
2181        existing = parent;
2182    }
2183}
2184
2185fn edited_directory(path: &Path) -> PathBuf {
2186    if path.is_dir() {
2187        path.to_path_buf()
2188    } else {
2189        path.parent().unwrap_or(path).to_path_buf()
2190    }
2191}
2192
2193fn git_root_for_path(path: &Path) -> Result<Option<PathBuf>> {
2194    let mut probe = edited_directory(path);
2195    while !probe.is_dir() {
2196        if !probe.pop() {
2197            return Ok(None);
2198        }
2199    }
2200    let output = Command::new("git")
2201        .args(["rev-parse", "--show-toplevel"])
2202        .current_dir(&probe)
2203        .output()
2204        .with_context(|| format!("start git in {}", probe.display()))?;
2205    if !output.status.success() {
2206        return Ok(None);
2207    }
2208    let root = String::from_utf8(output.stdout).context("decode Git repository root")?;
2209    let root = PathBuf::from(root.trim());
2210    Ok(Some(fs::canonicalize(&root).unwrap_or(root)))
2211}
2212
2213#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2214pub(crate) enum RepositoryIdentity {
2215    Github(String, String),
2216    Local(PathBuf),
2217}
2218
2219fn root_identity(root: &Path) -> Result<RepositoryIdentity> {
2220    let origin = git_optional_text(root, ["remote", "get-url", "origin"])?;
2221    if let Some(github) = origin.as_deref().and_then(github_repository_from_origin) {
2222        return Ok(RepositoryIdentity::Github(
2223            github.owner.to_ascii_lowercase(),
2224            github.repository.to_ascii_lowercase(),
2225        ));
2226    }
2227    // A linked worktree shares the identity of its main working tree.
2228    let root = main_worktree_root(root)?;
2229    Ok(RepositoryIdentity::Local(
2230        fs::canonicalize(&root).unwrap_or(root),
2231    ))
2232}
2233
2234fn configured_repository_identity(repository: &ProjectRepository) -> Option<RepositoryIdentity> {
2235    if let Some(source) = repository.github.as_deref() {
2236        let github = github_repository_from_origin(source)?;
2237        return Some(RepositoryIdentity::Github(
2238            github.owner.to_ascii_lowercase(),
2239            github.repository.to_ascii_lowercase(),
2240        ));
2241    }
2242    repository.local.as_ref().map(|path| {
2243        RepositoryIdentity::Local(fs::canonicalize(path).unwrap_or_else(|_| path.clone()))
2244    })
2245}
2246
2247/// Reuse an exact configured bundle or synthesize one from all detected roots.
2248pub fn resolve_bundle(
2249    config: &Config,
2250    cwd: &Path,
2251    targets: &SessionEditTargets,
2252    requested_bundle: Option<&str>,
2253) -> Result<BundleResolution> {
2254    let cwd_root = git_root_for_path(cwd)?.context("session cwd is not in a Git worktree")?;
2255    // A linked worktree stands in for its main repository, so bundles are named
2256    // after and point at the main working tree.
2257    let cwd_root = main_worktree_root(&cwd_root)?;
2258    let primary_identity = root_identity(&cwd_root)?;
2259    let detected = targets
2260        .git_roots
2261        .iter()
2262        .map(|root| root_identity(root))
2263        .collect::<Result<BTreeSet<_>>>()?;
2264
2265    if let Some(bundle_id) = requested_bundle {
2266        let bundle = config
2267            .bundles
2268            .get(bundle_id)
2269            .with_context(|| format!("unknown bundle {bundle_id:?}"))?;
2270        ensure!(
2271            bundle_matches(bundle, &detected, &primary_identity),
2272            "bundle {bundle_id:?} does not exactly match the session's edited Git roots and cwd primary repository"
2273        );
2274        return Ok(BundleResolution::Existing(bundle_id.to_owned()));
2275    }
2276    if let Some(id) = config.bundles.iter().find_map(|(id, bundle)| {
2277        bundle_matches(bundle, &detected, &primary_identity).then(|| id.clone())
2278    }) {
2279        return Ok(BundleResolution::Existing(id));
2280    }
2281
2282    let primary_name = cwd_root
2283        .file_name()
2284        .and_then(|name| name.to_str())
2285        .unwrap_or("repository");
2286    let bundle_id = unique_bundle_id(config, &setup_style_id(primary_name));
2287    let mut used_ids = BTreeSet::new();
2288    let mut repositories = Vec::new();
2289    let mut primary_repo = None;
2290    let mut roots = targets
2291        .git_roots
2292        .iter()
2293        .map(|root| main_worktree_root(root))
2294        .collect::<Result<Vec<_>>>()?;
2295    roots.sort_by_key(|root| root != &cwd_root);
2296    // Checkouts and worktrees of one repository share an identity. Keep the
2297    // first root per identity so the cwd repository stays primary.
2298    let mut used_identities = BTreeSet::new();
2299    for root in roots {
2300        if !used_identities.insert(root_identity(&root)?) {
2301            continue;
2302        }
2303        let base = setup_style_id(
2304            root.file_name()
2305                .and_then(|name| name.to_str())
2306                .unwrap_or("repository"),
2307        );
2308        let mut id = base.clone();
2309        for suffix in 2_u32.. {
2310            if used_ids.insert(id.clone()) {
2311                break;
2312            }
2313            id = format!("{base}-{suffix}");
2314        }
2315        if root == cwd_root {
2316            primary_repo = Some(id.clone());
2317        }
2318        let origin = git_optional_text(&root, ["remote", "get-url", "origin"])?;
2319        let github = origin
2320            .as_deref()
2321            .and_then(github_repository_from_origin)
2322            .map(|source| format!("{}/{}", source.owner, source.repository));
2323        repositories.push(ProjectRepository {
2324            id: id.clone(),
2325            local: github.is_none().then_some(root),
2326            github,
2327            destination: PathBuf::from(id),
2328            git_ref: None,
2329        });
2330    }
2331    Ok(BundleResolution::Synthesized {
2332        id: bundle_id,
2333        bundle: ProjectBundle {
2334            primary_repo: primary_repo.context("detected roots omitted the cwd repository")?,
2335            repositories,
2336        },
2337    })
2338}
2339
2340pub(crate) fn bundle_matches(
2341    bundle: &ProjectBundle,
2342    detected: &BTreeSet<RepositoryIdentity>,
2343    primary: &RepositoryIdentity,
2344) -> bool {
2345    let identities = bundle
2346        .repositories
2347        .iter()
2348        .filter_map(configured_repository_identity)
2349        .collect::<BTreeSet<_>>();
2350    identities.len() == bundle.repositories.len()
2351        && &identities == detected
2352        && bundle
2353            .primary()
2354            .and_then(configured_repository_identity)
2355            .as_ref()
2356            == Some(primary)
2357}
2358
2359/// Return the matching configured bundle for an origin. It accepts setup's
2360/// `owner/repository` shorthand as well as normal GitHub remote URLs.
2361pub fn configured_bundle_for_origin(config: &Config, origin: &GithubRepository) -> Option<String> {
2362    config.bundles.iter().find_map(|(id, bundle)| {
2363        let primary = bundle.primary()?;
2364        let configured = github_repository_from_origin(primary.github.as_deref()?)?;
2365        same_github_repository(&configured, origin).then(|| id.clone())
2366    })
2367}
2368
2369pub fn configured_bundle_for_local(config: &Config, local: &Path) -> Option<String> {
2370    let local = fs::canonicalize(local).unwrap_or_else(|_| local.to_path_buf());
2371    config.bundles.iter().find_map(|(id, bundle)| {
2372        let configured = bundle.primary()?.local.as_ref()?;
2373        let configured = fs::canonicalize(configured).unwrap_or_else(|_| configured.to_path_buf());
2374        (configured == local).then(|| id.clone())
2375    })
2376}
2377
2378fn same_github_repository(left: &GithubRepository, right: &GithubRepository) -> bool {
2379    left.owner.eq_ignore_ascii_case(&right.owner)
2380        && left.repository.eq_ignore_ascii_case(&right.repository)
2381}
2382
2383pub(crate) fn setup_style_id(value: &str) -> String {
2384    let mut id = value
2385        .chars()
2386        .filter(|character| {
2387            character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
2388        })
2389        .take(64)
2390        .collect::<String>();
2391    if id.is_empty() || matches!(id.as_str(), "." | "..") {
2392        id = "repository".into();
2393    }
2394    id
2395}
2396
2397pub(crate) fn unique_bundle_id(config: &Config, base: &str) -> String {
2398    if !config.bundles.contains_key(base) {
2399        return base.into();
2400    }
2401    let base = format!("import-{base}");
2402    if !config.bundles.contains_key(&base) {
2403        return base;
2404    }
2405    for suffix in 2_u32.. {
2406        let candidate = format!("{base}-{suffix}");
2407        if !config.bundles.contains_key(&candidate) {
2408            return candidate;
2409        }
2410    }
2411    unreachable!("u32 bundle suffixes are finite")
2412}
2413
2414/// Build, verify, and install a local archive, then update the in-memory state.
2415/// The caller saves `state` only after this returns successfully.
2416pub fn import_claude_session(
2417    config: &Config,
2418    state: &mut State,
2419    request: ClaudeImportRequest<'_>,
2420) -> Result<ImportedClaudeSession> {
2421    import_claude_session_inner(config, state, request, None)
2422}
2423
2424pub fn import_claude_session_with_control(
2425    config: &Config,
2426    state: &mut State,
2427    request: ClaudeImportRequest<'_>,
2428    control: &ImportControl<'_>,
2429) -> Result<ImportedClaudeSession> {
2430    import_claude_session_inner(config, state, request, Some(control))
2431}
2432
2433fn import_claude_session_inner(
2434    config: &Config,
2435    state: &mut State,
2436    request: ClaudeImportRequest<'_>,
2437    control: Option<&ImportControl<'_>>,
2438) -> Result<ImportedClaudeSession> {
2439    let ClaudeImportRequest {
2440        claude_home,
2441        source,
2442        transcript,
2443        bundle_id,
2444        profile_id,
2445        title,
2446        archive_directory,
2447    } = request;
2448    let bundle = config
2449        .bundles
2450        .get(bundle_id)
2451        .with_context(|| format!("unknown bundle {bundle_id:?}"))?;
2452    let session_title_override = title.map(str::to_owned);
2453    let title = match session_title_override.as_deref() {
2454        Some(title) if !title.trim().is_empty() => title.to_owned(),
2455        Some(_) => bail!("import title must not be empty"),
2456        None => harness_session_title(&transcript.events)
2457            .unwrap_or_else(|| format!("Imported Claude session {}", source.native_session_id)),
2458    };
2459    let targets = session_edit_targets(transcript, claude_home)?;
2460    let raw_project = raw_project_import(config, &targets);
2461    let repositories =
2462        collect_local_repositories(bundle, &targets.git_roots, raw_project.is_none(), control)?;
2463    let native_artifacts = collect_native_artifacts(
2464        HarnessKind::Claude,
2465        claude_home,
2466        &source.native_session_id,
2467        false,
2468    )?;
2469    let session_id = new_session_id()?;
2470    let canonical_session =
2471        canonical_import_session(&session_id, &transcript.events, &source.jsonl_path)?;
2472    let timestamp = timestamp();
2473    let profile_id = import_profile_id(config, profile_id, HarnessKind::Claude, claude_home)?;
2474    let target_id = default_import_target_id(config);
2475    let archive_path = archive_directory.join(format!("{session_id}.hel.zip"));
2476    if let Some(control) = control {
2477        control.report(ImportArchiveProgress::WritingArchive)?;
2478    }
2479    let verified = write_archive_atomic(
2480        &archive_path,
2481        &ArchiveInput {
2482            session: mj_checkpoint::archive::SessionManifest {
2483                id: session_id.clone(),
2484                title: title.clone(),
2485                harness_kind: HarnessKind::Claude,
2486                profile_id: profile_id.clone(),
2487                native_session_id: source.native_session_id.clone(),
2488                created_at: timestamp.clone(),
2489                checkpointed_at: timestamp.clone(),
2490                hel_version: env!("CARGO_PKG_VERSION").into(),
2491                relay_version: env!("CARGO_PKG_VERSION").into(),
2492                adapter_version: "acp-v1".into(),
2493            },
2494            target: TargetManifest {
2495                template_id: target_id.clone(),
2496                target_kind: "import".into(),
2497                details: BTreeMap::from([("source".into(), "claude-import".into())]),
2498            },
2499            bundle: BundleManifest {
2500                id: bundle_id.to_owned(),
2501                primary_repository: bundle.primary_repo.clone(),
2502            },
2503            canonical_session,
2504            native_artifacts,
2505            repositories,
2506        },
2507    )?;
2508    if let Some(control) = control
2509        && let Err(error) = control.check_cancelled()
2510    {
2511        let _ = fs::remove_file(&archive_path);
2512        return Err(error);
2513    }
2514    let checkpoint = CheckpointMetadata {
2515        archive_path: archive_path.clone(),
2516        sha256: verified.archive_sha256,
2517        created_at: timestamp.clone(),
2518        event_frontier: transcript.events.last().map_or(0, |event| event.seq),
2519    };
2520    state.sessions.insert(
2521        session_id.clone(),
2522        SessionRecord {
2523            mjolnir_subagents: None,
2524            create_managed_worktree: None,
2525            workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
2526            archived: false,
2527            container_cpus: None,
2528            container_memory: None,
2529            id: session_id.clone(),
2530            title,
2531            harness_kind: HarnessKind::Claude,
2532            last_profile: profile_id,
2533            bundle_id: bundle_id.to_owned(),
2534            project_directory: raw_project.as_ref().map(|(directory, _)| directory.clone()),
2535            managed_worktree: None,
2536            target_template_id: raw_project.map_or(target_id, |(_, raw_target_id)| raw_target_id),
2537            resource_allocation: None,
2538            additional_mounts: Vec::new(),
2539            state: SessionState::Stopped,
2540            target: None,
2541            native_session_id: Some(source.native_session_id.clone()),
2542            acp_session_title: None,
2543            session_title_override,
2544            created_at: timestamp.clone(),
2545            updated_at: timestamp,
2546            viewed_through_event_ordinal: 0,
2547            draft_input: String::new(),
2548            last_error: None,
2549            last_checkpoint_error: None,
2550            checkpoint: Some(checkpoint),
2551        },
2552    );
2553    Ok(ImportedClaudeSession {
2554        session_id,
2555        native_session_id: source.native_session_id.clone(),
2556        source_jsonl: source.jsonl_path.clone(),
2557        source_cwd: transcript.cwd.clone(),
2558        bundle_id: bundle_id.to_owned(),
2559        archive_path,
2560    })
2561}
2562
2563pub fn import_codex_session(
2564    config: &Config,
2565    state: &mut State,
2566    request: CodexImportRequest<'_>,
2567) -> Result<ImportedCodexSession> {
2568    import_codex_session_inner(config, state, request, None)
2569}
2570
2571pub fn import_codex_session_with_control(
2572    config: &Config,
2573    state: &mut State,
2574    request: CodexImportRequest<'_>,
2575    control: &ImportControl<'_>,
2576) -> Result<ImportedCodexSession> {
2577    import_codex_session_inner(config, state, request, Some(control))
2578}
2579
2580fn import_codex_session_inner(
2581    config: &Config,
2582    state: &mut State,
2583    request: CodexImportRequest<'_>,
2584    control: Option<&ImportControl<'_>>,
2585) -> Result<ImportedCodexSession> {
2586    let CodexImportRequest {
2587        codex_home,
2588        source,
2589        transcript,
2590        bundle_id,
2591        profile_id,
2592        title,
2593        archive_directory,
2594    } = request;
2595    import_native_session(
2596        config,
2597        state,
2598        NativeImportRequest {
2599            harness: HarnessKind::Codex,
2600            harness_home: codex_home,
2601            native_session_id: &source.native_session_id,
2602            source_path: &source.jsonl_path,
2603            transcript,
2604            bundle_id,
2605            profile_id,
2606            title,
2607            archive_directory,
2608        },
2609        control,
2610    )
2611}
2612
2613pub fn import_grok_session(
2614    config: &Config,
2615    state: &mut State,
2616    request: GrokImportRequest<'_>,
2617) -> Result<ImportedGrokSession> {
2618    import_grok_session_inner(config, state, request, None)
2619}
2620
2621pub fn import_grok_session_with_control(
2622    config: &Config,
2623    state: &mut State,
2624    request: GrokImportRequest<'_>,
2625    control: &ImportControl<'_>,
2626) -> Result<ImportedGrokSession> {
2627    import_grok_session_inner(config, state, request, Some(control))
2628}
2629
2630fn import_grok_session_inner(
2631    config: &Config,
2632    state: &mut State,
2633    request: GrokImportRequest<'_>,
2634    control: Option<&ImportControl<'_>>,
2635) -> Result<ImportedGrokSession> {
2636    let GrokImportRequest {
2637        grok_home,
2638        source,
2639        transcript,
2640        bundle_id,
2641        profile_id,
2642        title,
2643        archive_directory,
2644    } = request;
2645    import_native_session(
2646        config,
2647        state,
2648        NativeImportRequest {
2649            harness: HarnessKind::Grok,
2650            harness_home: grok_home,
2651            native_session_id: &source.native_session_id,
2652            source_path: &source.session_path,
2653            transcript,
2654            bundle_id,
2655            profile_id,
2656            title,
2657            archive_directory,
2658        },
2659        control,
2660    )
2661}
2662
2663pub fn import_kimi_session(
2664    config: &Config,
2665    state: &mut State,
2666    request: KimiImportRequest<'_>,
2667) -> Result<ImportedKimiSession> {
2668    import_kimi_session_inner(config, state, request, None)
2669}
2670
2671pub fn import_kimi_session_with_control(
2672    config: &Config,
2673    state: &mut State,
2674    request: KimiImportRequest<'_>,
2675    control: &ImportControl<'_>,
2676) -> Result<ImportedKimiSession> {
2677    import_kimi_session_inner(config, state, request, Some(control))
2678}
2679
2680fn import_kimi_session_inner(
2681    config: &Config,
2682    state: &mut State,
2683    request: KimiImportRequest<'_>,
2684    control: Option<&ImportControl<'_>>,
2685) -> Result<ImportedKimiSession> {
2686    let KimiImportRequest {
2687        kimi_home,
2688        source,
2689        transcript,
2690        bundle_id,
2691        profile_id,
2692        title,
2693        archive_directory,
2694    } = request;
2695    import_native_session(
2696        config,
2697        state,
2698        NativeImportRequest {
2699            harness: HarnessKind::Kimi,
2700            harness_home: kimi_home,
2701            native_session_id: &source.native_session_id,
2702            source_path: &source.session_path,
2703            transcript,
2704            bundle_id,
2705            profile_id,
2706            title,
2707            archive_directory,
2708        },
2709        control,
2710    )
2711}
2712
2713pub struct NativeImportRequest<'a> {
2714    pub harness: HarnessKind,
2715    pub harness_home: &'a Path,
2716    pub native_session_id: &'a str,
2717    pub source_path: &'a Path,
2718    pub transcript: &'a ClaudeTranscript,
2719    pub bundle_id: &'a str,
2720    pub profile_id: Option<&'a str>,
2721    pub title: Option<&'a str>,
2722    pub archive_directory: &'a Path,
2723}
2724
2725/// Import one already-located, already-parsed native session, for any harness.
2726/// The per-harness `import_*_session` wrappers are thin adapters over this.
2727pub fn import_native_session_with_control(
2728    config: &Config,
2729    state: &mut State,
2730    request: NativeImportRequest<'_>,
2731    control: &ImportControl<'_>,
2732) -> Result<ImportedClaudeSession> {
2733    import_native_session(config, state, request, Some(control))
2734}
2735
2736pub fn import_native_session(
2737    config: &Config,
2738    state: &mut State,
2739    request: NativeImportRequest<'_>,
2740    control: Option<&ImportControl<'_>>,
2741) -> Result<ImportedClaudeSession> {
2742    let NativeImportRequest {
2743        harness,
2744        harness_home,
2745        native_session_id,
2746        source_path,
2747        transcript,
2748        bundle_id,
2749        profile_id,
2750        title,
2751        archive_directory,
2752    } = request;
2753    let bundle = config
2754        .bundles
2755        .get(bundle_id)
2756        .with_context(|| format!("unknown bundle {bundle_id:?}"))?;
2757    let session_title_override = title.map(str::to_owned);
2758    let title = match session_title_override.as_deref() {
2759        Some(title) if !title.trim().is_empty() => title.to_owned(),
2760        Some(_) => bail!("import title must not be empty"),
2761        None => harness_session_title(&transcript.events).unwrap_or_else(|| {
2762            format!(
2763                "Imported {} session {native_session_id}",
2764                harness.display_name()
2765            )
2766        }),
2767    };
2768    let targets = session_edit_targets(transcript, harness_home)?;
2769    let raw_project = raw_project_import(config, &targets);
2770    let repositories =
2771        collect_local_repositories(bundle, &targets.git_roots, raw_project.is_none(), control)?;
2772    let native_artifacts =
2773        collect_import_native_artifacts(harness, harness_home, native_session_id, source_path)?;
2774    if matches!(harness, HarnessKind::Deepseek | HarnessKind::Muse) {
2775        // The preview may precede a user's confirmation by minutes. Never
2776        // pair its old transcript with a newer native conversation.
2777        if let Some(control) = control {
2778            control.check_cancelled()?;
2779        }
2780        let current = read_native_transcript(harness, source_path)?;
2781        ensure!(
2782            current.cwd == transcript.cwd
2783                && current.edited_paths == transcript.edited_paths
2784                && serde_json::to_value(&current.events)?
2785                    == serde_json::to_value(&transcript.events)?,
2786            "native session changed after it was selected; select it again"
2787        );
2788        ensure!(
2789            native_artifacts
2790                == collect_import_native_artifacts(
2791                    harness,
2792                    harness_home,
2793                    native_session_id,
2794                    source_path
2795                )?,
2796            "native session changed while being imported; stop its harness and retry"
2797        );
2798    }
2799    let session_id = new_session_id()?;
2800    let canonical_session =
2801        canonical_import_session(session_id.as_str(), &transcript.events, source_path)?;
2802    let timestamp = timestamp();
2803    let profile_id = import_profile_id(config, profile_id, harness, harness_home)?;
2804    let target_id = default_import_target_id(config);
2805    let archive_path = archive_directory.join(format!("{session_id}.hel.zip"));
2806    if let Some(control) = control {
2807        control.report(ImportArchiveProgress::WritingArchive)?;
2808    }
2809    let verified = write_archive_atomic(
2810        &archive_path,
2811        &ArchiveInput {
2812            session: mj_checkpoint::archive::SessionManifest {
2813                id: session_id.clone(),
2814                title: title.clone(),
2815                harness_kind: harness,
2816                profile_id: profile_id.clone(),
2817                native_session_id: native_session_id.to_owned(),
2818                created_at: timestamp.clone(),
2819                checkpointed_at: timestamp.clone(),
2820                hel_version: env!("CARGO_PKG_VERSION").into(),
2821                relay_version: env!("CARGO_PKG_VERSION").into(),
2822                adapter_version: "acp-v1".into(),
2823            },
2824            target: TargetManifest {
2825                template_id: target_id.clone(),
2826                target_kind: "import".into(),
2827                details: BTreeMap::from([("source".into(), format!("{}-import", harness.id()))]),
2828            },
2829            bundle: BundleManifest {
2830                id: bundle_id.to_owned(),
2831                primary_repository: bundle.primary_repo.clone(),
2832            },
2833            canonical_session,
2834            native_artifacts,
2835            repositories,
2836        },
2837    )?;
2838    if let Some(control) = control
2839        && let Err(error) = control.check_cancelled()
2840    {
2841        let _ = fs::remove_file(&archive_path);
2842        return Err(error);
2843    }
2844    let checkpoint = CheckpointMetadata {
2845        archive_path: archive_path.clone(),
2846        sha256: verified.archive_sha256,
2847        created_at: timestamp.clone(),
2848        event_frontier: transcript.events.last().map_or(0, |event| event.seq),
2849    };
2850    state.sessions.insert(
2851        session_id.clone(),
2852        SessionRecord {
2853            mjolnir_subagents: None,
2854            create_managed_worktree: None,
2855            workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
2856            archived: false,
2857            container_cpus: None,
2858            container_memory: None,
2859            id: session_id.clone(),
2860            title,
2861            harness_kind: harness,
2862            last_profile: profile_id,
2863            bundle_id: bundle_id.to_owned(),
2864            project_directory: raw_project.as_ref().map(|(directory, _)| directory.clone()),
2865            managed_worktree: None,
2866            target_template_id: raw_project.map_or(target_id, |(_, raw_target_id)| raw_target_id),
2867            resource_allocation: None,
2868            additional_mounts: Vec::new(),
2869            state: SessionState::Stopped,
2870            target: None,
2871            native_session_id: Some(native_session_id.to_owned()),
2872            acp_session_title: None,
2873            session_title_override,
2874            created_at: timestamp.clone(),
2875            updated_at: timestamp,
2876            viewed_through_event_ordinal: 0,
2877            draft_input: String::new(),
2878            last_error: None,
2879            last_checkpoint_error: None,
2880            checkpoint: Some(checkpoint),
2881        },
2882    );
2883    Ok(ImportedClaudeSession {
2884        session_id,
2885        native_session_id: native_session_id.to_owned(),
2886        source_jsonl: source_path.to_path_buf(),
2887        source_cwd: transcript.cwd.clone(),
2888        bundle_id: bundle_id.to_owned(),
2889        archive_path,
2890    })
2891}
2892
2893fn default_import_target_id(config: &Config) -> String {
2894    config
2895        .targets
2896        .get_key_value("podman")
2897        .map(|(id, _)| id)
2898        .or_else(|| {
2899            config.targets.iter().find_map(|(id, target)| {
2900                matches!(
2901                    target,
2902                    TargetTemplate::LocalPodman { .. }
2903                        | TargetTemplate::LocalDocker { .. }
2904                        | TargetTemplate::SshPodman { .. }
2905                        | TargetTemplate::SshDocker { .. }
2906                )
2907                .then_some(id)
2908            })
2909        })
2910        .or_else(|| config.targets.keys().next())
2911        .cloned()
2912        .unwrap_or_else(|| "import".into())
2913}
2914
2915/// Target that hosts raw project sessions on this machine.
2916fn raw_import_target_id(config: &Config) -> Option<String> {
2917    let local_bare = |template: &TargetTemplate| matches!(template, TargetTemplate::LocalBare);
2918    config
2919        .targets
2920        .get_key_value("localhost")
2921        .filter(|(_, template)| local_bare(template))
2922        .map(|(id, _)| id.clone())
2923        .or_else(|| {
2924            config
2925                .targets
2926                .iter()
2927                .find_map(|(id, template)| local_bare(template).then(|| id.clone()))
2928        })
2929}
2930
2931/// A session that only wrote to its own repository can keep working in that
2932/// directory, so import it as a raw project session instead of a bundle
2933/// session. `session_edit_targets` always records the cwd root, so a single
2934/// durable root is that root.
2935pub fn raw_project_import(
2936    config: &Config,
2937    targets: &SessionEditTargets,
2938) -> Option<(PathBuf, String)> {
2939    let [cwd_root] = targets.git_roots.as_slice() else {
2940        return None;
2941    };
2942    Some((cwd_root.clone(), raw_import_target_id(config)?))
2943}
2944
2945fn collect_local_repositories(
2946    bundle: &ProjectBundle,
2947    detected_roots: &[PathBuf],
2948    isolated: bool,
2949    control: Option<&ImportControl<'_>>,
2950) -> Result<Vec<mj_checkpoint::archive::RepositorySnapshot>> {
2951    let detected = detected_roots
2952        .iter()
2953        .map(|root| Ok((root_identity(root)?, root.clone())))
2954        .collect::<Result<BTreeMap<_, _>>>()?;
2955    let repository_paths = bundle
2956        .repositories
2957        .iter()
2958        .map(|repository| {
2959            // A local source remains identified by its configured path even
2960            // after its checkout gains a network origin. GitHub-origin
2961            // detection is still used for configured network sources.
2962            let path = if let Some(configured_path) = repository.local.as_ref() {
2963                let configured_path =
2964                    fs::canonicalize(configured_path).unwrap_or_else(|_| configured_path.clone());
2965                detected_roots
2966                    .iter()
2967                    .find(|root| {
2968                        fs::canonicalize(root).unwrap_or_else(|_| (*root).clone())
2969                            == configured_path
2970                    })
2971                    .cloned()
2972            } else {
2973                let identity = configured_repository_identity(repository).with_context(|| {
2974                    format!("repository {:?} has no usable source", repository.id)
2975                })?;
2976                detected.get(&identity).cloned()
2977            };
2978            let path = path.with_context(|| {
2979                format!(
2980                    "repository {:?} was not detected in the native session",
2981                    repository.id
2982                )
2983            })?;
2984            Ok((repository.id.clone(), path))
2985        })
2986        .collect::<Result<BTreeMap<_, _>>>()?;
2987    let git = SystemGit;
2988    let repository_count = bundle.repositories.len();
2989    bundle
2990        .repositories
2991        // Indexed parallel iteration keeps repository and manifest order
2992        // identical to the configured bundle.
2993        .par_iter()
2994        .enumerate()
2995        .map(|(index, repository)| {
2996            if let Some(control) = control {
2997                control.report(ImportArchiveProgress::Repository {
2998                    current: index + 1,
2999                    total: repository_count,
3000                    id: repository.id.clone(),
3001                })?;
3002            }
3003            let path = repository_paths
3004                .get(&repository.id)
3005                .expect("repository paths cover the validated bundle")
3006                .clone();
3007            ensure!(
3008                path.is_dir(),
3009                "local repository {:?} is missing at {}",
3010                repository.id,
3011                path.display()
3012            );
3013            let source = isolated
3014                .then(|| {
3015                    resolve_repository(repository, &ProcessExecutor)
3016                        .with_context(|| format!("resolve network source for {:?}", repository.id))
3017                })
3018                .transpose()?;
3019            // Isolated imports restore the native session's committed work on
3020            // top of the source's available network baseline. Raw imports
3021            // continue to use the live local checkout, including repositories
3022            // without any remote.
3023            let history = if isolated {
3024                GitHistoryMode::DeltaFrom(import_delta_base(
3025                    &path,
3026                    &source.as_ref().expect("isolated source resolved").fetch_url,
3027                )?)
3028            } else {
3029                GitHistoryMode::NoBundle
3030            };
3031            let origin_override = if let Some(source) = &source {
3032                Some(source.fetch_url.clone())
3033            } else {
3034                Some(path.to_string_lossy().into_owned())
3035            };
3036            let mut snapshot = collect_git_snapshot_with_progress(
3037                &git,
3038                &path,
3039                &GitCollectionSpec {
3040                    id: repository.id.clone(),
3041                    relative_destination: repository.destination.clone(),
3042                    history,
3043                    origin_override,
3044                },
3045                control.is_none_or(|control| control.include_untracked),
3046                &|progress| {
3047                    let Some(control) = control else {
3048                        return Ok(());
3049                    };
3050                    match progress {
3051                        GitSnapshotProgress::UntrackedFile {
3052                            current,
3053                            total,
3054                            path,
3055                        } => control.report(ImportArchiveProgress::UntrackedFile {
3056                            repository_id: repository.id.clone(),
3057                            current,
3058                            total,
3059                            path,
3060                        }),
3061                    }
3062                },
3063            )
3064            .with_context(|| format!("collect local repository {:?}", repository.id))?;
3065            if let Some(source) = source {
3066                snapshot.metadata.push_urls = source
3067                    .push_urls
3068                    .iter()
3069                    .map(|url| mj_checkpoint::archive::redact_origin_credentials(url))
3070                    .collect::<Result<Vec<_>>>()?;
3071                snapshot.metadata.remote_workspace = true;
3072            } else {
3073                snapshot.metadata.push_urls.clear();
3074            }
3075            Ok(snapshot)
3076        })
3077        .collect()
3078}
3079
3080fn canonical_import_session(
3081    session_id: &str,
3082    events: &[SequencedEvent],
3083    source_path: &Path,
3084) -> Result<mj_checkpoint::archive::CanonicalSessionSnapshot> {
3085    let mut events = events.to_vec();
3086    finalize_import_event_times(&mut events, source_path)?;
3087    let mut materialized =
3088        mj_transcript::projection::imported_materialized_session(session_id, &events);
3089    materialized.session_title = harness_session_title(&events);
3090    if let Some(last_activity_at_ms) = events.iter().filter_map(|event| event.recorded_at_ms).max()
3091    {
3092        materialized.last_activity_at_ms = Some(
3093            materialized
3094                .last_activity_at_ms
3095                .map_or(last_activity_at_ms, |current| {
3096                    current.max(last_activity_at_ms)
3097                }),
3098        );
3099    }
3100    canonical_session_from_materialized(&materialized)
3101}
3102
3103fn default_profile(config: &Config, harness: HarnessKind, home: &Path) -> String {
3104    let source = fs::canonicalize(home).unwrap_or_else(|_| home.to_path_buf());
3105    config
3106        .enabled_profiles()
3107        .find(|(_, profile)| {
3108            profile.kind == harness
3109                && fs::canonicalize(&profile.home).unwrap_or_else(|_| profile.home.clone())
3110                    == source
3111        })
3112        .or_else(|| {
3113            config
3114                .enabled_profiles()
3115                .find(|(_, profile)| profile.kind == harness)
3116        })
3117        .map(|(id, _)| id.to_owned())
3118        .unwrap_or_else(|| format!("{}-import", harness.id()))
3119}
3120
3121fn import_profile_id(
3122    config: &Config,
3123    requested: Option<&str>,
3124    harness: HarnessKind,
3125    home: &Path,
3126) -> Result<String> {
3127    let Some(requested) = requested else {
3128        return Ok(default_profile(config, harness, home));
3129    };
3130    let profile = config
3131        .profiles
3132        .get(requested)
3133        .with_context(|| format!("unknown import profile {requested:?}"))?;
3134    ensure!(profile.enabled, "import profile {requested:?} is disabled");
3135    ensure!(
3136        profile.kind == harness,
3137        "import profile {requested:?} does not use {harness:?}"
3138    );
3139    Ok(requested.to_owned())
3140}
3141
3142/// The upstream revision an imported repository deltas from. A repository
3143/// without remote-tracking refs cannot tell us which ancestry a newly
3144/// provisioned clone has, and Hel never bundles full history, so it fails here.
3145fn import_delta_base(path: &Path, fetch_url: &str) -> Result<String> {
3146    let remotes = git_optional_text(path, ["remote"])?.unwrap_or_default();
3147    let upstream_ref = git_optional_text(
3148        path,
3149        [
3150            "rev-parse",
3151            "--symbolic-full-name",
3152            "--verify",
3153            "--quiet",
3154            "@{upstream}",
3155        ],
3156    )?;
3157    for remote in remotes.lines() {
3158        let Some(url) = git_optional_text(path, ["remote", "get-url", remote])? else {
3159            continue;
3160        };
3161        let same_source = url == fetch_url
3162            || mj_core::state::ProjectSourceIdentity::git_remote(&url).is_some_and(|identity| {
3163                Some(identity) == mj_core::state::ProjectSourceIdentity::git_remote(fetch_url)
3164            });
3165        if !same_source {
3166            continue;
3167        }
3168        let prefix = format!("refs/remotes/{remote}/");
3169        let revision = upstream_ref
3170            .as_deref()
3171            .filter(|reference| reference.starts_with(&prefix))
3172            .map(str::to_owned)
3173            .unwrap_or_else(|| format!("{prefix}HEAD"));
3174        if let Some(base) =
3175            git_optional_text(path, ["rev-parse", "--verify", "--quiet", &revision])?
3176        {
3177            return Ok(base);
3178        }
3179    }
3180    bail!(
3181        "repository {} has no remote-tracking refs to import against for its selected network source; fetch its remote first",
3182        path.display()
3183    )
3184}
3185
3186fn git_optional_text<const N: usize>(cwd: &Path, arguments: [&str; N]) -> Result<Option<String>> {
3187    let output = Command::new("git")
3188        .args(arguments)
3189        .current_dir(cwd)
3190        .output()
3191        .with_context(|| format!("start git in {}", cwd.display()))?;
3192    if !output.status.success() {
3193        return Ok(None);
3194    }
3195    let text = String::from_utf8(output.stdout).context("decode Git output")?;
3196    Ok((!text.trim().is_empty()).then(|| text.trim().to_owned()))
3197}
3198
3199fn timestamp() -> String {
3200    Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
3201}
3202
3203#[cfg(test)]
3204mod tests;
3205
3206pub fn persist_imported_session_locally(session: &SessionRecord) -> Result<()> {
3207    crate::database::save_session(session)?;
3208    let checkpoint = session
3209        .checkpoint
3210        .as_ref()
3211        .context("imported session has no checkpoint")?;
3212    let canonical = mj_checkpoint::archive::verify_archive_streaming(&checkpoint.archive_path)?
3213        .canonical_session;
3214    let materialized = mj_transcript::projection::materialized_session_from_canonical(
3215        session.id.clone(),
3216        &canonical,
3217    )?;
3218    crate::database::save_materialized_session(&materialized)
3219}