all repos — dingger @ b0cfd10b80443d3f67d263ff4db177bb53cc90ed

Watches the AI coding agents in your tmux panes on macOS and tells you which need you, from the menu bar, a floating panel, the status line and notifications; jumps to them, and restores tmux sessions and agents after a crash

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
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
//! 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<PathBuf, String> {
    Ok(pi_extension_dir()?.join("dingger-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 = 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<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 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);
    }
}