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