From 7520141a55e8876824dcb4e2cf5e8e3027efa59d Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Fri, 23 Oct 2026 15:08:53 +0300 Subject: 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 ` 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. --- crates/dinggerd/src/integrations.rs | 166 +++++++++++++++++++++++++++++++++++- 1 file changed, 165 insertions(+), 1 deletion(-) (limited to 'crates/dinggerd/src/integrations.rs') 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 { + 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 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); + } +} -- cgit v1.2.3