//! 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: //! //! | raw | seen | display | priority | //! |----------|--------|----------|----------| //! | working | any | working | 2 | //! | blocked | any | blocked | 4 | //! | idle | false | done | 3 | //! | idle | true | idle | 1 | //! | none | any | unknown | 0 | //! //! `seen` follows herdr's `apply_pane_state_change` and //! `mark_active_tab_seen` (src/app/actions.rs); see [`advance`]. //! //! All per-pane state lives in tmux pane options, so a daemon restart picks //! up exactly where it left off: //! //! - `@agent_state` display status (what every surface renders) //! - `@dingger_since` unix ms the pane entered its current status //! - `@dingger_hook` last state an agent extension reported //! - `@dingger_ack` one-shot "the user jumped here" event from `dingger focus` //! - `@agent_session` the agent's session reference, for restore 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. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Status { Unknown, Idle, Working, Done, Blocked, } impl Status { pub fn label(self) -> &'static str { match self { Status::Unknown => "unknown", Status::Idle => "idle", Status::Working => "working", Status::Done => "done", Status::Blocked => "blocked", } } pub fn from_label(label: &str) -> Status { match label { "idle" => Status::Idle, "working" => Status::Working, "done" => Status::Done, "blocked" => Status::Blocked, _ => Status::Unknown, } } /// herdr's priority: blocked > done > working > idle > unknown. 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, } } /// herdr's per-pane attendance record. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PaneTrack { pub state: AgentState, pub seen: bool, /// Unix millisecond the pane entered its current status: the queue /// serves done and blocked oldest first; working and idle are listed /// most recent first. pub since: Option, } impl PaneTrack { /// A pane not observed yet. herdr: `PaneState::new` starts `seen = true`. pub const NEW: PaneTrack = PaneTrack { state: AgentState::Unknown, seen: true, since: None, }; pub fn status(&self) -> Status { match (self.state, self.seen) { (AgentState::Working, _) => Status::Working, (AgentState::Blocked, _) => Status::Blocked, (AgentState::Idle, false) => Status::Done, (AgentState::Idle, true) => Status::Idle, (AgentState::Unknown, _) => Status::Unknown, } } /// Rebuild a track from the pane options a previous daemon wrote. fn restore(written_state: &str, written_since: &str) -> PaneTrack { let since = written_since.trim().parse::().ok(); let (state, seen) = match written_state { "done" => (AgentState::Idle, false), "idle" => (AgentState::Idle, true), "working" => (AgentState::Working, true), "blocked" => (AgentState::Blocked, true), _ => return PaneTrack::NEW, }; PaneTrack { state, seen, since } } } /// One observation step for a pane: herdr's `apply_pane_state_change` /// followed by `mark_active_tab_seen`, as a pure function. /// /// - Transition to any non-idle state: seen. /// - Completion (working/blocked to idle): seen only if the user is looking /// at the pane's window; otherwise the pane is done. /// - Any other transition to idle (first observation): seen unchanged. /// - Looking at the pane's window, or jumping to it (`acked`): seen. pub fn advance( prev: PaneTrack, observed: AgentState, attended: bool, acked: bool, now: u64, ) -> PaneTrack { let mut next = prev; if observed != prev.state { next.state = observed; if observed != AgentState::Idle { next.seen = true; } else if matches!(prev.state, AgentState::Working | AgentState::Blocked) { next.seen = attended; } } if attended || acked { next.seen = true; } if next.status() != prev.status() || next.since.is_none() { next.since = Some(now); } next } /// The order agents are listed in: herdr's status priority; within a /// status, done and blocked oldest first (the queue: nothing waits /// forever), working and idle most recent first; then by place. pub fn list_order(status: Status, since: Option) -> (std::cmp::Reverse, i128) { let since = since.map(i128::from); let key = match status { Status::Done | Status::Blocked => since.unwrap_or(i128::MAX), _ => since.map(|since| -since).unwrap_or(i128::MAX), }; (std::cmp::Reverse(status.priority()), key) } pub struct Watcher { tracks: HashMap, snapshots: crate::snapshot::Recorder, offer: crate::restore::OfferPublisher, /// Last published (queue, blocked, done, working); avoids rewriting the /// global options every tick. last_published: Option<(String, usize, usize, usize)>, interval_secs: u64, sounds: bool, notifications: bool, } impl Watcher { pub fn new(interval_secs: u64, sounds: bool, notifications: bool) -> Self { Self { tracks: HashMap::new(), snapshots: Default::default(), offer: Default::default(), last_published: None, interval_secs: interval_secs.max(1), sounds, notifications, } } 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 { let started = std::time::Instant::now(); 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; } } // After the tick, so snapshots carry the states it just wrote. self.snapshots.tick(unix_now()); self.offer.tick(); debug!(ms = started.elapsed().as_millis() as u64, "tick"); hooks::wait_for_wake(std::time::Duration::from_secs(self.interval_secs)); } } fn tick(&mut self) -> Result<(), String> { let listed = std::time::Instant::now(); let panes = tmux::list_panes()?; let now = unix_now(); // 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 Some(observations) = observe(pane, listed) else { debug!(target = %pane.display_target, "observation skipped (viewer screen)"); continue; }; // First sight of a pane in this process: resume from its options. let restored = !self.tracks.contains_key(&pane.pane_id); let mut track = self .tracks .get(&pane.pane_id) .copied() .unwrap_or_else(|| PaneTrack::restore(&pane.written_state, &pane.written_since)); let acked = pane.written_ack == "1"; // Apply every observation in order: a turn that started and // finished between two ticks is still a completion. let mut alerts = Vec::new(); for observed in observations { let next = advance(track, observed, pane.attended, acked, now); if next.state != track.state { info!( target = %pane.display_target, prev = track.status().label(), next = next.status().label(), "state change" ); // Notify on transitions observed by this process, not // when resuming from options written before a restart. if !restored { alerts.extend(notification_kind_for_transition(track.state, next.state)); } } track = next; } self.tracks.insert(pane.pane_id.clone(), track); // Update what the user sees first, then alert. if acked { let _ = tmux::unset_pane_option(&pane.pane_id, "@dingger_ack"); } self.write_pane_options(pane, track); for kind in alerts { self.notify(pane, kind); } } self.publish(&panes); Ok(()) } /// Write the pane's options that changed, in one tmux command list: /// tmux applies it as a unit, so a daemon killed mid-write never leaves /// a display state that disagrees with the hook state it came from. fn write_pane_options(&self, pane: &tmux::Pane, track: PaneTrack) { let mut changes: Vec<(&str, Option)> = Vec::new(); let mut change = |name: &'static str, written: &str, value: String| { if written != value { changes.push((name, (!value.is_empty()).then_some(value))); } }; // The authoritative hook state, so a restart keeps it; it goes with // the agent. let hook = match pane.agent { Some(_) => hooks::last_state(&pane.pane_id).map(state_label).unwrap_or_default(), None => "", }; if pane.agent.is_none() || !hook.is_empty() { change("@dingger_hook", &pane.written_hook, hook.to_string()); } change("@agent_state", &pane.written_state, track.status().label().to_string()); change("@dingger_since", &pane.written_since, track.since.map(|s| s.to_string()).unwrap_or_default()); change("@agent", &pane.written_agent, pane.agent.map(detect_core::agent_label).unwrap_or("").to_string()); let name = crate::names::display_name(pane.agent, &pane.pane_title, &pane.cwd, host_name()) .unwrap_or_default(); change("@agent_name", &pane.written_name, name); // The session reference, for restore; it goes with the agent. match (pane.agent, hooks::session(&pane.pane_id)) { (Some(_), Some(session)) => change("@agent_session", &pane.written_session, session), (None, _) => change("@agent_session", &pane.written_session, String::new()), _ => {} } if let Err(err) = tmux::set_pane_options(&pane.pane_id, &changes) { warn!(target = %pane.display_target, %err, "failed to write pane options"); } } fn notify(&self, pane: &tmux::Pane, kind: NotificationKind) { // herdr: a blocked pane plays the alert even when the user is looking // at it; a finished pane stays silent when they already are. let watching = pane.attended; if matches!(kind, NotificationKind::Finished) && watching { return; } let agent = pane.agent.map(detect_core::agent_label).unwrap_or("agent"); if self.notifications && !watching { send_notification(agent, &kind, &format!("{} ({agent})", pane.display_target)); } if self.sounds { play_sound(&kind); } } /// Publish the ready queue and counts as server-global options for the /// status line, menu bar, and focus. The queue is ordered like the menu: /// herdr's priority (blocked before done), oldest first within it. fn publish(&mut self, panes: &[tmux::Pane]) { let mut queue: Vec<(std::cmp::Reverse, u64, &str)> = Vec::new(); let (mut blocked, mut done, mut working) = (0, 0, 0); for pane in panes { let Some(track) = self.tracks.get(&pane.pane_id) else { continue; }; match track.status() { Status::Blocked => blocked += 1, Status::Done => done += 1, Status::Working => working += 1, Status::Idle | Status::Unknown => {} } // The queue: what needs the user, oldest first. if matches!(track.status(), Status::Blocked | Status::Done) { let since = track.since.unwrap_or(u64::MAX); queue.push((std::cmp::Reverse(track.status().priority()), since, pane.pane_id.as_str())); } } queue.sort(); let queue = queue .iter() .map(|(_, _, pane_id)| *pane_id) .collect::>() .join(","); let next = (queue, blocked, done, working); if self.last_published.as_ref() == Some(&next) { return; } for (name, value) in [ ("@dingger_queue", next.0.clone()), ("@dingger_blocked", next.1.to_string()), ("@dingger_done", next.2.to_string()), ("@dingger_working", next.3.to_string()), ] { if let Err(err) = tmux::set_global(name, &value) { warn!(%err, "failed to write {name}"); } } self.last_published = Some(next); } } /// The pane's raw agent states this tick, in order, or `None` when the /// screen is a viewer (transcript, picker) that says nothing about state. fn observe(pane: &tmux::Pane, listed: std::time::Instant) -> Option> { if pane.dead { return Some(vec![AgentState::Idle]); } let Some(agent) = pane.agent else { // The agent is gone; its last hook report must not outlive it. hooks::drop_reports(&pane.pane_id, listed); return Some(vec![AgentState::Unknown]); }; // Agent-reported state is authoritative; it survives restarts through // the `@dingger_hook` option until the extension reports again. if let Some(states) = hooks::take_observations(&pane.pane_id) { return Some(states); } if let Some(state) = parse_state(&pane.written_hook) { return Some(vec![state]); } let screen = match tmux::capture_pane(&pane.pane_id) { Ok(screen) => screen, Err(err) => { warn!(target = %pane.display_target, %err, "capture failed"); return None; } }; // 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(|| vec![detection.state]) } /// The host name tmux uses as a pane's default title. fn host_name() -> &'static str { static HOST: std::sync::OnceLock = std::sync::OnceLock::new(); HOST.get_or_init(|| { std::process::Command::new("hostname") .output() .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string()) .unwrap_or_default() }) } fn state_label(state: AgentState) -> &'static str { match state { AgentState::Idle => "idle", AgentState::Working => "working", AgentState::Blocked => "blocked", AgentState::Unknown => "unknown", } } fn parse_state(label: &str) -> Option { match label { "idle" => Some(AgentState::Idle), "working" => Some(AgentState::Working), "blocked" => Some(AgentState::Blocked), _ => None, } } fn unix_now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0) } #[cfg(test)] mod tests { use super::*; use AgentState::{Blocked, Idle, Unknown, Working}; fn step(prev: PaneTrack, observed: AgentState, attended: bool) -> PaneTrack { advance(prev, observed, attended, false, 100) } #[test] fn notification_policy_matches_herdr() { assert!(matches!( notification_kind_for_transition(Working, Blocked), Some(NotificationKind::NeedsAttention) )); assert!(matches!( notification_kind_for_transition(Unknown, Blocked), Some(NotificationKind::NeedsAttention) )); assert!(matches!( notification_kind_for_transition(Working, Idle), Some(NotificationKind::Finished) )); assert!(matches!( notification_kind_for_transition(Blocked, Idle), Some(NotificationKind::Finished) )); assert!(notification_kind_for_transition(Blocked, Blocked).is_none()); assert!(notification_kind_for_transition(Unknown, Idle).is_none()); assert!(notification_kind_for_transition(Idle, Working).is_none()); assert!(notification_kind_for_transition(Idle, Unknown).is_none()); } // herdr: initial_idle_in_background_stays_seen #[test] fn first_observation_of_idle_is_idle() { let track = step(PaneTrack::NEW, Idle, false); assert_eq!(track.status(), Status::Idle); assert_eq!(track.since, Some(100)); } // herdr: state_changed_idle_in_background_marks_unseen #[test] fn completion_away_from_the_window_is_done() { let working = step(step(PaneTrack::NEW, Idle, false), Working, false); assert_eq!(working.status(), Status::Working); let done = step(working, Idle, false); assert_eq!(done.status(), Status::Done); assert_eq!(done.since, Some(100)); } // herdr: active_tab_completion_marks_pane_seen #[test] fn completion_while_looking_is_idle() { let working = step(PaneTrack::NEW, Working, true); let idle = advance(working, Idle, true, false, 150); assert_eq!(idle.status(), Status::Idle); assert_eq!(idle.since, Some(150), "idle since the turn ended"); } // herdr: switch_workspace_marks_panes_seen (mark_active_tab_seen) #[test] fn looking_at_the_window_turns_done_into_idle() { let done = step(step(PaneTrack::NEW, Working, false), Idle, false); assert_eq!(done.status(), Status::Done); let still_done = step(done, Idle, false); assert_eq!(still_done.status(), Status::Done); let seen = advance(still_done, Idle, true, false, 170); assert_eq!(seen.status(), Status::Idle); assert_eq!(seen.since, Some(170), "idle since the user looked"); } // herdr: agent_focus_marks_already_focused_done_agent_seen #[test] fn jumping_to_a_pane_turns_done_into_idle() { let done = step(step(PaneTrack::NEW, Working, false), Idle, false); let seen = advance(done, Idle, false, true, 200); assert_eq!(seen.status(), Status::Idle); } #[test] fn working_again_clears_done_and_the_next_completion_is_done_again() { let done = step(step(PaneTrack::NEW, Working, false), Idle, false); let working = advance(done, Working, false, false, 250); assert_eq!(working.status(), Status::Working); assert_eq!(working.since, Some(250)); let done_again = advance(working, Idle, false, false, 300); assert_eq!(done_again.status(), Status::Done); assert_eq!(done_again.since, Some(300)); } #[test] fn blocked_needs_the_user_even_while_looking() { let blocked = step(step(PaneTrack::NEW, Working, false), Blocked, true); assert_eq!(blocked.status(), Status::Blocked); assert_eq!(blocked.since, Some(100)); // answering the prompt: back to work, then a completion away → done let done = step(step(blocked, Working, false), Idle, false); assert_eq!(done.status(), Status::Done); } #[test] fn the_stamp_survives_unchanged_ticks() { let done = advance(step(PaneTrack::NEW, Working, false), Idle, false, false, 50); assert_eq!(advance(done, Idle, false, false, 999).since, Some(50)); let idle = advance(done, Idle, true, false, 60); assert_eq!(advance(idle, Idle, true, false, 999).since, Some(60)); } #[test] fn waiting_oldest_first_the_rest_most_recent_first() { let mut rows = [ ("idle old", Status::Idle, Some(10)), ("done new", Status::Done, Some(90)), ("idle unknown", Status::Idle, None), ("working old", Status::Working, Some(20)), ("idle new", Status::Idle, Some(80)), ("done old", Status::Done, Some(30)), ("blocked", Status::Blocked, Some(99)), ("working new", Status::Working, Some(70)), ]; rows.sort_by_key(|(_, status, since)| list_order(*status, *since)); let names: Vec<_> = rows.iter().map(|(name, ..)| *name).collect(); assert_eq!( names, ["blocked", "done old", "done new", "working new", "working old", "idle new", "idle old", "idle unknown"] ); } #[test] fn agent_exit_is_unknown() { let done = step(step(PaneTrack::NEW, Working, false), Idle, false); assert_eq!(step(done, Unknown, false).status(), Status::Unknown); } #[test] fn restore_resumes_where_the_previous_daemon_left_off() { let done = PaneTrack::restore("done", "42"); assert_eq!(done.status(), Status::Done); assert_eq!(done.since, Some(42)); // the same observation after a restart keeps it done assert_eq!(step(done, Idle, false).status(), Status::Done); assert_eq!(PaneTrack::restore("idle", "").status(), Status::Idle); assert_eq!(PaneTrack::restore("", "").status(), Status::Unknown); assert_eq!(PaneTrack::restore("idle", "42").since, Some(42), "recency survives a restart"); } #[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()); } }