aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src/hooks.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/dinggerd/src/hooks.rs')
-rw-r--r--crates/dinggerd/src/hooks.rs144
1 files changed, 94 insertions, 50 deletions
diff --git a/crates/dinggerd/src/hooks.rs b/crates/dinggerd/src/hooks.rs
index da1121e..40f0f20 100644
--- a/crates/dinggerd/src/hooks.rs
+++ b/crates/dinggerd/src/hooks.rs
@@ -6,23 +6,27 @@
//! {"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.
+//! Reports are state-change *events*: the watcher applies every one in
+//! order, so a turn that starts and finishes between two polls is still a
+//! completion, and each report wakes the watcher immediately.
+//!
+//! `{"method":"poke"}` only wakes the watcher; `dingger focus` and tmux's
+//! focus-change hooks send it so attendance updates without waiting for the
+//! next poll. The last reported state stays authoritative for its 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 std::sync::{Condvar, Mutex, MutexGuard, OnceLock};
+use std::time::Duration;
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);
@@ -30,54 +34,88 @@ pub fn socket_path() -> PathBuf {
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")
- })
+ 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)
+ state.join("agent.sock")
+}
+
+#[derive(Debug, Default)]
+struct PaneReports {
+ /// Last reported state: authoritative until the next report.
+ last: Option<AgentState>,
+ /// Reports not yet applied by the watcher, oldest first.
+ pending: Vec<AgentState>,
+}
+
+fn reports() -> MutexGuard<'static, HashMap<String, PaneReports>> {
+ static REPORTS: OnceLock<Mutex<HashMap<String, PaneReports>>> = OnceLock::new();
+ REPORTS
+ .get_or_init(|| Mutex::new(HashMap::new()))
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+}
+
+fn wake_signal() -> &'static (Mutex<bool>, Condvar) {
+ static WAKE: OnceLock<(Mutex<bool>, Condvar)> = OnceLock::new();
+ WAKE.get_or_init(|| (Mutex::new(false), Condvar::new()))
+}
+
+fn wake() {
+ let (flag, condvar) = wake_signal();
+ *flag.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) = true;
+ condvar.notify_all();
}
-#[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,
+/// Sleep until woken by a report or poke, or until `timeout` passes.
+pub fn wait_for_wake(timeout: Duration) {
+ let (flag, condvar) = wake_signal();
+ let guard = flag.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
+ let (mut guard, _) = condvar
+ .wait_timeout_while(guard, timeout, |woken| !*woken)
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ *guard = false;
}
-fn reports() -> &'static Mutex<HashMap<String, HookReport>> {
- static REPORTS: OnceLock<Mutex<HashMap<String, HookReport>>> = OnceLock::new();
- REPORTS.get_or_init(|| Mutex::new(HashMap::new()))
+/// Wake a running watcher (used by `dingger focus` and tmux focus hooks).
+pub fn poke() -> Result<(), String> {
+ use std::io::Write;
+ let mut stream = UnixStream::connect(socket_path())
+ .map_err(|err| format!("dingger watch is not running: {err}"))?;
+ stream
+ .write_all(b"{\"method\":\"poke\"}\n")
+ .map_err(|err| format!("poke failed: {err}"))
}
#[derive(Deserialize)]
struct HookMessage {
method: String,
- params: HookParams,
+ #[serde(default)]
+ params: Option<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() {
+ for line in BufReader::new(stream).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" {
+ if message.method == "poke" {
+ wake();
continue;
}
- let state = match message.params.state.as_str() {
+ let (true, Some(params)) = (message.method == "pane.report_agent", message.params) else {
+ continue;
+ };
+ let state = match params.state.as_str() {
"working" => AgentState::Working,
"blocked" => AgentState::Blocked,
"idle" => AgentState::Idle,
@@ -86,17 +124,14 @@ fn handle_stream(stream: UnixStream) {
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(),
- },
- );
+ debug!(pane = %params.pane_id, ?state, "hook report");
+ {
+ let mut reports = reports();
+ let pane = reports.entry(params.pane_id).or_default();
+ pane.last = Some(state);
+ pane.pending.push(state);
+ }
+ wake();
}
}
@@ -123,20 +158,29 @@ pub fn spawn_listener() -> Result<(), String> {
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)
+/// The pane's hook observations for this tick: every report since the last
+/// tick, in order; otherwise the last reported state; `None` if the pane
+/// never reported to this process.
+pub fn take_observations(pane_id: &str) -> Option<Vec<AgentState>> {
+ let mut reports = reports();
+ let pane = reports.get_mut(pane_id)?;
+ if pane.pending.is_empty() {
+ return pane.last.map(|state| vec![state]);
+ }
+ Some(std::mem::take(&mut pane.pending))
+}
+
+/// The last state the pane's extension reported, if any.
+pub fn last_state(pane_id: &str) -> Option<AgentState> {
+ reports().get(pane_id).and_then(|pane| pane.last)
}
-/// 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 a pane's reports (the agent exited).
+pub fn drop_reports(pane_id: &str) {
+ reports().remove(pane_id);
}
-/// Drop reports for panes that no longer exist or no longer run the agent.
+/// Drop reports for panes that no longer exist.
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));
+ reports().retain(|pane_id, _| pane_ids_to_keep.contains(pane_id));
}