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 /crates/dinggerd/src/hooks.rs | |
| 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.
Diffstat (limited to 'crates/dinggerd/src/hooks.rs')
| -rw-r--r-- | crates/dinggerd/src/hooks.rs | 142 |
1 files changed, 142 insertions, 0 deletions
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)); +} |
