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