//! Poll-and-detect loop: tmux pane capture → detection engine → herdr's //! transition policy → notifications and `@agent_state` write-back. use std::collections::HashMap; use detect_core::{detect_agent_with_osc, AgentState}; use tracing::{debug, info, warn}; use crate::notify::{play_sound, send_notification, NotificationKind}; use crate::tmux; pub struct Watcher { /// Last published state per pane id. `status` reads the same values back /// from tmux's `@agent_state`, so no other state is needed. states: HashMap, interval_secs: u64, sounds: bool, } /// herdr's notification policy (`notification_toast_for_state_change`). pub fn notification_kind_for_transition( prev_state: AgentState, new_state: AgentState, ) -> Option { if new_state == prev_state { return None; } match new_state { AgentState::Blocked => Some(NotificationKind::NeedsAttention), AgentState::Idle if matches!(prev_state, AgentState::Working | AgentState::Blocked) => { Some(NotificationKind::Finished) } _ => None, } } impl Watcher { pub fn new(interval_secs: u64, sounds: bool) -> Self { Self { states: HashMap::new(), interval_secs: interval_secs.max(1), sounds, } } pub fn run(&mut self) -> Result<(), String> { // Fail fast when tmux is unreachable at startup; after that, keep // retrying — the server may come back. self.tick()?; info!(interval = self.interval_secs, "watching tmux panes"); loop { if let Err(err) = self.tick() { warn!(%err, "watch tick failed"); } std::thread::sleep(std::time::Duration::from_secs(self.interval_secs)); } } fn tick(&mut self) -> Result<(), String> { let panes = tmux::list_panes()?; // Forget panes that went away. self.states .retain(|pane_id, _| panes.iter().any(|pane| pane.pane_id == *pane_id)); for pane in panes { let prev_state = self .states .get(&pane.pane_id) .copied() .unwrap_or(AgentState::Unknown); // The detection result is an observation of live agent state. // Viewer screens (transcripts, pickers) return // `skip_state_update: true` — they say nothing about state and // leave the previous one standing (herdr's publishing contract). let observed = if pane.dead { Some(AgentState::Idle) } else if let Some(agent) = pane.agent { let screen = match tmux::capture_pane(&pane.pane_id) { Ok(screen) => screen, Err(err) => { warn!(target = %pane.target, %err, "capture failed"); continue; } }; // tmux pane_title carries the last OSC title the application // set; tmux does not re-expose OSC progress sequences. let detection = detect_agent_with_osc(Some(agent), &screen, &pane.pane_title, ""); (!detection.skip_state_update).then_some(detection.state) } else { // A pane that stops running an agent (agent quit into a // shell) is no longer an observation. Some(AgentState::Unknown) }; let Some(new_state) = observed else { debug!(target = %pane.target, "observation skipped (viewer screen)"); continue; }; if new_state == prev_state { continue; } self.states.insert(pane.pane_id.clone(), new_state); debug!(target = %pane.target, ?prev_state, ?new_state, "state change"); if let Err(err) = tmux::set_agent_state(&pane.pane_id, new_state) { warn!(target = %pane.target, %err, "failed to write @agent_state"); } let agent_label = pane .agent .map(detect_core::agent_label) .unwrap_or("agent") .to_string(); if let Some(kind) = notification_kind_for_transition(prev_state, new_state) { if pane.suppresses_notifications() { debug!(target = %pane.target, "suppressed (active pane)"); continue; } let context = format!("{} ({})", pane.target, agent_label); send_notification(&agent_label, &kind, &context); if self.sounds { play_sound(&kind); } } } Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn policy_matches_herdr() { assert!(notification_kind_for_transition(AgentState::Working, AgentState::Blocked) .is_some_and(|kind| matches!(kind, NotificationKind::NeedsAttention))); assert!(notification_kind_for_transition(AgentState::Blocked, AgentState::Blocked).is_none()); assert!(notification_kind_for_transition(AgentState::Unknown, AgentState::Blocked) .is_some_and(|kind| matches!(kind, NotificationKind::NeedsAttention))); assert!(notification_kind_for_transition(AgentState::Working, AgentState::Idle) .is_some_and(|kind| matches!(kind, NotificationKind::Finished))); assert!(notification_kind_for_transition(AgentState::Blocked, AgentState::Idle) .is_some_and(|kind| matches!(kind, NotificationKind::Finished))); // Unknown → Idle is a first observation, not a completion. assert!(notification_kind_for_transition(AgentState::Unknown, AgentState::Idle).is_none()); assert!(notification_kind_for_transition(AgentState::Idle, AgentState::Working).is_none()); assert!(notification_kind_for_transition(AgentState::Idle, AgentState::Unknown).is_none()); } }