From 2e7d146d1d63bb38c9e48f9bd768a95a18d6cf7c Mon Sep 17 00:00:00 2001 From: dudegladiator Date: Tue, 9 Jun 2026 17:45:44 +0530 Subject: [PATCH 1/2] feat: parentUuid auto-relink on save + restore subcommand Fixes the failure mode where scattered deletes left surviving messages pointing to deleted ancestors, making Claude Code render the session as empty on resume. - Session::render_with_relink rewrites parentUuid for survivors whose ancestor was deleted, walking up to the nearest surviving ancestor (or null at the root). Foreign parent uuids are preserved verbatim. - 'delete' output reports parent_uuid_relinked count. - New 'cc-session restore [--list]' rolls back from .bak with a pre-restore snapshot saved aside as .pre-restore.. - 4 new unit tests cover relink-skips-deleted, relink-to-root, no-change-byte-equal, and foreign-parent-preserved. --- README.md | 3 +- src/cli.rs | 173 ++++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 15 ++++ src/session.rs | 187 ++++++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 358 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 5b599cb..9755d49 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,8 @@ cc-session search [--json] [--limit N] cc-session show [--json] [--full] [--include-hidden] cc-session info [--json] cc-session delete --indices 3,5,7 [--from-top N] [--from-bottom N] [--range lo..hi] [--dry-run] [--force] [--json] -cc-session update [--version v0.2.0] +cc-session update [--version v0.2.0] +cc-session restore [--list] [--json] ``` `` accepts a full path, a session UUID, or a unique substring of one. Indices are 0-based positions in the raw JSONL (use `cc-session show --json` to map text → index). Auto-pair always extends the delete set to keep `tool_use`/`tool_result` blocks together; `paired_added` in the output reports what was added. diff --git a/src/cli.rs b/src/cli.rs index c1c2045..25fcf97 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -248,6 +248,7 @@ pub fn show( #[derive(Serialize)] struct DeleteOutput { path: String, + parent_uuid_relinked: usize, backup: Option, requested: Vec, after_auto_pair: Vec, @@ -327,7 +328,7 @@ pub fn delete( )); } - let content = session.render(&marked)?; + let (content, relinked) = session.render_with_relink(&marked)?; let after = total - marked.len(); let (saved, backup) = if dry_run { @@ -344,6 +345,7 @@ pub fn delete( let out = DeleteOutput { path: entry.path.display().to_string(), + parent_uuid_relinked: relinked, backup, requested: requested_sorted, after_auto_pair: all_sorted, @@ -368,6 +370,7 @@ pub fn delete( out.total_messages_after, out.total_messages_before - out.total_messages_after ); + println!("parent_uuid relinked: {}", out.parent_uuid_relinked); println!("dry_run: {}", out.dry_run); println!("saved: {}", out.saved); if let Some(b) = &out.backup { @@ -474,6 +477,158 @@ pub fn info(projects_dir: &Path, target: &str, json: bool) -> Result<()> { Ok(()) } +// ---------- restore ---------- + +#[derive(Serialize)] +struct RestoreOutput { + path: String, + backup: String, + pre_restore_snapshot: Option, + backup_messages: usize, + current_messages: Option, + backup_size: u64, + backup_modified: String, + listed_only: bool, + restored: bool, +} + +pub fn restore( + projects_dir: &Path, + target: &str, + list_only: bool, + force: bool, + json: bool, +) -> Result<()> { + let entry = resolve_target(projects_dir, target)?; + let bak_path = bak_path_for(&entry.path); + if !bak_path.exists() { + bail!( + "no backup found at {} — cc-session writes .bak on every save", + bak_path.display() + ); + } + + // Sanity-check the backup parses; we don't want to restore a corrupt file. + let backup_session = Session::load(&bak_path)?; + let backup_messages = backup_session.messages.len(); + + let bak_meta = std::fs::metadata(&bak_path)?; + let bak_size = bak_meta.len(); + let bak_mtime: DateTime = bak_meta + .modified() + .unwrap_or(std::time::SystemTime::UNIX_EPOCH) + .into(); + let bak_modified = bak_mtime.format("%Y-%m-%d %H:%M:%S").to_string(); + + let current_messages = if entry.path.exists() { + Session::load(&entry.path).ok().map(|s| s.messages.len()) + } else { + None + }; + + if list_only { + let out = RestoreOutput { + path: entry.path.display().to_string(), + backup: bak_path.display().to_string(), + pre_restore_snapshot: None, + backup_messages, + current_messages, + backup_size: bak_size, + backup_modified: bak_modified, + listed_only: true, + restored: false, + }; + if json { + println!("{}", serde_json::to_string_pretty(&out)?); + } else { + println!("path: {}", out.path); + println!("backup: {}", out.backup); + println!("backup msgs: {}", out.backup_messages); + if let Some(c) = out.current_messages { + println!("current msgs: {c}"); + } else { + println!("current msgs: (file missing)"); + } + println!("backup size: {}", human_size(out.backup_size)); + println!("backup mtime: {}", out.backup_modified); + } + return Ok(()); + } + + if !force && entry.path.exists() && super::io::lsof::is_open(&entry.path)? { + bail!("file is open by another process; close Claude Code or pass --force"); + } + + // If a current file exists, snapshot it aside before overwriting so the + // restore itself is reversible. Use a sibling path that does NOT match + // *.bak (which we'd clobber on next save). + let snapshot = if entry.path.exists() { + let snap = pre_restore_snapshot_path(&entry.path); + std::fs::copy(&entry.path, &snap)?; + Some(snap) + } else { + None + }; + + // Atomic restore: copy bak -> .tmp, fsync, rename. + let tmp = with_extension_appended(&entry.path, "tmp"); + std::fs::copy(&bak_path, &tmp)?; + { + let f = std::fs::OpenOptions::new().write(true).open(&tmp)?; + f.sync_all()?; + } + std::fs::rename(&tmp, &entry.path)?; + + let out = RestoreOutput { + path: entry.path.display().to_string(), + backup: bak_path.display().to_string(), + pre_restore_snapshot: snapshot.map(|p| p.display().to_string()), + backup_messages, + current_messages, + backup_size: bak_size, + backup_modified: bak_modified, + listed_only: false, + restored: true, + }; + + if json { + println!("{}", serde_json::to_string_pretty(&out)?); + } else { + println!("restored: {}", out.path); + println!("from: {}", out.backup); + if let Some(s) = &out.pre_restore_snapshot { + println!("prev: {s} (snapshot of state before restore)"); + } + println!( + "messages: {} (was {})", + out.backup_messages, + out.current_messages + .map(|n| n.to_string()) + .unwrap_or_else(|| "missing".into()) + ); + } + Ok(()) +} + +fn bak_path_for(path: &Path) -> PathBuf { + with_extension_appended(path, "bak") +} + +fn pre_restore_snapshot_path(path: &Path) -> PathBuf { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + with_extension_appended(path, &format!("pre-restore.{stamp}")) +} + +fn with_extension_appended(path: &Path, suffix: &str) -> PathBuf { + let mut s = path.as_os_str().to_owned(); + s.push("."); + s.push(suffix); + PathBuf::from(s) +} + // ---------- agent guide ---------- pub const AGENT_GUIDE: &str = r#"# cc-session agent guide @@ -503,6 +658,10 @@ while keeping tool_use/tool_result pairs and conversational turns intact. bypasses the lsof safety check. 5. (Optional) self-update: cc-session update [--version v0.2.0] +6. If a delete breaks resume in Claude Code, restore from backup: + cc-session restore --list # inspect first + cc-session restore # apply (snapshots current + # to .pre-restore.) ## Target argument () @@ -541,6 +700,9 @@ The delete output reports `requested` (what you asked) and `paired_added` { "path": "", + "parent_uuid_relinked": int, // survivors whose parentUuid + // was rewritten to skip + // deleted ancestors "backup": ".bak | null when --dry-run", "requested": [int, ...], // sorted, what you asked "after_auto_pair": [int, ...], // sorted, final delete set @@ -633,6 +795,15 @@ Always inspect stderr on non-zero exit for the human-readable cause. cc-session search "auth middleware" --json --limit 1 cc-session show --json +## Resume safety: parentUuid auto-relink + +Every save scans surviving messages and rewrites any `parentUuid` that +points to a now-deleted ancestor, walking up the chain to the nearest +surviving ancestor (or null if the chain reaches the root). The count is +reported in `parent_uuid_relinked`. This keeps Claude Code's resume +renderer happy after scattered deletes; if it ever fails anyway, +`cc-session restore ` rolls back to the .bak snapshot. + ## Things this CLI will NOT do - Edit message contents in place. diff --git a/src/main.rs b/src/main.rs index ad3250f..f334925 100644 --- a/src/main.rs +++ b/src/main.rs @@ -105,6 +105,18 @@ enum Command { /// exit codes. Designed for LLMs and scripts to read once and operate /// autonomously. AgentGuide, + /// Restore a session from its .bak backup. Refuses to overwrite + /// while Claude Code holds the file open unless --force. + Restore { + /// Session id, file path, or substring of either. + target: String, + /// Just print the backup path and metadata; don't restore. + #[arg(long)] + list: bool, + /// Output JSON. + #[arg(long)] + json: bool, + }, } fn main() -> anyhow::Result<()> { @@ -160,6 +172,9 @@ fn main() -> anyhow::Result<()> { print!("{}", cli::AGENT_GUIDE); Ok(()) } + Some(Command::Restore { target, list, json }) => { + cli::restore(&projects_dir, &target, list, cli.force, json) + } } } diff --git a/src/session.rs b/src/session.rs index 4be046f..40cc838 100644 --- a/src/session.rs +++ b/src/session.rs @@ -107,35 +107,109 @@ impl Session { }) } - /// Render a subset of messages (those whose indices are not in `omit`) as - /// JSONL bytes. Untouched messages reuse their `original_line` for byte - /// equivalence; new or modified messages serialize via serde. - pub fn render(&self, omit: &std::collections::HashSet) -> Result { - let mut out = String::new(); + /// Render a subset of messages (those whose indices are not in `omit`) + /// as JSONL bytes. Survivors whose `parentUuid` points into the deleted + /// set are re-linked to the nearest surviving ancestor (or `None` if the + /// chain reaches the root); their line is re-serialized so the file + /// stays internally consistent. Other untouched messages reuse their + /// `original_line` for byte equivalence. + /// + /// Returns the rendered bytes and the number of survivors whose + /// `parentUuid` was rewritten. + pub fn render_with_relink( + &self, + omit: &std::collections::HashSet, + ) -> Result<(String, usize), SessionError> { + // Build uuid -> idx map for the full session, plus parent-uuid lookup + // by idx so we can climb chains in O(1) per step. + let mut uuid_to_idx: std::collections::HashMap<&str, usize> = + std::collections::HashMap::with_capacity(self.messages.len()); + for (idx, msg) in self.messages.iter().enumerate() { + if let Some(u) = &msg.uuid { + uuid_to_idx.insert(u.as_str(), idx); + } + } + + // For each msg, compute the parentUuid that should appear in the + // rewritten file: walk up via parent_uuid until you find a surviving + // ancestor, or None. Cache results. + let mut resolved_parent: Vec> = vec![None; self.messages.len()]; + let mut needs_rewrite: Vec = vec![false; self.messages.len()]; for (idx, msg) in self.messages.iter().enumerate() { if omit.contains(&idx) { continue; } - match &msg.original_line { - Some(line) => { - out.push_str(line); - out.push('\n'); + let original = msg.parent_uuid.clone(); + let mut cursor = original.as_deref(); + loop { + let Some(p_uuid) = cursor else { + // Chain reached root. + resolved_parent[idx] = None; + break; + }; + let Some(&p_idx) = uuid_to_idx.get(p_uuid) else { + // Parent uuid does not refer to any known message in this + // file. Preserve verbatim — this is foreign data we can't + // reason about. + resolved_parent[idx] = Some(p_uuid.to_string()); + break; + }; + if !omit.contains(&p_idx) { + resolved_parent[idx] = Some(p_uuid.to_string()); + break; } - None => { - out.push_str(&serde_json::to_string(msg).map_err(|source| { - SessionError::Parse { - line: idx + 1, - source, - } - })?); - out.push('\n'); + // Parent is deleted; climb to its parent. + cursor = self.messages[p_idx].parent_uuid.as_deref(); + } + if resolved_parent[idx] != original { + needs_rewrite[idx] = true; + } + } + + let mut out = String::new(); + let mut relinked = 0usize; + for (idx, msg) in self.messages.iter().enumerate() { + if omit.contains(&idx) { + continue; + } + if needs_rewrite[idx] { + relinked += 1; + let mut clone = msg.clone(); + clone.parent_uuid = resolved_parent[idx].clone(); + clone.original_line = None; + let line = serde_json::to_string(&clone).map_err(|source| SessionError::Parse { + line: idx + 1, + source, + })?; + out.push_str(&line); + out.push('\n'); + } else { + match &msg.original_line { + Some(line) => { + out.push_str(line); + out.push('\n'); + } + None => { + let line = + serde_json::to_string(msg).map_err(|source| SessionError::Parse { + line: idx + 1, + source, + })?; + out.push_str(&line); + out.push('\n'); + } } } } if !self.trailing_newline && out.ends_with('\n') { out.pop(); } - Ok(out) + Ok((out, relinked)) + } + + /// Backwards-compatible render that discards the relink count. + pub fn render(&self, omit: &std::collections::HashSet) -> Result { + Ok(self.render_with_relink(omit)?.0) } } @@ -201,6 +275,83 @@ mod tests { } } + #[test] + fn relink_skips_deleted_ancestor() { + // Chain: a (root) -> b -> c -> d. Delete b and c. d should now point + // to a; a should still point to nothing. + let content = concat!( + "{\"type\":\"user\",\"uuid\":\"a\",\"message\":{\"role\":\"user\",\"content\":\"a\"}}\n", + "{\"type\":\"assistant\",\"uuid\":\"b\",\"parentUuid\":\"a\",\"message\":{\"role\":\"assistant\",\"content\":\"b\"}}\n", + "{\"type\":\"user\",\"uuid\":\"c\",\"parentUuid\":\"b\",\"message\":{\"role\":\"user\",\"content\":\"c\"}}\n", + "{\"type\":\"assistant\",\"uuid\":\"d\",\"parentUuid\":\"c\",\"message\":{\"role\":\"assistant\",\"content\":\"d\"}}\n", + ); + let f = write_tmp(content); + let s = Session::load(f.path()).unwrap(); + let mut omit = std::collections::HashSet::new(); + omit.insert(1); // b + omit.insert(2); // c + let (out, relinked) = s.render_with_relink(&omit).unwrap(); + assert_eq!(relinked, 1, "only d's parent should change"); + // d's line should be re-serialized with parentUuid=a. + assert!(out.contains("\"uuid\":\"d\"")); + assert!(out.contains("\"parentUuid\":\"a\"")); + // a is untouched. + assert!(out.contains("\"uuid\":\"a\"")); + // b and c are gone. + assert!(!out.contains("\"uuid\":\"b\"")); + assert!(!out.contains("\"uuid\":\"c\"")); + } + + #[test] + fn relink_to_root_when_all_ancestors_deleted() { + // a -> b -> c. Delete a and b. c's parentUuid should become null + // (omitted in JSON because Option::None skip_serializing). + let content = concat!( + "{\"type\":\"user\",\"uuid\":\"a\"}\n", + "{\"type\":\"assistant\",\"uuid\":\"b\",\"parentUuid\":\"a\"}\n", + "{\"type\":\"user\",\"uuid\":\"c\",\"parentUuid\":\"b\"}\n", + ); + let f = write_tmp(content); + let s = Session::load(f.path()).unwrap(); + let mut omit = std::collections::HashSet::new(); + omit.insert(0); + omit.insert(1); + let (out, relinked) = s.render_with_relink(&omit).unwrap(); + assert_eq!(relinked, 1); + assert!(out.contains("\"uuid\":\"c\"")); + // c should NOT contain a parentUuid field after relink. + assert!(!out.contains("\"parentUuid\"")); + } + + #[test] + fn relink_no_change_byte_equal() { + // Nothing deleted -> output byte-equal to input, relinked=0. + let content = concat!( + "{\"type\":\"user\",\"uuid\":\"a\"}\n", + "{\"type\":\"assistant\",\"uuid\":\"b\",\"parentUuid\":\"a\"}\n", + ); + let f = write_tmp(content); + let s = Session::load(f.path()).unwrap(); + let omit = std::collections::HashSet::new(); + let (out, relinked) = s.render_with_relink(&omit).unwrap(); + assert_eq!(relinked, 0); + assert_eq!(out, content); + } + + #[test] + fn relink_preserves_unknown_parent_uuid() { + // c.parentUuid points outside the file (e.g. came from a fork). + // Even when ancestor uuids are not present, we should not blank it. + let content = + "{\"type\":\"user\",\"uuid\":\"c\",\"parentUuid\":\"foreign-id\"}\n".to_string(); + let f = write_tmp(&content); + let s = Session::load(f.path()).unwrap(); + let omit = std::collections::HashSet::new(); + let (out, relinked) = s.render_with_relink(&omit).unwrap(); + assert_eq!(relinked, 0); + assert!(out.contains("\"parentUuid\":\"foreign-id\"")); + } + #[test] fn omit_drops_indices() { let content = "{\"type\":\"user\",\"uuid\":\"a\"}\n{\"type\":\"user\",\"uuid\":\"b\"}\n{\"type\":\"user\",\"uuid\":\"c\"}\n"; From f0f90d85dfbd0852b3311dd1c672f1fc80b22328 Mon Sep 17 00:00:00 2001 From: dudegladiator Date: Tue, 9 Jun 2026 17:49:18 +0530 Subject: [PATCH 2/2] release: v0.3.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index efaef58..913fc84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -173,7 +173,7 @@ dependencies = [ [[package]] name = "cc-session" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "assert_fs", diff --git a/Cargo.toml b/Cargo.toml index b5134ee..13cc631 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cc-session" -version = "0.2.0" +version = "0.3.0" edition = "2021" rust-version = "1.75" description = "Interactive TUI editor for Claude Code session JSONL files. Browse, search, and surgically delete messages while preserving tool_use/tool_result pairing."