atagen

roundabout

public
Star0
Watch0
caac091f257ad8d53409925aac0d377b716994f8
1
ssh://brewery@pls.lobotomise.me:33/atagen/roundabout.git

Blame: src/hook.rs

6f5b84fad3efatagen2026-09-27 03:59 UTC1
use std::{
6f5b84fad3efatagen2026-09-27 03:59 UTC2
    io::{self, Read},
6f5b84fad3efatagen2026-09-27 03:59 UTC3
    path::{Path, PathBuf},
6f5b84fad3efatagen2026-09-27 03:59 UTC4
};
6f5b84fad3efatagen2026-09-27 03:59 UTC5
6f5b84fad3efatagen2026-09-27 03:59 UTC6
use serde::Deserialize;
6f5b84fad3efatagen2026-09-27 03:59 UTC7
use serde_json::{Value, json};
6f5b84fad3efatagen2026-09-27 03:59 UTC8
6f5b84fad3efatagen2026-09-27 03:59 UTC9
use crate::{
6f5b84fad3efatagen2026-09-27 03:59 UTC10
    artifacts, client,
6f5b84fad3efatagen2026-09-27 03:59 UTC11
    config::{Config, StopPolicy, Unwrapped},
6f5b84fad3efatagen2026-09-27 03:59 UTC12
    history, paths,
6f5b84fad3efatagen2026-09-27 03:59 UTC13
    proto::{self, Event, LeaseInfo, Reader, Request},
6f5b84fad3efatagen2026-09-27 03:59 UTC14
    units::Dur,
6f5b84fad3efatagen2026-09-27 03:59 UTC15
};
6f5b84fad3efatagen2026-09-27 03:59 UTC16
6f5b84fad3efatagen2026-09-27 03:59 UTC17
#[derive(Deserialize)]
6f5b84fad3efatagen2026-09-27 03:59 UTC18
struct Input {
6f5b84fad3efatagen2026-09-27 03:59 UTC19
    #[serde(default)]
6f5b84fad3efatagen2026-09-27 03:59 UTC20
    session_id: String,
6f5b84fad3efatagen2026-09-27 03:59 UTC21
    #[serde(default)]
6f5b84fad3efatagen2026-09-27 03:59 UTC22
    cwd: Option<PathBuf>,
6f5b84fad3efatagen2026-09-27 03:59 UTC23
    #[serde(default)]
6f5b84fad3efatagen2026-09-27 03:59 UTC24
    tool_name: Option<String>,
6f5b84fad3efatagen2026-09-27 03:59 UTC25
    #[serde(default, rename = "tool_input")]
6f5b84fad3efatagen2026-09-27 03:59 UTC26
    tool_args: Option<Value>,
6f5b84fad3efatagen2026-09-27 03:59 UTC27
    #[serde(default)]
6f5b84fad3efatagen2026-09-27 03:59 UTC28
    stop_hook_active: bool,
6f5b84fad3efatagen2026-09-27 03:59 UTC29
}
6f5b84fad3efatagen2026-09-27 03:59 UTC30
6f5b84fad3efatagen2026-09-27 03:59 UTC31
fn read_input() -> Option<Input> {
6f5b84fad3efatagen2026-09-27 03:59 UTC32
    let mut buf = String::new();
6f5b84fad3efatagen2026-09-27 03:59 UTC33
    io::stdin().read_to_string(&mut buf).ok()?;
6f5b84fad3efatagen2026-09-27 03:59 UTC34
    serde_json::from_str(&buf).ok()
6f5b84fad3efatagen2026-09-27 03:59 UTC35
}
6f5b84fad3efatagen2026-09-27 03:59 UTC36
6f5b84fad3efatagen2026-09-27 03:59 UTC37
fn emit(v: &Value) {
6f5b84fad3efatagen2026-09-27 03:59 UTC38
    println!("{v}");
6f5b84fad3efatagen2026-09-27 03:59 UTC39
}
6f5b84fad3efatagen2026-09-27 03:59 UTC40
6f5b84fad3efatagen2026-09-27 03:59 UTC41
/// quote-aware split on `&& || ; | & \n`. subshells, heredocs and friends get
6f5b84fad3efatagen2026-09-27 03:59 UTC42
/// treated as words, which errs toward seeing a build that isn't there
6f5b84fad3efatagen2026-09-27 03:59 UTC43
fn segments(cmd: &str) -> Vec<Vec<String>> {
6f5b84fad3efatagen2026-09-27 03:59 UTC44
    let mut out = Vec::new();
6f5b84fad3efatagen2026-09-27 03:59 UTC45
    let mut words = Vec::new();
6f5b84fad3efatagen2026-09-27 03:59 UTC46
    let mut word = String::new();
6f5b84fad3efatagen2026-09-27 03:59 UTC47
    let mut quote: Option<char> = None;
6f5b84fad3efatagen2026-09-27 03:59 UTC48
    let mut chars = cmd.chars().peekable();
6f5b84fad3efatagen2026-09-27 03:59 UTC49
    let flush_word = |word: &mut String, words: &mut Vec<String>| {
6f5b84fad3efatagen2026-09-27 03:59 UTC50
        if !word.is_empty() {
6f5b84fad3efatagen2026-09-27 03:59 UTC51
            words.push(std::mem::take(word));
6f5b84fad3efatagen2026-09-27 03:59 UTC52
        }
6f5b84fad3efatagen2026-09-27 03:59 UTC53
    };
6f5b84fad3efatagen2026-09-27 03:59 UTC54
    while let Some(c) = chars.next() {
6f5b84fad3efatagen2026-09-27 03:59 UTC55
        match (quote, c) {
6f5b84fad3efatagen2026-09-27 03:59 UTC56
            (Some(q), c) if c == q => quote = None,
6f5b84fad3efatagen2026-09-27 03:59 UTC57
            (Some('"') | None, '\\') => {
6f5b84fad3efatagen2026-09-27 03:59 UTC58
                if let Some(n) = chars.next() {
6f5b84fad3efatagen2026-09-27 03:59 UTC59
                    word.push(n);
6f5b84fad3efatagen2026-09-27 03:59 UTC60
                }
6f5b84fad3efatagen2026-09-27 03:59 UTC61
            }
6f5b84fad3efatagen2026-09-27 03:59 UTC62
            (None, '\'' | '"') => quote = Some(c),
6f5b84fad3efatagen2026-09-27 03:59 UTC63
            (None, '&') if word.ends_with('>') || chars.peek() == Some(&'>') => word.push(c),
6f5b84fad3efatagen2026-09-27 03:59 UTC64
            (None, '&' | '|' | ';' | '\n' | '(' | ')') => {
6f5b84fad3efatagen2026-09-27 03:59 UTC65
                flush_word(&mut word, &mut words);
6f5b84fad3efatagen2026-09-27 03:59 UTC66
                if !words.is_empty() {
6f5b84fad3efatagen2026-09-27 03:59 UTC67
                    out.push(std::mem::take(&mut words));
6f5b84fad3efatagen2026-09-27 03:59 UTC68
                }
6f5b84fad3efatagen2026-09-27 03:59 UTC69
            }
6f5b84fad3efatagen2026-09-27 03:59 UTC70
            (None, c) if c.is_whitespace() => flush_word(&mut word, &mut words),
6f5b84fad3efatagen2026-09-27 03:59 UTC71
            (_, c) => word.push(c),
6f5b84fad3efatagen2026-09-27 03:59 UTC72
        }
6f5b84fad3efatagen2026-09-27 03:59 UTC73
    }
6f5b84fad3efatagen2026-09-27 03:59 UTC74
    flush_word(&mut word, &mut words);
6f5b84fad3efatagen2026-09-27 03:59 UTC75
    if !words.is_empty() {
6f5b84fad3efatagen2026-09-27 03:59 UTC76
        out.push(words);
6f5b84fad3efatagen2026-09-27 03:59 UTC77
    }
6f5b84fad3efatagen2026-09-27 03:59 UTC78
    out
6f5b84fad3efatagen2026-09-27 03:59 UTC79
}
6f5b84fad3efatagen2026-09-27 03:59 UTC80
6f5b84fad3efatagen2026-09-27 03:59 UTC81
/// peels env assignments and wrappers that don't change what runs
6f5b84fad3efatagen2026-09-27 03:59 UTC82
fn head(seg: &[String]) -> &[String] {
6f5b84fad3efatagen2026-09-27 03:59 UTC83
    let mut s = seg;
6f5b84fad3efatagen2026-09-27 03:59 UTC84
    loop {
6f5b84fad3efatagen2026-09-27 03:59 UTC85
        match s.first().map(String::as_str) {
6f5b84fad3efatagen2026-09-27 03:59 UTC86
            Some(w) if w.contains('=') && !w.starts_with('-') && !w.starts_with('=') => s = &s[1..],
6f5b84fad3efatagen2026-09-27 03:59 UTC87
            Some("time" | "nice" | "nohup" | "command" | "exec" | "env" | "sudo" | "doas") => {
6f5b84fad3efatagen2026-09-27 03:59 UTC88
                s = &s[1..];
6f5b84fad3efatagen2026-09-27 03:59 UTC89
            }
6f5b84fad3efatagen2026-09-27 03:59 UTC90
            Some("timeout") => {
6f5b84fad3efatagen2026-09-27 03:59 UTC91
                s = &s[1..];
6f5b84fad3efatagen2026-09-27 03:59 UTC92
                while s.first().is_some_and(|w| w.starts_with('-')) {
6f5b84fad3efatagen2026-09-27 03:59 UTC93
                    s = &s[1..];
6f5b84fad3efatagen2026-09-27 03:59 UTC94
                }
6f5b84fad3efatagen2026-09-27 03:59 UTC95
                s = s.get(1..).unwrap_or_default();
6f5b84fad3efatagen2026-09-27 03:59 UTC96
            }
6f5b84fad3efatagen2026-09-27 03:59 UTC97
            _ => return s,
6f5b84fad3efatagen2026-09-27 03:59 UTC98
        }
6f5b84fad3efatagen2026-09-27 03:59 UTC99
    }
6f5b84fad3efatagen2026-09-27 03:59 UTC100
}
6f5b84fad3efatagen2026-09-27 03:59 UTC101
6f5b84fad3efatagen2026-09-27 03:59 UTC102
fn is_rabt(word: &str) -> bool {
6f5b84fad3efatagen2026-09-27 03:59 UTC103
    word == "rabt" || word.ends_with("/rabt")
6f5b84fad3efatagen2026-09-27 03:59 UTC104
}
6f5b84fad3efatagen2026-09-27 03:59 UTC105
6f5b84fad3efatagen2026-09-27 03:59 UTC106
fn matches_pattern(seg: &[String], pattern: &str) -> bool {
6f5b84fad3efatagen2026-09-27 03:59 UTC107
    let want: Vec<&str> = pattern.split_whitespace().collect();
6f5b84fad3efatagen2026-09-27 03:59 UTC108
    let first_ok = |w: &str, p: &str| w == p || w.rsplit('/').next() == Some(p);
6f5b84fad3efatagen2026-09-27 03:59 UTC109
    !want.is_empty()
6f5b84fad3efatagen2026-09-27 03:59 UTC110
        && seg.len() >= want.len()
6f5b84fad3efatagen2026-09-27 03:59 UTC111
        && first_ok(&seg[0], want[0])
6f5b84fad3efatagen2026-09-27 03:59 UTC112
        && seg[1..want.len()]
6f5b84fad3efatagen2026-09-27 03:59 UTC113
            .iter()
6f5b84fad3efatagen2026-09-27 03:59 UTC114
            .zip(&want[1..])
6f5b84fad3efatagen2026-09-27 03:59 UTC115
            .all(|(w, p)| w == p)
6f5b84fad3efatagen2026-09-27 03:59 UTC116
}
6f5b84fad3efatagen2026-09-27 03:59 UTC117
6f5b84fad3efatagen2026-09-27 03:59 UTC118
enum Scan {
6f5b84fad3efatagen2026-09-27 03:59 UTC119
    Clean,
6f5b84fad3efatagen2026-09-27 03:59 UTC120
    Unwrapped(Vec<String>),
6f5b84fad3efatagen2026-09-27 03:59 UTC121
}
6f5b84fad3efatagen2026-09-27 03:59 UTC122
6f5b84fad3efatagen2026-09-27 03:59 UTC123
fn scan(cfg: &Config, cmd: &str) -> Scan {
6f5b84fad3efatagen2026-09-27 03:59 UTC124
    let mut found = Vec::new();
6f5b84fad3efatagen2026-09-27 03:59 UTC125
    for seg in segments(cmd) {
6f5b84fad3efatagen2026-09-27 03:59 UTC126
        let seg = head(&seg);
6f5b84fad3efatagen2026-09-27 03:59 UTC127
        if seg.first().is_some_and(|w| is_rabt(w)) {
6f5b84fad3efatagen2026-09-27 03:59 UTC128
            continue;
6f5b84fad3efatagen2026-09-27 03:59 UTC129
        }
6f5b84fad3efatagen2026-09-27 03:59 UTC130
        if cfg.detect_patterns().any(|p| matches_pattern(seg, p)) {
6f5b84fad3efatagen2026-09-27 03:59 UTC131
            let words: Vec<&str> = seg
6f5b84fad3efatagen2026-09-27 03:59 UTC132
                .iter()
6f5b84fad3efatagen2026-09-27 03:59 UTC133
                .map(String::as_str)
6f5b84fad3efatagen2026-09-27 03:59 UTC134
                .take_while(|w| !w.contains('>') && !w.contains('<'))
6f5b84fad3efatagen2026-09-27 03:59 UTC135
                .collect();
6f5b84fad3efatagen2026-09-27 03:59 UTC136
            found.push(words.join(" "));
6f5b84fad3efatagen2026-09-27 03:59 UTC137
        }
6f5b84fad3efatagen2026-09-27 03:59 UTC138
    }
6f5b84fad3efatagen2026-09-27 03:59 UTC139
    if found.is_empty() {
6f5b84fad3efatagen2026-09-27 03:59 UTC140
        Scan::Clean
6f5b84fad3efatagen2026-09-27 03:59 UTC141
    } else {
6f5b84fad3efatagen2026-09-27 03:59 UTC142
        Scan::Unwrapped(found)
6f5b84fad3efatagen2026-09-27 03:59 UTC143
    }
6f5b84fad3efatagen2026-09-27 03:59 UTC144
}
6f5b84fad3efatagen2026-09-27 03:59 UTC145
6f5b84fad3efatagen2026-09-27 03:59 UTC146
fn guess(cfg: &Config, cwd: &Path, cmd: &[String]) -> String {
6f5b84fad3efatagen2026-09-27 03:59 UTC147
    let key = history::key(&paths::project_root(cwd), cmd);
6f5b84fad3efatagen2026-09-27 03:59 UTC148
    history::lookup(&key).map_or_else(
6f5b84fad3efatagen2026-09-27 03:59 UTC149
        || format!("--size {}", cfg.enforce.default_size),
6f5b84fad3efatagen2026-09-27 03:59 UTC150
        |rec| {
6f5b84fad3efatagen2026-09-27 03:59 UTC151
            crate::run::base(cfg).map_or_else(
6f5b84fad3efatagen2026-09-27 03:59 UTC152
                |_| format!("--size {}", cfg.enforce.default_size),
6f5b84fad3efatagen2026-09-27 03:59 UTC153
                |b| {
6f5b84fad3efatagen2026-09-27 03:59 UTC154
                    let n = history::suggest(&rec, b, cmd);
6f5b84fad3efatagen2026-09-27 03:59 UTC155
                    format!("--ram {} --cores {} --time {}", n.ram, n.cores, Dur(n.time))
6f5b84fad3efatagen2026-09-27 03:59 UTC156
                },
6f5b84fad3efatagen2026-09-27 03:59 UTC157
            )
6f5b84fad3efatagen2026-09-27 03:59 UTC158
        },
6f5b84fad3efatagen2026-09-27 03:59 UTC159
    )
6f5b84fad3efatagen2026-09-27 03:59 UTC160
}
6f5b84fad3efatagen2026-09-27 03:59 UTC161
6f5b84fad3efatagen2026-09-27 03:59 UTC162
fn shell_quote(s: &str) -> String {
6f5b84fad3efatagen2026-09-27 03:59 UTC163
    format!("'{}'", s.replace('\'', r"'\''"))
6f5b84fad3efatagen2026-09-27 03:59 UTC164
}
6f5b84fad3efatagen2026-09-27 03:59 UTC165
6f5b84fad3efatagen2026-09-27 03:59 UTC166
fn pre_tool_use(cfg: &Config, input: Input) {
6f5b84fad3efatagen2026-09-27 03:59 UTC167
    if input.tool_name.as_deref() != Some("Bash") {
6f5b84fad3efatagen2026-09-27 03:59 UTC168
        return;
6f5b84fad3efatagen2026-09-27 03:59 UTC169
    }
6f5b84fad3efatagen2026-09-27 03:59 UTC170
    let Some(mut tool_input) = input.tool_args else {
6f5b84fad3efatagen2026-09-27 03:59 UTC171
        return;
6f5b84fad3efatagen2026-09-27 03:59 UTC172
    };
6f5b84fad3efatagen2026-09-27 03:59 UTC173
    let Some(cmd) = tool_input
6f5b84fad3efatagen2026-09-27 03:59 UTC174
        .get("command")
6f5b84fad3efatagen2026-09-27 03:59 UTC175
        .and_then(Value::as_str)
6f5b84fad3efatagen2026-09-27 03:59 UTC176
        .map(str::to_owned)
6f5b84fad3efatagen2026-09-27 03:59 UTC177
    else {
6f5b84fad3efatagen2026-09-27 03:59 UTC178
        return;
6f5b84fad3efatagen2026-09-27 03:59 UTC179
    };
6f5b84fad3efatagen2026-09-27 03:59 UTC180
    let cwd = input.cwd.unwrap_or_else(|| "/".into());
6f5b84fad3efatagen2026-09-27 03:59 UTC181
6f5b84fad3efatagen2026-09-27 03:59 UTC182
    match (scan(cfg, &cmd), cfg.enforce.unwrapped) {
6f5b84fad3efatagen2026-09-27 03:59 UTC183
        (Scan::Clean, _) | (_, Unwrapped::Allow) => {}
6f5b84fad3efatagen2026-09-27 03:59 UTC184
        (Scan::Unwrapped(found), Unwrapped::Deny) => {
6f5b84fad3efatagen2026-09-27 03:59 UTC185
            let first: Vec<String> = found[0].split_whitespace().map(Into::into).collect();
6f5b84fad3efatagen2026-09-27 03:59 UTC186
            let hint = guess(cfg, &cwd, &first);
6f5b84fad3efatagen2026-09-27 03:59 UTC187
            let reason = format!(
6f5b84fad3efatagen2026-09-27 03:59 UTC188
                "builds go through roundabout here so parallel agents don't oom the box. wrap each build \
6f5b84fad3efatagen2026-09-27 03:59 UTC189
                 in `rabt run <size> -- <cmd>`, e.g. `rabt run {hint} -- {}`. sizes are {}, or \
6f5b84fad3efatagen2026-09-27 03:59 UTC190
                 give --ram/--disk/--cores/--time yourself. for anything long use \
6f5b84fad3efatagen2026-09-27 03:59 UTC191
                 run_in_background and start `rabt watch <lease>` in the background too so you \
6f5b84fad3efatagen2026-09-27 03:59 UTC192
                 hear if it drags. see the roundabout skill",
6f5b84fad3efatagen2026-09-27 03:59 UTC193
                found[0],
6f5b84fad3efatagen2026-09-27 03:59 UTC194
                cfg.sizes.keys().cloned().collect::<Vec<_>>().join("/"),
6f5b84fad3efatagen2026-09-27 03:59 UTC195
            );
6f5b84fad3efatagen2026-09-27 03:59 UTC196
            emit(&json!({
6f5b84fad3efatagen2026-09-27 03:59 UTC197
                "hookSpecificOutput": {
6f5b84fad3efatagen2026-09-27 03:59 UTC198
                    "hookEventName": "PreToolUse",
6f5b84fad3efatagen2026-09-27 03:59 UTC199
                    "permissionDecision": "deny",
6f5b84fad3efatagen2026-09-27 03:59 UTC200
                    "permissionDecisionReason": reason,
6f5b84fad3efatagen2026-09-27 03:59 UTC201
                }
6f5b84fad3efatagen2026-09-27 03:59 UTC202
            }));
6f5b84fad3efatagen2026-09-27 03:59 UTC203
        }
6f5b84fad3efatagen2026-09-27 03:59 UTC204
        (Scan::Unwrapped(_), Unwrapped::Wrap) => {
6f5b84fad3efatagen2026-09-27 03:59 UTC205
            let hint = guess(cfg, &cwd, std::slice::from_ref(&cmd));
6f5b84fad3efatagen2026-09-27 03:59 UTC206
            tool_input["command"] =
6f5b84fad3efatagen2026-09-27 03:59 UTC207
                Value::String(format!("rabt run {hint} -- bash -c {}", shell_quote(&cmd)));
6f5b84fad3efatagen2026-09-27 03:59 UTC208
            // no permissionDecision on purpose. allow would skip the user's
6f5b84fad3efatagen2026-09-27 03:59 UTC209
            // prompt for whatever the agent wrote, rewriting shouldn't
6f5b84fad3efatagen2026-09-27 03:59 UTC210
            emit(&json!({
6f5b84fad3efatagen2026-09-27 03:59 UTC211
                "hookSpecificOutput": {
6f5b84fad3efatagen2026-09-27 03:59 UTC212
                    "hookEventName": "PreToolUse",
6f5b84fad3efatagen2026-09-27 03:59 UTC213
                    "updatedInput": tool_input,
6f5b84fad3efatagen2026-09-27 03:59 UTC214
                }
6f5b84fad3efatagen2026-09-27 03:59 UTC215
            }));
6f5b84fad3efatagen2026-09-27 03:59 UTC216
        }
6f5b84fad3efatagen2026-09-27 03:59 UTC217
    }
6f5b84fad3efatagen2026-09-27 03:59 UTC218
}
6f5b84fad3efatagen2026-09-27 03:59 UTC219
4e5ca2e20401atagen2026-09-28 11:42 UTC220
fn codex_pre_tool_use(cfg: &Config, input: Input) -> Option<Value> {
4e5ca2e20401atagen2026-09-28 11:42 UTC221
    if input.tool_name.as_deref() != Some("Bash") {
4e5ca2e20401atagen2026-09-28 11:42 UTC222
        return None;
4e5ca2e20401atagen2026-09-28 11:42 UTC223
    }
4e5ca2e20401atagen2026-09-28 11:42 UTC224
    let command = input.tool_args?.get("command")?.as_str()?.to_owned();
4e5ca2e20401atagen2026-09-28 11:42 UTC225
    let cwd = input.cwd.unwrap_or_else(|| "/".into());
4e5ca2e20401atagen2026-09-28 11:42 UTC226
    let Scan::Unwrapped(found) = scan(cfg, &command) else {
4e5ca2e20401atagen2026-09-28 11:42 UTC227
        return None;
4e5ca2e20401atagen2026-09-28 11:42 UTC228
    };
4e5ca2e20401atagen2026-09-28 11:42 UTC229
    if cfg.enforce.unwrapped == Unwrapped::Allow {
4e5ca2e20401atagen2026-09-28 11:42 UTC230
        return None;
4e5ca2e20401atagen2026-09-28 11:42 UTC231
    }
4e5ca2e20401atagen2026-09-28 11:42 UTC232
    let first: Vec<String> = found[0].split_whitespace().map(Into::into).collect();
4e5ca2e20401atagen2026-09-28 11:42 UTC233
    let hint = guess(cfg, &cwd, &first);
4e5ca2e20401atagen2026-09-28 11:42 UTC234
    let reason = format!(
4e5ca2e20401atagen2026-09-28 11:42 UTC235
        "builds go through roundabout here so parallel agents don't oom the box. run `rabt run {hint} -- {}` instead. see the roundabout skill",
4e5ca2e20401atagen2026-09-28 11:42 UTC236
        found[0]
4e5ca2e20401atagen2026-09-28 11:42 UTC237
    );
4e5ca2e20401atagen2026-09-28 11:42 UTC238
    Some(json!({
4e5ca2e20401atagen2026-09-28 11:42 UTC239
        "hookSpecificOutput": {
4e5ca2e20401atagen2026-09-28 11:42 UTC240
            "hookEventName": "PreToolUse",
4e5ca2e20401atagen2026-09-28 11:42 UTC241
            "permissionDecision": "deny",
4e5ca2e20401atagen2026-09-28 11:42 UTC242
            "permissionDecisionReason": reason,
4e5ca2e20401atagen2026-09-28 11:42 UTC243
        }
4e5ca2e20401atagen2026-09-28 11:42 UTC244
    }))
4e5ca2e20401atagen2026-09-28 11:42 UTC245
}
4e5ca2e20401atagen2026-09-28 11:42 UTC246
6f5b84fad3efatagen2026-09-27 03:59 UTC247
fn overdue(session: &str) -> Vec<LeaseInfo> {
6f5b84fad3efatagen2026-09-27 03:59 UTC248
    if session.is_empty() {
6f5b84fad3efatagen2026-09-27 03:59 UTC249
        return Vec::new();
6f5b84fad3efatagen2026-09-27 03:59 UTC250
    }
6f5b84fad3efatagen2026-09-27 03:59 UTC251
    let Some(mut s) = client::connect() else {
6f5b84fad3efatagen2026-09-27 03:59 UTC252
        return Vec::new();
6f5b84fad3efatagen2026-09-27 03:59 UTC253
    };
6f5b84fad3efatagen2026-09-27 03:59 UTC254
    if proto::send(
6f5b84fad3efatagen2026-09-27 03:59 UTC255
        &mut s,
6f5b84fad3efatagen2026-09-27 03:59 UTC256
        &Request::Overdue {
6f5b84fad3efatagen2026-09-27 03:59 UTC257
            session: session.to_owned(),
6f5b84fad3efatagen2026-09-27 03:59 UTC258
        },
6f5b84fad3efatagen2026-09-27 03:59 UTC259
    )
6f5b84fad3efatagen2026-09-27 03:59 UTC260
    .is_err()
6f5b84fad3efatagen2026-09-27 03:59 UTC261
    {
6f5b84fad3efatagen2026-09-27 03:59 UTC262
        return Vec::new();
6f5b84fad3efatagen2026-09-27 03:59 UTC263
    }
6f5b84fad3efatagen2026-09-27 03:59 UTC264
    match Reader::new(&s).and_then(|mut r| r.recv::<Event>()) {
6f5b84fad3efatagen2026-09-27 03:59 UTC265
        Ok(Some(Event::Overdue { leases })) => leases,
6f5b84fad3efatagen2026-09-27 03:59 UTC266
        _ => Vec::new(),
6f5b84fad3efatagen2026-09-27 03:59 UTC267
    }
6f5b84fad3efatagen2026-09-27 03:59 UTC268
}
6f5b84fad3efatagen2026-09-27 03:59 UTC269
6f5b84fad3efatagen2026-09-27 03:59 UTC270
/// same source `rabt run` reads. after `--continue` the env var can keep the
6f5b84fad3efatagen2026-09-27 03:59 UTC271
/// startup id while the json moves on, and we need both ends to agree
4e5ca2e20401atagen2026-09-28 11:42 UTC272
fn session(input: &Input, codex: bool) -> String {
4e5ca2e20401atagen2026-09-28 11:42 UTC273
    if codex {
4e5ca2e20401atagen2026-09-28 11:42 UTC274
        return input.session_id.clone();
4e5ca2e20401atagen2026-09-28 11:42 UTC275
    }
6f5b84fad3efatagen2026-09-27 03:59 UTC276
    std::env::var("CLAUDE_CODE_SESSION_ID")
6f5b84fad3efatagen2026-09-27 03:59 UTC277
        .ok()
6f5b84fad3efatagen2026-09-27 03:59 UTC278
        .filter(|s| !s.is_empty())
6f5b84fad3efatagen2026-09-27 03:59 UTC279
        .unwrap_or_else(|| input.session_id.clone())
6f5b84fad3efatagen2026-09-27 03:59 UTC280
}
6f5b84fad3efatagen2026-09-27 03:59 UTC281
ecc1cf8df0f5atagen2026-09-27 13:36 UTC282
/// both ids, since around /clear the env var and the json can disagree about
ecc1cf8df0f5atagen2026-09-27 13:36 UTC283
/// which session just ended. never spawns, a missing daemon holds no intents
4e5ca2e20401atagen2026-09-28 11:42 UTC284
fn session_end(input: &Input, codex: bool) {
4e5ca2e20401atagen2026-09-28 11:42 UTC285
    let mut ids = vec![session(input, codex), input.session_id.clone()];
ecc1cf8df0f5atagen2026-09-27 13:36 UTC286
    ids.dedup();
ecc1cf8df0f5atagen2026-09-27 13:36 UTC287
    for session in ids.into_iter().filter(|s| !s.is_empty()) {
ecc1cf8df0f5atagen2026-09-27 13:36 UTC288
        if let Some(mut s) = client::connect() {
ecc1cf8df0f5atagen2026-09-27 13:36 UTC289
            let _ = proto::send(
ecc1cf8df0f5atagen2026-09-27 13:36 UTC290
                &mut s,
ecc1cf8df0f5atagen2026-09-27 13:36 UTC291
                &Request::Expect {
ecc1cf8df0f5atagen2026-09-27 13:36 UTC292
                    session,
ecc1cf8df0f5atagen2026-09-27 13:36 UTC293
                    need: None,
ecc1cf8df0f5atagen2026-09-27 13:36 UTC294
                },
ecc1cf8df0f5atagen2026-09-27 13:36 UTC295
            );
ecc1cf8df0f5atagen2026-09-27 13:36 UTC296
            let _ = Reader::new(&s).and_then(|mut r| r.recv::<Event>());
ecc1cf8df0f5atagen2026-09-27 13:36 UTC297
        }
ecc1cf8df0f5atagen2026-09-27 13:36 UTC298
    }
ecc1cf8df0f5atagen2026-09-27 13:36 UTC299
}
ecc1cf8df0f5atagen2026-09-27 13:36 UTC300
4e5ca2e20401atagen2026-09-28 11:42 UTC301
fn remind(event: &str, input: &Input, codex: bool) {
4e5ca2e20401atagen2026-09-28 11:42 UTC302
    let late = overdue(&session(input, codex));
6f5b84fad3efatagen2026-09-27 03:59 UTC303
    if late.is_empty() {
6f5b84fad3efatagen2026-09-27 03:59 UTC304
        return;
6f5b84fad3efatagen2026-09-27 03:59 UTC305
    }
6f5b84fad3efatagen2026-09-27 03:59 UTC306
    let lines: Vec<String> = late
6f5b84fad3efatagen2026-09-27 03:59 UTC307
        .iter()
6f5b84fad3efatagen2026-09-27 03:59 UTC308
        .map(|l| {
6f5b84fad3efatagen2026-09-27 03:59 UTC309
            format!(
6f5b84fad3efatagen2026-09-27 03:59 UTC310
                "roundabout lease {} (`{}`) has run {} against a {} estimate. `rabt kill {}` to stop it, \
6f5b84fad3efatagen2026-09-27 03:59 UTC311
                 `rabt extend {} <dur>` if it's worth the wait",
6f5b84fad3efatagen2026-09-27 03:59 UTC312
                l.id,
6f5b84fad3efatagen2026-09-27 03:59 UTC313
                l.cmd.join(" "),
6f5b84fad3efatagen2026-09-27 03:59 UTC314
                Dur::secs(l.elapsed),
6f5b84fad3efatagen2026-09-27 03:59 UTC315
                Dur(l.need.time),
6f5b84fad3efatagen2026-09-27 03:59 UTC316
                l.id,
6f5b84fad3efatagen2026-09-27 03:59 UTC317
                l.id
6f5b84fad3efatagen2026-09-27 03:59 UTC318
            )
6f5b84fad3efatagen2026-09-27 03:59 UTC319
        })
6f5b84fad3efatagen2026-09-27 03:59 UTC320
        .collect();
6f5b84fad3efatagen2026-09-27 03:59 UTC321
    emit(&json!({
6f5b84fad3efatagen2026-09-27 03:59 UTC322
        "hookSpecificOutput": {
6f5b84fad3efatagen2026-09-27 03:59 UTC323
            "hookEventName": event,
6f5b84fad3efatagen2026-09-27 03:59 UTC324
            "additionalContext": lines.join("\n"),
6f5b84fad3efatagen2026-09-27 03:59 UTC325
        }
6f5b84fad3efatagen2026-09-27 03:59 UTC326
    }));
6f5b84fad3efatagen2026-09-27 03:59 UTC327
}
6f5b84fad3efatagen2026-09-27 03:59 UTC328
6f5b84fad3efatagen2026-09-27 03:59 UTC329
fn stop(cfg: &Config, event: &str, input: &Input) {
6f5b84fad3efatagen2026-09-27 03:59 UTC330
    if input.stop_hook_active || cfg.artifacts.stop == StopPolicy::Off {
6f5b84fad3efatagen2026-09-27 03:59 UTC331
        return;
6f5b84fad3efatagen2026-09-27 03:59 UTC332
    }
6f5b84fad3efatagen2026-09-27 03:59 UTC333
    let root = input.cwd.as_deref().map(paths::project_root);
6f5b84fad3efatagen2026-09-27 03:59 UTC334
    let report = artifacts::check(cfg, root.as_deref(), false);
6f5b84fad3efatagen2026-09-27 03:59 UTC335
    if !report.over() {
6f5b84fad3efatagen2026-09-27 03:59 UTC336
        return;
6f5b84fad3efatagen2026-09-27 03:59 UTC337
    }
6f5b84fad3efatagen2026-09-27 03:59 UTC338
    let msg = format!(
6f5b84fad3efatagen2026-09-27 03:59 UTC339
        "{}. run `rabt artifacts gc --cap`, or `rabt artifacts list` and clear out what you \
6f5b84fad3efatagen2026-09-27 03:59 UTC340
         don't need",
6f5b84fad3efatagen2026-09-27 03:59 UTC341
        report.describe(cfg)
6f5b84fad3efatagen2026-09-27 03:59 UTC342
    );
6f5b84fad3efatagen2026-09-27 03:59 UTC343
    let out = match cfg.artifacts.stop {
6f5b84fad3efatagen2026-09-27 03:59 UTC344
        StopPolicy::Block => json!({ "decision": "block", "reason": msg }),
6f5b84fad3efatagen2026-09-27 03:59 UTC345
        StopPolicy::Nudge => json!({
6f5b84fad3efatagen2026-09-27 03:59 UTC346
            "hookSpecificOutput": { "hookEventName": event, "additionalContext": msg }
6f5b84fad3efatagen2026-09-27 03:59 UTC347
        }),
6f5b84fad3efatagen2026-09-27 03:59 UTC348
        StopPolicy::Warn => json!({ "systemMessage": msg }),
6f5b84fad3efatagen2026-09-27 03:59 UTC349
        StopPolicy::Off => return,
6f5b84fad3efatagen2026-09-27 03:59 UTC350
    };
6f5b84fad3efatagen2026-09-27 03:59 UTC351
    emit(&out);
6f5b84fad3efatagen2026-09-27 03:59 UTC352
}
6f5b84fad3efatagen2026-09-27 03:59 UTC353
6f5b84fad3efatagen2026-09-27 03:59 UTC354
/// hooks never fail loudly. a broken roundabout shouldn't wedge the agent
6f5b84fad3efatagen2026-09-27 03:59 UTC355
pub fn main(cfg: &Config, event: &str) -> i32 {
6f5b84fad3efatagen2026-09-27 03:59 UTC356
    let Some(input) = read_input() else {
6f5b84fad3efatagen2026-09-27 03:59 UTC357
        return 0;
6f5b84fad3efatagen2026-09-27 03:59 UTC358
    };
6f5b84fad3efatagen2026-09-27 03:59 UTC359
    match event {
4e5ca2e20401atagen2026-09-28 11:42 UTC360
        "codex-pre-tool-use" => {
4e5ca2e20401atagen2026-09-28 11:42 UTC361
            if let Some(output) = codex_pre_tool_use(cfg, input) {
4e5ca2e20401atagen2026-09-28 11:42 UTC362
                emit(&output);
4e5ca2e20401atagen2026-09-28 11:42 UTC363
            }
4e5ca2e20401atagen2026-09-28 11:42 UTC364
        }
4e5ca2e20401atagen2026-09-28 11:42 UTC365
        "codex-post-tool-use" | "omp-post-tool-use" => remind("PostToolUse", &input, true),
4e5ca2e20401atagen2026-09-28 11:42 UTC366
        "codex-prompt-submit" | "omp-prompt-submit" => remind("UserPromptSubmit", &input, true),
4e5ca2e20401atagen2026-09-28 11:42 UTC367
        "codex-stop" | "omp-stop" | "stop" => stop(cfg, "Stop", &input),
4e5ca2e20401atagen2026-09-28 11:42 UTC368
        "codex-subagent-stop" | "omp-subagent-stop" | "subagent-stop" => {
4e5ca2e20401atagen2026-09-28 11:42 UTC369
            stop(cfg, "SubagentStop", &input);
4e5ca2e20401atagen2026-09-28 11:42 UTC370
        }
4e5ca2e20401atagen2026-09-28 11:42 UTC371
        "codex-session-end" | "omp-session-end" => session_end(&input, true),
4e5ca2e20401atagen2026-09-28 11:42 UTC372
        "pre-tool-use" | "omp-pre-tool-use" => pre_tool_use(cfg, input),
4e5ca2e20401atagen2026-09-28 11:42 UTC373
        "post-tool-use" => remind("PostToolUse", &input, false),
4e5ca2e20401atagen2026-09-28 11:42 UTC374
        "prompt-submit" => remind("UserPromptSubmit", &input, false),
4e5ca2e20401atagen2026-09-28 11:42 UTC375
        "session-end" => session_end(&input, false),
6f5b84fad3efatagen2026-09-27 03:59 UTC376
        other => eprintln!("rabt: unknown hook {other}"),
6f5b84fad3efatagen2026-09-27 03:59 UTC377
    }
6f5b84fad3efatagen2026-09-27 03:59 UTC378
    0
6f5b84fad3efatagen2026-09-27 03:59 UTC379
}
4e5ca2e20401atagen2026-09-28 11:42 UTC380
4e5ca2e20401atagen2026-09-28 11:42 UTC381
#[cfg(test)]
4e5ca2e20401atagen2026-09-28 11:42 UTC382
mod tests {
4e5ca2e20401atagen2026-09-28 11:42 UTC383
    use super::*;
4e5ca2e20401atagen2026-09-28 11:42 UTC384
4e5ca2e20401atagen2026-09-28 11:42 UTC385
    fn bash(command: &str) -> Input {
4e5ca2e20401atagen2026-09-28 11:42 UTC386
        Input {
4e5ca2e20401atagen2026-09-28 11:42 UTC387
            session_id: String::new(),
4e5ca2e20401atagen2026-09-28 11:42 UTC388
            cwd: Some("/".into()),
4e5ca2e20401atagen2026-09-28 11:42 UTC389
            tool_name: Some("Bash".into()),
4e5ca2e20401atagen2026-09-28 11:42 UTC390
            tool_args: Some(json!({ "command": command })),
4e5ca2e20401atagen2026-09-28 11:42 UTC391
            stop_hook_active: false,
4e5ca2e20401atagen2026-09-28 11:42 UTC392
        }
4e5ca2e20401atagen2026-09-28 11:42 UTC393
    }
4e5ca2e20401atagen2026-09-28 11:42 UTC394
4e5ca2e20401atagen2026-09-28 11:42 UTC395
    #[test]
4e5ca2e20401atagen2026-09-28 11:42 UTC396
    fn codex_denies_unwrapped_builds() {
4e5ca2e20401atagen2026-09-28 11:42 UTC397
        let output = codex_pre_tool_use(&Config::default(), bash("cargo build")).unwrap();
4e5ca2e20401atagen2026-09-28 11:42 UTC398
        assert_eq!(output["hookSpecificOutput"]["permissionDecision"], "deny");
4e5ca2e20401atagen2026-09-28 11:42 UTC399
        assert!(
4e5ca2e20401atagen2026-09-28 11:42 UTC400
            output["hookSpecificOutput"]["permissionDecisionReason"]
4e5ca2e20401atagen2026-09-28 11:42 UTC401
                .as_str()
4e5ca2e20401atagen2026-09-28 11:42 UTC402
                .unwrap()
4e5ca2e20401atagen2026-09-28 11:42 UTC403
                .contains("rabt run")
4e5ca2e20401atagen2026-09-28 11:42 UTC404
        );
4e5ca2e20401atagen2026-09-28 11:42 UTC405
    }
4e5ca2e20401atagen2026-09-28 11:42 UTC406
4e5ca2e20401atagen2026-09-28 11:42 UTC407
    #[test]
4e5ca2e20401atagen2026-09-28 11:42 UTC408
    fn codex_does_not_skip_approval_to_wrap_builds() {
4e5ca2e20401atagen2026-09-28 11:42 UTC409
        let mut cfg = Config::default();
4e5ca2e20401atagen2026-09-28 11:42 UTC410
        cfg.enforce.unwrapped = Unwrapped::Wrap;
4e5ca2e20401atagen2026-09-28 11:42 UTC411
        let output = codex_pre_tool_use(&cfg, bash("nix build .#example")).unwrap();
4e5ca2e20401atagen2026-09-28 11:42 UTC412
        assert_eq!(output["hookSpecificOutput"]["permissionDecision"], "deny");
4e5ca2e20401atagen2026-09-28 11:42 UTC413
        assert!(output["hookSpecificOutput"].get("updatedInput").is_none());
4e5ca2e20401atagen2026-09-28 11:42 UTC414
    }
4e5ca2e20401atagen2026-09-28 11:42 UTC415
4e5ca2e20401atagen2026-09-28 11:42 UTC416
    #[test]
4e5ca2e20401atagen2026-09-28 11:42 UTC417
    fn codex_ignores_other_tools_and_wrapped_builds() {
4e5ca2e20401atagen2026-09-28 11:42 UTC418
        assert!(
4e5ca2e20401atagen2026-09-28 11:42 UTC419
            codex_pre_tool_use(&Config::default(), bash("rabt run -s m -- cargo build")).is_none()
4e5ca2e20401atagen2026-09-28 11:42 UTC420
        );
4e5ca2e20401atagen2026-09-28 11:42 UTC421
        let mut input = bash("cargo build");
4e5ca2e20401atagen2026-09-28 11:42 UTC422
        input.tool_name = Some("apply_patch".into());
4e5ca2e20401atagen2026-09-28 11:42 UTC423
        assert!(codex_pre_tool_use(&Config::default(), input).is_none());
4e5ca2e20401atagen2026-09-28 11:42 UTC424
    }
4e5ca2e20401atagen2026-09-28 11:42 UTC425
}