diff options
Diffstat (limited to 'crates/dinggerd/src')
| -rw-r--r-- | crates/dinggerd/src/hooks.rs | 90 | ||||
| -rw-r--r-- | crates/dinggerd/src/integrations.rs | 166 | ||||
| -rw-r--r-- | crates/dinggerd/src/main.rs | 10 | ||||
| -rw-r--r-- | crates/dinggerd/src/restore.rs | 46 | ||||
| -rw-r--r-- | crates/dinggerd/src/snapshot.rs | 9 | ||||
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 7 |
6 files changed, 284 insertions, 44 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"); + } +} diff --git a/crates/dinggerd/src/integrations.rs b/crates/dinggerd/src/integrations.rs index d7cbd34..b3daf1d 100644 --- a/crates/dinggerd/src/integrations.rs +++ b/crates/dinggerd/src/integrations.rs @@ -2,9 +2,11 @@ //! state (herdr's integration model), so detection does not rely on screen //! scraping for the agents that support it. -use std::path::PathBuf; +use std::io::Read; +use std::path::{Path, PathBuf}; use detect_core::Agent; +use serde_json::{json, Value}; /// The embedded pi extension, adapted from herdr's `herdr-agent-state.ts` /// (Apache-2.0, Copyright herdr contributors). @@ -63,6 +65,7 @@ pub fn install(agent: &str) -> Result<(), String> { .ok_or_else(|| format!("unknown agent: {agent}"))?; match parsed { Agent::Pi => install_pi(), + Agent::Claude => install_claude(), other => Err(format!( "no hook integration for {} yet — screen detection is used", detect_core::agent_label(other) @@ -82,3 +85,164 @@ fn install_pi() -> Result<(), String> { ); Ok(()) } + +/// Claude Code's user settings, where its hooks are configured. +fn claude_settings_path() -> Result<PathBuf, String> { + std::env::var_os("CLAUDE_CONFIG_DIR") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude"))) + .map(|dir| dir.join("settings.json")) + .ok_or_else(|| "cannot determine Claude's config dir (no HOME)".to_string()) +} + +fn is_dingger_claude_hook(hook: &Value) -> bool { + hook.get("command") + .and_then(Value::as_str) + .is_some_and(|command| command.contains("dingger") && command.ends_with(" hook claude")) +} + +/// `settings` with dingger's SessionStart hook running `command`, replacing +/// an earlier one; every other key and hook is kept, in order. +pub fn with_claude_hook(mut settings: Value, command: &str) -> Result<Value, String> { + let root = settings.as_object_mut().ok_or("settings.json is not a JSON object")?; + let hooks = root.entry("hooks").or_insert_with(|| json!({})); + let hooks = hooks.as_object_mut().ok_or("settings.json: \"hooks\" is not an object")?; + let groups = hooks.entry("SessionStart").or_insert_with(|| json!([])); + let groups = groups.as_array_mut().ok_or("settings.json: \"hooks.SessionStart\" is not a list")?; + groups.retain_mut(|group| { + let Some(list) = group.get_mut("hooks").and_then(Value::as_array_mut) else { + return true; + }; + let before = list.len(); + list.retain(|hook| !is_dingger_claude_hook(hook)); + // Drop a group only if it held nothing but dingger's hook. + !(list.is_empty() && before > 0) + }); + groups.push(json!({"hooks": [{"type": "command", "command": command, "timeout": 10}]})); + Ok(settings) +} + +/// Add the SessionStart hook to Claude's settings: Claude then reports each +/// session's transcript (`dingger hook claude`), for restore to resume with +/// `claude --resume <id>`. Claude's state still comes from its screen. +fn install_claude() -> Result<(), String> { + let path = claude_settings_path()?; + // Write through a symlink to the file it points to. + let path = std::fs::canonicalize(&path).unwrap_or(path); + let original = match std::fs::read_to_string(&path) { + Ok(text) => Some(text), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => None, + Err(err) => return Err(format!("cannot read {}: {err}", path.display())), + }; + let settings: Value = match &original { + Some(text) if !text.trim().is_empty() => serde_json::from_str(text) + .map_err(|err| format!("{} is not valid JSON ({err}); not changing it", path.display()))?, + _ => json!({}), + }; + let exe = std::env::current_exe().map_err(|err| format!("cannot find the dingger binary: {err}"))?; + let command = format!("{} hook claude", crate::snapshot::shell_quote(&exe.to_string_lossy())); + let updated = with_claude_hook(settings.clone(), &command)?; + let mut text = serde_json::to_string_pretty(&updated).map_err(|err| err.to_string())?; + text.push('\n'); + if original.as_deref() == Some(text.as_str()) { + println!("already installed in {}", path.display()); + return Ok(()); + } + if let Some(original) = &original { + let backup = path.with_extension("json.bak-dingger"); + if !backup.exists() { + std::fs::write(&backup, original).map_err(|err| format!("backup failed: {err}"))?; + println!("backed up {} to {}", path.display(), backup.display()); + } + } + write_replacing(&path, &text)?; + tracing::info!(path = %path.display(), "installed claude hook"); + println!( + "installed in {}: a SessionStart hook running\n {command}\nnew Claude sessions report their session for restore; running ones report \ + on their next start, /clear, or /resume", + path.display() + ); + Ok(()) +} + +/// Replace `path` with `text` atomically, keeping its permissions. +fn write_replacing(path: &Path, text: &str) -> Result<(), String> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).map_err(|err| format!("mkdir failed: {err}"))?; + } + let temporary = path.with_extension(format!("json.dingger-{}", std::process::id())); + std::fs::write(&temporary, text).map_err(|err| format!("write failed: {err}"))?; + if let Ok(metadata) = std::fs::metadata(path) { + let _ = std::fs::set_permissions(&temporary, metadata.permissions()); + } + std::fs::rename(&temporary, path).map_err(|err| { + let _ = std::fs::remove_file(&temporary); + format!("write failed: {err}") + }) +} + +/// `dingger hook <agent>`, run by the agent. Never fails it: exits 0 and +/// prints nothing (Claude adds a SessionStart hook's output to the +/// conversation). +pub fn run_hook(agent: &str) { + if agent != "claude" { + return; + } + let mut input = String::new(); + let _ = std::io::stdin().read_to_string(&mut input); + let pane = std::env::var("TMUX_PANE").ok(); + if let Some(report) = claude_report(&input, pane.as_deref()) { + if let Err(err) = crate::hooks::send(&report) { + tracing::debug!(%err, "claude hook: no report"); + } + } +} + +/// The report for Claude's SessionStart hook input: the pane and the +/// session's transcript, no state. `None` outside tmux, for other events, +/// and for subagents. +pub fn claude_report(input: &str, pane: Option<&str>) -> Option<String> { + let pane = pane.filter(|pane| pane.starts_with('%'))?; + let input: Value = serde_json::from_str(input).ok()?; + if input.get("hook_event_name")?.as_str()? != "SessionStart" || input.get("agent_id").is_some() { + return None; + } + let transcript = input.get("transcript_path")?.as_str().filter(|path| !path.is_empty())?; + let params = json!({"pane_id": pane, "agent": "claude", "agent_session_path": transcript}); + Some(json!({"method": "pane.report_agent", "params": params}).to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn claude_hook_is_added_beside_other_hooks_and_keys_in_order() { + let settings: Value = serde_json::from_str( + r#"{"model":"sonnet","hooks":{"SessionStart":[{"matcher":"^startup$","hooks":[{"type":"command","command":"bash herdr.sh session"}]}],"Stop":[]},"theme":"auto"}"#, + ) + .unwrap(); + let once = with_claude_hook(settings, "/bin/dingger hook claude").unwrap(); + let twice = with_claude_hook(once.clone(), "/new/dingger hook claude").unwrap(); + assert_eq!( + twice.to_string(), + r#"{"model":"sonnet","hooks":{"SessionStart":[{"matcher":"^startup$","hooks":[{"type":"command","command":"bash herdr.sh session"}]},{"hooks":[{"type":"command","command":"/new/dingger hook claude","timeout":10}]}],"Stop":[]},"theme":"auto"}"#, + "replaced, not added again; nothing else changes" + ); + assert_eq!(with_claude_hook(json!({}), "d hook claude").unwrap()["hooks"]["SessionStart"][0]["hooks"][0]["command"], "d hook claude"); + assert!(with_claude_hook(json!([]), "d hook claude").is_err()); + } + + #[test] + fn claude_report_is_the_session_start_transcript() { + let start = r#"{"session_id":"abc","transcript_path":"/p/abc.jsonl","hook_event_name":"SessionStart","source":"startup"}"#; + assert_eq!( + claude_report(start, Some("%7")).as_deref(), + Some(r#"{"method":"pane.report_agent","params":{"pane_id":"%7","agent":"claude","agent_session_path":"/p/abc.jsonl"}}"#) + ); + assert_eq!(claude_report(start, None), None, "outside tmux"); + assert_eq!(claude_report(&start.replace("SessionStart", "Stop"), Some("%7")), None); + assert_eq!(claude_report(&start.replace("\"source\"", "\"agent_id\":\"x\",\"source\""), Some("%7")), None, "a subagent"); + assert_eq!(claude_report("not json", Some("%7")), None); + } +} diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs index fced7c2..9606d5b 100644 --- a/crates/dinggerd/src/main.rs +++ b/crates/dinggerd/src/main.rs @@ -82,7 +82,13 @@ enum Command { /// Install integrations: an agent's hook extension (state and session /// reports), or `zsh` (command lines for restore to retype). Install { - /// `pi`, or `zsh`. + /// `pi`, `claude`, or `zsh`. + agent: String, + }, + /// An agent's hook, run by the agent (`dingger install` sets it up). + #[command(hide = true)] + Hook { + /// `claude`. agent: String, }, } @@ -164,6 +170,8 @@ fn main() { std::process::exit(1); } } + // Never fails the agent: exits 0, prints nothing. + Command::Hook { agent } => integrations::run_hook(&agent), } } diff --git a/crates/dinggerd/src/restore.rs b/crates/dinggerd/src/restore.rs index 1dca955..23d166e 100644 --- a/crates/dinggerd/src/restore.rs +++ b/crates/dinggerd/src/restore.rs @@ -10,8 +10,9 @@ //! pane. Everything is addressed by tmux id, so names with `.` or `:` work. //! //! Then, once each new shell shows its prompt: agents with a session -//! reference are resumed (`pi --session <file>`, run), and every other -//! saved command line is typed but not run, for the user to press Enter. +//! reference are resumed (`pi --session <file>`, `claude --resume <id>`, +//! run), and every other saved command line is typed but not run, for the +//! user to press Enter. use std::time::{Duration, Instant}; @@ -48,10 +49,20 @@ pub enum Typed { Retype(String), } -/// The command that resumes an agent's session. +/// The command that resumes an agent's session, from its session file. +/// `None` when the file does not exist: a session that never got a message +/// has nothing to resume (`claude --resume` would fail), so its command +/// line is retyped instead, like any other. fn resume_command(agent: &str, session: &str) -> Option<String> { + let file = std::path::Path::new(session); + if !file.is_file() { + return None; + } match agent { "pi" => Some(format!("pi --session {}", shell_quote(session))), + // By id, the transcript's name. It must run in the directory the + // session ran in (the pane's), or Claude moves the conversation. + "claude" => file.file_stem()?.to_str().map(|id| format!("claude --resume {}", shell_quote(id))), _ => None, } } @@ -657,18 +668,27 @@ mod tests { assert_eq!(typed_for(&pane), Some(Typed::Retype("nvim 'my file.sh'".into()))); pane.command = Some("echo one\necho two".into()); assert_eq!(typed_for(&pane), None, "a newline would run it"); - pane.agent = Some(AgentSnap { - agent: "pi".into(), - name: "x".into(), - state: "idle".into(), - session: Some("/s/a b.jsonl".into()), - }); - assert_eq!(typed_for(&pane), Some(Typed::Resume("pi --session '/s/a b.jsonl'".into()))); - if let Some(agent) = pane.agent.as_mut() { - agent.session = None; - } + let dir = std::env::temp_dir().join(format!("dingger-typed-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = |name: &str| dir.join(name).to_string_lossy().into_owned(); + std::fs::write(file("a b.jsonl"), "").unwrap(); + std::fs::write(file("4f1c-9e.jsonl"), "").unwrap(); + let agent = |agent: &str, session: Option<String>| { + Some(AgentSnap { agent: agent.into(), name: "x".into(), state: "idle".into(), session }) + }; pane.command = Some("pi --model x".into()); + pane.agent = agent("pi", Some(file("a b.jsonl"))); + assert_eq!(typed_for(&pane), Some(Typed::Resume(format!("pi --session '{}'", file("a b.jsonl"))))); + pane.agent = agent("pi", None); assert_eq!(typed_for(&pane), Some(Typed::Retype("pi --model x".into())), "no session: retyped"); + pane.agent = agent("pi", Some(file("never-written.jsonl"))); + assert_eq!(typed_for(&pane), Some(Typed::Retype("pi --model x".into())), "no session file: retyped"); + pane.command = Some("claude --model opus".into()); + pane.agent = agent("claude", Some(file("4f1c-9e.jsonl"))); + assert_eq!(typed_for(&pane), Some(Typed::Resume("claude --resume 4f1c-9e".into())), "by id"); + pane.agent = agent("claude", Some(file("no-messages.jsonl"))); + assert_eq!(typed_for(&pane), Some(Typed::Retype("claude --model opus".into())), "no transcript: retyped"); + std::fs::remove_dir_all(&dir).unwrap(); } #[test] diff --git a/crates/dinggerd/src/snapshot.rs b/crates/dinggerd/src/snapshot.rs index 07316e8..d4ee321 100644 --- a/crates/dinggerd/src/snapshot.rs +++ b/crates/dinggerd/src/snapshot.rs @@ -97,8 +97,8 @@ pub struct PaneSnap { /// The agent running in the pane, if any. #[serde(default, skip_serializing_if = "Option::is_none")] pub agent: Option<AgentSnap>, - /// The command line running in the pane (not for agents with a - /// session): retyped on restore, not run. + /// The command line running in the pane: retyped on restore, not run + /// (an agent's, when its session cannot be resumed). #[serde(default, skip_serializing_if = "Option::is_none")] pub command: Option<String>, } @@ -249,8 +249,9 @@ pub fn capture(now_ms: u64) -> Option<Snapshot> { let command = if f[20] == "1" || leader.is_none() || at_prompt { None // the hook's text may be stale at a prompt } else if let Some(agent) = &agent { - // Resumed by session; otherwise retyped as it was started. - agent.session.is_none().then(|| hook_command.unwrap_or_else(|| agent.agent.clone())) + // Kept even with a session: restore retypes it when the session + // cannot be resumed (it never got a message, or its file is gone). + Some(hook_command.unwrap_or_else(|| agent.agent.clone())) } else { hook_command.or_else(|| leader.and_then(command_line)) }; diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs index 91fa332..346bfa8 100644 --- a/crates/dinggerd/src/watcher.rs +++ b/crates/dinggerd/src/watcher.rs @@ -235,6 +235,7 @@ impl Watcher { } fn tick(&mut self) -> Result<(), String> { + let listed = std::time::Instant::now(); let panes = tmux::list_panes()?; let now = unix_now(); @@ -244,7 +245,7 @@ impl Watcher { hooks::forget(&panes.iter().map(|pane| pane.pane_id.clone()).collect::<Vec<_>>()); for pane in &panes { - let Some(observations) = observe(pane) else { + let Some(observations) = observe(pane, listed) else { debug!(target = %pane.display_target, "observation skipped (viewer screen)"); continue; }; @@ -393,13 +394,13 @@ impl Watcher { /// The pane's raw agent states this tick, in order, or `None` when the /// screen is a viewer (transcript, picker) that says nothing about state. -fn observe(pane: &tmux::Pane) -> Option<Vec<AgentState>> { +fn observe(pane: &tmux::Pane, listed: std::time::Instant) -> Option<Vec<AgentState>> { if pane.dead { return Some(vec![AgentState::Idle]); } let Some(agent) = pane.agent else { // The agent is gone; its last hook report must not outlive it. - hooks::drop_reports(&pane.pane_id); + hooks::drop_reports(&pane.pane_id, listed); return Some(vec![AgentState::Unknown]); }; // Agent-reported state is authoritative; it survives restarts through |
