wait for tmux at startup, force utf-8 for tmux clients, add launchd packaging running under launchd, the tmux client defaults to the c locale, which makes tmux mangle tab-separated -f output into underscores, so every pane parsed as one field and the watcher silently saw nothing; invoke tmux with -u so format output is locale-independent. drop the startup fail-fast: the watcher now waits for a tmux server and attaches when one appears, which is the right shape for a login agent. ship the launchd plist under packaging/launchd.
Igor Soarez igor@soarez.org
Mon, 19 Oct 2026 16:19:10 +0300
4 files changed,
68 insertions(+),
8 deletions(-)
M
README.md
→
README.md
@@ -56,6 +56,19 @@ - `sounds/` — herdr's bundled notification sounds.
## Status -Early scaffold. Roadmap: menu bar UI (Swift shell over the daemon), tmux -control-mode event source instead of polling, configurable notification -policy. +Early scaffold. Roadmap: menu bar UI (Swift shell over the daemon), configurable +notification policy. + +### Running as a login agent (launchd) + +`packaging/launchd/dev.dingger.dingger.plist` runs `dingger watch` at login +with auto-restart; adjust the user path, then: + +```bash +launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.dingger.dingger.plist +``` + +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.
M
crates/dinggerd/src/tmux.rs
→
crates/dinggerd/src/tmux.rs
@@ -4,6 +4,7 @@ use std::process::Command;
use detect_core::manifest::agent_state_label; use detect_core::{identify_agent, Agent, AgentState}; +use tracing::debug; #[derive(Debug, Clone)] pub struct Pane {@@ -36,6 +37,10 @@ }
fn run_tmux(args: &[&str]) -> Result<String, String> { let mut command = Command::new("tmux"); + // -u forces UTF-8 mode: without it, tmux mangles format output in + // non-UTF-8 locales (launchd/cron default to the C locale, turning the + // tab separators in our -F formats into underscores). + command.arg("-u"); // -L selects a named server socket; lets tests and multi-server setups // point dingger at a specific server. if let Some(socket) = std::env::var_os("DINGGER_TMUX_SOCKET") {
M
crates/dinggerd/src/watcher.rs
→
crates/dinggerd/src/watcher.rs
@@ -46,13 +46,25 @@ }
} pub fn run(&mut self) -> Result<(), String> { - // Fail fast when tmux is unreachable at startup; after that, keep - // retrying — the server may come back. - self.tick()?; info!(interval = self.interval_secs, "watching tmux panes"); + // 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 { - if let Err(err) = self.tick() { - warn!(%err, "watch tick failed"); + match self.tick() { + Ok(()) => { + if !had_server { + info!("tmux server available"); + had_server = true; + } + } + Err(err) => { + if had_server { + warn!(%err, "tmux server unavailable; waiting for it to return"); + } + had_server = false; + } } std::thread::sleep(std::time::Duration::from_secs(self.interval_secs)); }
A
packaging/launchd/dev.dingger.dingger.plist
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>EnvironmentVariables</key> + <dict> + <key>PATH</key> + <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string> + <key>RUST_LOG</key> + <string>debug</string> + </dict> + <key>KeepAlive</key> + <true/> + <key>Label</key> + <string>dev.dingger.dingger</string> + <key>ProgramArguments</key> + <array> + <string>/Users/YOURUSER/.local/bin/dingger</string> + <string>watch</string> + </array> + <key>RunAtLoad</key> + <true/> + <key>StandardErrorPath</key> + <string>/Users/YOURUSER/Library/Logs/dingger.log</string> + <key>StandardOutPath</key> + <string>/Users/YOURUSER/Library/Logs/dingger.log</string> + <key>ThrottleInterval</key> + <integer>10</integer> +</dict> +</plist>