From 0992f6ccf6814e6312c5f796e7c68b2d5947e766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sea=CC=81nan=20McCullough?= Date: Fri, 24 Apr 2026 16:01:42 -0700 Subject: [PATCH] feat(cli): add rat resurrect to replay an old log into a fresh daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `rat resurrect ` subcommand. Source is either a .log path or a session name/id; for names, look up the meta file (works for dead sessions too — metas outlive the process until clean shutdown or `rat kill` sweeps them). Plumbing: - Daemon gets a new `--seed-log ` flag. Between SessionStarted and the PTY spawn, it reads the seed log's PtyOutput events and re-appends them to the fresh log, then writes a yellow separator marker so the user can see where the new shell begins. Non-visible events (ClientInput, Resized, SessionStarted/Ended) are dropped. - Client gets `spawn_daemon_with_seed` as the plumbing core; `spawn_daemon` is now a thin `seed_log=None` shim over it, so the existing callers (`new`, `spawn_bare`, post-attach new-session) are unchanged. What resurrect rebuilds: visible scrollback. What it doesn't: the original process, env, cwd. This makes it genuinely useful after a daemon crash or host reboot without pretending to be a full session migration. Session migration is a separate roadmap item (durable state across hosts). --- README.md | 29 ++++++++++++++++++++ src/bin/rat-daemon.rs | 39 +++++++++++++++++++++++++++ src/bin/rat.rs | 62 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 657d437..f406ab2 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,35 @@ wait before escalating holdouts to SIGKILL. If you're currently inside a rat session when you run this, the prompt calls that out — you're about to kill the daemon under your own feet. +### `rat resurrect SOURCE [-n, --name NAME] [-f, --force]` + +Spin up a fresh daemon whose event log is pre-seeded with an old +session's `PtyOutput`, then attach. The original PTY is long gone, so +the new shell is fresh — but the scrollback you left behind is +replayed into place, followed by a yellow `-- rat resurrect: +previous session replayed above --` separator and the live prompt. + +`SOURCE` is either: +- a `.log` file path (absolute or relative), or +- a session name / alias / UUID / UUID prefix that still has a + meta file on disk (dead sessions qualify — metas are only removed + by clean shutdown or `rat kill`). + +```sh +# By path — works even if the meta is gone +rat resurrect ~/.local/state/rat/.log + +# By name — resolves via the still-present meta +rat resurrect agent + +# Name the resurrected session differently +rat resurrect agent -n agent-ii +``` + +What you get back: visual history. What you don't: the old process, +the old environment, the old working directory. Resurrect rebuilds +"what I was looking at," not "what I was running." + ### `rat replay LOG_PATH` Non-interactive playback of a session log file (the diff --git a/src/bin/rat-daemon.rs b/src/bin/rat-daemon.rs index e692a73..3d30c1f 100644 --- a/src/bin/rat-daemon.rs +++ b/src/bin/rat-daemon.rs @@ -25,6 +25,11 @@ struct Args { rows: u16, #[arg(long)] name: Option, + /// Seed the new session's log with PtyOutput events from an existing + /// log file before the fresh shell starts. Used by `rat resurrect` + /// to reconstruct a dead session's visible scrollback in a new daemon. + #[arg(long)] + seed_log: Option, /// Command to run; defaults to $SHELL. #[arg(trailing_var_arg = true)] cmd: Vec, @@ -103,6 +108,40 @@ async fn main() -> Result<()> { rows, })?; + // If we were asked to seed, copy the old log's PtyOutput into our fresh + // log between SessionStarted and the live shell's first byte. Clients + // attaching to this daemon will then see the previous session's + // scrollback followed by a visible marker and the new shell. + if let Some(seed_path) = &args.seed_log { + match FileEventLog::open(seed_path) { + Ok(seed_log) => match seed_log.read_from(0) { + Ok(events) => { + let mut log_guard = log.lock().unwrap(); + let mut copied = 0usize; + for ev in events { + if let Event::PtyOutput { data } = ev.event { + let _ = log_guard.append(Event::PtyOutput { data }); + copied += 1; + } + } + // Visible separator. Reset attributes, yellow bar, newline. + let marker = + b"\r\n\x1b[0m\x1b[33m-- rat resurrect: previous session replayed above --\x1b[0m\r\n"; + let _ = log_guard.append(Event::PtyOutput { + data: marker.to_vec(), + }); + tracing::info!(seed = %seed_path.display(), events = copied, "seeded log from prior session"); + } + Err(e) => { + tracing::warn!(seed = %seed_path.display(), error = %e, "failed to read seed log; continuing without"); + } + }, + Err(e) => { + tracing::warn!(seed = %seed_path.display(), error = %e, "failed to open seed log; continuing without"); + } + } + } + let mut child = pty.slave.spawn_command(cmd).context("spawn shell")?; let child_pid = child.process_id(); drop(pty.slave); diff --git a/src/bin/rat.rs b/src/bin/rat.rs index 70d98af..6760aad 100644 --- a/src/bin/rat.rs +++ b/src/bin/rat.rs @@ -131,6 +131,21 @@ enum Cmd { #[arg(value_enum)] shell: clap_complete::Shell, }, + /// Resurrect a dead session: spin up a fresh daemon whose event log + /// is pre-seeded with the old session's PtyOutput, then attach. The + /// shell is new (the original PTY is long gone) but the scrollback + /// is what you left behind. + Resurrect { + /// Path to a .log file, or a session name / UUID / UUID prefix + /// (resolved via the meta file for dead sessions still on disk). + source: String, + /// Optional name for the new session. + #[arg(short, long)] + name: Option, + /// Skip the nested-session warning. + #[arg(short, long)] + force: bool, + }, } #[tokio::main] @@ -157,6 +172,11 @@ async fn main() -> Result<()> { Some(Cmd::Rename { id, new_name }) => rename(&id, &new_name), Some(Cmd::Alias { id, alias }) => add_alias(&id, &alias), Some(Cmd::Completions { shell }) => completions(shell), + Some(Cmd::Resurrect { + source, + name, + force, + }) => resurrect(&source, name, force).await, } } @@ -167,6 +187,34 @@ fn completions(shell: clap_complete::Shell) -> Result<()> { Ok(()) } +// Resurrect: seed a fresh daemon with the PtyOutput from an old log and +// attach. The original PTY process is gone, but replaying its bytes into +// the new session's scrollback reconstructs what the user was looking at +// when it died — which is usually the only thing they miss. +async fn resurrect(source: &str, name: Option, force: bool) -> Result<()> { + let seed_path = resolve_log_source(source)?; + warn_if_nested(force)?; + let session_id = spawn_daemon_with_seed(name, Vec::new(), Some(seed_path)).await?; + run_attach_cycle(session_id, true).await +} + +// Accept either a path to a .log file or a session label/id. For labels +// we look up the meta (works for dead sessions too — the meta file is +// only removed on clean shutdown via cleanup_stale). +fn resolve_log_source(source: &str) -> Result { + let path = PathBuf::from(source); + if path.is_file() { + return Ok(path); + } + let meta = read_all_metas()? + .into_iter() + .find(|m| meta_has_label(m, source) || m.id.to_string().starts_with(source)) + .ok_or_else(|| { + anyhow::anyhow!("no log file or session matching '{source}' (pass a .log path directly if the meta is gone)") + })?; + Ok(meta.log_path) +} + fn kill(query: &str, yes: bool) -> Result<()> { // Locate metadata by UUID or name. We can't rely on resolve_session alone // because it filters out dead sessions — and one use of `kill` is cleaning @@ -521,8 +569,17 @@ async fn spawn_bare(force: bool) -> Result<()> { } // Fork rat-daemon into its own session and wait until it's bound the -// Unix socket. Returns the session id on success. +// Unix socket. Returns the session id on success. `seed_log`, when set, +// is forwarded as the daemon's `--seed-log` flag (see `rat resurrect`). async fn spawn_daemon(name: Option, cmd: Vec) -> Result { + spawn_daemon_with_seed(name, cmd, None).await +} + +async fn spawn_daemon_with_seed( + name: Option, + cmd: Vec, + seed_log: Option, +) -> Result { if let Some(n) = &name { if n.is_empty() { bail!("--name must not be empty"); @@ -567,6 +624,9 @@ async fn spawn_daemon(name: Option, cmd: Vec) -> Result