aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src/hooks.rs
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-19 19:30:03 +0300
committerIgor Soarez <igor@soarez.org>2026-10-19 19:30:03 +0300
commitfed066bc3908d18ede5490130975203a56c48fc2 (patch)
tree17ecccec1a1b5669e1868a0e169f263bdee53c73 /crates/dinggerd/src/hooks.rs
parent7081a103443bafa28d902529d502a89c2dccb79a (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.rs142
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));
+}