Skip to main content

supercode_harness/
store.rs

1//! A directory-backed store for supercode's own sessions — naming, titles,
2//! listing, archiving, and deletion. The analog of `claude --name` / the Codex
3//! `resume`/`archive`/`delete` session lifecycle.
4//!
5//! Each session is a `<name>.jsonl` transcript (one [`crate::ChatMessage`] per
6//! line) plus a `<name>.meta.json` sidecar carrying the title. Archiving moves
7//! the pair under an `archived/` subdirectory.
8
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Error, Result};
14use crate::reduce::ReductionLog;
15
16/// Lightweight metadata about a stored session.
17#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
18#[non_exhaustive]
19pub struct SessionInfo {
20    /// The session name (the file stem; unique within the store).
21    pub name: String,
22    /// A human-readable title.
23    #[serde(default)]
24    pub title: String,
25    /// Whether the session is archived.
26    #[serde(default)]
27    pub archived: bool,
28    /// Whether reduced-mode projection (A5) has ever been applied to this
29    /// session. `#[serde(default)]` so meta.json files written before A2
30    /// still parse (they simply read as `false`).
31    #[serde(default)]
32    pub reduced: bool,
33    /// The model tier (B1/D5) last used for this session, if tiers are
34    /// configured; empty otherwise.
35    #[serde(default)]
36    pub tier: String,
37    /// Serialized byte size of the full (unreduced) view, last measured (C9).
38    #[serde(default)]
39    pub full_bytes: u64,
40    /// Serialized byte size of the current reduced working view, last
41    /// measured (C9).
42    #[serde(default)]
43    pub view_bytes: u64,
44    /// Number of `[sc-reduced ...]` stubs currently standing in the working
45    /// view (C2/A4).
46    #[serde(default)]
47    pub stub_count: u32,
48    /// Number of escalation events recorded for this session (B5/C8).
49    #[serde(default)]
50    pub escalations: u32,
51    /// BP-8 (catalog:153 "Format versioning/migration"): the on-disk
52    /// generation this session's family was last written at. `0` — the
53    /// `#[serde(default)]`, and what every pre-BP-8 meta.json reads as —
54    /// means "unmarked", which is what [`SessionStore::upgrade_in_place`]
55    /// keys off.
56    #[serde(default)]
57    pub format_version: u32,
58}
59
60/// A filesystem session store rooted at a directory.
61pub struct SessionStore {
62    root: PathBuf,
63}
64
65impl SessionStore {
66    /// Address a store at `root` without touching the filesystem. Read-only
67    /// discovery paths use this so merely checking whether a named session
68    /// exists cannot create an empty store directory. Mutating methods still
69    /// create their required directories before writing.
70    pub fn at(root: impl Into<PathBuf>) -> Self {
71        SessionStore { root: root.into() }
72    }
73
74    /// Open (creating if needed) a store at `root`.
75    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    /// Reject session names that could escape the store root. Names are file
82    /// stems, so anything with a path separator, a `..` component, or a leading
83    /// dot/whitespace is invalid — without this, a name like `../../foo` would
84    /// read/write/delete files outside the store.
85    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    /// `<root>/<name>.sidecar.jsonl` (or under `archived/`) — the A1
106    /// native-v2 file, source of truth (D1).
107    fn sidecar_path_in(&self, name: &str, archived: bool) -> PathBuf {
108        self.dir(archived).join(format!("{name}.sidecar.jsonl"))
109    }
110    /// `<root>/<name>.reduction.json` (or under `archived/`) — the persisted
111    /// `ReductionLog`, the stub index (D1).
112    fn reduction_path(&self, name: &str, archived: bool) -> PathBuf {
113        self.dir(archived).join(format!("{name}.reduction.json"))
114    }
115    /// `<root>/<name>.events.jsonl` (or under `archived/`) — the session's
116    /// per-round-trip marker log (C8/D1). BP-7 filled this slot in:
117    /// [`Self::save_turn_records`]/[`Self::load_turn_records`] write and
118    /// read [`crate::turn_record::TurnRecord`]s here, and the lifecycle
119    /// sweep (`archive`/`delete`) carries the file with the family.
120    fn events_path(&self, name: &str, archived: bool) -> PathBuf {
121        self.dir(archived).join(format!("{name}.events.jsonl"))
122    }
123    /// `<root>/<name>.usage.jsonl` (or under `archived/`) — P4b (design
124    /// §5.2 "P4", §1.6, catalog §4a "persisted per-turn usage records"): one
125    /// [`crate::usage_log::UsageRecord`] per line.
126    fn usage_path(&self, name: &str, archived: bool) -> PathBuf {
127        self.dir(archived).join(format!("{name}.usage.jsonl"))
128    }
129    /// `<root>/<name>.model_change.jsonl` (or under `archived/`) — P4c
130    /// (design §5.2 "P4" core NEW-significant, §1.10/§3.1
131    /// `core.model_switch.allow_switch`): one
132    /// [`crate::model_change::ModelChangeRecord`] per line.
133    fn model_change_path(&self, name: &str, archived: bool) -> PathBuf {
134        self.dir(archived)
135            .join(format!("{name}.model_change.jsonl"))
136    }
137    /// `<root>/<name>.git.json` (or under `archived/`) — P4e (design §5.2
138    /// "P4e", §1.6/§3.1 `core.session.git_metadata`): the single
139    /// [`crate::git_metadata::GitMetadataRecord`] captured for this
140    /// session, if any (a single record, not a JSONL log — see that
141    /// module's doc comment).
142    fn git_metadata_path(&self, name: &str, archived: bool) -> PathBuf {
143        self.dir(archived).join(format!("{name}.git.json"))
144    }
145    /// `<root>/<name>.goal.json` (or under `archived/`) — BP-7 (catalog
146    /// §4a "Goals"): the session's single [`crate::goals::GoalRecord`], if
147    /// one was ever set. A single record, not a log, exactly like
148    /// `<name>.git.json`.
149    fn goal_path(&self, name: &str, archived: bool) -> PathBuf {
150        self.dir(archived).join(format!("{name}.goal.json"))
151    }
152    /// `<root>/<name>.fork.json` (or under `archived/`) — P4e (§1.6
153    /// obligation-6 "fork-to-new-file WITH provenance", CX shape): the
154    /// [`ForkProvenance`] record for a session created via [`Self::fork`].
155    /// Absent for a session that was never forked (the overwhelmingly
156    /// common case).
157    fn fork_path(&self, name: &str, archived: bool) -> PathBuf {
158        self.dir(archived).join(format!("{name}.fork.json"))
159    }
160    /// `<root>/<name>.tree.json` (or under `archived/`) — P5-5 (design §2
161    /// module 21 `session.tree`, §2.1 D-6): the persisted
162    /// [`supercode_interchange::session_tree::SessionTree`] — the full in-place tree (every
163    /// node of every branch), typed and lossless. Absent for a session that
164    /// never invoked a tree operation (rewind/branch/label) — the
165    /// overwhelmingly common, degenerate-single-path case; the plain
166    /// `<name>.jsonl` transcript alone already IS that session's complete
167    /// record, so no sidecar is ever created for it, keeping default-off
168    /// behavior byte-identical to pre-P5-5.
169    fn tree_path(&self, name: &str, archived: bool) -> PathBuf {
170        self.dir(archived).join(format!("{name}.tree.json"))
171    }
172    /// `<root>/<name>.journal.jsonl` (or under `archived/`) — BP-8
173    /// (catalog:150 "Append-only durable transcript", catalog:154
174    /// "Queued-prompt persistence", catalog:156 "Todos/plan persisted per
175    /// session"): the append-only, flush-per-record operation log
176    /// (`crate::session_journal`). Absent for a session whose config never
177    /// armed it (`[core.session] append_only`), which is every pre-BP-8
178    /// caller — the plain `<name>.jsonl` transcript alone stays that
179    /// session's complete record.
180    fn journal_path_in(&self, name: &str, archived: bool) -> PathBuf {
181        self.dir(archived).join(format!("{name}.journal.jsonl"))
182    }
183    /// `<root>/<name>.plan.json` (or under `archived/`) — BP-8
184    /// (catalog:156): the session's current `update_plan` checklist, the
185    /// folded head of the journal's `plan` records, written so a reader
186    /// that only wants the plan does not have to replay the whole log.
187    fn plan_path_in(&self, name: &str, archived: bool) -> PathBuf {
188        self.dir(archived).join(format!("{name}.plan.json"))
189    }
190    /// Claude runtime-state manifest reconstructed at import time. Kept as a
191    /// separate family member so scheduling/control-plane state is never
192    /// flattened into the provider-visible message transcript.
193    fn claude_runtime_path(&self, name: &str, archived: bool) -> PathBuf {
194        self.dir(archived)
195            .join(format!("{name}.claude-runtime.json"))
196    }
197    /// `<root>/<name>.subagents/` (or under `archived/`) — P5-3 (design §2
198    /// module 9 D5 "subagent transcripts"): the directory holding one
199    /// `<child_id>.sidecar.jsonl` (the D5 transcript) + one
200    /// `<child_id>.lineage.json` (the typed [`crate::subagents::SubagentLineage`]
201    /// record) pair per child natively spawned under this parent session —
202    /// the D5 analog of Claude Code's own `<stem>/subagents/agent-*.jsonl`
203    /// on-disk convention (`supercode_interchange::session::subagents_dir_for`), but for
204    /// sessions THIS store owns rather than an imported CC transcript.
205    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    /// The path a sidecar for `name` lives (or would live) at:
230    /// `<root>/<name>.sidecar.jsonl`. Does not validate `name` or touch the
231    /// filesystem — like the private `transcript_path`/`meta_path` helpers,
232    /// it's the read/write methods (`save_sidecar`, `load_sidecar`, and
233    /// `Agent::resume_recorded`'s caller) that enforce `validate_name` before
234    /// any I/O happens.
235    pub fn sidecar_path(&self, name: &str) -> PathBuf {
236        self.sidecar_path_in(name, false)
237    }
238
239    /// The active reduction-log path for `name`. Like [`Self::sidecar_path`],
240    /// this is a path projection only; callers that read or write must still
241    /// go through the validated store methods.
242    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    /// Canonical active transcript location for a validated session name.
248    /// The file need not exist yet; runtime registration uses this to report
249    /// where the SDK owner will persist successful turns.
250    pub fn session_path(&self, name: &str) -> Result<PathBuf> {
251        Self::validate_name(name)?;
252        Ok(self.transcript_path(name, false))
253    }
254
255    /// Exact active/archive transcript path. Unlike [`Self::session_path`],
256    /// this preserves an explicit archived-family selection.
257    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    /// Exact active/archive native-v2 sidecar path.
263    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    /// Exact active/archive stored-child sidecar path.
269    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    /// Overwrite (or create) `<name>`'s sidecar file with `sidecar_jsonl`
281    /// verbatim.
282    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    /// Read `<name>`'s sidecar file (active or archived), if it exists.
290    /// `None` when no sidecar has ever been recorded for this session (e.g.
291    /// a plain, non-reduced resume).
292    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    /// Read a sidecar from exactly the selected active/archive family.
307    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    /// Persist `<name>`'s [`ReductionLog`] (the stub index) as
317    /// `<name>.reduction.json`.
318    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    /// Read `<name>`'s [`ReductionLog`] (active or archived), if one has
327    /// ever been saved.
328    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    /// Read a reduction log from exactly the selected active/archive family.
344    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    /// P4b: overwrite (or create) `<name>`'s usage log with `records`
359    /// (bulk-write, like [`Self::save_reduction_log`] — not an incremental
360    /// append — so a caller with the full in-memory
361    /// [`crate::usage_log::UsageRecord`] list, e.g. [`crate::Agent::usage_records`],
362    /// can persist it in one call).
363    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    /// P4b: read `<name>`'s usage log (active or archived). Empty (not an
376    /// error) when no usage log has ever been saved for this session.
377    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    /// BP-7 (catalog §4a "Goals"): write `<name>`'s standing objective.
392    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    /// BP-7: read `<name>`'s standing objective (active or archived).
401    /// `None` when the session never set one.
402    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    /// BP-7: drop `<name>`'s standing objective (both locations). A no-op
423    /// when none was ever written.
424    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    /// BP-7 (catalog §4a "Turn/step bracketing records"): overwrite (or
436    /// create) `<name>`'s per-round-trip marker log — the
437    /// `<name>.events.jsonl` family member this store has always reserved
438    /// and swept but never had a writer for. Same bulk-write shape as
439    /// [`Self::save_usage_log`], for a caller holding the full in-memory
440    /// [`crate::turn_record::TurnRecord`] list (e.g.
441    /// [`crate::Agent::turn_records`]).
442    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    /// BP-7: read `<name>`'s marker log (active or archived). Empty (not an
455    /// error) when none has ever been written for this session.
456    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    /// P4c: overwrite (or create) `<name>`'s model-change log with
471    /// `records` — same bulk-write shape as [`Self::save_usage_log`], for a
472    /// caller with the full in-memory [`crate::model_change::ModelChangeRecord`]
473    /// list (e.g. [`crate::Agent::model_change_records`]).
474    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    /// P4c: read `<name>`'s model-change log (active or archived). Empty
487    /// (not an error) when no model-change log has ever been saved for this
488    /// session — the overwhelmingly common case (`allow_switch = false`,
489    /// the default, or a session that never switched models).
490    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    /// P4e (§1.6/§3.1 `core.session.git_metadata`): persist `<name>`'s
508    /// captured git metadata as `<name>.git.json` — a single-record
509    /// overwrite, like [`Self::save_reduction_log`], not an append.
510    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    /// P4e: read `<name>`'s captured git metadata (active or archived).
523    /// `None` (not an error) when no git metadata was ever saved for this
524    /// session — the default (`session_git_metadata = false`).
525    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    /// Save (or overwrite) a session's transcript JSONL and title.
545    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    /// Read a session's transcript JSONL (active or archived).
563    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    /// Read a transcript from exactly the selected active/archive family.
575    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    /// Read a transcript from exactly the selected family when its directory
583    /// entry exists, preserving [`Self::load_if_present`]'s error semantics.
584    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    /// Read a session's transcript JSONL when a transcript directory entry
595    /// exists (active or archived).
596    ///
597    /// Unlike [`Self::transcript_mtime`], this distinguishes genuine absence
598    /// from metadata/read failures. A dangling symlink, directory in place of
599    /// the transcript, permission failure, or any other present-but-unreadable
600    /// entry is an error rather than `None`.
601    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    /// List all sessions (active and archived).
615    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                // `*.meta.json`
628                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    /// Move a session into the archive: the whole `<name>.*` family (D1) —
644    /// transcript, meta, sidecar, reduction log, and event log — tolerating
645    /// any member that doesn't exist (e.g. a session never recorded in
646    /// reduced mode has no sidecar/reduction/events file).
647    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        // P5-3 (D5 "folded into archive… like other session sidecars"): the
695        // `<name>.subagents/` directory is a WHOLE-DIRECTORY member of the
696        // family — moved as a unit (not file-by-file) since its member
697        // count varies per session.
698        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    /// Permanently delete a session (active or archived): the whole
706    /// `<name>.*` family (D1) — a delete that left a full-fidelity sidecar
707    /// behind would be a data-retention surprise. Tolerates any member that
708    /// doesn't exist.
709    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            // P5-3 (D5 "…delete… like other session sidecars"): the whole
733            // `<name>.subagents/` directory, active and archived.
734            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    /// Rename the human-readable title of a session, preserving its other
743    /// recorded stats (`reduced`, `tier`, byte/stub counts, ...) rather than
744    /// resetting them to defaults.
745    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    /// The store's root directory.
767    pub fn root(&self) -> &Path {
768        &self.root
769    }
770
771    /// The transcript file's mtime (active or archived), if it exists.
772    ///
773    /// Legacy session names embed a creation timestamp (`<tag>-<micros>`),
774    /// so callers could derive age/order from the name alone. UX-25's
775    /// memorable names (`<tag>-<adjective>-<noun>`) carry no timestamp, so
776    /// callers that need one — ordering `sessions list`, resolving
777    /// `--continue`/`--last` — fall back to this instead.
778    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    /// Record (or update) a session's reduced-mode stats (C1/C9):
790    /// `reduced = true` plus the full/view byte counts and stub count.
791    /// Creates `<name>.meta.json` with `title` if it doesn't exist yet (so a
792    /// reduced-mode `resume` is visible to `sessions list` even before any
793    /// plain transcript has been saved for it under this name); otherwise
794    /// preserves the existing title/archived flag, like [`Self::set_title`].
795    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    /// P4e (§1.6 obligation-6 "fork-to-new-file WITH provenance", CX shape:
823    /// "linear store, fork copies + truncation"): copy session `from`'s
824    /// transcript into a NEW session `to`, optionally truncated to the
825    /// first `truncate_at_message` lines (each line is one message; `None`
826    /// is a full, byte-identical copy — the pre-P4e `sessions fork`
827    /// behavior), and persist a [`ForkProvenance`] record for `to` (typed,
828    /// lossless per §1.13: an auditor/translator can always recover exactly
829    /// which session and message offset a fork came from). Does NOT touch
830    /// `from` at all -- the source session's own full fidelity is
831    /// unaffected regardless of whether `to` is truncated.
832    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        // DEFECT-4 fix (independent Fable-5 review of P4e): copy the whole
862        // `<name>.*` sidecar family so a fork of a REDUCED session stays
863        // expandable (§1.13 lossless: the fork doc claims lossless, but a
864        // fork that dropped the sidecar left dangling `.sidecar.jsonl`/
865        // `.reduction.json` references). Always copied WHOLE — even when
866        // `truncate_at_message` shortens the transcript — because the
867        // sidecar/logs are the full-fidelity source of truth the (possibly
868        // truncated) transcript is only ever a PROJECTION of; truncating
869        // them to match the transcript would throw away exactly the data
870        // `/expand`/handoff need to reconstruct anything beyond the cut
871        // line. `validate_safe_truncation` above is what keeps a truncated
872        // fork coherent instead: it refuses a cut that would leave the
873        // transcript ending on a dangling tool_call, so the transcript
874        // itself is always a valid, replayable prefix regardless of how
875        // much of the sidecar's fuller history now sits "ahead" of it.
876        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        // P5-5: the `.tree.json` sidecar (if this session ever branched) is
882        // a full-fidelity family member too — copied whole, same rationale
883        // as the sidecar/reduction-log copies just above (the possibly
884        // truncated transcript is only ever a projection of it).
885        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    /// DEFECT-4 fix: copy one member of the `<name>.*` sidecar family from
898    /// `from` to `to`'s ACTIVE location (a fresh fork always lands active,
899    /// never pre-archived), reading `from`'s active copy if present, else
900    /// its archived one — mirrors every other member accessor's
901    /// active-or-archived fallback (`load_sidecar`, `load_reduction_log`,
902    /// ...). A no-op (not an error) when `from` never recorded this member
903    /// at all, matching [`Self::archive`]/[`Self::delete`]'s tolerance.
904    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    /// DEFECT-4 fix: refuse a `--at n` fork whose cut point would leave the
926    /// truncated transcript ending on an assistant `tool_calls` message
927    /// whose tool-result reply (or replies, for a parallel batch) falls at
928    /// or past `n` — i.e. a dangling tool_call with no matching tool
929    /// message in the kept prefix. Such a transcript is neither a valid
930    /// provider request (an assistant tool_calls turn MUST be followed by
931    /// matching tool results before the next real turn) nor safely
932    /// `/expand`-able. `n == 0` (an empty fork) and any `n` that lands on a
933    /// clean turn boundary both pass trivially.
934    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    /// Persist `<name>`'s [`ForkProvenance`] as `<name>.fork.json` —
965    /// overwrite semantics, like [`Self::save_reduction_log`].
966    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    /// Read `<name>`'s [`ForkProvenance`] (active or archived). `None`
975    /// (not an error) when `<name>` was never created via [`Self::fork`].
976    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    /// P5-5 (design §2 module 21 `session.tree`, §1.6 "typed session data …
993    /// folded into archive/delete/list"): persist `<name>`'s
994    /// [`supercode_interchange::session_tree::SessionTree`] as `<name>.tree.json` —
995    /// overwrite semantics, like [`Self::save_reduction_log`].
996    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    /// Persist the non-executing Claude runtime manifest as a member of this
1009    /// session's sidecar family.
1010    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    /// Load a Claude runtime manifest from the active or archived family.
1023    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    /// Load a Claude runtime manifest from exactly the selected family.
1043    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    /// Read `<name>`'s [`supercode_interchange::session_tree::SessionTree`] (active or
1059    /// archived). `None` (not an error) when no tree operation was ever
1060    /// persisted for this session — the default, degenerate-single-path
1061    /// case (see `Self::tree_path`'s doc comment).
1062    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    /// P5-3 (design §2 module 9 D5 "subagent transcripts"): persist a
1082    /// natively-spawned child's full sidecar (native-v2 JSONL, typically
1083    /// [`supercode_interchange::session::Session::to_native_jsonl_v2`]'s output, carrying
1084    /// the child's own lineage header — see that method's doc comment) at
1085    /// `<parent_name>.subagents/<child_id>.sidecar.jsonl`. `child_id` is
1086    /// validated exactly like a top-level session name (it becomes a file
1087    /// stem too) — same path-traversal floor as `Self::validate_name`.
1088    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    /// Persist every subagent attached to an imported [`supercode_interchange::session::Session`]
1105    /// into this store's existing `<parent_name>.subagents/` family.
1106    ///
1107    /// Each child is wrapped in native-v2 before it is written, so its
1108    /// foreign-harness `raw` body survives a later process/disk reload
1109    /// byte-for-byte. All ids are validated (and duplicates rejected) before
1110    /// the first write: an import with incomplete lineage must fail loudly
1111    /// instead of silently dropping or overwriting a child transcript.
1112    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        // Serialize/write one at a time: real Claude sessions can have
1135        // hundreds of MiB of child logs, so retaining a second in-memory
1136        // copy of every child at once would defeat the resume path this
1137        // helper exists to support.
1138        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    /// Read a child's sidecar (active or archived). `None` when this
1146    /// `(parent_name, child_id)` pair was never saved.
1147    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    /// Read a child sidecar from exactly the selected parent family.
1167    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    /// P5-3: persist a child's typed [`crate::subagents::SubagentLineage`]
1183    /// record at `<parent_name>.subagents/<child_id>.lineage.json` —
1184    /// overwrite semantics, like [`Self::save_reduction_log`].
1185    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    /// Read a child's lineage record (active or archived). `None` when this
1203    /// `(parent_name, child_id)` pair was never saved.
1204    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    /// P5-3: every child id natively spawned under `parent_name` (active AND
1226    /// archived, deduped and sorted) — discovered from the `.sidecar.jsonl`
1227    /// members of `Self::subagents_dir`, the same "list what's on disk"
1228    /// posture [`Self::list`] uses for top-level sessions.
1229    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    /// List child ids from exactly the selected active/archive family.
1250    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    // ---- BP-8: the append-only journal (catalog:150/154/156) ------------
1269
1270    /// The journal path for `name` — `<root>/<name>.journal.jsonl`.
1271    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    /// Open (creating if absent) `name`'s append-only journal for writing.
1277    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    /// Replay `name`'s journal (active or archived). `None` when the
1283    /// session never had one — every pre-BP-8 session, and every session
1284    /// whose config left `[core.session] append_only` off.
1285    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    /// Write `name`'s current plan (the folded head of the journal's `plan`
1297    /// records) as `<name>.plan.json`.
1298    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    /// Read `name`'s persisted plan (active or archived), if any.
1309    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    // ---- BP-8: rename (catalog:152 "Session naming/rename") -------------
1322
1323    /// Change a session's RESUME HANDLE — the name every resume door takes
1324    /// — by moving its whole `<name>.*` family (D1) to the new stem, in
1325    /// whichever of the active/archived directories it lives in.
1326    ///
1327    /// This is the half [`Self::set_title`] is not: `set_title` changes the
1328    /// display string only, and a session found by its old handle after a
1329    /// title change is the same session. A rename moves the handle itself,
1330    /// so it must move every family member atomically enough that a
1331    /// half-renamed session is never left behind — hence the up-front
1332    /// collision check (any `<to>.*` member existing at all refuses) rather
1333    /// than discovering the clash halfway through the moves.
1334    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                // `<stem>.` prefix: session stems never contain a `.`, so
1351                // this can only match this session's own family members
1352                // (`<name>.jsonl`, `<name>.meta.json`, `<name>.subagents/`, …).
1353                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        // The meta carries the name as data too; a renamed session that
1374        // still reported its old name to `list` would be a split brain.
1375        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        // The rename is itself a session event: record it in the (moved)
1389        // journal so the handle's history is recoverable from the log.
1390        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    // ---- BP-8: format versioning + in-place upgrade (catalog:153) --------
1402
1403    /// The generation THIS build writes. A session whose meta carries this
1404    /// value is already in the current on-disk shape and
1405    /// [`Self::upgrade_in_place`] does nothing for it.
1406    pub const FORMAT_VERSION: u32 = 2;
1407
1408    /// The generation `name`'s stored family is at. `0` means the meta
1409    /// carries no marker at all — every session written before BP-8.
1410    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    /// Upgrade `name`'s stored transcript IN PLACE to
1419    /// [`Self::FORMAT_VERSION`], and stamp the marker so no later read
1420    /// repeats the work.
1421    ///
1422    /// **Reversible.** The original bytes are copied verbatim to
1423    /// `<name>.v<old>.jsonl` BEFORE anything is rewritten, so the
1424    /// pre-upgrade file is always recoverable; the rewrite itself is a
1425    /// per-line [`crate::ChatMessage`] round-trip, which drops keys this
1426    /// build does not model and normalizes the ones it does — never
1427    /// collapsing multimodal parts into text or otherwise changing what the
1428    /// line MEANS.
1429    ///
1430    /// Returns `None` when the session is already current (or has no
1431    /// transcript) — that `None` is the whole point of the marker: the
1432    /// tolerant path is paid once, not on every read.
1433    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        // Preserve first, rewrite second: an interruption between the two
1469        // leaves the original intact under both names, never neither.
1470        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    /// Stamp the format marker on `name`'s meta, preserving every other
1495    /// recorded field (same posture as [`Self::set_title`]).
1496    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    // ---- BP-8: the derived index cache (catalog:151) ---------------------
1518
1519    /// `<root>/.session-index.json` — the derived listing cache. Never a
1520    /// session name (it starts with a dot, which [`Self::validate_name`]
1521    /// rejects) and never a `*.meta.json`, so [`Self::list`] cannot see it.
1522    pub fn index_path(&self) -> PathBuf {
1523        self.root.join(".session-index.json")
1524    }
1525
1526    /// Delete the derived index. Purely a cache drop: the next
1527    /// [`Self::index`] rebuilds an identical answer from the transcripts,
1528    /// which remain the only record.
1529    pub fn invalidate_index(&self) {
1530        let _ = std::fs::remove_file(self.index_path());
1531    }
1532
1533    /// The fast listing: title, age-ordering time, message count and a
1534    /// first-user-line preview for every session, WITHOUT reading each
1535    /// transcript on every call.
1536    ///
1537    /// The transcripts stay authoritative. Each cached row carries the
1538    /// `(mtime, size)` of the transcript it was derived from; a row whose
1539    /// validator still matches is reused, and any row that does not (or is
1540    /// missing) is re-derived by reading that one file. The cache is then
1541    /// written back. Deleting [`Self::index_path`] therefore changes
1542    /// nothing except how much work the next call does — which is exactly
1543    /// what makes it a cache and not a second source of truth.
1544    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        // Never CREATES the root: addressing a store must not bring one
1590        // into existence (see `SessionStore::at`), and a listing is a read.
1591        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    /// P4e (§1.6/§3.1 `core.session.retention_days`): permanently delete
1640    /// every ARCHIVED session (never an active one — retention is a
1641    /// post-archive concern, matching every peer harness) whose transcript
1642    /// is older than `retention_days` days as of `now`. Returns the names
1643    /// deleted (empty if nothing was old enough, or `retention_days == 0`
1644    /// which this treats as "prune nothing" rather than "prune
1645    /// everything" -- an explicit, non-surprising floor).
1646    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/// BP-8: what [`SessionStore::upgrade_in_place`] did.
1677#[derive(Debug, Clone, PartialEq, Eq)]
1678pub struct FormatUpgrade {
1679    /// The generation the file was at (`0` = unmarked).
1680    pub from_version: u32,
1681    /// The generation it is at now.
1682    pub to_version: u32,
1683    /// Store-relative file holding the ORIGINAL bytes verbatim.
1684    pub original: String,
1685    /// Messages in the upgraded transcript.
1686    pub messages: usize,
1687    /// Whether the transcript bytes actually changed (a file already in
1688    /// today's shape is only STAMPED, never rewritten).
1689    pub rewritten: bool,
1690}
1691
1692/// BP-8: the derived-index cache format version.
1693pub const SESSION_INDEX_VERSION: u32 = 1;
1694
1695/// One cached listing row — see [`SessionStore::index`].
1696#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1697pub struct SessionIndexEntry {
1698    /// Session name (the resume handle).
1699    pub name: String,
1700    /// Display title.
1701    pub title: String,
1702    /// Whether the session is archived.
1703    pub archived: bool,
1704    /// Seconds since the epoch used to order "newest first".
1705    pub time_secs: u64,
1706    /// First user line, truncated — the picker's preview.
1707    pub preview: String,
1708    /// Messages in the transcript.
1709    pub messages: usize,
1710    /// Validator: transcript mtime in unix-NANOSECONDS at derivation time.
1711    /// Nanoseconds, not milliseconds: two writes inside one millisecond
1712    /// that happen to produce the same byte count would otherwise validate
1713    /// a stale row.
1714    pub mtime_nanos: u64,
1715    /// Validator: transcript size in bytes at derivation time.
1716    pub size: u64,
1717}
1718
1719/// The listing plus how it was obtained (how many rows came from the cache
1720/// and how many had to be re-derived) — the counters make "this really is
1721/// read-repaired, not re-read from scratch" a checkable claim.
1722#[derive(Debug, Clone, Default, PartialEq, Eq)]
1723pub struct SessionIndex {
1724    /// Rows, newest first.
1725    pub entries: Vec<SessionIndexEntry>,
1726    /// Rows served from the cache without opening the transcript.
1727    pub reused: usize,
1728    /// Rows re-derived from the transcript this call.
1729    pub rederived: usize,
1730}
1731
1732#[derive(Serialize, Deserialize)]
1733struct SessionIndexFile {
1734    version: u32,
1735    entries: Vec<SessionIndexEntry>,
1736}
1737
1738/// The "when was this session last active" rule, in one place so the store
1739/// and its callers cannot drift: a legacy `<tag>-<micros>` name carries its
1740/// own creation time, and everything else falls back to the transcript's
1741/// mtime.
1742pub 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
1749/// One-line, length-capped preview of a message body.
1750fn 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/// P4e (§1.6 obligation-6 "fork-to-new-file WITH provenance") — see
1761/// [`SessionStore::fork`].
1762#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1763pub struct ForkProvenance {
1764    /// The session name this fork was copied from.
1765    pub forked_from: String,
1766    /// If the fork was truncated, how many leading messages it kept.
1767    /// `None` means a full, untruncated copy.
1768    #[serde(default)]
1769    pub forked_at_message: Option<usize>,
1770    /// Unix-ms wall-clock time the fork was created.
1771    #[serde(default)]
1772    pub timestamp_ms: i64,
1773}