From 7751dcadc0a1c01cb0c2f1d68f23ad1dc86a086c Mon Sep 17 00:00:00 2001 From: Emeric Favarel <47535798+moukrea@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:14:33 +0100 Subject: [PATCH 01/10] fix(snag): set TERM and COLORTERM env vars in spawned PTY sessions The daemon detaches from the terminal on startup, so spawned shells inherit an environment without proper TERM. Explicitly set TERM=xterm-256color and COLORTERM=truecolor before exec to ensure colors and terminal features work correctly. --- src/daemon/pty.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/daemon/pty.rs b/src/daemon/pty.rs index 6007c7b..ccc30c0 100644 --- a/src/daemon/pty.rs +++ b/src/daemon/pty.rs @@ -47,6 +47,11 @@ pub fn spawn_shell(shell: &str, cwd: &Path) -> Result { let _ = std::env::set_current_dir("/"); } + // Ensure terminal environment is set for proper color and feature support. + // The daemon may have been started from a non-terminal context. + std::env::set_var("TERM", "xterm-256color"); + std::env::set_var("COLORTERM", "truecolor"); + // Exec shell let shell_cstr = CString::new(shell).unwrap_or_else(|_| CString::new("/bin/sh").unwrap()); From 540833266661fb3f6138ef72af2c39bc5c9e4c4d Mon Sep 17 00:00:00 2001 From: Emeric Favarel <47535798+moukrea@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:16:33 +0100 Subject: [PATCH 02/10] fix(snag): prevent session from attaching to itself Detect self-attach by comparing the client's controlling terminal (via peer PID from Unix socket credentials and /proc//fd/0) with the target session's PTY path. Returns a clear error message when a session tries to attach to itself. --- src/daemon/server.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 31f5bdc..1038225 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -32,6 +32,7 @@ struct AttachedClient { tx: mpsc::Sender>, read_only: bool, session_id: Option, + peer_pid: Option, } pub async fn run_daemon(config: Config) -> Result<()> { @@ -101,11 +102,13 @@ pub async fn run_daemon(config: Config) -> Result<()> { match event { DaemonEvent::NewConnection(stream) => { let client_id = NEXT_CLIENT_ID.fetch_add(1, Ordering::Relaxed); + let peer_pid = stream.peer_cred().ok().and_then(|c| c.pid().map(|p| p as u32)); let (tx, rx) = mpsc::channel(64); clients.insert(client_id, AttachedClient { tx, read_only: false, session_id: None, + peer_pid, }); let event_tx = event_tx.clone(); tokio::spawn(handle_client_connection(client_id, stream, rx, event_tx)); @@ -539,6 +542,18 @@ fn handle_session_attach( match registry.resolve(target) { Ok(id) => { if let Some(session) = registry.get_mut(&id) { + // Prevent self-attach: check if the client's terminal IS this session's PTY + if let Some(peer_pid) = clients.get(&client_id).and_then(|c| c.peer_pid) { + if let Ok(client_tty) = std::fs::read_link(format!("/proc/{peer_pid}/fd/0")) { + if client_tty == session.pts_path { + return Response::Error { + code: 5, + message: "cannot attach a session to itself".to_string(), + }; + } + } + } + session.attached_clients.push(client_id); if let Some(client) = clients.get_mut(&client_id) { From 8965d5d42ccd2034e4ef378964e956f584970bce Mon Sep 17 00:00:00 2001 From: Emeric Favarel <47535798+moukrea@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:22:57 +0100 Subject: [PATCH 03/10] feat(snag): detect alternate screen buffer in TUI preview Track ESC[?1049h/l and ESC[?47h/l transitions in PTY output to know when a session is running a full-screen TUI app. When in alternate screen mode, the preview shows '[TUI running: ]' instead of garbled output. Normal preview resumes when the app exits. --- src/daemon/registry.rs | 1 + src/daemon/server.rs | 50 ++++++++++++++++++++++++++++++++++++++++++ src/daemon/session.rs | 3 +++ 3 files changed, 54 insertions(+) diff --git a/src/daemon/registry.rs b/src/daemon/registry.rs index fc347c0..5507ea5 100644 --- a/src/daemon/registry.rs +++ b/src/daemon/registry.rs @@ -168,6 +168,7 @@ mod tests { registered: false, capture_path: None, capture_abort: None, + in_alternate_screen: false, } } diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 1038225..c787315 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -153,6 +153,7 @@ pub async fn run_daemon(config: Config) -> Result<()> { } DaemonEvent::PtyData(session_id, data) => { if let Some(session) = registry.get_mut(&session_id) { + update_alternate_screen_state(session, &data); session.scrollback.write(&data); let attached: Vec = session.attached_clients.clone(); let output_frame = encode_response(&Response::PtyOutput(data.clone())).unwrap_or_default(); @@ -657,6 +658,24 @@ fn handle_session_output( }; } + // When in alternate screen (vim, htop, etc.), show a placeholder + if session.in_alternate_screen && !follow { + let fg = pty::fg_process(&session.pts_path); + let app_name = fg + .iter() + .find(|(pid, _)| { + session + .child_pid + .map(|cp| *pid != cp.as_raw() as u32) + .unwrap_or(true) + }) + .map(|(_, cmd)| cmd.as_str()) + .unwrap_or("application"); + return Response::Ok(ResponseData::Output(format!( + "[TUI running: {app_name}]" + ))); + } + let output = if let Some(n) = lines { session.scrollback.last_n_lines(n as usize) } else { @@ -1083,3 +1102,34 @@ fn handle_daemon_status(registry: &SessionRegistry, start_time: Instant) -> Resp session_count: registry.len(), }) } + +/// Scan PTY output for alternate screen buffer transitions. +/// Tracks ESC[?1049h/l (xterm) and ESC[?47h/l (older terminals). +fn update_alternate_screen_state(session: &mut Session, data: &[u8]) { + // Look for \x1b[?1049h, \x1b[?1049l, \x1b[?47h, \x1b[?47l + let len = data.len(); + let mut i = 0; + while i < len { + if data[i] == 0x1b && i + 2 < len && data[i + 1] == b'[' && data[i + 2] == b'?' { + // Parse the numeric parameter + let start = i + 3; + let mut j = start; + while j < len && data[j].is_ascii_digit() { + j += 1; + } + if j < len && j > start { + let param = &data[start..j]; + if param == b"1049" || param == b"47" { + match data[j] { + b'h' => session.in_alternate_screen = true, + b'l' => session.in_alternate_screen = false, + _ => {} + } + i = j + 1; + continue; + } + } + } + i += 1; + } +} diff --git a/src/daemon/session.rs b/src/daemon/session.rs index db95c1f..de5a92e 100644 --- a/src/daemon/session.rs +++ b/src/daemon/session.rs @@ -32,6 +32,7 @@ pub struct Session { pub registered: bool, pub capture_path: Option, pub capture_abort: Option, + pub in_alternate_screen: bool, } impl Session { @@ -59,6 +60,7 @@ impl Session { registered: false, capture_path: None, capture_abort: None, + in_alternate_screen: false, } } @@ -88,6 +90,7 @@ impl Session { registered: true, capture_path, capture_abort: None, + in_alternate_screen: false, } } From 74dcb74fd86240a8c156a3880526d38538958e12 Mon Sep 17 00:00:00 2001 From: Emeric Favarel <47535798+moukrea@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:25:09 +0100 Subject: [PATCH 04/10] fix(snag): re-apply terminal size on client detach with remaining viewers Track each client's last known terminal dimensions. When a client detaches and other clients remain attached, apply a remaining client's terminal size to the session PTY so it matches the active viewer. --- src/daemon/server.rs | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/daemon/server.rs b/src/daemon/server.rs index c787315..a958309 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -33,6 +33,7 @@ struct AttachedClient { read_only: bool, session_id: Option, peer_pid: Option, + last_size: Option<(u16, u16)>, // (cols, rows) } pub async fn run_daemon(config: Config) -> Result<()> { @@ -109,6 +110,7 @@ pub async fn run_daemon(config: Config) -> Result<()> { read_only: false, session_id: None, peer_pid, + last_size: None, }); let event_tx = event_tx.clone(); tokio::spawn(handle_client_connection(client_id, stream, rx, event_tx)); @@ -596,10 +598,26 @@ fn handle_session_detach( if let Some(ref session_id) = client.session_id.take() { if let Some(session) = registry.get_mut(session_id) { session.attached_clients.retain(|&id| id != client_id); - // If no more attached clients, signal snag wrap to resume - if session.registered && session.attached_clients.is_empty() { - if let Some(pid) = session.child_pid { - let _ = nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGUSR2); + if session.attached_clients.is_empty() { + // No more clients — signal snag wrap to resume and re-apply terminal size + if session.registered { + if let Some(pid) = session.child_pid { + let _ = + nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGUSR2); + } + } + } else { + // Remaining clients exist — apply a remaining client's terminal size + // so the session PTY matches the active viewer's dimensions + let remaining_size = session + .attached_clients + .iter() + .filter_map(|cid| { + clients.get(cid).and_then(|c| c.last_size) + }) + .next(); + if let Some((cols, rows)) = remaining_size { + let _ = pty::set_winsize(session.raw_fd(), rows, cols); } } } @@ -1051,11 +1069,14 @@ fn capture_dir() -> PathBuf { fn handle_resize( registry: &mut SessionRegistry, - clients: &HashMap, + clients: &mut HashMap, client_id: ClientId, cols: u16, rows: u16, ) -> Response { + if let Some(client) = clients.get_mut(&client_id) { + client.last_size = Some((cols, rows)); + } let session_id = clients.get(&client_id).and_then(|c| c.session_id.clone()); if let Some(id) = session_id { From fa202bfb16b566fc52b7610181bd083cdc8e7c32 Mon Sep 17 00:00:00 2001 From: Emeric Favarel <47535798+moukrea@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:26:34 +0100 Subject: [PATCH 05/10] feat(snag): show kill notification in attached terminals When a session is killed, attached terminals now display '[Session killed by snag]' before disconnecting. For registered sessions, the original terminal also receives a notification. --- src/cli/commands.rs | 5 +++++ src/daemon/server.rs | 12 ++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/cli/commands.rs b/src/cli/commands.rs index d975eeb..caa512c 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -471,6 +471,11 @@ pub async fn cmd_attach(config: &Config, target: String, read_only: bool) -> Res let _ = std::io::Write::write_all(&mut tty_out, &payload); let _ = std::io::Write::flush(&mut tty_out); } else if msg_type == MSG_SESSION_EVENT { + let _ = std::io::Write::write_all( + &mut tty_out, + b"\r\n\x1b[33m[Session killed by snag]\x1b[0m\r\n", + ); + let _ = std::io::Write::flush(&mut tty_out); break Ok(()); } // MSG_OK/MSG_ERROR from control messages — ignore diff --git a/src/daemon/server.rs b/src/daemon/server.rs index a958309..d55bf1a 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -477,12 +477,12 @@ fn handle_session_kill(registry: &mut SessionRegistry, target: &str) -> Response } if let Some(session) = registry.remove(&id) { - // For registered sessions, just drop the fd — don't kill the shell. - // The shell continues running in the original terminal. - if !session.registered { - if let Some(pid) = session.child_pid { - pty::kill_session(pid); - } + if session.registered { + // Write notification to the wrapped session's terminal + let msg = b"\r\n\x1b[33m[Session unregistered by snag]\x1b[0m\r\n"; + let _ = nix::unistd::write(&session.master_fd, msg); + } else if let Some(pid) = session.child_pid { + pty::kill_session(pid); } Response::Ok(ResponseData::Empty) } else { From c4e1cce5a325a698717fcece413f83ddc67c4b41 Mon Sep 17 00:00:00 2001 From: Emeric Favarel <47535798+moukrea@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:28:45 +0100 Subject: [PATCH 06/10] feat(snag): prevent simultaneous multi-attach with force-steal option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-read-only attach now fails if another active client is already attached. Use --force to steal the session — the evicted client receives '[Session stolen by another client]'. Read-only attaches can still coexist with one active attach. --- src/cli/commands.rs | 18 ++++++++++++----- src/cli/mod.rs | 3 +++ src/daemon/server.rs | 46 ++++++++++++++++++++++++++++++++++++++++--- src/main.rs | 8 +++++--- src/protocol/codec.rs | 8 +++++++- src/protocol/types.rs | 2 ++ src/tui/mod.rs | 2 +- 7 files changed, 74 insertions(+), 13 deletions(-) diff --git a/src/cli/commands.rs b/src/cli/commands.rs index caa512c..5a1efba 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -403,7 +403,7 @@ pub async fn cmd_info(config: &Config, target: String, json: bool) -> Result<()> } } -pub async fn cmd_attach(config: &Config, target: String, read_only: bool) -> Result<()> { +pub async fn cmd_attach(config: &Config, target: String, read_only: bool, force: bool) -> Result<()> { use crossterm::event::{Event, EventStream, KeyCode, KeyModifiers}; use crossterm::terminal; use futures_lite::StreamExt; @@ -415,6 +415,7 @@ pub async fn cmd_attach(config: &Config, target: String, read_only: bool) -> Res .request(&Request::SessionAttach { target: target.clone(), read_only, + force, }) .await?; @@ -471,10 +472,17 @@ pub async fn cmd_attach(config: &Config, target: String, read_only: bool) -> Res let _ = std::io::Write::write_all(&mut tty_out, &payload); let _ = std::io::Write::flush(&mut tty_out); } else if msg_type == MSG_SESSION_EVENT { - let _ = std::io::Write::write_all( - &mut tty_out, - b"\r\n\x1b[33m[Session killed by snag]\x1b[0m\r\n", - ); + let msg = if let Ok(resp) = decode_response(msg_type, &payload) { + match resp { + Response::SessionEvent { event, .. } if event == "stolen" => { + "\r\n\x1b[33m[Session stolen by another client]\x1b[0m\r\n" + } + _ => "\r\n\x1b[33m[Session killed by snag]\x1b[0m\r\n", + } + } else { + "\r\n\x1b[33m[Session ended]\x1b[0m\r\n" + }; + let _ = std::io::Write::write_all(&mut tty_out, msg.as_bytes()); let _ = std::io::Write::flush(&mut tty_out); break Ok(()); } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 1889de3..0aab09a 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -66,6 +66,9 @@ pub enum Command { /// Read-only mode #[arg(long)] read_only: bool, + /// Force-steal from another attached client + #[arg(long)] + force: bool, }, /// Send a command to a session Send { diff --git a/src/daemon/server.rs b/src/daemon/server.rs index d55bf1a..553b1b7 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -317,9 +317,11 @@ async fn handle_request( } Request::SessionList => handle_session_list(registry), Request::SessionInfo { target } => handle_session_info(registry, &target), - Request::SessionAttach { target, read_only } => { - handle_session_attach(registry, clients, client_id, &target, read_only, event_tx) - } + Request::SessionAttach { + target, + read_only, + force, + } => handle_session_attach(registry, clients, client_id, &target, read_only, force, event_tx), Request::SessionDetach => handle_session_detach(registry, clients, client_id), Request::SessionSend { target, input } => handle_session_send(registry, &target, &input), Request::SessionOutput { @@ -540,6 +542,7 @@ fn handle_session_attach( client_id: ClientId, target: &str, read_only: bool, + force: bool, _event_tx: &mpsc::Sender, ) -> Response { match registry.resolve(target) { @@ -557,6 +560,43 @@ fn handle_session_attach( } } + // Check for existing non-read-only attached client + if !read_only { + let active_client = session + .attached_clients + .iter() + .find(|&&cid| { + clients + .get(&cid) + .map(|c| !c.read_only) + .unwrap_or(false) + }) + .copied(); + + if let Some(existing_cid) = active_client { + if !force { + return Response::Error { + code: 10, + message: "session already attached by another client (use --force to steal)".to_string(), + }; + } + // Force-steal: notify and detach the existing client + if let Some(evicted) = clients.get(&existing_cid) { + let stolen_msg = Response::SessionEvent { + event: "stolen".to_string(), + session_id: id.clone(), + }; + if let Ok(frame) = encode_response(&stolen_msg) { + let _ = evicted.tx.try_send(frame); + } + } + session.attached_clients.retain(|&cid| cid != existing_cid); + if let Some(evicted) = clients.get_mut(&existing_cid) { + evicted.session_id = None; + } + } + } + session.attached_clients.push(client_id); if let Some(client) = clients.get_mut(&client_id) { diff --git a/src/main.rs b/src/main.rs index fd7ab5e..6be2d6d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,9 +36,11 @@ async fn main() { Some(Command::Info { target, json }) => { cli::commands::cmd_info(&config, target, json).await } - Some(Command::Attach { target, read_only }) => { - cli::commands::cmd_attach(&config, target, read_only).await - } + Some(Command::Attach { + target, + read_only, + force, + }) => cli::commands::cmd_attach(&config, target, read_only, force).await, Some(Command::Send { target, command }) => { cli::commands::cmd_send(&config, target, command).await } diff --git a/src/protocol/codec.rs b/src/protocol/codec.rs index 760aebe..f24c41c 100644 --- a/src/protocol/codec.rs +++ b/src/protocol/codec.rs @@ -164,14 +164,20 @@ mod tests { let req = Request::SessionAttach { target: "abc123".to_string(), read_only: true, + force: false, }; let frame = encode_request(&req).unwrap(); assert_eq!(frame[0], MSG_SESSION_ATTACH); let decoded = decode_request(frame[0], &frame[5..]).unwrap(); match decoded { - Request::SessionAttach { target, read_only } => { + Request::SessionAttach { + target, + read_only, + force, + } => { assert_eq!(target, "abc123"); assert!(read_only); + assert!(!force); } _ => panic!("wrong request type"), } diff --git a/src/protocol/types.rs b/src/protocol/types.rs index fd2fb3a..87d1b14 100644 --- a/src/protocol/types.rs +++ b/src/protocol/types.rs @@ -45,6 +45,8 @@ pub enum Request { SessionAttach { target: String, read_only: bool, + #[serde(default)] + force: bool, }, SessionDetach, SessionSend { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 9d5420b..eb68562 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -95,7 +95,7 @@ async fn handle_normal_key( if let Some(id) = app.selected_id() { // Exit TUI, attach to session cleanup_terminal(terminal)?; - crate::cli::commands::cmd_attach(config, id, false).await?; + crate::cli::commands::cmd_attach(config, id, false, false).await?; // Re-enter TUI after detach terminal::enable_raw_mode()?; crossterm::execute!(io::stdout(), crossterm::terminal::EnterAlternateScreen)?; From c2c69a4fff18ea82bf0632703f531c25b9d02bc5 Mon Sep 17 00:00:00 2001 From: Emeric Favarel <47535798+moukrea@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:30:37 +0100 Subject: [PATCH 07/10] feat(snag): detect and prevent attach loops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walk the attach chain from target session through attached clients' origin sessions to detect cycles. Shows the full chain in the error message (e.g., 'attach would create a loop: A → B → C → A'). Subsumes the previous self-attach check as a special case. --- src/daemon/registry.rs | 8 +++++ src/daemon/server.rs | 81 +++++++++++++++++++++++++++++++++++------- 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/src/daemon/registry.rs b/src/daemon/registry.rs index 5507ea5..9c7d5ad 100644 --- a/src/daemon/registry.rs +++ b/src/daemon/registry.rs @@ -140,6 +140,14 @@ impl SessionRegistry { pub fn session_ids(&self) -> Vec { self.sessions.keys().cloned().collect() } + + /// Find a session whose pts_path matches the given path. + pub fn find_by_pts(&self, pts_path: &std::path::Path) -> Option<&str> { + self.sessions + .iter() + .find(|(_, s)| s.pts_path == pts_path) + .map(|(id, _)| id.as_str()) + } } #[cfg(test)] diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 553b1b7..da49960 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -547,19 +547,15 @@ fn handle_session_attach( ) -> Response { match registry.resolve(target) { Ok(id) => { - if let Some(session) = registry.get_mut(&id) { - // Prevent self-attach: check if the client's terminal IS this session's PTY - if let Some(peer_pid) = clients.get(&client_id).and_then(|c| c.peer_pid) { - if let Ok(client_tty) = std::fs::read_link(format!("/proc/{peer_pid}/fd/0")) { - if client_tty == session.pts_path { - return Response::Error { - code: 5, - message: "cannot attach a session to itself".to_string(), - }; - } - } - } + // Detect attach loops (including self-attach) before taking mutable borrow + if let Some(cycle) = detect_attach_cycle(&id, client_id, registry, clients) { + return Response::Error { + code: 5, + message: format!("attach would create a loop: {cycle}"), + }; + } + if let Some(session) = registry.get_mut(&id) { // Check for existing non-read-only attached client if !read_only { let active_client = session @@ -1164,6 +1160,67 @@ fn handle_daemon_status(registry: &SessionRegistry, start_time: Instant) -> Resp }) } +/// Detect if attaching client_id to target_session_id would create a cycle. +/// Returns Some(chain_description) if a cycle is found, None otherwise. +/// +/// A cycle occurs when the target session's PTY is the terminal of a client +/// that is attached to a session whose PTY is the terminal of another client... +/// eventually reaching back to the session that the requesting client is in. +fn detect_attach_cycle( + target_session_id: &str, + client_id: ClientId, + registry: &SessionRegistry, + clients: &HashMap, +) -> Option { + // Find which session the requesting client is running in + let client_tty = clients + .get(&client_id) + .and_then(|c| c.peer_pid) + .and_then(|pid| std::fs::read_link(format!("/proc/{pid}/fd/0")).ok())?; + + let source_session = registry.find_by_pts(&client_tty)?; + + // Walk the attach chain from target_session_id + let mut visited = vec![source_session.to_string()]; + let mut current = target_session_id.to_string(); + + loop { + if current == source_session { + // Cycle detected + visited.push(current); + return Some(visited.join(" → ")); + } + + if visited.contains(¤t) { + break; // Already visited, no cycle back to source + } + visited.push(current.clone()); + + // Find non-read-only clients attached to this session, and which session they're in + let session = registry.get(¤t)?; + let next = session + .attached_clients + .iter() + .filter_map(|&cid| { + let c = clients.get(&cid)?; + if c.read_only { + return None; + } + let pid = c.peer_pid?; + let tty = std::fs::read_link(format!("/proc/{pid}/fd/0")).ok()?; + registry.find_by_pts(&tty).map(|s| s.to_string()) + }) + .next(); + + match next { + Some(next_id) => current = next_id, + None => break, // Chain ends, no cycle + } + } + + None +} + /// Scan PTY output for alternate screen buffer transitions. /// Tracks ESC[?1049h/l (xterm) and ESC[?47h/l (older terminals). fn update_alternate_screen_state(session: &mut Session, data: &[u8]) { From 228d6f66f5fe30bec38ab876d11c12079c927398 Mon Sep 17 00:00:00 2001 From: Emeric Favarel <47535798+moukrea@users.noreply.github.com> Date: Sun, 29 Mar 2026 01:00:59 +0100 Subject: [PATCH 08/10] feat(snag): show snagged-by relationships in TUI with hide toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add snagged_by field to SessionInfo populated by resolving attached clients' origin sessions via peer PID. TUI shows snagged sessions in magenta with '← ' suffix. Press 'h' to toggle hiding snagged sessions from the list. --- src/daemon/server.rs | 38 +++++++++++++++++++++++++++++++++++--- src/daemon/session.rs | 1 + src/protocol/codec.rs | 1 + src/protocol/types.rs | 2 ++ src/tui/app.rs | 34 ++++++++++++++++++++++++++-------- src/tui/mod.rs | 3 +++ src/tui/ui.rs | 20 +++++++++++++++----- 7 files changed, 83 insertions(+), 16 deletions(-) diff --git a/src/daemon/server.rs b/src/daemon/server.rs index da49960..cdbe1a4 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -315,7 +315,7 @@ async fn handle_request( Request::SessionRename { target, new_name } => { handle_session_rename(registry, &target, new_name) } - Request::SessionList => handle_session_list(registry), + Request::SessionList => handle_session_list(registry, clients), Request::SessionInfo { target } => handle_session_info(registry, &target), Request::SessionAttach { target, @@ -521,8 +521,40 @@ fn handle_session_rename( } } -fn handle_session_list(registry: &SessionRegistry) -> Response { - let sessions: Vec<_> = registry.iter().map(|s| s.to_info()).collect(); +fn handle_session_list( + registry: &SessionRegistry, + clients: &HashMap, +) -> Response { + let sessions: Vec<_> = registry + .iter() + .map(|s| { + let mut info = s.to_info(); + // Find which session the active attacher is coming from + if !s.attached_clients.is_empty() { + let snagged_by = s + .attached_clients + .iter() + .filter_map(|&cid| { + let client = clients.get(&cid)?; + if client.read_only { + return None; + } + let pid = client.peer_pid?; + let tty = std::fs::read_link(format!("/proc/{pid}/fd/0")).ok()?; + let src_id = registry.find_by_pts(&tty)?; + let src = registry.get(src_id)?; + Some( + src.name + .clone() + .unwrap_or_else(|| src.id[..8.min(src.id.len())].to_string()), + ) + }) + .next(); + info.snagged_by = snagged_by; + } + info + }) + .collect(); Response::Ok(ResponseData::SessionList(sessions)) } diff --git a/src/daemon/session.rs b/src/daemon/session.rs index de5a92e..dd9c024 100644 --- a/src/daemon/session.rs +++ b/src/daemon/session.rs @@ -130,6 +130,7 @@ impl Session { attached: self.attached_clients.len(), registered: self.registered, created_at: self.created_at_utc.clone(), + snagged_by: None, } } diff --git a/src/protocol/codec.rs b/src/protocol/codec.rs index f24c41c..bcc8091 100644 --- a/src/protocol/codec.rs +++ b/src/protocol/codec.rs @@ -382,6 +382,7 @@ mod tests { attached: 1, registered: false, created_at: "2026-03-22T10:00:00Z".to_string(), + snagged_by: None, }])); let frame = encode_response(&resp).unwrap(); let decoded = decode_response(frame[0], &frame[5..]).unwrap(); diff --git a/src/protocol/types.rs b/src/protocol/types.rs index 87d1b14..0f9d0fe 100644 --- a/src/protocol/types.rs +++ b/src/protocol/types.rs @@ -133,6 +133,8 @@ pub struct SessionInfo { pub attached: usize, pub registered: bool, pub created_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snagged_by: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/tui/app.rs b/src/tui/app.rs index 7442f6c..796c428 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -14,6 +14,7 @@ pub struct App { pub preview_raw: String, pub input_mode: InputMode, pub input_buffer: String, + pub hide_snagged: bool, } impl App { @@ -25,32 +26,49 @@ impl App { preview_raw: String::new(), input_mode: InputMode::Normal, input_buffer: String::new(), + hide_snagged: false, } } + /// Returns the filtered list of sessions based on hide_snagged toggle. + pub fn visible_sessions(&self) -> Vec<&SessionInfo> { + self.sessions + .iter() + .filter(|s| !self.hide_snagged || s.snagged_by.is_none()) + .collect() + } + pub fn select_next(&mut self) { - if !self.sessions.is_empty() { - self.selected = (self.selected + 1) % self.sessions.len(); + let count = self.visible_sessions().len(); + if count > 0 { + self.selected = (self.selected + 1) % count; } } pub fn select_prev(&mut self) { - if !self.sessions.is_empty() { - self.selected = self - .selected - .checked_sub(1) - .unwrap_or(self.sessions.len() - 1); + let count = self.visible_sessions().len(); + if count > 0 { + self.selected = self.selected.checked_sub(1).unwrap_or(count - 1); } } pub fn selected_session(&self) -> Option<&SessionInfo> { - self.sessions.get(self.selected) + let visible = self.visible_sessions(); + visible.get(self.selected).copied() } pub fn selected_id(&self) -> Option { self.selected_session().map(|s| s.id.clone()) } + pub fn toggle_hide_snagged(&mut self) { + self.hide_snagged = !self.hide_snagged; + let count = self.visible_sessions().len(); + if self.selected >= count && count > 0 { + self.selected = count - 1; + } + } + pub fn enter_input_mode(&mut self, mode: InputMode) { self.input_mode = mode; self.input_buffer.clear(); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index eb68562..3e4a0e4 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -135,6 +135,9 @@ async fn handle_normal_key( app.enter_input_mode(InputMode::Send); } } + KeyCode::Char('h') => { + app.toggle_hide_snagged(); + } _ => {} } Ok(()) diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 7116c78..a162311 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -16,9 +16,9 @@ pub fn draw(frame: &mut Frame, app: &App) { ]) .split(frame.area()); - // Session list - let items: Vec = app - .sessions + // Session list (filtered by hide_snagged toggle) + let visible = app.visible_sessions(); + let items: Vec = visible .iter() .enumerate() .map(|(i, s)| { @@ -29,11 +29,18 @@ pub fn draw(frame: &mut Frame, app: &App) { let marker = if i == app.selected { "▸ " } else { " " }; let type_marker = if s.registered { " [R]" } else { "" }; + let snagged = s + .snagged_by + .as_deref() + .map(|by| format!(" ← {by}")) + .unwrap_or_default(); let style = if i == app.selected { Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD) + } else if s.snagged_by.is_some() { + Style::default().fg(Color::Magenta) } else if s.state != "running" { Style::default().fg(Color::DarkGray) } else { @@ -42,7 +49,7 @@ pub fn draw(frame: &mut Frame, app: &App) { let id_short = &s.id[..8.min(s.id.len())]; ListItem::new(Line::from(vec![Span::styled( - format!("{marker}{id_short} {name:<10} {shell:<6} {cwd:<25} {fg}{type_marker}"), + format!("{marker}{id_short} {name:<10} {shell:<6} {cwd:<25} {fg}{type_marker}{snagged}"), style, )])) }) @@ -110,7 +117,10 @@ pub fn draw(frame: &mut Frame, app: &App) { // Status bar let status_text = match app.input_mode { - InputMode::Normal => " [n]ew [x]kill [r]ename [s]end [Enter]attach [q]uit".to_string(), + InputMode::Normal => { + let hide_label = if app.hide_snagged { "[h]show snagged" } else { "[h]hide snagged" }; + format!(" [n]ew [x]kill [r]ename [s]end [Enter]attach {hide_label} [q]uit") + } InputMode::Rename => { format!(" Rename: {} [Enter]confirm [Esc]cancel", app.input_buffer) } From 4333435f3beca2d10878ee37dad12d173b5c2261 Mon Sep 17 00:00:00 2001 From: Emeric Favarel <47535798+moukrea@users.noreply.github.com> Date: Sun, 29 Mar 2026 10:57:25 +0200 Subject: [PATCH 09/10] fix(snag): harden error handling and implement PTS fallback - Implement get_pts_number_for_fd() fallback: scan sibling fds for /dev/pts/ symlinks when fdinfo lacks tty-index - Validate SHELL env var before CString conversion to prevent panic on null bytes - Use fallback path for socket_path.parent() instead of unwrap --- src/cli/commands.rs | 8 +++++++- src/client.rs | 5 ++++- src/daemon/adopt.rs | 20 ++++++++++++++++++-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 5a1efba..bf503e4 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -59,7 +59,13 @@ pub fn cmd_wrap(capture: &str) -> Result<()> { if slave_raw > libc::STDERR_FILENO { let _ = close(slave_raw); } - let shell_cstr = CString::new(shell.as_str()).unwrap(); + 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_cstr, std::slice::from_ref(&shell_cstr)); unsafe { libc::_exit(127); diff --git a/src/client.rs b/src/client.rs index 0905610..e1cb796 100644 --- a/src/client.rs +++ b/src/client.rs @@ -90,7 +90,10 @@ fn start_daemon(config: &Config) -> Result<()> { let _ = nix::unistd::dup2(devnull.as_raw_fd(), nix::libc::STDIN_FILENO); let _ = nix::unistd::dup2(devnull.as_raw_fd(), nix::libc::STDOUT_FILENO); // Keep stderr for daemon logging - let log_path = socket_path.parent().unwrap().join("snagd.log"); + let log_path = socket_path + .parent() + .unwrap_or(std::path::Path::new("/tmp")) + .join("snagd.log"); if let Ok(log) = std::fs::File::create(&log_path) { let _ = nix::unistd::dup2(log.as_raw_fd(), nix::libc::STDERR_FILENO); } diff --git a/src/daemon/adopt.rs b/src/daemon/adopt.rs index ddde966..a819220 100644 --- a/src/daemon/adopt.rs +++ b/src/daemon/adopt.rs @@ -97,8 +97,24 @@ fn get_pts_number_for_fd(pid: u32, fd: i32) -> Option { } } - // Fallback: try to figure out from /proc//fd/ -> /dev/pts/ mapping - // by checking all processes for matching tty + // Fallback: try to resolve via /proc//fd/ -> /dev/pts/ symlink. + // Some PTY masters (e.g., opened via /dev/pts/ptmx) have a sibling slave fd + // pointing to /dev/pts/. Scan adjacent fds for one linked to /dev/pts/. + let fd_dir = format!("/proc/{pid}/fd"); + if let Ok(entries) = std::fs::read_dir(&fd_dir) { + for entry in entries.flatten() { + if let Ok(target) = std::fs::read_link(entry.path()) { + let s = target.to_string_lossy(); + if let Some(rest) = s.strip_prefix("/dev/pts/") { + if rest != "ptmx" { + if let Ok(n) = rest.parse::() { + return Some(n); + } + } + } + } + } + } None } From b309b9803467ceeb71de2696a94800474d91929a Mon Sep 17 00:00:00 2001 From: Emeric Favarel <47535798+moukrea@users.noreply.github.com> Date: Sun, 29 Mar 2026 14:04:12 +0200 Subject: [PATCH 10/10] style(snag): cargo fmt --- src/cli/commands.rs | 7 ++++++- src/daemon/server.rs | 18 ++++++------------ src/tui/ui.rs | 6 +++++- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/cli/commands.rs b/src/cli/commands.rs index bf503e4..5a0b0da 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -409,7 +409,12 @@ pub async fn cmd_info(config: &Config, target: String, json: bool) -> Result<()> } } -pub async fn cmd_attach(config: &Config, target: String, read_only: bool, force: bool) -> Result<()> { +pub async fn cmd_attach( + config: &Config, + target: String, + read_only: bool, + force: bool, +) -> Result<()> { use crossterm::event::{Event, EventStream, KeyCode, KeyModifiers}; use crossterm::terminal; use futures_lite::StreamExt; diff --git a/src/daemon/server.rs b/src/daemon/server.rs index cdbe1a4..7742d6a 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -321,7 +321,9 @@ async fn handle_request( target, read_only, force, - } => handle_session_attach(registry, clients, client_id, &target, read_only, force, event_tx), + } => handle_session_attach( + registry, clients, client_id, &target, read_only, force, event_tx, + ), Request::SessionDetach => handle_session_detach(registry, clients, client_id), Request::SessionSend { target, input } => handle_session_send(registry, &target, &input), Request::SessionOutput { @@ -593,12 +595,7 @@ fn handle_session_attach( let active_client = session .attached_clients .iter() - .find(|&&cid| { - clients - .get(&cid) - .map(|c| !c.read_only) - .unwrap_or(false) - }) + .find(|&&cid| clients.get(&cid).map(|c| !c.read_only).unwrap_or(false)) .copied(); if let Some(existing_cid) = active_client { @@ -670,8 +667,7 @@ fn handle_session_detach( // No more clients — signal snag wrap to resume and re-apply terminal size if session.registered { if let Some(pid) = session.child_pid { - let _ = - nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGUSR2); + let _ = nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGUSR2); } } } else { @@ -680,9 +676,7 @@ fn handle_session_detach( let remaining_size = session .attached_clients .iter() - .filter_map(|cid| { - clients.get(cid).and_then(|c| c.last_size) - }) + .filter_map(|cid| clients.get(cid).and_then(|c| c.last_size)) .next(); if let Some((cols, rows)) = remaining_size { let _ = pty::set_winsize(session.raw_fd(), rows, cols); diff --git a/src/tui/ui.rs b/src/tui/ui.rs index a162311..6376837 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -118,7 +118,11 @@ pub fn draw(frame: &mut Frame, app: &App) { // Status bar let status_text = match app.input_mode { InputMode::Normal => { - let hide_label = if app.hide_snagged { "[h]show snagged" } else { "[h]hide snagged" }; + let hide_label = if app.hide_snagged { + "[h]show snagged" + } else { + "[h]hide snagged" + }; format!(" [n]ew [x]kill [r]ename [s]end [Enter]attach {hide_label} [q]uit") } InputMode::Rename => {