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