Skip to main content

mj_controller/
import.rs

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