diff options
Diffstat (limited to 'src')
30 files changed, 2042 insertions, 898 deletions
diff --git a/src/agent_detection_policy.rs b/src/agent_detection_policy.rs new file mode 100644 index 00000000..a0d7ba4c --- /dev/null +++ b/src/agent_detection_policy.rs @@ -0,0 +1,288 @@ +use crate::detect::{Agent, AgentDetection, AgentState}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PtySignal { + pub active: bool, + pub tainted: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct DetectionPolicyInput { + pub agent: Option<Agent>, + pub screen_detection: AgentDetection, + pub process_exited: bool, + pub startup_grace_active: bool, + pub pty_signal: Option<PtySignal>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DetectionPolicyDecision { + Publish(AgentDetection), + Freeze, +} + +fn screen_blocked_or_idle_fallback(detection: AgentDetection) -> AgentDetection { + if detection.visible_blocker { + return AgentDetection { + state: AgentState::Blocked, + skip_state_update: false, + visible_blocker: true, + visible_working: false, + }; + } + + AgentDetection { + state: AgentState::Idle, + skip_state_update: false, + visible_blocker: false, + visible_working: false, + } +} + +#[cfg(test)] +pub(crate) fn full_lifecycle_detected_agent(agent: Agent) -> bool { + matches!( + agent, + Agent::Pi | Agent::GithubCopilot | Agent::Kimi | Agent::Hermes | Agent::Qodercli + ) +} + +pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) -> bool { + matches!( + (source, agent_label), + ("herdr:pi", "pi") + | ("herdr:omp", "omp") + | ("herdr:kimi", "kimi") + | ("herdr:copilot", "copilot") + | ("herdr:hermes", "hermes") + | ("herdr:qodercli", "qodercli") + ) +} + +pub(crate) fn apply_detection_policy(input: DetectionPolicyInput) -> DetectionPolicyDecision { + if input.process_exited { + return DetectionPolicyDecision::Publish(input.screen_detection); + } + + if input.startup_grace_active { + return DetectionPolicyDecision::Freeze; + } + + if input.agent.is_none() { + return DetectionPolicyDecision::Publish(input.screen_detection); + }; + + let Some(pty_signal) = input.pty_signal else { + return DetectionPolicyDecision::Publish(input.screen_detection); + }; + + if pty_signal.tainted { + return DetectionPolicyDecision::Freeze; + } + + if pty_signal.active { + return DetectionPolicyDecision::Publish(AgentDetection { + state: AgentState::Working, + skip_state_update: false, + visible_blocker: false, + visible_working: false, + }); + } + + DetectionPolicyDecision::Publish(screen_blocked_or_idle_fallback(input.screen_detection)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn detection(state: AgentState) -> AgentDetection { + AgentDetection { + state, + skip_state_update: false, + visible_blocker: false, + visible_working: state == AgentState::Working, + } + } + + fn input(screen_detection: AgentDetection) -> DetectionPolicyInput { + DetectionPolicyInput { + agent: Some(Agent::Codex), + screen_detection, + process_exited: false, + startup_grace_active: false, + pty_signal: Some(PtySignal { + active: false, + tainted: false, + }), + } + } + + #[test] + fn classifies_full_lifecycle_hook_sources() { + assert!(full_lifecycle_hook_authority("herdr:pi", "pi")); + assert!(full_lifecycle_hook_authority("herdr:omp", "omp")); + assert!(full_lifecycle_hook_authority("herdr:kimi", "kimi")); + assert!(full_lifecycle_hook_authority("herdr:copilot", "copilot")); + assert!(full_lifecycle_hook_authority("herdr:hermes", "hermes")); + assert!(full_lifecycle_hook_authority("herdr:qodercli", "qodercli")); + assert!(!full_lifecycle_hook_authority("herdr:codex", "codex")); + assert!(!full_lifecycle_hook_authority("herdr:claude", "claude")); + assert!(!full_lifecycle_hook_authority("herdr:opencode", "opencode")); + assert!(!full_lifecycle_hook_authority("custom", "pi")); + } + + #[test] + fn classifies_full_lifecycle_detected_agents_without_omp_variant() { + assert!(full_lifecycle_detected_agent(Agent::Pi)); + assert!(full_lifecycle_detected_agent(Agent::Kimi)); + assert!(full_lifecycle_detected_agent(Agent::GithubCopilot)); + assert!(full_lifecycle_detected_agent(Agent::Hermes)); + assert!(full_lifecycle_detected_agent(Agent::Qodercli)); + assert!(!full_lifecycle_detected_agent(Agent::Codex)); + assert!(!full_lifecycle_detected_agent(Agent::Claude)); + } + + #[test] + fn startup_grace_freezes_publish() { + let mut input = input(detection(AgentState::Working)); + input.startup_grace_active = true; + + assert_eq!( + apply_detection_policy(input), + DetectionPolicyDecision::Freeze + ); + } + + #[test] + fn taint_freezes_weak_publish() { + let mut input = input(detection(AgentState::Idle)); + input.pty_signal = Some(PtySignal { + active: false, + tainted: true, + }); + + assert_eq!( + apply_detection_policy(input), + DetectionPolicyDecision::Freeze + ); + } + + #[test] + fn taint_freezes_visible_blocker_until_pty_is_quiet() { + let mut blocker = detection(AgentState::Blocked); + blocker.visible_blocker = true; + let mut input = input(blocker); + input.pty_signal = Some(PtySignal { + active: false, + tainted: true, + }); + + assert_eq!( + apply_detection_policy(input), + DetectionPolicyDecision::Freeze + ); + } + + #[test] + fn process_exit_publishes_even_during_taint() { + let mut input = input(detection(AgentState::Idle)); + input.process_exited = true; + input.pty_signal = Some(PtySignal { + active: true, + tainted: true, + }); + + assert_eq!( + apply_detection_policy(input), + DetectionPolicyDecision::Publish(detection(AgentState::Idle)) + ); + } + + #[test] + fn pty_activity_publishes_working_without_inventing_visible_working() { + let mut input = input(detection(AgentState::Idle)); + input.pty_signal = Some(PtySignal { + active: true, + tainted: false, + }); + + assert_eq!( + apply_detection_policy(input), + DetectionPolicyDecision::Publish(AgentDetection { + state: AgentState::Working, + skip_state_update: false, + visible_blocker: false, + visible_working: false, + }) + ); + } + + #[test] + fn active_pty_wins_over_visible_blocker() { + let mut blocker = detection(AgentState::Blocked); + blocker.visible_blocker = true; + let mut input = input(blocker); + input.pty_signal = Some(PtySignal { + active: true, + tainted: false, + }); + + assert_eq!( + apply_detection_policy(input), + DetectionPolicyDecision::Publish(AgentDetection { + state: AgentState::Working, + skip_state_update: false, + visible_blocker: false, + visible_working: false, + }) + ); + } + + #[test] + fn silent_pty_with_visible_blocker_publishes_blocked() { + let mut screen = detection(AgentState::Blocked); + screen.visible_blocker = true; + let input = input(screen); + + assert_eq!( + apply_detection_policy(input), + DetectionPolicyDecision::Publish(AgentDetection { + state: AgentState::Blocked, + skip_state_update: false, + visible_blocker: true, + visible_working: false, + }) + ); + } + + #[test] + fn silent_pty_downgrades_screen_working_to_idle() { + let input = input(detection(AgentState::Working)); + + assert_eq!( + apply_detection_policy(input), + DetectionPolicyDecision::Publish(AgentDetection { + state: AgentState::Idle, + skip_state_update: false, + visible_blocker: false, + visible_working: false, + }) + ); + } + + #[test] + fn silent_pty_downgrades_weak_screen_blocked_to_idle() { + let input = input(detection(AgentState::Blocked)); + + assert_eq!( + apply_detection_policy(input), + DetectionPolicyDecision::Publish(AgentDetection { + state: AgentState::Idle, + skip_state_update: false, + visible_blocker: false, + visible_working: false, + }) + ); + } +} diff --git a/src/app/actions.rs b/src/app/actions.rs index 371457a8..249d0e64 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -22,6 +22,28 @@ fn is_background_completion_transition(prev_state: AgentState, new_state: AgentS && matches!(prev_state, AgentState::Working | AgentState::Blocked) } +fn is_completion_transition(change: &EffectiveStateChange) -> bool { + is_completion_transition_parts( + change.previous_state, + change.state, + change.previous_agent_label.as_deref(), + change.agent_label.as_deref(), + ) +} + +pub fn is_completion_transition_parts( + previous_state: AgentState, + state: AgentState, + previous_agent_label: Option<&str>, + agent_label: Option<&str>, +) -> bool { + is_background_completion_transition(previous_state, state) + || (previous_state == AgentState::Unknown + && state == AgentState::Idle + && previous_agent_label.is_some() + && previous_agent_label == agent_label) +} + pub fn active_tab_suppresses_notifications( is_active_tab: bool, outer_terminal_focus: Option<bool>, @@ -29,6 +51,7 @@ pub fn active_tab_suppresses_notifications( is_active_tab && outer_terminal_focus != Some(false) } +#[cfg(test)] pub fn notification_sound_for_state_change( suppress_active_tab_notifications: bool, prev_state: AgentState, @@ -50,10 +73,58 @@ pub fn notification_sound_for_state_change( } } -pub fn notification_toast_for_state_change( +pub fn notification_sound_for_state_change_with_agent_labels( suppress_active_tab_notifications: bool, prev_state: AgentState, new_state: AgentState, + previous_agent_label: Option<&str>, + agent_label: Option<&str>, +) -> Option<crate::sound::Sound> { + if new_state == prev_state { + return None; + } + + match new_state { + AgentState::Blocked => Some(crate::sound::Sound::Request), + AgentState::Idle + if is_completion_transition_parts( + prev_state, + new_state, + previous_agent_label, + agent_label, + ) && !suppress_active_tab_notifications => + { + Some(crate::sound::Sound::Done) + } + _ => None, + } +} + +fn notification_sound_for_effective_state_change( + suppress_active_tab_notifications: bool, + change: &EffectiveStateChange, +) -> Option<crate::sound::Sound> { + if change.state == change.previous_state { + return None; + } + + match change.state { + AgentState::Blocked => Some(crate::sound::Sound::Request), + AgentState::Idle + if is_completion_transition(change) && !suppress_active_tab_notifications => + { + Some(crate::sound::Sound::Done) + } + _ => None, + } +} + +pub fn notification_toast_for_state_change_with_agent_labels( + suppress_active_tab_notifications: bool, + prev_state: AgentState, + new_state: AgentState, + previous_agent_label: Option<&str>, + agent_label: Option<&str>, ) -> Option<ToastKind> { if suppress_active_tab_notifications || new_state == prev_state { return None; @@ -61,13 +132,52 @@ pub fn notification_toast_for_state_change( match new_state { AgentState::Blocked => Some(ToastKind::NeedsAttention), - AgentState::Idle if is_background_completion_transition(prev_state, new_state) => { + AgentState::Idle + if is_completion_transition_parts( + prev_state, + new_state, + previous_agent_label, + agent_label, + ) => + { Some(ToastKind::Finished) } _ => None, } } +fn notification_toast_for_effective_state_change( + suppress_active_tab_notifications: bool, + change: &EffectiveStateChange, +) -> Option<ToastKind> { + if suppress_active_tab_notifications || change.state == change.previous_state { + return None; + } + + match change.state { + AgentState::Blocked => Some(ToastKind::NeedsAttention), + AgentState::Idle if is_completion_transition(change) => Some(ToastKind::Finished), + _ => None, + } +} + +pub fn notification_toast_for_pane_state_update( + suppress_active_tab_notifications: bool, + update: &PaneStateUpdate, +) -> Option<ToastKind> { + if suppress_active_tab_notifications || update.state == update.previous_state { + return None; + } + + notification_toast_for_state_change_with_agent_labels( + suppress_active_tab_notifications, + update.previous_state, + update.state, + update.previous_agent_label.as_deref(), + update.agent_label.as_deref(), + ) +} + fn toast_agent_label(agent_label: &str) -> &str { agent_label } @@ -2207,7 +2317,6 @@ impl AppState { agent, state, visible_blocker, - visible_idle, visible_working, process_exited, observed_at, @@ -2217,7 +2326,7 @@ impl AppState { agent, state, visible_blocker, - visible_idle, + false, visible_working, process_exited, observed_at, @@ -2405,6 +2514,27 @@ impl AppState { Some(update) } + pub(crate) fn publish_pane_process_exit_if_agent( + &mut self, + pane_id: PaneId, + ) -> Option<PaneStateUpdate> { + let observed_at = std::time::Instant::now(); + self.update_terminal_state(pane_id, |terminal| { + let agent = terminal + .effective_known_agent() + .or(terminal.detected_agent)?; + Some(terminal.set_detected_state_with_screen_signals_at( + Some(agent), + AgentState::Idle, + false, + false, + false, + true, + observed_at, + )) + }) + } + fn apply_pane_state_change( &mut self, ws_idx: usize, @@ -2421,7 +2551,7 @@ impl AppState { if change.state != AgentState::Idle { pane.seen = true; - } else if is_background_completion_transition(change.previous_state, change.state) { + } else if is_completion_transition(change) { pane.seen = suppress_active_tab_notifications; } let seen = pane.seen; @@ -2445,15 +2575,13 @@ impl AppState { let suppress_active_tab_notifications = active_tab_suppresses_notifications(is_active_tab, self.outer_terminal_focus); - let client_notification_kind = notification_toast_for_state_change( + let client_notification_kind = notification_toast_for_effective_state_change( suppress_active_tab_notifications, - change.previous_state, - change.state, + change, ); - let sound = notification_sound_for_state_change( + let sound = notification_sound_for_effective_state_change( suppress_active_tab_notifications, - change.previous_state, - change.state, + change, ); if client_notification_kind.is_none() && sound.is_none() { return None; @@ -3834,7 +3962,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Working, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -3854,6 +3981,7 @@ mod tests { #[test] fn state_changed_idle_in_background_marks_unseen() { let mut state = app_with_workspaces(&["active", "background"]); + state.toast_config.delivery = crate::config::ToastDelivery::Herdr; state.active = Some(0); let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); @@ -3872,7 +4000,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Idle, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -3880,6 +4007,10 @@ mod tests { let pane = state.workspaces[1].panes.get(&bg_pane_id).unwrap(); assert!(!pane.seen); + assert!(matches!( + state.toast.as_ref().map(|toast| toast.kind), + Some(ToastKind::Finished) + )); } #[test] @@ -3902,7 +4033,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Idle, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -3925,7 +4055,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Idle, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -3936,6 +4065,36 @@ mod tests { } #[test] + fn idle_after_known_unknown_agent_in_background_marks_done() { + let mut state = app_with_workspaces(&["active", "background"]); + state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + state.active = Some(0); + let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); + + state.handle_app_event(AppEvent::StateChanged { + pane_id: bg_pane_id, + agent: Some(Agent::Pi), + state: AgentState::Unknown, + visible_blocker: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + state.handle_app_event(AppEvent::StateChanged { + pane_id: bg_pane_id, + agent: Some(Agent::Pi), + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + + let pane = state.workspaces[1].panes.get(&bg_pane_id).unwrap(); + assert!(!pane.seen); + } + + #[test] fn waiting_sound_plays_even_in_active_workspace() { assert_eq!( notification_sound_for_state_change(true, AgentState::Working, AgentState::Blocked), @@ -3971,7 +4130,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Blocked, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -3996,7 +4154,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Blocked, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -4029,7 +4186,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Blocked, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -4041,7 +4197,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Working, visible_blocker: false, - visible_idle: false, visible_working: true, process_exited: false, observed_at: std::time::Instant::now(), @@ -4065,7 +4220,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Blocked, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -4091,7 +4245,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Blocked, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -4119,7 +4272,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Blocked, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -4176,7 +4328,6 @@ mod tests { agent: Some(Agent::Codex), state: AgentState::Idle, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -4196,7 +4347,6 @@ mod tests { agent: Some(Agent::Codex), state: AgentState::Blocked, visible_blocker: true, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -4227,7 +4377,6 @@ mod tests { agent: Some(Agent::Claude), state: AgentState::Working, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -4252,7 +4401,6 @@ mod tests { agent: Some(Agent::Claude), state: AgentState::Idle, visible_blocker: false, - visible_idle: true, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -4279,7 +4427,6 @@ mod tests { agent: Some(Agent::Claude), state: AgentState::Working, visible_blocker: false, - visible_idle: false, visible_working: true, process_exited: false, observed_at: std::time::Instant::now(), @@ -4298,7 +4445,7 @@ mod tests { } #[test] - fn hidden_session_ref_only_update_marks_session_dirty_without_visible_update() { + fn hidden_custom_session_ref_only_update_marks_session_dirty_without_visible_update() { let mut state = app_with_workspaces(&["active"]); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); let test_dir = std::env::current_dir().unwrap(); @@ -4307,7 +4454,7 @@ mod tests { let first_updates = state.handle_app_event(AppEvent::HookStateReported { pane_id, - source: "herdr:pi".into(), + source: "custom:pi".into(), agent_label: "pi".into(), state: AgentState::Working, message: None, @@ -4320,7 +4467,7 @@ mod tests { let second_updates = state.handle_app_event(AppEvent::HookStateReported { pane_id, - source: "herdr:pi".into(), + source: "custom:pi".into(), agent_label: "pi".into(), state: AgentState::Working, message: None, @@ -4377,7 +4524,6 @@ mod tests { agent: Some(Agent::Droid), state: AgentState::Idle, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -4407,7 +4553,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Blocked, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -4434,7 +4579,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Blocked, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -4458,7 +4602,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Blocked, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -4480,7 +4623,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Blocked, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -4678,6 +4820,39 @@ mod tests { } #[test] + fn pane_process_exit_publish_marks_agent_idle_before_pane_removal() { + let mut state = app_with_workspaces(&["active", "background"]); + state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + state.active = Some(1); + state.ensure_test_terminals(); + let pane_id = state.workspaces[0].tabs[0].root_pane; + let terminal_id = state.terminal_id_for_pane(0, pane_id).unwrap(); + state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_detected_state(Some(Agent::Pi), AgentState::Working); + assert_eq!( + state.terminals.get(&terminal_id).unwrap().state, + AgentState::Working + ); + + let update = state + .publish_pane_process_exit_if_agent(pane_id) + .expect("process exit update"); + + assert!(!state.pane_is_in_active_tab(update.ws_idx, pane_id)); + assert_eq!(update.previous_state, AgentState::Working); + assert_eq!(update.state, AgentState::Idle); + assert_eq!(update.agent_label.as_deref(), Some("pi")); + assert_eq!(update.known_agent, Some(Agent::Pi)); + assert!(matches!( + state.toast.as_ref().map(|toast| toast.kind), + Some(ToastKind::Finished) + )); + } + + #[test] fn close_pane_removes_unattached_terminal_state() { let mut state = app_with_workspaces(&["test"]); let pane_id = state.workspaces[0].test_split(Direction::Horizontal); diff --git a/src/app/api.rs b/src/app/api.rs index 166ea789..76d9c537 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -67,6 +67,12 @@ impl App { } if let AppEvent::PaneDied { pane_id } = &ev { + let previous_toast = self.state.toast.clone(); + if let Some(update) = self.state.publish_pane_process_exit_if_agent(*pane_id) { + self.refresh_new_herdr_toast_context_for_update(&update, &previous_toast); + self.emit_pane_state_update(&update); + self.emit_terminal_or_system_agent_notifications(std::slice::from_ref(&update)); + } if self.runtime_exit_action(*pane_id) == RuntimeExitAction::RespawnShell && self.respawn_shell_for_launch_pane(*pane_id) { @@ -161,57 +167,7 @@ impl App { let instruction = crate::update::update_install_instruction(&install_command); let _ = notify(&format!("v{version} available"), Some(&instruction)); } else if self.state.toast_config.delay_seconds == 0 { - for update in &pane_updates { - let is_active_tab = self - .state - .pane_is_in_active_tab(update.ws_idx, update.pane_id); - let suppress_active_tab_notifications = - crate::app::actions::active_tab_suppresses_notifications( - is_active_tab, - self.state.outer_terminal_focus, - ); - let Some(kind) = crate::app::actions::notification_toast_for_state_change( - suppress_active_tab_notifications, - update.previous_state, - update.state, - ) else { - continue; - }; - let Some(ws) = self.state.workspaces.get(update.ws_idx) else { - continue; - }; - let Some(pane) = ws - .tabs - .iter() - .find_map(|tab| tab.panes.get(&update.pane_id)) - else { - continue; - }; - let Some(agent_label) = self - .state - .terminals - .get(&pane.attached_terminal_id) - .and_then(|terminal| terminal.effective_agent_label()) - else { - continue; - }; - let event_text = match kind { - ToastKind::NeedsAttention => "needs attention", - ToastKind::Finished => "finished", - ToastKind::UpdateInstalled => "updated", - }; - let workspace_label = - ws.display_name_from(&self.state.terminals, &self.terminal_runtimes); - let _ = notify( - &format!("{} {}", agent_label, event_text), - Some(&crate::app::actions::notification_context( - ws, - &workspace_label, - update.ws_idx, - update.pane_id, - )), - ); - } + self.emit_terminal_or_system_agent_notifications(&pane_updates); } } @@ -407,6 +363,78 @@ impl App { } } + fn emit_terminal_or_system_agent_notifications( + &self, + pane_updates: &[crate::app::actions::PaneStateUpdate], + ) { + if !self.local_terminal_notifications + || self.state.toast_config.delay_seconds != 0 + || !matches!( + self.state.toast_config.delivery, + crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System + ) + { + return; + } + + let notify = match self.state.toast_config.delivery { + crate::config::ToastDelivery::Terminal => crate::terminal_notify::show_notification, + crate::config::ToastDelivery::System => crate::platform::show_desktop_notification, + _ => return, + }; + + for update in pane_updates { + let is_active_tab = self + .state + .pane_is_in_active_tab(update.ws_idx, update.pane_id); + let suppress_active_tab_notifications = + crate::app::actions::active_tab_suppresses_notifications( + is_active_tab, + self.state.outer_terminal_focus, + ); + let Some(kind) = crate::app::actions::notification_toast_for_pane_state_update( + suppress_active_tab_notifications, + update, + ) else { + continue; + }; + let Some(ws) = self.state.workspaces.get(update.ws_idx) else { + continue; + }; + let Some(pane) = ws + .tabs + .iter() + .find_map(|tab| tab.panes.get(&update.pane_id)) + else { + continue; + }; + let Some(agent_label) = self + .state + .terminals + .get(&pane.attached_terminal_id) + .and_then(|terminal| terminal.effective_agent_label()) + else { + continue; + }; + let event_text = match kind { + ToastKind::NeedsAttention => "needs attention", + ToastKind::Finished => "finished", + ToastKind::UpdateInstalled => "updated", + }; + let workspace_label = + ws.display_name_from(&self.state.terminals, &self.terminal_runtimes); + let _ = notify( + &format!("{} {}", agent_label, event_text), + Some(&crate::app::actions::notification_context( + ws, + &workspace_label, + update.ws_idx, + update.pane_id, + )), + ); + } + } + pub(crate) fn sync_toast_deadline( &mut self, previous_toast: Option<crate::app::state::ToastNotification>, @@ -891,7 +919,6 @@ mod tests { agent: Some(Agent::Codex), state: AgentState::Working, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -901,7 +928,6 @@ mod tests { agent: Some(Agent::Codex), state: AgentState::Idle, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -984,7 +1010,6 @@ mod tests { agent: Some(Agent::Codex), state: AgentState::Working, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -994,7 +1019,6 @@ mod tests { agent: Some(Agent::Codex), state: AgentState::Idle, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -1096,7 +1120,6 @@ mod tests { agent: Some(Agent::Codex), state: AgentState::Working, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -1117,7 +1140,6 @@ mod tests { agent: Some(Agent::Codex), state: AgentState::Idle, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index a682d7ac..a241dadb 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -2260,7 +2260,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Idle, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), diff --git a/src/app/mod.rs b/src/app/mod.rs index 3a038dee..936b082f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3621,7 +3621,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Working, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), @@ -3646,7 +3645,6 @@ mod tests { agent: Some(Agent::Pi), state: AgentState::Idle, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: std::time::Instant::now(), diff --git a/src/detect/agents/amp.rs b/src/detect/agents/amp.rs index ad76ca27..b1fcba4d 100644 --- a/src/detect/agents/amp.rs +++ b/src/detect/agents/amp.rs @@ -17,6 +17,19 @@ use super::super::AgentState; pub(super) fn detect(content: &str) -> AgentState { let lower = content.to_lowercase(); + if has_visible_blocker(content) { + return AgentState::Blocked; + } + + if lower.contains("esc to cancel") { + return AgentState::Working; + } + + AgentState::Idle +} + +pub(super) fn has_visible_blocker(content: &str) -> bool { + let lower = content.to_lowercase(); let has_waiting_for_approval = lower.contains("waiting for approval"); let has_approval_header = lower.contains("invoke tool") || lower.contains("run this command?") @@ -29,13 +42,5 @@ pub(super) fn detect(content: &str) -> AgentState { || lower.contains("allow file for every session") || lower.contains("deny with feedback")); - if has_approval_actions && (has_waiting_for_approval || has_approval_header) { - return AgentState::Blocked; - } - - if lower.contains("esc to cancel") { - return AgentState::Working; - } - - AgentState::Idle + has_approval_actions && (has_waiting_for_approval || has_approval_header) } diff --git a/src/detect/agents/antigravity.rs b/src/detect/agents/antigravity.rs index b504a96a..9790c5a4 100644 --- a/src/detect/agents/antigravity.rs +++ b/src/detect/agents/antigravity.rs @@ -1,12 +1,7 @@ use super::super::AgentState; pub(super) fn detect(content: &str) -> AgentState { - let lower = content.to_lowercase(); - - let has_permission_request = lower.contains("requesting permission for:"); - let has_permission_question = lower.contains("do you want to proceed?"); - let has_permission_controls = lower.contains("tab amend") && lower.contains("edit command"); - if has_permission_request && (has_permission_question || has_permission_controls) { + if has_visible_blocker(content) { return AgentState::Blocked; } @@ -17,6 +12,14 @@ pub(super) fn detect(content: &str) -> AgentState { AgentState::Idle } +pub(super) fn has_visible_blocker(content: &str) -> bool { + let lower = content.to_lowercase(); + let has_permission_request = lower.contains("requesting permission for:"); + let has_permission_question = lower.contains("do you want to proceed?"); + let has_permission_controls = lower.contains("tab amend") && lower.contains("edit command"); + has_permission_request && (has_permission_question || has_permission_controls) +} + fn has_antigravity_spinner(content: &str) -> bool { content.lines().any(|line| { let trimmed = line.trim_start(); diff --git a/src/detect/agents/claude_code.rs b/src/detect/agents/claude_code.rs index 910eb679..a13019a4 100644 --- a/src/detect/agents/claude_code.rs +++ b/src/detect/agents/claude_code.rs @@ -27,6 +27,10 @@ pub(super) fn detect(content: &str) -> AgentState { return AgentState::Blocked; } + if has_dynamic_workflow_prompt(&lower) { + return AgentState::Blocked; + } + if has_working_chrome(content) { return AgentState::Working; } @@ -45,6 +49,7 @@ pub(super) fn detect(content: &str) -> AgentState { pub(super) fn has_visible_blocker(content: &str) -> bool { let lower = content.to_lowercase(); has_live_blocked_form(content) + || has_dynamic_workflow_prompt(&lower) || lower.contains("do you want to proceed?") && has_claude_yes_no_choice(content) && (lower.contains("bash command") @@ -96,6 +101,7 @@ fn has_claude_blocked_prompt(content: &str, lower_content: &str) -> bool { has_confirmation_prompt(lower_content) || lower_content.contains("do you want to proceed?") || lower_content.contains("would you like to proceed?") + || has_dynamic_workflow_prompt(lower_content) || lower_content.contains("waiting for permission") || lower_content.contains("do you want to allow this connection?") || lower_content.contains("tab to amend") @@ -105,6 +111,10 @@ fn has_claude_blocked_prompt(content: &str, lower_content: &str) -> bool { || (has_selection_prompt(content) && has_claude_yes_no_choice(content)) } +fn has_dynamic_workflow_prompt(lower_content: &str) -> bool { + lower_content.contains("run a dynamic workflow?") && lower_content.contains("esc to cancel") +} + fn has_live_blocked_form(content: &str) -> bool { let region = content_after_last_horizontal_rule(content); region.lines().any(|line| { diff --git a/src/detect/agents/cline.rs b/src/detect/agents/cline.rs index 86b5ce01..13509606 100644 --- a/src/detect/agents/cline.rs +++ b/src/detect/agents/cline.rs @@ -4,11 +4,7 @@ pub(super) fn detect(content: &str) -> AgentState { let lower = content.to_lowercase(); // Blocked - if lower.contains("let cline use this tool") { - return AgentState::Blocked; - } - // [act mode] or [plan mode] followed by "yes" - if (lower.contains("[act mode]") || lower.contains("[plan mode]")) && lower.contains("yes") { + if has_visible_blocker(content) { return AgentState::Blocked; } @@ -20,3 +16,11 @@ pub(super) fn detect(content: &str) -> AgentState { // Cline defaults to working (unlike most agents that default to idle) AgentState::Working } + +pub(super) fn has_visible_blocker(content: &str) -> bool { + let lower = content.to_lowercase(); + lower.contains("let cline use this tool") + || ((lower.contains("[act mode]") || lower.contains("[plan mode]")) + && (lower.contains("execute command?") || lower.contains("use this tool?")) + && lower.contains("yes")) +} diff --git a/src/detect/agents/codex.rs b/src/detect/agents/codex.rs index 0a9bdadb..b77b7d25 100644 --- a/src/detect/agents/codex.rs +++ b/src/detect/agents/codex.rs @@ -38,10 +38,6 @@ pub(super) fn has_visible_blocker(content: &str) -> bool { has_codex_strong_blocked_prompt(content) } -pub(super) fn has_prompt(content: &str) -> bool { - has_codex_current_prompt(content) || content.lines().any(codex_prompt_line) -} - pub(super) fn has_visible_working(content: &str) -> bool { has_codex_live_working_at_current_prompt(content) || (!has_codex_current_prompt(content) && has_codex_visible_working_without_prompt(content)) diff --git a/src/detect/agents/cursor.rs b/src/detect/agents/cursor.rs index a62e9382..f0f00b9b 100644 --- a/src/detect/agents/cursor.rs +++ b/src/detect/agents/cursor.rs @@ -4,7 +4,7 @@ pub(super) fn detect(content: &str) -> AgentState { let lower = content.to_lowercase(); // Blocked - if has_cursor_blocked_prompt(content, &lower) { + if has_visible_blocker(content) { return AgentState::Blocked; } @@ -19,8 +19,12 @@ pub(super) fn detect(content: &str) -> AgentState { AgentState::Idle } -fn has_cursor_blocked_prompt(content: &str, lower: &str) -> bool { - if lower.contains("waiting for approval") || lower.contains("run this command?") { +pub(super) fn has_visible_blocker(content: &str) -> bool { + let lower = content.to_lowercase(); + if lower.contains("waiting for approval") + && lower.contains("run this command?") + && (lower.contains("run (once) (y)") || lower.contains("skip (esc or n)")) + { return true; } diff --git a/src/detect/agents/droid.rs b/src/detect/agents/droid.rs index ea19da3f..52c5e087 100644 --- a/src/detect/agents/droid.rs +++ b/src/detect/agents/droid.rs @@ -9,19 +9,7 @@ use super::super::{has_braille_spinner, AgentState}; pub(super) fn detect(content: &str) -> AgentState { let lower = content.to_lowercase(); - // Blocked: EXECUTE approval prompt with selection UI chrome - // Primary (AND): structural keyword + chrome text = certain - let has_execute = content.contains("EXECUTE"); - let has_selection_chrome = lower.contains("enter to select") - || lower.contains("↑↓ to navigate") - || lower.contains("esc to cancel"); - let has_selection_options = lower.contains("> yes, allow") || lower.contains("> no, cancel"); - - if has_execute && (has_selection_chrome || has_selection_options) { - return AgentState::Blocked; - } - // Secondary: selection chrome + options together (no EXECUTE needed) - if has_selection_chrome && has_selection_options { + if has_visible_blocker(content) { return AgentState::Blocked; } @@ -37,3 +25,16 @@ pub(super) fn detect(content: &str) -> AgentState { AgentState::Idle } + +pub(super) fn has_visible_blocker(content: &str) -> bool { + let lower = content.to_lowercase(); + // Primary (AND): structural keyword + chrome text = certain + let has_execute = content.contains("EXECUTE"); + let has_selection_chrome = lower.contains("enter to select") + || lower.contains("↑↓ to navigate") + || lower.contains("esc to cancel"); + let has_selection_options = lower.contains("> yes, allow") || lower.contains("> no, cancel"); + + (has_execute && (has_selection_chrome || has_selection_options)) + || (has_selection_chrome && has_selection_options) +} diff --git a/src/detect/agents/gemini.rs b/src/detect/agents/gemini.rs index c47355c0..86cf3bca 100644 --- a/src/detect/agents/gemini.rs +++ b/src/detect/agents/gemini.rs @@ -1,24 +1,11 @@ -use super::super::{has_confirmation_prompt, AgentState}; +use super::super::AgentState; pub(super) fn detect(content: &str) -> AgentState { - let lower = content.to_lowercase(); - - // Blocked — explicit confirmation - if lower.contains("waiting for user confirmation") { - return AgentState::Blocked; - } - - // Blocked — box-drawing confirmation prompts - if content.contains("│ Apply this change") - || content.contains("│ Allow execution") - || content.contains("│ Do you want to proceed") - { - return AgentState::Blocked; - } - if has_confirmation_prompt(&lower) { + if has_visible_blocker(content) { return AgentState::Blocked; } + let lower = content.to_lowercase(); // Working if lower.contains("esc to cancel") { return AgentState::Working; @@ -26,3 +13,19 @@ pub(super) fn detect(content: &str) -> AgentState { AgentState::Idle } + +pub(super) fn has_visible_blocker(content: &str) -> bool { + let lower = content.to_lowercase(); + let has_choice = lower.contains("yes") || lower.contains("no"); + + content.contains("│ Apply this change") + || content.contains("│ Allow execution") + || (has_choice + && (lower.contains("waiting for user confirmation") + || content.contains("│ Do you want to proceed") + || lower.contains("do you want to proceed?"))) + || content.lines().any(|line| { + let line = line.trim().to_ascii_lowercase(); + line.starts_with("❯") && (line.contains("yes") || line.contains("allow")) + }) +} diff --git a/src/detect/agents/github_copilot.rs b/src/detect/agents/github_copilot.rs index eed03011..0eece95b 100644 --- a/src/detect/agents/github_copilot.rs +++ b/src/detect/agents/github_copilot.rs @@ -4,11 +4,7 @@ pub(super) fn detect(content: &str) -> AgentState { let lower = content.to_lowercase(); // Blocked - if lower.contains("esc to cancel") - && (lower.contains("enter to select") - || lower.contains("enter to confirm") - || lower.contains("enter to submit")) - { + if has_visible_blocker(content) { return AgentState::Blocked; } @@ -22,3 +18,11 @@ pub(super) fn detect(content: &str) -> AgentState { AgentState::Idle } + +pub(super) fn has_visible_blocker(content: &str) -> bool { + let lower = content.to_lowercase(); + lower.contains("esc to cancel") + && (lower.contains("enter to select") + || lower.contains("enter to confirm") + || lower.contains("enter to submit")) +} diff --git a/src/detect/agents/grok.rs b/src/detect/agents/grok.rs index 75b5cd35..8a5834a2 100644 --- a/src/detect/agents/grok.rs +++ b/src/detect/agents/grok.rs @@ -7,17 +7,11 @@ use super::super::{has_braille_spinner, AgentState}; /// status line such as "⠋ Waiting… 1.8s" plus live controls like /// "Ctrl+c:cancel" and "Ctrl+Enter:interject". pub(super) fn detect(content: &str) -> AgentState { - let lower = content.to_lowercase(); - - if lower.contains("use ← → to choose permission whitelist scope") - || lower.contains("yes, proceed") - || lower.contains("no, reject") - || lower.contains("ctrl+o:yolo") - || lower.contains(":scope") - { + if has_visible_blocker(content) { return AgentState::Blocked; } + let lower = content.to_lowercase(); if has_braille_spinner(content) && (lower.contains("waiting") || lower.contains("run ") @@ -34,3 +28,10 @@ pub(super) fn detect(content: &str) -> AgentState { AgentState::Idle } + +pub(super) fn has_visible_blocker(content: &str) -> bool { + let lower = content.to_lowercase(); + let has_scope_selector = lower.contains("use ← → to choose permission whitelist scope") + || lower.contains("←/→:scope"); + has_scope_selector && lower.contains("yes, proceed") && lower.contains("no, reject") +} diff --git a/src/detect/agents/hermes.rs b/src/detect/agents/hermes.rs index d6c4c97e..474dda5c 100644 --- a/src/detect/agents/hermes.rs +++ b/src/detect/agents/hermes.rs @@ -6,21 +6,25 @@ use super::super::AgentState; /// dialogs for dangerous terminal commands. Prefer the modal controls for /// blocked detection, then the live interrupt/status controls for working. pub(super) fn detect(content: &str) -> AgentState { - let lower = content.to_lowercase(); - - let has_approval_options = lower.contains("allow once") - && lower.contains("allow for this session") - && lower.contains("deny"); - let has_approval_controls = lower.contains("enter to confirm") - || lower.contains("↑/↓ to select") - || lower.contains("show full command"); - if (lower.contains("dangerous command") || has_approval_options) && has_approval_controls { + if has_visible_blocker(content) { return AgentState::Blocked; } + let lower = content.to_lowercase(); if lower.contains("msg=interrupt") || lower.contains("ctrl+c cancel") { return AgentState::Working; } AgentState::Idle } + +pub(super) fn has_visible_blocker(content: &str) -> bool { + let lower = content.to_lowercase(); + let has_approval_options = lower.contains("allow once") + && lower.contains("allow for this session") + && lower.contains("deny"); + let has_approval_controls = lower.contains("enter to confirm") + || lower.contains("↑/↓ to select") + || lower.contains("show full command"); + (lower.contains("dangerous command") || has_approval_options) && has_approval_controls +} diff --git a/src/detect/agents/kilo.rs b/src/detect/agents/kilo.rs index 1ab3651a..46a5601a 100644 --- a/src/detect/agents/kilo.rs +++ b/src/detect/agents/kilo.rs @@ -7,3 +7,7 @@ pub(super) fn detect(content: &str) -> AgentState { super::opencode::detect(content) } + +pub(super) fn has_visible_blocker(content: &str) -> bool { + super::opencode::has_visible_blocker(content) +} diff --git a/src/detect/agents/kimi.rs b/src/detect/agents/kimi.rs index b42bf885..9d3821ed 100644 --- a/src/detect/agents/kimi.rs +++ b/src/detect/agents/kimi.rs @@ -16,10 +16,6 @@ pub(super) fn has_visible_blocker(content: &str) -> bool { has_current_approval_panel(content) || has_question_panel(content) } -pub(super) fn has_prompt_box(content: &str) -> bool { - has_editor_prompt_box(content) && has_footer_context(content) -} - pub(super) fn has_visible_working(content: &str) -> bool { has_kimi_working_status(content) } @@ -99,49 +95,3 @@ fn has_question_panel(content: &str) -> bool { fn has_numeric_choose_hint(lower_content: &str) -> bool { lower_content.contains(" choose") && lower_content.contains('1') && lower_content.contains('2') } - -fn has_editor_prompt_box(content: &str) -> bool { - let lines: Vec<&str> = content.lines().collect(); - - for top_index in 0..lines.len() { - if !is_editor_top_border(lines[top_index]) { - continue; - } - - let mut saw_prompt = false; - for line in lines.iter().skip(top_index + 1) { - if is_editor_bottom_border(line) { - if saw_prompt { - return true; - } - break; - } - - saw_prompt |= is_editor_prompt_line(line); - } - } - - false -} - -fn is_editor_top_border(line: &str) -> bool { - let trimmed = line.trim(); - ((trimmed.starts_with('╭') && trimmed.ends_with('╮')) - || (trimmed.starts_with('├') && trimmed.ends_with('┤'))) - && trimmed.contains('─') -} - -fn is_editor_bottom_border(line: &str) -> bool { - let trimmed = line.trim(); - trimmed.starts_with('╰') && trimmed.ends_with('╯') && trimmed.contains('─') -} - -fn is_editor_prompt_line(line: &str) -> bool { - let trimmed = line.trim_start(); - let inner = trimmed.strip_prefix('│').unwrap_or(trimmed).trim_start(); - inner.starts_with('>') -} - -fn has_footer_context(content: &str) -> bool { - content.to_lowercase().contains("context: ") -} diff --git a/src/detect/agents/kiro.rs b/src/detect/agents/kiro.rs index f40b9bcb..889f5315 100644 --- a/src/detect/agents/kiro.rs +++ b/src/detect/agents/kiro.rs @@ -7,7 +7,7 @@ use super::super::AgentState; pub(super) fn detect(content: &str) -> AgentState { let lower = content.to_lowercase(); - if has_kiro_blocked_prompt(&lower) { + if has_visible_blocker(content) { return AgentState::Blocked; } @@ -20,8 +20,9 @@ pub(super) fn detect(content: &str) -> AgentState { AgentState::Idle } -fn has_kiro_blocked_prompt(lower_content: &str) -> bool { - has_tool_approval_prompt(lower_content) || has_subagent_approval_prompt(lower_content) +pub(super) fn has_visible_blocker(content: &str) -> bool { + let lower_content = content.to_lowercase(); + has_tool_approval_prompt(&lower_content) || has_subagent_approval_prompt(&lower_content) } fn has_tool_approval_prompt(lower_content: &str) -> bool { diff --git a/src/detect/agents/mod.rs b/src/detect/agents/mod.rs index 46a25f66..704350df 100644 --- a/src/detect/agents/mod.rs +++ b/src/detect/agents/mod.rs @@ -45,7 +45,6 @@ pub(super) fn detect(agent: Agent, screen_content: &str) -> AgentDetection { state, skip_state_update: true, visible_blocker: false, - visible_idle: false, visible_working: false, }; } @@ -54,7 +53,6 @@ pub(super) fn detect(agent: Agent, screen_content: &str) -> AgentDetection { state, skip_state_update: false, visible_blocker: has_visible_blocker(agent, screen_content, state), - visible_idle: has_visible_idle(agent, screen_content, state), visible_working: has_visible_working(agent, screen_content, state), } } @@ -79,20 +77,20 @@ fn has_visible_blocker(agent: Agent, content: &str, state: AgentState) -> bool { // is known to be structural and live. Agent::Claude => claude_code::has_visible_blocker(content), Agent::Codex => codex::has_visible_blocker(content), + Agent::Gemini => gemini::has_visible_blocker(content), + Agent::Cursor => cursor::has_visible_blocker(content), + Agent::Antigravity => antigravity::has_visible_blocker(content), + Agent::Cline => cline::has_visible_blocker(content), + Agent::OpenCode => opencode::has_visible_blocker(content), + Agent::GithubCopilot => github_copilot::has_visible_blocker(content), Agent::Kimi => kimi::has_visible_blocker(content), - _ => false, - } -} - -fn has_visible_idle(agent: Agent, content: &str, state: AgentState) -> bool { - if state != AgentState::Idle { - return false; - } - - match agent { - Agent::Claude => claude_code::has_prompt_box(content), - Agent::Codex => codex::has_prompt(content), - Agent::Kimi => kimi::has_prompt_box(content), + Agent::Kiro => kiro::has_visible_blocker(content), + Agent::Droid => droid::has_visible_blocker(content), + Agent::Amp => amp::has_visible_blocker(content), + Agent::Grok => grok::has_visible_blocker(content), + Agent::Hermes => hermes::has_visible_blocker(content), + Agent::Kilo => kilo::has_visible_blocker(content), + Agent::Qodercli => qodercli::has_visible_blocker(content), _ => false, } } diff --git a/src/detect/agents/opencode.rs b/src/detect/agents/opencode.rs index 219f69ed..ab8a424f 100644 --- a/src/detect/agents/opencode.rs +++ b/src/detect/agents/opencode.rs @@ -2,7 +2,7 @@ use super::super::{has_interrupt_pattern, AgentState}; pub(super) fn detect(content: &str) -> AgentState { // Blocked - if content.contains("△ Permission required") || has_opencode_question_prompt(content) { + if has_visible_blocker(content) { return AgentState::Blocked; } @@ -17,6 +17,10 @@ pub(super) fn detect(content: &str) -> AgentState { AgentState::Idle } +pub(super) fn has_visible_blocker(content: &str) -> bool { + content.contains("△ Permission required") || has_opencode_question_prompt(content) +} + fn has_opencode_question_prompt(content: &str) -> bool { let lower = content.to_lowercase(); let has_enter_action = lower.contains("enter confirm") diff --git a/src/detect/agents/qodercli.rs b/src/detect/agents/qodercli.rs index b7da356b..0a4372df 100644 --- a/src/detect/agents/qodercli.rs +++ b/src/detect/agents/qodercli.rs @@ -15,7 +15,7 @@ pub(super) fn detect(content: &str) -> AgentState { return AgentState::Idle; } - if has_qodercli_blocked_prompt(&lower) { + if has_visible_blocker(content) { return AgentState::Blocked; } @@ -87,9 +87,15 @@ fn has_qodercli_spinner_row(content: &str) -> bool { /// defensive fallbacks in case the title row scrolls off-screen. /// * The interactive shell waiting hint emitted by qodercli when an agent /// spawns a shell that is now parked for user keystrokes. -fn has_qodercli_blocked_prompt(lower_content: &str) -> bool { - lower_content.contains("waiting for user confirmation") - || lower_content.contains("awaiting approval") +pub(super) fn has_visible_blocker(content: &str) -> bool { + let lower_content = content.to_lowercase(); + (lower_content.contains("waiting for user confirmation") + && (lower_content.contains("yes") + || lower_content.contains("no") + || lower_content.contains("allow") + || lower_content.contains("reject"))) + || (lower_content.contains("awaiting approval") + && (lower_content.contains("allow") || lower_content.contains("reject"))) || lower_content.contains("permission required") || lower_content.contains("allow once or always?") || lower_content.contains("asking user") diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 529574f9..24393c59 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -29,13 +29,9 @@ pub struct AgentDetection { /// human input. This is stronger than arbitrary prompt-like text in the /// scrollback and may override a non-blocked integration state. pub visible_blocker: bool, - /// True when the current screen visibly shows the agent's idle input UI. - /// This lets Herdr recover from integrations that miss an interrupt/stop - /// event without treating an empty or ambiguous screen as idle authority. - pub visible_idle: bool, - /// True when the current screen visibly shows live working chrome. This is - /// narrower than a fallback `Working` heuristic and may guard against stale - /// hook idle reports. + /// True when the current screen visibly shows live working chrome. PTY + /// activity is the normal working authority; this remains diagnostic + /// metadata and for non-PTY fallback paths. pub visible_working: bool, } @@ -178,7 +174,6 @@ pub fn detect_agent(agent: Option<Agent>, screen_content: &str) -> AgentDetectio state: AgentState::Unknown, skip_state_update: false, visible_blocker: false, - visible_idle: false, visible_working: false, }; }; @@ -1135,7 +1130,6 @@ mod tests { let detection = detect_agent(Some(Agent::Claude), screen); assert!(detection.skip_state_update); - assert!(!detection.visible_idle); assert!(!detection.visible_working); assert!(!detection.visible_blocker); } @@ -1162,7 +1156,6 @@ mod tests { let detection = detect_agent(Some(Agent::Claude), screen); assert!(!detection.skip_state_update); - assert!(detection.visible_idle); } #[test] @@ -1196,7 +1189,6 @@ mod tests { assert_eq!(detection.state, AgentState::Blocked); assert!(detection.visible_blocker); - assert!(!detection.visible_idle); } #[test] @@ -1216,13 +1208,31 @@ mod tests { } #[test] + fn claude_dynamic_workflow_prompt_is_visible_blocker() { + let screen = "● Workflow(Review the current worktree diff and verify the resulting findings)\n\n─────────────────────────────────────────────────────────────────────────────────────────\n Run a dynamic workflow?\n\n Review the current worktree diff and verify the resulting findings\n\n This dynamic workflow will spin up multiple subagents across the following phases:\n 1. Overview — map the diff and identify coupled subsystems\n 2. Review — review each changed file in parallel\n 3. Verify — adversarially verify each finding\n 4. Synthesize — summarize confirmed issues and residual risk\n\n Dynamic workflows can use a lot of tokens quickly by running many subagents in parallel — which counts against your usage limit. Stop a running workflow at any time with\n /workflows, or disable dynamic workflows in /config.\n\n ❯ 1. Yes, run it\n 2. View raw script\n 3. No\n\n Esc to cancel · Tab to amend\n ctrl+g to edit script in $EDITOR"; + let detection = detect_agent(Some(Agent::Claude), screen); + + assert_eq!(detection.state, AgentState::Blocked); + assert!(detection.visible_blocker); + assert!(!detection.visible_working); + } + + #[test] + fn claude_dynamic_workflow_prompt_uses_stable_modal_gate() { + let screen = "● Workflow(Evaluate changes)\n\n─────────────────────────────────────────────────────────────────────────────────────────\n Run a dynamic workflow?\n\n 1. Scope\n 2. Review\n 3. Synthesize\n\n Esc to cancel · Tab to amend"; + let detection = detect_agent(Some(Agent::Claude), screen); + + assert_eq!(detection.state, AgentState::Blocked); + assert!(detection.visible_blocker); + } + + #[test] fn claude_question_form_selected_top_is_visible_blocker() { let screen = "❯ ask again\n─────────────────────────────────────────────────────────────────────────────────────────\n← ☐ Subject ☐ Tone ✔ Submit →\n\nWhat should I ask you about?\n\n❯ 1. Today\n Your current plan or priority.\n 2. Project\n A codebase, feature, bug, or PR.\n 3. Preference\n How you want me to work with you.\n 4. Random\n A casual question with no work context.\n 5. Type something.\n─────────────────────────────────────────────────────────────────────────────────────────\n 6. Chat about this\n\nEnter to select · Tab/Arrow keys to navigate · Esc to cancel"; let detection = detect_agent(Some(Agent::Claude), screen); assert_eq!(detection.state, AgentState::Blocked); assert!(detection.visible_blocker); - assert!(!detection.visible_idle); } #[test] @@ -1232,7 +1242,6 @@ mod tests { assert_eq!(detection.state, AgentState::Blocked); assert!(detection.visible_blocker); - assert!(!detection.visible_idle); } #[test] @@ -1242,7 +1251,6 @@ mod tests { assert_eq!(detection.state, AgentState::Blocked); assert!(detection.visible_blocker); - assert!(!detection.visible_idle); } #[test] @@ -1264,31 +1272,28 @@ mod tests { } #[test] - fn claude_prompt_box_is_visible_idle() { + fn claude_prompt_box_does_not_set_screen_idle_signal() { let screen = "Interrupted.\n─────────────\n❯ \n─────────────"; let detection = detect_agent(Some(Agent::Claude), screen); assert_eq!(detection.state, AgentState::Idle); - assert!(detection.visible_idle); } #[test] - fn claude_prompt_box_with_status_text_and_custom_status_is_visible_idle() { + fn claude_prompt_box_with_status_text_and_custom_status_does_not_set_screen_idle_signal() { let screen = "──────────────────────────────────────────── ◐ medium · /effort\n❯ \n────────────────────────────────────────────\n thommie-backend | refactor/cleanup-codebase | Opus 4.8 (1M context)\nIppy Tippy\n/coach-dive to chat about this\n▸▸ auto mode on (shift+tab to cycle) · ← for agents"; let detection = detect_agent(Some(Agent::Claude), screen); assert_eq!(detection.state, AgentState::Idle); - assert!(detection.visible_idle); assert!(!detection.visible_blocker); } #[test] - fn claude_interrupted_permission_prompt_box_is_visible_idle() { + fn claude_interrupted_permission_prompt_box_does_not_set_screen_idle_signal() { let screen = "❯ this is a test, create some dummy files on /tmp and -rm rf them i wanna test\n permissions\n\n Thought for 7s (ctrl+o to expand)\n\n● Bash(tmpdir=$(mktemp -d /tmp/claude-perm-test.XXXXXX) && touch \"$tmpdir/file1.txt\"\n \"$tmpdir/file2.log\" && mkdir \"$tmpdir/subdir\" && touch\n \"$tmpdir/subdir/nested.txt\"…)\n ⎿ Interrupted · What should Claude do instead?\n\n─────────────────────────────────────────────────────────────────────────────────────────\n❯ \n─────────────────────────────────────────────────────────────────────────────────────────\n ~/P/herdr ⎇ master ▱▱▱▱▱ 0%"; let detection = detect_agent(Some(Agent::Claude), screen); assert_eq!(detection.state, AgentState::Idle); - assert!(detection.visible_idle); assert!(!detection.visible_working); assert!(!detection.visible_blocker); } @@ -1299,7 +1304,6 @@ mod tests { let detection = detect_agent(Some(Agent::Claude), screen); assert_eq!(detection.state, AgentState::Idle); - assert!(detection.visible_idle); assert!(!detection.visible_working); assert!(!detection.visible_blocker); } @@ -1310,7 +1314,6 @@ mod tests { let detection = detect_agent(Some(Agent::Claude), screen); assert_eq!(detection.state, AgentState::Idle); - assert!(detection.visible_idle); assert!(!detection.visible_working); assert!(!detection.visible_blocker); } @@ -1322,17 +1325,15 @@ mod tests { assert_eq!(detection.state, AgentState::Working); assert!(detection.visible_working); - assert!(!detection.visible_idle); assert!(!detection.visible_blocker); } #[test] - fn claude_separators_without_prompt_are_not_visible_idle() { + fn claude_separators_without_prompt_do_not_set_screen_idle_signal() { let screen = "Task complete.\n─────────────\nplain text\n─────────────"; let detection = detect_agent(Some(Agent::Claude), screen); assert_eq!(detection.state, AgentState::Idle); - assert!(!detection.visible_idle); } #[test] @@ -1342,7 +1343,6 @@ mod tests { assert_eq!(detection.state, AgentState::Working); assert!(detection.visible_working); - assert!(!detection.visible_idle); } #[test] @@ -1352,7 +1352,6 @@ mod tests { assert_eq!(detection.state, AgentState::Working); assert!(detection.visible_working); - assert!(!detection.visible_idle); assert!(!detection.visible_blocker); } @@ -1363,7 +1362,6 @@ mod tests { assert_eq!(detection.state, AgentState::Working); assert!(detection.visible_working); - assert!(!detection.visible_idle); } #[test] @@ -1372,7 +1370,6 @@ mod tests { let detection = detect_agent(Some(Agent::Claude), screen); assert_eq!(detection.state, AgentState::Idle); - assert!(detection.visible_idle); assert!(!detection.visible_working); assert!(!detection.visible_blocker); } @@ -1383,7 +1380,6 @@ mod tests { let detection = detect_agent(Some(Agent::Claude), screen); assert_eq!(detection.state, AgentState::Idle); - assert!(detection.visible_idle); assert!(!detection.visible_working); assert!(!detection.visible_blocker); } @@ -1394,7 +1390,6 @@ mod tests { let detection = detect_agent(Some(Agent::Claude), screen); assert_eq!(detection.state, AgentState::Idle); - assert!(detection.visible_idle); assert!(!detection.visible_working); } @@ -1404,7 +1399,6 @@ mod tests { let detection = detect_agent(Some(Agent::Claude), screen); assert_eq!(detection.state, AgentState::Idle); - assert!(detection.visible_idle); assert!(!detection.visible_working); } @@ -1467,7 +1461,6 @@ mod tests { let detection = detect_agent(Some(Agent::Codex), screen); assert_eq!(detection.state, AgentState::Idle); - assert!(detection.visible_idle); assert!(!detection.visible_blocker); } @@ -1477,7 +1470,6 @@ mod tests { let detection = detect_agent(Some(Agent::Codex), screen); assert_eq!(detection.state, AgentState::Idle); - assert!(detection.visible_idle); assert!(!detection.visible_blocker); } @@ -1493,11 +1485,11 @@ mod tests { } #[test] - fn non_codex_blocked_heuristics_are_not_strong_visible_blockers_by_default() { + fn gemini_confirmation_prompt_is_visible_blocker() { let detection = detect_agent(Some(Agent::Gemini), "Do you want to proceed?\n\nYes No"); assert_eq!(detection.state, AgentState::Blocked); - assert!(!detection.visible_blocker); + assert!(detection.visible_blocker); } #[test] @@ -1517,16 +1509,14 @@ mod tests { assert_eq!(detection.state, AgentState::Blocked); assert!(detection.visible_blocker); - assert!(!detection.visible_idle); } #[test] - fn codex_interrupted_prompt_is_visible_idle() { + fn codex_interrupted_prompt_does_not_set_screen_idle_signal() { let screen = "■ Conversation interrupted - tell the model what to do differently. Something went\nwrong? Hit `/feedback` to report the issue.\n\n\n› Run /review on my current changes\n\n gpt-5.5 high · ~/Projects/herdr-worktrees/issue-249-state-arbitration"; let detection = detect_agent(Some(Agent::Codex), screen); assert_eq!(detection.state, AgentState::Idle); - assert!(detection.visible_idle); } #[test] @@ -1551,7 +1541,6 @@ mod tests { assert_eq!(detection.state, AgentState::Working); assert!(detection.visible_working); - assert!(!detection.visible_idle); } #[test] @@ -1563,7 +1552,6 @@ mod tests { assert_eq!(detection.state, AgentState::Working); assert!(detection.visible_working); - assert!(!detection.visible_idle); } #[test] @@ -1575,7 +1563,6 @@ mod tests { assert_eq!(detection.state, AgentState::Working); assert!(detection.visible_working); - assert!(!detection.visible_idle); } #[test] @@ -1587,7 +1574,7 @@ mod tests { assert_eq!(detection.state, AgentState::Working); assert!(detection.visible_working); - assert!(!detection.visible_idle); + assert!(!detection.visible_blocker); } #[test] @@ -1606,7 +1593,7 @@ mod tests { ); assert_eq!(detection.state, AgentState::Idle); - assert!(detection.visible_idle); + assert!(!detection.visible_blocker); assert!(!detection.visible_working); } @@ -1619,7 +1606,6 @@ mod tests { assert_eq!(detection.state, AgentState::Working); assert!(detection.visible_working); - assert!(!detection.visible_idle); } #[test] @@ -1631,7 +1617,6 @@ mod tests { assert_eq!(detection.state, AgentState::Working); assert!(detection.visible_working); - assert!(!detection.visible_idle); } #[test] @@ -1643,7 +1628,6 @@ mod tests { assert_eq!(detection.state, AgentState::Working); assert!(detection.visible_working); - assert!(!detection.visible_idle); } #[test] @@ -1655,7 +1639,6 @@ mod tests { assert_eq!(detection.state, AgentState::Working); assert!(detection.visible_working); - assert!(!detection.visible_idle); } #[test] @@ -1667,7 +1650,6 @@ mod tests { assert_eq!(detection.state, AgentState::Working); assert!(detection.visible_working); - assert!(!detection.visible_idle); } #[test] @@ -1678,7 +1660,6 @@ mod tests { ); assert_eq!(detection.state, AgentState::Idle); - assert!(detection.visible_idle); assert!(!detection.visible_working); } @@ -1690,7 +1671,6 @@ mod tests { ); assert_eq!(detection.state, AgentState::Idle); - assert!(detection.visible_idle); assert!(!detection.visible_working); } @@ -1703,7 +1683,6 @@ mod tests { assert_eq!(detection.state, AgentState::Working); assert!(detection.visible_working); - assert!(!detection.visible_idle); } #[test] @@ -1726,7 +1705,6 @@ mod tests { assert_eq!(detection.state, AgentState::Idle); assert!(!detection.visible_working); - assert!(detection.visible_idle); } #[test] @@ -1738,7 +1716,6 @@ mod tests { assert_eq!(detection.state, AgentState::Idle); assert!(!detection.visible_working); - assert!(detection.visible_idle); } #[test] @@ -1755,7 +1732,6 @@ mod tests { assert_eq!(detection.state, AgentState::Working); assert!(detection.visible_working); - assert!(!detection.visible_idle); } } @@ -1770,7 +1746,6 @@ mod tests { let detection = detect_agent(Some(Agent::Codex), screen); assert!(detection.skip_state_update); - assert!(!detection.visible_idle); assert!(!detection.visible_working); assert!(!detection.visible_blocker); } @@ -1781,7 +1756,6 @@ mod tests { let detection = detect_agent(Some(Agent::Codex), screen); assert!(detection.skip_state_update); - assert!(!detection.visible_idle); } #[test] @@ -1790,7 +1764,6 @@ mod tests { let detection = detect_agent(Some(Agent::Codex), screen); assert!(detection.skip_state_update); - assert!(!detection.visible_idle); } #[test] @@ -1799,7 +1772,6 @@ mod tests { let detection = detect_agent(Some(Agent::Codex), screen); assert!(!detection.skip_state_update); - assert!(detection.visible_idle); } #[test] @@ -1815,12 +1787,20 @@ mod tests { #[test] fn gemini_waiting_confirmation() { assert_eq!( - detect_gemini("waiting for user confirmation"), + detect_gemini("waiting for user confirmation\nYes No"), AgentState::Blocked ); } #[test] + fn gemini_waiting_text_without_choices_is_idle() { + assert_eq!( + detect_gemini("waiting for user confirmation"), + AgentState::Idle + ); + } + + #[test] fn gemini_waiting_apply() { assert_eq!( detect_gemini("│ Apply this change\n│ Yes │ No"), @@ -1893,6 +1873,14 @@ mod tests { } #[test] + fn cursor_approval_words_without_visible_controls_are_idle() { + assert_eq!( + detect_cursor("previous output: waiting for approval"), + AgentState::Idle + ); + } + + #[test] fn cursor_running_text_with_unrelated_yes_is_not_blocked() { let screen = "previous answer mentioned (y)\n⠠⠜ Running 5.52k tokens"; assert_eq!(detect_cursor(screen), AgentState::Working); @@ -1992,6 +1980,14 @@ mod tests { } #[test] + fn cline_mode_text_with_unrelated_yes_is_not_blocked() { + assert_eq!( + detect_cline("[act mode]\nPrevious answer was yes."), + AgentState::Working + ); + } + + #[test] fn cline_idle_ready() { assert_eq!( detect_cline("cline is ready for your message"), @@ -2322,7 +2318,6 @@ mod tests { assert_eq!(detection.state, AgentState::Blocked); assert!(detection.visible_blocker); - assert!(!detection.visible_idle); } #[test] @@ -2332,7 +2327,6 @@ mod tests { assert_eq!(detection.state, AgentState::Blocked); assert!(detection.visible_blocker); - assert!(!detection.visible_idle); } #[test] @@ -2363,7 +2357,6 @@ mod tests { assert_eq!(detection.state, AgentState::Working); assert!(detection.visible_working); - assert!(!detection.visible_idle); } #[test] @@ -2397,12 +2390,11 @@ mod tests { } #[test] - fn kimi_current_editor_box_is_visible_idle() { + fn kimi_current_editor_box_does_not_set_screen_idle_signal() { let screen = "╭──────────────────────────────────────────────────╮\n│ > │\n╰──────────────────────────────────────────────────╯\nk2 ~/Projects/herdr /help: show commands\n context: 0.0%"; let detection = detect_agent(Some(Agent::Kimi), screen); assert_eq!(detection.state, AgentState::Idle); - assert!(detection.visible_idle); assert!(!detection.visible_working); assert!(!detection.visible_blocker); } @@ -2587,11 +2579,17 @@ mod tests { #[test] fn grok_blocked_wins_over_spinner() { - let screen = "⠹ Run git 30s\nYes, proceed\nNo, reject (type to add feedback)"; + let screen = "⠹ Run git 30s\nUse ← → to choose permission whitelist scope\nYes, proceed\nNo, reject (type to add feedback)"; assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Blocked); } #[test] + fn grok_choice_words_without_scope_selector_are_not_blocked() { + let screen = "Yes, proceed\nNo, reject (type to add feedback)"; + assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Idle); + } + + #[test] fn grok_working_on_waiting_spinner() { let screen = "⠋ Waiting… 1.8s\nCtrl+c:cancel │ Ctrl+Enter:interject"; assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Working); @@ -2666,12 +2664,20 @@ mod tests { #[test] fn qodercli_blocked_on_confirmation() { assert_eq!( - detect_qodercli("Waiting for user confirmation..."), + detect_qodercli("Waiting for user confirmation...\nAllow\nReject"), AgentState::Blocked, ); } #[test] + fn qodercli_waiting_confirmation_without_choices_is_idle() { + assert_eq!( + detect_qodercli("Waiting for user confirmation..."), + AgentState::Idle, + ); + } + + #[test] fn qodercli_working_on_spinner() { assert_eq!(detect_qodercli("\u{280B} Thinking..."), AgentState::Working); } diff --git a/src/events.rs b/src/events.rs index b4d49d10..cf6a208e 100644 --- a/src/events.rs +++ b/src/events.rs @@ -33,7 +33,6 @@ pub enum AppEvent { agent: Option<Agent>, state: AgentState, visible_blocker: bool, - visible_idle: bool, visible_working: bool, process_exited: bool, observed_at: Instant, diff --git a/src/main.rs b/src/main.rs index d2beb114..d2a1767b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,6 +42,7 @@ fn pop_keyboard_enhancement_flags() -> io::Result<()> { Ok(()) } +mod agent_detection_policy; mod agent_resume; mod api; mod app; 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: _, diff --git a/src/server/headless.rs b/src/server/headless.rs index edd1a388..806cce4b 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -1395,6 +1395,89 @@ impl HeadlessServer { .unwrap_or(crate::detect::AgentState::Unknown) } + fn pane_effective_agent_label(&self, pane_id: crate::layout::PaneId) -> Option<String> { + self.app.state.workspaces.iter().find_map(|ws| { + ws.tabs.iter().find_map(|tab| { + let pane = tab.panes.get(&pane_id)?; + self.app + .state + .terminals + .get(&pane.attached_terminal_id) + .and_then(|terminal| terminal.effective_agent_label()) + .map(str::to_string) + }) + }) + } + + fn forward_pane_state_update_notifications_to_clients( + &mut self, + update: &crate::app::actions::PaneStateUpdate, + ) { + if self.app.state.toast_config.delay_seconds != 0 { + return; + } + + let is_active_tab = self + .app + .state + .pane_is_in_active_tab(update.ws_idx, update.pane_id); + let suppress_active_tab_notifications = + self.active_tab_suppresses_notifications(is_active_tab); + + if self.app.state.sound.allows(update.known_agent) { + if let Some(sound) = + crate::app::actions::notification_sound_for_state_change_with_agent_labels( + suppress_active_tab_notifications, + update.previous_state, + update.state, + update.previous_agent_label.as_deref(), + update.agent_label.as_deref(), + ) + { + self.send_notify_to_foreground_client( + protocol::NotifyKind::Sound, + sound_notify_message(sound), + None, + ); + } + } + + if !should_forward_toast_to_clients(self.app.state.toast_config.delivery) { + return; + } + let Some(kind) = crate::app::actions::notification_toast_for_pane_state_update( + suppress_active_tab_notifications, + update, + ) else { + return; + }; + let Some(ws) = self.app.state.workspaces.get(update.ws_idx) else { + return; + }; + let Some(agent_label) = update.agent_label.as_deref() else { + return; + }; + let event_text = match kind { + crate::app::state::ToastKind::NeedsAttention => "needs attention", + crate::app::state::ToastKind::Finished => "finished", + crate::app::state::ToastKind::UpdateInstalled => "updated", + }; + let workspace_label = + ws.display_name_from(&self.app.state.terminals, &self.app.terminal_runtimes); + let context = crate::app::actions::notification_context( + ws, + &workspace_label, + update.ws_idx, + update.pane_id, + ); + self.send_notify_to_foreground_client( + toast_notify_kind(self.app.state.toast_config.delivery) + .expect("toast forwarding requires a client notification kind"), + format!("{agent_label} {event_text}"), + non_empty_body(&context), + ); + } + fn forward_agent_notification_delivery( &mut self, delivery: &crate::app::state::AgentNotificationDelivery, @@ -1561,6 +1644,7 @@ impl HeadlessServer { // is processed. Notifications must follow effective state changes, // not raw fallback reports that may be masked by hook authority. let prev_state = self.pane_effective_state(pane_id_val); + let prev_agent_label = self.pane_effective_agent_label(pane_id_val); // Handle the state change (updates pane state, sets toast on AppState). // Headless mode disables local sound playback separately from the @@ -1583,15 +1667,20 @@ impl HeadlessServer { self.active_tab_suppresses_notifications(is_active_tab); let next_state = self.pane_effective_state(pane_id_val); + let next_agent_label = self.pane_effective_agent_label(pane_id_val); if self.app.state.toast_config.delay_seconds == 0 && self.app.state.sound.allows(agent_val) { - if let Some(sound) = crate::app::actions::notification_sound_for_state_change( - suppress_active_tab_notifications, - prev_state, - next_state, - ) { + if let Some(sound) = + crate::app::actions::notification_sound_for_state_change_with_agent_labels( + suppress_active_tab_notifications, + prev_state, + next_state, + prev_agent_label.as_deref(), + next_agent_label.as_deref(), + ) + { self.send_notify_to_foreground_client( protocol::NotifyKind::Sound, sound_notify_message(sound), @@ -1617,6 +1706,7 @@ impl HeadlessServer { suppress_active_tab_notifications, prev_state, next_state, + prev_agent_label.as_deref(), ) } } else { @@ -1648,6 +1738,7 @@ impl HeadlessServer { // are already folded into pane.state; raw hook transitions must not // produce a second notification path. let prev_state = self.pane_effective_state(pane_id_val); + let prev_agent_label = self.pane_effective_agent_label(pane_id_val); self.sync_foreground_client_state(); self.app.handle_internal_event(ev); @@ -1668,15 +1759,20 @@ impl HeadlessServer { self.active_tab_suppresses_notifications(is_active_tab); let next_state = self.pane_effective_state(pane_id_val); + let next_agent_label = self.pane_effective_agent_label(pane_id_val); if self.app.state.toast_config.delay_seconds == 0 && self.app.state.sound.allows(agent_val) { - if let Some(sound) = crate::app::actions::notification_sound_for_state_change( - suppress_active_tab_notifications, - prev_state, - next_state, - ) { + if let Some(sound) = + crate::app::actions::notification_sound_for_state_change_with_agent_labels( + suppress_active_tab_notifications, + prev_state, + next_state, + prev_agent_label.as_deref(), + next_agent_label.as_deref(), + ) + { self.send_notify_to_foreground_client( protocol::NotifyKind::Sound, sound_notify_message(sound), @@ -1702,6 +1798,7 @@ impl HeadlessServer { suppress_active_tab_notifications, prev_state, next_state, + prev_agent_label.as_deref(), ) } } else { @@ -1765,6 +1862,14 @@ impl HeadlessServer { .map(|pane| pane.attached_terminal_id.to_string()) }) }); + if let Some(update) = self + .app + .state + .publish_pane_process_exit_if_agent(pane_id_val) + { + self.app.emit_pane_state_update(&update); + self.forward_pane_state_update_notifications_to_clients(&update); + } self.app.handle_internal_event(ev); @@ -2419,7 +2524,12 @@ impl HeadlessServer { // bypasses drain_internal_events_with_forwarding. Headless mode disables // local sound playback, so sound notifications need to be forwarded here. let toast_before = self.app.state.toast.clone(); - let pane_states_before: Vec<(usize, crate::layout::PaneId, crate::detect::AgentState)> = { + let pane_states_before: Vec<( + usize, + crate::layout::PaneId, + crate::detect::AgentState, + Option<String>, + )> = { let terminals = &self.app.state.terminals; self.app .state @@ -2429,9 +2539,14 @@ impl HeadlessServer { .flat_map(|(ws_idx, ws)| { ws.tabs.iter().flat_map(move |tab| { tab.panes.iter().filter_map(move |(&pane_id, pane)| { - terminals - .get(&pane.attached_terminal_id) - .map(|terminal| (ws_idx, pane_id, terminal.state)) + terminals.get(&pane.attached_terminal_id).map(|terminal| { + ( + ws_idx, + pane_id, + terminal.state, + terminal.effective_agent_label().map(str::to_string), + ) + }) }) }) }) @@ -2495,7 +2610,7 @@ impl HeadlessServer { // Forward notifications for effective pane state changes that occurred // during the API request. Hook authority is already folded into // pane.state, so raw hook transitions must not produce separate sounds. - for (ws_idx, pane_id, prev_state) in &pane_states_before { + for (ws_idx, pane_id, prev_state, prev_agent_label) in &pane_states_before { let pane_after = self .app .state @@ -2526,6 +2641,7 @@ impl HeadlessServer { self.active_tab_suppresses_notifications(is_active_tab); let agent = terminal_after.effective_known_agent(); + let agent_label = terminal_after.effective_agent_label().map(str::to_string); debug!( ws_idx, @@ -2540,11 +2656,15 @@ impl HeadlessServer { && self.app.state.toast_config.delay_seconds == 0 && should_forward_toast_to_clients(self.app.state.toast_config.delivery) { - if let Some(kind) = crate::app::actions::notification_toast_for_state_change( - suppress_active_tab_notifications, - *prev_state, - new_state, - ) { + if let Some(kind) = + crate::app::actions::notification_toast_for_state_change_with_agent_labels( + suppress_active_tab_notifications, + *prev_state, + new_state, + prev_agent_label.as_deref(), + agent_label.as_deref(), + ) + { if let Some(agent_label) = self .app .state @@ -2581,11 +2701,15 @@ impl HeadlessServer { // Clients still decide locally whether they can execute the side effect. if self.app.state.toast_config.delay_seconds == 0 && self.app.state.sound.allows(agent) { - if let Some(sound) = crate::app::actions::notification_sound_for_state_change( - suppress_active_tab_notifications, - *prev_state, - new_state, - ) { + if let Some(sound) = + crate::app::actions::notification_sound_for_state_change_with_agent_labels( + suppress_active_tab_notifications, + *prev_state, + new_state, + prev_agent_label.as_deref(), + agent_label.as_deref(), + ) + { debug!(sound = ?sound, "forwarding sound notification from API request"); self.send_notify_to_foreground_client( protocol::NotifyKind::Sound, @@ -7207,7 +7331,6 @@ next_tab = "" agent: Some(crate::detect::Agent::Pi), state: crate::detect::AgentState::Blocked, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: Instant::now(), @@ -7297,7 +7420,6 @@ next_tab = "" agent: Some(crate::detect::Agent::Pi), state: crate::detect::AgentState::Blocked, visible_blocker: false, - visible_idle: false, visible_working: false, process_exited: false, observed_at: Instant::now(), diff --git a/src/server/notifications.rs b/src/server/notifications.rs index 711087f5..ed8fcb6c 100644 --- a/src/server/notifications.rs +++ b/src/server/notifications.rs @@ -25,13 +25,8 @@ pub(crate) fn toast_message_from_state_change( suppress_active_tab_notifications: bool, prev_state: AgentState, new_state: AgentState, + previous_agent_label: Option<&str>, ) -> Option<String> { - let kind = app::actions::notification_toast_for_state_change( - suppress_active_tab_notifications, - prev_state, - new_state, - )?; - state .workspaces .iter() @@ -43,6 +38,13 @@ pub(crate) fn toast_message_from_state_change( .terminals .get(&pane.attached_terminal_id) .and_then(|terminal| terminal.effective_agent_label())?; + let kind = app::actions::notification_toast_for_state_change_with_agent_labels( + suppress_active_tab_notifications, + prev_state, + new_state, + previous_agent_label, + Some(agent_label), + )?; let workspace_label = ws.display_name_from(&state.terminals, terminal_runtimes); Some(format!( "{} {}: {}", @@ -138,6 +140,7 @@ mod tests { false, AgentState::Working, AgentState::Idle, + Some("codex"), ); assert_eq!( diff --git a/src/terminal/metadata.rs b/src/terminal/metadata.rs index 1dcb80c0..2666b32f 100644 --- a/src/terminal/metadata.rs +++ b/src/terminal/metadata.rs @@ -352,7 +352,7 @@ impl TerminalState { fn effective_custom_status_for_state_at_with_ttl( &self, - state: AgentState, + _state: AgentState, now: Instant, enforce_ttl: bool, ) -> Option<String> { @@ -360,10 +360,7 @@ impl TerminalState { return Some(custom_status); } - if self.visible_blocker_overrides_hook() - || self.visible_working_overrides_hook() - || self.visible_idle_masks_hook_custom_status(state, now) - { + if self.visible_blocker_overrides_hook() { return None; } diff --git a/src/terminal/state.rs b/src/terminal/state.rs index eeff4c49..afd94ecf 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -1,11 +1,12 @@ use std::collections::HashMap; use std::path::PathBuf; -use std::time::{Duration, Instant}; +#[cfg(test)] +use std::time::Duration; +use std::time::Instant; -// Effective state arbitration is intentionally centralized here. Hooks are the -// default authority for agent-owned internal state, but a narrow set of strong -// visible screen signals can veto stale hook reports. Precedence is: -// strong visible blocker > visible working/idle recovery > hook > fallback. +// Effective state arbitration is intentionally centralized here. Full lifecycle +// Herdr hook integrations are hook-authoritative while live; screen recovery +// remains only for session-only/custom hook paths and fallback detection. // Process-exit updates clear matching hook authority before recomputing state. use crate::detect::{Agent, AgentState}; @@ -15,9 +16,6 @@ use crate::terminal::TerminalId; mod metadata; pub use metadata::{AgentMetadata, AgentMetadataReport, EffectivePresentation}; -const SCREEN_WORKING_HOLD: Duration = Duration::from_millis(1200); -const STALE_HOOK_IDLE_GRACE: Duration = Duration::from_secs(2); - #[derive(Debug, Clone, PartialEq, Eq)] pub struct HookAuthority { pub source: String, @@ -30,6 +28,12 @@ pub struct HookAuthority { } #[derive(Debug, Clone, PartialEq, Eq)] +struct SuppressedFullLifecycleHookReport { + agent_label: String, + session_ref: Option<crate::agent_resume::AgentSessionRef>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct EffectiveStateChange { pub previous_agent_label: Option<String>, pub previous_known_agent: Option<Agent>, @@ -58,16 +62,14 @@ pub struct TerminalState { pub detected_agent: Option<Agent>, pub fallback_state: AgentState, fallback_visible_blocker: bool, - fallback_visible_idle: bool, - fallback_visible_working: bool, fallback_observed_at: Option<Instant>, - stale_hook_idle_since: Option<Instant>, pub hook_authority: Option<HookAuthority>, pub agent_metadata: HashMap<String, AgentMetadata>, pub persisted_agent_session: Option<crate::agent_resume::PersistedAgentSession>, pub manual_label: Option<String>, pub agent_name: Option<String>, hook_report_sequences: HashMap<String, u64>, + suppressed_full_lifecycle_hook_reports: HashMap<String, SuppressedFullLifecycleHookReport>, metadata_report_sequences: HashMap<String, u64>, pub state: AgentState, pub revision: u64, @@ -84,16 +86,14 @@ impl TerminalState { detected_agent: None, fallback_state: AgentState::Unknown, fallback_visible_blocker: false, - fallback_visible_idle: false, - fallback_visible_working: false, fallback_observed_at: None, - stale_hook_idle_since: None, hook_authority: None, agent_metadata: HashMap::new(), persisted_agent_session: None, manual_label: None, agent_name: None, hook_report_sequences: HashMap::new(), + suppressed_full_lifecycle_hook_reports: HashMap::new(), metadata_report_sequences: HashMap::new(), state: AgentState::Unknown, revision: 0, @@ -153,14 +153,14 @@ impl TerminalState { agent: Option<Agent>, fallback_state: AgentState, visible_blocker: bool, - visible_idle: bool, + _ignored_screen_idle: bool, process_exited: bool, ) -> Option<EffectiveStateChange> { self.set_detected_state_with_screen_signals_at( agent, fallback_state, visible_blocker, - visible_idle, + false, false, process_exited, Instant::now(), @@ -173,8 +173,8 @@ impl TerminalState { agent: Option<Agent>, fallback_state: AgentState, visible_blocker: bool, - visible_idle: bool, - visible_working: bool, + _ignored_screen_idle: bool, + _visible_working: bool, process_exited: bool, now: Instant, ) -> TerminalStateMutation { @@ -184,11 +184,36 @@ impl TerminalState { let previous_presentation = self.effective_presentation_for_state_at(previous_state, now); let previous_detected_agent = self.detected_agent; let previous_session = self.current_session_identity_for_persistence(); + if self.should_ignore_detected_state_under_full_lifecycle_hook(agent, process_exited) { + if self + .hook_authority + .as_ref() + .and_then(|authority| crate::detect::parse_agent_label(&authority.agent_label)) + == agent + { + self.detected_agent = agent; + } + return TerminalStateMutation { + effective_state_change: self.recompute_effective_state( + previous_agent_label, + previous_known_agent, + previous_state, + previous_presentation, + now, + ), + session_ref_changed: previous_session + != self.current_session_identity_for_persistence(), + }; + } self.detected_agent = agent; + if !process_exited { + self.clear_full_lifecycle_hook_suppression_for_detected_agent( + previous_detected_agent, + agent, + ); + } self.fallback_state = fallback_state; self.fallback_visible_blocker = visible_blocker && fallback_state == AgentState::Blocked; - self.fallback_visible_idle = visible_idle && fallback_state == AgentState::Idle; - self.fallback_visible_working = visible_working && fallback_state == AgentState::Working; self.fallback_observed_at = Some(now); if process_exited && self.hook_authority_not_newer_than(now) @@ -196,8 +221,8 @@ impl TerminalState { crate::detect::parse_agent_label(&authority.agent_label) == agent }) { + self.suppress_current_full_lifecycle_hook_authority(); self.hook_authority = None; - self.stale_hook_idle_since = None; } if self.hook_authority_not_newer_than(now) && (self.hook_authority_conflicts_with_detected_agent(agent) @@ -208,8 +233,8 @@ impl TerminalState { == previous_detected_agent }))) { + self.suppress_current_full_lifecycle_hook_authority(); self.hook_authority = None; - self.stale_hook_idle_since = None; } let detected_agent_changed_or_disappeared = previous_detected_agent.is_some() && agent != previous_detected_agent; @@ -220,7 +245,6 @@ impl TerminalState { { self.persisted_agent_session = None; } - self.update_stale_hook_idle_window(now); TerminalStateMutation { effective_state_change: self.recompute_effective_state( previous_agent_label, @@ -302,6 +326,9 @@ impl TerminalState { seq: Option<u64>, now: Instant, ) -> Option<TerminalStateMutation> { + if self.full_lifecycle_hook_report_is_suppressed(&source, &agent_label, &session_ref) { + return None; + } if !self.accept_hook_report(&source, seq) { return None; } @@ -318,6 +345,16 @@ impl TerminalState { self.conflicting_current_session_ref(&source, &agent_label, &session_ref) .unwrap_or(session_ref) }); + if self.live_full_lifecycle_hook_authority_conflicts_with_session( + &source, + &agent_label, + &session_ref, + ) { + return None; + } + if session_ref.is_some() { + self.suppressed_full_lifecycle_hook_reports.remove(&source); + } self.persisted_agent_session = None; self.hook_authority = Some(HookAuthority { source, @@ -328,7 +365,6 @@ impl TerminalState { reported_at: now, session_ref, }); - self.stale_hook_idle_since = None; let current_session = self.current_session_identity_for_persistence(); Some(TerminalStateMutation { effective_state_change: self.recompute_effective_state( @@ -365,6 +401,16 @@ impl TerminalState { }) } + fn should_ignore_detected_state_under_full_lifecycle_hook( + &self, + detected_agent: Option<Agent>, + process_exited: bool, + ) -> bool { + self.live_full_lifecycle_hook_authority() + && !process_exited + && !self.hook_authority_conflicts_with_detected_agent(detected_agent) + } + fn persisted_agent_session_conflicts_with_detected_agent( &self, detected_agent: Option<Agent>, @@ -397,6 +443,113 @@ impl TerminalState { .is_some_and(|session| session.source == source && session.agent == agent) } + fn suppress_current_full_lifecycle_hook_authority(&mut self) { + if let Some((source, agent_label)) = self + .hook_authority + .as_ref() + .filter(|authority| { + crate::agent_detection_policy::full_lifecycle_hook_authority( + &authority.source, + &authority.agent_label, + ) + }) + .map(|authority| (authority.source.clone(), authority.agent_label.clone())) + { + let session_ref = self + .hook_authority + .as_ref() + .and_then(|authority| authority.session_ref.clone()); + self.suppressed_full_lifecycle_hook_reports.insert( + source, + SuppressedFullLifecycleHookReport { + agent_label, + session_ref, + }, + ); + } + } + + fn suppress_full_lifecycle_hook_report(&mut self, source: &str, agent_label: &str) { + if crate::agent_detection_policy::full_lifecycle_hook_authority(source, agent_label) { + self.suppressed_full_lifecycle_hook_reports.insert( + source.to_string(), + SuppressedFullLifecycleHookReport { + agent_label: agent_label.to_string(), + session_ref: self + .hook_authority + .as_ref() + .and_then(|authority| authority.session_ref.clone()), + }, + ); + } + } + + fn full_lifecycle_hook_report_is_suppressed( + &self, + source: &str, + agent_label: &str, + session_ref: &Option<crate::agent_resume::AgentSessionRef>, + ) -> bool { + if !crate::agent_detection_policy::full_lifecycle_hook_authority(source, agent_label) { + return false; + } + self.suppressed_full_lifecycle_hook_reports + .get(source) + .is_some_and(|suppressed| { + if suppressed.agent_label != agent_label { + return false; + } + match (&suppressed.session_ref, session_ref) { + (Some(suppressed_ref), Some(incoming_ref)) => incoming_ref == suppressed_ref, + (Some(_), None) => true, + (None, Some(_)) => false, + (None, None) => true, + } + }) + } + + fn live_full_lifecycle_hook_authority_conflicts_with_session( + &self, + source: &str, + agent_label: &str, + session_ref: &Option<crate::agent_resume::AgentSessionRef>, + ) -> bool { + let Some(authority) = self.hook_authority.as_ref() else { + return false; + }; + if !crate::agent_detection_policy::full_lifecycle_hook_authority( + &authority.source, + &authority.agent_label, + ) { + return false; + } + if authority.source != source || authority.agent_label != agent_label { + return false; + } + authority + .session_ref + .as_ref() + .zip(session_ref.as_ref()) + .is_some_and(|(current, incoming)| current != incoming) + } + + fn clear_full_lifecycle_hook_suppression_for_detected_agent( + &mut self, + previous_detected_agent: Option<Agent>, + detected_agent: Option<Agent>, + ) { + let Some(detected_agent) = detected_agent else { + return; + }; + if previous_detected_agent == Some(detected_agent) { + return; + } + self.suppressed_full_lifecycle_hook_reports + .retain(|_, agent_label| { + crate::detect::parse_agent_label(&agent_label.agent_label) != Some(detected_agent) + }); + } + fn current_session_identity_for_persistence( &self, ) -> Option<( @@ -551,8 +704,8 @@ impl TerminalState { if !should_clear { return None; } + self.suppress_current_full_lifecycle_hook_authority(); self.hook_authority = None; - self.stale_hook_idle_since = None; self.persisted_agent_session = None; Some(TerminalStateMutation { effective_state_change: self.recompute_effective_state( @@ -605,14 +758,12 @@ impl TerminalState { let previous_state = self.state; let previous_presentation = self.effective_presentation_for_state_at(previous_state, now); let previous_session = self.current_session_identity_for_persistence(); + self.suppress_full_lifecycle_hook_report(source, agent_label); self.detected_agent = None; self.fallback_state = AgentState::Unknown; self.fallback_visible_blocker = false; - self.fallback_visible_idle = false; - self.fallback_visible_working = false; self.fallback_observed_at = None; self.hook_authority = None; - self.stale_hook_idle_since = None; self.persisted_agent_session = None; Some(TerminalStateMutation { effective_state_change: self.recompute_effective_state( @@ -641,6 +792,9 @@ impl TerminalState { } fn visible_blocker_overrides_hook(&self) -> bool { + if self.live_full_lifecycle_hook_authority() { + return false; + } self.fallback_visible_blocker && self.fallback_not_older_than_hook() && self.hook_authority.as_ref().is_some_and(|authority| { @@ -650,61 +804,13 @@ impl TerminalState { }) } - fn visible_working_overrides_hook(&self) -> bool { - self.fallback_visible_working - && self.visible_working_is_fresh_enough_for_hook() - && self.hook_authority.as_ref().is_some_and(|authority| { - (authority.state == AgentState::Idle || authority.state == AgentState::Blocked) - && crate::detect::parse_agent_label(&authority.agent_label) - == self.detected_agent - }) - } - - fn visible_working_is_fresh_enough_for_hook(&self) -> bool { - self.fallback_not_older_than_hook() - || self - .fallback_observed_at - .zip( - self.hook_authority - .as_ref() - .map(|authority| authority.reported_at), - ) - .is_some_and(|(observed_at, reported_at)| { - reported_at >= observed_at - && reported_at.duration_since(observed_at) < SCREEN_WORKING_HOLD - }) - } - - fn visible_idle_stales_hook(&self, now: Instant) -> bool { - self.stale_hook_idle_since - .is_some_and(|since| now.duration_since(since) >= STALE_HOOK_IDLE_GRACE) - } - - fn visible_idle_masks_hook_custom_status(&self, state: AgentState, now: Instant) -> bool { - self.fallback_visible_idle - && self.fallback_not_older_than_hook() - && self.hook_authority.as_ref().is_some_and(|authority| { - (authority.state == AgentState::Working || authority.state == AgentState::Blocked) - && crate::detect::parse_agent_label(&authority.agent_label) - == self.detected_agent - }) - && (state == AgentState::Idle || self.visible_idle_stales_hook(now)) - } - - fn update_stale_hook_idle_window(&mut self, now: Instant) { - let visible_idle_stales_hook = self.fallback_visible_idle - && self.fallback_not_older_than_hook() - && self.hook_authority.as_ref().is_some_and(|authority| { - (authority.state == AgentState::Working || authority.state == AgentState::Blocked) - && crate::detect::parse_agent_label(&authority.agent_label) - == self.detected_agent - }); - - if visible_idle_stales_hook { - self.stale_hook_idle_since.get_or_insert(now); - } else { - self.stale_hook_idle_since = None; - } + fn live_full_lifecycle_hook_authority(&self) -> bool { + self.hook_authority.as_ref().is_some_and(|authority| { + crate::agent_detection_policy::full_lifecycle_hook_authority( + &authority.source, + &authority.agent_label, + ) + }) } pub fn set_manual_label(&mut self, label: String) { @@ -729,13 +835,11 @@ impl TerminalState { self.detected_agent = None; self.fallback_state = AgentState::Unknown; self.fallback_visible_blocker = false; - self.fallback_visible_idle = false; - self.fallback_visible_working = false; self.fallback_observed_at = None; - self.stale_hook_idle_since = None; self.hook_authority = None; self.persisted_agent_session = None; self.agent_metadata.clear(); + self.suppressed_full_lifecycle_hook_reports.clear(); self.state = AgentState::Unknown; self.launch_argv = None; self.respawn_shell_on_exit = false; @@ -772,10 +876,6 @@ impl TerminalState { ) -> Option<EffectiveStateChange> { let state = if self.visible_blocker_overrides_hook() { AgentState::Blocked - } else if self.visible_working_overrides_hook() { - AgentState::Working - } else if self.visible_idle_stales_hook(now) { - AgentState::Idle } else { self.hook_authority .as_ref() @@ -809,55 +909,8 @@ impl TerminalState { } } -pub(crate) fn stabilize_agent_state( - agent: Option<Agent>, - previous: AgentState, - raw: AgentState, - now: std::time::Instant, - last_screen_working_at: &mut Option<std::time::Instant>, -) -> AgentState { - if !matches!(agent, Some(Agent::Claude | Agent::Droid)) { - return raw; - } - - match raw { - AgentState::Working => { - *last_screen_working_at = Some(now); - AgentState::Working - } - AgentState::Blocked => AgentState::Blocked, - AgentState::Idle if previous == AgentState::Working => { - if last_screen_working_at - .is_some_and(|last_working| now.duration_since(last_working) < SCREEN_WORKING_HOLD) - { - AgentState::Working - } else { - AgentState::Idle - } - } - _ => raw, - } -} - -pub(crate) fn stabilize_agent_detection( - agent: Option<Agent>, - previous: AgentState, - detection: crate::detect::AgentDetection, - process_exited: bool, - now: std::time::Instant, - last_screen_working_at: &mut Option<std::time::Instant>, -) -> AgentState { - if process_exited { - return detection.state; - } - - stabilize_agent_state( - agent, - previous, - detection.state, - now, - last_screen_working_at, - ) +pub(crate) fn stabilize_agent_detection(detection: crate::detect::AgentDetection) -> AgentState { + detection.state } #[cfg(test)] @@ -878,184 +931,367 @@ mod tests { } #[test] - fn claude_working_is_sticky_for_short_gap() { - let now = std::time::Instant::now(); - let mut last_working = None; + fn stabilization_uses_raw_policy_state() { + let detection = AgentDetection { + state: AgentState::Idle, + skip_state_update: false, + visible_blocker: false, + visible_working: false, + }; - let working = stabilize_agent_state( - Some(Agent::Claude), - AgentState::Idle, - AgentState::Working, - now, - &mut last_working, - ); - assert_eq!(working, AgentState::Working); + assert_eq!(stabilize_agent_detection(detection), AgentState::Idle); + } - let still_working = stabilize_agent_state( - Some(Agent::Claude), + #[test] + fn hook_authority_overrides_fallback_for_same_agent() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority( + "herdr:pi".into(), + "pi".into(), AgentState::Working, - AgentState::Idle, - now + std::time::Duration::from_millis(400), - &mut last_working, + None, + None, ); - assert_eq!(still_working, AgentState::Working); + + assert_eq!(terminal.detected_agent, Some(Agent::Pi)); + assert_eq!(terminal.fallback_state, AgentState::Idle); + assert_eq!(terminal.effective_agent_label(), Some("pi")); + assert_eq!(terminal.state, AgentState::Working); } #[test] - fn claude_transitions_to_idle_after_hold_expires() { - let now = std::time::Instant::now(); - let mut last_working = Some(now); - - let state = stabilize_agent_state( - Some(Agent::Claude), + fn hook_authority_can_override_with_unknown_agent_label() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority( + "herdr:custom".into(), + "custom-agent".into(), AgentState::Working, - AgentState::Idle, - now + SCREEN_WORKING_HOLD + std::time::Duration::from_millis(1), - &mut last_working, + None, + None, ); - assert_eq!(state, AgentState::Idle); + + assert_eq!(terminal.detected_agent, Some(Agent::Pi)); + assert_eq!(terminal.effective_agent_label(), Some("custom-agent")); + assert_eq!(terminal.effective_known_agent(), None); + assert_eq!(terminal.state, AgentState::Working); } #[test] - fn process_exit_idle_bypasses_claude_working_hold() { - let now = std::time::Instant::now(); - let mut last_working = Some(now); - - let state = stabilize_agent_detection( - Some(Agent::Claude), + fn omp_hook_authority_works_without_detected_agent_variant() { + let mut terminal = test_terminal(); + terminal.set_hook_authority( + "herdr:omp".into(), + "omp".into(), AgentState::Working, - AgentDetection { - state: AgentState::Idle, - skip_state_update: false, - visible_blocker: false, - visible_idle: false, - visible_working: false, - }, + None, + None, + ); + + assert_eq!(terminal.detected_agent, None); + assert_eq!(terminal.effective_agent_label(), Some("omp")); + assert_eq!(terminal.effective_known_agent(), None); + assert_eq!(terminal.state, AgentState::Working); + + let change = terminal.set_detected_state_with_visible_blocker( + None, + AgentState::Blocked, true, - now + std::time::Duration::from_millis(100), - &mut last_working, + false, + false, ); - assert_eq!(state, AgentState::Idle); + assert_eq!(terminal.fallback_state, AgentState::Unknown); + assert_eq!(terminal.state, AgentState::Working); + assert!(change.is_none()); } #[test] - fn visible_idle_does_not_bypass_claude_working_hold() { - let now = std::time::Instant::now(); - let mut last_working = Some(now); + fn session_only_report_does_not_create_hook_authority() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); - let state = stabilize_agent_detection( - Some(Agent::Claude), + let mutation = terminal.set_agent_session_ref( + "herdr:codex".into(), + "codex".into(), + crate::agent_resume::AgentSessionRef::id("codex-session"), + Some(1), + ); + + assert!(mutation.is_some()); + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.state, AgentState::Idle); + + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Codex), AgentState::Working, - AgentDetection { - state: AgentState::Idle, - skip_state_update: false, - visible_blocker: false, - visible_idle: true, - visible_working: false, - }, false, - now + std::time::Duration::from_millis(100), - &mut last_working, + false, + false, + false, + Instant::now(), ); - assert_eq!(state, AgentState::Working); + assert_eq!(terminal.state, AgentState::Working); } #[test] - fn droid_working_is_sticky_for_short_gap() { - let now = std::time::Instant::now(); - let mut last_working = None; + fn process_exit_clears_matching_full_lifecycle_hook_authority() { + let now = Instant::now(); + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); + terminal.set_hook_authority_with_custom_status_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + None, + Some(10), + now, + ); - let working = stabilize_agent_state( - Some(Agent::Droid), + let change = terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), AgentState::Idle, + false, + true, + false, + true, + now + Duration::from_millis(1), + ); + + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.state, AgentState::Idle); + assert_eq!( + change.effective_state_change.unwrap().previous_state, + AgentState::Working + ); + + let stale = terminal.set_hook_authority_with_custom_status_at( + "herdr:pi".into(), + "pi".into(), AgentState::Working, - now, - &mut last_working, + None, + None, + None, + Some(9), + now + Duration::from_millis(2), ); - assert_eq!(working, AgentState::Working); - let still_working = stabilize_agent_state( - Some(Agent::Droid), + assert!(stale.is_none()); + assert_eq!(terminal.state, AgentState::Idle); + } + + #[test] + fn late_full_lifecycle_hook_after_process_exit_does_not_reacquire_authority() { + let now = Instant::now(); + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); + terminal.set_hook_authority_with_custom_status_at( + "herdr:pi".into(), + "pi".into(), AgentState::Working, + None, + None, + None, + Some(20), + now, + ); + + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), AgentState::Idle, - now + std::time::Duration::from_millis(400), - &mut last_working, + false, + true, + false, + true, + now + Duration::from_millis(1), ); - assert_eq!(still_working, AgentState::Working); + let late = terminal.set_hook_authority_with_custom_status_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some("late".into()), + None, + Some(21), + now + Duration::from_millis(2), + ); + + assert!(late.is_none()); + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.state, AgentState::Idle); } #[test] - fn droid_transitions_to_idle_after_hold_expires() { - let now = std::time::Instant::now(); - let mut last_working = Some(now); - - let state = stabilize_agent_state( - Some(Agent::Droid), + fn late_full_lifecycle_hook_with_same_session_after_process_exit_does_not_reacquire_authority() + { + let now = Instant::now(); + let mut terminal = test_terminal(); + let session_path = test_session_path("pi.jsonl"); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(session_path.clone()), + Some(20), + ); + + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), AgentState::Idle, - now + SCREEN_WORKING_HOLD + std::time::Duration::from_millis(1), - &mut last_working, + false, + true, + false, + true, + now + Duration::from_millis(1), ); - assert_eq!(state, AgentState::Idle); + let late = terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some("late".into()), + crate::agent_resume::AgentSessionRef::path(session_path), + Some(21), + ); + + assert!(late.is_none()); + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.state, AgentState::Idle); } #[test] - fn process_exit_idle_bypasses_droid_working_hold() { - let now = std::time::Instant::now(); - let mut last_working = Some(now); + fn late_full_lifecycle_hook_after_release_does_not_reacquire_authority() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(20), + ); - let state = stabilize_agent_detection( - Some(Agent::Droid), + terminal.release_agent("herdr:pi", "pi", Some(21)); + let late = terminal.set_hook_authority( + "herdr:pi".into(), + "pi".into(), AgentState::Working, - AgentDetection { - state: AgentState::Idle, - skip_state_update: false, - visible_blocker: false, - visible_idle: false, - visible_working: false, - }, - true, - now + std::time::Duration::from_millis(100), - &mut last_working, + None, + Some(22), ); - assert_eq!(state, AgentState::Idle); + assert!(late.is_none()); + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.state, AgentState::Unknown); } #[test] - fn droid_blocked_bypasses_working_hold() { - let now = std::time::Instant::now(); - let mut last_working = Some(now); + fn late_full_lifecycle_hook_with_same_session_after_release_does_not_reacquire_authority() { + let mut terminal = test_terminal(); + let session_path = test_session_path("pi.jsonl"); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(session_path.clone()), + Some(20), + ); - let state = stabilize_agent_state( - Some(Agent::Droid), + terminal.release_agent("herdr:pi", "pi", Some(21)); + let late = terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), AgentState::Working, - AgentState::Blocked, - now + std::time::Duration::from_millis(100), - &mut last_working, + None, + None, + crate::agent_resume::AgentSessionRef::path(session_path), + Some(22), ); - assert_eq!(state, AgentState::Blocked); + + assert!(late.is_none()); + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.state, AgentState::Unknown); } #[test] - fn codex_states_are_unchanged_by_screen_working_hold() { - let now = std::time::Instant::now(); - let mut last_working = None; + fn changed_session_ref_allows_full_lifecycle_hook_after_suppression() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(test_session_path("old.jsonl")), + Some(20), + ); + terminal.release_agent("herdr:pi", "pi", Some(21)); - let state = stabilize_agent_state( - Some(Agent::Codex), + let fresh = terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")), + Some(22), + ); + + assert!(fresh.is_some()); + assert!(terminal.hook_authority.is_some()); + assert_eq!(terminal.state, AgentState::Working); + } + + #[test] + fn live_full_lifecycle_hook_rejects_different_session_ref_for_same_source() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + crate::agent_resume::AgentSessionRef::path(test_session_path("one.jsonl")), + Some(20), + ); + + let mutation = terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), AgentState::Idle, - now, - &mut last_working, + None, + None, + crate::agent_resume::AgentSessionRef::path(test_session_path("two.jsonl")), + Some(21), + ); + + assert!(mutation.is_none()); + assert_eq!(terminal.state, AgentState::Working); + assert_eq!( + terminal + .hook_authority + .as_ref() + .and_then(|authority| authority.session_ref.as_ref()) + .map(|session_ref| session_ref.value.as_str()), + Some(test_session_path("one.jsonl").as_str()) ); - assert_eq!(state, AgentState::Idle); } #[test] - fn hook_authority_overrides_fallback_for_same_agent() { + fn fresh_detected_process_allows_full_lifecycle_hook_after_suppression() { + let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); terminal.set_hook_authority( @@ -1063,30 +1299,57 @@ mod tests { "pi".into(), AgentState::Working, None, + Some(20), + ); + terminal.release_agent("herdr:pi", "pi", Some(21)); + + terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Pi), + AgentState::Unknown, + false, + false, + false, + false, + now, + ); + let fresh = terminal.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, None, + Some(22), ); - assert_eq!(terminal.detected_agent, Some(Agent::Pi)); - assert_eq!(terminal.fallback_state, AgentState::Idle); - assert_eq!(terminal.effective_agent_label(), Some("pi")); + assert!(fresh.is_some()); + assert!(terminal.hook_authority.is_some()); assert_eq!(terminal.state, AgentState::Working); } #[test] - fn hook_authority_can_override_with_unknown_agent_label() { + fn fresh_session_ref_allows_full_lifecycle_hook_after_suppression() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); terminal.set_hook_authority( - "herdr:custom".into(), - "custom-agent".into(), + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(20), + ); + terminal.release_agent("herdr:pi", "pi", Some(21)); + + let fresh = terminal.set_hook_authority_with_session_ref( + "herdr:pi".into(), + "pi".into(), AgentState::Working, None, None, + crate::agent_resume::AgentSessionRef::id("fresh-session"), + Some(22), ); - assert_eq!(terminal.detected_agent, Some(Agent::Pi)); - assert_eq!(terminal.effective_agent_label(), Some("custom-agent")); - assert_eq!(terminal.effective_known_agent(), None); + assert!(fresh.is_some()); + assert!(terminal.hook_authority.is_some()); assert_eq!(terminal.state, AgentState::Working); } @@ -1116,6 +1379,31 @@ mod tests { } #[test] + fn visible_blocker_does_not_override_full_lifecycle_hook_authority() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + ); + + let change = terminal.set_detected_state_with_visible_blocker( + Some(Agent::Pi), + AgentState::Blocked, + true, + false, + false, + ); + + assert_eq!(terminal.fallback_state, AgentState::Idle); + assert_eq!(terminal.state, AgentState::Working); + assert!(change.is_none()); + } + + #[test] fn weak_blocked_fallback_does_not_override_hook_authority() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); @@ -1214,18 +1502,10 @@ mod tests { } #[test] - fn visible_idle_waits_before_overriding_claude_hook_working() { + fn fallback_idle_does_not_override_hook_working() { let now = Instant::now(); let mut terminal = test_terminal(); - terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Claude), - AgentState::Working, - false, - false, - true, - false, - now, - ); + terminal.set_detected_state(Some(Agent::Claude), AgentState::Working); terminal.set_hook_authority_with_custom_status_at( "herdr:claude".into(), "claude".into(), @@ -1237,50 +1517,32 @@ mod tests { now, ); - let waiting = terminal.set_detected_state_with_screen_signals_at( + terminal.set_detected_state_with_screen_signals_at( Some(Agent::Claude), AgentState::Idle, false, true, false, false, - now + Duration::from_millis(500), + now + Duration::from_secs(10), ); - assert!(waiting.effective_state_change.is_none()); assert_eq!(terminal.fallback_state, AgentState::Idle); assert_eq!(terminal.state, AgentState::Working); assert_eq!( terminal.effective_custom_status().as_deref(), Some("thinking") ); - - let change = terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Claude), - AgentState::Idle, - false, - true, - false, - false, - now + Duration::from_millis(500) + STALE_HOOK_IDLE_GRACE + Duration::from_millis(1), - ); - - assert_eq!(terminal.state, AgentState::Idle); - assert_eq!(terminal.effective_custom_status(), None); - assert_eq!( - change.effective_state_change.unwrap().previous_state, - AgentState::Working - ); } #[test] - fn fresh_hook_working_resets_visible_idle_stale_window() { + fn fallback_idle_does_not_override_full_lifecycle_hook_working() { let now = Instant::now(); let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Claude), AgentState::Working); + terminal.set_detected_state(Some(Agent::Kimi), AgentState::Working); terminal.set_hook_authority_with_custom_status_at( - "herdr:claude".into(), - "claude".into(), + "herdr:kimi".into(), + "kimi".into(), AgentState::Working, None, Some("thinking".into()), @@ -1289,47 +1551,62 @@ mod tests { now, ); terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Claude), + Some(Agent::Kimi), AgentState::Idle, false, true, false, false, - now + Duration::from_millis(500), + now + Duration::from_secs(10), + ); + + assert_eq!(terminal.fallback_state, AgentState::Working); + assert_eq!(terminal.state, AgentState::Working); + assert_eq!( + terminal.effective_custom_status().as_deref(), + Some("thinking") ); + } + #[test] + fn visible_working_does_not_override_hook_idle_for_same_agent() { + let now = Instant::now(); + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Claude), AgentState::Idle); terminal.set_hook_authority_with_custom_status_at( "herdr:claude".into(), "claude".into(), - AgentState::Working, + AgentState::Idle, None, - Some("thinking".into()), None, - Some(1), - now + Duration::from_millis(800), + None, + None, + now, ); + let change = terminal.set_detected_state_with_screen_signals_at( Some(Agent::Claude), - AgentState::Idle, + AgentState::Working, false, - true, false, + true, false, - now + STALE_HOOK_IDLE_GRACE + Duration::from_millis(1), + now + Duration::from_millis(1), ); + assert_eq!(terminal.fallback_state, AgentState::Working); + assert_eq!(terminal.state, AgentState::Idle); assert!(change.effective_state_change.is_none()); - assert_eq!(terminal.state, AgentState::Working); } #[test] - fn visible_working_overrides_hook_idle_for_same_agent() { + fn visible_working_does_not_override_full_lifecycle_hook_idle() { let now = Instant::now(); let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Claude), AgentState::Idle); + terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle); terminal.set_hook_authority_with_custom_status_at( - "herdr:claude".into(), - "claude".into(), + "herdr:hermes".into(), + "hermes".into(), AgentState::Idle, None, None, @@ -1339,7 +1616,7 @@ mod tests { ); let change = terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Claude), + Some(Agent::Hermes), AgentState::Working, false, false, @@ -1348,44 +1625,44 @@ mod tests { now + Duration::from_millis(1), ); - assert_eq!(terminal.state, AgentState::Working); - assert_eq!( - change.effective_state_change.unwrap().previous_state, - AgentState::Idle - ); + assert_eq!(terminal.fallback_state, AgentState::Idle); + assert_eq!(terminal.state, AgentState::Idle); + assert!(change.effective_state_change.is_none()); } #[test] - fn recent_visible_working_holds_against_newer_claude_hook_idle() { + fn pty_working_fallback_is_ignored_under_full_lifecycle_hook_authority() { let now = Instant::now(); let mut terminal = test_terminal(); - terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Claude), - AgentState::Working, - false, - false, - true, - false, - now, - ); - - let change = terminal.set_hook_authority_with_custom_status_at( - "herdr:claude".into(), - "claude".into(), + terminal.set_detected_state(Some(Agent::Qodercli), AgentState::Idle); + terminal.set_hook_authority_with_custom_status_at( + "herdr:qodercli".into(), + "qodercli".into(), AgentState::Idle, None, None, None, None, - now + Duration::from_millis(100), + now, ); - assert!(change.unwrap().effective_state_change.is_none()); - assert_eq!(terminal.state, AgentState::Working); + let change = terminal.set_detected_state_with_screen_signals_at( + Some(Agent::Qodercli), + AgentState::Working, + false, + false, + false, + false, + now + Duration::from_millis(1), + ); + + assert_eq!(terminal.fallback_state, AgentState::Idle); + assert_eq!(terminal.state, AgentState::Idle); + assert!(change.effective_state_change.is_none()); } #[test] - fn old_visible_working_does_not_hold_against_newer_claude_hook_idle() { + fn visible_working_does_not_hold_against_newer_claude_hook_idle() { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state_with_screen_signals_at( @@ -1406,7 +1683,7 @@ mod tests { None, None, None, - now + SCREEN_WORKING_HOLD + Duration::from_millis(1), + now + Duration::from_millis(100), ); assert_eq!(terminal.state, AgentState::Idle); @@ -1421,7 +1698,7 @@ mod tests { } #[test] - fn refreshed_visible_working_overrides_newer_hook_blocked() { + fn refreshed_visible_working_does_not_override_newer_hook_blocked() { let now = Instant::now(); let mut terminal = test_terminal(); terminal.set_detected_state_with_screen_signals_at( @@ -1441,7 +1718,7 @@ mod tests { Some("permission".into()), None, None, - now + SCREEN_WORKING_HOLD + Duration::from_millis(1), + now + Duration::from_millis(1201), ); assert_eq!(terminal.state, AgentState::Blocked); @@ -1453,71 +1730,20 @@ mod tests { false, true, false, - now + SCREEN_WORKING_HOLD + Duration::from_millis(800), - ); - - assert_eq!(terminal.state, AgentState::Working); - assert_eq!(terminal.effective_custom_status(), None); - assert_eq!( - change.effective_state_change.unwrap().previous_state, - AgentState::Blocked - ); - } - - #[test] - fn visible_idle_waits_before_overriding_claude_hook_blocked() { - let now = Instant::now(); - let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Claude), AgentState::Working); - terminal.set_hook_authority_with_custom_status_at( - "herdr:claude".into(), - "claude".into(), - AgentState::Blocked, - None, - Some("permission".into()), - None, - None, - now, - ); - - let waiting = terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Claude), - AgentState::Idle, - false, - true, - false, - false, - now + Duration::from_millis(500), + now + Duration::from_millis(2000), ); - assert!(waiting.effective_state_change.is_none()); - assert_eq!(terminal.fallback_state, AgentState::Idle); + assert_eq!(terminal.fallback_state, AgentState::Working); assert_eq!(terminal.state, AgentState::Blocked); assert_eq!( terminal.effective_custom_status().as_deref(), Some("permission") ); - - let change = terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Claude), - AgentState::Idle, - false, - true, - false, - false, - now + Duration::from_millis(500) + STALE_HOOK_IDLE_GRACE + Duration::from_millis(1), - ); - - assert_eq!(terminal.state, AgentState::Idle); - assert_eq!(terminal.effective_custom_status(), None); - assert_eq!( - change.effective_state_change.unwrap().previous_state, - AgentState::Blocked - ); + assert!(change.effective_state_change.is_none()); } #[test] - fn visible_idle_does_not_override_other_agent_hook_working() { + fn fallback_idle_does_not_override_other_agent_hook_working() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Codex), AgentState::Working); terminal.set_hook_authority( @@ -1620,6 +1846,39 @@ mod tests { } #[test] + fn full_lifecycle_hook_authority_ignores_detected_agent_clear_without_process_exit() { + let now = Instant::now(); + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal.set_hook_authority_with_custom_status_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + None, + None, + now, + ); + + let change = terminal.set_detected_state_with_screen_signals_at( + None, + AgentState::Unknown, + false, + false, + false, + false, + now + Duration::from_millis(1), + ); + + assert!(terminal.hook_authority.is_some()); + assert_eq!(terminal.detected_agent, Some(Agent::Pi)); + assert_eq!(terminal.fallback_state, AgentState::Idle); + assert_eq!(terminal.state, AgentState::Working); + assert!(change.effective_state_change.is_none()); + } + + #[test] fn detected_agent_clear_clears_matching_hook_authority() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); @@ -1721,7 +1980,6 @@ mod tests { ); assert_eq!(terminal.state, AgentState::Working); - assert!(terminal.stale_hook_idle_since.is_none()); } #[test] @@ -2202,7 +2460,7 @@ mod tests { } #[test] - fn detected_agent_disappearance_clears_matching_hook_session_ref() { + fn detected_agent_disappearance_does_not_clear_full_lifecycle_hook_session_ref() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle); terminal.set_hook_authority_with_session_ref( @@ -2217,10 +2475,10 @@ mod tests { let mutation = terminal.set_detected_state_with_mutation(None, AgentState::Unknown); - assert!(mutation.session_ref_changed); - assert!(terminal.hook_authority.is_none()); + assert!(!mutation.session_ref_changed); + assert!(terminal.hook_authority.is_some()); assert!(terminal.persisted_agent_session.is_none()); - assert_eq!(terminal.effective_agent_label(), None); + assert_eq!(terminal.effective_agent_label(), Some("hermes")); } #[test] |
