//! dingger: agent state watching for tmux panes. mod hooks; mod importance; 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 std::io::IsTerminal; 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, /// Set an agent's importance: it orders agents within a status (the /// list, and the jump), and a low agent makes no sound. In pi, use /// /importance instead: pi saves it with the session. Importance { /// `high`, `normal`, or `low`; without it, print the current one. level: Option, /// The agent's pane (default: the pane this runs in). #[arg(long)] pane: Option, }, /// Mark an idle agent unread: it shows as done again, last among done /// agents of its importance, until you come back to it. In pi, /unread /// does the same. Unread { /// The agent's pane (default: the pane this runs in). #[arg(long)] pane: Option, }, /// Reload the pi agents that run an old dingger extension: those idle /// and off screen now, the others once they are (a reload while busy, /// or with a draft in the input box, waits). Until all are reloaded or /// the daemon restarts. ReloadAgents, /// 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. // tmux shows whatever its hooks print, and a non-zero exit, as a // message: the commands tmux runs are silent unless run by hand. let by_tmux = matches!(cli.command, Command::Terminal { .. }) && !std::io::stderr().is_terminal(); let default_level = match cli.command { Command::Watch { .. } => "info", _ if by_tmux => "off", _ => "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, } => { integrations::update_pi_extension(); 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::Importance { level, pane } => { if let Err(err) = set_importance(level.as_deref(), pane) { eprintln!("dingger: {err}"); std::process::exit(1); } } Command::Unread { pane } => { if let Err(err) = mark_unread(pane) { eprintln!("dingger: {err}"); std::process::exit(1); } } Command::ReloadAgents => { if let Err(err) = reload_agents() { 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), }; // Best effort, run by tmux (the focus hook, a raise): an outcome // like "focus moved on" or "terminal gone" is not a failure. if let Err(err) = result { if !by_tmux { eprintln!("dingger: {err}"); } } } // 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); } } /// `dingger importance`: report the pane's agent importance to the daemon, /// which writes it (the daemon is the one writer of pane state). fn set_importance(level: Option<&str>, pane: Option) -> Result<(), String> { let target = pane .or_else(|| std::env::var("TMUX_PANE").ok()) .ok_or("not in a tmux pane; pass --pane")?; let info = tmux::run(&[ "display-message", "-p", "-t", &target, "#{pane_id}\t#{@agent}\t#{@dingger_hook}\t#{@dingger_importance}", ]) .map_err(|_| format!("no pane {target}"))?; let fields: Vec<&str> = info.trim_end_matches('\n').split('\t').collect(); let [pane_id, agent, hook, written] = fields[..] else { return Err(format!("no pane {target}")); }; if agent.is_empty() { return Err(format!("no agent in pane {target}")); } let Some(level) = level else { println!("{}", importance::Importance::from_option(written).label()); return Ok(()); }; let level = importance::Importance::parse(level).ok_or("importance is high, normal, or low")?; // pi's extension reports the importance saved in the session; a value // set here would last only until its next report. if agent == "pi" && !hook.is_empty() { return Err("this pi reports to dingger: use /importance in it (pi saves it with the session)".into()); } let message = serde_json::json!({ "method": "pane.report_agent", "params": { "pane_id": pane_id, "importance": level.label() }, }); hooks::send(&message.to_string())?; println!("importance: {}", level.label()); Ok(()) } /// `dingger reload-agents`: ask the daemon to reload the agents on an old /// extension; report what happened within a few seconds. fn reload_agents() -> Result<(), String> { let count = |name: &str| tmux::show_global(name).and_then(|v| v.trim().parse::().ok()).unwrap_or(0); let by_hand = count("@dingger_outdated_by_hand"); let by_hand_note = || { if by_hand > 0 { let agents = if by_hand == 1 { "agent runs" } else { "agents run" }; println!("{by_hand} {agents} an extension too old to reload this way: type /reload in each"); } }; let before = count("@dingger_outdated"); if before == 0 { if by_hand == 0 { println!("every pi agent runs the current dingger extension"); } by_hand_note(); return Ok(()); } if !integrations::pi_extension_installed_is_current() { return Err("the installed pi extension is not this dingger's, so a reload would load the old one again: run `dingger install pi` first".into()); } hooks::send(r#"{"method":"agents.reload_outdated"}"#)?; let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); let mut left = before; while left > 0 && std::time::Instant::now() < deadline { std::thread::sleep(std::time::Duration::from_millis(200)); left = count("@dingger_outdated"); } let agents = |n: usize| if n == 1 { "agent" } else { "agents" }; println!("reloaded {} of {before} {} on an old dingger extension", before.saturating_sub(left), agents(before)); if left > 0 { println!("the other {left} reload once idle and off screen, with an empty input box"); } by_hand_note(); Ok(()) } /// `dingger unread`: ask the daemon to mark the pane's idle agent unread, /// and wait until it shows as done. fn mark_unread(pane: Option) -> Result<(), String> { let target = pane .or_else(|| std::env::var("TMUX_PANE").ok()) .ok_or("not in a tmux pane; pass --pane")?; let read = || -> Result<(String, String, String), String> { let info = tmux::run(&["display-message", "-p", "-t", &target, "#{pane_id}\t#{@agent}\t#{@agent_state}"]) .map_err(|_| format!("no pane {target}"))?; match info.trim_end_matches('\n').split('\t').collect::>()[..] { [id, agent, state] => Ok((id.to_string(), agent.to_string(), state.to_string())), _ => Err(format!("no pane {target}")), } }; let (pane_id, agent, state) = read()?; match (agent.as_str(), state.as_str()) { ("", _) => return Err(format!("no agent in pane {target}")), (_, "idle") => {} (_, "done") => return Err("it is unread already (done)".into()), (_, state) => return Err(format!("only an idle agent can be marked unread; this one is {state}")), } let message = serde_json::json!({ "method": "pane.mark_unread", "params": { "pane_id": pane_id } }); hooks::send(&message.to_string())?; let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); while std::time::Instant::now() < deadline { match read()?.2.as_str() { "done" => { println!("marked unread"); return Ok(()); } "idle" => std::thread::sleep(std::time::Duration::from_millis(50)), other => return Err(format!("not marked: it turned {other}")), } } Err("not marked: the daemon did not apply it".into()) } /// 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 importance = importance::Importance::from_option(&pane.written_importance); (watcher::list_order(status, importance, 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} {: format!("! {}", pane.written_name), importance::Importance::Low => format!("{} (low)", pane.written_name), importance::Importance::Normal => pane.written_name.clone(), }; println!("{state:<8} {: