aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/dinggerd/src')
-rw-r--r--crates/dinggerd/src/hooks.rs142
-rw-r--r--crates/dinggerd/src/integrations.rs45
-rw-r--r--crates/dinggerd/src/main.rs17
-rw-r--r--crates/dinggerd/src/watcher.rs46
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).