//! 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, #[allow(dead_code)] // kept for future staleness policy seen: Instant, } fn reports() -> &'static Mutex> { static REPORTS: OnceLock>> = 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, } 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::(&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 { 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)); }