diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-28 00:20:12 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-28 00:20:12 +0300 |
| commit | 787786c62009f6c27a0091b61017ae5ad5c95c0e (patch) | |
| tree | e0c21aec008cae69e79514c6c6a25c9d4f411fdf | |
| parent | 0403775fd2a9458417c5349d5441125ab9b2553c (diff) | |
fix wrapped foreground job agent detection
| -rw-r--r-- | src/detect.rs | 95 | ||||
| -rw-r--r-- | src/pane.rs | 27 | ||||
| -rw-r--r-- | src/platform/fallback.rs | 4 | ||||
| -rw-r--r-- | src/platform/linux.rs | 74 | ||||
| -rw-r--r-- | src/platform/macos.rs | 107 | ||||
| -rw-r--r-- | src/platform/mod.rs | 14 |
6 files changed, 273 insertions, 48 deletions
diff --git a/src/detect.rs b/src/detect.rs index 235a1603..f1e0cb42 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -66,6 +66,25 @@ pub fn identify_agent(process_name: &str) -> Option<Agent> { } } +pub fn identify_agent_in_job(job: &crate::platform::ForegroundJob) -> Option<(Agent, String)> { + let mut best: Option<(u8, Agent, String)> = None; + + for process in &job.processes { + let candidate = normalized_process_name(process); + let Some(agent) = identify_agent(&candidate) else { + continue; + }; + let score = process_priority(process, &candidate); + + match &best { + Some((best_score, _, _)) if *best_score >= score => {} + _ => best = Some((score, agent, candidate)), + } + } + + best.map(|(_, agent, name)| (agent, name)) +} + /// Detect the state of an agent from the visible screen content. /// If `agent` is `None`, returns `Unknown`. pub fn detect_state(agent: Option<Agent>, screen_content: &str) -> AgentState { @@ -497,10 +516,42 @@ fn content_above_prompt_box(content: &str) -> &str { // Process identification (platform-specific) // --------------------------------------------------------------------------- -/// Get the foreground process name for a given child PID. +/// Get the foreground job for a given child PID. /// Delegates to platform-specific implementation. -pub fn foreground_process_name(child_pid: u32) -> Option<String> { - crate::platform::foreground_process_name(child_pid) +pub fn foreground_job(child_pid: u32) -> Option<crate::platform::ForegroundJob> { + crate::platform::foreground_job(child_pid) +} + +fn normalized_process_name(process: &crate::platform::ForegroundProcess) -> String { + let effective = process.argv0.as_deref().unwrap_or(&process.name); + let lower_effective = effective.to_lowercase(); + let lower_cmdline = process.cmdline.as_deref().unwrap_or_default().to_lowercase(); + + if lower_effective == "node" + && (lower_cmdline.contains("/codex") || lower_cmdline.contains("@openai/codex")) + { + return "codex".to_string(); + } + + effective.to_string() +} + +fn process_priority(process: &crate::platform::ForegroundProcess, normalized_name: &str) -> u8 { + let lower_name = normalized_name.to_lowercase(); + if lower_name != process.name.to_lowercase() { + return 3; + } + if !is_generic_runtime_or_shell(&lower_name) { + return 2; + } + 1 +} + +fn is_generic_runtime_or_shell(name: &str) -> bool { + matches!( + name, + "sh" | "bash" | "zsh" | "fish" | "tmux" | "node" | "bun" | "python" | "python3" + ) } // --------------------------------------------------------------------------- @@ -542,6 +593,29 @@ mod tests { assert_eq!(identify_agent("Codex"), Some(Agent::Codex)); } + #[test] + fn identify_agent_in_job_prefers_wrapped_codex() { + let job = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![ + crate::platform::ForegroundProcess { + pid: 1, + name: "node".to_string(), + argv0: None, + cmdline: Some("node /path/to/bin/codex".to_string()), + }, + crate::platform::ForegroundProcess { + pid: 2, + name: "bash".to_string(), + argv0: None, + cmdline: Some("bash".to_string()), + }, + ], + }; + + assert_eq!(identify_agent_in_job(&job), Some((Agent::Codex, "codex".to_string()))); + } + // ---- Workspace state rollup ---- #[test] @@ -1037,7 +1111,7 @@ mod tests { #[cfg(target_os = "linux")] #[test] - fn foreground_process_name_detects_sleep() { + fn foreground_job_detects_sleep() { use portable_pty::{native_pty_system, CommandBuilder, PtySize}; let pty_system = native_pty_system(); @@ -1059,8 +1133,9 @@ mod tests { // Give the process a moment to become the foreground group std::thread::sleep(std::time::Duration::from_millis(50)); - let name = foreground_process_name(pid); - assert_eq!(name.as_deref(), Some("sleep"), "expected 'sleep', got {name:?}"); + let job = foreground_job(pid).expect("expected foreground job"); + assert!(job.processes.iter().any(|p| p.name == "sleep"), "expected sleep in {job:?}"); + assert_eq!(identify_agent_in_job(&job), None, "sleep should not map to an agent"); // Clean up child.kill().ok(); @@ -1069,7 +1144,7 @@ mod tests { #[cfg(target_os = "linux")] #[test] - fn foreground_process_name_detects_shell_running_command() { + fn foreground_job_detects_shell_running_command() { use portable_pty::{native_pty_system, CommandBuilder, PtySize}; use std::io::Write; @@ -1096,9 +1171,9 @@ mod tests { std::thread::sleep(std::time::Duration::from_millis(100)); - let name = foreground_process_name(pid); - // The foreground process should now be "sleep", not "sh" - assert_eq!(name.as_deref(), Some("sleep"), "expected 'sleep', got {name:?}"); + let job = foreground_job(pid).expect("expected foreground job"); + assert!(job.processes.iter().any(|p| p.name == "sleep"), "expected sleep in {job:?}"); + assert_eq!(identify_agent_in_job(&job), None, "sleep should not map to an agent"); child.kill().ok(); child.wait().ok(); diff --git a/src/pane.rs b/src/pane.rs index 821cdec9..c9ab3649 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -227,15 +227,26 @@ impl PaneRuntime { last_process_check = now; let pid = child_pid.load(Ordering::Acquire); if pid > 0 { - if let Some(name) = detect::foreground_process_name(pid) { - let new_agent = detect::identify_agent(&name); + if let Some(job) = detect::foreground_job(pid) { + let identified = detect::identify_agent_in_job(&job); + let new_agent = identified.as_ref().map(|(agent, _)| *agent); if new_agent != agent { - info!( - pane = pane_id.raw(), - ?new_agent, - process = %name, - "agent changed" - ); + if let Some((_, process_name)) = identified { + info!( + pane = pane_id.raw(), + ?new_agent, + process = %process_name, + pgid = job.process_group_id, + "agent changed" + ); + } else { + info!( + pane = pane_id.raw(), + ?new_agent, + pgid = job.process_group_id, + "agent changed" + ); + } agent = new_agent; agent_changed = true; } diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs index 735f7c11..7bcd23c5 100644 --- a/src/platform/fallback.rs +++ b/src/platform/fallback.rs @@ -1,7 +1,9 @@ use std::path::PathBuf; +use super::ForegroundJob; + /// Unsupported platform stub. -pub fn foreground_process_name(_child_pid: u32) -> Option<String> { +pub fn foreground_job(_child_pid: u32) -> Option<ForegroundJob> { None } diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 001ef03e..10695e92 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -1,8 +1,51 @@ use std::path::PathBuf; -/// Get the foreground process name for a given child PID. -/// Uses /proc/<pid>/stat to find the tpgid, then /proc/<tpgid>/comm. -pub fn foreground_process_name(child_pid: u32) -> Option<String> { +use super::{ForegroundJob, ForegroundProcess}; + +/// Collect the foreground terminal job for a given child PID. +pub fn foreground_job(child_pid: u32) -> Option<ForegroundJob> { + let tpgid = foreground_pgid(child_pid)? as u32; + let mut processes = Vec::new(); + + for entry in std::fs::read_dir("/proc").ok()? { + let entry = entry.ok()?; + let file_name = entry.file_name(); + let pid_str = file_name.to_str()?; + if !pid_str.bytes().all(|b| b.is_ascii_digit()) { + continue; + } + + let pid: u32 = match pid_str.parse() { + Ok(pid) => pid, + Err(_) => continue, + }; + + let Some((pgrp, name)) = process_pgrp_and_comm(pid) else { + continue; + }; + if pgrp as u32 != tpgid { + continue; + } + + processes.push(ForegroundProcess { + pid, + name, + argv0: None, + cmdline: process_cmdline(pid), + }); + } + + if processes.is_empty() { + return None; + } + + Some(ForegroundJob { + process_group_id: tpgid, + processes, + }) +} + +fn foreground_pgid(child_pid: u32) -> Option<i32> { // /proc/<pid>/stat format: "pid (comm) state ppid pgrp session tty_nr tpgid ..." // The (comm) field can contain spaces and parens, so we find the last ')' first. let stat = std::fs::read_to_string(format!("/proc/{child_pid}/stat")).ok()?; @@ -10,11 +53,30 @@ pub fn foreground_process_name(child_pid: u32) -> Option<String> { let fields: Vec<&str> = rest.split_whitespace().collect(); // After (comm): state(0) ppid(1) pgrp(2) session(3) tty_nr(4) tpgid(5) let tpgid: i32 = fields.get(5)?.parse().ok()?; - if tpgid <= 0 { + (tpgid > 0).then_some(tpgid) +} + +fn process_pgrp_and_comm(pid: u32) -> Option<(i32, String)> { + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + let close = stat.rfind(')')?; + let comm = stat.get(1 + stat.find('(')?..close)?.to_string(); + let rest = stat.get(close + 2..)?; + let fields: Vec<&str> = rest.split_whitespace().collect(); + let pgrp: i32 = fields.get(2)?.parse().ok()?; + Some((pgrp, comm)) +} + +fn process_cmdline(pid: u32) -> Option<String> { + let bytes = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?; + if bytes.is_empty() { return None; } - let comm = std::fs::read_to_string(format!("/proc/{tpgid}/comm")).ok()?; - Some(comm.trim().to_string()) + let parts: Vec<String> = bytes + .split(|&b| b == 0) + .filter(|part| !part.is_empty()) + .map(|part| String::from_utf8_lossy(part).into_owned()) + .collect(); + (!parts.is_empty()).then(|| parts.join(" ")) } /// Get the current working directory of a process. diff --git a/src/platform/macos.rs b/src/platform/macos.rs index f8482bba..524896d4 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -2,34 +2,60 @@ use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; -/// Get the foreground process name for a given child PID. -/// -/// Uses `proc_pidinfo(PROC_PIDTBSDINFO)` to read `e_tpgid` — the foreground -/// process group ID of the child's controlling terminal — then resolves the -/// group leader to a human-visible name via `KERN_PROCARGS2`. -/// -/// `KERN_PROCARGS2` reads the process's `argv[0]`, which reflects runtime -/// title changes (e.g. Node.js `process.title = "pi"`). This matches what -/// `ps -o comm` displays and is the only reliable way on macOS to see the -/// effective command name for scripting runtimes. -/// -/// Falls back to `pbi_comm` from `proc_pidinfo` if `KERN_PROCARGS2` fails. -pub fn foreground_process_name(child_pid: u32) -> Option<String> { +use super::{ForegroundJob, ForegroundProcess}; + +/// Collect the foreground terminal job for a given child PID. +pub fn foreground_job(child_pid: u32) -> Option<ForegroundJob> { if child_pid == 0 { return None; } let fg_pgid = foreground_pgid(child_pid)?; + let mut pids = vec![0i32; 4096]; + let bytes = unsafe { + libc::proc_listallpids( + pids.as_mut_ptr() as *mut libc::c_void, + (pids.len() * std::mem::size_of::<i32>()) as libc::c_int, + ) + }; + if bytes <= 0 { + return None; + } + + let count = (bytes as usize) / std::mem::size_of::<i32>(); + let mut processes = Vec::new(); + + for raw_pid in pids.into_iter().take(count) { + if raw_pid <= 0 { + continue; + } + let pid = raw_pid as u32; + let Some(info) = process_bsdinfo(pid) else { + continue; + }; + if info.pbi_pgid as u32 != fg_pgid { + continue; + } - // Primary: argv[0] via KERN_PROCARGS2 (reflects process.title changes) - // Note: we use the PGID as a PID — works because the group leader's PID - // equals the PGID. Same assumption Linux makes with /proc/{tpgid}/comm. - if let Some(name) = process_argv0_name(fg_pgid) { - return Some(name); + let Some(name) = comm_from_bsdinfo(&info) else { + continue; + }; + processes.push(ForegroundProcess { + pid, + name, + argv0: process_argv0_name(pid), + cmdline: process_cmdline(pid), + }); + } + + if processes.is_empty() { + return None; } - // Fallback: kernel comm name from proc_pidinfo - process_comm_name(fg_pgid) + Some(ForegroundJob { + process_group_id: fg_pgid, + processes, + }) } /// Read `e_tpgid` (foreground process group of the controlling terminal) @@ -151,6 +177,10 @@ fn kern_procargs2(pid: u32) -> Option<Vec<u8>> { /// This is the kernel-level short command name (like `node`, `zsh`). /// It does NOT reflect `process.title` changes. fn process_comm_name(pid: u32) -> Option<String> { + process_bsdinfo(pid).and_then(|info| comm_from_bsdinfo(&info)) +} + +fn process_bsdinfo(pid: u32) -> Option<libc::proc_bsdinfo> { let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() }; let size = std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int; @@ -164,10 +194,10 @@ fn process_comm_name(pid: u32) -> Option<String> { ) }; - if ret != size { - return None; - } + (ret == size).then_some(info) +} +fn comm_from_bsdinfo(info: &libc::proc_bsdinfo) -> Option<String> { let end = info .pbi_comm .iter() @@ -181,6 +211,37 @@ fn process_comm_name(pid: u32) -> Option<String> { String::from_utf8(bytes).ok() } +fn process_cmdline(pid: u32) -> Option<String> { + let buf = kern_procargs2(pid)?; + if buf.len() < 4 { + return None; + } + + let argc = i32::from_ne_bytes([buf[0], buf[1], buf[2], buf[3]]); + if argc < 1 { + return None; + } + + let rest = &buf[4..]; + let mut start = 0usize; + while start < rest.len() && rest[start] != 0 { + start += 1; + } + while start < rest.len() && rest[start] == 0 { + start += 1; + } + if start >= rest.len() { + return None; + } + + let parts: Vec<String> = rest[start..] + .split(|&b| b == 0) + .filter(|part| !part.is_empty()) + .map(|part| String::from_utf8_lossy(part).into_owned()) + .collect(); + (!parts.is_empty()).then(|| parts.join(" ")) +} + /// Get the current working directory of a process. /// /// Uses `proc_pidinfo(PROC_PIDVNODEPATHINFO)` to read `pvi_cdir.vip_path`. diff --git a/src/platform/mod.rs b/src/platform/mod.rs index df8869c7..ade86249 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -3,6 +3,20 @@ //! Centralizes OS-dependent behavior behind a clean boundary so core //! modules don't scatter `#[cfg]` branches through product logic. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ForegroundProcess { + pub pid: u32, + pub name: String, + pub argv0: Option<String>, + pub cmdline: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ForegroundJob { + pub process_group_id: u32, + pub processes: Vec<ForegroundProcess>, +} + #[cfg(target_os = "linux")] mod linux; #[cfg(target_os = "linux")] |
