Skip to main content

mj_controller/
hel_import.rs

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