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