aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd/src/hooks.rs
blob: da1121e02bec46bf328b10f1b33261e3146c2176 (plain)
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
//! Hook receiver: agents report their own state (herdr's integration model).
//!
//! A unix socket accepts JSON lines from agent extensions:
//!
//! ```json
//! {"method":"pane.report_agent","params":{"pane_id":"%3","agent":"pi","state":"working"}}
//! ```
//!
//! Reports are authoritative for their pane until the next report or until
//! the pane stops running the agent; screen detection is the fallback for
//! panes that never report.

use std::collections::HashMap;
use std::io::{BufRead, BufReader};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use std::time::Instant;

use detect_core::AgentState;
use serde::Deserialize;
use tracing::{debug, info, warn};

pub const SOCKET_FILENAME: &str = "agent.sock";

pub fn socket_path() -> PathBuf {
    if let Some(path) = std::env::var_os("DINGGER_AGENT_SOCKET") {
        return PathBuf::from(path);
    }
    let state = std::env::var_os("DINGGER_STATE_DIR")
        .map(PathBuf::from)
        .or_else(|| {
            std::env::var_os("HOME").map(|home| {
                PathBuf::from(home).join(".local").join("state").join("dingger")
            })
        })
        .unwrap_or_else(|| PathBuf::from(".dingger"));
    state.join(SOCKET_FILENAME)
}

#[derive(Debug, Clone)]
struct HookReport {
    state: AgentState,
    #[allow(dead_code)] // kept for future hook diagnostics
    agent: Option<String>,
    #[allow(dead_code)] // kept for future staleness policy
    seen: Instant,
}

fn reports() -> &'static Mutex<HashMap<String, HookReport>> {
    static REPORTS: OnceLock<Mutex<HashMap<String, HookReport>>> = OnceLock::new();
    REPORTS.get_or_init(|| Mutex::new(HashMap::new()))
}

#[derive(Deserialize)]
struct HookMessage {
    method: String,
    params: HookParams,
}

#[derive(Deserialize)]
struct HookParams {
    pane_id: String,
    state: String,
    #[serde(default)]
    agent: Option<String>,
}

fn handle_stream(stream: UnixStream) {
    let reader = BufReader::new(stream);
    for line in reader.lines() {
        let Ok(line) = line else { return };
        let Ok(message) = serde_json::from_str::<HookMessage>(&line) else {
            warn!(%line, "invalid hook message");
            continue;
        };
        if message.method != "pane.report_agent" {
            continue;
        }
        let state = match message.params.state.as_str() {
            "working" => AgentState::Working,
            "blocked" => AgentState::Blocked,
            "idle" => AgentState::Idle,
            other => {
                debug!(state = other, "unknown hook state");
                continue;
            }
        };
        let pane_id = message.params.pane_id;
        debug!(pane = %pane_id, ?state, "hook report");
        let mut reports = reports().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
        reports.insert(
            pane_id,
            HookReport {
                state,
                agent: message.params.agent,
                seen: Instant::now(),
            },
        );
    }
}

/// Start listening; reports accumulate for the watcher to consume.
pub fn spawn_listener() -> Result<(), String> {
    let path = socket_path();
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|err| format!("mkdir failed: {err}"))?;
    }
    let _ = std::fs::remove_file(&path);
    let listener =
        UnixListener::bind(&path).map_err(|err| format!("bind {}: {err}", path.display()))?;
    info!(path = %path.display(), "hook receiver listening");
    std::thread::spawn(move || {
        for stream in listener.incoming() {
            match stream {
                Ok(stream) => {
                    std::thread::spawn(move || handle_stream(stream));
                }
                Err(err) => warn!(%err, "hook listener accept failed"),
            }
        }
    });
    Ok(())
}

/// The authoritative state for a pane, if an extension reported one.
pub fn hook_state(pane_id: &str) -> Option<AgentState> {
    let reports = reports().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
    reports.get(pane_id).map(|report| report.state)
}

/// Drop a single pane's report (the agent exited).
pub fn drop_report(pane_id: &str) {
    let mut reports = reports().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
    reports.remove(pane_id);
}

/// Drop reports for panes that no longer exist or no longer run the agent.
pub fn forget(pane_ids_to_keep: &[String]) {
    let mut reports = reports().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
    reports.retain(|pane_id, _| pane_ids_to_keep.contains(pane_id));
}