aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md149
-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
-rw-r--r--menubar/main.swift440
-rw-r--r--packaging/launchd/dev.dingger.dingger.plist2
-rw-r--r--packaging/launchd/dev.dingger.menubar.plist28
-rw-r--r--packaging/tmux/dingger.tmux14
-rwxr-xr-xtests/e2e.sh244
11 files changed, 1261 insertions, 706 deletions
diff --git a/README.md b/README.md
index 6e96683..7fda838 100644
--- a/README.md
+++ b/README.md
@@ -1,74 +1,107 @@
# dingger
-macOS agent watcher for tmux panes. dingger watches every pane in your tmux
-server, detects which AI coding agent is running in each one (claude, codex,
-pi, gemini, and 19 others), tracks its state (idle / working / blocked), and
-sends a macOS notification — with sound — when an agent needs attention or
-finishes.
-
-dingger owns no terminals. It is a client of your tmux server: `capture-pane`
-for screen text, `#{pane_title}` for OSC titles, and `#{pane_pid}` plus a `ps`
-tree walk for agent identity. The detection engine and rule manifests are
-extracted from [herdr](https://github.com/herdrdev/herdr) (Apache-2.0), so
-detection quality matches herdr's screen-matching engine. herdr's manifests
-use the same terminal-screen regions (`capture-pane` output maps 1:1); OSC
-progress sequences are not re-exposed by tmux (a small confidence loss on a
-few rules). Agents launched as bare `node <entry.js>` scripts (rather than via
-an install shim) are not identified yet.
+macOS agent watcher for tmux panes: herdr's agent panel, without herdr.
+dingger watches every pane in your tmux server, detects which AI coding agent
+runs in each (claude, codex, pi, gemini, and 19 others), tracks herdr's five
+statuses, and tells you which agents are waiting for you: in the menu bar, in
+the tmux status line, and with a notification and a sound.
+
+dingger owns no terminals. It is a client of your tmux server. The detection
+engine and rule manifests are extracted from
+[herdr](https://github.com/herdrdev/herdr) (Apache-2.0).
+
+## Statuses (herdr's, verbatim)
+
+| status | meaning | symbol | color | priority |
+|---------|--------------------------------------------------|--------|--------|----------|
+| blocked | needs your input | `×` | red | 4 |
+| done | finished a turn while you were looking elsewhere | `✓` | teal | 3 |
+| working | turn in flight | `◐` | yellow | 2 |
+| idle | at its prompt, nothing new since you looked | `○` | green | 1 |
+
+The state machine is herdr's `apply_pane_state_change` and
+`mark_active_tab_seen`, implemented as one pure function (`watcher::advance`)
+and unit-tested against herdr's own test cases:
+
+- a turn that finishes while you look at another window becomes **done**;
+- a turn that finishes in the window you are looking at stays **idle**;
+- looking at a window (a tmux window is herdr's tab) turns its done agents
+ **idle**, as does jumping to one from the menu bar or status line;
+- an agent seen for the first time is **idle**, not done.
+
+dingger watches tmux focus through tmux hooks, so attendance updates the
+moment you switch windows; agent reports apply in order, so a turn shorter
+than a poll is still a completion.
+
+## Surfaces
+
+- **Menu bar** (`menubar/main.swift`): `×1 ✓3 ◐2` in herdr's colors; the
+ menu lists every agent in priority order, oldest waiting first. Clicking a
+ row jumps to that pane.
+- **tmux status line** (`packaging/tmux/dingger.tmux`): the same summary after
+ the clock. Clicking it jumps to the top waiting agent (the menu's top row),
+ skipping the window you already look at.
+- **Notifications**: "needs attention" (blocked) and "finished" (done), with
+ herdr's sounds; silent for completions you are already watching.
+
+## Where state comes from
+
+1. **Agent hooks** (authoritative): `dingger install pi` installs a pi
+ extension that reports working/blocked/idle over
+ `~/.local/state/dingger/agent.sock`. Restart running pi agents to load it.
+2. **Screen matching** (fallback): `capture-pane` text through herdr's
+ manifests, for agents without hooks.
+
+All per-pane state lives in tmux pane options (`@agent_state`,
+`@dingger_since`, `@dingger_hook`), so restarting dingger loses nothing.
## Usage
```bash
-dingger watch # run the watcher; notify on transitions
-dingger status # table of panes, agents, states
-dingger manifests update # refresh manifests from herdr's published catalog
+dingger watch # the daemon (run by launchd, see below)
+dingger status # agents in priority order
+dingger focus [--pane ID] # jump to an agent (default: the top waiter)
+dingger install pi # install the pi state-reporting extension
+dingger manifests update # refresh manifests from herdr's catalog
```
-`dingger status` reads state written back into tmux as the per-pane user
-option `@agent_state`, so it works without any daemon IPC. Render it directly
-in your status line with `#{:@agent_state}` in `status-format` /
-`pane-border-status`. `dingger manifests update` stores refreshed manifests
-from herdr's published catalog; restart `dingger watch` to pick them up.
+## Install
-Environment:
-
-- `DINGGER_TMUX_SOCKET` — target a named tmux server socket (`tmux -L <name>`)
-- `DINGGER_CONFIG_DIR` / `DINGGER_STATE_DIR` — override/remote manifest locations
- (default `~/.config/dingger` / `~/.local/state/dingger`)
-
-## Notification policy
-
-herdr's transition policy: notify on any transition to `blocked` ("needs
-attention", `request.mp3`) and on `working`/`blocked` → `idle` ("finished",
-`done.mp3`), with no notification for `unknown` panes. One deliberate
-difference from herdr: a pane on the active window of an attached client is
-suppressed entirely (herdr still plays the blocker sound there). Viewer
-screens such as Claude's transcript never change state.
-
-## Layout
-
-- `crates/detect-core` — the detection engine (manifest compiler, region
- extractors, priority arbitration) and 22 bundled manifests, extracted from
- herdr's `src/detect/`. Pure text-in → state-out.
-- `crates/dinggerd` — the `dingger` binary: tmux watcher, notification
- policy, sounds, CLI.
-- `sounds/` — herdr's bundled notification sounds.
-
-## Status
+```bash
+cargo build --release
+cp target/release/dingger ~/.local/bin/dingger # do not strip: invalidates
+codesign --force --sign - ~/.local/bin/dingger # the signature, macOS kills it
+swiftc -O -o target/dingger-menu menubar/main.swift # menu bar, bundled as
+ # ~/Applications/dingger.app
+```
-Early scaffold. Roadmap: menu bar UI (Swift shell over the daemon), configurable
-notification policy.
+- launchd agents (start at login, restart on exit):
+ `packaging/launchd/dev.dingger.{dingger,menubar}.plist`; adjust the user
+ path, copy to `~/Library/LaunchAgents/`, `launchctl bootstrap gui/$(id -u) <plist>`.
+- tmux: `source-file /path/to/dingger/packaging/tmux/dingger.tmux` (needs
+ `set -g mouse on` for the click).
-### Running as a login agent (launchd)
+dingger does not need tmux running first; it attaches when a server appears.
-`packaging/launchd/dev.dingger.dingger.plist` runs `dingger watch` at login
-with auto-restart; adjust the user path, then:
+## Testing
```bash
-launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.dingger.dingger.plist
+cargo test # engine tests + herdr state-machine tests
+tests/e2e.sh # end to end against an isolated tmux server
```
-dingger does not require tmux to be running first — it waits for a server and
-attaches when one appears. All tmux invocations use `-u` (forced UTF-8): under
-launchd's C locale, tmux mangles tab-separated `-F` output into underscores,
-which would silently hide every pane.
+`tests/e2e.sh` starts its own tmux server, its own daemon (polling only every
+30 s, so everything asserted must arrive through events), fake agents driven
+over the hook protocol, and a real tmux client whose focus it controls. It
+asserts every status transition, attendance by navigation, both click paths
+(including the real menu bar binary), the status-line segment, latency with a
+deliberately slow notifier, and state surviving a daemon restart. It never
+touches your tmux server.
+
+## Layout
+
+- `crates/detect-core`: the detection engine and 22 manifests, from herdr.
+- `crates/dinggerd`: the `dingger` binary (watcher, hooks, focus, CLI).
+- `menubar/`: the menu bar app.
+- `packaging/`: launchd agents and the tmux snippet.
+- `tests/e2e.sh`: the end-to-end suite.
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]
diff --git a/menubar/main.swift b/menubar/main.swift
index d0a3b30..602b5cc 100644
--- a/menubar/main.swift
+++ b/menubar/main.swift
@@ -1,286 +1,272 @@
-// dingger menu bar: live agent states from tmux pane user options
-// (@agent / @agent_state), written by the dingger daemon.
+// dingger menu bar: herdr's agent panel as a macOS status item.
//
-// Visual language is herdr's agent panel, verbatim: five statuses
-// (working ◐ yellow, blocked × red, done ✓ teal, idle ○ green,
-// unknown · gray), priority order blocked > done > working > idle,
-// and the number in the title is herdr's actionable set (done + blocked).
+// Reads the pane options the dingger daemon writes (@agent, @agent_state,
+// @dingger_since) and renders herdr's Symbols style with herdr's palette:
+//
+// blocked × red done ✓ teal working ◐ yellow idle ○ green
+//
+// in herdr's priority order (blocked > done > working > idle), oldest
+// waiting first. Clicking a row runs `dingger focus --pane`, the same tested
+// code path as the tmux status-line click.
+//
+// Debug flags (used by tests/e2e.sh):
+// --print print the title and rows the menu would show, then exit
+// --click <pane> perform a row click for <pane>, then exit
import AppKit
-struct PaneRow {
- let paneId: String
- let target: String
- let agent: String
- let state: String
-}
-
-let red = NSColor.systemRed
-let yellow = NSColor.systemYellow
-let teal = NSColor.systemTeal
-let green = NSColor.systemGreen
-let gray = NSColor.systemGray
+// MARK: herdr's palette (catppuccin mocha on dark, latte on light)
-/// Tint a symbol image (non-template, so it keeps its color in menus).
-func tinted(_ image: NSImage?, _ color: NSColor) -> NSImage? {
- guard let image = image else { return nil }
- let img = NSImage(size: image.size, flipped: false) { rect in
- image.draw(in: rect)
- color.set()
- rect.fill(using: .sourceAtop)
- return true
+func herdrColor(dark: (CGFloat, CGFloat, CGFloat), light: (CGFloat, CGFloat, CGFloat)) -> NSColor {
+ NSColor(name: nil) { appearance in
+ let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
+ let (r, g, b) = isDark ? dark : light
+ return NSColor(srgbRed: r / 255, green: g / 255, blue: b / 255, alpha: 1)
}
- img.isTemplate = false
- return img
}
-func symbol(_ name: String, _ color: NSColor? = nil) -> NSImage? {
- let image = NSImage(systemSymbolName: name, accessibilityDescription: nil)?
- .withSymbolConfiguration(.init(pointSize: 13, weight: .regular))
- return color.map { tinted(image, $0) } ?? image
-}
+let herdrRed = herdrColor(dark: (243, 139, 168), light: (210, 15, 57))
+let herdrTeal = herdrColor(dark: (148, 226, 213), light: (23, 146, 153))
+let herdrYellow = herdrColor(dark: (249, 226, 175), light: (223, 142, 29))
+let herdrGreen = herdrColor(dark: (166, 227, 161), light: (64, 160, 43))
+let herdrGray = herdrColor(dark: (108, 112, 134), light: (156, 160, 176))
-/// Herdr's status_color + Symbols style, as SF Symbols.
-func statusSymbol(_ status: String) -> NSImage? {
- switch status {
- case "working": return symbol("circle.dotted", yellow)
- case "blocked": return symbol("exclamationmark.circle.fill", red)
- case "done": return symbol("checkmark.circle.fill", teal)
- case "idle": return symbol("checkmark.circle", green)
- default: return symbol("questionmark.circle", gray)
- }
-}
+// MARK: herdr's status vocabulary
-func statusPriority(_ status: String) -> Int {
- switch status {
- case "blocked": return 4
- case "done": return 3
- case "working": return 2
- case "idle": return 1
- default: return 0
+enum Status: String {
+ case blocked, done, working, idle, unknown
+
+ var symbol: String {
+ switch self {
+ case .blocked: return "×"
+ case .done: return "✓"
+ case .working: return "◐"
+ case .idle: return "○"
+ case .unknown: return "·"
+ }
}
-}
-final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
- private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
- private let menu = NSMenu()
- private var panes: [PaneRow] = []
- private var queue: [String] = [] // pane ids, oldest waiting first
+ var color: NSColor {
+ switch self {
+ case .blocked: return herdrRed
+ case .done: return herdrTeal
+ case .working: return herdrYellow
+ case .idle: return herdrGreen
+ case .unknown: return herdrGray
+ }
+ }
- func applicationDidFinishLaunching(_: Notification) {
- NSApp.setActivationPolicy(.accessory) // no Dock icon
- item.button?.image = symbol("bell", gray)
- item.button?.font = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular)
- item.menu = menu
- menu.delegate = self
- let timer = Timer(timeInterval: 2.0, repeats: true) { [weak self] _ in
- self?.refreshTitle()
+ var priority: Int {
+ switch self {
+ case .blocked: return 4
+ case .done: return 3
+ case .working: return 2
+ case .idle: return 1
+ case .unknown: return 0
}
- RunLoop.main.add(timer, forMode: .common)
- refreshTitle()
}
+}
- // MARK: data
+struct Row {
+ let paneId: String
+ let agent: String
+ let status: Status
+ let target: String
+ let since: Int
+}
- private func runTmux(_ args: [String]) -> String? {
- let process = Process()
- process.executableURL = URL(fileURLWithPath: "/opt/homebrew/bin/tmux")
- process.arguments = args
- let pipe = Pipe()
- process.standardOutput = pipe
- process.standardError = FileHandle.nullDevice
- do {
- try process.run()
- } catch {
- return nil
- }
- let data = pipe.fileHandleForReading.readDataToEndOfFile()
- process.waitUntilExit()
- guard process.terminationStatus == 0 else { return nil }
- return String(data: data, encoding: .utf8)
+// MARK: tmux and dingger access
+
+let environment = ProcessInfo.processInfo.environment
+
+func run(_ path: String, _ args: [String]) -> String? {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: path)
+ process.arguments = args
+ let pipe = Pipe()
+ process.standardOutput = pipe
+ process.standardError = FileHandle.nullDevice
+ do {
+ try process.run()
+ } catch {
+ return nil
}
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
+ process.waitUntilExit()
+ guard process.terminationStatus == 0 else { return nil }
+ return String(data: data, encoding: .utf8)
+}
- private func fetchPanes() -> [PaneRow] {
- let fmt = "#{pane_id}\t#{@agent}\t#{@agent_state}\t#{@dingger_ack}\t#{session_name}:#{window_name}\t#{window_panes}\t#{pane_index}"
- guard let out = runTmux(["-u", "list-panes", "-a", "-F", fmt]) else { return [] }
- return out.split(separator: "\n").compactMap { line in
- let fields = line.split(separator: "\t", omittingEmptySubsequences: false)
- guard fields.count >= 7 else { return nil }
- let agent = String(fields[1])
- let state = fields[2].isEmpty ? "unknown" : String(fields[2])
- guard !agent.isEmpty, ["working", "blocked", "done", "idle"].contains(state) else {
- return nil
- }
- // fields[4] is already `session:window-name`; add the pane index
- // only when the window is split.
- var target = String(fields[4])
- if Int(fields[5]) ?? 1 > 1 {
- target += ".\(fields[6])"
- }
- return PaneRow(paneId: String(fields[0]), target: target, agent: agent, state: state)
- }
+func tmux(_ args: [String]) -> String? {
+ var all = ["-u"]
+ if let socket = environment["DINGGER_TMUX_SOCKET"] {
+ all += ["-L", socket]
}
+ return run(environment["DINGGER_TMUX"] ?? "/opt/homebrew/bin/tmux", all + args)
+}
- private func fetchQueue() -> [String] {
- guard let out = runTmux(["-u", "show-option", "-gv", "@dingger_queue"]) else { return [] }
- return out.trimmingCharacters(in: .whitespacesAndNewlines)
- .split(separator: ",").map { $0.split(separator: ":").first.map(String.init) ?? "" }
+let dinggerBinary = environment["DINGGER_BIN"] ?? (NSHomeDirectory() + "/.local/bin/dingger")
+
+/// Every agent pane, in herdr's priority order, oldest waiting first.
+func fetchRows() -> [Row] {
+ let format = [
+ "#{pane_id}", "#{@agent}", "#{@agent_state}", "#{@dingger_since}",
+ "#{session_name}:#{window_name}", "#{window_panes}", "#{pane_index}",
+ ].joined(separator: "\t")
+ guard let out = tmux(["list-panes", "-a", "-F", format]) else { return [] }
+ let rows: [Row] = out.split(separator: "\n").compactMap { line in
+ let f = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init)
+ guard f.count >= 7, !f[1].isEmpty else { return nil }
+ let status = Status(rawValue: f[2]) ?? .unknown
+ guard status != .unknown else { return nil }
+ let target = (Int(f[5]) ?? 1) > 1 ? "\(f[4]).\(f[6])" : f[4]
+ return Row(paneId: f[0], agent: f[1], status: status, target: target,
+ since: Int(f[3]) ?? Int.max)
+ }
+ return rows.sorted {
+ if $0.status.priority != $1.status.priority { return $0.status.priority > $1.status.priority }
+ if $0.since != $1.since { return $0.since < $1.since }
+ return $0.target < $1.target
}
+}
+
+/// Show the pane and mark it attended; the same code path as the tmux
+/// status-line click.
+func focus(_ paneId: String) {
+ _ = run(dinggerBinary, ["focus", "--pane", paneId])
+}
- private func orderedPanes() -> [PaneRow] {
- // herdr's priority: blocked > done > working > idle; FIFO within the
- // queue (oldest waiting first), target order for the rest.
- let priority = { (row: PaneRow) -> Int in statusPriority(row.state) }
- var rows = panes
- rows.sort {
- let (pa, pb) = (priority($0), priority($1))
- if pa != pb { return pa > pb }
- let (qa, qb) = (queue.firstIndex(of: $0.paneId), queue.firstIndex(of: $1.paneId))
- if let qa, let qb { return qa < qb }
- if qa != nil { return true }
- if qb != nil { return false }
- return $0.target < $1.target
+// MARK: rendering
+
+/// herdr-style summary: `×1 ✓3 ◐2` in priority order; `○` when every agent
+/// is idle; `·` when there are none.
+func title(for rows: [Row]) -> NSAttributedString {
+ let font = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .medium)
+ let text = NSMutableAttributedString()
+ for status in [Status.blocked, .done, .working] {
+ let count = rows.filter { $0.status == status }.count
+ guard count > 0 else { continue }
+ if text.length > 0 {
+ text.append(NSAttributedString(string: " ", attributes: [.font: font]))
}
- return rows
+ text.append(NSAttributedString(
+ string: "\(status.symbol)\(count)",
+ attributes: [.font: font, .foregroundColor: status.color]))
}
+ if text.length == 0 {
+ let status: Status = rows.isEmpty ? .unknown : .idle
+ text.append(NSAttributedString(
+ string: status.symbol, attributes: [.font: font, .foregroundColor: status.color]))
+ }
+ return text
+}
- // MARK: title (timer-driven; never touches the open menu)
+func plainTitle(for rows: [Row]) -> String {
+ title(for: rows).string
+}
- private func refreshTitle() {
- panes = fetchPanes()
- queue = fetchQueue()
- FileHandle.standardError.write(Data(
- "\(Date()) menubar: panes=\(panes.count) queue=\(queue.count)\n".utf8))
- redrawTitle()
- }
+func rowTitle(_ row: Row, tabStop: CGFloat) -> NSAttributedString {
+ let font = NSFont.menuFont(ofSize: 0)
+ let mono = NSFont.monospacedSystemFont(ofSize: font.pointSize - 1, weight: .regular)
+ let paragraph = NSMutableParagraphStyle()
+ paragraph.tabStops = [NSTextTab(textAlignment: .right, location: tabStop)]
+ let text = NSMutableAttributedString()
+ text.append(NSAttributedString(
+ string: "\(row.status.symbol) ",
+ attributes: [.font: mono, .foregroundColor: row.status.color, .paragraphStyle: paragraph]))
+ text.append(NSAttributedString(
+ string: row.agent, attributes: [.font: font, .paragraphStyle: paragraph]))
+ text.append(NSAttributedString(
+ string: "\t\(row.target)",
+ attributes: [.font: mono, .foregroundColor: NSColor.secondaryLabelColor,
+ .paragraphStyle: paragraph]))
+ return text
+}
- private func redrawTitle() {
- let blocked = panes.filter { $0.state == "blocked" }.count
- let done = panes.filter { $0.state == "done" }.count
- let working = panes.filter { $0.state == "working" }.count
+// MARK: app
- let (image, count): (NSImage?, String)
- if blocked > 0 {
- (image, count) = (symbol("bell.fill", red), String(blocked))
- } else if done > 0 {
- (image, count) = (symbol("bell.fill", teal), String(done))
- } else if working > 0 {
- (image, count) = (symbol("bell", yellow), "")
- } else {
- (image, count) = (symbol("bell", gray), "")
+final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
+ private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+ private let menu = NSMenu()
+
+ func applicationDidFinishLaunching(_: Notification) {
+ menu.delegate = self
+ item.menu = menu
+ refreshTitle()
+ let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
+ self?.refreshTitle()
}
- item.button?.image = image
- item.button?.title = count
+ RunLoop.main.add(timer, forMode: .common)
+ }
+ private func refreshTitle() {
+ let rows = fetchRows()
+ item.button?.attributedTitle = title(for: rows)
var tip: [String] = []
- if blocked > 0 { tip.append("\(blocked) blocked") }
- if done > 0 { tip.append("\(done) done") }
- if working > 0 { tip.append("\(working) working") }
+ for status in [Status.blocked, .done, .working, .idle] {
+ let count = rows.filter { $0.status == status }.count
+ if count > 0 { tip.append("\(count) \(status.rawValue)") }
+ }
let tipText = tip.isEmpty ? "No agents" : tip.joined(separator: " · ")
item.button?.toolTip = tipText
item.button?.setAccessibilityLabel("dingger, " + tipText)
}
- // MARK: menu (rebuilt when opened, so rows don't reshuffle under the cursor)
-
+ // Rebuilt from fresh data every time the menu opens.
func menuNeedsUpdate(_ menu: NSMenu) {
- rebuildMenu()
- }
-
- private func rebuildMenu() {
menu.removeAllItems()
-
- let rows = orderedPanes()
+ let rows = fetchRows()
if rows.isEmpty {
let none = NSMenuItem(title: "No agents", action: nil, keyEquivalent: "")
none.isEnabled = false
menu.addItem(none)
}
-
- var previousPriority: Int?
- for pane in rows {
- let priority = statusPriority(pane.state)
- if let previous = previousPriority, previous != priority {
- menu.addItem(.separator())
- }
- previousPriority = priority
-
- let row = NSMenuItem(title: "\(pane.agent)\t\(pane.target)",
- action: #selector(paneClicked(_:)), keyEquivalent: "")
- row.target = self
- row.image = statusSymbol(pane.state)
- row.attributedTitle = attributedRow(agent: pane.agent, target: pane.target)
- row.toolTip = "\(pane.state) — \(pane.target)"
- row.representedObject = pane.paneId
- menu.addItem(row)
+ let font = NSFont.menuFont(ofSize: 0)
+ let mono = NSFont.monospacedSystemFont(ofSize: font.pointSize - 1, weight: .regular)
+ let widest = rows.map { row -> CGFloat in
+ ("\(row.status.symbol) " as NSString).size(withAttributes: [.font: mono]).width
+ + (row.agent as NSString).size(withAttributes: [.font: font]).width
+ + (row.target as NSString).size(withAttributes: [.font: mono]).width
+ }.max() ?? 0
+ let tabStop = ceil(widest + 40)
+ for row in rows {
+ let entry = NSMenuItem(title: row.agent, action: #selector(rowClicked(_:)), keyEquivalent: "")
+ entry.target = self
+ entry.attributedTitle = rowTitle(row, tabStop: tabStop)
+ entry.toolTip = "\(row.status.rawValue) · \(row.target)"
+ entry.representedObject = row.paneId
+ menu.addItem(entry)
}
-
menu.addItem(.separator())
- let quit = NSMenuItem(title: "Quit dingger", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
- menu.addItem(quit)
+ menu.addItem(NSMenuItem(title: "Quit dingger", action: #selector(NSApplication.terminate(_:)),
+ keyEquivalent: "q"))
}
- /// Agent left, tmux target right-aligned at a computed tab stop — like a
- /// native menu's key-equivalent column.
- private func attributedRow(agent: String, target: String) -> NSAttributedString {
- let menuFont = NSFont.menuFont(ofSize: 0)
- let monoFont = NSFont.monospacedSystemFont(
- ofSize: menuFont.pointSize - 1, weight: .regular)
- let agentWidth = (agent as NSString).size(withAttributes: [.font: menuFont]).width
- let targetWidth = (target as NSString).size(withAttributes: [.font: monoFont]).width
- let tabStop = ceil(agentWidth + 40 + targetWidth)
-
- let paragraph = NSMutableParagraphStyle()
- paragraph.tabStops = [NSTextTab(textAlignment: .right, location: tabStop)]
-
- let text = NSMutableAttributedString()
- text.append(NSAttributedString(
- string: agent,
- attributes: [.font: menuFont, .paragraphStyle: paragraph]))
- text.append(NSAttributedString(string: "\t"))
- text.append(NSAttributedString(
- string: target,
- attributes: [.font: monoFont, .paragraphStyle: paragraph]))
- return text
- }
-
- // MARK: actions
-
- func printPanesForDebug() {
- panes = fetchPanes()
- queue = fetchQueue()
- let rows = orderedPanes()
- if rows.isEmpty { print("(no agents)") }
- for pane in rows {
- print("\(pane.state) \(pane.agent) \(pane.target)")
+ @objc private func rowClicked(_ sender: NSMenuItem) {
+ guard let paneId = sender.representedObject as? String else { return }
+ DispatchQueue.global().async {
+ focus(paneId)
+ DispatchQueue.main.async { self.refreshTitle() }
}
}
+}
- @objc private func paneClicked(_ sender: NSMenuItem) {
- guard let paneId = sender.representedObject as? String else { return }
- // Ack like `dingger focus` does, so both counts drop together.
- _ = runTmux(["-u", "set-option", "-p", "-t", paneId, "@dingger_ack", "1"])
- let target = panes.first { $0.paneId == paneId }?.target ?? ""
- guard !target.isEmpty else { return }
- _ = runTmux(["-u", "switch-client", "-t", target])
- for bundle in ["com.mitchellh.ghostty", "org.alacritty", "com.apple.Terminal"] {
- if let app = NSRunningApplication.runningApplications(withBundleIdentifier: bundle).first {
- app.activate()
- break
- }
- }
+let arguments = CommandLine.arguments
+if arguments.contains("--print") {
+ let rows = fetchRows()
+ print("title: \(plainTitle(for: rows))")
+ for row in rows {
+ print("\(row.status.symbol) \(row.status.rawValue) \(row.agent) \(row.target)")
}
+ exit(0)
+}
+if let index = arguments.firstIndex(of: "--click"), index + 1 < arguments.count {
+ focus(arguments[index + 1])
+ exit(0)
}
let app = NSApplication.shared
let delegate = Menubar()
app.delegate = delegate
app.setActivationPolicy(.accessory)
-
-// Debug: print the rows the menu would show and exit.
-if CommandLine.arguments.contains("--print") {
- delegate.printPanesForDebug()
- exit(0)
-}
-
app.run()
diff --git a/packaging/launchd/dev.dingger.dingger.plist b/packaging/launchd/dev.dingger.dingger.plist
index 212b903..2bb434a 100644
--- a/packaging/launchd/dev.dingger.dingger.plist
+++ b/packaging/launchd/dev.dingger.dingger.plist
@@ -7,7 +7,7 @@
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>RUST_LOG</key>
- <string>debug</string>
+ <string>info</string>
</dict>
<key>KeepAlive</key>
<true/>
diff --git a/packaging/launchd/dev.dingger.menubar.plist b/packaging/launchd/dev.dingger.menubar.plist
index c0b5b50..5ff8f0a 100644
--- a/packaging/launchd/dev.dingger.menubar.plist
+++ b/packaging/launchd/dev.dingger.menubar.plist
@@ -2,17 +2,21 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
- <key>Label</key>
- <string>dev.dingger.menubar</string>
- <key>ProgramArguments</key>
- <array>
- <string>/Users/sz/Applications/dingger.app/Contents/MacOS/dingger-menu</string>
- </array>
- <key>RunAtLoad</key>
- <true/>
- <key>KeepAlive</key>
- <true/>
- <key>ThrottleInterval</key>
- <integer>10</integer>
+ <key>KeepAlive</key>
+ <true/>
+ <key>Label</key>
+ <string>dev.dingger.menubar</string>
+ <key>ProgramArguments</key>
+ <array>
+ <string>/Users/YOURUSER/Applications/dingger.app/Contents/MacOS/dingger-menu</string>
+ </array>
+ <key>RunAtLoad</key>
+ <true/>
+ <key>StandardErrorPath</key>
+ <string>/Users/YOURUSER/Library/Logs/dingger-menubar.log</string>
+ <key>StandardOutPath</key>
+ <string>/Users/YOURUSER/Library/Logs/dingger-menubar.log</string>
+ <key>ThrottleInterval</key>
+ <integer>10</integer>
</dict>
</plist>
diff --git a/packaging/tmux/dingger.tmux b/packaging/tmux/dingger.tmux
new file mode 100644
index 0000000..64c5bff
--- /dev/null
+++ b/packaging/tmux/dingger.tmux
@@ -0,0 +1,14 @@
+# dingger: herdr-style agent summary after the clock, in herdr's colors:
+# × blocked (red, bold) ✓ done (teal) ◐ working (yellow)
+# Clicking the status line jumps to the top waiting agent, like the top row
+# of the menu bar: blocked first, then the longest-done (FIFO), skipping the
+# window you are already looking at.
+#
+# Source this from your tmux config: source-file /path/to/dingger.tmux
+set -g status-right '%d-%b %H:%M#{?#{e|>:#{@dingger_blocked},0}, #[fg=#f38ba8#,bold]×#{@dingger_blocked}#[default],}#{?#{e|>:#{@dingger_done},0}, #[fg=#94e2d5]✓#{@dingger_done}#[default],}#{?#{e|>:#{@dingger_working},0}, #[fg=#f9e2af]◐#{@dingger_working}#[default],}'
+bind -n MouseDown1StatusRight run-shell "$HOME/.local/bin/dingger focus --client '#{client_name}'"
+# Tell dingger the moment you look somewhere else (it marks what you see as
+# attended). Index 77 keeps your own hooks and makes reloading idempotent.
+set-hook -g 'client-session-changed[77]' 'run-shell -b "$HOME/.local/bin/dingger poke"'
+set-hook -g 'session-window-changed[77]' 'run-shell -b "$HOME/.local/bin/dingger poke"'
+set-hook -g 'window-pane-changed[77]' 'run-shell -b "$HOME/.local/bin/dingger poke"'
diff --git a/tests/e2e.sh b/tests/e2e.sh
new file mode 100755
index 0000000..d74d73b
--- /dev/null
+++ b/tests/e2e.sh
@@ -0,0 +1,244 @@
+#!/usr/bin/env bash
+# End-to-end test of dingger's status model against a real, isolated tmux
+# server. Nothing here touches the user's tmux server or hook socket.
+#
+# tests/e2e.sh [path/to/dingger]
+#
+# The test daemon polls only every 30s, so every update asserted here must
+# arrive through events (hook reports, focus pokes, tmux focus hooks), the
+# same paths that make the real UI react immediately.
+#
+# Layout:
+# session a: window wa1 (pane A1, fake pi), window wa2 (pane A2, fake pi)
+# session b: window wb1 (pane B1, fake pi)
+# session viewer: a pane running a real tmux client attached to session b,
+# so "what the user is looking at" is real tmux state.
+set -u
+
+ROOT=$(cd "$(dirname "$0")/.." && pwd)
+DINGGER="${1:-$ROOT/target/release/dingger}"
+MENUBAR="${2:-$ROOT/target/dingger-menu}"
+SOCK=dingger-e2e
+DIR=$(mktemp -d /tmp/dingger-e2e.XXXXXX)
+HOOK="$DIR/agent.sock"
+FAILS=0
+PASSES=0
+
+# Every dingger invocation below (daemon, focus, poke, menu bar) targets the
+# isolated server and the test daemon's hook socket.
+export DINGGER_TMUX_SOCKET="$SOCK" DINGGER_AGENT_SOCKET="$HOOK"
+
+t() { tmux -u -L "$SOCK" "$@"; }
+
+cleanup() {
+ [ -n "${DAEMON_PID:-}" ] && kill "$DAEMON_PID" 2>/dev/null
+ t kill-server 2>/dev/null
+ rm -rf "$DIR"
+}
+trap cleanup EXIT
+
+# Notifications are on, delivered by a fake notifier that takes 3s like the
+# real one can; the UI must not wait for it.
+NOTES="$DIR/notifications"
+start_daemon() {
+ DINGGER_NOTIFIER="sleep 3; echo \"\$0|\$1\" >> $NOTES" RUST_LOG=debug \
+ "$DINGGER" watch --interval-secs 30 --no-sounds >>"$DIR/daemon.log" 2>&1 &
+ DAEMON_PID=$!
+ for _ in $(seq 50); do [ -S "$HOOK" ] && return; sleep 0.1; done
+ echo "daemon did not start"; cat "$DIR/daemon.log"; exit 1
+}
+
+hook() { # pane state
+ python3 - "$HOOK" "$1" "$2" <<'PY'
+import json, socket, sys
+s = socket.socket(socket.AF_UNIX)
+s.connect(sys.argv[1])
+s.sendall((json.dumps({"method": "pane.report_agent",
+ "params": {"pane_id": sys.argv[2], "agent": "pi", "state": sys.argv[3]}}) + "\n").encode())
+s.close()
+PY
+}
+
+opt() { t show -pv -t "$1" "$2" 2>/dev/null; } # pane option
+gopt() { t show -gv "$1" 2>/dev/null; } # global option
+client_window() { t display -p -c "$CLIENT" '#{window_name}'; }
+
+pass() { PASSES=$((PASSES + 1)); echo " ok $1"; }
+fail() { FAILS=$((FAILS + 1)); echo " FAIL $1"; }
+
+expect() { # description command expected [tries] (polls every 0.1s, default 4s)
+ local desc=$1 cmd=$2 want=$3 tries=${4:-40} got
+ for _ in $(seq "$tries"); do
+ got=$(eval "$cmd")
+ [ "$got" = "$want" ] && { pass "$desc"; return; }
+ sleep 0.1
+ done
+ fail "$desc: want [$want] got [$got]"
+}
+
+# --- setup -------------------------------------------------------------------
+mkdir -p "$DIR/bin"
+printf '#!/bin/sh\nexec sleep 100000\n' >"$DIR/bin/pi"
+chmod +x "$DIR/bin/pi"
+
+t -f /dev/null new-session -d -s a -n wa1 -x 120 -y 40 "$DIR/bin/pi"
+t new-window -d -t a: -n wa2 "$DIR/bin/pi"
+t new-session -d -s b -n wb1 -x 120 -y 40 "$DIR/bin/pi"
+t new-session -d -s c -n wc1 -x 120 -y 40 "$DIR/bin/pi"
+t set -g mouse on
+A1=$(t display -p -t a:wa1 '#{pane_id}')
+A2=$(t display -p -t a:wa2 '#{pane_id}')
+B1=$(t display -p -t b:wb1 '#{pane_id}')
+C1=$(t display -p -t c:wc1 '#{pane_id}')
+t new-session -d -s viewer -x 120 -y 40 "env -u TMUX tmux -u -L $SOCK attach -t b"
+for _ in $(seq 50); do CLIENT=$(t list-clients -F '#{client_name}' | head -1); [ -n "$CLIENT" ] && break; sleep 0.1; done
+[ -z "$CLIENT" ] && { echo "no client attached"; exit 1; }
+echo "panes A1=$A1 A2=$A2 B1=$B1, client $CLIENT on $(client_window)"
+
+# tmux focus hooks, as in packaging/tmux/dingger.tmux, pointed at the test daemon
+POKE="run-shell -b \"DINGGER_AGENT_SOCKET=$HOOK $DINGGER poke\""
+[ -z "${E2E_NO_FOCUS_HOOKS:-}" ] && for h in client-session-changed session-window-changed window-pane-changed; do t set-hook -g "$h[77]" "$POKE"; done
+
+start_daemon
+
+# --- 1. first observation is idle, not done (herdr: initial idle stays seen) --
+echo "1. first observation"
+for p in "$A1" "$A2" "$B1" "$C1"; do hook "$p" idle; done
+expect "A1 idle on first sight" "opt $A1 @agent_state" idle
+expect "A2 idle on first sight" "opt $A2 @agent_state" idle
+expect "B1 idle on first sight" "opt $B1 @agent_state" idle
+expect "queue empty" "gopt @dingger_queue" ""
+
+# --- 2. working, then completion away from the window -> done ----------------
+echo "2. completion while away"
+hook "$A1" working
+expect "A1 working within 0.5s (event-driven)" "opt $A1 @agent_state" working 5
+expect "working count 1" "gopt @dingger_working" 1
+hook "$A1" idle
+expect "A1 done within 0.5s despite a 3s notifier" "opt $A1 @agent_state" done 5
+expect "done count 1" "gopt @dingger_done" 1
+expect "queue = A1" "gopt @dingger_queue" "$A1"
+expect "'pi finished' notification for a:wa1" "grep -c 'pi finished|a:wa1 (pi)' $NOTES 2>/dev/null" 1 60
+
+# --- 3. completion in the window the user is looking at -> idle --------------
+echo "3. completion while looking"
+hook "$B1" working
+expect "B1 working" "opt $B1 @agent_state" working
+hook "$B1" idle
+expect "B1 idle (user is looking at wb1)" "opt $B1 @agent_state" idle
+expect "queue still = A1" "gopt @dingger_queue" "$A1"
+sleep 3.5
+expect "no notification for a completion the user watched" "grep -c 'b:wb1' $NOTES" 0
+
+# --- 4. FIFO and blocked -------------------------------------------------------
+echo "4. fifo and blocked"
+hook "$A2" working
+hook "$A2" blocked
+expect "A2 blocked within 0.5s" "opt $A2 @agent_state" blocked 5
+expect "blocked count 1" "gopt @dingger_blocked" 1
+expect "'pi needs attention' notification for a:wa2" "grep -c 'pi needs attention|a:wa2 (pi)' $NOTES 2>/dev/null" 1 60
+expect "queue = A2,A1 (blocked before done)" "gopt @dingger_queue" "$A2,$A1"
+hook "$C1" working; hook "$C1" idle
+expect "C1 done" "opt $C1 @agent_state" done
+expect "queue = A2,A1,C1 (oldest done first)" "gopt @dingger_queue" "$A2,$A1,$C1"
+DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus --client "$CLIENT" --pane "$C1"
+expect "client shows wc1" "client_window" wc1
+expect "C1 idle after the jump" "opt $C1 @agent_state" idle
+
+# --- 5. attending by navigating ---------------------------------------------
+echo "5. navigate to a done agent"
+t switch-client -c "$CLIENT" -t "$A1"
+expect "client shows wa1" "client_window" wa1
+expect "A1 idle within 1s of looking (tmux focus hook)" "opt $A1 @agent_state" idle 10
+expect "queue = A2" "gopt @dingger_queue" "$A2"
+expect "done count 0" "gopt @dingger_done" 0
+
+# --- 6. status-line click: focus with no args jumps to the queue head --------
+echo "6. dingger focus (status-line click)"
+DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus
+expect "client jumped to wa2" "client_window" wa2
+expect "A2 still blocked (needs input even when seen)" "opt $A2 @agent_state" blocked
+expect "ack consumed" "opt $A2 @dingger_ack" ""
+hook "$A2" working
+hook "$A2" idle
+expect "A2 idle (completed while looked at)" "opt $A2 @agent_state" idle
+expect "queue empty" "gopt @dingger_queue" ""
+
+# --- 7. menu row click: focus --pane -----------------------------------------
+echo "7. dingger focus --pane (menu row click)"
+t switch-client -c "$CLIENT" -t "$B1"
+expect "client back on wb1" "client_window" wb1
+hook "$A1" working
+hook "$A1" idle
+expect "A1 done again" "opt $A1 @agent_state" done
+DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus --client "$CLIENT" --pane "$A1"
+expect "client shows wa1" "client_window" wa1
+expect "A1 idle after the jump" "opt $A1 @agent_state" idle
+
+# --- 8. a whole turn between two polls is still a completion ----------------
+echo "8. sub-second turn"
+t switch-client -c "$CLIENT" -t "$B1"
+expect "client back on wb1" "client_window" wb1
+hook "$A1" working; hook "$A1" idle # both within one poll interval
+expect "A1 done after a sub-second turn" "opt $A1 @agent_state" done
+t switch-client -c "$CLIENT" -t "$A1"
+expect "A1 idle once looked at" "opt $A1 @agent_state" idle
+
+# --- 9. restart keeps state -----------------------------------------------------
+echo "9. daemon restart"
+t switch-client -c "$CLIENT" -t "$B1"
+expect "client back on wb1" "client_window" wb1
+hook "$A2" working
+hook "$A2" idle
+expect "A2 done before restart" "opt $A2 @agent_state" done
+kill "$DAEMON_PID"; wait "$DAEMON_PID" 2>/dev/null
+start_daemon
+sleep 1.5
+expect "A2 still done after restart" "opt $A2 @agent_state" done
+expect "A1 still idle after restart" "opt $A1 @agent_state" idle
+expect "B1 still idle after restart" "opt $B1 @agent_state" idle
+expect "queue = A2 after restart" "gopt @dingger_queue" "$A2"
+hook "$A2" working
+expect "hook state still authoritative after restart" "opt $A2 @agent_state" working
+
+# --- 10. the tmux status-line segment and its click binding -----------------
+echo "10. status line"
+t switch-client -c "$CLIENT" -t "$B1"
+expect "client back on wb1" "client_window" wb1
+hook "$A2" blocked
+hook "$A1" working; hook "$A1" idle
+expect "A2 blocked" "opt $A2 @agent_state" blocked
+expect "A1 done" "opt $A1 @agent_state" done
+t source-file "$ROOT/packaging/tmux/dingger.tmux"
+segment=$(t display -p -c "$CLIENT" '#{T:status-right}' | sed 's/#\[[^]]*\]//g')
+case "$segment" in *"×1"*"✓1"*) pass "segment shows ×1 ✓1 ($segment)";; *) fail "segment: [$segment]";; esac
+expect "mouse binding installed" "t list-keys -T root | grep MouseDown1StatusRight | grep -c 'dingger focus'" 1
+# same run-shell + #{client_name} expansion as the mouse binding, via a key
+t bind -n F12 run-shell "DINGGER_TMUX_SOCKET=$SOCK $DINGGER focus --client '#{client_name}'"
+t send-keys -t viewer: F12
+expect "key binding jumped to the top waiter (A2, blocked)" "client_window" wa2
+t send-keys -t viewer: F12
+expect "next press jumps to the next waiter (A1)" "client_window" wa1
+expect "A1 idle once jumped to" "opt $A1 @agent_state" idle
+
+# --- 11. the menu bar binary: rendering and row click ------------------------
+echo "11. menu bar"
+if [ -x "$MENUBAR" ]; then
+ mb() { DINGGER_TMUX_SOCKET="$SOCK" DINGGER_BIN="$DINGGER" "$MENUBAR" "$@"; }
+ t switch-client -c "$CLIENT" -t "$B1"
+ expect "client back on wb1" "client_window" wb1
+ hook "$A1" working; hook "$A1" idle
+ expect "A1 done" "opt $A1 @agent_state" done
+ expect "menu title" "mb --print | head -1" "title: ×1 ✓1"
+ expect "menu rows in herdr priority order" "mb --print | tail -n +2 | cut -d' ' -f2,4 | tr '\n' ' '" "blocked a:wa2 done a:wa1 idle b:wb1 idle c:wc1 "
+ mb --click "$A1"
+ expect "row click jumped to wa1" "client_window" wa1
+ expect "row click marked A1 idle" "opt $A1 @agent_state" idle
+ expect "menu title after the click" "mb --print | head -1" "title: ×1"
+else
+ fail "menu bar binary not found at $MENUBAR"
+fi
+
+echo
+echo "$PASSES passed, $FAILS failed"
+[ "$FAILS" -eq 0 ] || { echo "--- daemon log tail"; tail -30 "$DIR/daemon.log"; exit 1; }