@@ -248,6 +248,7 @@ pub fn show(
248248#[ derive( Serialize ) ]
249249struct DeleteOutput {
250250 path : String ,
251+ parent_uuid_relinked : usize ,
251252 backup : Option < String > ,
252253 requested : Vec < usize > ,
253254 after_auto_pair : Vec < usize > ,
@@ -327,7 +328,7 @@ pub fn delete(
327328 ) ) ;
328329 }
329330
330- let content = session. render ( & marked) ?;
331+ let ( content, relinked ) = session. render_with_relink ( & marked) ?;
331332 let after = total - marked. len ( ) ;
332333
333334 let ( saved, backup) = if dry_run {
@@ -344,6 +345,7 @@ pub fn delete(
344345
345346 let out = DeleteOutput {
346347 path : entry. path . display ( ) . to_string ( ) ,
348+ parent_uuid_relinked : relinked,
347349 backup,
348350 requested : requested_sorted,
349351 after_auto_pair : all_sorted,
@@ -368,6 +370,7 @@ pub fn delete(
368370 out. total_messages_after,
369371 out. total_messages_before - out. total_messages_after
370372 ) ;
373+ println ! ( "parent_uuid relinked: {}" , out. parent_uuid_relinked) ;
371374 println ! ( "dry_run: {}" , out. dry_run) ;
372375 println ! ( "saved: {}" , out. saved) ;
373376 if let Some ( b) = & out. backup {
@@ -474,6 +477,158 @@ pub fn info(projects_dir: &Path, target: &str, json: bool) -> Result<()> {
474477 Ok ( ( ) )
475478}
476479
480+ // ---------- restore ----------
481+
482+ #[ derive( Serialize ) ]
483+ struct RestoreOutput {
484+ path : String ,
485+ backup : String ,
486+ pre_restore_snapshot : Option < String > ,
487+ backup_messages : usize ,
488+ current_messages : Option < usize > ,
489+ backup_size : u64 ,
490+ backup_modified : String ,
491+ listed_only : bool ,
492+ restored : bool ,
493+ }
494+
495+ pub fn restore (
496+ projects_dir : & Path ,
497+ target : & str ,
498+ list_only : bool ,
499+ force : bool ,
500+ json : bool ,
501+ ) -> Result < ( ) > {
502+ let entry = resolve_target ( projects_dir, target) ?;
503+ let bak_path = bak_path_for ( & entry. path ) ;
504+ if !bak_path. exists ( ) {
505+ bail ! (
506+ "no backup found at {} — cc-session writes <file>.bak on every save" ,
507+ bak_path. display( )
508+ ) ;
509+ }
510+
511+ // Sanity-check the backup parses; we don't want to restore a corrupt file.
512+ let backup_session = Session :: load ( & bak_path) ?;
513+ let backup_messages = backup_session. messages . len ( ) ;
514+
515+ let bak_meta = std:: fs:: metadata ( & bak_path) ?;
516+ let bak_size = bak_meta. len ( ) ;
517+ let bak_mtime: DateTime < Local > = bak_meta
518+ . modified ( )
519+ . unwrap_or ( std:: time:: SystemTime :: UNIX_EPOCH )
520+ . into ( ) ;
521+ let bak_modified = bak_mtime. format ( "%Y-%m-%d %H:%M:%S" ) . to_string ( ) ;
522+
523+ let current_messages = if entry. path . exists ( ) {
524+ Session :: load ( & entry. path ) . ok ( ) . map ( |s| s. messages . len ( ) )
525+ } else {
526+ None
527+ } ;
528+
529+ if list_only {
530+ let out = RestoreOutput {
531+ path : entry. path . display ( ) . to_string ( ) ,
532+ backup : bak_path. display ( ) . to_string ( ) ,
533+ pre_restore_snapshot : None ,
534+ backup_messages,
535+ current_messages,
536+ backup_size : bak_size,
537+ backup_modified : bak_modified,
538+ listed_only : true ,
539+ restored : false ,
540+ } ;
541+ if json {
542+ println ! ( "{}" , serde_json:: to_string_pretty( & out) ?) ;
543+ } else {
544+ println ! ( "path: {}" , out. path) ;
545+ println ! ( "backup: {}" , out. backup) ;
546+ println ! ( "backup msgs: {}" , out. backup_messages) ;
547+ if let Some ( c) = out. current_messages {
548+ println ! ( "current msgs: {c}" ) ;
549+ } else {
550+ println ! ( "current msgs: (file missing)" ) ;
551+ }
552+ println ! ( "backup size: {}" , human_size( out. backup_size) ) ;
553+ println ! ( "backup mtime: {}" , out. backup_modified) ;
554+ }
555+ return Ok ( ( ) ) ;
556+ }
557+
558+ if !force && entry. path . exists ( ) && super :: io:: lsof:: is_open ( & entry. path ) ? {
559+ bail ! ( "file is open by another process; close Claude Code or pass --force" ) ;
560+ }
561+
562+ // If a current file exists, snapshot it aside before overwriting so the
563+ // restore itself is reversible. Use a sibling path that does NOT match
564+ // *.bak (which we'd clobber on next save).
565+ let snapshot = if entry. path . exists ( ) {
566+ let snap = pre_restore_snapshot_path ( & entry. path ) ;
567+ std:: fs:: copy ( & entry. path , & snap) ?;
568+ Some ( snap)
569+ } else {
570+ None
571+ } ;
572+
573+ // Atomic restore: copy bak -> <path>.tmp, fsync, rename.
574+ let tmp = with_extension_appended ( & entry. path , "tmp" ) ;
575+ std:: fs:: copy ( & bak_path, & tmp) ?;
576+ {
577+ let f = std:: fs:: OpenOptions :: new ( ) . write ( true ) . open ( & tmp) ?;
578+ f. sync_all ( ) ?;
579+ }
580+ std:: fs:: rename ( & tmp, & entry. path ) ?;
581+
582+ let out = RestoreOutput {
583+ path : entry. path . display ( ) . to_string ( ) ,
584+ backup : bak_path. display ( ) . to_string ( ) ,
585+ pre_restore_snapshot : snapshot. map ( |p| p. display ( ) . to_string ( ) ) ,
586+ backup_messages,
587+ current_messages,
588+ backup_size : bak_size,
589+ backup_modified : bak_modified,
590+ listed_only : false ,
591+ restored : true ,
592+ } ;
593+
594+ if json {
595+ println ! ( "{}" , serde_json:: to_string_pretty( & out) ?) ;
596+ } else {
597+ println ! ( "restored: {}" , out. path) ;
598+ println ! ( "from: {}" , out. backup) ;
599+ if let Some ( s) = & out. pre_restore_snapshot {
600+ println ! ( "prev: {s} (snapshot of state before restore)" ) ;
601+ }
602+ println ! (
603+ "messages: {} (was {})" ,
604+ out. backup_messages,
605+ out. current_messages
606+ . map( |n| n. to_string( ) )
607+ . unwrap_or_else( || "missing" . into( ) )
608+ ) ;
609+ }
610+ Ok ( ( ) )
611+ }
612+
613+ fn bak_path_for ( path : & Path ) -> PathBuf {
614+ with_extension_appended ( path, "bak" )
615+ }
616+
617+ fn pre_restore_snapshot_path ( path : & Path ) -> PathBuf {
618+ let stamp = std:: time:: SystemTime :: now ( )
619+ . duration_since ( std:: time:: UNIX_EPOCH )
620+ . map ( |d| d. as_secs ( ) )
621+ . unwrap_or ( 0 ) ;
622+ with_extension_appended ( path, & format ! ( "pre-restore.{stamp}" ) )
623+ }
624+
625+ fn with_extension_appended ( path : & Path , suffix : & str ) -> PathBuf {
626+ let mut s = path. as_os_str ( ) . to_owned ( ) ;
627+ s. push ( "." ) ;
628+ s. push ( suffix) ;
629+ PathBuf :: from ( s)
630+ }
631+
477632// ---------- agent guide ----------
478633
479634pub const AGENT_GUIDE : & str = r#"# cc-session agent guide
@@ -503,6 +658,10 @@ while keeping tool_use/tool_result pairs and conversational turns intact.
503658 bypasses the lsof safety check.
5046595. (Optional) self-update:
505660 cc-session update [--version v0.2.0]
661+ 6. If a delete breaks resume in Claude Code, restore from backup:
662+ cc-session restore <id> --list # inspect first
663+ cc-session restore <id> # apply (snapshots current
664+ # to <path>.pre-restore.<ts>)
506665
507666## Target argument (<id-or-path>)
508667
@@ -541,6 +700,9 @@ The delete output reports `requested` (what you asked) and `paired_added`
541700
542701 {
543702 "path": "<absolute path>",
703+ "parent_uuid_relinked": int, // survivors whose parentUuid
704+ // was rewritten to skip
705+ // deleted ancestors
544706 "backup": "<path>.bak | null when --dry-run",
545707 "requested": [int, ...], // sorted, what you asked
546708 "after_auto_pair": [int, ...], // sorted, final delete set
@@ -633,6 +795,15 @@ Always inspect stderr on non-zero exit for the human-readable cause.
633795 cc-session search "auth middleware" --json --limit 1
634796 cc-session show <id-from-above> --json
635797
798+ ## Resume safety: parentUuid auto-relink
799+
800+ Every save scans surviving messages and rewrites any `parentUuid` that
801+ points to a now-deleted ancestor, walking up the chain to the nearest
802+ surviving ancestor (or null if the chain reaches the root). The count is
803+ reported in `parent_uuid_relinked`. This keeps Claude Code's resume
804+ renderer happy after scattered deletes; if it ever fails anyway,
805+ `cc-session restore <id>` rolls back to the .bak snapshot.
806+
636807## Things this CLI will NOT do
637808
638809 - Edit message contents in place.
0 commit comments