//! dingger: agent state watching for tmux panes. mod hooks; mod integrations; mod manifest_update; mod names; 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, /// Disable macOS notifications (sounds still play unless --no-sounds). #[arg(long)] no_notifications: bool, }, /// Print the current agent state of every tmux pane. Status, /// Refresh detection manifests from herdr's published catalog. Manifests { #[command(subcommand)] command: ManifestCommand, }, /// Show an agent pane and mark it attended. Without --pane, jumps to the /// agent that has waited longest (FIFO), skipping the window the client /// already shows. Used by the tmux status-line click and the menu bar. Focus { /// tmux client to switch (default: the most recently active one). #[arg(long)] client: Option, /// Pane to show (default: the head of the ready queue). #[arg(long)] pane: Option, }, /// Wake the watcher so focus changes show up immediately (tmux hooks). Poke, /// Install agent integrations (hook extensions that report state). Install { /// Agent to install the integration for. agent: String, }, } #[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, no_notifications, } => { if let Err(err) = hooks::spawn_listener() { eprintln!("dingger: {err}"); std::process::exit(1); } let mut watcher = watcher::Watcher::new(interval_secs, !no_sounds, !no_notifications); 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); } } }, Command::Focus { client, pane } => { if let Err(err) = focus(client, pane) { eprintln!("dingger: {err}"); std::process::exit(1); } } Command::Poke => { // Best effort: a stopped watcher just means nothing to wake. let _ = hooks::poke(); } Command::Install { agent } => { if let Err(err) = integrations::install(&agent) { eprintln!("dingger: {err}"); std::process::exit(1); } } } } /// Show a pane in a client and mark it attended (`@dingger_ack`, consumed /// by the watcher on its next tick). This is the single code path behind /// every click: the tmux status line and the menu bar rows. fn focus(client: Option, pane: Option) -> Result<(), String> { let clients = tmux::clients()?; // The named client if it is attached; otherwise the most recently active // one (a click on the status line makes the clicking client the most // recent, so this is right even if the name did not expand). let chosen = client .and_then(|name| clients.iter().find(|(n, _)| *n == name).cloned()) .or_else(|| clients.first().cloned()); let Some((client, client_pid)) = chosen else { return Err("no tmux client attached".to_string()); }; let pane_id = match pane { Some(pane) => pane, None => { let current_window = tmux::client_format(&client, "#{window_id}"); let queue = tmux::show_global("@dingger_queue").unwrap_or_default(); let next = queue.split(',').filter(|id| !id.is_empty()).find(|id| { tmux::pane_format(id, "#{window_id}").as_deref() != current_window.as_deref() }); match next { Some(id) => id.to_string(), None => return Ok(()), // nothing waiting elsewhere } } }; tmux::set_pane_option(&pane_id, "@dingger_ack", "1")?; tmux::show_pane(&client, &pane_id)?; let _ = hooks::poke(); activate_terminal_app(client_pid); Ok(()) } /// Bring the terminal app hosting a tmux client to the front: walk the /// client's process ancestry up to the first `*.app` bundle (Ghostty, /// Alacritty, ...) and `open -a` it. fn activate_terminal_app(client_pid: u32) { let mut pid = client_pid; for _ in 0..16 { let Ok(out) = std::process::Command::new("ps") .args(["-o", "ppid=,comm=", "-p", &pid.to_string()]) .output() else { return; }; let line = String::from_utf8_lossy(&out.stdout).trim().to_string(); let Some((ppid, comm)) = line.split_once(char::is_whitespace) else { return; }; if let Some(end) = comm.find(".app/") { let bundle = &comm[..end + 4]; let _ = std::process::Command::new("open").args(["-a", bundle]).status(); return; } match ppid.trim().parse::() { Ok(parent) if parent > 1 => pid = parent, _ => return, } } } /// Every agent pane in herdr's priority order (blocked, done, working, /// idle), oldest-waiting first within a status. fn status() -> Result<(), String> { let mut panes: Vec<_> = tmux::list_panes()? .into_iter() .filter(|pane| pane.agent.is_some()) .collect(); if panes.is_empty() { println!("no agent panes"); return Ok(()); } panes.sort_by_key(|pane| { let status = watcher::Status::from_label(&pane.written_state); let since = pane.written_since.parse::().unwrap_or(u64::MAX); (std::cmp::Reverse(status.priority()), since, pane.display_target.clone()) }); let place = |pane: &tmux::Pane| pane.display_target.replacen(':', " ยท ", 1); let width = panes.iter().map(|pane| place(pane).chars().count()).max().unwrap_or(5).max(5); println!("{:<8} {: