Skip to main content

mj_controller/
hel_import.rs

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