//! Agent integrations: hook extensions that let agents report their own //! state (herdr's integration model), so detection does not rely on screen //! scraping for the agents that support it. 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). /// It carries a placeholder for its version, which install fills in. const PI_EXTENSION: &str = include_str!("../assets/pi/pi-agent-state.ts"); const VERSION_PLACEHOLDER: &str = "__DINGGER_EXTENSION_VERSION__"; /// The extension's version: a fingerprint of its text (FNV-1a, so it does /// not change with the Rust release). A running pi reports the version it /// loaded; one that differs from this runs an old extension. pub fn pi_extension_version() -> String { let hash = PI_EXTENSION.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3) }); format!("{hash:016x}") } /// The extension as installed: stamped with its version. fn pi_extension_text() -> String { PI_EXTENSION.replace(VERSION_PLACEHOLDER, &pi_extension_version()) } fn pi_extension_path() -> Result { Ok(pi_extension_dir()?.join("dingger-agent-state.ts")) } fn pi_extension_dir() -> Result { let base = std::env::var_os("PI_CODING_AGENT_DIR") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".pi").join("agent"))) .ok_or("cannot determine pi agent dir (no HOME)")?; Ok(base.join("extensions")) } /// zsh hook: records the command line running in each tmux pane exactly as /// typed (aliases, pipes, quoting), for restore to retype. const ZSH_HOOK: &str = r#"# installed by dingger; `dingger install zsh` overwrites this file. # # Records the command line running in this tmux pane, as typed, in the pane # option @dingger_cmd, so `dingger restore` can type it again (without # running it) after a crash or shutdown. if [[ -n $TMUX_PANE ]]; then _dingger_preexec() { command tmux set-option -p -t "$TMUX_PANE" @dingger_cmd "$1" 2>/dev/null &! } _dingger_precmd() { command tmux set-option -p -u -t "$TMUX_PANE" @dingger_cmd 2>/dev/null &! } autoload -Uz add-zsh-hook add-zsh-hook preexec _dingger_preexec add-zsh-hook precmd _dingger_precmd fi "#; fn config_dir() -> Result { std::env::var_os("DINGGER_CONFIG_DIR") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config").join("dingger"))) .ok_or_else(|| "cannot determine the config dir (no HOME)".to_string()) } fn install_zsh() -> Result<(), String> { let dir = config_dir()?; std::fs::create_dir_all(&dir).map_err(|err| format!("mkdir failed: {err}"))?; let path = dir.join("dingger.zsh"); std::fs::write(&path, ZSH_HOOK).map_err(|err| format!("write failed: {err}"))?; println!( "installed {}\nadd this line to your .zshrc (dingger does not edit it):\n\n [[ -r {0} ]] && source {0}\n", path.display() ); Ok(()) } /// Install an integration: an agent's state hooks, or `zsh` for retyping /// command lines on restore. pub fn install(agent: &str) -> Result<(), String> { if agent == "zsh" { return install_zsh(); } let parsed = detect_core::parse_agent_label(agent) .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) )), } } fn install_pi() -> Result<(), String> { let dir = pi_extension_dir()?; std::fs::create_dir_all(&dir).map_err(|err| format!("mkdir failed: {err}"))?; let path = pi_extension_path()?; std::fs::write(&path, pi_extension_text()).map_err(|err| format!("write failed: {err}"))?; tracing::info!(path = %path.display(), "installed pi extension"); println!( "installed {} — new pi sessions report their state directly;\nrunning pi agents pick it up when reloaded (`dingger reload-agents`)", path.display() ); Ok(()) } /// Whether the installed pi extension is this dingger's: only then does a /// reload bring an agent up to date (otherwise it loads the old one again). pub fn pi_extension_installed_is_current() -> bool { pi_extension_path() .and_then(|path| std::fs::read_to_string(path).map_err(|err| err.to_string())) .is_ok_and(|installed| installed == pi_extension_text()) } /// At the daemon's start: if the pi extension is installed and differs /// from this dingger's, update it, so a dingger update needs no reinstall. /// Only dingger's own file (it says so on its first line). pub fn update_pi_extension() { let Ok(path) = pi_extension_path() else { return }; let Ok(installed) = std::fs::read_to_string(&path) else { return }; let current = pi_extension_text(); if installed == current || !installed.starts_with("// installed by dingger") { return; } match std::fs::write(&path, current) { Ok(()) => tracing::info!(path = %path.display(), version = %pi_extension_version(), "updated pi extension"), Err(err) => tracing::warn!(path = %path.display(), %err, "failed to update pi extension"), } } /// Claude Code's user settings, where its hooks are configured. fn claude_settings_path() -> Result { 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 { 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 `. 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 `, 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 { 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 the_installed_extension_carries_its_version_once() { let version = pi_extension_version(); assert_eq!(version.len(), 16); assert_eq!(PI_EXTENSION.matches(VERSION_PLACEHOLDER).count(), 1, "the asset has one placeholder"); let text = pi_extension_text(); assert!(!text.contains(VERSION_PLACEHOLDER)); assert!(text.contains(&format!("\"{version}\"")), "stamped as a string literal"); assert!(text.starts_with("// installed by dingger"), "update_pi_extension recognizes its own file"); } #[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); } }