aboutsummaryrefslogtreecommitdiff
path: root/src/pane.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-08 20:55:51 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-09 04:26:19 +0300
commitee51c5e5fbaea694e65bb0a4908f75bcaf9707d1 (patch)
treef566d2a5921d4112602a7abcffb5cf6eb04d5f24 /src/pane.rs
parenta7cd7806f2fa0251e2dca41ffec61ceafb6f0e18 (diff)
feat: use pty-first agent detection
Diffstat (limited to 'src/pane.rs')
-rw-r--r--src/pane.rs576
1 files changed, 427 insertions, 149 deletions
diff --git a/src/pane.rs b/src/pane.rs
index 34775f00..1d9dd7d4 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -2,7 +2,7 @@ use std::cell::Cell;
use std::io;
use std::path::Path;
use std::sync::{
- atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering},
+ atomic::{AtomicBool, AtomicU16, AtomicU32, AtomicU64, Ordering},
Arc, Mutex,
};
@@ -81,7 +81,6 @@ async fn publish_state_changed_event(
agent: Option<Agent>,
state: AgentState,
visible_blocker: bool,
- visible_idle: bool,
visible_working: bool,
process_exited: bool,
observed_at: std::time::Instant,
@@ -95,7 +94,6 @@ async fn publish_state_changed_event(
agent,
state,
visible_blocker,
- visible_idle,
visible_working,
process_exited,
observed_at,
@@ -120,6 +118,9 @@ const PROCESS_ACQUISITION_FAST_RECHECK: std::time::Duration = std::time::Duratio
const PROCESS_ACQUISITION_SLOW_RECHECK: std::time::Duration = std::time::Duration::from_secs(2);
const PROCESS_ACQUISITION_IDLE_RESET: std::time::Duration = std::time::Duration::from_secs(2);
const STABLE_VISIBLE_SIGNAL_REFRESH: std::time::Duration = std::time::Duration::from_millis(800);
+const AGENT_PTY_ACTIVITY_WINDOW: std::time::Duration = std::time::Duration::from_secs(1);
+const AGENT_INPUT_TAINT_WINDOW: std::time::Duration = std::time::Duration::from_millis(1200);
+const AGENT_STARTUP_GRACE_WINDOW: std::time::Duration = std::time::Duration::from_secs(3);
#[derive(Debug, Clone, Copy)]
struct AgentDetectionPresence {
@@ -328,7 +329,6 @@ fn probe_foreground_process(pid: u32, foreground_pgid: Option<u32>) -> ProcessPr
struct DetectionPublishState {
state: AgentState,
visible_blocker: bool,
- visible_idle: bool,
visible_working: bool,
}
@@ -341,14 +341,10 @@ fn should_publish_detection_update(
) -> bool {
next.state != previous.state
|| next.visible_blocker != previous.visible_blocker
- || next.visible_idle != previous.visible_idle
|| next.visible_working != previous.visible_working
|| agent_changed
|| process_exited
- || (stable_visible_signal_refresh_due
- && ((next.visible_blocker && previous.visible_blocker)
- || (next.visible_idle && previous.visible_idle)
- || (next.visible_working && previous.visible_working)))
+ || (stable_visible_signal_refresh_due && next.visible_blocker && previous.visible_blocker)
}
fn stable_visible_signal_refresh_due(
@@ -357,11 +353,7 @@ fn stable_visible_signal_refresh_due(
last_refresh: Option<std::time::Instant>,
now: std::time::Instant,
) -> bool {
- // TODO: Evaluate expanding agent-specific visible_* predicates so more
- // detectors can use screen-authoritative refreshes safely.
- let stable_visible_signal = (next.visible_blocker && previous.visible_blocker)
- || (next.visible_idle && previous.visible_idle)
- || (next.visible_working && previous.visible_working);
+ let stable_visible_signal = next.visible_blocker && previous.visible_blocker;
stable_visible_signal
&& last_refresh.is_none_or(|last_refresh| {
@@ -383,7 +375,6 @@ fn detection_update_for_publish(
state: AgentState::Idle,
skip_state_update: false,
visible_blocker: false,
- visible_idle: false,
visible_working: false,
});
}
@@ -392,11 +383,71 @@ fn detection_update_for_publish(
(!detection.skip_state_update).then_some(detection)
}
+#[derive(Debug, Default)]
+struct PtyCausalityTracker {
+ last_pty_render_seq: u64,
+ last_input_seq: u64,
+ input_tainted_until: Option<std::time::Instant>,
+ last_agent_pty_at: Option<std::time::Instant>,
+}
+
+#[derive(Debug, Clone, Copy)]
+struct PtyActivitySignal {
+ active: bool,
+ tainted: bool,
+}
+
+fn baseline_pty_causality(tracker: &mut PtyCausalityTracker, pty_render_seq: u64, input_seq: u64) {
+ tracker.last_pty_render_seq = pty_render_seq;
+ tracker.last_input_seq = input_seq;
+ tracker.input_tainted_until = None;
+ tracker.last_agent_pty_at = None;
+}
+
+fn agent_caused_pty_activity_active(
+ pty_render_seq: u64,
+ input_seq: u64,
+ tracker: &mut PtyCausalityTracker,
+ now: std::time::Instant,
+) -> PtyActivitySignal {
+ if input_seq != tracker.last_input_seq {
+ tracker.last_input_seq = input_seq;
+ tracker.input_tainted_until = Some(now + AGENT_INPUT_TAINT_WINDOW);
+ tracker.last_agent_pty_at = None;
+ }
+
+ let tainted = tracker.input_tainted_until.is_some_and(|until| now < until);
+
+ if pty_render_seq != tracker.last_pty_render_seq {
+ tracker.last_pty_render_seq = pty_render_seq;
+ if !tainted {
+ tracker.last_agent_pty_at = Some(now);
+ }
+ }
+
+ if tainted {
+ return PtyActivitySignal {
+ active: false,
+ tainted: true,
+ };
+ }
+
+ let active = tracker
+ .last_agent_pty_at
+ .is_some_and(|last| now.duration_since(last) < AGENT_PTY_ACTIVITY_WINDOW);
+ PtyActivitySignal {
+ active,
+ tainted: false,
+ }
+}
+
#[cfg(unix)]
fn spawn_basic_detection_task(
pane_id: PaneId,
child_pid: Arc<AtomicU32>,
terminal: Arc<PaneTerminal>,
+ pty_render_seq: Arc<AtomicU64>,
+ input_write_seq: Arc<AtomicU64>,
state_events: mpsc::Sender<AppEvent>,
) -> (
tokio::task::AbortHandle,
@@ -412,17 +463,19 @@ fn spawn_basic_detection_task(
let mut agent_presence = AgentDetectionPresence::from_agent(None);
let mut state = AgentState::Unknown;
let mut last_visible_blocker = false;
- let mut last_visible_idle = false;
let mut last_visible_working = false;
let mut last_visible_signal_refresh = None;
- let mut last_screen_working_at = None;
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 last_content_change_at = None;
+ let mut pending_foreground_shell_clear = false;
+ let mut foreground_shell_exit_reported = false;
let mut release_was_active = false;
let mut last_detection_text = String::new();
+ let mut pty_causality = PtyCausalityTracker::default();
+ let mut agent_startup_grace_until = None;
loop {
tokio::select! {
@@ -431,17 +484,19 @@ fn spawn_basic_detection_task(
agent_presence = AgentDetectionPresence::from_agent(None);
state = AgentState::Unknown;
last_visible_blocker = false;
- last_visible_idle = false;
last_visible_working = false;
last_visible_signal_refresh = None;
- last_screen_working_at = None;
last_process_check = std::time::Instant::now();
last_foreground_pgid = None;
has_process_probe = false;
acquisition_started_at = None;
last_content_change_at = None;
+ pending_foreground_shell_clear = false;
+ foreground_shell_exit_reported = false;
release_was_active = false;
last_detection_text.clear();
+ pty_causality = PtyCausalityTracker::default();
+ agent_startup_grace_until = None;
}
}
@@ -456,27 +511,11 @@ fn spawn_basic_detection_task(
let pid = child_pid.load(Ordering::Acquire);
let mut agent_changed = false;
let mut agent = agent_presence.current_agent();
- let content = terminal.detection_text();
- let content_changed = content != last_detection_text;
- last_detection_text.clone_from(&content);
- if crate::detect::should_skip_state_update(agent, &content) {
- continue;
- }
-
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);
- sync_content_change_acquisition(
- agent_presence.current_agent(),
- suppressed_agent,
- process_group_changed,
- content_changed,
- now,
- &mut acquisition_started_at,
- &mut last_content_change_at,
- );
let should_check_process = pid > 0
&& should_probe_foreground_job(ProcessProbeInput {
current_agent: agent_presence.current_agent(),
@@ -486,7 +525,7 @@ fn spawn_basic_detection_task(
has_process_probe,
acquisition_age: acquisition_started_at
.map(|started| now.duration_since(started)),
- pending_foreground_shell_clear: false,
+ pending_foreground_shell_clear,
pending_restore_probe: false,
elapsed_since_process_check: now.duration_since(last_process_check),
});
@@ -496,6 +535,8 @@ fn spawn_basic_detection_task(
let had_process_probe = has_process_probe;
has_process_probe = true;
let probe = probe_foreground_process(pid, foreground_pgid);
+ 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) {
@@ -504,51 +545,157 @@ fn spawn_basic_detection_task(
*pending_release = None;
}
}
- if new_agent.is_none() {
- last_foreground_pgid = probe.process_group_id.or(foreground_pgid);
+ let previous_agent = agent_presence.current_agent();
+ let changed = match foreground_shell_agent_action(
+ previous_agent,
+ new_agent,
+ foreground_is_pane_shell,
+ foreground_shell_exit_reported,
+ ) {
+ ForegroundShellAgentAction::ReportProcessExit => {
+ pending_foreground_shell_clear = true;
+ false
+ }
+ ForegroundShellAgentAction::ClearAgent => {
+ pending_foreground_shell_clear = false;
+ foreground_shell_exit_reported = false;
+ agent_presence.clear_current_agent()
+ }
+ ForegroundShellAgentAction::ObserveProbe => {
+ pending_foreground_shell_clear = false;
+ foreground_shell_exit_reported = false;
+ agent_presence.observe_process_probe(new_agent)
+ }
+ };
+ if new_agent.is_some() {
+ last_foreground_pgid = process_group_id.or(foreground_pgid);
+ acquisition_started_at = None;
+ last_content_change_at = None;
+ } else if agent_presence.current_agent().is_none() {
+ last_foreground_pgid = 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;
- last_content_change_at = None;
+ last_foreground_pgid = process_group_id.or(foreground_pgid);
}
- let previous_agent = agent_presence.current_agent();
- if agent_presence.observe_process_probe(new_agent) {
+ if changed {
agent = agent_presence.current_agent();
agent_changed = previous_agent != agent;
if agent_changed {
- last_screen_working_at = None;
+ if agent.is_some() {
+ agent_startup_grace_until = Some(now + AGENT_STARTUP_GRACE_WINDOW);
+ baseline_pty_causality(
+ &mut pty_causality,
+ pty_render_seq.load(Ordering::Relaxed),
+ input_write_seq.load(Ordering::Relaxed),
+ );
+ state = AgentState::Idle;
+ last_visible_blocker = false;
+ last_visible_working = false;
+ last_visible_signal_refresh = None;
+ publish_state_changed_event(
+ state_events.clone(),
+ pane_id,
+ agent,
+ AgentState::Idle,
+ false,
+ false,
+ false,
+ now,
+ )
+ .await;
+ } else {
+ agent_startup_grace_until = None;
+ }
+ }
+ }
+ }
+
+ let process_exited = pending_foreground_shell_clear
+ && agent.is_some()
+ && !foreground_shell_exit_reported;
+
+ if let Some(until) = agent_startup_grace_until {
+ if process_exited {
+ agent_startup_grace_until = None;
+ } else {
+ if now < until {
+ continue;
}
+ baseline_pty_causality(
+ &mut pty_causality,
+ pty_render_seq.load(Ordering::Relaxed),
+ input_write_seq.load(Ordering::Relaxed),
+ );
+ agent_startup_grace_until = None;
+ continue;
}
}
- let Some(detection) = detection_update_for_publish(agent, &content, false) else {
+ let content = terminal.detection_text();
+ let content_changed = content != last_detection_text;
+ last_detection_text.clone_from(&content);
+ if !process_exited && crate::detect::should_skip_state_update(agent, &content) {
continue;
- };
- let new_state = crate::terminal::state::stabilize_agent_detection(
- agent,
- state,
- detection,
- false,
+ }
+ sync_content_change_acquisition(
+ agent_presence.current_agent(),
+ suppressed_agent,
+ process_group_changed,
+ content_changed,
now,
- &mut last_screen_working_at,
+ &mut acquisition_started_at,
+ &mut last_content_change_at,
);
+
+ let Some(screen_detection) =
+ detection_update_for_publish(agent, &content, process_exited)
+ else {
+ continue;
+ };
+ let pty_signal = if agent.is_some() {
+ let signal = agent_caused_pty_activity_active(
+ pty_render_seq.load(Ordering::Relaxed),
+ input_write_seq.load(Ordering::Relaxed),
+ &mut pty_causality,
+ now,
+ );
+ Some(crate::agent_detection_policy::PtySignal {
+ active: signal.active,
+ tainted: signal.tainted,
+ })
+ } else {
+ None
+ };
+ let detection = match crate::agent_detection_policy::apply_detection_policy(
+ crate::agent_detection_policy::DetectionPolicyInput {
+ agent,
+ screen_detection,
+ process_exited,
+ startup_grace_active: false,
+ pty_signal,
+ },
+ ) {
+ crate::agent_detection_policy::DetectionPolicyDecision::Publish(detection) => {
+ detection
+ }
+ crate::agent_detection_policy::DetectionPolicyDecision::Freeze => {
+ continue;
+ }
+ };
+ let new_state = crate::terminal::state::stabilize_agent_detection(detection);
let visible_blocker = detection.visible_blocker && new_state == AgentState::Blocked;
- let visible_idle = detection.visible_idle && new_state == AgentState::Idle;
let visible_working = detection.visible_working && new_state == AgentState::Working;
let previous_publish = DetectionPublishState {
state,
visible_blocker: last_visible_blocker,
- visible_idle: last_visible_idle,
visible_working: last_visible_working,
};
let next_publish = DetectionPublishState {
state: new_state,
visible_blocker,
- visible_idle,
visible_working,
};
let stable_refresh_due = stable_visible_signal_refresh_due(
@@ -562,14 +709,13 @@ fn spawn_basic_detection_task(
previous_publish,
next_publish,
agent_changed,
- false,
+ process_exited,
stable_refresh_due,
) {
state = new_state;
last_visible_blocker = visible_blocker;
- last_visible_idle = visible_idle;
last_visible_working = visible_working;
- if visible_blocker || visible_idle || visible_working {
+ if visible_blocker || visible_working {
last_visible_signal_refresh = Some(now);
} else {
last_visible_signal_refresh = None;
@@ -580,12 +726,14 @@ fn spawn_basic_detection_task(
agent,
new_state,
visible_blocker,
- visible_idle,
visible_working,
- false,
+ process_exited,
now,
)
.await;
+ if process_exited {
+ foreground_shell_exit_reported = true;
+ }
}
}
});
@@ -657,6 +805,7 @@ pub struct PaneRuntime {
reported_cwd: Arc<Mutex<Option<std::path::PathBuf>>>,
child_wait_completed: Option<Arc<AtomicBool>>,
kitty_keyboard_flags: Arc<AtomicU16>,
+ input_write_seq: Arc<AtomicU64>,
detect_reset_notify: Arc<Notify>,
pending_release: Arc<Mutex<Option<PendingAgentRelease>>>,
preserve_processes_on_drop: bool,
@@ -1399,12 +1548,15 @@ impl PaneRuntime {
let child_pid = Arc::new(AtomicU32::new(child_pid));
let reported_cwd = Arc::new(Mutex::new(None));
let kitty_keyboard_flags = Arc::new(AtomicU16::new(keyboard_protocol_flags));
+ let input_write_seq = Arc::new(AtomicU64::new(0));
+ let pty_render_seq = Arc::new(AtomicU64::new(0));
let io = {
let terminal = terminal.clone();
let response_writer = response_tx.clone();
let render_notify = render_notify.clone();
let render_dirty = render_dirty.clone();
+ let pty_render_seq = pty_render_seq.clone();
let child_pid = child_pid.clone();
let read_events = events.clone();
let reported_cwd = reported_cwd.clone();
@@ -1414,8 +1566,11 @@ impl PaneRuntime {
let shell_pid = child_pid.load(Ordering::Acquire);
let result =
terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer);
- if result.request_render && !render_dirty.swap(true, Ordering::AcqRel) {
- render_notify.notify_one();
+ if result.request_render {
+ pty_render_seq.fetch_add(1, Ordering::Relaxed);
+ if !render_dirty.swap(true, Ordering::AcqRel) {
+ render_notify.notify_one();
+ }
}
if let Some(delay) = result.render_delay {
let render_notify = render_notify.clone();
@@ -1457,8 +1612,14 @@ impl PaneRuntime {
})?)
};
- let (detect_handle, detect_reset_notify, pending_release) =
- spawn_basic_detection_task(pane_id, child_pid.clone(), terminal.clone(), events);
+ let (detect_handle, detect_reset_notify, pending_release) = spawn_basic_detection_task(
+ pane_id,
+ child_pid.clone(),
+ terminal.clone(),
+ pty_render_seq,
+ input_write_seq.clone(),
+ events,
+ );
Ok(Self {
pane_id,
@@ -1469,6 +1630,7 @@ impl PaneRuntime {
reported_cwd,
child_wait_completed: None,
kitty_keyboard_flags,
+ input_write_seq,
detect_reset_notify,
pending_release,
preserve_processes_on_drop: true,
@@ -1517,6 +1679,8 @@ impl PaneRuntime {
let child_pid = Arc::new(AtomicU32::new(0));
let reported_cwd = Arc::new(Mutex::new(None));
let child_wait_completed = Arc::new(AtomicBool::new(false));
+ let input_write_seq = Arc::new(AtomicU64::new(0));
+ let pty_render_seq = Arc::new(AtomicU64::new(0));
{
let child_pid = child_pid.clone();
let child_wait_completed = child_wait_completed.clone();
@@ -1548,6 +1712,7 @@ impl PaneRuntime {
let response_writer = response_tx.clone();
let render_notify = render_notify.clone();
let render_dirty = render_dirty.clone();
+ let pty_render_seq = pty_render_seq.clone();
let child_pid = child_pid.clone();
let events = events.clone();
let reported_cwd = reported_cwd.clone();
@@ -1556,8 +1721,11 @@ impl PaneRuntime {
let shell_pid = child_pid.load(Ordering::Acquire);
let result =
terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer);
- if result.request_render && !render_dirty.swap(true, Ordering::AcqRel) {
- render_notify.notify_one();
+ if result.request_render {
+ pty_render_seq.fetch_add(1, Ordering::Relaxed);
+ if !render_dirty.swap(true, Ordering::AcqRel) {
+ render_notify.notify_one();
+ }
}
if let Some(delay) = result.render_delay {
let render_notify = render_notify.clone();
@@ -1609,6 +1777,8 @@ impl PaneRuntime {
let child_pid = child_pid.clone();
let terminal = terminal.clone();
let state_events = events.clone();
+ let pty_render_seq = pty_render_seq.clone();
+ let input_write_seq_for_task = input_write_seq.clone();
let render_notify = render_notify.clone();
let render_dirty = render_dirty.clone();
let detect_reset_notify = Arc::new(Notify::new());
@@ -1619,11 +1789,7 @@ impl PaneRuntime {
let handle = tokio::spawn(async move {
let mut agent_presence =
AgentDetectionPresence::from_agent(initial_state.detected_agent);
- let mut state = if initial_state.detected_agent.is_some() {
- AgentState::Idle
- } else {
- AgentState::Unknown
- };
+ let mut state = AgentState::Idle;
let mut last_process_check = Instant::now();
let mut last_foreground_pgid = None;
let mut has_process_probe = false;
@@ -1633,12 +1799,12 @@ impl PaneRuntime {
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_screen_working_at = None;
let mut last_visible_blocker = false;
- let mut last_visible_idle = false;
let mut last_visible_working = false;
let mut last_visible_signal_refresh = None;
let mut last_detection_text = String::new();
+ let mut pty_causality = PtyCausalityTracker::default();
+ let mut agent_startup_grace_until = None;
tokio::time::sleep(Duration::from_millis(50)).await;
@@ -1666,12 +1832,12 @@ impl PaneRuntime {
foreground_shell_exit_reported = false;
release_was_active = false;
pending_restore_probe = false;
- last_screen_working_at = None;
last_visible_blocker = false;
- last_visible_idle = false;
last_visible_working = false;
last_visible_signal_refresh = None;
last_detection_text.clear();
+ pty_causality = PtyCausalityTracker::default();
+ agent_startup_grace_until = None;
}
}
@@ -1684,26 +1850,11 @@ impl PaneRuntime {
}
release_was_active = suppressed_agent.is_some();
let pid = child_pid.load(Ordering::Acquire);
- let content = terminal.detection_text();
- let content_changed = content != last_detection_text;
- last_detection_text.clone_from(&content);
- if detect::should_skip_state_update(agent_presence.current_agent(), &content) {
- continue;
- }
let foreground_pgid = (pid > 0)
.then(|| detect::foreground_process_group_id(pid))
.flatten();
let process_group_changed =
foreground_group_changed(foreground_pgid, last_foreground_pgid);
- sync_content_change_acquisition(
- agent_presence.current_agent(),
- suppressed_agent,
- process_group_changed,
- content_changed,
- now,
- &mut acquisition_started_at,
- &mut last_content_change_at,
- );
let should_check_process = pid > 0
&& should_probe_foreground_job(ProcessProbeInput {
current_agent: agent_presence.current_agent(),
@@ -1780,7 +1931,32 @@ impl PaneRuntime {
if changed {
agent = agent_presence.current_agent();
if agent != previous_agent {
- last_screen_working_at = None;
+ if agent.is_some() {
+ agent_startup_grace_until =
+ Some(now + AGENT_STARTUP_GRACE_WINDOW);
+ baseline_pty_causality(
+ &mut pty_causality,
+ pty_render_seq.load(Ordering::Relaxed),
+ input_write_seq_for_task.load(Ordering::Relaxed),
+ );
+ state = AgentState::Idle;
+ last_visible_blocker = false;
+ last_visible_working = false;
+ last_visible_signal_refresh = None;
+ publish_state_changed_event(
+ state_events.clone(),
+ pane_id,
+ agent,
+ AgentState::Idle,
+ false,
+ false,
+ false,
+ now,
+ )
+ .await;
+ } else {
+ agent_startup_grace_until = None;
+ }
}
if let Some(process_name) = process_name {
info!(
@@ -1817,36 +1993,89 @@ impl PaneRuntime {
let process_exited = pending_foreground_shell_clear
&& agent.is_some()
&& !foreground_shell_exit_reported;
- let Some(detection) =
+
+ if let Some(until) = agent_startup_grace_until {
+ if process_exited {
+ agent_startup_grace_until = None;
+ } else {
+ if now < until {
+ continue;
+ }
+ baseline_pty_causality(
+ &mut pty_causality,
+ pty_render_seq.load(Ordering::Relaxed),
+ input_write_seq_for_task.load(Ordering::Relaxed),
+ );
+ agent_startup_grace_until = None;
+ continue;
+ }
+ }
+
+ let content = terminal.detection_text();
+ let content_changed = content != last_detection_text;
+ last_detection_text.clone_from(&content);
+ if detect::should_skip_state_update(agent, &content) {
+ continue;
+ }
+ sync_content_change_acquisition(
+ agent_presence.current_agent(),
+ suppressed_agent,
+ process_group_changed,
+ content_changed,
+ now,
+ &mut acquisition_started_at,
+ &mut last_content_change_at,
+ );
+
+ let Some(screen_detection) =
detection_update_for_publish(agent, &content, process_exited)
else {
continue;
};
- let raw_state = detection.state;
- let new_state = crate::terminal::state::stabilize_agent_detection(
- agent,
- state,
- detection,
- process_exited,
- now,
- &mut last_screen_working_at,
- );
+ let pty_signal = if agent.is_some() {
+ let signal = agent_caused_pty_activity_active(
+ pty_render_seq.load(Ordering::Relaxed),
+ input_write_seq_for_task.load(Ordering::Relaxed),
+ &mut pty_causality,
+ now,
+ );
+ Some(crate::agent_detection_policy::PtySignal {
+ active: signal.active,
+ tainted: signal.tainted,
+ })
+ } else {
+ None
+ };
+ let detection = match crate::agent_detection_policy::apply_detection_policy(
+ crate::agent_detection_policy::DetectionPolicyInput {
+ agent,
+ screen_detection,
+ process_exited,
+ startup_grace_active: false,
+ pty_signal,
+ },
+ ) {
+ crate::agent_detection_policy::DetectionPolicyDecision::Publish(
+ detection,
+ ) => detection,
+ crate::agent_detection_policy::DetectionPolicyDecision::Freeze => {
+ continue;
+ }
+ };
+ let new_state = crate::terminal::state::stabilize_agent_detection(detection);
let visible_blocker =
detection.visible_blocker && new_state == AgentState::Blocked;
- let visible_idle = detection.visible_idle && new_state == AgentState::Idle;
let visible_working =
detection.visible_working && new_state == AgentState::Working;
let previous_publish = DetectionPublishState {
state,
visible_blocker: last_visible_blocker,
- visible_idle: last_visible_idle,
visible_working: last_visible_working,
};
let next_publish = DetectionPublishState {
state: new_state,
visible_blocker,
- visible_idle,
visible_working,
};
let stable_refresh_due = stable_visible_signal_refresh_due(
@@ -1863,19 +2092,10 @@ impl PaneRuntime {
process_exited,
stable_refresh_due,
) {
- debug!(
- pane = pane_id.raw(),
- ?state,
- ?raw_state,
- ?new_state,
- ?agent,
- "state changed"
- );
state = new_state;
last_visible_blocker = visible_blocker;
- last_visible_idle = visible_idle;
last_visible_working = visible_working;
- if visible_blocker || visible_idle || visible_working {
+ if visible_blocker || visible_working {
last_visible_signal_refresh = Some(now);
} else {
last_visible_signal_refresh = None;
@@ -1886,7 +2106,6 @@ impl PaneRuntime {
agent,
new_state,
visible_blocker,
- visible_idle,
visible_working,
process_exited,
now,
@@ -1910,6 +2129,7 @@ impl PaneRuntime {
reported_cwd,
child_wait_completed: Some(child_wait_completed),
kitty_keyboard_flags,
+ input_write_seq,
detect_reset_notify,
pending_release,
preserve_processes_on_drop: false,
@@ -1940,6 +2160,7 @@ impl PaneRuntime {
if self.current_size.get() == size {
return;
}
+ self.input_write_seq.fetch_add(1, Ordering::Relaxed);
self.current_size.set(size);
let terminal_responses = self
.terminal
@@ -1955,6 +2176,7 @@ impl PaneRuntime {
#[cfg(unix)]
pub fn nudge_child_redraw_after_handoff(&self) {
+ self.input_write_seq.fetch_add(1, Ordering::Relaxed);
let (rows, cols, cell_width_px, cell_height_px) = self.current_size.get();
self.io
.nudge_child_redraw_after_handoff(rows, cols, cell_width_px, cell_height_px);
@@ -2081,11 +2303,19 @@ impl PaneRuntime {
}
pub async fn send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::SendError<Bytes>> {
- self.io.send_bytes(bytes).await
+ let result = self.io.send_bytes(bytes).await;
+ if result.is_ok() {
+ self.input_write_seq.fetch_add(1, Ordering::Relaxed);
+ }
+ result
}
pub fn try_send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::TrySendError<Bytes>> {
- self.io.try_send_bytes(bytes)
+ let result = self.io.try_send_bytes(bytes);
+ if result.is_ok() {
+ self.input_write_seq.fetch_add(1, Ordering::Relaxed);
+ }
+ result
}
pub async fn send_paste(&self, text: String) -> Result<(), mpsc::error::SendError<Bytes>> {
@@ -2283,6 +2513,7 @@ impl PaneRuntime {
reported_cwd: Arc::new(Mutex::new(None)),
child_wait_completed: None,
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
+ input_write_seq: Arc::new(AtomicU64::new(0)),
detect_reset_notify: Arc::new(Notify::new()),
pending_release: Arc::new(Mutex::new(None)),
preserve_processes_on_drop: true,
@@ -2655,6 +2886,7 @@ mod tests {
reported_cwd: Arc::new(Mutex::new(None)),
child_wait_completed: None,
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
+ input_write_seq: Arc::new(AtomicU64::new(0)),
detect_reset_notify: Arc::new(Notify::new()),
pending_release: Arc::new(Mutex::new(None)),
preserve_processes_on_drop: true,
@@ -2684,6 +2916,7 @@ mod tests {
reported_cwd: Arc::new(Mutex::new(None)),
child_wait_completed: None,
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
+ input_write_seq: Arc::new(AtomicU64::new(0)),
detect_reset_notify: Arc::new(Notify::new()),
pending_release: Arc::new(Mutex::new(None)),
preserve_processes_on_drop: true,
@@ -2744,37 +2977,11 @@ mod tests {
}
#[test]
- fn stable_visible_idle_republishes_for_stale_hook_deadline() {
- let now = std::time::Instant::now();
- let previous = DetectionPublishState {
- state: AgentState::Idle,
- visible_blocker: false,
- visible_idle: true,
- visible_working: false,
- };
- let refresh_due = stable_visible_signal_refresh_due(
- previous,
- previous,
- Some(now - STABLE_VISIBLE_SIGNAL_REFRESH),
- now,
- );
-
- assert!(should_publish_detection_update(
- previous,
- previous,
- false,
- false,
- refresh_due
- ));
- }
-
- #[test]
fn stable_plain_idle_does_not_republish() {
let now = std::time::Instant::now();
let previous = DetectionPublishState {
state: AgentState::Idle,
visible_blocker: false,
- visible_idle: false,
visible_working: false,
};
let refresh_due = stable_visible_signal_refresh_due(
@@ -2794,12 +3001,11 @@ mod tests {
}
#[test]
- fn stable_visible_working_republishes_for_hook_override_refresh() {
+ fn stable_visible_working_does_not_republish() {
let now = std::time::Instant::now();
let previous = DetectionPublishState {
state: AgentState::Working,
visible_blocker: false,
- visible_idle: false,
visible_working: true,
};
let refresh_due = stable_visible_signal_refresh_due(
@@ -2809,7 +3015,7 @@ mod tests {
now,
);
- assert!(should_publish_detection_update(
+ assert!(!should_publish_detection_update(
previous,
previous,
false,
@@ -2824,7 +3030,6 @@ mod tests {
let previous = DetectionPublishState {
state: AgentState::Blocked,
visible_blocker: true,
- visible_idle: false,
visible_working: false,
};
let refresh_due = stable_visible_signal_refresh_due(
@@ -3220,6 +3425,81 @@ mod tests {
assert_eq!(presence.current_agent(), None);
}
+ #[test]
+ fn pty_activity_outside_taint_reports_active_until_hold_expires() {
+ let now = std::time::Instant::now();
+ let mut tracker = PtyCausalityTracker::default();
+ baseline_pty_causality(&mut tracker, 1, 1);
+
+ let active = agent_caused_pty_activity_active(2, 1, &mut tracker, now);
+ assert!(active.active);
+ assert!(!active.tainted);
+
+ let held = agent_caused_pty_activity_active(
+ 2,
+ 1,
+ &mut tracker,
+ now + AGENT_PTY_ACTIVITY_WINDOW - std::time::Duration::from_millis(1),
+ );
+ assert!(held.active);
+ assert!(!held.tainted);
+
+ let expired = agent_caused_pty_activity_active(
+ 2,
+ 1,
+ &mut tracker,
+ now + AGENT_PTY_ACTIVITY_WINDOW + std::time::Duration::from_millis(1),
+ );
+ assert!(!expired.active);
+ assert!(!expired.tainted);
+ }
+
+ #[test]
+ fn input_taint_discards_pty_activity_until_fresh_post_taint_render() {
+ let now = std::time::Instant::now();
+ let mut tracker = PtyCausalityTracker::default();
+ baseline_pty_causality(&mut tracker, 1, 1);
+
+ let tainted = agent_caused_pty_activity_active(2, 2, &mut tracker, now);
+ assert!(!tainted.active);
+ assert!(tainted.tainted);
+
+ let after_taint = agent_caused_pty_activity_active(
+ 2,
+ 2,
+ &mut tracker,
+ now + AGENT_INPUT_TAINT_WINDOW + std::time::Duration::from_millis(1),
+ );
+ assert!(!after_taint.active);
+ assert!(!after_taint.tainted);
+
+ let fresh_render = agent_caused_pty_activity_active(
+ 3,
+ 2,
+ &mut tracker,
+ now + AGENT_INPUT_TAINT_WINDOW + std::time::Duration::from_millis(2),
+ );
+ assert!(fresh_render.active);
+ assert!(!fresh_render.tainted);
+ }
+
+ #[test]
+ fn startup_grace_rebaseline_discards_accumulated_pty_activity() {
+ let now = std::time::Instant::now();
+ let mut tracker = PtyCausalityTracker::default();
+ baseline_pty_causality(&mut tracker, 1, 1);
+
+ let startup_render = agent_caused_pty_activity_active(2, 1, &mut tracker, now);
+ assert!(startup_render.active);
+
+ baseline_pty_causality(&mut tracker, 2, 1);
+ let after_grace =
+ agent_caused_pty_activity_active(2, 1, &mut tracker, now + AGENT_STARTUP_GRACE_WINDOW);
+
+ assert!(!after_grace.active);
+ assert!(!after_grace.tainted);
+ }
+
#[tokio::test]
async fn state_changed_event_waits_for_queue_space_instead_of_dropping() {
let (tx, mut rx) = mpsc::channel(1);
@@ -3239,7 +3519,6 @@ mod tests {
false,
false,
false,
- false,
std::time::Instant::now(),
);
tokio::pin!(publish);
@@ -3276,7 +3555,6 @@ mod tests {
agent: Some(Agent::Pi),
state: AgentState::Idle,
visible_blocker: false,
- visible_idle: false,
visible_working: false,
process_exited: false,
observed_at: _,