diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-19 16:02:57 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-19 16:02:57 +0300 |
| commit | ab33633ceb9e4bea3e292b2371241797d1587d02 (patch) | |
| tree | 7b03faf43e5714782e43d582a00626a6f0d852f1 /crates/dinggerd/src/watcher.rs | |
initial dingger: tmux agent watcher with herdr's detection engine
extract detect-core from herdr's src/detect (engine + 22 manifests,
tests unchanged) and add the dingger binary: tmux capture-pane watcher,
process-tree agent identification, herdr's notification policy, embedded
notification sounds, @agent_state write-back, and catalog-based manifest
refresh. viewer screens are non-observations per the engine contract;
reviewed by a three-model panel (opus 5.5, gpt 6.1 sol, kimi k3) with
all confirmed findings applied as deletions.
Diffstat (limited to 'crates/dinggerd/src/watcher.rs')
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 156 |
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()); + } +} |
