aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src/watcher.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/dinggerd/src/watcher.rs')
-rw-r--r--crates/dinggerd/src/watcher.rs156
1 files changed, 156 insertions, 0 deletions
diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs
new file mode 100644
index 0000000..46f5ebd
--- /dev/null
+++ b/crates/dinggerd/src/watcher.rs
@@ -0,0 +1,156 @@
+//! 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<String, AgentState>,
+ 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<NotificationKind> {
+ 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());
+ }
+}