//! Poll-and-detect loop: tmux pane capture → detection engine → herdr's //! status model → notifications and pane-option write-back. //! //! Status model (herdr's, verbatim): five display statuses over two inputs — //! the raw agent state (working/blocked/idle, from hooks or scraping) and a //! `seen` flag (attended since the last state change): //! //! | raw | seen | display | priority | //! |----------|--------|----------|----------| //! | working | — | working | 2 | //! | blocked | — | blocked | 4 | //! | idle | false | done | 3 | //! | idle | true | idle | 1 | //! | none | — | unknown | 0 | //! //! "Seen" is maintained three ways: the pane is fully focused in an attached //! client (the daemon watches tmux focus), the user jumped to it via //! `dingger focus` / menu click, or its next transition. use std::collections::HashMap; use detect_core::{detect_agent_with_osc, AgentState}; use tracing::{debug, info, warn}; use crate::hooks; use crate::notify::{play_sound, send_notification, NotificationKind}; use crate::tmux; /// herdr's display statuses, in priority order (highest first). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Status { Unknown, Idle, Working, Done, Blocked, } impl Status { #[allow(dead_code)] // herdr parity: the full vocabulary is part of the model pub fn label(&self) -> &'static str { match self { Status::Unknown => "unknown", Status::Idle => "idle", Status::Working => "working", Status::Done => "done", Status::Blocked => "blocked", } } #[allow(dead_code)] // herdr parity pub fn parse(label: &str) -> Option { match label { "working" => Some(Status::Working), "blocked" => Some(Status::Blocked), "done" => Some(Status::Done), "idle" => Some(Status::Idle), "unknown" => Some(Status::Unknown), _ => None, } } /// herdr's priority: blocked > done > working > idle > unknown. #[allow(dead_code)] // herdr parity; used once the menu bar consumes statuses from the daemon pub fn priority(&self) -> u8 { match self { Status::Blocked => 4, Status::Done => 3, Status::Working => 2, Status::Idle => 1, Status::Unknown => 0, } } } /// herdr's notification policy (`notification_toast_for_state_change`), /// driven by raw state transitions. 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, } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct PaneTrack { state: AgentState, seen: bool, /// Unix second the pane entered its current done/blocked episode; drives /// the FIFO ready queue. ready_since: Option, } pub struct Watcher { tracks: HashMap, /// Last published (queue, counts); avoids rewriting options every tick. last_published: Option<(String, (String, String, String, String))>, interval_secs: u64, sounds: bool, } impl Watcher { pub fn new(interval_secs: u64, sounds: bool) -> Self { Self { tracks: HashMap::new(), last_published: None, interval_secs: interval_secs.max(1), sounds, } } pub fn run(&mut self) -> Result<(), String> { info!(interval = self.interval_secs, "watching tmux panes"); // dingger is a client of the tmux server, which may not exist yet — // it starts when the first terminal opens one. Wait for it, then // keep watching across server restarts. let mut had_server = false; loop { match self.tick() { Ok(()) => { if !had_server { info!("tmux server available"); had_server = true; } } Err(err) => { if had_server { warn!(%err, "tmux server unavailable; waiting for it to return"); } had_server = false; } } 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.tracks .retain(|pane_id, _| panes.iter().any(|pane| pane.pane_id == *pane_id)); hooks::forget( &panes .iter() .map(|pane| pane.pane_id.clone()) .collect::>(), ); for pane in &panes { let prev_track = self.tracks.get(&pane.pane_id).copied(); let (prev_state, prev_seen) = prev_track .map(|track| (track.state, track.seen)) .unwrap_or((AgentState::Unknown, true)); // Raw state: hook reports are authoritative while the pane runs // the agent; screen detection is the fallback for panes that // never report. 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 raw = if pane.dead { Some(AgentState::Idle) } else if let Some(agent) = pane.agent { if let Some(state) = hooks::hook_state(&pane.pane_id) { Some(state) } else { let screen = match tmux::capture_pane(&pane.pane_id) { Ok(screen) => screen, Err(err) => { warn!(target = %pane.display_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; a stale hook report // from the dead agent is not authoritative. Some(AgentState::Unknown) }; let Some(new_state) = raw else { debug!(target = %pane.display_target, "observation skipped (viewer screen)"); continue; }; // Seen: attended since the last state change. Attended means the // pane is fully focused in an attached client, the user jumped to // it (`@dingger_ack`), or it was just first observed. A first // observation of idle is seen (nothing has happened yet); a // working/blocked → idle transition is an unseen completion. let transition = new_state != prev_state; // Attended also via `dingger focus` / menu click (@dingger_ack). let mut seen = if transition { !(new_state == AgentState::Idle && prev_state == AgentState::Unknown) } else { prev_seen || pane.written_ack == "1" }; if !seen && pane.attended() { seen = true; } if transition || seen != prev_seen { let ready_since = if is_ready_to_attend(new_state, seen) { Some(unix_now()) } else { None }; self.tracks.insert( pane.pane_id.clone(), PaneTrack { state: new_state, seen, ready_since, }, ); } if transition { debug!(target = %pane.display_target, ?prev_state, ?new_state, seen, "state change"); let _ = tmux::clear_ack(&pane.pane_id); } else if seen != prev_seen { let _ = tmux::ack(&pane.pane_id); } let display = display_status( self.tracks .get(&pane.pane_id) .unwrap_or(&PaneTrack { state: new_state, seen, ready_since: None }), ); // @agent_state mirrors the display status on both transitions and // attendance changes (done → idle when the user focuses the pane). if pane.written_state != display.label() { if let Err(err) = tmux::set_agent_state(&pane.pane_id, display.label()) { warn!(target = %pane.display_target, %err, "failed to write @agent_state"); } } if new_state == AgentState::Unknown { // The agent is gone; its last hook report must not outlive it. hooks::drop_report(&pane.pane_id); } // Keep the pane's agent label current even when the state is // unchanged (e.g. an agent swap between two idle panes). let agent_label = pane .agent .map(detect_core::agent_label) .unwrap_or("") .to_string(); if pane.written_agent != agent_label { if let Err(err) = tmux::set_agent(&pane.pane_id, pane.agent) { warn!(target = %pane.display_target, %err, "failed to write @agent"); } } if transition { if let Some(kind) = notification_kind_for_transition(prev_state, new_state) { // herdr: a blocked pane plays the alert sound even when // the user is watching; a finished pane stays silent when // they are already looking at it. let suppressed = pane.suppresses_notifications(); if !suppressed { let context = format!("{} ({})", pane.display_target, agent_label); send_notification(&agent_label, &kind, &context); } if self.sounds { play_sound(&kind); } } } } self.publish(&panes); Ok(()) } /// Publish the FIFO queue (done + blocked panes, oldest transition first) /// and counts as server-global options for the tmux status line and /// `dingger focus`. fn publish(&mut self, panes: &[tmux::Pane]) { let mut ready: Vec<(&str, String, u64)> = Vec::new(); // (pane_id, target, ready_since) let mut blocked = 0usize; let mut done = 0usize; let mut working = 0usize; for pane in panes { let Some(track) = self.tracks.get(&pane.pane_id) else { continue; }; let status = display_status(track); match status { Status::Working => working += 1, Status::Done | Status::Blocked => { if track.ready_since.is_some() { if status == Status::Blocked { blocked += 1; } else { done += 1; } if let Some(since) = track.ready_since { ready.push((pane.pane_id.as_str(), pane.display_target.clone(), since)); } } } Status::Idle | Status::Unknown => {} } } ready.sort_by_key(|(_, _, since)| *since); let queue = ready .iter() .map(|(pane_id, _, since)| format!("{pane_id}:{since}")) .collect::>() .join(","); let counts = ( ready.len().to_string(), blocked.to_string(), working.to_string(), done.to_string(), ); let next = (queue.clone(), counts.clone()); if self.last_published.as_ref() != Some(&next) { self.last_published = Some(next); for (name, value) in [ ("@dingger_queue", queue.as_str()), ("@dingger_ready", counts.0.as_str()), ("@dingger_blocked", counts.1.as_str()), ("@dingger_working", counts.2.as_str()), ("@dingger_done", counts.3.as_str()), ] { if let Err(err) = tmux::set_global(name, value) { warn!(%err, "failed to write {name}"); } } } } } /// When a pane became ready for the user, as unix seconds. Ready means the /// agent is waiting on the user: done (finished, unseen) or blocked. fn is_ready_to_attend(state: AgentState, seen: bool) -> bool { match state { AgentState::Blocked => true, AgentState::Idle => !seen, _ => false, } } fn display_status(track: &PaneTrack) -> Status { match track.state { AgentState::Working => Status::Working, AgentState::Blocked => Status::Blocked, AgentState::Idle => { if track.seen { Status::Idle } else { Status::Done } } AgentState::Unknown => Status::Unknown, } } fn unix_now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } #[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()); } #[test] fn display_status_maps_herdr() { let unseen = PaneTrack { state: AgentState::Idle, seen: false, ready_since: Some(1) }; let seen = PaneTrack { state: AgentState::Idle, seen: true, ready_since: None }; assert_eq!(display_status(&unseen), Status::Done); assert_eq!(display_status(&seen), Status::Idle); assert_eq!( display_status(&PaneTrack { state: AgentState::Working, seen: false, ready_since: None }), Status::Working ); assert_eq!( display_status(&PaneTrack { state: AgentState::Blocked, seen: false, ready_since: Some(1) }), Status::Blocked ); } #[test] fn priority_matches_herdr() { assert!(Status::Blocked.priority() > Status::Done.priority()); assert!(Status::Done.priority() > Status::Working.priority()); assert!(Status::Working.priority() > Status::Idle.priority()); assert!(Status::Idle.priority() > Status::Unknown.priority()); } }