aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-08 20:55:51 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-09 04:26:19 +0300
commitee51c5e5fbaea694e65bb0a4908f75bcaf9707d1 (patch)
treef566d2a5921d4112602a7abcffb5cf6eb04d5f24 /src
parenta7cd7806f2fa0251e2dca41ffec61ceafb6f0e18 (diff)
feat: use pty-first agent detection
Diffstat (limited to 'src')
-rw-r--r--src/agent_detection_policy.rs288
-rw-r--r--src/app/actions.rs245
-rw-r--r--src/app/api.rs136
-rw-r--r--src/app/input/mouse.rs1
-rw-r--r--src/app/mod.rs2
-rw-r--r--src/detect/agents/amp.rs23
-rw-r--r--src/detect/agents/antigravity.rs15
-rw-r--r--src/detect/agents/claude_code.rs10
-rw-r--r--src/detect/agents/cline.rs14
-rw-r--r--src/detect/agents/codex.rs4
-rw-r--r--src/detect/agents/cursor.rs10
-rw-r--r--src/detect/agents/droid.rs27
-rw-r--r--src/detect/agents/gemini.rs35
-rw-r--r--src/detect/agents/github_copilot.rs14
-rw-r--r--src/detect/agents/grok.rs17
-rw-r--r--src/detect/agents/hermes.rs22
-rw-r--r--src/detect/agents/kilo.rs4
-rw-r--r--src/detect/agents/kimi.rs50
-rw-r--r--src/detect/agents/kiro.rs7
-rw-r--r--src/detect/agents/mod.rs28
-rw-r--r--src/detect/agents/opencode.rs6
-rw-r--r--src/detect/agents/qodercli.rs14
-rw-r--r--src/detect/mod.rs140
-rw-r--r--src/events.rs1
-rw-r--r--src/main.rs1
-rw-r--r--src/pane.rs576
-rw-r--r--src/server/headless.rs176
-rw-r--r--src/server/notifications.rs15
-rw-r--r--src/terminal/metadata.rs7
-rw-r--r--src/terminal/state.rs1052
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]