crates/dinggerd/src/integrations.rs (view raw)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 |
//! 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).
const PI_EXTENSION: &str = include_str!("../assets/pi/pi-agent-state.ts");
fn pi_extension_dir() -> Result<PathBuf, String> {
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<PathBuf, String> {
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 = dir.join("dingger-agent-state.ts");
std::fs::write(&path, PI_EXTENSION).map_err(|err| format!("write failed: {err}"))?;
tracing::info!(path = %path.display(), "installed pi extension");
println!(
"installed {} — new pi sessions report their state directly;\nrestart running pi agents (or reload them) to pick it up",
path.display()
);
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);
}
}
|