1use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Error, Result};
14use crate::reduce::ReductionLog;
15
16#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
18#[non_exhaustive]
19pub struct SessionInfo {
20 pub name: String,
22 #[serde(default)]
24 pub title: String,
25 #[serde(default)]
27 pub archived: bool,
28 #[serde(default)]
32 pub reduced: bool,
33 #[serde(default)]
36 pub tier: String,
37 #[serde(default)]
39 pub full_bytes: u64,
40 #[serde(default)]
43 pub view_bytes: u64,
44 #[serde(default)]
47 pub stub_count: u32,
48 #[serde(default)]
50 pub escalations: u32,
51 #[serde(default)]
57 pub format_version: u32,
58}
59
60pub struct SessionStore {
62 root: PathBuf,
63}
64
65impl SessionStore {
66 pub fn at(root: impl Into<PathBuf>) -> Self {
71 SessionStore { root: root.into() }
72 }
73
74 pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
76 let root = root.into();
77 std::fs::create_dir_all(&root)?;
78 Ok(Self::at(root))
79 }
80
81 fn validate_name(name: &str) -> Result<()> {
86 let bad = name.is_empty()
87 || name.contains('/')
88 || name.contains('\\')
89 || name.contains('\0')
90 || name.split(['/', '\\']).any(|c| c == ".." || c == ".")
91 || std::path::Path::new(name).is_absolute()
92 || name.trim() != name;
93 if bad {
94 return Err(Error::Other(format!("invalid session name: `{name}`")));
95 }
96 Ok(())
97 }
98
99 fn transcript_path(&self, name: &str, archived: bool) -> PathBuf {
100 self.dir(archived).join(format!("{name}.jsonl"))
101 }
102 fn meta_path(&self, name: &str, archived: bool) -> PathBuf {
103 self.dir(archived).join(format!("{name}.meta.json"))
104 }
105 fn sidecar_path_in(&self, name: &str, archived: bool) -> PathBuf {
108 self.dir(archived).join(format!("{name}.sidecar.jsonl"))
109 }
110 fn reduction_path(&self, name: &str, archived: bool) -> PathBuf {
113 self.dir(archived).join(format!("{name}.reduction.json"))
114 }
115 fn events_path(&self, name: &str, archived: bool) -> PathBuf {
121 self.dir(archived).join(format!("{name}.events.jsonl"))
122 }
123 fn usage_path(&self, name: &str, archived: bool) -> PathBuf {
127 self.dir(archived).join(format!("{name}.usage.jsonl"))
128 }
129 fn model_change_path(&self, name: &str, archived: bool) -> PathBuf {
134 self.dir(archived)
135 .join(format!("{name}.model_change.jsonl"))
136 }
137 fn git_metadata_path(&self, name: &str, archived: bool) -> PathBuf {
143 self.dir(archived).join(format!("{name}.git.json"))
144 }
145 fn goal_path(&self, name: &str, archived: bool) -> PathBuf {
150 self.dir(archived).join(format!("{name}.goal.json"))
151 }
152 fn fork_path(&self, name: &str, archived: bool) -> PathBuf {
158 self.dir(archived).join(format!("{name}.fork.json"))
159 }
160 fn tree_path(&self, name: &str, archived: bool) -> PathBuf {
170 self.dir(archived).join(format!("{name}.tree.json"))
171 }
172 fn journal_path_in(&self, name: &str, archived: bool) -> PathBuf {
181 self.dir(archived).join(format!("{name}.journal.jsonl"))
182 }
183 fn plan_path_in(&self, name: &str, archived: bool) -> PathBuf {
188 self.dir(archived).join(format!("{name}.plan.json"))
189 }
190 fn claude_runtime_path(&self, name: &str, archived: bool) -> PathBuf {
194 self.dir(archived)
195 .join(format!("{name}.claude-runtime.json"))
196 }
197 fn subagents_dir(&self, parent_name: &str, archived: bool) -> PathBuf {
206 self.dir(archived).join(format!("{parent_name}.subagents"))
207 }
208 fn subagent_transcript_path(
209 &self,
210 parent_name: &str,
211 child_id: &str,
212 archived: bool,
213 ) -> PathBuf {
214 self.subagents_dir(parent_name, archived)
215 .join(format!("{child_id}.sidecar.jsonl"))
216 }
217 fn subagent_lineage_path(&self, parent_name: &str, child_id: &str, archived: bool) -> PathBuf {
218 self.subagents_dir(parent_name, archived)
219 .join(format!("{child_id}.lineage.json"))
220 }
221 fn dir(&self, archived: bool) -> PathBuf {
222 if archived {
223 self.root.join("archived")
224 } else {
225 self.root.clone()
226 }
227 }
228
229 pub fn sidecar_path(&self, name: &str) -> PathBuf {
236 self.sidecar_path_in(name, false)
237 }
238
239 pub fn reduction_log_path(&self, name: &str) -> Result<PathBuf> {
243 Self::validate_name(name)?;
244 Ok(self.reduction_path(name, false))
245 }
246
247 pub fn session_path(&self, name: &str) -> Result<PathBuf> {
251 Self::validate_name(name)?;
252 Ok(self.transcript_path(name, false))
253 }
254
255 pub fn session_path_for(&self, name: &str, archived: bool) -> Result<PathBuf> {
258 Self::validate_name(name)?;
259 Ok(self.transcript_path(name, archived))
260 }
261
262 pub fn sidecar_path_for(&self, name: &str, archived: bool) -> Result<PathBuf> {
264 Self::validate_name(name)?;
265 Ok(self.sidecar_path_in(name, archived))
266 }
267
268 pub fn subagent_transcript_path_for(
270 &self,
271 parent_name: &str,
272 child_id: &str,
273 archived: bool,
274 ) -> Result<PathBuf> {
275 Self::validate_name(parent_name)?;
276 Self::validate_name(child_id)?;
277 Ok(self.subagent_transcript_path(parent_name, child_id, archived))
278 }
279
280 pub fn save_sidecar(&self, name: &str, sidecar_jsonl: &str) -> Result<()> {
283 Self::validate_name(name)?;
284 std::fs::create_dir_all(self.dir(false))?;
285 std::fs::write(self.sidecar_path_in(name, false), sidecar_jsonl)?;
286 Ok(())
287 }
288
289 pub fn load_sidecar(&self, name: &str) -> Result<Option<String>> {
293 Self::validate_name(name)?;
294 let active = self.sidecar_path_in(name, false);
295 let path = if active.exists() {
296 active
297 } else {
298 self.sidecar_path_in(name, true)
299 };
300 if !path.exists() {
301 return Ok(None);
302 }
303 Ok(Some(std::fs::read_to_string(path)?))
304 }
305
306 pub fn load_sidecar_from(&self, name: &str, archived: bool) -> Result<Option<String>> {
308 Self::validate_name(name)?;
309 let path = self.sidecar_path_in(name, archived);
310 if !path.exists() {
311 return Ok(None);
312 }
313 Ok(Some(std::fs::read_to_string(path)?))
314 }
315
316 pub fn save_reduction_log(&self, name: &str, log: &ReductionLog) -> Result<()> {
319 Self::validate_name(name)?;
320 std::fs::create_dir_all(self.dir(false))?;
321 let json = serde_json::to_string(log).map_err(Error::Decode)?;
322 std::fs::write(self.reduction_path(name, false), json)?;
323 Ok(())
324 }
325
326 pub fn load_reduction_log(&self, name: &str) -> Result<Option<ReductionLog>> {
329 Self::validate_name(name)?;
330 let active = self.reduction_path(name, false);
331 let path = if active.exists() {
332 active
333 } else {
334 self.reduction_path(name, true)
335 };
336 if !path.exists() {
337 return Ok(None);
338 }
339 let text = std::fs::read_to_string(path)?;
340 Ok(Some(serde_json::from_str(&text).map_err(Error::Decode)?))
341 }
342
343 pub fn load_reduction_log_from(
345 &self,
346 name: &str,
347 archived: bool,
348 ) -> Result<Option<ReductionLog>> {
349 Self::validate_name(name)?;
350 let path = self.reduction_path(name, archived);
351 if !path.exists() {
352 return Ok(None);
353 }
354 let text = std::fs::read_to_string(path)?;
355 Ok(Some(serde_json::from_str(&text).map_err(Error::Decode)?))
356 }
357
358 pub fn save_usage_log(
364 &self,
365 name: &str,
366 records: &[crate::usage_log::UsageRecord],
367 ) -> Result<()> {
368 Self::validate_name(name)?;
369 std::fs::create_dir_all(self.dir(false))?;
370 let jsonl = crate::usage_log::to_jsonl(records)?;
371 std::fs::write(self.usage_path(name, false), jsonl)?;
372 Ok(())
373 }
374
375 pub fn load_usage_log(&self, name: &str) -> Result<Vec<crate::usage_log::UsageRecord>> {
378 Self::validate_name(name)?;
379 let active = self.usage_path(name, false);
380 let path = if active.exists() {
381 active
382 } else {
383 self.usage_path(name, true)
384 };
385 if !path.exists() {
386 return Ok(Vec::new());
387 }
388 crate::usage_log::from_jsonl(&std::fs::read_to_string(path)?)
389 }
390
391 pub fn save_goal(&self, name: &str, goal: &crate::goals::GoalRecord) -> Result<()> {
393 Self::validate_name(name)?;
394 std::fs::create_dir_all(self.dir(false))?;
395 let json = serde_json::to_string_pretty(goal).map_err(crate::Error::Decode)?;
396 std::fs::write(self.goal_path(name, false), json)?;
397 Ok(())
398 }
399
400 pub fn load_goal(&self, name: &str) -> Result<Option<crate::goals::GoalRecord>> {
403 Self::validate_name(name)?;
404 let active = self.goal_path(name, false);
405 let path = if active.exists() {
406 active
407 } else {
408 self.goal_path(name, true)
409 };
410 if !path.exists() {
411 return Ok(None);
412 }
413 let text = std::fs::read_to_string(path)?;
414 if text.trim().is_empty() {
415 return Ok(None);
416 }
417 Ok(Some(
418 serde_json::from_str(&text).map_err(crate::Error::Decode)?,
419 ))
420 }
421
422 pub fn clear_goal(&self, name: &str) -> Result<()> {
425 Self::validate_name(name)?;
426 for archived in [false, true] {
427 let p = self.goal_path(name, archived);
428 if p.exists() {
429 std::fs::remove_file(p)?;
430 }
431 }
432 Ok(())
433 }
434
435 pub fn save_turn_records(
443 &self,
444 name: &str,
445 records: &[crate::turn_record::TurnRecord],
446 ) -> Result<()> {
447 Self::validate_name(name)?;
448 std::fs::create_dir_all(self.dir(false))?;
449 let jsonl = crate::turn_record::to_jsonl(records)?;
450 std::fs::write(self.events_path(name, false), jsonl)?;
451 Ok(())
452 }
453
454 pub fn load_turn_records(&self, name: &str) -> Result<Vec<crate::turn_record::TurnRecord>> {
457 Self::validate_name(name)?;
458 let active = self.events_path(name, false);
459 let path = if active.exists() {
460 active
461 } else {
462 self.events_path(name, true)
463 };
464 if !path.exists() {
465 return Ok(Vec::new());
466 }
467 crate::turn_record::from_jsonl(&std::fs::read_to_string(path)?)
468 }
469
470 pub fn save_model_change_log(
475 &self,
476 name: &str,
477 records: &[crate::model_change::ModelChangeRecord],
478 ) -> Result<()> {
479 Self::validate_name(name)?;
480 std::fs::create_dir_all(self.dir(false))?;
481 let jsonl = crate::model_change::to_jsonl(records)?;
482 std::fs::write(self.model_change_path(name, false), jsonl)?;
483 Ok(())
484 }
485
486 pub fn load_model_change_log(
491 &self,
492 name: &str,
493 ) -> Result<Vec<crate::model_change::ModelChangeRecord>> {
494 Self::validate_name(name)?;
495 let active = self.model_change_path(name, false);
496 let path = if active.exists() {
497 active
498 } else {
499 self.model_change_path(name, true)
500 };
501 if !path.exists() {
502 return Ok(Vec::new());
503 }
504 crate::model_change::from_jsonl(&std::fs::read_to_string(path)?)
505 }
506
507 pub fn save_git_metadata(
511 &self,
512 name: &str,
513 record: &crate::git_metadata::GitMetadataRecord,
514 ) -> Result<()> {
515 Self::validate_name(name)?;
516 std::fs::create_dir_all(self.dir(false))?;
517 let json = crate::git_metadata::to_json(record)?;
518 std::fs::write(self.git_metadata_path(name, false), json)?;
519 Ok(())
520 }
521
522 pub fn load_git_metadata(
526 &self,
527 name: &str,
528 ) -> Result<Option<crate::git_metadata::GitMetadataRecord>> {
529 Self::validate_name(name)?;
530 let active = self.git_metadata_path(name, false);
531 let path = if active.exists() {
532 active
533 } else {
534 self.git_metadata_path(name, true)
535 };
536 if !path.exists() {
537 return Ok(None);
538 }
539 Ok(Some(crate::git_metadata::from_json(
540 &std::fs::read_to_string(path)?,
541 )?))
542 }
543
544 pub fn save(&self, name: &str, title: &str, transcript_jsonl: &str) -> Result<()> {
546 Self::validate_name(name)?;
547 std::fs::create_dir_all(self.dir(false))?;
548 std::fs::write(self.transcript_path(name, false), transcript_jsonl)?;
549 let info = SessionInfo {
550 name: name.to_string(),
551 title: title.to_string(),
552 archived: false,
553 ..Default::default()
554 };
555 std::fs::write(
556 self.meta_path(name, false),
557 serde_json::to_string(&info).map_err(Error::Decode)?,
558 )?;
559 Ok(())
560 }
561
562 pub fn load(&self, name: &str) -> Result<String> {
564 Self::validate_name(name)?;
565 let active = self.transcript_path(name, false);
566 let path = if active.exists() {
567 active
568 } else {
569 self.transcript_path(name, true)
570 };
571 Ok(std::fs::read_to_string(path)?)
572 }
573
574 pub fn load_from(&self, name: &str, archived: bool) -> Result<String> {
576 Self::validate_name(name)?;
577 Ok(std::fs::read_to_string(
578 self.transcript_path(name, archived),
579 )?)
580 }
581
582 pub fn load_if_present_from(&self, name: &str, archived: bool) -> Result<Option<String>> {
585 Self::validate_name(name)?;
586 let path = self.transcript_path(name, archived);
587 match std::fs::symlink_metadata(&path) {
588 Ok(_) => Ok(Some(std::fs::read_to_string(path)?)),
589 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
590 Err(error) => Err(error.into()),
591 }
592 }
593
594 pub fn load_if_present(&self, name: &str) -> Result<Option<String>> {
602 Self::validate_name(name)?;
603 for archived in [false, true] {
604 let path = self.transcript_path(name, archived);
605 match std::fs::symlink_metadata(&path) {
606 Ok(_) => return Ok(Some(std::fs::read_to_string(path)?)),
607 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
608 Err(e) => return Err(e.into()),
609 }
610 }
611 Ok(None)
612 }
613
614 pub fn list(&self) -> Vec<SessionInfo> {
616 let mut out = Vec::new();
617 for archived in [false, true] {
618 let dir = self.dir(archived);
619 let Ok(rd) = std::fs::read_dir(&dir) else {
620 continue;
621 };
622 for entry in rd.flatten() {
623 let p = entry.path();
624 if p.extension().and_then(|e| e.to_str()) != Some("json") {
625 continue;
626 }
627 if !p.to_string_lossy().ends_with(".meta.json") {
629 continue;
630 }
631 if let Ok(text) = std::fs::read_to_string(&p) {
632 if let Ok(mut info) = serde_json::from_str::<SessionInfo>(&text) {
633 info.archived = archived;
634 out.push(info);
635 }
636 }
637 }
638 }
639 out.sort_by(|a, b| a.name.cmp(&b.name));
640 out
641 }
642
643 pub fn archive(&self, name: &str) -> Result<()> {
648 Self::validate_name(name)?;
649 std::fs::create_dir_all(self.dir(true))?;
650 for (from, to) in [
651 (
652 self.transcript_path(name, false),
653 self.transcript_path(name, true),
654 ),
655 (self.meta_path(name, false), self.meta_path(name, true)),
656 (
657 self.sidecar_path_in(name, false),
658 self.sidecar_path_in(name, true),
659 ),
660 (
661 self.reduction_path(name, false),
662 self.reduction_path(name, true),
663 ),
664 (self.events_path(name, false), self.events_path(name, true)),
665 (self.usage_path(name, false), self.usage_path(name, true)),
666 (
667 self.model_change_path(name, false),
668 self.model_change_path(name, true),
669 ),
670 (
671 self.git_metadata_path(name, false),
672 self.git_metadata_path(name, true),
673 ),
674 (self.goal_path(name, false), self.goal_path(name, true)),
675 (self.fork_path(name, false), self.fork_path(name, true)),
676 (self.tree_path(name, false), self.tree_path(name, true)),
677 (
678 self.journal_path_in(name, false),
679 self.journal_path_in(name, true),
680 ),
681 (
682 self.plan_path_in(name, false),
683 self.plan_path_in(name, true),
684 ),
685 (
686 self.claude_runtime_path(name, false),
687 self.claude_runtime_path(name, true),
688 ),
689 ] {
690 if from.exists() {
691 std::fs::rename(&from, &to)?;
692 }
693 }
694 let subagents_from = self.subagents_dir(name, false);
699 if subagents_from.exists() {
700 std::fs::rename(&subagents_from, self.subagents_dir(name, true))?;
701 }
702 Ok(())
703 }
704
705 pub fn delete(&self, name: &str) -> Result<()> {
710 Self::validate_name(name)?;
711 for archived in [false, true] {
712 for p in [
713 self.transcript_path(name, archived),
714 self.meta_path(name, archived),
715 self.sidecar_path_in(name, archived),
716 self.reduction_path(name, archived),
717 self.events_path(name, archived),
718 self.usage_path(name, archived),
719 self.model_change_path(name, archived),
720 self.git_metadata_path(name, archived),
721 self.goal_path(name, archived),
722 self.fork_path(name, archived),
723 self.tree_path(name, archived),
724 self.journal_path_in(name, archived),
725 self.plan_path_in(name, archived),
726 self.claude_runtime_path(name, archived),
727 ] {
728 if p.exists() {
729 std::fs::remove_file(p)?;
730 }
731 }
732 let subagents_dir = self.subagents_dir(name, archived);
735 if subagents_dir.exists() {
736 std::fs::remove_dir_all(&subagents_dir)?;
737 }
738 }
739 Ok(())
740 }
741
742 pub fn set_title(&self, name: &str, title: &str) -> Result<()> {
746 Self::validate_name(name)?;
747 for archived in [false, true] {
748 let mp = self.meta_path(name, archived);
749 if mp.exists() {
750 let mut info: SessionInfo = std::fs::read_to_string(&mp)
751 .ok()
752 .and_then(|t| serde_json::from_str(&t).ok())
753 .unwrap_or_else(|| SessionInfo {
754 name: name.to_string(),
755 ..Default::default()
756 });
757 info.title = title.to_string();
758 info.archived = archived;
759 std::fs::write(&mp, serde_json::to_string(&info).map_err(Error::Decode)?)?;
760 return Ok(());
761 }
762 }
763 Err(Error::Other(format!("no session named `{name}`")))
764 }
765
766 pub fn root(&self) -> &Path {
768 &self.root
769 }
770
771 pub fn transcript_mtime(&self, name: &str) -> Option<std::time::SystemTime> {
779 Self::validate_name(name).ok()?;
780 let active = self.transcript_path(name, false);
781 let path = if active.exists() {
782 active
783 } else {
784 self.transcript_path(name, true)
785 };
786 std::fs::metadata(path).ok()?.modified().ok()
787 }
788
789 pub fn set_reduction_stats(
796 &self,
797 name: &str,
798 title: &str,
799 full_bytes: u64,
800 view_bytes: u64,
801 stub_count: u32,
802 ) -> Result<()> {
803 Self::validate_name(name)?;
804 std::fs::create_dir_all(self.dir(false))?;
805 let mp = self.meta_path(name, false);
806 let mut info: SessionInfo = std::fs::read_to_string(&mp)
807 .ok()
808 .and_then(|t| serde_json::from_str(&t).ok())
809 .unwrap_or_else(|| SessionInfo {
810 name: name.to_string(),
811 title: title.to_string(),
812 ..Default::default()
813 });
814 info.reduced = true;
815 info.full_bytes = full_bytes;
816 info.view_bytes = view_bytes;
817 info.stub_count = stub_count;
818 std::fs::write(&mp, serde_json::to_string(&info).map_err(Error::Decode)?)?;
819 Ok(())
820 }
821
822 pub fn fork(
833 &self,
834 from: &str,
835 to: &str,
836 title: &str,
837 truncate_at_message: Option<usize>,
838 timestamp_ms: i64,
839 ) -> Result<ForkProvenance> {
840 Self::validate_name(from)?;
841 Self::validate_name(to)?;
842 let jsonl = self.load(from)?;
843 if let Some(n) = truncate_at_message {
844 Self::validate_safe_truncation(&jsonl, n)?;
845 }
846 let content = match truncate_at_message {
847 Some(n) => {
848 let lines: Vec<&str> = jsonl.lines().take(n).collect();
849 if lines.is_empty() {
850 String::new()
851 } else {
852 let mut s = lines.join("\n");
853 s.push('\n');
854 s
855 }
856 }
857 None => jsonl,
858 };
859 self.save(to, title, &content)?;
860
861 self.copy_family_member(from, to, Self::sidecar_path_in)?;
877 self.copy_family_member(from, to, Self::reduction_path)?;
878 self.copy_family_member(from, to, Self::usage_path)?;
879 self.copy_family_member(from, to, Self::model_change_path)?;
880 self.copy_family_member(from, to, Self::git_metadata_path)?;
881 self.copy_family_member(from, to, Self::tree_path)?;
886 self.copy_family_member(from, to, Self::claude_runtime_path)?;
887
888 let provenance = ForkProvenance {
889 forked_from: from.to_string(),
890 forked_at_message: truncate_at_message,
891 timestamp_ms,
892 };
893 self.save_fork_provenance(to, &provenance)?;
894 Ok(provenance)
895 }
896
897 fn copy_family_member(
905 &self,
906 from: &str,
907 to: &str,
908 path_of: impl Fn(&Self, &str, bool) -> PathBuf,
909 ) -> Result<()> {
910 let active = path_of(self, from, false);
911 let src = if active.exists() {
912 active
913 } else {
914 let archived = path_of(self, from, true);
915 if !archived.exists() {
916 return Ok(());
917 }
918 archived
919 };
920 std::fs::create_dir_all(self.dir(false))?;
921 std::fs::copy(&src, path_of(self, to, false))?;
922 Ok(())
923 }
924
925 fn validate_safe_truncation(jsonl: &str, n: usize) -> Result<()> {
935 let kept: Vec<supercode_interchange::ChatMessage> = jsonl
936 .lines()
937 .take(n)
938 .filter(|l| !l.trim().is_empty())
939 .map(|l| serde_json::from_str(l).map_err(Error::Decode))
940 .collect::<Result<_>>()?;
941 let mut pending: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
942 for m in &kept {
943 if let Some(calls) = &m.tool_calls {
944 for c in calls {
945 pending.insert(c.id.clone());
946 }
947 }
948 if let Some(id) = &m.tool_call_id {
949 pending.remove(id);
950 }
951 }
952 if !pending.is_empty() {
953 return Err(Error::Other(format!(
954 "fork --at {n} would cut off {} unresolved tool_call result(s) ({}) — \
955 choose a boundary at or after the assistant's tool_calls message AND \
956 all of its tool results",
957 pending.len(),
958 pending.into_iter().collect::<Vec<_>>().join(", "),
959 )));
960 }
961 Ok(())
962 }
963
964 pub fn save_fork_provenance(&self, name: &str, provenance: &ForkProvenance) -> Result<()> {
967 Self::validate_name(name)?;
968 std::fs::create_dir_all(self.dir(false))?;
969 let json = serde_json::to_string(provenance).map_err(Error::Decode)?;
970 std::fs::write(self.fork_path(name, false), json)?;
971 Ok(())
972 }
973
974 pub fn load_fork_provenance(&self, name: &str) -> Result<Option<ForkProvenance>> {
977 Self::validate_name(name)?;
978 let active = self.fork_path(name, false);
979 let path = if active.exists() {
980 active
981 } else {
982 self.fork_path(name, true)
983 };
984 if !path.exists() {
985 return Ok(None);
986 }
987 Ok(Some(
988 serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
989 ))
990 }
991
992 pub fn save_tree(
997 &self,
998 name: &str,
999 tree: &supercode_interchange::session_tree::SessionTree,
1000 ) -> Result<()> {
1001 Self::validate_name(name)?;
1002 std::fs::create_dir_all(self.dir(false))?;
1003 let json = serde_json::to_string(tree).map_err(Error::Decode)?;
1004 std::fs::write(self.tree_path(name, false), json)?;
1005 Ok(())
1006 }
1007
1008 pub fn save_claude_runtime_manifest(
1011 &self,
1012 name: &str,
1013 manifest: &crate::claude_runtime_state::ClaudeRuntimeManifest,
1014 ) -> Result<()> {
1015 Self::validate_name(name)?;
1016 std::fs::create_dir_all(self.dir(false))?;
1017 let json = serde_json::to_string(manifest).map_err(Error::Decode)?;
1018 std::fs::write(self.claude_runtime_path(name, false), json)?;
1019 Ok(())
1020 }
1021
1022 pub fn load_claude_runtime_manifest(
1024 &self,
1025 name: &str,
1026 ) -> Result<Option<crate::claude_runtime_state::ClaudeRuntimeManifest>> {
1027 Self::validate_name(name)?;
1028 let active = self.claude_runtime_path(name, false);
1029 let path = if active.exists() {
1030 active
1031 } else {
1032 self.claude_runtime_path(name, true)
1033 };
1034 if !path.exists() {
1035 return Ok(None);
1036 }
1037 Ok(Some(
1038 serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
1039 ))
1040 }
1041
1042 pub fn load_claude_runtime_manifest_from(
1044 &self,
1045 name: &str,
1046 archived: bool,
1047 ) -> Result<Option<crate::claude_runtime_state::ClaudeRuntimeManifest>> {
1048 Self::validate_name(name)?;
1049 let path = self.claude_runtime_path(name, archived);
1050 if !path.exists() {
1051 return Ok(None);
1052 }
1053 Ok(Some(
1054 serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
1055 ))
1056 }
1057
1058 pub fn load_tree(
1063 &self,
1064 name: &str,
1065 ) -> Result<Option<supercode_interchange::session_tree::SessionTree>> {
1066 Self::validate_name(name)?;
1067 let active = self.tree_path(name, false);
1068 let path = if active.exists() {
1069 active
1070 } else {
1071 self.tree_path(name, true)
1072 };
1073 if !path.exists() {
1074 return Ok(None);
1075 }
1076 Ok(Some(
1077 serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
1078 ))
1079 }
1080
1081 pub fn save_subagent_transcript(
1089 &self,
1090 parent_name: &str,
1091 child_id: &str,
1092 sidecar_jsonl: &str,
1093 ) -> Result<()> {
1094 Self::validate_name(parent_name)?;
1095 Self::validate_name(child_id)?;
1096 std::fs::create_dir_all(self.subagents_dir(parent_name, false))?;
1097 std::fs::write(
1098 self.subagent_transcript_path(parent_name, child_id, false),
1099 sidecar_jsonl,
1100 )?;
1101 Ok(())
1102 }
1103
1104 pub fn save_imported_subagents(
1113 &self,
1114 parent_name: &str,
1115 subagents: &[supercode_interchange::session::Session],
1116 ) -> Result<usize> {
1117 Self::validate_name(parent_name)?;
1118
1119 let mut seen = std::collections::BTreeSet::new();
1120 for child in subagents {
1121 let child_id = child.meta.agent_id.as_deref().ok_or_else(|| {
1122 Error::Other(format!(
1123 "cannot persist an imported subagent for `{parent_name}` without an agent id"
1124 ))
1125 })?;
1126 Self::validate_name(child_id)?;
1127 if !seen.insert(child_id.to_string()) {
1128 return Err(Error::Other(format!(
1129 "duplicate imported subagent id `{child_id}` for `{parent_name}`"
1130 )));
1131 }
1132 }
1133
1134 for child in subagents {
1139 let child_id = child.meta.agent_id.as_deref().expect("validated above");
1140 self.save_subagent_transcript(parent_name, child_id, &child.to_native_jsonl_v2(&[]))?;
1141 }
1142 Ok(subagents.len())
1143 }
1144
1145 pub fn load_subagent_transcript(
1148 &self,
1149 parent_name: &str,
1150 child_id: &str,
1151 ) -> Result<Option<String>> {
1152 Self::validate_name(parent_name)?;
1153 Self::validate_name(child_id)?;
1154 let active = self.subagent_transcript_path(parent_name, child_id, false);
1155 let path = if active.exists() {
1156 active
1157 } else {
1158 self.subagent_transcript_path(parent_name, child_id, true)
1159 };
1160 if !path.exists() {
1161 return Ok(None);
1162 }
1163 Ok(Some(std::fs::read_to_string(path)?))
1164 }
1165
1166 pub fn load_subagent_transcript_from(
1168 &self,
1169 parent_name: &str,
1170 child_id: &str,
1171 archived: bool,
1172 ) -> Result<Option<String>> {
1173 Self::validate_name(parent_name)?;
1174 Self::validate_name(child_id)?;
1175 let path = self.subagent_transcript_path(parent_name, child_id, archived);
1176 if !path.exists() {
1177 return Ok(None);
1178 }
1179 Ok(Some(std::fs::read_to_string(path)?))
1180 }
1181
1182 pub fn save_subagent_lineage(
1186 &self,
1187 parent_name: &str,
1188 child_id: &str,
1189 record: &crate::subagents::SubagentLineage,
1190 ) -> Result<()> {
1191 Self::validate_name(parent_name)?;
1192 Self::validate_name(child_id)?;
1193 std::fs::create_dir_all(self.subagents_dir(parent_name, false))?;
1194 let json = serde_json::to_string(record).map_err(Error::Decode)?;
1195 std::fs::write(
1196 self.subagent_lineage_path(parent_name, child_id, false),
1197 json,
1198 )?;
1199 Ok(())
1200 }
1201
1202 pub fn load_subagent_lineage(
1205 &self,
1206 parent_name: &str,
1207 child_id: &str,
1208 ) -> Result<Option<crate::subagents::SubagentLineage>> {
1209 Self::validate_name(parent_name)?;
1210 Self::validate_name(child_id)?;
1211 let active = self.subagent_lineage_path(parent_name, child_id, false);
1212 let path = if active.exists() {
1213 active
1214 } else {
1215 self.subagent_lineage_path(parent_name, child_id, true)
1216 };
1217 if !path.exists() {
1218 return Ok(None);
1219 }
1220 Ok(Some(
1221 serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
1222 ))
1223 }
1224
1225 pub fn list_subagent_ids(&self, parent_name: &str) -> Result<Vec<String>> {
1230 Self::validate_name(parent_name)?;
1231 let mut ids = std::collections::BTreeSet::new();
1232 for archived in [false, true] {
1233 let dir = self.subagents_dir(parent_name, archived);
1234 let Ok(rd) = std::fs::read_dir(&dir) else {
1235 continue;
1236 };
1237 for entry in rd.flatten() {
1238 let p = entry.path();
1239 if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
1240 if let Some(id) = name.strip_suffix(".sidecar.jsonl") {
1241 ids.insert(id.to_string());
1242 }
1243 }
1244 }
1245 }
1246 Ok(ids.into_iter().collect())
1247 }
1248
1249 pub fn list_subagent_ids_from(&self, parent_name: &str, archived: bool) -> Result<Vec<String>> {
1251 Self::validate_name(parent_name)?;
1252 let mut ids = std::collections::BTreeSet::new();
1253 let dir = self.subagents_dir(parent_name, archived);
1254 let Ok(rd) = std::fs::read_dir(&dir) else {
1255 return Ok(Vec::new());
1256 };
1257 for entry in rd.flatten() {
1258 let p = entry.path();
1259 if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
1260 if let Some(id) = name.strip_suffix(".sidecar.jsonl") {
1261 ids.insert(id.to_string());
1262 }
1263 }
1264 }
1265 Ok(ids.into_iter().collect())
1266 }
1267
1268 pub fn journal_path(&self, name: &str) -> Result<PathBuf> {
1272 Self::validate_name(name)?;
1273 Ok(self.journal_path_in(name, false))
1274 }
1275
1276 pub fn open_journal(&self, name: &str) -> Result<crate::session_journal::SessionJournal> {
1278 Self::validate_name(name)?;
1279 crate::session_journal::SessionJournal::open_append(&self.journal_path_in(name, false))
1280 }
1281
1282 pub fn load_journal(&self, name: &str) -> Result<Option<crate::session_journal::JournalState>> {
1286 Self::validate_name(name)?;
1287 for archived in [false, true] {
1288 let path = self.journal_path_in(name, archived);
1289 if path.exists() {
1290 return crate::session_journal::replay(&path);
1291 }
1292 }
1293 Ok(None)
1294 }
1295
1296 pub fn save_plan(&self, name: &str, plan: &[crate::session_journal::PlanEntry]) -> Result<()> {
1299 Self::validate_name(name)?;
1300 std::fs::create_dir_all(self.dir(false))?;
1301 std::fs::write(
1302 self.plan_path_in(name, false),
1303 serde_json::to_string(plan).map_err(Error::Decode)?,
1304 )?;
1305 Ok(())
1306 }
1307
1308 pub fn load_plan(&self, name: &str) -> Result<Option<Vec<crate::session_journal::PlanEntry>>> {
1310 Self::validate_name(name)?;
1311 for archived in [false, true] {
1312 let path = self.plan_path_in(name, archived);
1313 if path.exists() {
1314 let text = std::fs::read_to_string(path)?;
1315 return Ok(Some(serde_json::from_str(&text).map_err(Error::Decode)?));
1316 }
1317 }
1318 Ok(None)
1319 }
1320
1321 pub fn rename(&self, from: &str, to: &str) -> Result<()> {
1335 Self::validate_name(from)?;
1336 Self::validate_name(to)?;
1337 if from == to {
1338 return Ok(());
1339 }
1340 let mut moves: Vec<(PathBuf, PathBuf)> = Vec::new();
1341 let mut found = false;
1342 for archived in [false, true] {
1343 let dir = self.dir(archived);
1344 let Ok(rd) = std::fs::read_dir(&dir) else {
1345 continue;
1346 };
1347 for entry in rd.flatten() {
1348 let file = entry.file_name();
1349 let Some(file) = file.to_str() else { continue };
1350 let Some(suffix) = file.strip_prefix(&format!("{from}.")) else {
1354 continue;
1355 };
1356 found = true;
1357 let target = dir.join(format!("{to}.{suffix}"));
1358 if target.exists() {
1359 return Err(Error::Other(format!(
1360 "cannot rename `{from}` to `{to}`: `{}` already exists",
1361 target.display()
1362 )));
1363 }
1364 moves.push((entry.path(), target));
1365 }
1366 }
1367 if !found {
1368 return Err(Error::Other(format!("no session named `{from}`")));
1369 }
1370 for (src, dst) in &moves {
1371 std::fs::rename(src, dst)?;
1372 }
1373 for archived in [false, true] {
1376 let mp = self.meta_path(to, archived);
1377 if mp.exists() {
1378 if let Some(mut info) = std::fs::read_to_string(&mp)
1379 .ok()
1380 .and_then(|t| serde_json::from_str::<SessionInfo>(&t).ok())
1381 {
1382 info.name = to.to_string();
1383 info.archived = archived;
1384 std::fs::write(&mp, serde_json::to_string(&info).map_err(Error::Decode)?)?;
1385 }
1386 }
1387 }
1388 if self.journal_path_in(to, false).exists() {
1391 let mut journal = self.open_journal(to)?;
1392 journal.append(crate::session_journal::JournalOp::Rename {
1393 from: from.to_string(),
1394 to: to.to_string(),
1395 })?;
1396 }
1397 self.invalidate_index();
1398 Ok(())
1399 }
1400
1401 pub const FORMAT_VERSION: u32 = 2;
1407
1408 pub fn format_version(&self, name: &str) -> u32 {
1411 self.list()
1412 .into_iter()
1413 .find(|s| s.name == name)
1414 .map(|s| s.format_version)
1415 .unwrap_or(0)
1416 }
1417
1418 pub fn upgrade_in_place(&self, name: &str) -> Result<Option<FormatUpgrade>> {
1434 Self::validate_name(name)?;
1435 let current = self.format_version(name);
1436 if current >= Self::FORMAT_VERSION {
1437 return Ok(None);
1438 }
1439 let (path, archived) = {
1440 let active = self.transcript_path(name, false);
1441 if active.exists() {
1442 (active, false)
1443 } else {
1444 let arch = self.transcript_path(name, true);
1445 if !arch.exists() {
1446 return Ok(None);
1447 }
1448 (arch, true)
1449 }
1450 };
1451 let original = std::fs::read_to_string(&path)?;
1452 let mut upgraded = String::with_capacity(original.len());
1453 let mut lines = 0usize;
1454 for line in original.lines() {
1455 if line.trim().is_empty() {
1456 continue;
1457 }
1458 let msg: crate::ChatMessage =
1459 serde_json::from_str(line.trim()).map_err(Error::Decode)?;
1460 if lines > 0 {
1461 upgraded.push('\n');
1462 }
1463 upgraded.push_str(&serde_json::to_string(&msg).map_err(Error::Decode)?);
1464 lines += 1;
1465 }
1466 let backup_name = format!("{name}.v{current}.jsonl");
1467 let backup = self.dir(archived).join(&backup_name);
1468 std::fs::write(&backup, original.as_bytes())?;
1471 let rewritten = upgraded != original;
1472 if rewritten {
1473 std::fs::write(&path, upgraded.as_bytes())?;
1474 }
1475 self.set_format_version(name, Self::FORMAT_VERSION)?;
1476 if self.journal_path_in(name, false).exists() {
1477 let mut journal = self.open_journal(name)?;
1478 journal.append(crate::session_journal::JournalOp::Upgrade {
1479 from_version: current,
1480 to_version: Self::FORMAT_VERSION,
1481 original: backup_name.clone(),
1482 })?;
1483 }
1484 self.invalidate_index();
1485 Ok(Some(FormatUpgrade {
1486 from_version: current,
1487 to_version: Self::FORMAT_VERSION,
1488 original: backup_name,
1489 messages: lines,
1490 rewritten,
1491 }))
1492 }
1493
1494 pub fn set_format_version(&self, name: &str, version: u32) -> Result<()> {
1497 Self::validate_name(name)?;
1498 for archived in [false, true] {
1499 let mp = self.meta_path(name, archived);
1500 if mp.exists() {
1501 let mut info: SessionInfo = std::fs::read_to_string(&mp)
1502 .ok()
1503 .and_then(|t| serde_json::from_str(&t).ok())
1504 .unwrap_or_else(|| SessionInfo {
1505 name: name.to_string(),
1506 ..Default::default()
1507 });
1508 info.format_version = version;
1509 info.archived = archived;
1510 std::fs::write(&mp, serde_json::to_string(&info).map_err(Error::Decode)?)?;
1511 return Ok(());
1512 }
1513 }
1514 Err(Error::Other(format!("no session named `{name}`")))
1515 }
1516
1517 pub fn index_path(&self) -> PathBuf {
1523 self.root.join(".session-index.json")
1524 }
1525
1526 pub fn invalidate_index(&self) {
1530 let _ = std::fs::remove_file(self.index_path());
1531 }
1532
1533 pub fn index(&self) -> SessionIndex {
1545 let cached: std::collections::HashMap<String, SessionIndexEntry> =
1546 std::fs::read_to_string(self.index_path())
1547 .ok()
1548 .and_then(|t| serde_json::from_str::<SessionIndexFile>(&t).ok())
1549 .filter(|f| f.version == SESSION_INDEX_VERSION)
1550 .map(|f| f.entries.into_iter().map(|e| (e.name.clone(), e)).collect())
1551 .unwrap_or_default();
1552 let mut out = SessionIndex::default();
1553 for info in self.list() {
1554 let path = self.transcript_path(&info.name, info.archived);
1555 let (mtime_nanos, size) = match std::fs::metadata(&path) {
1556 Ok(m) => (
1557 m.modified()
1558 .ok()
1559 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1560 .map(|d| d.as_nanos() as u64)
1561 .unwrap_or(0),
1562 m.len(),
1563 ),
1564 Err(_) => (0, 0),
1565 };
1566 match cached.get(&info.name) {
1567 Some(hit)
1568 if hit.mtime_nanos == mtime_nanos
1569 && hit.size == size
1570 && hit.archived == info.archived
1571 && hit.title == info.title =>
1572 {
1573 out.reused += 1;
1574 out.entries.push(hit.clone());
1575 }
1576 _ => {
1577 out.rederived += 1;
1578 out.entries
1579 .push(self.derive_index_entry(&info, mtime_nanos, size));
1580 }
1581 }
1582 }
1583 out.entries
1584 .sort_by(|a, b| b.time_secs.cmp(&a.time_secs).then(a.name.cmp(&b.name)));
1585 let file = SessionIndexFile {
1586 version: SESSION_INDEX_VERSION,
1587 entries: out.entries.clone(),
1588 };
1589 if self.root.is_dir() {
1592 if let Ok(text) = serde_json::to_string(&file) {
1593 let _ = std::fs::write(self.index_path(), text);
1594 }
1595 }
1596 out
1597 }
1598
1599 fn derive_index_entry(
1600 &self,
1601 info: &SessionInfo,
1602 mtime_nanos: u64,
1603 size: u64,
1604 ) -> SessionIndexEntry {
1605 let text = self
1606 .load_if_present_from(&info.name, info.archived)
1607 .ok()
1608 .flatten()
1609 .unwrap_or_default();
1610 let mut messages = 0usize;
1611 let mut preview = String::new();
1612 for line in text.lines() {
1613 if line.trim().is_empty() {
1614 continue;
1615 }
1616 messages += 1;
1617 if preview.is_empty() {
1618 if let Ok(msg) = serde_json::from_str::<crate::ChatMessage>(line.trim()) {
1619 if msg.role == crate::Role::User {
1620 if let Some(c) = msg.content.as_deref() {
1621 preview = preview_line(c);
1622 }
1623 }
1624 }
1625 }
1626 }
1627 SessionIndexEntry {
1628 name: info.name.clone(),
1629 title: info.title.clone(),
1630 archived: info.archived,
1631 time_secs: derive_session_time(&info.name, mtime_nanos / 1_000_000),
1632 preview,
1633 messages,
1634 mtime_nanos,
1635 size,
1636 }
1637 }
1638
1639 pub fn prune_expired(
1647 &self,
1648 retention_days: u32,
1649 now: std::time::SystemTime,
1650 ) -> Result<Vec<String>> {
1651 if retention_days == 0 {
1652 return Ok(Vec::new());
1653 }
1654 let Some(cutoff) = now.checked_sub(std::time::Duration::from_secs(
1655 retention_days as u64 * 86_400,
1656 )) else {
1657 return Ok(Vec::new());
1658 };
1659 let mut pruned = Vec::new();
1660 for info in self.list() {
1661 if !info.archived {
1662 continue;
1663 }
1664 let Some(mtime) = self.transcript_mtime(&info.name) else {
1665 continue;
1666 };
1667 if mtime < cutoff {
1668 self.delete(&info.name)?;
1669 pruned.push(info.name);
1670 }
1671 }
1672 Ok(pruned)
1673 }
1674}
1675
1676#[derive(Debug, Clone, PartialEq, Eq)]
1678pub struct FormatUpgrade {
1679 pub from_version: u32,
1681 pub to_version: u32,
1683 pub original: String,
1685 pub messages: usize,
1687 pub rewritten: bool,
1690}
1691
1692pub const SESSION_INDEX_VERSION: u32 = 1;
1694
1695#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1697pub struct SessionIndexEntry {
1698 pub name: String,
1700 pub title: String,
1702 pub archived: bool,
1704 pub time_secs: u64,
1706 pub preview: String,
1708 pub messages: usize,
1710 pub mtime_nanos: u64,
1715 pub size: u64,
1717}
1718
1719#[derive(Debug, Clone, Default, PartialEq, Eq)]
1723pub struct SessionIndex {
1724 pub entries: Vec<SessionIndexEntry>,
1726 pub reused: usize,
1728 pub rederived: usize,
1730}
1731
1732#[derive(Serialize, Deserialize)]
1733struct SessionIndexFile {
1734 version: u32,
1735 entries: Vec<SessionIndexEntry>,
1736}
1737
1738pub fn derive_session_time(name: &str, mtime_ms: u64) -> u64 {
1743 if let Some(micros) = name.rsplit('-').next().and_then(|s| s.parse::<u128>().ok()) {
1744 return (micros / 1_000_000) as u64;
1745 }
1746 mtime_ms / 1000
1747}
1748
1749fn preview_line(content: &str) -> String {
1751 let line = content.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
1752 let line = line.trim();
1753 if line.chars().count() <= 80 {
1754 return line.to_string();
1755 }
1756 let truncated: String = line.chars().take(79).collect();
1757 format!("{truncated}…")
1758}
1759
1760#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1763pub struct ForkProvenance {
1764 pub forked_from: String,
1766 #[serde(default)]
1769 pub forked_at_message: Option<usize>,
1770 #[serde(default)]
1772 pub timestamp_ms: i64,
1773}