aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src/tmux.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/dinggerd/src/tmux.rs')
-rw-r--r--crates/dinggerd/src/tmux.rs177
1 files changed, 177 insertions, 0 deletions
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(|_| ())
+}