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