diff --git a/README.md b/README.md index 657d437..c5f777e 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,25 @@ rat attach pp # resolves to processing-pipeline Aliases share the same namespace as names; they must be unique among live sessions. They disappear when the session ends. +### `rat watch ID_OR_NAME` + +Read-only passive observer. Connects to a session, replays its log, +and then tails live output to stdout. **Nothing is sent back** — no +input forwarded, no resize sent, the interactive attacher's session +is untouched. `Ctrl-C` stops watching. + +```sh +rat watch agent # follow session 'agent' +rat watch agent | tee agent.out # also capture to a file +rat watch agent > /dev/null & # background watch for CI/monitor use +``` + +Useful for over-the-shoulder review without risking an errant keypress +into the live session, or for piping a session's live output into +another tool. The watcher doesn't enter raw mode, so output is +subject to your terminal's current width; for clean rendering match +the attached client's size. + ### `rat kill ID_OR_NAME [-y, --yes]` Terminate a session. Prompts `Are you sure? [y/N]` (default No) unless diff --git a/src/bin/rat.rs b/src/bin/rat.rs index 70d98af..72cee7f 100644 --- a/src/bin/rat.rs +++ b/src/bin/rat.rs @@ -131,6 +131,13 @@ enum Cmd { #[arg(value_enum)] shell: clap_complete::Shell, }, + /// Read-only passive observation of a live session. Replays then + /// tails the output stream. No input forwarded, no resize sent — + /// the attached interactive client is untouched. Ctrl-C to stop. + Watch { + /// Session name, alias, UUID, or UUID prefix. + id: String, + }, } #[tokio::main] @@ -157,6 +164,10 @@ 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::Watch { id }) => { + let session_id = resolve_session(&id)?; + watch(session_id).await + } } } @@ -868,6 +879,122 @@ async fn run_attach_cycle(mut id: SessionId, force: bool) -> Result<()> { } } +// Read-only attach. Connects the same way a normal client does, replays +// the log from seq 0, and streams PtyOutput to stdout. Never sends +// ClientMsg::Input, ClientMsg::Detach, or ClientMsg::Resize, so the +// interactive attacher (if any) is unaffected. Stays in cooked mode so +// Ctrl-C from the terminal line discipline cleanly ends the watch. +async fn watch(session_id: SessionId) -> Result<()> { + let sock = paths::sock_path(&session_id.to_string()); + let stream = UnixStream::connect(&sock) + .await + .with_context(|| format!("connect {}", sock.display()))?; + + let (r, w) = stream.into_split(); + let mut r = BufReader::new(r); + let mut w = w; + + protocol::write_frame(&mut w, &ClientMsg::Attach { from_seq: 0 }).await?; + + let first: DaemonMsg = protocol::read_frame(&mut r).await?; + let (sid, sname) = match first { + DaemonMsg::Attached { + session_id, name, .. + } => (session_id, name), + DaemonMsg::Error { message } => bail!("daemon error: {message}"), + _ => bail!("expected Attached first"), + }; + + print_watch_banner(&sid, sname.as_deref()); + + let stop = Arc::new(Notify::new()); + let stop_c = Arc::clone(&stop); + tokio::spawn(async move { + let _ = tokio::signal::ctrl_c().await; + stop_c.notify_one(); + }); + + let passthrough_kbd = kbd_passthrough_enabled(); + let mut filter = OutputFilter::new(passthrough_kbd); + let mut stdout = tokio::io::stdout(); + let mut ended_code: Option> = None; + + loop { + tokio::select! { + _ = stop.notified() => break, + msg = protocol::read_frame::<_, DaemonMsg>(&mut r) => { + let msg = match msg { + Ok(m) => m, + Err(_) => break, + }; + match msg { + DaemonMsg::Event { event } => match event.event { + Event::PtyOutput { data } => { + let filtered = filter.process(&data); + if !filtered.is_empty() + && stdout.write_all(&filtered).await.is_err() + { + break; + } + let _ = stdout.flush().await; + } + Event::SessionEnded { exit_code: code } => { + ended_code = Some(code); + break; + } + _ => {} + }, + DaemonMsg::SessionEnded { exit_code: code } => { + ended_code = Some(code); + break; + } + _ => {} + } + } + } + } + + if let Some(code) = ended_code { + print_session_ended(&sid, sname.as_deref(), code); + } else { + eprintln!(); + eprintln!("— watch stopped for {} —", sname.as_deref().unwrap_or(&sid)); + } + Ok(()) +} + +fn print_watch_banner(session_id: &str, name: Option<&str>) { + let short = &session_id[..session_id.len().min(8)]; + eprint!("\x1b]2;[rat watch] {short}\x07"); + eprintln!(); + eprintln!("{}", frame_top()); + eprintln!("{}", frame_blank()); + eprintln!( + "{}", + frame_row(&format!( + " {BOLD}rat{RESET} {DIM}·{RESET} watching {DIM}(read-only){RESET}" + )) + ); + eprintln!("{}", frame_blank()); + eprintln!( + "{}", + frame_row(&format!( + " session: {BOLD}{session_id}{RESET}{}", + name.map(|n| format!(" {DIM}({n}){RESET}")) + .unwrap_or_default() + )) + ); + eprintln!( + "{}", + frame_row(&format!( + " {DIM}(Ctrl-C to stop · no input forwarded to the session){RESET}" + )) + ); + eprintln!("{}", frame_blank()); + eprintln!("{}", frame_bot()); + eprintln!(); +} + fn list_text() -> Result<()> { let mut metas = read_all_metas()?; if metas.is_empty() {