atagen

roundabout

public
Star0
Watch0
ecc1cf8df0f5b88c6bf2cd07254c98d13827cd26
1
ssh://brewery@pls.lobotomise.me:33/atagen/roundabout.git
src/hook.rsRust339 lines10.5 KB
1
use std::{
2
    io::{self, Read},
3
    path::{Path, PathBuf},
4
};
5
6
use serde::Deserialize;
7
use serde_json::{Value, json};
8
9
use crate::{
10
    artifacts, client,
11
    config::{Config, StopPolicy, Unwrapped},
12
    history, paths,
13
    proto::{self, Event, LeaseInfo, Reader, Request},
14
    units::Dur,
15
};
16
17
#[derive(Deserialize)]
18
struct Input {
19
    #[serde(default)]
20
    session_id: String,
21
    #[serde(default)]
22
    cwd: Option<PathBuf>,
23
    #[serde(default)]
24
    tool_name: Option<String>,
25
    #[serde(default, rename = "tool_input")]
26
    tool_args: Option<Value>,
27
    #[serde(default)]
28
    stop_hook_active: bool,
29
}
30
31
fn read_input() -> Option<Input> {
32
    let mut buf = String::new();
33
    io::stdin().read_to_string(&mut buf).ok()?;
34
    serde_json::from_str(&buf).ok()
35
}
36
37
fn emit(v: &Value) {
38
    println!("{v}");
39
}
40
41
/// quote-aware split on `&& || ; | & \n`. subshells, heredocs and friends get
42
/// treated as words, which errs toward seeing a build that isn't there
43
fn segments(cmd: &str) -> Vec<Vec<String>> {
44
    let mut out = Vec::new();
45
    let mut words = Vec::new();
46
    let mut word = String::new();
47
    let mut quote: Option<char> = None;
48
    let mut chars = cmd.chars().peekable();
49
    let flush_word = |word: &mut String, words: &mut Vec<String>| {
50
        if !word.is_empty() {
51
            words.push(std::mem::take(word));
52
        }
53
    };
54
    while let Some(c) = chars.next() {
55
        match (quote, c) {
56
            (Some(q), c) if c == q => quote = None,
57
            (Some('"') | None, '\\') => {
58
                if let Some(n) = chars.next() {
59
                    word.push(n);
60
                }
61
            }
62
            (None, '\'' | '"') => quote = Some(c),
63
            (None, '&') if word.ends_with('>') || chars.peek() == Some(&'>') => word.push(c),
64
            (None, '&' | '|' | ';' | '\n' | '(' | ')') => {
65
                flush_word(&mut word, &mut words);
66
                if !words.is_empty() {
67
                    out.push(std::mem::take(&mut words));
68
                }
69
            }
70
            (None, c) if c.is_whitespace() => flush_word(&mut word, &mut words),
71
            (_, c) => word.push(c),
72
        }
73
    }
74
    flush_word(&mut word, &mut words);
75
    if !words.is_empty() {
76
        out.push(words);
77
    }
78
    out
79
}
80
81
/// peels env assignments and wrappers that don't change what runs
82
fn head(seg: &[String]) -> &[String] {
83
    let mut s = seg;
84
    loop {
85
        match s.first().map(String::as_str) {
86
            Some(w) if w.contains('=') && !w.starts_with('-') && !w.starts_with('=') => s = &s[1..],
87
            Some("time" | "nice" | "nohup" | "command" | "exec" | "env" | "sudo" | "doas") => {
88
                s = &s[1..];
89
            }
90
            Some("timeout") => {
91
                s = &s[1..];
92
                while s.first().is_some_and(|w| w.starts_with('-')) {
93
                    s = &s[1..];
94
                }
95
                s = s.get(1..).unwrap_or_default();
96
            }
97
            _ => return s,
98
        }
99
    }
100
}
101
102
fn is_rabt(word: &str) -> bool {
103
    word == "rabt" || word.ends_with("/rabt")
104
}
105
106
fn matches_pattern(seg: &[String], pattern: &str) -> bool {
107
    let want: Vec<&str> = pattern.split_whitespace().collect();
108
    let first_ok = |w: &str, p: &str| w == p || w.rsplit('/').next() == Some(p);
109
    !want.is_empty()
110
        && seg.len() >= want.len()
111
        && first_ok(&seg[0], want[0])
112
        && seg[1..want.len()]
113
            .iter()
114
            .zip(&want[1..])
115
            .all(|(w, p)| w == p)
116
}
117
118
enum Scan {
119
    Clean,
120
    Unwrapped(Vec<String>),
121
}
122
123
fn scan(cfg: &Config, cmd: &str) -> Scan {
124
    let mut found = Vec::new();
125
    for seg in segments(cmd) {
126
        let seg = head(&seg);
127
        if seg.first().is_some_and(|w| is_rabt(w)) {
128
            continue;
129
        }
130
        if cfg.detect_patterns().any(|p| matches_pattern(seg, p)) {
131
            let words: Vec<&str> = seg
132
                .iter()
133
                .map(String::as_str)
134
                .take_while(|w| !w.contains('>') && !w.contains('<'))
135
                .collect();
136
            found.push(words.join(" "));
137
        }
138
    }
139
    if found.is_empty() {
140
        Scan::Clean
141
    } else {
142
        Scan::Unwrapped(found)
143
    }
144
}
145
146
fn guess(cfg: &Config, cwd: &Path, cmd: &[String]) -> String {
147
    let key = history::key(&paths::project_root(cwd), cmd);
148
    history::lookup(&key).map_or_else(
149
        || format!("--size {}", cfg.enforce.default_size),
150
        |rec| {
151
            crate::run::base(cfg).map_or_else(
152
                |_| format!("--size {}", cfg.enforce.default_size),
153
                |b| {
154
                    let n = history::suggest(&rec, b, cmd);
155
                    format!("--ram {} --cores {} --time {}", n.ram, n.cores, Dur(n.time))
156
                },
157
            )
158
        },
159
    )
160
}
161
162
fn shell_quote(s: &str) -> String {
163
    format!("'{}'", s.replace('\'', r"'\''"))
164
}
165
166
fn pre_tool_use(cfg: &Config, input: Input) {
167
    if input.tool_name.as_deref() != Some("Bash") {
168
        return;
169
    }
170
    let Some(mut tool_input) = input.tool_args else {
171
        return;
172
    };
173
    let Some(cmd) = tool_input
174
        .get("command")
175
        .and_then(Value::as_str)
176
        .map(str::to_owned)
177
    else {
178
        return;
179
    };
180
    let cwd = input.cwd.unwrap_or_else(|| "/".into());
181
182
    match (scan(cfg, &cmd), cfg.enforce.unwrapped) {
183
        (Scan::Clean, _) | (_, Unwrapped::Allow) => {}
184
        (Scan::Unwrapped(found), Unwrapped::Deny) => {
185
            let first: Vec<String> = found[0].split_whitespace().map(Into::into).collect();
186
            let hint = guess(cfg, &cwd, &first);
187
            let reason = format!(
188
                "builds go through roundabout here so parallel agents don't oom the box. wrap each build \
189
                 in `rabt run <size> -- <cmd>`, e.g. `rabt run {hint} -- {}`. sizes are {}, or \
190
                 give --ram/--disk/--cores/--time yourself. for anything long use \
191
                 run_in_background and start `rabt watch <lease>` in the background too so you \
192
                 hear if it drags. see the roundabout skill",
193
                found[0],
194
                cfg.sizes.keys().cloned().collect::<Vec<_>>().join("/"),
195
            );
196
            emit(&json!({
197
                "hookSpecificOutput": {
198
                    "hookEventName": "PreToolUse",
199
                    "permissionDecision": "deny",
200
                    "permissionDecisionReason": reason,
201
                }
202
            }));
203
        }
204
        (Scan::Unwrapped(_), Unwrapped::Wrap) => {
205
            let hint = guess(cfg, &cwd, std::slice::from_ref(&cmd));
206
            tool_input["command"] =
207
                Value::String(format!("rabt run {hint} -- bash -c {}", shell_quote(&cmd)));
208
            // no permissionDecision on purpose. allow would skip the user's
209
            // prompt for whatever the agent wrote, rewriting shouldn't
210
            emit(&json!({
211
                "hookSpecificOutput": {
212
                    "hookEventName": "PreToolUse",
213
                    "updatedInput": tool_input,
214
                }
215
            }));
216
        }
217
    }
218
}
219
220
fn overdue(session: &str) -> Vec<LeaseInfo> {
221
    if session.is_empty() {
222
        return Vec::new();
223
    }
224
    let Some(mut s) = client::connect() else {
225
        return Vec::new();
226
    };
227
    if proto::send(
228
        &mut s,
229
        &Request::Overdue {
230
            session: session.to_owned(),
231
        },
232
    )
233
    .is_err()
234
    {
235
        return Vec::new();
236
    }
237
    match Reader::new(&s).and_then(|mut r| r.recv::<Event>()) {
238
        Ok(Some(Event::Overdue { leases })) => leases,
239
        _ => Vec::new(),
240
    }
241
}
242
243
/// same source `rabt run` reads. after `--continue` the env var can keep the
244
/// startup id while the json moves on, and we need both ends to agree
245
fn session(input: &Input) -> String {
246
    std::env::var("CLAUDE_CODE_SESSION_ID")
247
        .ok()
248
        .filter(|s| !s.is_empty())
249
        .unwrap_or_else(|| input.session_id.clone())
250
}
251
252
/// both ids, since around /clear the env var and the json can disagree about
253
/// which session just ended. never spawns, a missing daemon holds no intents
254
fn session_end(input: &Input) {
255
    let mut ids = vec![session(input), input.session_id.clone()];
256
    ids.dedup();
257
    for session in ids.into_iter().filter(|s| !s.is_empty()) {
258
        if let Some(mut s) = client::connect() {
259
            let _ = proto::send(
260
                &mut s,
261
                &Request::Expect {
262
                    session,
263
                    need: None,
264
                },
265
            );
266
            let _ = Reader::new(&s).and_then(|mut r| r.recv::<Event>());
267
        }
268
    }
269
}
270
271
fn remind(event: &str, input: &Input) {
272
    let late = overdue(&session(input));
273
    if late.is_empty() {
274
        return;
275
    }
276
    let lines: Vec<String> = late
277
        .iter()
278
        .map(|l| {
279
            format!(
280
                "roundabout lease {} (`{}`) has run {} against a {} estimate. `rabt kill {}` to stop it, \
281
                 `rabt extend {} <dur>` if it's worth the wait",
282
                l.id,
283
                l.cmd.join(" "),
284
                Dur::secs(l.elapsed),
285
                Dur(l.need.time),
286
                l.id,
287
                l.id
288
            )
289
        })
290
        .collect();
291
    emit(&json!({
292
        "hookSpecificOutput": {
293
            "hookEventName": event,
294
            "additionalContext": lines.join("\n"),
295
        }
296
    }));
297
}
298
299
fn stop(cfg: &Config, event: &str, input: &Input) {
300
    if input.stop_hook_active || cfg.artifacts.stop == StopPolicy::Off {
301
        return;
302
    }
303
    let root = input.cwd.as_deref().map(paths::project_root);
304
    let report = artifacts::check(cfg, root.as_deref(), false);
305
    if !report.over() {
306
        return;
307
    }
308
    let msg = format!(
309
        "{}. run `rabt artifacts gc --cap`, or `rabt artifacts list` and clear out what you \
310
         don't need",
311
        report.describe(cfg)
312
    );
313
    let out = match cfg.artifacts.stop {
314
        StopPolicy::Block => json!({ "decision": "block", "reason": msg }),
315
        StopPolicy::Nudge => json!({
316
            "hookSpecificOutput": { "hookEventName": event, "additionalContext": msg }
317
        }),
318
        StopPolicy::Warn => json!({ "systemMessage": msg }),
319
        StopPolicy::Off => return,
320
    };
321
    emit(&out);
322
}
323
324
/// hooks never fail loudly. a broken roundabout shouldn't wedge the agent
325
pub fn main(cfg: &Config, event: &str) -> i32 {
326
    let Some(input) = read_input() else {
327
        return 0;
328
    };
329
    match event {
330
        "pre-tool-use" => pre_tool_use(cfg, input),
331
        "post-tool-use" => remind("PostToolUse", &input),
332
        "prompt-submit" => remind("UserPromptSubmit", &input),
333
        "stop" => stop(cfg, "Stop", &input),
334
        "subagent-stop" => stop(cfg, "SubagentStop", &input),
335
        "session-end" => session_end(&input),
336
        other => eprintln!("rabt: unknown hook {other}"),
337
    }
338
    0
339
}