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