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