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