diff --git a/src/detect.rs b/src/detect.rs index 32b7a6cd..500d6eb3 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -997,6 +997,14 @@ pub fn foreground_job(child_pid: u32) -> Option crate::platform::foreground_job(child_pid) } +/// Get the foreground process group leader as a one-process job. +/// This is cheaper than collecting every process in the foreground job. +pub fn foreground_group_leader_job( + process_group_id: u32, +) -> Option { + crate::platform::foreground_group_leader_job(process_group_id) +} + /// Get the foreground process group for a pane shell PID. /// This is cheaper than collecting every process in the foreground job. pub fn foreground_process_group_id(child_pid: u32) -> Option { diff --git a/src/pane.rs b/src/pane.rs index aed8d6b8..77249de8 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -102,6 +102,12 @@ async fn publish_state_changed_event( } const AGENT_MISS_CONFIRMATION_ATTEMPTS: u8 = 6; +const PROCESS_RECHECK_IDENTIFIED: std::time::Duration = std::time::Duration::from_secs(5); +const PROCESS_RECHECK_UNIDENTIFIED: std::time::Duration = std::time::Duration::from_secs(30); +const PROCESS_ACQUISITION_WINDOW: std::time::Duration = std::time::Duration::from_secs(8); +const PROCESS_ACQUISITION_FAST_WINDOW: std::time::Duration = std::time::Duration::from_millis(1500); +const PROCESS_ACQUISITION_FAST_RECHECK: std::time::Duration = std::time::Duration::from_millis(500); +const PROCESS_ACQUISITION_SLOW_RECHECK: std::time::Duration = std::time::Duration::from_secs(2); #[derive(Debug, Clone, Copy)] struct AgentDetectionPresence { @@ -145,6 +151,99 @@ fn foreground_shell_agent_action( } } +#[derive(Debug, Clone, Copy)] +struct ProcessProbeInput { + current_agent: Option, + suppressed_agent: Option, + foreground_pgid: Option, + last_foreground_pgid: Option, + has_process_probe: bool, + acquisition_age: Option, + pending_foreground_shell_clear: bool, + pending_restore_probe: bool, + elapsed_since_process_check: std::time::Duration, +} + +fn foreground_group_changed( + foreground_pgid: Option, + last_foreground_pgid: Option, +) -> bool { + foreground_pgid != last_foreground_pgid + && (foreground_pgid.is_some() || last_foreground_pgid.is_some()) +} + +fn should_probe_foreground_job(input: ProcessProbeInput) -> bool { + if input.pending_foreground_shell_clear || input.pending_restore_probe { + return true; + } + + let foreground_group_changed = + foreground_group_changed(input.foreground_pgid, input.last_foreground_pgid); + + if input.suppressed_agent.is_some() { + return !input.has_process_probe || foreground_group_changed; + } + + if let Some(acquisition_age) = input.acquisition_age { + let acquisition_interval = if acquisition_age <= PROCESS_ACQUISITION_FAST_WINDOW { + PROCESS_ACQUISITION_FAST_RECHECK + } else { + PROCESS_ACQUISITION_SLOW_RECHECK + }; + if acquisition_age <= PROCESS_ACQUISITION_WINDOW + && input.elapsed_since_process_check >= acquisition_interval + { + return true; + } + } + + if input.current_agent.is_none() { + return !input.has_process_probe + || foreground_group_changed + || input.elapsed_since_process_check >= PROCESS_RECHECK_UNIDENTIFIED; + } + + foreground_group_changed || input.elapsed_since_process_check >= PROCESS_RECHECK_IDENTIFIED +} + +#[derive(Debug, Clone)] +struct ProcessProbeResult { + process_group_id: Option, + foreground_is_pane_shell: bool, + agent: Option, + process_name: Option, +} + +fn probe_foreground_process(pid: u32, foreground_pgid: Option) -> ProcessProbeResult { + if let Some(job) = foreground_pgid.and_then(crate::detect::foreground_group_leader_job) { + if let Some((agent, process_name)) = crate::detect::identify_agent_in_job(&job) { + return ProcessProbeResult { + process_group_id: Some(job.process_group_id), + foreground_is_pane_shell: job.processes.iter().any(|p| p.pid == pid), + agent: Some(agent), + process_name: Some(process_name), + }; + } + } + + if let Some(job) = crate::detect::foreground_job(pid) { + let identified = crate::detect::identify_agent_in_job(&job); + return ProcessProbeResult { + process_group_id: Some(job.process_group_id), + foreground_is_pane_shell: job.processes.iter().any(|p| p.pid == pid), + agent: identified.as_ref().map(|(agent, _)| *agent), + process_name: identified.map(|(_, process_name)| process_name), + }; + } + + ProcessProbeResult { + process_group_id: foreground_pgid, + foreground_is_pane_shell: false, + agent: None, + process_name: None, + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct DetectionPublishState { state: AgentState, @@ -189,6 +288,11 @@ fn spawn_basic_detection_task( let mut last_visible_blocker = false; let mut last_visible_idle = false; let mut last_visible_working = false; + let mut last_process_check = std::time::Instant::now(); + let mut last_foreground_pgid = None; + let mut has_process_probe = false; + let mut acquisition_started_at = None; + let mut release_was_active = false; loop { tokio::select! { @@ -199,18 +303,67 @@ fn spawn_basic_detection_task( last_visible_blocker = false; last_visible_idle = false; last_visible_working = false; + last_process_check = std::time::Instant::now(); + last_foreground_pgid = None; + has_process_probe = false; + acquisition_started_at = None; + release_was_active = false; } } let now = std::time::Instant::now(); + let suppressed_agent = active_pending_release(&pending_release_for_task, now); + if suppressed_agent.is_none() && release_was_active { + has_process_probe = false; + acquisition_started_at = None; + } + release_was_active = suppressed_agent.is_some(); let pid = child_pid.load(Ordering::Acquire); let mut agent_changed = false; let mut agent = agent_presence.current_agent(); + let content = terminal.detection_text(); - if pid > 0 { - let new_agent = crate::detect::foreground_job(pid).and_then(|job| { - crate::detect::identify_agent_in_job(&job).map(|(agent, _)| agent) + let foreground_pgid = (pid > 0) + .then(|| crate::detect::foreground_process_group_id(pid)) + .flatten(); + let process_group_changed = + foreground_group_changed(foreground_pgid, last_foreground_pgid); + let should_check_process = pid > 0 + && should_probe_foreground_job(ProcessProbeInput { + current_agent: agent_presence.current_agent(), + suppressed_agent, + foreground_pgid, + last_foreground_pgid, + has_process_probe, + acquisition_age: acquisition_started_at + .map(|started| now.duration_since(started)), + pending_foreground_shell_clear: false, + pending_restore_probe: false, + elapsed_since_process_check: now.duration_since(last_process_check), }); + + if should_check_process { + last_process_check = now; + let had_process_probe = has_process_probe; + has_process_probe = true; + let probe = probe_foreground_process(pid, foreground_pgid); + let mut new_agent = probe.agent; + if let Some(suppressed_agent) = suppressed_agent { + if new_agent == Some(suppressed_agent) { + new_agent = None; + } else if let Ok(mut pending_release) = pending_release_for_task.lock() { + *pending_release = None; + } + } + if new_agent.is_none() { + last_foreground_pgid = probe.process_group_id.or(foreground_pgid); + if had_process_probe && process_group_changed { + acquisition_started_at = Some(now); + } + } else { + last_foreground_pgid = probe.process_group_id.or(foreground_pgid); + acquisition_started_at = None; + } let previous_agent = agent_presence.current_agent(); if agent_presence.observe_process_probe(new_agent) { agent = agent_presence.current_agent(); @@ -218,7 +371,6 @@ fn spawn_basic_detection_task( } } - let content = terminal.detection_text(); let detection = crate::detect::detect_agent(agent, &content); let new_state = detection.state; let visible_blocker = detection.visible_blocker && new_state == AgentState::Blocked; @@ -258,8 +410,6 @@ fn spawn_basic_detection_task( ) .await; } - - let _ = active_pending_release(&pending_release_for_task, now); } }); @@ -1293,7 +1443,6 @@ impl PaneRuntime { const TICK_UNIDENTIFIED: Duration = Duration::from_millis(500); const TICK_IDENTIFIED: Duration = Duration::from_millis(300); const TICK_PENDING_RELEASE: Duration = Duration::from_millis(50); - const PROCESS_RECHECK: Duration = Duration::from_secs(5); let child_pid = child_pid.clone(); let terminal = terminal.clone(); @@ -1315,8 +1464,11 @@ impl PaneRuntime { }; let mut last_process_check = Instant::now(); let mut last_foreground_pgid = None; + let mut has_process_probe = false; + let mut acquisition_started_at = None; let mut pending_foreground_shell_clear = false; let mut foreground_shell_exit_reported = false; + let mut release_was_active = false; let mut pending_restore_probe = initial_state.detected_agent.is_some(); let mut last_claude_working_at = None; let mut last_visible_blocker = false; @@ -1342,8 +1494,11 @@ impl PaneRuntime { agent_presence = AgentDetectionPresence::from_agent(None); state = AgentState::Unknown; last_foreground_pgid = None; + has_process_probe = false; + acquisition_started_at = None; pending_foreground_shell_clear = false; foreground_shell_exit_reported = false; + release_was_active = false; pending_restore_probe = false; last_claude_working_at = None; last_visible_blocker = false; @@ -1354,44 +1509,44 @@ impl PaneRuntime { let now = Instant::now(); let suppressed_agent = active_pending_release(&pending_release_for_task, now); + if suppressed_agent.is_none() && release_was_active { + has_process_probe = false; + acquisition_started_at = None; + } + release_was_active = suppressed_agent.is_some(); let pid = child_pid.load(Ordering::Acquire); - let foreground_pgid = (pid > 0 && agent_presence.current_agent().is_some()) + let content = terminal.detection_text(); + let foreground_pgid = (pid > 0) .then(|| detect::foreground_process_group_id(pid)) .flatten(); - let foreground_group_changed = foreground_pgid.is_some() - && last_foreground_pgid.is_some() - && foreground_pgid != last_foreground_pgid; - let should_check_process = suppressed_agent.is_some() - || agent_presence.current_agent().is_none() - || foreground_group_changed - || pending_foreground_shell_clear - || pending_restore_probe - || now.duration_since(last_process_check) >= PROCESS_RECHECK; + let process_group_changed = + foreground_group_changed(foreground_pgid, last_foreground_pgid); + let should_check_process = pid > 0 + && should_probe_foreground_job(ProcessProbeInput { + current_agent: agent_presence.current_agent(), + suppressed_agent, + foreground_pgid, + last_foreground_pgid, + has_process_probe, + acquisition_age: acquisition_started_at + .map(|started| now.duration_since(started)), + pending_foreground_shell_clear, + pending_restore_probe, + elapsed_since_process_check: now.duration_since(last_process_check), + }); let mut agent_changed = false; let mut agent = agent_presence.current_agent(); if should_check_process { last_process_check = now; + let had_process_probe = has_process_probe; + has_process_probe = true; if pid > 0 { - let mut process_name = None; - let mut process_group_id = None; - let mut foreground_is_pane_shell = false; - let mut new_agent = None; - - if let Some(job) = detect::foreground_job(pid) { - process_group_id = Some(job.process_group_id); - last_foreground_pgid = Some(job.process_group_id); - foreground_is_pane_shell = - job.processes.iter().any(|p| p.pid == pid); - let identified = detect::identify_agent_in_job(&job); - process_name = identified - .as_ref() - .map(|(_, process_name)| process_name.clone()); - new_agent = identified.as_ref().map(|(agent, _)| *agent); - } else if foreground_pgid.is_some() { - process_group_id = foreground_pgid; - last_foreground_pgid = foreground_pgid; - } + let probe = probe_foreground_process(pid, foreground_pgid); + let process_name = probe.process_name; + let process_group_id = probe.process_group_id; + let foreground_is_pane_shell = probe.foreground_is_pane_shell; + let mut new_agent = probe.agent; if let Some(suppressed_agent) = suppressed_agent { if new_agent == Some(suppressed_agent) { @@ -1427,10 +1582,16 @@ impl PaneRuntime { }; if new_agent.is_some() { last_foreground_pgid = process_group_id; + acquisition_started_at = None; pending_restore_probe = false; } else if agent_presence.current_agent().is_none() { - last_foreground_pgid = None; + last_foreground_pgid = process_group_id.or(foreground_pgid); + if had_process_probe && process_group_changed { + acquisition_started_at = Some(now); + } pending_restore_probe = false; + } else { + last_foreground_pgid = process_group_id.or(foreground_pgid); } if changed { agent = agent_presence.current_agent(); @@ -1466,7 +1627,6 @@ impl PaneRuntime { } } - let content = terminal.detection_text(); let process_exited = pending_foreground_shell_clear && agent.is_some() && !foreground_shell_exit_reported; @@ -2327,6 +2487,206 @@ mod tests { )); } + fn process_probe_input() -> ProcessProbeInput { + ProcessProbeInput { + current_agent: None, + suppressed_agent: None, + foreground_pgid: Some(42), + last_foreground_pgid: Some(42), + has_process_probe: true, + acquisition_age: None, + pending_foreground_shell_clear: false, + pending_restore_probe: false, + elapsed_since_process_check: std::time::Duration::from_secs(1), + } + } + + #[test] + fn unchanged_unidentified_foreground_group_skips_full_process_probe() { + assert!(!should_probe_foreground_job(process_probe_input())); + } + + #[test] + fn unidentified_foreground_group_change_runs_full_process_probe() { + assert!(should_probe_foreground_job(ProcessProbeInput { + foreground_pgid: Some(43), + ..process_probe_input() + })); + } + + #[test] + fn unidentified_pane_gets_initial_and_safety_process_probes() { + assert!(should_probe_foreground_job(ProcessProbeInput { + has_process_probe: false, + ..process_probe_input() + })); + assert!(should_probe_foreground_job(ProcessProbeInput { + elapsed_since_process_check: PROCESS_RECHECK_UNIDENTIFIED, + ..process_probe_input() + })); + } + + #[test] + fn unidentified_pane_without_foreground_group_uses_safety_process_probe() { + assert!(!should_probe_foreground_job(ProcessProbeInput { + foreground_pgid: None, + last_foreground_pgid: None, + ..process_probe_input() + })); + assert!(should_probe_foreground_job(ProcessProbeInput { + foreground_pgid: None, + last_foreground_pgid: None, + elapsed_since_process_check: PROCESS_RECHECK_UNIDENTIFIED, + ..process_probe_input() + })); + } + + #[test] + fn unidentified_pane_probes_when_foreground_group_disappears() { + assert!(should_probe_foreground_job(ProcessProbeInput { + foreground_pgid: None, + last_foreground_pgid: Some(42), + ..process_probe_input() + })); + } + + #[test] + fn pending_shell_clear_and_restore_force_process_probes() { + assert!(should_probe_foreground_job(ProcessProbeInput { + current_agent: Some(Agent::Codex), + pending_foreground_shell_clear: true, + ..process_probe_input() + })); + assert!(should_probe_foreground_job(ProcessProbeInput { + current_agent: Some(Agent::Codex), + pending_restore_probe: true, + ..process_probe_input() + })); + } + + #[test] + fn pending_release_forces_initial_process_probe() { + assert!(should_probe_foreground_job(ProcessProbeInput { + current_agent: Some(Agent::Codex), + suppressed_agent: Some(Agent::Codex), + has_process_probe: false, + ..process_probe_input() + })); + } + + #[test] + fn pending_release_forces_process_probe_after_runtime_identity_clears() { + assert!(should_probe_foreground_job(ProcessProbeInput { + current_agent: None, + suppressed_agent: Some(Agent::Codex), + has_process_probe: false, + ..process_probe_input() + })); + } + + #[test] + fn pending_release_skips_repeated_probe_when_foreground_group_is_stable() { + assert!(!should_probe_foreground_job(ProcessProbeInput { + current_agent: None, + suppressed_agent: Some(Agent::Codex), + ..process_probe_input() + })); + } + + #[test] + fn pending_release_probes_when_foreground_group_changes() { + assert!(should_probe_foreground_job(ProcessProbeInput { + current_agent: None, + suppressed_agent: Some(Agent::Codex), + foreground_pgid: Some(43), + ..process_probe_input() + })); + } + + #[test] + fn acquisition_window_catches_delayed_same_group_wrapper_startup() { + assert!(!should_probe_foreground_job(ProcessProbeInput { + current_agent: None, + acquisition_age: Some(std::time::Duration::from_millis(1250)), + elapsed_since_process_check: PROCESS_ACQUISITION_FAST_RECHECK + - std::time::Duration::from_millis(1), + ..process_probe_input() + })); + assert!(should_probe_foreground_job(ProcessProbeInput { + current_agent: None, + acquisition_age: Some(std::time::Duration::from_millis(1250)), + elapsed_since_process_check: PROCESS_ACQUISITION_FAST_RECHECK, + ..process_probe_input() + })); + assert!(should_probe_foreground_job(ProcessProbeInput { + current_agent: None, + acquisition_age: Some(std::time::Duration::from_secs(5)), + elapsed_since_process_check: PROCESS_ACQUISITION_SLOW_RECHECK, + ..process_probe_input() + })); + assert!(!should_probe_foreground_job(ProcessProbeInput { + current_agent: None, + acquisition_age: Some(PROCESS_ACQUISITION_WINDOW + std::time::Duration::from_millis(1),), + elapsed_since_process_check: PROCESS_ACQUISITION_SLOW_RECHECK, + ..process_probe_input() + })); + } + + #[test] + fn release_expiry_can_force_reacquire_probe_by_resetting_probe_state() { + assert!(should_probe_foreground_job(ProcessProbeInput { + current_agent: None, + has_process_probe: false, + ..process_probe_input() + })); + } + + #[test] + fn identified_agent_uses_shorter_safety_process_probe() { + assert!(!should_probe_foreground_job(ProcessProbeInput { + current_agent: Some(Agent::Codex), + elapsed_since_process_check: PROCESS_RECHECK_IDENTIFIED + - std::time::Duration::from_millis(1), + ..process_probe_input() + })); + assert!(should_probe_foreground_job(ProcessProbeInput { + current_agent: Some(Agent::Codex), + elapsed_since_process_check: PROCESS_RECHECK_IDENTIFIED, + ..process_probe_input() + })); + } + + #[test] + fn identified_agent_probes_when_foreground_group_disappears() { + assert!(should_probe_foreground_job(ProcessProbeInput { + current_agent: Some(Agent::Codex), + foreground_pgid: None, + last_foreground_pgid: Some(42), + elapsed_since_process_check: PROCESS_RECHECK_IDENTIFIED + - std::time::Duration::from_millis(1), + ..process_probe_input() + })); + } + + #[test] + fn stable_missing_foreground_group_uses_safety_process_probe() { + assert!(!should_probe_foreground_job(ProcessProbeInput { + current_agent: Some(Agent::Codex), + foreground_pgid: None, + last_foreground_pgid: None, + elapsed_since_process_check: PROCESS_RECHECK_IDENTIFIED + - std::time::Duration::from_millis(1), + ..process_probe_input() + })); + assert!(should_probe_foreground_job(ProcessProbeInput { + current_agent: Some(Agent::Codex), + foreground_pgid: None, + last_foreground_pgid: None, + elapsed_since_process_check: PROCESS_RECHECK_IDENTIFIED, + ..process_probe_input() + })); + } + #[test] fn transient_process_miss_keeps_current_agent_detected() { let mut presence = AgentDetectionPresence::from_agent(Some(Agent::Pi)); diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs index 825aaccd..a8f9d8ce 100644 --- a/src/platform/fallback.rs +++ b/src/platform/fallback.rs @@ -7,6 +7,11 @@ pub fn foreground_job(_child_pid: u32) -> Option { None } +/// Unsupported platform stub. +pub fn foreground_group_leader_job(_process_group_id: u32) -> Option { + None +} + /// Unsupported platform stub. pub fn foreground_process_group_id(_child_pid: u32) -> Option { None diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 73a4f0a5..00f4b906 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -54,6 +54,25 @@ pub fn foreground_job(child_pid: u32) -> Option { }) } +pub fn foreground_group_leader_job(process_group_id: u32) -> Option { + let (pgrp, name) = process_pgrp_and_comm(process_group_id)?; + if pgrp as u32 != process_group_id { + return None; + } + + let argv = process_argv(process_group_id); + Some(ForegroundJob { + process_group_id, + processes: vec![ForegroundProcess { + pid: process_group_id, + name, + argv0: None, + cmdline: argv.as_ref().map(|parts| parts.join(" ")), + argv, + }], + }) +} + pub fn foreground_process_group_id(child_pid: u32) -> Option { // /proc//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. diff --git a/src/platform/macos.rs b/src/platform/macos.rs index bfce664a..354a6b3c 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -52,6 +52,26 @@ pub fn foreground_job(child_pid: u32) -> Option { }) } +pub fn foreground_group_leader_job(process_group_id: u32) -> Option { + let info = process_bsdinfo(process_group_id)?; + if info.pbi_pgid != process_group_id { + return None; + } + + let name = comm_from_bsdinfo(&info)?; + let argv = process_argv(process_group_id); + Some(ForegroundJob { + process_group_id, + processes: vec![ForegroundProcess { + pid: process_group_id, + name, + argv0: process_argv0_name(process_group_id), + cmdline: argv.as_ref().map(|parts| parts.join(" ")), + argv, + }], + }) +} + fn process_group_pids(process_group_id: u32) -> Vec { let mut capacity = 16usize;