diff options
Diffstat (limited to 'crates/dinggerd/src/hooks.rs')
| -rw-r--r-- | crates/dinggerd/src/hooks.rs | 90 |
1 files changed, 68 insertions, 22 deletions
diff --git a/crates/dinggerd/src/hooks.rs b/crates/dinggerd/src/hooks.rs index 191309a..c467c98 100644 --- a/crates/dinggerd/src/hooks.rs +++ b/crates/dinggerd/src/hooks.rs @@ -8,6 +8,8 @@ //! //! A report may carry the agent's session reference (`agent_session_path` //! or `agent_session_id`, herdr's names), which restore uses to resume it. +//! A report without a state only records the session (Claude's +//! SessionStart hook, `dingger hook claude`); state stays with the screen. //! //! 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 @@ -24,7 +26,7 @@ use std::io::{BufRead, BufReader}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::PathBuf; use std::sync::{Condvar, Mutex, MutexGuard, OnceLock}; -use std::time::Duration; +use std::time::{Duration, Instant}; use detect_core::AgentState; use serde::Deserialize; @@ -45,6 +47,8 @@ struct PaneReports { pending: Vec<AgentState>, /// The agent's latest session reference. session: Option<String>, + /// When the last report arrived. + at: Option<Instant>, } fn reports() -> MutexGuard<'static, HashMap<String, PaneReports>> { @@ -78,12 +82,17 @@ pub fn wait_for_wake(timeout: Duration) { /// Wake a running watcher (used by `dingger focus` and tmux focus hooks). pub fn poke() -> Result<(), String> { + send("{\"method\":\"poke\"}") +} + +/// Send one JSON message to the running watcher. +pub fn send(message: &str) -> 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}")) + .write_all(format!("{message}\n").as_bytes()) + .map_err(|err| format!("send failed: {err}")) } #[derive(Deserialize)] @@ -96,7 +105,8 @@ struct HookMessage { #[derive(Deserialize)] struct HookParams { pane_id: String, - state: String, + #[serde(default)] + state: Option<String>, #[serde(default)] agent_session_path: Option<String>, #[serde(default)] @@ -117,30 +127,38 @@ fn handle_stream(stream: UnixStream) { 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, - other => { + let state = match params.state.as_deref() { + None => None, + Some("working") => Some(AgentState::Working), + Some("blocked") => Some(AgentState::Blocked), + Some("idle") => Some(AgentState::Idle), + Some(other) => { debug!(state = other, "unknown hook state"); continue; } }; - info!(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); - let session = params.agent_session_path.or(params.agent_session_id); - if let Some(session) = session.filter(|s| !s.is_empty() && !s.chars().any(char::is_control)) { - pane.session = Some(session); - } + match state { + Some(state) => info!(pane = %params.pane_id, ?state, "hook report"), + None => info!(pane = %params.pane_id, "hook session report"), } + record(params.pane_id, state, params.agent_session_path.or(params.agent_session_id)); wake(); } } +fn record(pane_id: String, state: Option<AgentState>, session: Option<String>) { + let mut reports = reports(); + let pane = reports.entry(pane_id).or_default(); + pane.at = Some(Instant::now()); + if let Some(state) = state { + pane.last = Some(state); + pane.pending.push(state); + } + if let Some(session) = session.filter(|s| !s.is_empty() && !s.chars().any(char::is_control)) { + pane.session = Some(session); + } +} + /// Start listening; reports accumulate for the watcher to consume. pub fn spawn_listener() -> Result<(), String> { let path = socket_path(); @@ -186,12 +204,40 @@ pub fn session(pane_id: &str) -> Option<String> { reports().get(pane_id).and_then(|pane| pane.session.clone()) } -/// Drop a pane's reports (the agent exited). -pub fn drop_reports(pane_id: &str) { - reports().remove(pane_id); +/// Drop a pane's reports: the agent was not running when the panes were +/// listed at `listed`. A report since then is kept: the agent started +/// after the listing (Claude reports its session once, as it starts). +pub fn drop_reports(pane_id: &str, listed: Instant) { + let mut reports = reports(); + if reports.get(pane_id).is_some_and(|pane| pane.at.is_none_or(|at| at < listed)) { + reports.remove(pane_id); + } } /// Drop reports for panes that no longer exist. pub fn forget(pane_ids_to_keep: &[String]) { reports().retain(|pane_id, _| pane_ids_to_keep.contains(pane_id)); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_session_report_records_only_the_session() { + record("%t1".into(), None, Some("/p/s.jsonl".into())); + assert_eq!(session("%t1").as_deref(), Some("/p/s.jsonl")); + assert_eq!(take_observations("%t1"), None, "state stays with the screen"); + assert_eq!(last_state("%t1"), None); + } + + #[test] + fn a_report_after_the_pane_listing_is_not_dropped() { + let listed = Instant::now(); + record("%t2".into(), None, Some("/p/s.jsonl".into())); + drop_reports("%t2", listed); + assert!(session("%t2").is_some(), "the agent started after the listing"); + drop_reports("%t2", Instant::now()); + assert_eq!(session("%t2"), None, "listed since, without the agent: dropped"); + } +} |
