Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<uuid>.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
Expand Down
39 changes: 39 additions & 0 deletions src/bin/rat-daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ struct Args {
rows: u16,
#[arg(long)]
name: Option<String>,
/// 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<PathBuf>,
/// Command to run; defaults to $SHELL.
#[arg(trailing_var_arg = true)]
cmd: Vec<String>,
Expand Down Expand Up @@ -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);
Expand Down
62 changes: 61 additions & 1 deletion src/bin/rat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Skip the nested-session warning.
#[arg(short, long)]
force: bool,
},
}

#[tokio::main]
Expand All @@ -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,
}
}

Expand All @@ -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<String>, 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<PathBuf> {
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
Expand Down Expand Up @@ -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<String>, cmd: Vec<String>) -> Result<SessionId> {
spawn_daemon_with_seed(name, cmd, None).await
}

async fn spawn_daemon_with_seed(
name: Option<String>,
cmd: Vec<String>,
seed_log: Option<PathBuf>,
) -> Result<SessionId> {
if let Some(n) = &name {
if n.is_empty() {
bail!("--name must not be empty");
Expand Down Expand Up @@ -567,6 +624,9 @@ async fn spawn_daemon(name: Option<String>, cmd: Vec<String>) -> Result<SessionI
if let Some(n) = &name {
builder.arg("--name").arg(n);
}
if let Some(seed) = &seed_log {
builder.arg("--seed-log").arg(seed);
}
if !cmd.is_empty() {
builder.arg("--");
for a in &cmd {
Expand Down