//! dingger: agent state watching for tmux panes. mod hooks; mod integrations; mod manifest_update; mod names; mod notify; mod platform; mod restore; mod snapshot; mod terminals; 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 TerminalAction { /// The client gained focus: learn its terminal (tmux's client-focus-in). Focused { #[arg(long)] client: String, }, /// Raise the client's terminal. Raise { #[arg(long)] client: String, }, } #[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, /// Restore tmux sessions saved before tmux last stopped (a crash, /// shutdown, or kill-server): windows, panes, layouts, directories. /// Only adds what is missing; a bare session of the same name (what a /// terminal creates on startup) is replaced, its clients moved over. Restore { /// Show what would happen without changing anything. #[arg(long)] dry_run: bool, /// List the restore candidate and saved snapshots. #[arg(long)] list: bool, /// Restore from this snapshot file instead of the candidate. #[arg(long)] from: Option, /// Restore only these sessions. #[arg(long = "session")] sessions: Vec, /// Write the report to restore.log and notify a summary. #[arg(long)] report: bool, /// Stop offering the saved layout (it can still be restored). #[arg(long)] dismiss: bool, }, /// Install integrations: an agent's hook extension (state and session /// reports), or `zsh` (command lines for restore to retype). Install { /// `pi`, `claude`, or `zsh`. agent: String, }, /// The terminal window of a tmux client (tmux runs these; see /// `terminals`). #[command(hide = true)] Terminal { #[command(subcommand)] action: TerminalAction, }, /// An agent's hook, run by the agent (`dingger install` sets it up). #[command(hide = true)] Hook { /// `claude`. 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() { platform::default_sigpipe(); let cli = Cli::parse(); // The daemon logs its work; commands print their own output and only // log problems. let default_level = if matches!(cli.command, Command::Watch { .. }) { "info" } else { "warn" }; tracing_subscriber::fmt() .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| default_level.into())) .init(); match cli.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::Restore { dry_run, list, from, sessions, report, dismiss } => { let result = if list { restore::list() } else if dismiss { restore::dismiss() } else { restore::run(from.as_deref(), &sessions, dry_run, report) }; if let Err(err) = result { eprintln!("dingger: {err}"); std::process::exit(1); } } Command::Install { agent } => { if let Err(err) = integrations::install(&agent) { eprintln!("dingger: {err}"); std::process::exit(1); } } Command::Terminal { action } => { let result = match &action { TerminalAction::Focused { client } => terminals::learn(client), TerminalAction::Raise { client } => terminals::raise(client), }; if let Err(err) = result { eprintln!("dingger: {err}"); std::process::exit(1); } } // Never fails the agent: exits 0, prints nothing. Command::Hook { agent } => integrations::run_hook(&agent), } } /// Show a pane in a client and mark it attended (`@dingger_ack`, consumed /// by the watcher on its next tick), then raise that client's terminal /// window. The single code path behind every click: the tmux status line, /// the menu bar rows, the panel, search, and the shortcuts. /// /// The client: the named one (a status-line click names the clicking /// client); otherwise one already on a session holding the pane's window /// (several terminal windows can show one session), else any; the focused /// one first, then the most recently used (see `terminals::choose`). fn focus(client: Option, pane: Option) -> Result<(), String> { let clients = terminals::clients()?; if clients.is_empty() { return Err("no tmux client attached".to_string()); } let named = client.and_then(|name| clients.iter().find(|c| c.name == name)); let pane_id = match pane { Some(pane) => pane, None => { // The queue head, skipping the window the user looks at. let looking = named.or_else(|| terminals::choose(&clients, &[]).map(|(c, _)| c)); let current_window = looking.and_then(|c| tmux::client_format(&c.name, "#{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 } } }; let showing = tmux::sessions_with_pane(&pane_id); let (client, keeps_session) = match named { Some(client) => (client, showing.contains(&client.session_id)), None => terminals::choose(&clients, &showing).ok_or("no tmux client attached")?, }; tmux::set_pane_option(&pane_id, "@dingger_ack", "1")?; tmux::show_pane(&client.name, &pane_id, keeps_session.then_some(client.session_id.as_str()))?; let _ = hooks::poke(); raise(client); Ok(()) } /// Bring a client's terminal forward: its exact window, tab, and split if /// dingger learned which it is (raised by the tmux server, see /// `terminals`); otherwise the terminal app. fn raise(client: &terminals::Client) { if terminals::pairing(&client.tty).is_some() { if let Ok(exe) = std::env::current_exe() { let command = format!( "{} terminal raise --client {} >/dev/null 2>&1", snapshot::shell_quote(&exe.to_string_lossy()), snapshot::shell_quote(&client.name) ); if tmux::run(&["run-shell", "-b", &command]).is_ok() { return; } } } if let Some(app) = terminals::terminal_app(client.pid) { terminals::open_app(&app); } } /// 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); (watcher::list_order(status, pane.written_since.parse().ok()), 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} {: