From 48fcb90239b8c6d05ffa6b28a3d812bb3fec5195 Mon Sep 17 00:00:00 2001 From: Emeric Favarel <47535798+moukrea@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:14:41 +0200 Subject: [PATCH 1/3] fix(hook): use tee-based capture instead of PTY proxy --- src/cli/commands.rs | 175 +++----------------------------------------- 1 file changed, 12 insertions(+), 163 deletions(-) diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 1990fe4..efac12a 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -59,32 +59,14 @@ pub fn cmd_wrap(capture: &str) -> Result<()> { if slave_raw > libc::STDERR_FILENO { let _ = close(slave_raw); } - // Decrement SHLVL so the inner shell sees the expected level - // (the outer shell already incremented it; exec snag wrap preserved that) - if let Ok(val) = std::env::var("SHLVL") { - if let Ok(level) = val.parse::() { - unsafe { std::env::set_var("SHLVL", (level - 1).max(0).to_string()) }; - } - } - // Start a login shell (argv[0] = "-bash") so /etc/profile.d/*.sh is - // sourced — this restores VTE terminal integration (__vte_prompt_command, - // __vte_osc7) which provides dynamic title updates and CWD tracking. - let shell_path_cstr = match CString::new(shell.as_str()) { - Ok(c) => c, - Err(_) => { - eprintln!("error: SHELL contains invalid characters"); - unsafe { libc::_exit(1) }; - } - }; - let shell_basename = shell.rsplit('/').next().unwrap_or(&shell); - let login_argv0 = match CString::new(format!("-{shell_basename}")) { + let shell_cstr = match CString::new(shell.as_str()) { Ok(c) => c, Err(_) => { eprintln!("error: SHELL contains invalid characters"); unsafe { libc::_exit(1) }; } }; - let _ = execvp(&shell_path_cstr, std::slice::from_ref(&login_argv0)); + let _ = execvp(&shell_cstr, std::slice::from_ref(&shell_cstr)); unsafe { libc::_exit(127); } @@ -104,6 +86,12 @@ pub fn cmd_wrap(capture: &str) -> Result<()> { .open("/dev/tty") .unwrap_or_else(|_| unsafe { std::fs::File::from_raw_fd(libc::STDOUT_FILENO) }); + // Set initial terminal title to the shell name (not "snag") + let shell_name = shell.rsplit('/').next().unwrap_or(&shell); + let title_seq = format!("\x1b]0;{shell_name}\x07"); + let _ = std::io::Write::write_all(&mut tty_out, title_seq.as_bytes()); + let _ = std::io::Write::flush(&mut tty_out); + // Put outer terminal in raw mode — but use minimal raw mode // (like script does) to preserve mouse tracking and scroll behavior. // crossterm's enable_raw_mode() is too aggressive for a PTY proxy. @@ -266,12 +254,6 @@ pub fn cmd_wrap(capture: &str) -> Result<()> { break; } let data = &buf[..n as usize]; - // Track inner shell's CWD via OSC 7 sequences so that - // /proc//cwd stays current (used by terminal - // emulators for new-tab CWD and by `snag cwd`/`snag ls`). - if let Some(path) = extract_osc7_path(data) { - let _ = std::env::set_current_dir(&path); - } if !is_snagged { let _ = std::io::Write::write_all(&mut tty_out, data); let _ = std::io::Write::flush(&mut tty_out); @@ -321,74 +303,6 @@ pub fn cmd_wrap(capture: &str) -> Result<()> { } } -/// Scan a data buffer for OSC 7 sequences and extract the directory path. -/// OSC 7 format: `\x1b]7;file://HOSTNAME/PATH` where terminator -/// is BEL (`\x07`) or ST (`\x1b\\`). Returns the last path found, if any. -fn extract_osc7_path(data: &[u8]) -> Option { - const PREFIX: &[u8] = b"\x1b]7;"; - let mut last_path: Option = None; - let mut i = 0; - while i + PREFIX.len() < data.len() { - if data[i..].starts_with(PREFIX) { - let start = i + PREFIX.len(); - // Find terminator: BEL (\x07) or ST (\x1b\\) - let mut end = None; - for j in start..data.len() { - if data[j] == 0x07 { - end = Some(j); - break; - } - if data[j] == 0x1b && j + 1 < data.len() && data[j + 1] == b'\\' { - end = Some(j); - break; - } - } - if let Some(end) = end { - if let Ok(uri) = std::str::from_utf8(&data[start..end]) { - // Strip "file://hostname" prefix — path starts at the first '/' after "//" - if let Some(rest) = uri.strip_prefix("file://") { - if let Some(slash) = rest.find('/') { - last_path = Some(percent_decode(&rest[slash..])); - } - } - } - i = end + 1; - continue; - } - } - i += 1; - } - last_path -} - -/// Minimal percent-decoding for file paths (e.g. `%20` → space). -fn percent_decode(input: &str) -> String { - let bytes = input.as_bytes(); - let mut out = Vec::with_capacity(bytes.len()); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'%' && i + 2 < bytes.len() { - if let (Some(hi), Some(lo)) = (from_hex(bytes[i + 1]), from_hex(bytes[i + 2])) { - out.push(hi << 4 | lo); - i += 3; - continue; - } - } - out.push(bytes[i]); - i += 1; - } - String::from_utf8(out).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()) -} - -fn from_hex(b: u8) -> Option { - match b { - b'0'..=b'9' => Some(b - b'0'), - b'a'..=b'f' => Some(b - b'a' + 10), - b'A'..=b'F' => Some(b - b'A' + 10), - _ => None, - } -} - pub async fn cmd_new( config: &Config, shell: Option, @@ -928,12 +842,13 @@ pub async fn cmd_register(config: &Config, pid: Option, name: Option { // Print shell commands for the hook to eval. - // `exec snag wrap` replaces the shell with a PTY proxy that - // captures ALL terminal output (echo, prompt, ANSI escapes). + // Use tee-based capture (same as adopted sessions) to preserve + // the terminal's direct relationship with the shell — tab titles, + // CWD tracking, colors, and all shell integrations work normally. let escaped_path = capture_path.replace('\'', "'\\''"); println!("export SNAG_SESSION={id}"); println!("export SNAG_CAPTURE='{escaped_path}'"); - println!("exec snag wrap --capture '{escaped_path}'"); + println!("exec > >(tee -a '{escaped_path}') 2>&1"); Ok(()) } Response::Error { message, .. } => { @@ -1027,69 +942,3 @@ pub async fn cmd_daemon_status(config: &Config) -> Result<()> { } } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn osc7_bel_terminator() { - let data = b"\x1b]7;file://myhost/home/user/project\x07"; - assert_eq!( - extract_osc7_path(data), - Some("/home/user/project".to_string()) - ); - } - - #[test] - fn osc7_st_terminator() { - let data = b"\x1b]7;file://myhost/tmp\x1b\\"; - assert_eq!(extract_osc7_path(data), Some("/tmp".to_string())); - } - - #[test] - fn osc7_percent_encoded() { - let data = b"\x1b]7;file://host/home/user/my%20folder\x07"; - assert_eq!( - extract_osc7_path(data), - Some("/home/user/my folder".to_string()) - ); - } - - #[test] - fn osc7_no_match() { - let data = b"normal terminal output with no osc sequences"; - assert_eq!(extract_osc7_path(data), None); - } - - #[test] - fn osc7_embedded_in_output() { - let mut data = Vec::new(); - data.extend_from_slice(b"prompt$ \x1b[32m"); - data.extend_from_slice(b"\x1b]7;file://host/var/log\x07"); - data.extend_from_slice(b" more output"); - assert_eq!(extract_osc7_path(&data), Some("/var/log".to_string())); - } - - #[test] - fn osc7_multiple_returns_last() { - let mut data = Vec::new(); - data.extend_from_slice(b"\x1b]7;file://host/first\x07"); - data.extend_from_slice(b"\x1b]7;file://host/second\x07"); - assert_eq!(extract_osc7_path(&data), Some("/second".to_string())); - } - - #[test] - fn osc7_root_path() { - let data = b"\x1b]7;file://host/\x07"; - assert_eq!(extract_osc7_path(data), Some("/".to_string())); - } - - #[test] - fn percent_decode_basic() { - assert_eq!(percent_decode("/path/to/file"), "/path/to/file"); - assert_eq!(percent_decode("/has%20space"), "/has space"); - assert_eq!(percent_decode("%2Fencoded%2Fslash"), "/encoded/slash"); - assert_eq!(percent_decode("100%25done"), "100%done"); - } -} From 9b5de7aa0d3a6605f569f0b412f07dc68a4084c0 Mon Sep 17 00:00:00 2001 From: Emeric Favarel <47535798+moukrea@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:24:55 +0200 Subject: [PATCH 2/3] fix(hook): drop tee capture and use tcgetpgrp for fg process --- src/cli/commands.rs | 15 +++++++-------- src/daemon/pty.rs | 13 ++++++++++++- src/daemon/session.rs | 17 +---------------- 3 files changed, 20 insertions(+), 25 deletions(-) diff --git a/src/cli/commands.rs b/src/cli/commands.rs index efac12a..1aa398b 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -840,15 +840,14 @@ pub async fn cmd_register(config: &Config, pid: Option, name: Option { - // Print shell commands for the hook to eval. - // Use tee-based capture (same as adopted sessions) to preserve - // the terminal's direct relationship with the shell — tab titles, - // CWD tracking, colors, and all shell integrations work normally. - let escaped_path = capture_path.replace('\'', "'\\''"); + Response::Ok(ResponseData::SessionRegistered { id, .. }) => { + // Only export the session ID — no exec, no redirect, no child + // processes. The shell continues completely unmodified so titles, + // CWD, colors, isatty(), and TUI apps all work normally. + // Output capture is not available for hooked sessions (snag output + // won't have scrollback), but ls/cwd/ps/attach/send all work via + // /proc and the stolen master fd. println!("export SNAG_SESSION={id}"); - println!("export SNAG_CAPTURE='{escaped_path}'"); - println!("exec > >(tee -a '{escaped_path}') 2>&1"); Ok(()) } Response::Error { message, .. } => { diff --git a/src/daemon/pty.rs b/src/daemon/pty.rs index ccc30c0..55d4be4 100644 --- a/src/daemon/pty.rs +++ b/src/daemon/pty.rs @@ -4,7 +4,7 @@ use nix::pty::openpty; use nix::sys::wait::WaitStatus; use nix::unistd::{close, dup2, fork, setsid, ForkResult, Pid}; use std::ffi::CString; -use std::os::fd::{AsRawFd, OwnedFd, RawFd}; +use std::os::fd::{AsFd, AsRawFd, OwnedFd, RawFd}; use std::path::{Path, PathBuf}; pub struct SpawnResult { @@ -184,3 +184,14 @@ pub fn fg_process(pts_path: &Path) -> Vec<(u32, String)> { results } + +/// Get the foreground process name for a PTY via tcgetpgrp. +/// Returns "idle" if the shell is in the foreground, the command name +/// if another process is running, or None on error. +pub fn fg_process_name(master_fd: &impl AsFd, shell_pid: Option) -> Option { + let pgid = nix::unistd::tcgetpgrp(master_fd).ok()?; + if shell_pid.is_some_and(|sp| sp == pgid) { + return Some("idle".to_string()); + } + read_comm(pgid.as_raw() as u32).or(Some("idle".to_string())) +} diff --git a/src/daemon/session.rs b/src/daemon/session.rs index dd9c024..253088b 100644 --- a/src/daemon/session.rs +++ b/src/daemon/session.rs @@ -100,22 +100,7 @@ impl Session { .and_then(|pid| pty::read_cwd(pid.as_raw() as u32)) .unwrap_or_else(|| "?".to_string()); - let fg = pty::fg_process(&self.pts_path); - let fg_process = fg - .iter() - .find(|(pid, _)| { - self.child_pid - .map(|cp| *pid != cp.as_raw() as u32) - .unwrap_or(true) - }) - .map(|(_, cmd)| cmd.clone()) - .or_else(|| { - if fg.is_empty() { - None - } else { - Some("idle".to_string()) - } - }); + let fg_process = pty::fg_process_name(&self.master_fd, self.child_pid); SessionInfo { id: self.id.clone(), From 11f90c3db29d179b9c55ce25ec3dee1fc104267b Mon Sep 17 00:00:00 2001 From: Emeric Favarel <47535798+moukrea@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:26:44 +0200 Subject: [PATCH 3/3] chore: trigger CI