all repos — dingger @ 7520141a55e8876824dcb4e2cf5e8e3027efa59d

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
//! 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);
    }
}