diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-23 15:08:53 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-23 15:08:53 +0300 |
| commit | 7520141a55e8876824dcb4e2cf5e8e3027efa59d (patch) | |
| tree | 02022949d5b9bd41c5421ae7134ca8816f4b045b /crates/dinggerd/src/hooks.rs | |
| parent | a7d34521c7f874beb44e0314017ab28979550495 (diff) | |
restore Claude Code agents in their own sessions
`dingger install claude` adds a SessionStart hook to Claude's settings
(every other key and hook kept, in order; the original backed up; written
through a symlink; installing again changes nothing). The hook is
`dingger hook claude`: it reports the session's transcript for its tmux
pane, prints nothing (Claude adds SessionStart output to the conversation)
and always exits 0. Claude's state still comes from its screen.
restore types `claude --resume <id>` in the directory the session ran in
(elsewhere, Claude moves the conversation there). it resumes an agent only
when its session file exists: a Claude session that never got a message
has no transcript, and `--resume` fails ("No conversation found"). the
snapshot now keeps an agent's command line too, so such a pane gets it
retyped instead of nothing.
- the hook receiver takes a report without a state: only the session.
- a report that arrives after the watcher listed the panes is not dropped
as "agent gone": Claude reports once, as it starts.
- serde_json preserve_order (indexmap is already a dependency, via toml).
e2e step 18: install against a settings file like a real one, a fake
claude running the exact installed hook command, resume by id, retype
without a transcript, the resumed agent reporting again. a server that
restore starts now gets the test's plain shells: it got the user's login
shell, whose profile put the real claude first, which started (stuck at
its trust dialog) during development. every test restore now checks that
no real pi or claude runs in a test pane.
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"); + } +} |
