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