fix: remove pty taint from agent detection

This commit is contained in:
Ogulcan Celik 2026-06-10 21:00:56 +03:00
parent 9e0dd9412f
commit 0efd8eaad1
4 changed files with 128 additions and 1524 deletions

View File

@ -4,6 +4,7 @@
### Fixed
- Agent state detection for non-authoritative agents now comes from screen manifests instead of PTY-first semantic arbitration, so terminal output activity no longer publishes `working`, vetoes visible blockers, or decides idle fallback.
- Removed the remaining PTY input-taint debounce from agent detection, so user input, pane resizes, and redraw nudges no longer delay screen/OSC manifest state updates.
- Numeric keypad keys that send VT100 application-keypad escape sequences now enter their digits and operators instead of being dropped. (#493)
- Codex panes now stay marked working when the live status header uses reasoning-summary text such as `Investigating code output` instead of the literal `Working` label. (#501)
- Native pane URL clicks now use Cmd-click on macOS and Ctrl-click on other platforms.

View File

@ -32,12 +32,11 @@ mod terminal;
mod xtgettcap;
use self::agent_detection::{
agent_caused_pty_activity_active, baseline_pty_causality, decide_detection_screen_read,
decide_screen_detection_publish, detection_update_for_publish_with_osc,
handle_skipped_detection_update, observe_pty_output_activity, DetectionPublishDecision,
DetectionScreenReadDecision, DetectionScreenReadInput, PendingIdleConfirmation,
PendingWorkingConfirmation, PostTaintWorkingLease, PtyCausalityTracker,
ScreenDetectionPublishInput, AGENT_PENDING_IDLE_RECHECK, AGENT_STARTUP_GRACE_WINDOW,
decide_detection_screen_read, decide_screen_detection_publish,
detection_update_for_publish_with_osc, mark_detection_content_changed,
observe_detection_content_change, DetectionPublishDecision, DetectionScreenReadDecision,
DetectionScreenReadInput, PendingIdleConfirmation, ScreenDetectionPublishInput,
AGENT_PENDING_IDLE_RECHECK, AGENT_STARTUP_GRACE_WINDOW,
};
use self::terminal::{GhosttyPaneTerminal, PaneTerminal};
pub(crate) use self::terminal::{TerminalDirtyPatch, TerminalDirtyPatchOutcome};
@ -384,8 +383,7 @@ fn spawn_basic_detection_task(
pane_id: PaneId,
child_pid: Arc<AtomicU32>,
terminal: Arc<PaneTerminal>,
pty_output_seq: Arc<AtomicU64>,
input_write_seq: Arc<AtomicU64>,
detection_content_seq: Arc<AtomicU64>,
full_lifecycle_authority_active: Arc<AtomicBool>,
state_events: mpsc::Sender<AppEvent>,
) -> (
@ -414,18 +412,12 @@ fn spawn_basic_detection_task(
let mut foreground_shell_exit_reported = false;
let mut release_was_active = false;
let mut last_detection_text = String::new();
let mut last_screen_scan_pty_output_seq = None;
let mut pty_causality = PtyCausalityTracker::default();
let mut last_screen_scan_detection_content_seq = None;
let mut agent_startup_grace_until = None;
let mut pending_idle = PendingIdleConfirmation::default();
let mut pending_working = PendingWorkingConfirmation::default();
let mut post_taint_working = PostTaintWorkingLease::default();
loop {
let now_for_sleep = std::time::Instant::now();
let sleep_duration = if pending_working.active() {
pending_working.recheck_delay(now_for_sleep)
} else if pending_idle.active() {
let sleep_duration = if pending_idle.active() {
AGENT_PENDING_IDLE_RECHECK
} else {
std::time::Duration::from_millis(300)
@ -448,12 +440,9 @@ fn spawn_basic_detection_task(
foreground_shell_exit_reported = false;
release_was_active = false;
last_detection_text.clear();
last_screen_scan_pty_output_seq = None;
pty_causality = PtyCausalityTracker::default();
last_screen_scan_detection_content_seq = None;
agent_startup_grace_until = None;
pending_idle.clear();
pending_working.clear();
post_taint_working.clear();
}
}
@ -541,19 +530,12 @@ fn spawn_basic_detection_task(
agent_changed = previous_agent != agent;
if agent_changed {
pending_idle.clear();
pending_working.clear();
post_taint_working.clear();
last_screen_scan_pty_output_seq = None;
last_screen_scan_detection_content_seq = None;
// A new foreground agent must not inherit OSC
// title/progress evidence from the previous process.
terminal.clear_agent_osc_state();
if agent.is_some() {
agent_startup_grace_until = Some(now + AGENT_STARTUP_GRACE_WINDOW);
baseline_pty_causality(
&mut pty_causality,
pty_output_seq.load(Ordering::Relaxed),
input_write_seq.load(Ordering::Relaxed),
);
state = AgentState::Idle;
last_visible_idle = true;
last_visible_blocker = false;
@ -583,8 +565,6 @@ fn spawn_basic_detection_task(
if full_lifecycle_authority_active.load(Ordering::Acquire) && !process_exited {
pending_idle.clear();
pending_working.clear();
post_taint_working.clear();
continue;
}
@ -592,36 +572,20 @@ fn spawn_basic_detection_task(
if process_exited {
agent_startup_grace_until = None;
pending_idle.clear();
pending_working.clear();
post_taint_working.clear();
} else {
if now < until {
pending_idle.clear();
pending_working.clear();
post_taint_working.clear();
continue;
}
baseline_pty_causality(
&mut pty_causality,
pty_output_seq.load(Ordering::Relaxed),
input_write_seq.load(Ordering::Relaxed),
);
agent_startup_grace_until = None;
last_screen_scan_pty_output_seq = None;
last_screen_scan_detection_content_seq = None;
pending_idle.clear();
pending_working.clear();
post_taint_working.clear();
continue;
}
}
let pty_activity = if agent.is_some() {
Some(agent_caused_pty_activity_active(
pty_output_seq.load(Ordering::Relaxed),
input_write_seq.load(Ordering::Relaxed),
&mut pty_causality,
now,
))
let current_detection_content_seq = if agent.is_some() {
Some(detection_content_seq.load(Ordering::Relaxed))
} else {
None
};
@ -629,33 +593,21 @@ fn spawn_basic_detection_task(
state,
agent,
pending_idle_active: pending_idle.active(),
pending_working_active: pending_working.active(),
post_taint_working_active: post_taint_working.active(),
agent_changed,
process_exited,
pty_activity,
last_screen_scan_pty_output_seq,
current_detection_content_seq,
last_screen_scan_detection_content_seq,
}) {
DetectionScreenReadDecision::Read => {}
DetectionScreenReadDecision::Skip => continue,
}
let content = terminal.detection_text();
last_screen_scan_pty_output_seq = pty_activity.map(|signal| signal.output_seq);
last_screen_scan_detection_content_seq = current_detection_content_seq;
let content_changed = content != last_detection_text;
last_detection_text.clone_from(&content);
if !process_exited && crate::detect::should_skip_state_update(agent, &content) {
handle_skipped_detection_update(
state,
pty_activity,
&mut post_taint_working,
&mut pty_causality,
pty_output_seq.load(Ordering::Relaxed),
input_write_seq.load(Ordering::Relaxed),
now,
);
pending_idle.clear();
pending_working.clear();
continue;
}
sync_content_change_acquisition(
@ -677,17 +629,7 @@ fn spawn_basic_detection_task(
&osc_progress,
process_exited,
) else {
handle_skipped_detection_update(
state,
pty_activity,
&mut post_taint_working,
&mut pty_causality,
pty_output_seq.load(Ordering::Relaxed),
input_write_seq.load(Ordering::Relaxed),
now,
);
pending_idle.clear();
pending_working.clear();
continue;
};
match decide_screen_detection_publish(
@ -700,12 +642,9 @@ fn spawn_basic_detection_task(
last_visible_signal_refresh,
process_exited,
agent_changed,
pty_activity,
now,
},
&mut pending_idle,
&mut pending_working,
&mut post_taint_working,
) {
DetectionPublishDecision::NoPublish => {}
DetectionPublishDecision::Publish {
@ -807,7 +746,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>,
detection_content_seq: Arc<AtomicU64>,
full_lifecycle_authority_active: Arc<AtomicBool>,
detect_reset_notify: Arc<Notify>,
pending_release: Arc<Mutex<Option<PendingAgentRelease>>>,
@ -1551,25 +1490,24 @@ 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_output_seq = Arc::new(AtomicU64::new(0));
let detection_content_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_output_seq = pty_output_seq.clone();
let detection_content_seq = detection_content_seq.clone();
let child_pid = child_pid.clone();
let read_events = events.clone();
let reported_cwd = reported_cwd.clone();
let rt = tokio::runtime::Handle::current();
let delay_rt = rt.clone();
let on_read = Box::new(move |bytes: &[u8]| {
observe_pty_output_activity(bytes, &pty_output_seq);
let shell_pid = child_pid.load(Ordering::Acquire);
let result =
terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer);
observe_detection_content_change(bytes, &detection_content_seq);
if result.request_render && !render_dirty.swap(true, Ordering::AcqRel) {
render_notify.notify_one();
}
@ -1618,8 +1556,7 @@ impl PaneRuntime {
pane_id,
child_pid.clone(),
terminal.clone(),
pty_output_seq,
input_write_seq.clone(),
detection_content_seq.clone(),
full_lifecycle_authority_active.clone(),
events,
);
@ -1633,7 +1570,7 @@ impl PaneRuntime {
reported_cwd,
child_wait_completed: None,
kitty_keyboard_flags,
input_write_seq,
detection_content_seq,
full_lifecycle_authority_active,
detect_reset_notify,
pending_release,
@ -1683,8 +1620,7 @@ 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_output_seq = Arc::new(AtomicU64::new(0));
let detection_content_seq = Arc::new(AtomicU64::new(0));
let full_lifecycle_authority_active = Arc::new(AtomicBool::new(false));
{
let child_pid = child_pid.clone();
@ -1717,16 +1653,16 @@ impl PaneRuntime {
let response_writer = response_tx.clone();
let render_notify = render_notify.clone();
let render_dirty = render_dirty.clone();
let pty_output_seq = pty_output_seq.clone();
let detection_content_seq = detection_content_seq.clone();
let child_pid = child_pid.clone();
let events = events.clone();
let reported_cwd = reported_cwd.clone();
let rt = tokio::runtime::Handle::current();
let on_read = Box::new(move |bytes: &[u8]| {
observe_pty_output_activity(bytes, &pty_output_seq);
let shell_pid = child_pid.load(Ordering::Acquire);
let result =
terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer);
observe_detection_content_change(bytes, &detection_content_seq);
if result.request_render && !render_dirty.swap(true, Ordering::AcqRel) {
render_notify.notify_one();
}
@ -1780,8 +1716,7 @@ impl PaneRuntime {
let child_pid = child_pid.clone();
let terminal = terminal.clone();
let state_events = events.clone();
let pty_output_seq = pty_output_seq.clone();
let input_write_seq_for_task = input_write_seq.clone();
let detection_content_seq = detection_content_seq.clone();
let full_lifecycle_authority_active_for_task = full_lifecycle_authority_active.clone();
let render_notify = render_notify.clone();
let render_dirty = render_dirty.clone();
@ -1808,12 +1743,9 @@ impl PaneRuntime {
let mut last_visible_working = false;
let mut last_visible_signal_refresh = None;
let mut last_detection_text = String::new();
let mut last_screen_scan_pty_output_seq = None;
let mut pty_causality = PtyCausalityTracker::default();
let mut last_screen_scan_detection_content_seq = None;
let mut agent_startup_grace_until = None;
let mut pending_idle = PendingIdleConfirmation::default();
let mut pending_working = PendingWorkingConfirmation::default();
let mut post_taint_working = PostTaintWorkingLease::default();
tokio::time::sleep(Duration::from_millis(50)).await;
@ -1824,8 +1756,6 @@ impl PaneRuntime {
|| terminal.has_transient_default_color_override()
{
TICK_PENDING_RELEASE
} else if pending_working.active() {
pending_working.recheck_delay(now_for_tick)
} else if pending_idle.active() {
AGENT_PENDING_IDLE_RECHECK
} else if agent_presence.current_agent().is_none() {
@ -1851,12 +1781,9 @@ impl PaneRuntime {
last_visible_working = false;
last_visible_signal_refresh = None;
last_detection_text.clear();
last_screen_scan_pty_output_seq = None;
pty_causality = PtyCausalityTracker::default();
last_screen_scan_detection_content_seq = None;
agent_startup_grace_until = None;
pending_idle.clear();
pending_working.clear();
post_taint_working.clear();
}
}
@ -1951,20 +1878,13 @@ impl PaneRuntime {
agent = agent_presence.current_agent();
if agent != previous_agent {
pending_idle.clear();
pending_working.clear();
post_taint_working.clear();
last_screen_scan_pty_output_seq = None;
last_screen_scan_detection_content_seq = None;
// A new foreground agent must not inherit OSC
// title/progress evidence from the previous process.
terminal.clear_agent_osc_state();
if agent.is_some() {
agent_startup_grace_until =
Some(now + AGENT_STARTUP_GRACE_WINDOW);
baseline_pty_causality(
&mut pty_causality,
pty_output_seq.load(Ordering::Relaxed),
input_write_seq_for_task.load(Ordering::Relaxed),
);
state = AgentState::Idle;
last_visible_idle = true;
last_visible_blocker = false;
@ -2025,45 +1945,27 @@ impl PaneRuntime {
&& !process_exited
{
pending_idle.clear();
pending_working.clear();
post_taint_working.clear();
continue;
}
if let Some(until) = agent_startup_grace_until {
if process_exited {
agent_startup_grace_until = None;
last_screen_scan_pty_output_seq = None;
last_screen_scan_detection_content_seq = None;
pending_idle.clear();
pending_working.clear();
post_taint_working.clear();
} else {
if now < until {
pending_idle.clear();
pending_working.clear();
post_taint_working.clear();
continue;
}
baseline_pty_causality(
&mut pty_causality,
pty_output_seq.load(Ordering::Relaxed),
input_write_seq_for_task.load(Ordering::Relaxed),
);
agent_startup_grace_until = None;
pending_idle.clear();
pending_working.clear();
post_taint_working.clear();
continue;
}
}
let pty_activity = if agent.is_some() {
Some(agent_caused_pty_activity_active(
pty_output_seq.load(Ordering::Relaxed),
input_write_seq_for_task.load(Ordering::Relaxed),
&mut pty_causality,
now,
))
let current_detection_content_seq = if agent.is_some() {
Some(detection_content_seq.load(Ordering::Relaxed))
} else {
None
};
@ -2071,33 +1973,21 @@ impl PaneRuntime {
state,
agent,
pending_idle_active: pending_idle.active(),
pending_working_active: pending_working.active(),
post_taint_working_active: post_taint_working.active(),
agent_changed,
process_exited,
pty_activity,
last_screen_scan_pty_output_seq,
current_detection_content_seq,
last_screen_scan_detection_content_seq,
}) {
DetectionScreenReadDecision::Read => {}
DetectionScreenReadDecision::Skip => continue,
}
let content = terminal.detection_text();
last_screen_scan_pty_output_seq = pty_activity.map(|signal| signal.output_seq);
last_screen_scan_detection_content_seq = current_detection_content_seq;
let content_changed = content != last_detection_text;
last_detection_text.clone_from(&content);
if detect::should_skip_state_update(agent, &content) {
handle_skipped_detection_update(
state,
pty_activity,
&mut post_taint_working,
&mut pty_causality,
pty_output_seq.load(Ordering::Relaxed),
input_write_seq_for_task.load(Ordering::Relaxed),
now,
);
pending_idle.clear();
pending_working.clear();
continue;
}
sync_content_change_acquisition(
@ -2119,17 +2009,7 @@ impl PaneRuntime {
&osc_progress,
process_exited,
) else {
handle_skipped_detection_update(
state,
pty_activity,
&mut post_taint_working,
&mut pty_causality,
pty_output_seq.load(Ordering::Relaxed),
input_write_seq_for_task.load(Ordering::Relaxed),
now,
);
pending_idle.clear();
pending_working.clear();
continue;
};
match decide_screen_detection_publish(
@ -2142,12 +2022,9 @@ impl PaneRuntime {
last_visible_signal_refresh,
process_exited,
agent_changed,
pty_activity,
now,
},
&mut pending_idle,
&mut pending_working,
&mut post_taint_working,
) {
DetectionPublishDecision::NoPublish => {}
DetectionPublishDecision::Publish {
@ -2193,7 +2070,7 @@ impl PaneRuntime {
reported_cwd,
child_wait_completed: Some(child_wait_completed),
kitty_keyboard_flags,
input_write_seq,
detection_content_seq,
full_lifecycle_authority_active,
detect_reset_notify,
pending_release,
@ -2242,11 +2119,11 @@ 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
.resize(rows, cols, cell_width_px, cell_height_px);
mark_detection_content_changed(&self.detection_content_seq);
self.io.resize(
rows,
cols,
@ -2258,7 +2135,6 @@ 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);
@ -2397,19 +2273,11 @@ impl PaneRuntime {
}
pub async fn send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::SendError<Bytes>> {
let result = self.io.send_bytes(bytes).await;
if result.is_ok() {
self.input_write_seq.fetch_add(1, Ordering::Relaxed);
}
result
self.io.send_bytes(bytes).await
}
pub fn try_send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::TrySendError<Bytes>> {
let result = self.io.try_send_bytes(bytes);
if result.is_ok() {
self.input_write_seq.fetch_add(1, Ordering::Relaxed);
}
result
self.io.try_send_bytes(bytes)
}
pub async fn send_paste(&self, text: String) -> Result<(), mpsc::error::SendError<Bytes>> {
@ -2607,7 +2475,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)),
detection_content_seq: Arc::new(AtomicU64::new(0)),
full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)),
detect_reset_notify: Arc::new(Notify::new()),
pending_release: Arc::new(Mutex::new(None)),
@ -2981,7 +2849,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)),
detection_content_seq: Arc::new(AtomicU64::new(0)),
full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)),
detect_reset_notify: Arc::new(Notify::new()),
pending_release: Arc::new(Mutex::new(None)),
@ -3012,7 +2880,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)),
detection_content_seq: Arc::new(AtomicU64::new(0)),
full_lifecycle_authority_active: Arc::new(AtomicBool::new(false)),
detect_reset_notify: Arc::new(Notify::new()),
pending_release: Arc::new(Mutex::new(None)),

File diff suppressed because it is too large Load Diff

View File

@ -1733,7 +1733,7 @@ mod tests {
}
#[test]
fn pty_working_fallback_is_ignored_under_full_lifecycle_hook_authority() {
fn detected_working_fallback_is_ignored_under_full_lifecycle_hook_authority() {
let now = Instant::now();
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Kilo), AgentState::Idle);