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