diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-19 19:30:03 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-19 19:30:03 +0300 |
| commit | fed066bc3908d18ede5490130975203a56c48fc2 (patch) | |
| tree | 17ecccec1a1b5669e1868a0e169f263bdee53c73 | |
| parent | 7081a103443bafa28d902529d502a89c2dccb79a (diff) | |
menu bar: herdr-style colored state glyphs; tmux segment before the clock
the design panel's monochrome template rendering made every row look
alike; restore herdr's agent-panel visual language - green ready, yellow
working, red blocked, filled when unseen in the queue. the tmux segment
moves left of the clock so it is visible at a glance; the status item
logs what it observes for diagnosis.
| -rw-r--r-- | Cargo.lock | 1 | ||||
| -rw-r--r-- | crates/dinggerd/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/dinggerd/assets/pi/pi-agent-state.ts | 186 | ||||
| -rw-r--r-- | crates/dinggerd/src/hooks.rs | 142 | ||||
| -rw-r--r-- | crates/dinggerd/src/integrations.rs | 45 | ||||
| -rw-r--r-- | crates/dinggerd/src/main.rs | 17 | ||||
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 46 | ||||
| -rw-r--r-- | menubar/main.swift | 62 |
8 files changed, 465 insertions, 35 deletions
@@ -116,6 +116,7 @@ dependencies = [ "detect-core", "mac-notification-sys", "serde", + "serde_json", "toml", "tracing", "tracing-subscriber", diff --git a/crates/dinggerd/Cargo.toml b/crates/dinggerd/Cargo.toml index 073b65e..8bd56b1 100644 --- a/crates/dinggerd/Cargo.toml +++ b/crates/dinggerd/Cargo.toml @@ -8,6 +8,7 @@ description = "macOS agent watcher for tmux panes: notifications and status with detect-core = { path = "../detect-core" } clap = { version = "4.5", default-features = false, features = ["std", "help", "usage", "error-context", "string", "derive"] } mac-notification-sys = "0.6" +serde_json = { workspace = true } serde = { workspace = true } toml = { workspace = true } tracing = { workspace = true } diff --git a/crates/dinggerd/assets/pi/pi-agent-state.ts b/crates/dinggerd/assets/pi/pi-agent-state.ts new file mode 100644 index 0000000..49d4b9c --- /dev/null +++ b/crates/dinggerd/assets/pi/pi-agent-state.ts @@ -0,0 +1,186 @@ +// installed by dingger +// managed by dingger; reinstalling or updating the integration overwrites this file. +// add custom hooks/plugins beside this file instead of editing it. + +import net from "node:net"; +import path from "node:path"; +import { execSync } from "node:child_process"; + +// The dingger hook receiver listens here; override with DINGGER_AGENT_SOCKET. +const socketPath = + process.env.DINGGER_AGENT_SOCKET || + `${process.env.HOME}/.local/state/dingger/agent.sock`; + +// The extension runs inside a tmux pane, so it finds its own pane id. +function discoverPaneId(): string | undefined { + try { + const out = execSync('tmux display-message -p "#{pane_id}"', { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + const paneId = out.trim(); + return paneId.startsWith("%") ? paneId : undefined; + } catch { + return undefined; + } +} + +const paneId = process.env.DINGGER_PANE_ID || discoverPaneId(); +const source = "dingger:pi"; + +function enabled() { + return !!paneId; +} + +function sendRequestAttempt(request: unknown, timeoutMs: number): Promise<boolean> { + if (!enabled()) { + return Promise.resolve(true); + } + + return new Promise((resolve) => { + let done = false; + let timeout: ReturnType<typeof setTimeout> | undefined; + const finish = (delivered: boolean) => { + if (done) return; + done = true; + if (timeout) { + clearTimeout(timeout); + } + socket.destroy(); + resolve(delivered); + }; + + const socket = net.createConnection(socketPath); + socket.on("error", () => finish(false)); + socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`)); + socket.on("data", () => finish(true)); + socket.on("end", () => finish(false)); + timeout = setTimeout(() => finish(false), timeoutMs); + timeout.unref?.(); + }); +} + +async function sendRequest(request: unknown): Promise<void> { + if (await sendRequestAttempt(request, 500)) { + return; + } + await sendRequestAttempt(request, 1500); +} + +type AgentState = "working" | "blocked" | "idle"; + +function report(state: AgentState, message?: string): Promise<void> { + return sendRequest({ + id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`, + method: "pane.report_agent", + params: { + pane_id: paneId!, + source, + agent: "pi", + state, + message, + seq: Date.now() * 1000, + }, + }); +} + +let sendInFlight = false; +let queuedState: { state: AgentState; message?: string } | undefined; + +function queueState(state: AgentState, message?: string): void { + queuedState = { state, message }; + if (!sendInFlight) { + void drainStateQueue(); + } +} + +async function drainStateQueue(): Promise<void> { + if (sendInFlight) { + return; + } + + sendInFlight = true; + try { + while (queuedState) { + const next = queuedState; + queuedState = undefined; + await report(next.state, next.message); + } + } finally { + sendInFlight = false; + if (queuedState) { + void drainStateQueue(); + } + } +} + +export default function (pi) { + if (!enabled()) { + return; + } + + let agentActive = false; + let blockedCount = 0; + let blockedMessage: string | undefined; + let lastState: AgentState | undefined; + let lastMessage: string | undefined; + + function desiredState() { + if (blockedCount > 0) { + return { state: "blocked" as const, message: blockedMessage }; + } + if (agentActive) { + return { state: "working" as const, message: undefined }; + } + return { state: "idle" as const, message: undefined }; + } + + function publishState(force = false) { + const next = desiredState(); + if (!force && next.state === lastState && next.message === lastMessage) { + return; + } + lastState = next.state; + lastMessage = next.message; + queueState(next.state, next.message); + } + + pi.events.on("blocked", (data) => { + if (!data?.active) { + blockedCount = Math.max(0, blockedCount - 1); + if (blockedCount === 0) { + blockedMessage = undefined; + } + publishState(); + return; + } + + blockedCount += 1; + blockedMessage = data.label; + publishState(); + }); + + pi.on("session_start", async (event, ctx) => { + // TUI only: RPC/JSON/print modes are headless. + if (ctx?.mode !== "tui") { + return; + } + // A reload can replace this extension mid-run without emitting another agent_start. + agentActive = ctx?.isIdle?.() === false; + publishState(true); + }); + + pi.on("agent_start", (_event, ctx) => { + agentActive = true; + publishState(); + }); + + pi.on("agent_settled", (_event, ctx) => { + if (ctx?.isIdle?.() !== true) { + return; + } + + agentActive = false; + publishState(); + }); +} diff --git a/crates/dinggerd/src/hooks.rs b/crates/dinggerd/src/hooks.rs new file mode 100644 index 0000000..da1121e --- /dev/null +++ b/crates/dinggerd/src/hooks.rs @@ -0,0 +1,142 @@ +//! Hook receiver: agents report their own state (herdr's integration model). +//! +//! A unix socket accepts JSON lines from agent extensions: +//! +//! ```json +//! {"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. + +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 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); + } + 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") + }) + }) + .unwrap_or_else(|| PathBuf::from(".dingger")); + state.join(SOCKET_FILENAME) +} + +#[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, +} + +fn reports() -> &'static Mutex<HashMap<String, HookReport>> { + static REPORTS: OnceLock<Mutex<HashMap<String, HookReport>>> = OnceLock::new(); + REPORTS.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[derive(Deserialize)] +struct HookMessage { + method: String, + params: 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() { + 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" { + continue; + } + let state = match message.params.state.as_str() { + "working" => AgentState::Working, + "blocked" => AgentState::Blocked, + "idle" => AgentState::Idle, + other => { + debug!(state = other, "unknown hook state"); + 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(), + }, + ); + } +} + +/// Start listening; reports accumulate for the watcher to consume. +pub fn spawn_listener() -> Result<(), String> { + let path = socket_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|err| format!("mkdir failed: {err}"))?; + } + let _ = std::fs::remove_file(&path); + let listener = + UnixListener::bind(&path).map_err(|err| format!("bind {}: {err}", path.display()))?; + info!(path = %path.display(), "hook receiver listening"); + std::thread::spawn(move || { + for stream in listener.incoming() { + match stream { + Ok(stream) => { + std::thread::spawn(move || handle_stream(stream)); + } + Err(err) => warn!(%err, "hook listener accept failed"), + } + } + }); + 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) +} + +/// 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 reports for panes that no longer exist or no longer run the agent. +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)); +} diff --git a/crates/dinggerd/src/integrations.rs b/crates/dinggerd/src/integrations.rs new file mode 100644 index 0000000..a6378d1 --- /dev/null +++ b/crates/dinggerd/src/integrations.rs @@ -0,0 +1,45 @@ +//! Agent integrations: hook extensions that let agents report their own +//! state (herdr's integration model), so detection does not rely on screen +//! scraping for the agents that support it. + +use std::path::PathBuf; + +use detect_core::Agent; + +/// The embedded pi extension, adapted from herdr's `herdr-agent-state.ts` +/// (Apache-2.0, Copyright herdr contributors). +const PI_EXTENSION: &str = include_str!("../assets/pi/pi-agent-state.ts"); + +fn pi_extension_dir() -> Result<PathBuf, String> { + let base = std::env::var_os("PI_CODING_AGENT_DIR") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".pi").join("agent"))) + .ok_or("cannot determine pi agent dir (no HOME)")?; + Ok(base.join("extensions")) +} + +/// Install the integration for one agent. Only pi has an integration today. +pub fn install(agent: &str) -> Result<(), String> { + let parsed = detect_core::parse_agent_label(agent) + .ok_or_else(|| format!("unknown agent: {agent}"))?; + match parsed { + Agent::Pi => install_pi(), + other => Err(format!( + "no hook integration for {} yet — screen detection is used", + detect_core::agent_label(other) + )), + } +} + +fn install_pi() -> Result<(), String> { + let dir = pi_extension_dir()?; + std::fs::create_dir_all(&dir).map_err(|err| format!("mkdir failed: {err}"))?; + let path = dir.join("dingger-agent-state.ts"); + std::fs::write(&path, PI_EXTENSION).map_err(|err| format!("write failed: {err}"))?; + tracing::info!(path = %path.display(), "installed pi extension"); + println!( + "installed {} — new pi sessions report their state directly;\nrestart running pi agents (or reload them) to pick it up", + path.display() + ); + Ok(()) +} diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs index 4c8521c..61d9c15 100644 --- a/crates/dinggerd/src/main.rs +++ b/crates/dinggerd/src/main.rs @@ -1,5 +1,7 @@ //! dingger: agent state watching for tmux panes. +mod hooks; +mod integrations; mod manifest_update; mod notify; mod tmux; @@ -39,6 +41,11 @@ enum Command { /// Name of the tmux client that clicked, passed as #{client_name}. client: Option<String>, }, + /// Install agent integrations (hook extensions that report state). + Install { + /// Agent to install the integration for. + agent: String, + }, } #[derive(Subcommand)] @@ -59,6 +66,10 @@ fn main() { interval_secs, no_sounds, } => { + if let Err(err) = hooks::spawn_listener() { + eprintln!("dingger: {err}"); + std::process::exit(1); + } let mut watcher = watcher::Watcher::new(interval_secs, !no_sounds); if let Err(err) = watcher.run() { eprintln!("dingger: {err}"); @@ -85,6 +96,12 @@ fn main() { std::process::exit(1); } } + Command::Install { agent } => { + if let Err(err) = integrations::install(&agent) { + eprintln!("dingger: {err}"); + std::process::exit(1); + } + } } } diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs index 9fd999d..d352ab2 100644 --- a/crates/dinggerd/src/watcher.rs +++ b/crates/dinggerd/src/watcher.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; use detect_core::{detect_agent_with_osc, AgentState}; use tracing::{debug, info, warn}; +use crate::hooks; use crate::notify::{play_sound, send_notification, NotificationKind}; use crate::tmux; @@ -93,6 +94,12 @@ impl Watcher { // Forget panes that went away. self.panes .retain(|pane_id, _| panes.iter().any(|pane| pane.pane_id == *pane_id)); + hooks::forget( + &panes + .iter() + .map(|pane| pane.pane_id.clone()) + .collect::<Vec<_>>(), + ); for pane in &panes { let prev_state = self @@ -102,27 +109,34 @@ impl Watcher { .unwrap_or(AgentState::Unknown); // The detection result is an observation of live agent state. - // Viewer screens (transcripts, pickers) return + // Agent-reported state (hook receiver) is 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 observed = if pane.dead { Some(AgentState::Idle) } else if let Some(agent) = pane.agent { - let screen = match tmux::capture_pane(&pane.pane_id) { - Ok(screen) => screen, - Err(err) => { - warn!(target = %pane.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) + 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.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. + // shell) is no longer an observation; a stale hook report + // from the dead agent is not authoritative. Some(AgentState::Unknown) }; @@ -130,6 +144,10 @@ impl Watcher { debug!(target = %pane.target, "observation skipped (viewer screen)"); continue; }; + if new_state == AgentState::Unknown { + // The agent is gone; its last hook report must not outlive it. + hooks::drop_report(&pane.pane_id); + } // Keep the pane's agent label current even when the state is // unchanged (e.g. an agent swap between two idle panes). diff --git a/menubar/main.swift b/menubar/main.swift index b5a3059..6c6c9a3 100644 --- a/menubar/main.swift +++ b/menubar/main.swift @@ -1,10 +1,9 @@ // dingger menu bar: live agent states from tmux pane user options // (@agent / @agent_state), written by the dingger daemon. // -// Design (per design panel): one question answered — "how many agents are -// waiting on me?" The status item is a bell plus the ready-queue depth; -// shape carries state (filled = needs you, outline = seen/calm, dotted = -// working); color is reserved for the tmux segment's blocked accent. +// Visual language is herdr's agent panel: green = ready (finished, waiting +// for you), yellow = working, red = blocked; filled glyph = in the ready +// queue (unseen), outline = already seen. Priority order = queue order. import AppKit @@ -15,18 +14,37 @@ struct PaneRow { let state: String } -func symbol(_ name: String) -> NSImage? { - let image = NSImage(systemSymbolName: name, accessibilityDescription: nil) - return image?.withSymbolConfiguration(.init(pointSize: 13, weight: .regular)) +let red = NSColor.systemRed +let yellow = NSColor.systemYellow +let green = NSColor.systemGreen +let dim = NSColor.systemGray + +/// 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 + } + 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 } -func stateSymbol(_ state: String, queued: Bool) -> NSImage? { +func rowSymbol(_ state: String, queued: Bool) -> NSImage? { switch (state, queued) { - case ("blocked", true): return symbol("exclamationmark.circle.fill") - case ("idle", true): return symbol("circle.fill") - case ("working", _): return symbol("circle.dotted") ?? symbol("hourglass") - case ("blocked", _): return symbol("exclamationmark.circle") - default: return symbol("circle") + case ("blocked", true): return symbol("exclamationmark.circle.fill", red) + case ("idle", true): return symbol("checkmark.circle.fill", green) + case ("working", _): return symbol("circle.dotted", yellow) + case ("blocked", _): return symbol("exclamationmark.circle", dim) + default: return symbol("checkmark.circle", dim) } } @@ -38,7 +56,7 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { func applicationDidFinishLaunching(_: Notification) { NSApp.setActivationPolicy(.accessory) // no Dock icon - item.button?.image = symbol("bell") + item.button?.image = symbol("bell", dim) item.button?.font = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular) item.menu = menu menu.delegate = self @@ -102,21 +120,23 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { private func refreshTitle() { panes = fetchPanes() queue = fetchQueue() + FileHandle.standardError.write(Data( + "\(Date()) menubar: panes=\(panes.count) queue=\(queue.count)\n".utf8)) let queued = panes.filter { row in queue.contains(row.paneId) } let blocked = queued.filter { $0.state == "blocked" }.count let working = panes.filter { $0.state == "working" }.count - let (name, count): (String, String) + let (name, color, count): (String, NSColor, String) if blocked > 0 { - (name, count) = ("bell.fill", String(queued.count)) + (name, color, count) = ("bell.fill", red, String(queued.count)) } else if !queued.isEmpty { - (name, count) = ("bell", String(queued.count)) + (name, color, count) = ("bell.fill", green, String(queued.count)) } else if working > 0 { - (name, count) = ("bell", "") + (name, color, count) = ("bell", yellow, "") } else { - (name, count) = ("bell", "") + (name, color, count) = ("bell", dim, "") } - item.button?.image = symbol(name) + item.button?.image = symbol(name, color) item.button?.title = count var tip: [String] = [] @@ -169,7 +189,7 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate { let row = NSMenuItem(title: "\(pane.agent)\t\(pane.target)", action: #selector(paneClicked(_:)), keyEquivalent: "") row.target = self - row.image = stateSymbol(pane.state, queued: queued) + row.image = rowSymbol(pane.state, queued: queued) row.attributedTitle = attributedRow(agent: pane.agent, target: pane.target) row.toolTip = "\(pane.state) — \(pane.target)" row.representedObject = pane.paneId |
