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