aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/dinggerd/src')
-rw-r--r--crates/dinggerd/src/main.rs100
-rw-r--r--crates/dinggerd/src/manifest_update.rs79
-rw-r--r--crates/dinggerd/src/notify.rs76
-rw-r--r--crates/dinggerd/src/tmux.rs177
-rw-r--r--crates/dinggerd/src/watcher.rs156
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(&current_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());
+ }
+}