atagen

roundabout

public
Star0
Watch0
e50aca5650c22545f0d99db926ab1ae120f24699
1
ssh://brewery@pls.lobotomise.me:33/atagen/roundabout.git
src/hook.rsRust319 lines9.8 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
fn remind(event: &str, input: &Input) {
253
    let late = overdue(&session(input));
254
    if late.is_empty() {
255
        return;
256
    }
257
    let lines: Vec<String> = late
258
        .iter()
259
        .map(|l| {
260
            format!(
261
                "roundabout lease {} (`{}`) has run {} against a {} estimate. `rabt kill {}` to stop it, \
262
                 `rabt extend {} <dur>` if it's worth the wait",
263
                l.id,
264
                l.cmd.join(" "),
265
                Dur::secs(l.elapsed),
266
                Dur(l.need.time),
267
                l.id,
268
                l.id
269
            )
270
        })
271
        .collect();
272
    emit(&json!({
273
        "hookSpecificOutput": {
274
            "hookEventName": event,
275
            "additionalContext": lines.join("\n"),
276
        }
277
    }));
278
}
279
280
fn stop(cfg: &Config, event: &str, input: &Input) {
281
    if input.stop_hook_active || cfg.artifacts.stop == StopPolicy::Off {
282
        return;
283
    }
284
    let root = input.cwd.as_deref().map(paths::project_root);
285
    let report = artifacts::check(cfg, root.as_deref(), false);
286
    if !report.over() {
287
        return;
288
    }
289
    let msg = format!(
290
        "{}. run `rabt artifacts gc --cap`, or `rabt artifacts list` and clear out what you \
291
         don't need",
292
        report.describe(cfg)
293
    );
294
    let out = match cfg.artifacts.stop {
295
        StopPolicy::Block => json!({ "decision": "block", "reason": msg }),
296
        StopPolicy::Nudge => json!({
297
            "hookSpecificOutput": { "hookEventName": event, "additionalContext": msg }
298
        }),
299
        StopPolicy::Warn => json!({ "systemMessage": msg }),
300
        StopPolicy::Off => return,
301
    };
302
    emit(&out);
303
}
304
305
/// hooks never fail loudly. a broken roundabout shouldn't wedge the agent
306
pub fn main(cfg: &Config, event: &str) -> i32 {
307
    let Some(input) = read_input() else {
308
        return 0;
309
    };
310
    match event {
311
        "pre-tool-use" => pre_tool_use(cfg, input),
312
        "post-tool-use" => remind("PostToolUse", &input),
313
        "prompt-submit" => remind("UserPromptSubmit", &input),
314
        "stop" => stop(cfg, "Stop", &input),
315
        "subagent-stop" => stop(cfg, "SubagentStop", &input),
316
        other => eprintln!("rabt: unknown hook {other}"),
317
    }
318
    0
319
}