aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-03-28 00:20:12 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-03-28 00:20:12 +0300
commit787786c62009f6c27a0091b61017ae5ad5c95c0e (patch)
treee0c21aec008cae69e79514c6c6a25c9d4f411fdf
parent0403775fd2a9458417c5349d5441125ab9b2553c (diff)
fix wrapped foreground job agent detection
-rw-r--r--src/detect.rs95
-rw-r--r--src/pane.rs27
-rw-r--r--src/platform/fallback.rs4
-rw-r--r--src/platform/linux.rs74
-rw-r--r--src/platform/macos.rs107
-rw-r--r--src/platform/mod.rs14
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")]