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.rs90
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");
+ }
+}