diff options
Diffstat (limited to 'crates/dinggerd/src')
| -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 |
4 files changed, 236 insertions, 14 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)); +} 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). |
