crates/dinggerd/src/main.rs (view raw)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 |
//! 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<String>,
/// Pane to show (default: the head of the ready queue).
#[arg(long)]
pane: Option<String>,
},
/// 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<String>, pane: Option<String>) -> 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::<u32>() {
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::<u64>().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} {:<width$} {:<7} NAME", "STATUS", "WHERE", "AGENT");
for pane in &panes {
let state = if pane.written_state.is_empty() { "-" } else { &pane.written_state };
let agent = pane.agent.map(detect_core::agent_label).unwrap_or("-");
println!("{state:<8} {:<width$} {agent:<7} {}", place(pane), pane.written_name);
}
Ok(())
}
|