aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-20 12:43:07 +0300
committerIgor Soarez <igor@soarez.org>2026-10-20 12:43:07 +0300
commit4af6a4f625b1965b783c9efb857f8ac665adb8eb (patch)
treedd2dac9627735fd02b3372a4f569125a702ba57a /crates
parent943fc940d9c387916708cb3fdc912323c92ef6dc (diff)
rebuild the status model as herdr's state machine, event-driven and tested
the seen logic was inverted: first observations were marked done and real completions idle, so the menu showed teal for agents that did nothing and green for agents that finished, and attending changed nothing visible. - watcher::advance is herdr's apply_pane_state_change + mark_active_tab_seen as a pure function, unit-tested against herdr's own cases; attendance is window-level like herdr's active tab - hook reports are events applied in order, so a turn shorter than a poll is still a completion - event-driven: hook reports, dingger focus, and tmux focus hooks wake the watcher; notifications are delivered off-thread (the macos call blocked ticks for 2+ seconds) after the pane options are written - all per-pane state persists in tmux pane options, so restarts and deploys lose nothing, including the authoritative hook state - every click goes through dingger focus: the menu bar shells out to it and the status-line binding runs it; it picks the right client, skips the window already shown, and activates the terminal app hosting the client - the ready queue is ordered like the menu (blocked first, then oldest done) - menu bar and tmux segment render herdr's symbols in herdr's palette - tests/e2e.sh: 60 assertions against an isolated tmux server with a real client, a 30s poll interval, and a slow fake notifier
Diffstat (limited to 'crates')
-rw-r--r--crates/dinggerd/src/hooks.rs144
-rw-r--r--crates/dinggerd/src/main.rs144
-rw-r--r--crates/dinggerd/src/notify.rs31
-rw-r--r--crates/dinggerd/src/tmux.rs137
-rw-r--r--crates/dinggerd/src/watcher.rs634
5 files changed, 682 insertions, 408 deletions
diff --git a/crates/dinggerd/src/hooks.rs b/crates/dinggerd/src/hooks.rs
index da1121e..40f0f20 100644
--- a/crates/dinggerd/src/hooks.rs
+++ b/crates/dinggerd/src/hooks.rs
@@ -6,23 +6,27 @@
//! {"method":"pane.report_agent","params":{"pane_id":"%3","agent":"pi","state":"working"}}
//! ```
//!
-//! Reports are authoritative for their pane until the next report or until
-//! the pane stops running the agent; screen detection is the fallback for
-//! panes that never report.
+//! Reports are state-change *events*: the watcher applies every one in
+//! order, so a turn that starts and finishes between two polls is still a
+//! completion, and each report wakes the watcher immediately.
+//!
+//! `{"method":"poke"}` only wakes the watcher; `dingger focus` and tmux's
+//! focus-change hooks send it so attendance updates without waiting for the
+//! next poll. The last reported state stays authoritative for its pane
+//! until the next report or until the pane stops running the agent; screen
+//! detection is the fallback for panes that never report.
use std::collections::HashMap;
use std::io::{BufRead, BufReader};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
-use std::sync::{Mutex, OnceLock};
-use std::time::Instant;
+use std::sync::{Condvar, Mutex, MutexGuard, OnceLock};
+use std::time::Duration;
use detect_core::AgentState;
use serde::Deserialize;
use tracing::{debug, info, warn};
-pub const SOCKET_FILENAME: &str = "agent.sock";
-
pub fn socket_path() -> PathBuf {
if let Some(path) = std::env::var_os("DINGGER_AGENT_SOCKET") {
return PathBuf::from(path);
@@ -30,54 +34,88 @@ pub fn socket_path() -> PathBuf {
let state = std::env::var_os("DINGGER_STATE_DIR")
.map(PathBuf::from)
.or_else(|| {
- std::env::var_os("HOME").map(|home| {
- PathBuf::from(home).join(".local").join("state").join("dingger")
- })
+ std::env::var_os("HOME")
+ .map(|home| PathBuf::from(home).join(".local").join("state").join("dingger"))
})
.unwrap_or_else(|| PathBuf::from(".dingger"));
- state.join(SOCKET_FILENAME)
+ state.join("agent.sock")
+}
+
+#[derive(Debug, Default)]
+struct PaneReports {
+ /// Last reported state: authoritative until the next report.
+ last: Option<AgentState>,
+ /// Reports not yet applied by the watcher, oldest first.
+ pending: Vec<AgentState>,
+}
+
+fn reports() -> MutexGuard<'static, HashMap<String, PaneReports>> {
+ static REPORTS: OnceLock<Mutex<HashMap<String, PaneReports>>> = OnceLock::new();
+ REPORTS
+ .get_or_init(|| Mutex::new(HashMap::new()))
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+}
+
+fn wake_signal() -> &'static (Mutex<bool>, Condvar) {
+ static WAKE: OnceLock<(Mutex<bool>, Condvar)> = OnceLock::new();
+ WAKE.get_or_init(|| (Mutex::new(false), Condvar::new()))
+}
+
+fn wake() {
+ let (flag, condvar) = wake_signal();
+ *flag.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) = true;
+ condvar.notify_all();
}
-#[derive(Debug, Clone)]
-struct HookReport {
- state: AgentState,
- #[allow(dead_code)] // kept for future hook diagnostics
- agent: Option<String>,
- #[allow(dead_code)] // kept for future staleness policy
- seen: Instant,
+/// Sleep until woken by a report or poke, or until `timeout` passes.
+pub fn wait_for_wake(timeout: Duration) {
+ let (flag, condvar) = wake_signal();
+ let guard = flag.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
+ let (mut guard, _) = condvar
+ .wait_timeout_while(guard, timeout, |woken| !*woken)
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ *guard = false;
}
-fn reports() -> &'static Mutex<HashMap<String, HookReport>> {
- static REPORTS: OnceLock<Mutex<HashMap<String, HookReport>>> = OnceLock::new();
- REPORTS.get_or_init(|| Mutex::new(HashMap::new()))
+/// Wake a running watcher (used by `dingger focus` and tmux focus hooks).
+pub fn poke() -> Result<(), String> {
+ use std::io::Write;
+ let mut stream = UnixStream::connect(socket_path())
+ .map_err(|err| format!("dingger watch is not running: {err}"))?;
+ stream
+ .write_all(b"{\"method\":\"poke\"}\n")
+ .map_err(|err| format!("poke failed: {err}"))
}
#[derive(Deserialize)]
struct HookMessage {
method: String,
- params: HookParams,
+ #[serde(default)]
+ params: Option<HookParams>,
}
#[derive(Deserialize)]
struct HookParams {
pane_id: String,
state: String,
- #[serde(default)]
- agent: Option<String>,
}
fn handle_stream(stream: UnixStream) {
- let reader = BufReader::new(stream);
- for line in reader.lines() {
+ for line in BufReader::new(stream).lines() {
let Ok(line) = line else { return };
let Ok(message) = serde_json::from_str::<HookMessage>(&line) else {
warn!(%line, "invalid hook message");
continue;
};
- if message.method != "pane.report_agent" {
+ if message.method == "poke" {
+ wake();
continue;
}
- let state = match message.params.state.as_str() {
+ let (true, Some(params)) = (message.method == "pane.report_agent", message.params) else {
+ continue;
+ };
+ let state = match params.state.as_str() {
"working" => AgentState::Working,
"blocked" => AgentState::Blocked,
"idle" => AgentState::Idle,
@@ -86,17 +124,14 @@ fn handle_stream(stream: UnixStream) {
continue;
}
};
- let pane_id = message.params.pane_id;
- debug!(pane = %pane_id, ?state, "hook report");
- let mut reports = reports().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
- reports.insert(
- pane_id,
- HookReport {
- state,
- agent: message.params.agent,
- seen: Instant::now(),
- },
- );
+ debug!(pane = %params.pane_id, ?state, "hook report");
+ {
+ let mut reports = reports();
+ let pane = reports.entry(params.pane_id).or_default();
+ pane.last = Some(state);
+ pane.pending.push(state);
+ }
+ wake();
}
}
@@ -123,20 +158,29 @@ pub fn spawn_listener() -> Result<(), String> {
Ok(())
}
-/// The authoritative state for a pane, if an extension reported one.
-pub fn hook_state(pane_id: &str) -> Option<AgentState> {
- let reports = reports().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
- reports.get(pane_id).map(|report| report.state)
+/// The pane's hook observations for this tick: every report since the last
+/// tick, in order; otherwise the last reported state; `None` if the pane
+/// never reported to this process.
+pub fn take_observations(pane_id: &str) -> Option<Vec<AgentState>> {
+ let mut reports = reports();
+ let pane = reports.get_mut(pane_id)?;
+ if pane.pending.is_empty() {
+ return pane.last.map(|state| vec![state]);
+ }
+ Some(std::mem::take(&mut pane.pending))
+}
+
+/// The last state the pane's extension reported, if any.
+pub fn last_state(pane_id: &str) -> Option<AgentState> {
+ reports().get(pane_id).and_then(|pane| pane.last)
}
-/// Drop a single pane's report (the agent exited).
-pub fn drop_report(pane_id: &str) {
- let mut reports = reports().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
- reports.remove(pane_id);
+/// Drop a pane's reports (the agent exited).
+pub fn drop_reports(pane_id: &str) {
+ reports().remove(pane_id);
}
-/// Drop reports for panes that no longer exist or no longer run the agent.
+/// Drop reports for panes that no longer exist.
pub fn forget(pane_ids_to_keep: &[String]) {
- let mut reports = reports().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
- reports.retain(|pane_id, _| pane_ids_to_keep.contains(pane_id));
+ reports().retain(|pane_id, _| pane_ids_to_keep.contains(pane_id));
}
diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs
index 7ef601f..f458e18 100644
--- a/crates/dinggerd/src/main.rs
+++ b/crates/dinggerd/src/main.rs
@@ -27,6 +27,9 @@ enum Command {
/// 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,
@@ -35,12 +38,19 @@ enum Command {
#[command(subcommand)]
command: ManifestCommand,
},
- /// Switch to the agent that has been waiting the longest (FIFO). Bound to
- /// a mouse click on the tmux status line by the packaged config.
+ /// 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 {
- /// Name of the tmux client that clicked, passed as #{client_name}.
+ /// 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.
@@ -65,12 +75,13 @@ fn main() {
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);
+ 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);
@@ -90,12 +101,16 @@ fn main() {
}
}
},
- Command::Focus { client } => {
- if let Err(err) = focus(client) {
+ 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}");
@@ -105,55 +120,92 @@ fn main() {
}
}
-/// Jump to the head of the ready queue (oldest waiting agent) and ack it, so
-/// the next focus moves on to the following one.
-fn focus(client: Option<String>) -> Result<(), String> {
- let queue = tmux::show_global("@dingger_queue")
- .unwrap_or_default()
- .split(',')
- .filter(|entry| !entry.is_empty())
- .map(|entry| entry.split(':').next().unwrap_or("").to_string())
- .collect::<Vec<_>>();
- let Some(pane_id) = queue.first() else {
- return Ok(()); // nothing waiting
+/// 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 target = tmux::show_pane_format(pane_id, "#{session_name}:#{window_index}.#{pane_index}")
- .unwrap_or_default();
- if target.is_empty() {
- return Err("ready pane no longer exists".to_string());
- }
- tmux::ack(pane_id)?;
- let mut args = vec!["switch-client".to_string()];
- if let Some(client) = client {
- args.push("-c".to_string());
- args.push(client);
+
+ 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,
+ }
}
- args.push("-t".to_string());
- args.push(target);
- tmux::run_tmux_client(
- &args.iter().map(String::as_str).collect::<Vec<_>>(),
- )
}
+/// Every agent pane in herdr's priority order (blocked, done, working,
+/// idle), oldest-waiting first within a status.
fn status() -> Result<(), String> {
- let panes = tmux::list_panes()?;
+ let mut panes: Vec<_> = tmux::list_panes()?
+ .into_iter()
+ .filter(|pane| pane.agent.is_some())
+ .collect();
if panes.is_empty() {
- println!("no tmux panes");
+ println!("no agent panes");
return Ok(());
}
- println!("{:<22} {:<10} {:<12} COMMAND", "TARGET", "AGENT", "STATE");
+ 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())
+ });
+ println!("{:<8} {:<10} TARGET", "STATUS", "AGENT");
for pane in panes {
- println!(
- "{:<22} {:<10} {:<12} {}",
- pane.display_target,
- pane.agent.map(detect_core::agent_label).unwrap_or("-"),
- if pane.written_state.is_empty() {
- "-"
- } else {
- &pane.written_state
- },
- pane.current_command,
- );
+ 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} {agent:<10} {}", pane.display_target);
}
Ok(())
}
diff --git a/crates/dinggerd/src/notify.rs b/crates/dinggerd/src/notify.rs
index 9fcfcde..e619d6d 100644
--- a/crates/dinggerd/src/notify.rs
+++ b/crates/dinggerd/src/notify.rs
@@ -3,7 +3,7 @@
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
-use tracing::{debug, warn};
+use tracing::{info, warn};
pub enum NotificationKind {
NeedsAttention,
@@ -63,14 +63,27 @@ pub fn play_sound(kind: &NotificationKind) {
}
}
+/// Deliver a macOS notification without blocking the caller: the delivery
+/// call waits on Notification Center for seconds, and the watcher must keep
+/// updating the UI meanwhile.
pub fn send_notification(agent_label: &str, kind: &NotificationKind, context: &str) {
- debug!(agent = agent_label, kind = kind.title(), context, "notifying");
+ info!(agent = agent_label, kind = kind.title(), context, "notifying");
let title = format!("{agent_label} {}", kind.title());
- let notification = mac_notification_sys::Notification::new()
- .title(title.as_str())
- .message(context)
- .send();
- if let Err(err) = notification {
- warn!(%err, "failed to deliver macOS notification");
- }
+ let context = context.to_string();
+ std::thread::spawn(move || {
+ // Test seam: deliver through a command instead (tests/e2e.sh).
+ if let Ok(command) = std::env::var("DINGGER_NOTIFIER") {
+ let _ = std::process::Command::new("sh")
+ .args(["-c", &command, &title, &context])
+ .status();
+ return;
+ }
+ let notification = mac_notification_sys::Notification::new()
+ .title(title.as_str())
+ .message(context.as_str())
+ .send();
+ if let Err(err) = notification {
+ warn!(%err, "failed to deliver macOS notification");
+ }
+ });
}
diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs
index f6c2f17..326eda9 100644
--- a/crates/dinggerd/src/tmux.rs
+++ b/crates/dinggerd/src/tmux.rs
@@ -2,7 +2,7 @@
use std::process::Command;
-use detect_core::{identify_agent, Agent, AgentState};
+use detect_core::{identify_agent, Agent};
#[derive(Debug, Clone)]
pub struct Pane {
@@ -10,38 +10,27 @@ pub struct Pane {
pub pane_id: String,
/// Human-readable target, e.g. `main:work` (`.pane` suffix when split).
pub display_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.
+ /// The pane's window is the current 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.
+ /// Pane options written by dingger (see the watcher module docs).
pub written_state: String,
- /// The `@agent` user option (agent label) previously written, if any.
pub written_agent: String,
- /// The `@dingger_ack` user option: "1" when the user already jumped to
- /// this pane in its current ready episode.
pub written_ack: String,
- /// The pane is the active pane of the active window.
- pub pane_active: bool,
+ pub written_since: String,
+ pub written_hook: String,
}
impl Pane {
- /// Panes the user is actively looking at suppress finished notifications,
- /// mirroring herdr's active-tab suppression.
- pub fn suppresses_notifications(&self) -> bool {
- self.window_active && self.session_attached
- }
-
- /// The pane is fully focused: the user is looking at this exact pane.
+ /// The user is looking at this pane's window. herdr marks every pane of
+ /// the active tab seen (`mark_active_tab_seen`); a tmux window is a tab.
pub fn attended(&self) -> bool {
- self.pane_active && self.suppresses_notifications()
+ self.window_active && self.session_attached
}
}
@@ -80,42 +69,42 @@ pub fn list_panes() -> Result<Vec<Pane>, String> {
"#{@agent_state}\t",
"#{@agent}\t",
"#{@dingger_ack}\t",
- "#{pane_active}\t",
+ "#{@dingger_since}\t",
+ "#{@dingger_hook}\t",
"#{session_name}:#{window_name}\t",
"#{window_panes}\t",
"#{pane_index}",
);
let out = run_tmux(&["list-panes", "-a", "-F", format])?;
let mut panes = Vec::new();
+ let mut process_tree = None;
for line in out.lines() {
let fields: Vec<&str> = line.split('\t').collect();
- if fields.len() < 15 {
+ if fields.len() < 16 {
continue;
}
- let current_command = fields[1].to_string();
- let start_command = fields[6].to_string();
+ let current_command = fields[1];
+ let start_command = fields[6];
let pane_pid: Option<u32> = fields[7].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));
+ 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);
}
// Display target: session:window-name, pane index only when split.
- let mut display_target = fields[12].to_string();
- if fields[13].parse::<u32>().map(|n| n > 1).unwrap_or(false) {
+ let mut display_target = fields[13].to_string();
+ if fields[14].parse::<u32>().map(|n| n > 1).unwrap_or(false) {
display_target.push('.');
- display_target.push_str(fields[14]);
+ display_target.push_str(fields[15]);
}
panes.push(Pane {
pane_id: fields[0].to_string(),
display_target,
- current_command,
pane_title: fields[2].to_string(),
dead: fields[3] == "1",
window_active: fields[4] == "1",
@@ -124,7 +113,8 @@ pub fn list_panes() -> Result<Vec<Pane>, String> {
written_state: fields[8].to_string(),
written_agent: fields[9].to_string(),
written_ack: fields[10].to_string(),
- pane_active: fields[11] == "1",
+ written_since: fields[11].to_string(),
+ written_hook: fields[12].to_string(),
});
}
Ok(panes)
@@ -187,35 +177,18 @@ fn fetch_process_tree() -> ProcessTree {
(children, comm)
}
-/// Run a tmux command whose effect is an action (e.g. switch-client), not
-/// output to parse.
-pub fn run_tmux_client(args: &[&str]) -> Result<(), String> {
- run_tmux(args).map(|_| ())
-}
-
-/// Read a format expansion for one pane.
-pub fn show_pane_format(pane_id: &str, format: &str) -> Option<String> {
- run_tmux(&["display-message", "-p", "-t", pane_id, format])
- .ok()
- .map(|value| value.trim().to_string())
-}
-
-/// Capture the pane's visible screen as plain text — the exact input shape
+/// 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])
}
-/// Write the pane's display status (herdr's vocabulary: done/idle/...).
-pub fn set_agent_state(pane_id: &str, label: &str) -> Result<(), String> {
- run_tmux(&["set-option", "-p", "-t", pane_id, "@agent_state", label]).map(|_| ())
+pub fn set_pane_option(pane_id: &str, name: &str, value: &str) -> Result<(), String> {
+ run_tmux(&["set-option", "-p", "-t", pane_id, name, value]).map(|_| ())
}
-/// Label the pane with the agent running in it, for tmux status-line and
-/// menu-bar rendering; clears the label when the pane stops running one.
-pub fn set_agent(pane_id: &str, agent: Option<Agent>) -> Result<(), String> {
- let label = agent.map(detect_core::agent_label).unwrap_or("");
- run_tmux(&["set-option", "-p", "-t", pane_id, "@agent", label]).map(|_| ())
+pub fn unset_pane_option(pane_id: &str, name: &str) -> Result<(), String> {
+ run_tmux(&["set-option", "-p", "-u", "-t", pane_id, name]).map(|_| ())
}
/// Write a server-global option (readable from any client and from formats).
@@ -230,12 +203,60 @@ pub fn show_global(name: &str) -> Option<String> {
.filter(|value| !value.is_empty())
}
-/// Mark a pane as seen by the user after `dingger focus`: taken out of the
-/// ready queue until its next state transition.
-pub fn ack(pane_id: &str) -> Result<(), String> {
- run_tmux(&["set-option", "-p", "-t", pane_id, "@dingger_ack", "1"]).map(|_| ())
+/// Expand a format in the context of a pane.
+pub fn pane_format(pane_id: &str, format: &str) -> Option<String> {
+ run_tmux(&["display-message", "-p", "-t", pane_id, format])
+ .ok()
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty())
}
-pub fn clear_ack(pane_id: &str) -> Result<(), String> {
- run_tmux(&["set-option", "-p", "-t", pane_id, "-u", "@dingger_ack"]).map(|_| ())
+/// Expand a format in the context of a client (its current pane/window).
+pub fn client_format(client: &str, format: &str) -> Option<String> {
+ run_tmux(&["display-message", "-p", "-c", client, format])
+ .ok()
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty())
+}
+
+/// Attached clients as (name, pid), most recently active first.
+pub fn clients() -> Vec<(String, u32)> {
+ let Ok(out) = run_tmux(&[
+ "list-clients",
+ "-F",
+ "#{client_activity}\t#{client_name}\t#{client_pid}",
+ ]) else {
+ return Vec::new();
+ };
+ let mut clients: Vec<(u64, String, u32)> = out
+ .lines()
+ .filter_map(|line| {
+ let mut fields = line.split('\t');
+ let activity = fields.next()?.parse().ok()?;
+ let name = fields.next()?.to_string();
+ let pid = fields.next()?.parse().ok()?;
+ Some((activity, name, pid))
+ })
+ .collect();
+ clients.sort_by_key(|client| std::cmp::Reverse(client.0));
+ clients.into_iter().map(|(_, name, pid)| (name, pid)).collect()
+}
+
+/// Show a pane in a client: switch the client to the pane's window and make
+/// the pane the active one.
+pub fn show_pane(client: &str, pane_id: &str) -> Result<(), String> {
+ run_tmux(&["switch-client", "-c", client, "-t", pane_id])?;
+ run_tmux(&["select-pane", "-t", pane_id]).map(|_| ())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_ps_tree() {
+ let (children, comm) = fetch_process_tree();
+ assert!(comm.contains_key(&1u32), "launchd should be present");
+ assert!(!children.is_empty());
+ }
}
diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs
index f1cb90e..2455407 100644
--- a/crates/dinggerd/src/watcher.rs
+++ b/crates/dinggerd/src/watcher.rs
@@ -1,21 +1,28 @@
//! Poll-and-detect loop: tmux pane capture → detection engine → herdr's
//! status model → notifications and pane-option write-back.
//!
-//! Status model (herdr's, verbatim): five display statuses over two inputs —
+//! Status model (herdr's, verbatim): five display statuses over two inputs,
//! the raw agent state (working/blocked/idle, from hooks or scraping) and a
-//! `seen` flag (attended since the last state change):
+//! `seen` flag:
//!
//! | raw | seen | display | priority |
//! |----------|--------|----------|----------|
-//! | working | — | working | 2 |
-//! | blocked | — | blocked | 4 |
+//! | working | any | working | 2 |
+//! | blocked | any | blocked | 4 |
//! | idle | false | done | 3 |
//! | idle | true | idle | 1 |
-//! | none | — | unknown | 0 |
+//! | none | any | unknown | 0 |
//!
-//! "Seen" is maintained three ways: the pane is fully focused in an attached
-//! client (the daemon watches tmux focus), the user jumped to it via
-//! `dingger focus` / menu click, or its next transition.
+//! `seen` follows herdr's `apply_pane_state_change` and
+//! `mark_active_tab_seen` (src/app/actions.rs); see [`advance`].
+//!
+//! All per-pane state lives in tmux pane options, so a daemon restart picks
+//! up exactly where it left off:
+//!
+//! - `@agent_state` display status (what every surface renders)
+//! - `@dingger_since` unix ms the pane started needing the user (FIFO)
+//! - `@dingger_hook` last state an agent extension reported
+//! - `@dingger_ack` one-shot "the user jumped here" event from `dingger focus`
use std::collections::HashMap;
@@ -26,8 +33,8 @@ use crate::hooks;
use crate::notify::{play_sound, send_notification, NotificationKind};
use crate::tmux;
-/// herdr's display statuses, in priority order (highest first).
-#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+/// herdr's display statuses.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
Unknown,
Idle,
@@ -37,8 +44,7 @@ pub enum Status {
}
impl Status {
- #[allow(dead_code)] // herdr parity: the full vocabulary is part of the model
- pub fn label(&self) -> &'static str {
+ pub fn label(self) -> &'static str {
match self {
Status::Unknown => "unknown",
Status::Idle => "idle",
@@ -48,21 +54,18 @@ impl Status {
}
}
- #[allow(dead_code)] // herdr parity
- pub fn parse(label: &str) -> Option<Status> {
+ pub fn from_label(label: &str) -> Status {
match label {
- "working" => Some(Status::Working),
- "blocked" => Some(Status::Blocked),
- "done" => Some(Status::Done),
- "idle" => Some(Status::Idle),
- "unknown" => Some(Status::Unknown),
- _ => None,
+ "idle" => Status::Idle,
+ "working" => Status::Working,
+ "done" => Status::Done,
+ "blocked" => Status::Blocked,
+ _ => Status::Unknown,
}
}
/// herdr's priority: blocked > done > working > idle > unknown.
- #[allow(dead_code)] // herdr parity; used once the menu bar consumes statuses from the daemon
- pub fn priority(&self) -> u8 {
+ pub fn priority(self) -> u8 {
match self {
Status::Blocked => 4,
Status::Done => 3,
@@ -84,49 +87,126 @@ pub fn notification_kind_for_transition(
}
match new_state {
AgentState::Blocked => Some(NotificationKind::NeedsAttention),
- AgentState::Idle
- if matches!(prev_state, AgentState::Working | AgentState::Blocked) =>
- {
+ AgentState::Idle if matches!(prev_state, AgentState::Working | AgentState::Blocked) => {
Some(NotificationKind::Finished)
}
_ => None,
}
}
+/// herdr's per-pane attendance record.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-struct PaneTrack {
- state: AgentState,
- seen: bool,
- /// Unix second the pane entered its current done/blocked episode; drives
- /// the FIFO ready queue.
- ready_since: Option<u64>,
+pub struct PaneTrack {
+ pub state: AgentState,
+ pub seen: bool,
+ /// Unix millisecond the pane started needing the user (done or blocked);
+ /// orders the FIFO queue within a status.
+ pub since: Option<u64>,
+}
+
+impl PaneTrack {
+ /// A pane not observed yet. herdr: `PaneState::new` starts `seen = true`.
+ pub const NEW: PaneTrack = PaneTrack {
+ state: AgentState::Unknown,
+ seen: true,
+ since: None,
+ };
+
+ pub fn status(&self) -> Status {
+ match (self.state, self.seen) {
+ (AgentState::Working, _) => Status::Working,
+ (AgentState::Blocked, _) => Status::Blocked,
+ (AgentState::Idle, false) => Status::Done,
+ (AgentState::Idle, true) => Status::Idle,
+ (AgentState::Unknown, _) => Status::Unknown,
+ }
+ }
+
+ /// Rebuild a track from the pane options a previous daemon wrote.
+ fn restore(written_state: &str, written_since: &str) -> PaneTrack {
+ let since = written_since.trim().parse::<u64>().ok();
+ let (state, seen) = match written_state {
+ "done" => (AgentState::Idle, false),
+ "idle" => (AgentState::Idle, true),
+ "working" => (AgentState::Working, true),
+ "blocked" => (AgentState::Blocked, true),
+ _ => return PaneTrack::NEW,
+ };
+ let track = PaneTrack { state, seen, since };
+ let needs_user = matches!(track.status(), Status::Done | Status::Blocked);
+ PaneTrack {
+ since: if needs_user { since } else { None },
+ ..track
+ }
+ }
+}
+
+/// One observation step for a pane: herdr's `apply_pane_state_change`
+/// followed by `mark_active_tab_seen`, as a pure function.
+///
+/// - Transition to any non-idle state: seen.
+/// - Completion (working/blocked to idle): seen only if the user is looking
+/// at the pane's window; otherwise the pane is done.
+/// - Any other transition to idle (first observation): seen unchanged.
+/// - Looking at the pane's window, or jumping to it (`acked`): seen.
+pub fn advance(
+ prev: PaneTrack,
+ observed: AgentState,
+ attended: bool,
+ acked: bool,
+ now: u64,
+) -> PaneTrack {
+ let mut next = prev;
+ if observed != prev.state {
+ next.state = observed;
+ if observed != AgentState::Idle {
+ next.seen = true;
+ } else if matches!(prev.state, AgentState::Working | AgentState::Blocked) {
+ next.seen = attended;
+ }
+ next.since = None; // a new episode
+ }
+ if attended || acked {
+ next.seen = true;
+ }
+ let needs_user = matches!(next.status(), Status::Done | Status::Blocked);
+ next.since = match (needs_user, next.since) {
+ (true, Some(since)) => Some(since),
+ (true, None) => Some(now),
+ (false, _) => None,
+ };
+ next
}
pub struct Watcher {
tracks: HashMap<String, PaneTrack>,
- /// Last published (queue, counts); avoids rewriting options every tick.
- last_published: Option<(String, (String, String, String, String))>,
+ /// Last published (queue, blocked, done, working); avoids rewriting the
+ /// global options every tick.
+ last_published: Option<(String, usize, usize, usize)>,
interval_secs: u64,
sounds: bool,
+ notifications: bool,
}
impl Watcher {
- pub fn new(interval_secs: u64, sounds: bool) -> Self {
+ pub fn new(interval_secs: u64, sounds: bool, notifications: bool) -> Self {
Self {
tracks: HashMap::new(),
last_published: None,
interval_secs: interval_secs.max(1),
sounds,
+ notifications,
}
}
pub fn run(&mut self) -> Result<(), String> {
info!(interval = self.interval_secs, "watching tmux panes");
- // dingger is a client of the tmux server, which may not exist yet —
+ // dingger is a client of the tmux server, which may not exist yet:
// it starts when the first terminal opens one. Wait for it, then
// keep watching across server restarts.
let mut had_server = false;
loop {
+ let started = std::time::Instant::now();
match self.tick() {
Ok(()) => {
if !had_server {
@@ -141,145 +221,64 @@ impl Watcher {
had_server = false;
}
}
- std::thread::sleep(std::time::Duration::from_secs(self.interval_secs));
+ debug!(ms = started.elapsed().as_millis() as u64, "tick");
+ hooks::wait_for_wake(std::time::Duration::from_secs(self.interval_secs));
}
}
fn tick(&mut self) -> Result<(), String> {
let panes = tmux::list_panes()?;
+ let now = unix_now();
// Forget panes that went away.
self.tracks
.retain(|pane_id, _| panes.iter().any(|pane| pane.pane_id == *pane_id));
- hooks::forget(
- &panes
- .iter()
- .map(|pane| pane.pane_id.clone())
- .collect::<Vec<_>>(),
- );
+ hooks::forget(&panes.iter().map(|pane| pane.pane_id.clone()).collect::<Vec<_>>());
for pane in &panes {
- let prev_track = self.tracks.get(&pane.pane_id).copied();
- let (prev_state, prev_seen) = prev_track
- .map(|track| (track.state, track.seen))
- .unwrap_or((AgentState::Unknown, true));
-
- // Raw state: hook reports are authoritative while the pane runs
- // the agent; screen detection is the fallback for panes that
- // never report. Viewer screens (transcripts, pickers) return
- // `skip_state_update: true` — they say nothing about state and
- // leave the previous one standing (herdr's publishing contract).
- let raw = if pane.dead {
- Some(AgentState::Idle)
- } else if let Some(agent) = pane.agent {
- if let Some(state) = hooks::hook_state(&pane.pane_id) {
- Some(state)
- } else {
- let screen = match tmux::capture_pane(&pane.pane_id) {
- Ok(screen) => screen,
- Err(err) => {
- warn!(target = %pane.display_target, %err, "capture failed");
- continue;
- }
- };
- // tmux pane_title carries the last OSC title the application
- // set; tmux does not re-expose OSC progress sequences.
- let detection =
- detect_agent_with_osc(Some(agent), &screen, &pane.pane_title, "");
- (!detection.skip_state_update).then_some(detection.state)
- }
- } else {
- // A pane that stops running an agent (agent quit into a
- // shell) is no longer an observation; a stale hook report
- // from the dead agent is not authoritative.
- Some(AgentState::Unknown)
- };
-
- let Some(new_state) = raw else {
+ let Some(observations) = observe(pane) else {
debug!(target = %pane.display_target, "observation skipped (viewer screen)");
continue;
};
- // Seen: attended since the last state change. Attended means the
- // pane is fully focused in an attached client, the user jumped to
- // it (`@dingger_ack`), or it was just first observed. A first
- // observation of idle is seen (nothing has happened yet); a
- // working/blocked → idle transition is an unseen completion.
- let transition = new_state != prev_state;
- // Attended also via `dingger focus` / menu click (@dingger_ack).
- let mut seen = if transition {
- !(new_state == AgentState::Idle && prev_state == AgentState::Unknown)
- } else {
- prev_seen || pane.written_ack == "1"
- };
- if !seen && pane.attended() {
- seen = true;
- }
- if transition || seen != prev_seen {
- let ready_since = if is_ready_to_attend(new_state, seen) {
- Some(unix_now())
- } else {
- None
- };
- self.tracks.insert(
- pane.pane_id.clone(),
- PaneTrack {
- state: new_state,
- seen,
- ready_since,
- },
- );
- }
- if transition {
- debug!(target = %pane.display_target, ?prev_state, ?new_state, seen, "state change");
- let _ = tmux::clear_ack(&pane.pane_id);
- } else if seen != prev_seen {
- let _ = tmux::ack(&pane.pane_id);
- }
- let display = display_status(
- self.tracks
- .get(&pane.pane_id)
- .unwrap_or(&PaneTrack { state: new_state, seen, ready_since: None }),
- );
- // @agent_state mirrors the display status on both transitions and
- // attendance changes (done → idle when the user focuses the pane).
- if pane.written_state != display.label() {
- if let Err(err) = tmux::set_agent_state(&pane.pane_id, display.label()) {
- warn!(target = %pane.display_target, %err, "failed to write @agent_state");
- }
- }
- if new_state == AgentState::Unknown {
- // The agent is gone; its last hook report must not outlive it.
- hooks::drop_report(&pane.pane_id);
- }
+ // First sight of a pane in this process: resume from its options.
+ let restored = !self.tracks.contains_key(&pane.pane_id);
+ let mut track = self
+ .tracks
+ .get(&pane.pane_id)
+ .copied()
+ .unwrap_or_else(|| PaneTrack::restore(&pane.written_state, &pane.written_since));
+ let acked = pane.written_ack == "1";
- // Keep the pane's agent label current even when the state is
- // unchanged (e.g. an agent swap between two idle panes).
- let agent_label = pane
- .agent
- .map(detect_core::agent_label)
- .unwrap_or("")
- .to_string();
- if pane.written_agent != agent_label {
- if let Err(err) = tmux::set_agent(&pane.pane_id, pane.agent) {
- warn!(target = %pane.display_target, %err, "failed to write @agent");
+ // Apply every observation in order: a turn that started and
+ // finished between two ticks is still a completion.
+ let mut alerts = Vec::new();
+ for observed in observations {
+ let next = advance(track, observed, pane.attended(), acked, now);
+ if next.state != track.state {
+ info!(
+ target = %pane.display_target,
+ prev = track.status().label(),
+ next = next.status().label(),
+ "state change"
+ );
+ // Notify on transitions observed by this process, not
+ // when resuming from options written before a restart.
+ if !restored {
+ alerts.extend(notification_kind_for_transition(track.state, next.state));
+ }
}
+ track = next;
}
+ self.tracks.insert(pane.pane_id.clone(), track);
- if transition {
- if let Some(kind) = notification_kind_for_transition(prev_state, new_state) {
- // herdr: a blocked pane plays the alert sound even when
- // the user is watching; a finished pane stays silent when
- // they are already looking at it.
- let suppressed = pane.suppresses_notifications();
- if !suppressed {
- let context = format!("{} ({})", pane.display_target, agent_label);
- send_notification(&agent_label, &kind, &context);
- }
- if self.sounds {
- play_sound(&kind);
- }
- }
+ // Update what the user sees first, then alert.
+ if acked {
+ let _ = tmux::unset_pane_option(&pane.pane_id, "@dingger_ack");
+ }
+ self.write_pane_options(pane, track);
+ for kind in alerts {
+ self.notify(pane, kind);
}
}
@@ -287,134 +286,279 @@ impl Watcher {
Ok(())
}
- /// Publish the FIFO queue (done + blocked panes, oldest transition first)
- /// and counts as server-global options for the tmux status line and
- /// `dingger focus`.
+ fn write_pane_options(&self, pane: &tmux::Pane, track: PaneTrack) {
+ let label = track.status().label();
+ if pane.written_state != label {
+ if let Err(err) = tmux::set_pane_option(&pane.pane_id, "@agent_state", label) {
+ warn!(target = %pane.display_target, %err, "failed to write @agent_state");
+ }
+ }
+ let since = track.since.map(|s| s.to_string()).unwrap_or_default();
+ if pane.written_since != since {
+ let _ = if since.is_empty() {
+ tmux::unset_pane_option(&pane.pane_id, "@dingger_since")
+ } else {
+ tmux::set_pane_option(&pane.pane_id, "@dingger_since", &since)
+ };
+ }
+ let agent = pane.agent.map(detect_core::agent_label).unwrap_or("");
+ if pane.written_agent != agent {
+ let _ = tmux::set_pane_option(&pane.pane_id, "@agent", agent);
+ }
+ // Persist the authoritative hook state so a restart keeps it.
+ let hook = hooks::last_state(&pane.pane_id)
+ .map(state_label)
+ .unwrap_or_default();
+ if pane.agent.is_some() && !hook.is_empty() && pane.written_hook != hook {
+ let _ = tmux::set_pane_option(&pane.pane_id, "@dingger_hook", hook);
+ }
+ if pane.agent.is_none() && !pane.written_hook.is_empty() {
+ let _ = tmux::unset_pane_option(&pane.pane_id, "@dingger_hook");
+ }
+ }
+
+ fn notify(&self, pane: &tmux::Pane, kind: NotificationKind) {
+ // herdr: a blocked pane plays the alert even when the user is looking
+ // at it; a finished pane stays silent when they already are.
+ let watching = pane.attended();
+ if matches!(kind, NotificationKind::Finished) && watching {
+ return;
+ }
+ let agent = pane.agent.map(detect_core::agent_label).unwrap_or("agent");
+ if self.notifications && !watching {
+ send_notification(agent, &kind, &format!("{} ({agent})", pane.display_target));
+ }
+ if self.sounds {
+ play_sound(&kind);
+ }
+ }
+
+ /// Publish the ready queue and counts as server-global options for the
+ /// status line, menu bar, and focus. The queue is ordered like the menu:
+ /// herdr's priority (blocked before done), oldest first within it.
fn publish(&mut self, panes: &[tmux::Pane]) {
- let mut ready: Vec<(&str, String, u64)> = Vec::new(); // (pane_id, target, ready_since)
- let mut blocked = 0usize;
- let mut done = 0usize;
- let mut working = 0usize;
+ let mut queue: Vec<(std::cmp::Reverse<u8>, u64, &str)> = Vec::new();
+ let (mut blocked, mut done, mut working) = (0, 0, 0);
for pane in panes {
let Some(track) = self.tracks.get(&pane.pane_id) else {
continue;
};
- let status = display_status(track);
- match status {
+ match track.status() {
+ Status::Blocked => blocked += 1,
+ Status::Done => done += 1,
Status::Working => working += 1,
- Status::Done | Status::Blocked => {
- if track.ready_since.is_some() {
- if status == Status::Blocked {
- blocked += 1;
- } else {
- done += 1;
- }
- if let Some(since) = track.ready_since {
- ready.push((pane.pane_id.as_str(), pane.display_target.clone(), since));
- }
- }
- }
Status::Idle | Status::Unknown => {}
}
+ if let Some(since) = track.since {
+ queue.push((std::cmp::Reverse(track.status().priority()), since, pane.pane_id.as_str()));
+ }
}
- ready.sort_by_key(|(_, _, since)| *since);
-
- let queue = ready
+ queue.sort();
+ let queue = queue
.iter()
- .map(|(pane_id, _, since)| format!("{pane_id}:{since}"))
+ .map(|(_, _, pane_id)| *pane_id)
.collect::<Vec<_>>()
.join(",");
- let counts = (
- ready.len().to_string(),
- blocked.to_string(),
- working.to_string(),
- done.to_string(),
- );
- let next = (queue.clone(), counts.clone());
- if self.last_published.as_ref() != Some(&next) {
- self.last_published = Some(next);
- for (name, value) in [
- ("@dingger_queue", queue.as_str()),
- ("@dingger_ready", counts.0.as_str()),
- ("@dingger_blocked", counts.1.as_str()),
- ("@dingger_working", counts.2.as_str()),
- ("@dingger_done", counts.3.as_str()),
- ] {
- if let Err(err) = tmux::set_global(name, value) {
- warn!(%err, "failed to write {name}");
- }
+
+ let next = (queue, blocked, done, working);
+ if self.last_published.as_ref() == Some(&next) {
+ return;
+ }
+ for (name, value) in [
+ ("@dingger_queue", next.0.clone()),
+ ("@dingger_blocked", next.1.to_string()),
+ ("@dingger_done", next.2.to_string()),
+ ("@dingger_working", next.3.to_string()),
+ ] {
+ if let Err(err) = tmux::set_global(name, &value) {
+ warn!(%err, "failed to write {name}");
}
}
+ self.last_published = Some(next);
+ }
+}
+
+/// The pane's raw agent states this tick, in order, or `None` when the
+/// screen is a viewer (transcript, picker) that says nothing about state.
+fn observe(pane: &tmux::Pane) -> Option<Vec<AgentState>> {
+ if pane.dead {
+ return Some(vec![AgentState::Idle]);
+ }
+ let Some(agent) = pane.agent else {
+ // The agent is gone; its last hook report must not outlive it.
+ hooks::drop_reports(&pane.pane_id);
+ return Some(vec![AgentState::Unknown]);
+ };
+ // Agent-reported state is authoritative; it survives restarts through
+ // the `@dingger_hook` option until the extension reports again.
+ if let Some(states) = hooks::take_observations(&pane.pane_id) {
+ return Some(states);
+ }
+ if let Some(state) = parse_state(&pane.written_hook) {
+ return Some(vec![state]);
}
+ let screen = match tmux::capture_pane(&pane.pane_id) {
+ Ok(screen) => screen,
+ Err(err) => {
+ warn!(target = %pane.display_target, %err, "capture failed");
+ return None;
+ }
+ };
+ // tmux pane_title carries the last OSC title the application set; tmux
+ // does not re-expose OSC progress sequences.
+ let detection = detect_agent_with_osc(Some(agent), &screen, &pane.pane_title, "");
+ (!detection.skip_state_update).then(|| vec![detection.state])
}
-/// When a pane became ready for the user, as unix seconds. Ready means the
-/// agent is waiting on the user: done (finished, unseen) or blocked.
-fn is_ready_to_attend(state: AgentState, seen: bool) -> bool {
+fn state_label(state: AgentState) -> &'static str {
match state {
- AgentState::Blocked => true,
- AgentState::Idle => !seen,
- _ => false,
+ AgentState::Idle => "idle",
+ AgentState::Working => "working",
+ AgentState::Blocked => "blocked",
+ AgentState::Unknown => "unknown",
}
}
-fn display_status(track: &PaneTrack) -> Status {
- match track.state {
- AgentState::Working => Status::Working,
- AgentState::Blocked => Status::Blocked,
- AgentState::Idle => {
- if track.seen {
- Status::Idle
- } else {
- Status::Done
- }
- }
- AgentState::Unknown => Status::Unknown,
+fn parse_state(label: &str) -> Option<AgentState> {
+ match label {
+ "idle" => Some(AgentState::Idle),
+ "working" => Some(AgentState::Working),
+ "blocked" => Some(AgentState::Blocked),
+ _ => None,
}
}
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
- .map(|d| d.as_secs())
+ .map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
+ use AgentState::{Blocked, Idle, Unknown, Working};
+
+ fn step(prev: PaneTrack, observed: AgentState, attended: bool) -> PaneTrack {
+ advance(prev, observed, attended, false, 100)
+ }
+
+ #[test]
+ fn notification_policy_matches_herdr() {
+ assert!(matches!(
+ notification_kind_for_transition(Working, Blocked),
+ Some(NotificationKind::NeedsAttention)
+ ));
+ assert!(matches!(
+ notification_kind_for_transition(Unknown, Blocked),
+ Some(NotificationKind::NeedsAttention)
+ ));
+ assert!(matches!(
+ notification_kind_for_transition(Working, Idle),
+ Some(NotificationKind::Finished)
+ ));
+ assert!(matches!(
+ notification_kind_for_transition(Blocked, Idle),
+ Some(NotificationKind::Finished)
+ ));
+ assert!(notification_kind_for_transition(Blocked, Blocked).is_none());
+ assert!(notification_kind_for_transition(Unknown, Idle).is_none());
+ assert!(notification_kind_for_transition(Idle, Working).is_none());
+ assert!(notification_kind_for_transition(Idle, Unknown).is_none());
+ }
+
+ // herdr: initial_idle_in_background_stays_seen
+ #[test]
+ fn first_observation_of_idle_is_idle() {
+ let track = step(PaneTrack::NEW, Idle, false);
+ assert_eq!(track.status(), Status::Idle);
+ assert_eq!(track.since, None);
+ }
+
+ // herdr: state_changed_idle_in_background_marks_unseen
+ #[test]
+ fn completion_away_from_the_window_is_done() {
+ let working = step(step(PaneTrack::NEW, Idle, false), Working, false);
+ assert_eq!(working.status(), Status::Working);
+ let done = step(working, Idle, false);
+ assert_eq!(done.status(), Status::Done);
+ assert_eq!(done.since, Some(100));
+ }
+
+ // herdr: active_tab_completion_marks_pane_seen
+ #[test]
+ fn completion_while_looking_is_idle() {
+ let working = step(PaneTrack::NEW, Working, true);
+ let idle = step(working, Idle, true);
+ assert_eq!(idle.status(), Status::Idle);
+ assert_eq!(idle.since, None);
+ }
+
+ // herdr: switch_workspace_marks_panes_seen (mark_active_tab_seen)
+ #[test]
+ fn looking_at_the_window_turns_done_into_idle() {
+ let done = step(step(PaneTrack::NEW, Working, false), Idle, false);
+ assert_eq!(done.status(), Status::Done);
+ let still_done = step(done, Idle, false);
+ assert_eq!(still_done.status(), Status::Done);
+ let seen = step(still_done, Idle, true);
+ assert_eq!(seen.status(), Status::Idle);
+ assert_eq!(seen.since, None);
+ }
+
+ // herdr: agent_focus_marks_already_focused_done_agent_seen
+ #[test]
+ fn jumping_to_a_pane_turns_done_into_idle() {
+ let done = step(step(PaneTrack::NEW, Working, false), Idle, false);
+ let seen = advance(done, Idle, false, true, 200);
+ assert_eq!(seen.status(), Status::Idle);
+ }
+
+ #[test]
+ fn working_again_clears_done_and_the_next_completion_is_done_again() {
+ let done = step(step(PaneTrack::NEW, Working, false), Idle, false);
+ let working = step(done, Working, false);
+ assert_eq!(working.status(), Status::Working);
+ assert_eq!(working.since, None);
+ let done_again = advance(working, Idle, false, false, 300);
+ assert_eq!(done_again.status(), Status::Done);
+ assert_eq!(done_again.since, Some(300));
+ }
+
+ #[test]
+ fn blocked_needs_the_user_even_while_looking() {
+ let blocked = step(step(PaneTrack::NEW, Working, false), Blocked, true);
+ assert_eq!(blocked.status(), Status::Blocked);
+ assert_eq!(blocked.since, Some(100));
+ // answering the prompt: back to work, then a completion away → done
+ let done = step(step(blocked, Working, false), Idle, false);
+ assert_eq!(done.status(), Status::Done);
+ }
+
+ #[test]
+ fn fifo_stamp_survives_unchanged_ticks() {
+ let done = advance(step(PaneTrack::NEW, Working, false), Idle, false, false, 50);
+ let later = advance(done, Idle, false, false, 999);
+ assert_eq!(later.since, Some(50));
+ }
#[test]
- fn policy_matches_herdr() {
- assert!(notification_kind_for_transition(AgentState::Working, AgentState::Blocked)
- .is_some_and(|kind| matches!(kind, NotificationKind::NeedsAttention)));
- assert!(notification_kind_for_transition(AgentState::Blocked, AgentState::Blocked).is_none());
- assert!(notification_kind_for_transition(AgentState::Unknown, AgentState::Blocked)
- .is_some_and(|kind| matches!(kind, NotificationKind::NeedsAttention)));
- assert!(notification_kind_for_transition(AgentState::Working, AgentState::Idle)
- .is_some_and(|kind| matches!(kind, NotificationKind::Finished)));
- assert!(notification_kind_for_transition(AgentState::Blocked, AgentState::Idle)
- .is_some_and(|kind| matches!(kind, NotificationKind::Finished)));
- // Unknown → Idle is a first observation, not a completion.
- assert!(notification_kind_for_transition(AgentState::Unknown, AgentState::Idle).is_none());
- assert!(notification_kind_for_transition(AgentState::Idle, AgentState::Working).is_none());
- assert!(notification_kind_for_transition(AgentState::Idle, AgentState::Unknown).is_none());
+ fn agent_exit_is_unknown() {
+ let done = step(step(PaneTrack::NEW, Working, false), Idle, false);
+ assert_eq!(step(done, Unknown, false).status(), Status::Unknown);
}
#[test]
- fn display_status_maps_herdr() {
- let unseen = PaneTrack { state: AgentState::Idle, seen: false, ready_since: Some(1) };
- let seen = PaneTrack { state: AgentState::Idle, seen: true, ready_since: None };
- assert_eq!(display_status(&unseen), Status::Done);
- assert_eq!(display_status(&seen), Status::Idle);
- assert_eq!(
- display_status(&PaneTrack { state: AgentState::Working, seen: false, ready_since: None }),
- Status::Working
- );
- assert_eq!(
- display_status(&PaneTrack { state: AgentState::Blocked, seen: false, ready_since: Some(1) }),
- Status::Blocked
- );
+ fn restore_resumes_where_the_previous_daemon_left_off() {
+ let done = PaneTrack::restore("done", "42");
+ assert_eq!(done.status(), Status::Done);
+ assert_eq!(done.since, Some(42));
+ // the same observation after a restart keeps it done
+ assert_eq!(step(done, Idle, false).status(), Status::Done);
+ assert_eq!(PaneTrack::restore("idle", "").status(), Status::Idle);
+ assert_eq!(PaneTrack::restore("", "").status(), Status::Unknown);
+ assert_eq!(PaneTrack::restore("idle", "42").since, None);
}
#[test]