diff options
Diffstat (limited to 'crates/dinggerd/src')
| -rw-r--r-- | crates/dinggerd/src/main.rs | 100 | ||||
| -rw-r--r-- | crates/dinggerd/src/manifest_update.rs | 79 | ||||
| -rw-r--r-- | crates/dinggerd/src/notify.rs | 76 | ||||
| -rw-r--r-- | crates/dinggerd/src/tmux.rs | 177 | ||||
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 156 |
5 files changed, 588 insertions, 0 deletions
diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs new file mode 100644 index 0000000..f046732 --- /dev/null +++ b/crates/dinggerd/src/main.rs @@ -0,0 +1,100 @@ +//! dingger: agent state watching for tmux panes. + +mod manifest_update; +mod notify; +mod tmux; +mod watcher; + +use clap::{Parser, Subcommand}; +use tracing_subscriber::EnvFilter; + +#[derive(Parser)] +#[command(name = "dingger", about = "Agent state watching for tmux panes")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Watch all tmux panes and notify on agent state transitions. + Watch { + /// Poll interval in seconds. + #[arg(long, default_value_t = 1)] + interval_secs: u64, + /// Disable notification sounds. + #[arg(long)] + no_sounds: bool, + }, + /// Print the current agent state of every tmux pane. + Status, + /// Refresh detection manifests from herdr's published catalog. + Manifests { + #[command(subcommand)] + command: ManifestCommand, + }, +} + +#[derive(Subcommand)] +enum ManifestCommand { + /// Download newer manifests from herdr's published catalog into the + /// local remote store; bundled manifests are the offline fallback. + /// Restart `dingger watch` to pick them up. + Update, +} + +fn main() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) + .init(); + + match Cli::parse().command { + Command::Watch { + interval_secs, + no_sounds, + } => { + let mut watcher = watcher::Watcher::new(interval_secs, !no_sounds); + if let Err(err) = watcher.run() { + eprintln!("dingger: {err}"); + std::process::exit(1); + } + } + Command::Status => { + if let Err(err) = status() { + eprintln!("dingger: {err}"); + std::process::exit(1); + } + } + Command::Manifests { command } => match command { + ManifestCommand::Update => { + if let Err(err) = manifest_update::update() { + eprintln!("dingger: {err}"); + std::process::exit(1); + } + } + }, + } +} + +fn status() -> Result<(), String> { + let panes = tmux::list_panes()?; + if panes.is_empty() { + println!("no tmux panes"); + return Ok(()); + } + println!("{:<22} {:<10} {:<12} COMMAND", "TARGET", "AGENT", "STATE"); + for pane in panes { + println!( + "{:<22} {:<10} {:<12} {}", + pane.target, + pane.agent.map(detect_core::agent_label).unwrap_or("-"), + if pane.written_state.is_empty() { + "-" + } else { + &pane.written_state + }, + pane.current_command, + ); + } + Ok(()) +} diff --git a/crates/dinggerd/src/manifest_update.rs b/crates/dinggerd/src/manifest_update.rs new file mode 100644 index 0000000..b18a153 --- /dev/null +++ b/crates/dinggerd/src/manifest_update.rs @@ -0,0 +1,79 @@ +//! Refresh detection manifests from herdr's published catalog +//! (`https://herdr.dev/agent-detection/index.toml`). Bundled manifests remain +//! the offline fallback; downloads land in dingger's state dir where the +//! detection engine picks them up on the next watcher start. + +use std::process::Command; + +use serde::Deserialize; +use tracing::info; + +use detect_core::manifest_update::{remote_manifest_path, remote_store_dir}; +use detect_core::{agent_label, Agent}; + +const DEFAULT_CATALOG_URL: &str = "https://herdr.dev/agent-detection/index.toml"; +const CATALOG_URL_ENV: &str = "DINGGER_AGENT_DETECTION_MANIFEST_CATALOG_URL"; + +#[derive(Deserialize)] +struct ManifestCatalog { + #[serde(default)] + agents: Vec<ManifestCatalogAgent>, +} + +#[derive(Deserialize)] +struct ManifestCatalogAgent { + id: String, + path: String, +} + +fn catalog_url() -> String { + std::env::var(CATALOG_URL_ENV).unwrap_or_else(|_| DEFAULT_CATALOG_URL.to_string()) +} + +fn fetch_text(url: &str) -> Result<String, String> { + let output = Command::new("curl") + .args(["-fsSL", "--max-time", "30", url]) + .output() + .map_err(|err| format!("failed to run curl: {err}"))?; + if !output.status.success() { + return Err(format!("curl {url} failed: {}", output.status)); + } + String::from_utf8(output.stdout).map_err(|err| format!("non-utf8 response: {err}")) +} + +pub fn update() -> Result<(), String> { + let url = catalog_url(); + let catalog_text = fetch_text(&url)?; + let catalog: ManifestCatalog = toml::from_str(&catalog_text) + .map_err(|err| format!("invalid catalog: {err}"))?; + + let base = url.rsplit_once('/').map(|(prefix, _)| prefix).unwrap_or(""); + let store = remote_store_dir(); + std::fs::create_dir_all(&store).map_err(|err| format!("mkdir failed: {err}"))?; + + let mut stored = 0usize; + for entry in &catalog.agents { + let Some(agent) = Agent::ALL.iter().copied().find(|a| agent_label(*a) == entry.id) + else { + continue; + }; + let manifest_url = format!("{base}/{}", entry.path); + let content = match fetch_text(&manifest_url) { + Ok(content) => content, + Err(err) => { + tracing::warn!(agent = %entry.id, %err, "skipping manifest"); + continue; + } + }; + std::fs::write(remote_manifest_path(agent), content) + .map_err(|err| format!("write failed: {err}"))?; + stored += 1; + } + + info!(stored, "manifest update complete"); + println!( + "stored {stored} manifests into {}\nrestart `dingger watch` to pick them up", + store.display() + ); + Ok(()) +} diff --git a/crates/dinggerd/src/notify.rs b/crates/dinggerd/src/notify.rs new file mode 100644 index 0000000..9fcfcde --- /dev/null +++ b/crates/dinggerd/src/notify.rs @@ -0,0 +1,76 @@ +//! Notifications and sounds, mirroring herdr's macOS delivery. + +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use tracing::{debug, warn}; + +pub enum NotificationKind { + NeedsAttention, + Finished, +} + +impl NotificationKind { + pub fn title(&self) -> &'static str { + match self { + Self::NeedsAttention => "needs attention", + Self::Finished => "finished", + } + } + + /// herdr's bundled notification sounds; `request` for blockers, `done` + /// for completions. + fn sound_bytes(&self) -> &'static [u8] { + match self { + Self::NeedsAttention => include_bytes!("../assets/request.mp3"), + Self::Finished => include_bytes!("../assets/done.mp3"), + } + } +} + +static SOUND_SEQ: AtomicU64 = AtomicU64::new(0); + +pub fn play_sound(kind: &NotificationKind) { + // Sounds are embedded; afplay needs a file, so stage the bytes in the + // system temp dir. + let path = std::env::temp_dir().join(format!( + "dingger-{}-{}.mp3", + std::process::id(), + SOUND_SEQ.fetch_add(1, Ordering::Relaxed) + )); + if let Err(err) = std::fs::write(&path, kind.sound_bytes()) { + warn!(%err, "failed to stage notification sound"); + return; + } + match Command::new("afplay") + .arg(&path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + // Fire and forget: never let audio latency block the watch loop. + Ok(mut child) => { + std::thread::spawn(move || { + let _ = child.wait(); + let _ = std::fs::remove_file(&path); + }); + } + Err(err) => { + warn!(%err, "failed to play notification sound"); + let _ = std::fs::remove_file(&path); + } + } +} + +pub fn send_notification(agent_label: &str, kind: &NotificationKind, context: &str) { + debug!(agent = agent_label, kind = kind.title(), context, "notifying"); + let title = format!("{agent_label} {}", kind.title()); + let notification = mac_notification_sys::Notification::new() + .title(title.as_str()) + .message(context) + .send(); + if let Err(err) = notification { + warn!(%err, "failed to deliver macOS notification"); + } +} diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs new file mode 100644 index 0000000..04a6c77 --- /dev/null +++ b/crates/dinggerd/src/tmux.rs @@ -0,0 +1,177 @@ +//! tmux IPC wrapper. Everything dingger knows comes from these commands. + +use std::process::Command; + +use detect_core::manifest::agent_state_label; +use detect_core::{identify_agent, Agent, AgentState}; + +#[derive(Debug, Clone)] +pub struct Pane { + /// Unique tmux pane id, e.g. `%3`. Stable for the pane's lifetime. + pub pane_id: String, + /// User-facing target, e.g. `main:1.0`. + pub target: String, + /// Foreground command name in the pane, from `#{pane_current_command}`. + pub current_command: String, + /// Last OSC title the pane's application set, from `#{pane_title}`. + pub pane_title: String, + pub dead: bool, + /// The pane's window is the active window of its session. + pub window_active: bool, + /// The pane's session has at least one attached client. + pub session_attached: bool, + pub agent: Option<Agent>, + /// The `@agent_state` user option previously written for this pane, + /// read straight from the pane format. + pub written_state: String, +} + +impl Pane { + /// Panes the user is actively looking at suppress notifications, mirroring + /// herdr's active-tab suppression. + pub fn suppresses_notifications(&self) -> bool { + self.window_active && self.session_attached + } +} + +fn run_tmux(args: &[&str]) -> Result<String, String> { + let mut command = Command::new("tmux"); + // -L selects a named server socket; lets tests and multi-server setups + // point dingger at a specific server. + if let Some(socket) = std::env::var_os("DINGGER_TMUX_SOCKET") { + command.arg("-L").arg(socket); + } + let output = command + .args(args) + .output() + .map_err(|err| format!("failed to run tmux: {err}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("tmux {} failed: {}", args.join(" "), stderr.trim())); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +pub fn list_panes() -> Result<Vec<Pane>, String> { + let format = concat!( + "#{pane_id}\t", + "#{session_name}:#{window_index}.#{pane_index}\t", + "#{pane_current_command}\t", + "#{pane_title}\t", + "#{pane_dead}\t", + "#{window_active}\t", + "#{session_attached}\t", + "#{pane_start_command}\t", + "#{pane_pid}\t", + "#{@agent_state}" + ); + let out = run_tmux(&["list-panes", "-a", "-F", format])?; + let mut panes = Vec::new(); + for line in out.lines() { + let fields: Vec<&str> = line.split('\t').collect(); + if fields.len() < 10 { + continue; + } + let current_command = fields[2].to_string(); + let start_command = fields[7].to_string(); + let pane_pid: Option<u32> = fields[8].trim().parse().ok(); + // An agent may be started via a wrapper or still be initializing + // behind the shell; `pane_start_command` covers launch-command cases. + let mut agent = + identify_agent(¤t_command).or_else(|| identify_agent(&start_command)); + // Some agents rename their process (claude sets its title to the + // version string), so `pane_current_command` alone is not enough. + let mut process_tree = None; + if agent.is_none() { + let tree = process_tree.get_or_insert_with(fetch_process_tree); + agent = resolve_agent_in_process_tree(pane_pid, tree); + } + panes.push(Pane { + pane_id: fields[0].to_string(), + target: fields[1].to_string(), + current_command, + pane_title: fields[3].to_string(), + dead: fields[4] == "1", + window_active: fields[5] == "1", + session_attached: fields[6] != "0", + agent, + written_state: fields[9].to_string(), + }); + } + Ok(panes) +} + +type ProcessTree = (std::collections::HashMap<u32, Vec<u32>>, std::collections::HashMap<u32, String>); + +/// Identify the pane's agent by walking its process tree. Some agents rename +/// their process (claude sets its title to the version string), so the kernel +/// name in `pane_current_command` is not enough; `ps` still shows the real +/// binary path. One `ps` call per `list_panes` covers all panes. +fn resolve_agent_in_process_tree(pane_pid: Option<u32>, tree: &ProcessTree) -> Option<Agent> { + let pane_pid = pane_pid?; + let (children, comm) = tree; + + // BFS from the pane's shell. + let mut queue = std::collections::VecDeque::from([pane_pid]); + while let Some(pid) = queue.pop_front() { + if let Some(name) = comm.get(&pid) { + if let Some(agent) = identify_agent(name) { + return Some(agent); + } + } + if let Some(child_ids) = children.get(&pid) { + for child in child_ids { + queue.push_back(*child); + } + } + } + None +} + +fn fetch_process_tree() -> ProcessTree { + let Ok(output) = Command::new("ps").args(["-axo", "pid=,ppid=,comm="]).output() else { + return Default::default(); + }; + if !output.status.success() { + return Default::default(); + } + let mut children: std::collections::HashMap<u32, Vec<u32>> = + std::collections::HashMap::new(); + let mut comm: std::collections::HashMap<u32, String> = std::collections::HashMap::new(); + for line in String::from_utf8_lossy(&output.stdout).lines() { + // Columns are whitespace-aligned, so runs of spaces separate pid, + // ppid, and the command path; the command itself may contain spaces. + let Some((pid, rest)) = line.trim_start().split_once(char::is_whitespace) else { + continue; + }; + let Some((ppid, name)) = rest.trim_start().split_once(char::is_whitespace) else { + continue; + }; + let name = name.trim_start(); + let (Ok(pid), Ok(ppid)) = (pid.parse::<u32>(), ppid.parse::<u32>()) else { + continue; + }; + children.entry(ppid).or_default().push(pid); + let basename = name.rsplit('/').next().unwrap_or(name); + comm.insert(pid, basename.to_string()); + } + (children, comm) +} + +/// Capture the pane's visible screen as plain text — the exact input shape +/// the detection manifests expect. +pub fn capture_pane(pane_id: &str) -> Result<String, String> { + run_tmux(&["capture-pane", "-p", "-q", "-t", pane_id]) +} + +pub fn set_agent_state(pane_id: &str, state: AgentState) -> Result<(), String> { + run_tmux(&[ + "set-option", + "-p", + "-t", + pane_id, + "@agent_state", + agent_state_label(state), + ]) + .map(|_| ()) +} 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()); + } +} |
