atagen

roundabout

public
Star0
Watch0

Commit 4e5ca2e20401

tarball

atagen <boss@atagen.co>2026-10-01 02:53 UTC

14 files changed+563-20
agents: route codex and omp builds through roundabout
Modified file: README.md+21 additions
@@ -46,6 +46,27 @@ that installs roundabout and a socket-activated systemd user unit for the daemon
4646
4747 the plugin brings a skill and hooks. unwrapped builds get bounced back with instructions, overdue builds get mentioned to the agent that started them, and agents over their artifact cap get told to tidy up before they stop. `rabt` needs to be on PATH for the agent too, so install `.#roundabout` alongside.
4848
49+## codex
50+
51+```
52+nix build .#codex-plugin
53+codex plugin marketplace add ./result
54+codex plugin add roundabout@roundabout
55+```
56+
57+restart codex and review the plugin hooks with `/hooks` before using them. install `.#roundabout` as well so `rabt run` and `rabt expect` are on PATH. codex can also load the hooks directly from `~/.codex/hooks.json` or a trusted project's `.codex/hooks.json` using the `hooks` object in `result/plugins/roundabout/hooks/hooks.json`. use one installation route to avoid duplicate reminders.
58+
59+on nixos, `programs.roundabout.codex` exposes read-only `hooks`, `skill` and `plugin`, alongside the claude options. the codex hooks deny unwrapped builds and suggest `rabt run` instead of silently rewriting the command, which keeps the normal tool approval flow intact even when `enforce.unwrapped` is set to `wrap`. `rabt run` and `rabt expect` associate builds with the current codex session, and session-end hooks release its expectation.
60+
61+## omp
62+
63+```
64+nix build .#omp-plugin
65+omp plugin link ./result
66+```
67+
68+restart omp after linking. install `.#roundabout` on PATH for the agent's bash tool, or set `ROUNDABOUT_CLI` to the `rabt` binary for the extension. the plugin bundles the skill and checks shell builds before they run. it also delivers overdue reminders, checks artifact caps before the main agent stops, and releases expectations when a session closes or switches. wrapped builds and `rabt expect` inherit the omp session id through the bash tool's environment. on nixos, `programs.roundabout.omp` exposes read-only `plugin` and `skill` options.
69+
4970 ## config
5071
5172 `~/.config/roundabout/config.toml`, falling back to `/etc/roundabout/config.toml`, or `$ROUNDABOUT_CONFIG` over both. the daemon picks up edits on save, on linux anyway. a broken config gets complained about and ignored rather than taking every agent down with it. everything's optional, these are the defaults
Modified file: flake.nix+5 additions
@@ -28,6 +28,8 @@
2828 inherit roundabout;
2929 default = roundabout;
3030 plugin = pkgs.callPackage ./nix/plugin.nix { inherit roundabout; };
31+ codex-plugin = pkgs.callPackage ./nix/codex-plugin.nix { inherit roundabout; };
32+ omp-plugin = pkgs.callPackage ./nix/omp-plugin.nix { inherit roundabout; };
3133 }
3234 );
3335
@@ -59,6 +61,9 @@
5961 {
6062 inherit roundabout;
6163 plugin = self.packages.${system}.plugin;
64+ codex-plugin = self.packages.${system}.codex-plugin;
65+ omp-plugin = self.packages.${system}.omp-plugin;
66+ omp-hooks = pkgs.callPackage ./nix/omp-tests.nix { inherit roundabout; };
6267 clippy = roundabout.overrideAttrs (old: {
6368 pname = "roundabout-clippy";
6469 nativeBuildInputs = old.nativeBuildInputs ++ [ pkgs.clippy ];
Added file: nix/codex-hooks.nix+21 additions
@@ -0,0 +1,21 @@
1+{ lib, roundabout }:
2+
3+let
4+ hook = event: {
5+ type = "command";
6+ command = "${lib.getExe roundabout} hook codex-${event}";
7+ };
8+in
9+{
10+ PreToolUse = [
11+ {
12+ matcher = "Bash";
13+ hooks = [ (hook "pre-tool-use") ];
14+ }
15+ ];
16+ PostToolUse = [ { hooks = [ (hook "post-tool-use") ]; } ];
17+ UserPromptSubmit = [ { hooks = [ (hook "prompt-submit") ]; } ];
18+ Stop = [ { hooks = [ (hook "stop") ]; } ];
19+ SubagentStop = [ { hooks = [ (hook "subagent-stop") ]; } ];
20+ SessionEnd = [ { hooks = [ (hook "session-end") ]; } ];
21+}
Added file: nix/codex-plugin.nix+60 additions
@@ -0,0 +1,60 @@
1+{
2+ lib,
3+ runCommand,
4+ writeText,
5+ roundabout,
6+}:
7+
8+let
9+ hooks = writeText "hooks.json" (
10+ builtins.toJSON {
11+ hooks = import ./codex-hooks.nix { inherit lib roundabout; };
12+ }
13+ );
14+ plugin = writeText "plugin.json" (
15+ builtins.toJSON {
16+ name = "roundabout";
17+ version = roundabout.version;
18+ description = "build admission for agents sharing a machine";
19+ author.name = "atagen";
20+ skills = "./skills/";
21+ interface = {
22+ displayName = "Roundabout";
23+ shortDescription = "Give shared builds room to run";
24+ longDescription = "Queue builds, notice overdue leases and keep artifacts within budget.";
25+ developerName = "atagen";
26+ category = "Productivity";
27+ capabilities = [ ];
28+ defaultPrompt = "Help me run builds through Roundabout.";
29+ };
30+ }
31+ );
32+ marketplace = writeText "marketplace.json" (
33+ builtins.toJSON {
34+ name = "roundabout";
35+ interface.displayName = "Roundabout";
36+ plugins = [
37+ {
38+ name = "roundabout";
39+ source = {
40+ source = "local";
41+ path = "./plugins/roundabout";
42+ };
43+ policy = {
44+ installation = "AVAILABLE";
45+ authentication = "ON_INSTALL";
46+ };
47+ category = "Productivity";
48+ }
49+ ];
50+ }
51+ );
52+in
53+runCommand "roundabout-codex-plugin-${roundabout.version}" { } ''
54+ p=$out/plugins/roundabout
55+ mkdir -p $out/.agents/plugins $p/.codex-plugin $p/hooks $p/skills
56+ cp ${marketplace} $out/.agents/plugins/marketplace.json
57+ cp ${plugin} $p/.codex-plugin/plugin.json
58+ cp ${hooks} $p/hooks/hooks.json
59+ cp -r ${../plugin/skills}/. $p/skills/
60+''
Modified file: nix/nixos-module.nix+49 additions
@@ -10,6 +10,8 @@ let
1010 cfg = config.programs.roundabout;
1111 toml = pkgs.formats.toml { };
1212 plugin = pkgs.callPackage ./plugin.nix { roundabout = cfg.package; };
13+ codexPlugin = pkgs.callPackage ./codex-plugin.nix { roundabout = cfg.package; };
14+ ompPlugin = pkgs.callPackage ./omp-plugin.nix { roundabout = cfg.package; };
1315 in
1416 {
1517 options.programs.roundabout = {
@@ -79,6 +81,53 @@ in
7981 description = "hooks and skill as a marketplace, for `claude plugin marketplace add`";
8082 };
8183 };
84+
85+ codex = {
86+ hooks = lib.mkOption {
87+ type = lib.types.attrs;
88+ readOnly = true;
89+ default = import ./codex-hooks.nix {
90+ inherit lib;
91+ roundabout = cfg.package;
92+ };
93+ defaultText = lib.literalMD "roundabout's Codex hooks, keyed by event";
94+ description = "hooks in Codex hooks.json shape, for configs that manage Codex themselves";
95+ };
96+
97+ skill = lib.mkOption {
98+ type = lib.types.path;
99+ readOnly = true;
100+ default = "${codexPlugin}/plugins/roundabout/skills/roundabout";
101+ defaultText = lib.literalMD "the roundabout skill directory for Codex";
102+ description = "the skill directory, for configs that manage Codex skills themselves";
103+ };
104+
105+ plugin = lib.mkOption {
106+ type = lib.types.package;
107+ readOnly = true;
108+ default = codexPlugin;
109+ defaultText = lib.literalMD "the roundabout Codex plugin marketplace";
110+ description = "hooks and skill as a marketplace for Codex";
111+ };
112+ };
113+
114+ omp = {
115+ plugin = lib.mkOption {
116+ type = lib.types.package;
117+ readOnly = true;
118+ default = ompPlugin;
119+ defaultText = lib.literalMD "the roundabout OMP plugin";
120+ description = "the extension and skill for omp plugin link";
121+ };
122+
123+ skill = lib.mkOption {
124+ type = lib.types.path;
125+ readOnly = true;
126+ default = "${ompPlugin}/skills/roundabout";
127+ defaultText = lib.literalMD "the roundabout skill directory for OMP";
128+ description = "the skill directory, for configs that manage OMP skills themselves";
129+ };
130+ };
82131 };
83132
84133 config = lib.mkIf cfg.enable {
Added file: nix/omp-plugin.nix+13 additions
@@ -0,0 +1,13 @@
1+{
2+ lib,
3+ runCommand,
4+ roundabout,
5+}:
6+
7+runCommand "roundabout-omp-plugin-${roundabout.version}" { } ''
8+ mkdir -p $out/bin $out/skills
9+ cp ${../plugin/omp/package.json} $out/package.json
10+ cp ${../plugin/omp/index.js} $out/index.js
11+ cp -r ${../plugin/skills}/. $out/skills/
12+ ln -s ${lib.getExe roundabout} $out/bin/rabt
13+''
Added file: nix/omp-tests.nix+32 additions
@@ -0,0 +1,32 @@
1+{
2+ lib,
3+ nodejs,
4+ nushell,
5+ runCommand,
6+ writeScript,
7+ roundabout,
8+}:
9+
10+let
11+ source = lib.fileset.toSource {
12+ root = ../.;
13+ fileset = lib.fileset.unions [
14+ ../tests
15+ ../plugin/omp
16+ ];
17+ };
18+ mockCli = writeScript "roundabout-test-cli" ''
19+ #!${lib.getExe nushell} --stdin
20+ ${builtins.readFile ../tests/mock-rabt.nu}
21+ '';
22+in
23+runCommand "roundabout-omp-tests"
24+ {
25+ nativeBuildInputs = [ nodejs ];
26+ ROUNDABOUT_TEST_CLI = mockCli;
27+ ROUNDABOUT_TEST_RABT = lib.getExe roundabout;
28+ }
29+ ''
30+ node --test ${source}/tests/omp.test.mjs
31+ touch $out
32+ ''
Added file: plugin/omp/index.js+114 additions
@@ -0,0 +1,114 @@
1+import { spawn } from "node:child_process";
2+import { existsSync } from "node:fs";
3+import { fileURLToPath } from "node:url";
4+
5+const packagedCli = fileURLToPath(new URL("./bin/rabt", import.meta.url));
6+
7+function cli() {
8+ return process.env.ROUNDABOUT_CLI || (existsSync(packagedCli) ? packagedCli : "rabt");
9+}
10+
11+function invoke(event, context, input = {}) {
12+ return new Promise((resolve) => {
13+ const child = spawn(cli(), ["hook", event], { stdio: ["pipe", "pipe", "ignore"] });
14+ let output = "";
15+ const timeout = setTimeout(() => child.kill("SIGKILL"), event === "omp-session-end" ? 2000 : 10000);
16+ timeout.unref();
17+ child.on("error", () => {});
18+ child.stdout.on("data", (chunk) => {
19+ output += chunk;
20+ });
21+ child.on("close", (code) => {
22+ clearTimeout(timeout);
23+ if (code !== 0 || !output) return resolve(undefined);
24+ try {
25+ resolve(JSON.parse(output));
26+ } catch {
27+ resolve(undefined);
28+ }
29+ });
30+ child.stdin.on("error", () => {});
31+ child.stdin.end(JSON.stringify({
32+ session_id: context.sessionManager.getSessionId(),
33+ cwd: context.cwd,
34+ ...input,
35+ }));
36+ });
37+}
38+
39+export default function roundabout(pi) {
40+ pi.on("tool_call", async (event, context) => {
41+ if (event.toolName !== "bash") return;
42+ const output = await invoke("omp-pre-tool-use", context, {
43+ cwd: event.input.cwd || context.cwd,
44+ tool_name: "Bash",
45+ tool_input: event.input,
46+ });
47+ const verdict = output?.hookSpecificOutput;
48+ if (verdict?.permissionDecision === "deny") {
49+ return { block: true, reason: verdict.permissionDecisionReason };
50+ }
51+ return {
52+ input: {
53+ ...event.input,
54+ ...verdict?.updatedInput,
55+ env: {
56+ ...event.input.env,
57+ ROUNDABOUT_SESSION_ID: context.sessionManager.getSessionId(),
58+ },
59+ },
60+ };
61+ });
62+
63+ pi.on("tool_result", async (event, context) => {
64+ if (event.isError) return;
65+ const output = await invoke("omp-post-tool-use", context);
66+ const reminder = output?.hookSpecificOutput?.additionalContext;
67+ if (reminder) {
68+ return { content: [...event.content, { type: "text", text: reminder }] };
69+ }
70+ });
71+
72+ pi.on("before_agent_start", async (_event, context) => {
73+ const output = await invoke("omp-prompt-submit", context);
74+ const reminder = output?.hookSpecificOutput?.additionalContext;
75+ if (reminder) {
76+ return {
77+ message: {
78+ customType: "roundabout",
79+ content: reminder,
80+ display: true,
81+ attribution: "agent",
82+ },
83+ };
84+ }
85+ });
86+
87+ pi.on("session_stop", async (event, context) => {
88+ const output = await invoke("omp-stop", context);
89+ if (output?.decision === "block") {
90+ return { decision: "block", reason: output.reason };
91+ }
92+ if (event.stop_hook_active) return;
93+ const reminder = output?.hookSpecificOutput?.additionalContext;
94+ if (reminder) return { continue: true, additionalContext: reminder };
95+ if (output?.systemMessage && context.hasUI) {
96+ context.ui.notify(output.systemMessage, "warning");
97+ }
98+ });
99+
100+ pi.on("agent_end", async (_event, context) => {
101+ if (context.agent.kind !== "sub") return;
102+ const output = await invoke("omp-subagent-stop", context);
103+ const reminder = output?.reason || output?.hookSpecificOutput?.additionalContext || output?.systemMessage;
104+ if (reminder && context.hasUI) context.ui.notify(reminder, "warning");
105+ });
106+
107+ pi.on("session_before_switch", async (_event, context) => {
108+ await invoke("omp-session-end", context);
109+ });
110+
111+ pi.on("session_shutdown", async (_event, context) => {
112+ await invoke("omp-session-end", context);
113+ });
114+}
Added file: plugin/omp/package.json+10 additions
@@ -0,0 +1,10 @@
1+{
2+ "name": "roundabout",
3+ "version": "0.1.0",
4+ "type": "module",
5+ "description": "Build admission for shared agent machines",
6+ "license": "EUPL-1.2",
7+ "omp": {
8+ "extensions": ["./index.js"]
9+ }
10+}
Modified file: plugin/skills/roundabout/SKILL.md+6 additions−2 deletions
@@ -1,6 +1,6 @@
11 ---
22 name: roundabout
3-description: use before running any build, test suite or other heavy command (cargo, nix, make, npm, go, zig...), when a bash call gets denied for skipping roundabout, when reminded about an overdue build, or when told build artifacts are over their cap
3+description: use before running any build, test suite or other heavy command (cargo, nix, make, npm, go, zig...), when a shell call gets denied for skipping roundabout, when reminded about an overdue build, or when told build artifacts are over their cap
44 ---
55
66 # roundabout
@@ -43,12 +43,16 @@ you may get more cores than you asked for when the box is quiet, `CARGO_BUILD_JO
4343
4444 ## waiting
4545
46-a queued build prints its place and why to stderr. bash moves slow commands to the background on its own, but for anything likely over a couple of minutes
46+a queued build prints its place and why to stderr. for anything likely over a couple of minutes
4747
4848 1. run the build with `run_in_background`
4949 2. read the lease id from `rabt: lease N go`
5050 3. also start `rabt watch N` with `run_in_background`. it exits when the build finishes or goes overdue, which wakes you up
5151
52+in codex, use `exec_command` with a short `yield_time_ms` for the build, then poll its `session_id` with `write_stdin`. use another `exec_command` for `rabt watch N` if the build is likely to take longer. don't launch the build a second time while polling.
53+
54+in omp, use the bash tool's `async` option for the build and `omp ps` to follow its output. use another bash call for `rabt watch N` if you need an overdue reminder.
55+
5256 `--max-wait 10m` gives up queueing after that (exit 75). `--deadline 30m` kills the build if it's still going (exit 124). only use a deadline when a late result is useless.
5357
5458 ## overdue
Modified file: src/cli.rs+20 additions−7 deletions
@@ -36,7 +36,7 @@ pub enum Rabt {
3636 /// stop queueing past this, exit 75
3737 #[pound(long)]
3838 max_wait: Option<Dur>,
39- #[pound(long, hidden, env = "CLAUDE_CODE_SESSION_ID")]
39+ #[pound(long, hidden)]
4040 session: Option<String>,
4141 /// exactly the cores asked for, no spares
4242 #[pound(long)]
@@ -55,7 +55,7 @@ pub enum Rabt {
5555 /// changed your mind
5656 #[pound(long)]
5757 done: bool,
58- #[pound(long, hidden, env = "CLAUDE_CODE_SESSION_ID")]
58+ #[pound(long, hidden)]
5959 session: Option<String>,
6060 },
6161 /// who's running, who's waiting
@@ -79,9 +79,9 @@ pub enum Rabt {
7979 #[pound(subcommand)]
8080 action: Artifacts,
8181 },
82- /// claude code hook entrypoint, json on stdin
82+ /// agent hook entrypoint, json on stdin
8383 Hook {
84- /// pre-tool-use post-tool-use prompt-submit stop subagent-stop session-end
84+ /// pre-tool-use post-tool-use prompt-submit stop subagent-stop session-end, or codex-prefixed variants
8585 event: String,
8686 },
8787 #[pound(hidden)]
@@ -215,9 +215,22 @@ fn reply(ev: io::Result<Option<Event>>) -> i32 {
215215 }
216216 }
217217
218+fn agent_session(session: Option<String>) -> Option<String> {
219+ session.or_else(|| {
220+ [
221+ "ROUNDABOUT_SESSION_ID",
222+ "CLAUDE_CODE_SESSION_ID",
223+ "CODEX_THREAD_ID",
224+ "CODEX_SESSION_ID",
225+ ]
226+ .into_iter()
227+ .find_map(|key| std::env::var(key).ok().filter(|value| !value.is_empty()))
228+ })
229+}
230+
218231 fn expect(cfg: &Config, session: Option<String>, done: bool, a: &run::Args) -> i32 {
219232 let Some(session) = session.filter(|s| !s.is_empty()) else {
220- eprintln!("rabt: no CLAUDE_CODE_SESSION_ID, so nobody to expect on behalf of");
233+ eprintln!("rabt: no agent session id, use --session to name one");
221234 return 2;
222235 };
223236 let need = if done {
@@ -372,7 +385,7 @@ pub fn dispatch(cfg: &Config, cmd: Rabt) -> i32 {
372385 time,
373386 deadline,
374387 max_wait,
375- session,
388+ session: agent_session(session),
376389 exact,
377390 cmd,
378391 },
@@ -385,7 +398,7 @@ pub fn dispatch(cfg: &Config, cmd: Rabt) -> i32 {
385398 session,
386399 } => expect(
387400 cfg,
388- session,
401+ agent_session(session),
389402 done,
390403 &run::Args {
391404 size,
Modified file: src/hook.rs+97 additions−11 deletions
@@ -217,6 +217,33 @@ fn pre_tool_use(cfg: &Config, input: Input) {
217217 }
218218 }
219219
220+fn codex_pre_tool_use(cfg: &Config, input: Input) -> Option<Value> {
221+ if input.tool_name.as_deref() != Some("Bash") {
222+ return None;
223+ }
224+ let command = input.tool_args?.get("command")?.as_str()?.to_owned();
225+ let cwd = input.cwd.unwrap_or_else(|| "/".into());
226+ let Scan::Unwrapped(found) = scan(cfg, &command) else {
227+ return None;
228+ };
229+ if cfg.enforce.unwrapped == Unwrapped::Allow {
230+ return None;
231+ }
232+ let first: Vec<String> = found[0].split_whitespace().map(Into::into).collect();
233+ let hint = guess(cfg, &cwd, &first);
234+ let reason = format!(
235+ "builds go through roundabout here so parallel agents don't oom the box. run `rabt run {hint} -- {}` instead. see the roundabout skill",
236+ found[0]
237+ );
238+ Some(json!({
239+ "hookSpecificOutput": {
240+ "hookEventName": "PreToolUse",
241+ "permissionDecision": "deny",
242+ "permissionDecisionReason": reason,
243+ }
244+ }))
245+}
246+
220247 fn overdue(session: &str) -> Vec<LeaseInfo> {
221248 if session.is_empty() {
222249 return Vec::new();
@@ -242,7 +269,10 @@ fn overdue(session: &str) -> Vec<LeaseInfo> {
242269
243270 /// same source `rabt run` reads. after `--continue` the env var can keep the
244271 /// startup id while the json moves on, and we need both ends to agree
245-fn session(input: &Input) -> String {
272+fn session(input: &Input, codex: bool) -> String {
273+ if codex {
274+ return input.session_id.clone();
275+ }
246276 std::env::var("CLAUDE_CODE_SESSION_ID")
247277 .ok()
248278 .filter(|s| !s.is_empty())
@@ -251,8 +281,8 @@ fn session(input: &Input) -> String {
251281
252282 /// both ids, since around /clear the env var and the json can disagree about
253283 /// 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()];
284+fn session_end(input: &Input, codex: bool) {
285+ let mut ids = vec![session(input, codex), input.session_id.clone()];
256286 ids.dedup();
257287 for session in ids.into_iter().filter(|s| !s.is_empty()) {
258288 if let Some(mut s) = client::connect() {
@@ -268,8 +298,8 @@ fn session_end(input: &Input) {
268298 }
269299 }
270300
271-fn remind(event: &str, input: &Input) {
272- let late = overdue(&session(input));
301+fn remind(event: &str, input: &Input, codex: bool) {
302+ let late = overdue(&session(input, codex));
273303 if late.is_empty() {
274304 return;
275305 }
@@ -327,13 +357,69 @@ pub fn main(cfg: &Config, event: &str) -> i32 {
327357 return 0;
328358 };
329359 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),
360+ "codex-pre-tool-use" => {
361+ if let Some(output) = codex_pre_tool_use(cfg, input) {
362+ emit(&output);
363+ }
364+ }
365+ "codex-post-tool-use" | "omp-post-tool-use" => remind("PostToolUse", &input, true),
366+ "codex-prompt-submit" | "omp-prompt-submit" => remind("UserPromptSubmit", &input, true),
367+ "codex-stop" | "omp-stop" | "stop" => stop(cfg, "Stop", &input),
368+ "codex-subagent-stop" | "omp-subagent-stop" | "subagent-stop" => {
369+ stop(cfg, "SubagentStop", &input);
370+ }
371+ "codex-session-end" | "omp-session-end" => session_end(&input, true),
372+ "pre-tool-use" | "omp-pre-tool-use" => pre_tool_use(cfg, input),
373+ "post-tool-use" => remind("PostToolUse", &input, false),
374+ "prompt-submit" => remind("UserPromptSubmit", &input, false),
375+ "session-end" => session_end(&input, false),
336376 other => eprintln!("rabt: unknown hook {other}"),
337377 }
338378 0
339379 }
380+
381+#[cfg(test)]
382+mod tests {
383+ use super::*;
384+
385+ fn bash(command: &str) -> Input {
386+ Input {
387+ session_id: String::new(),
388+ cwd: Some("/".into()),
389+ tool_name: Some("Bash".into()),
390+ tool_args: Some(json!({ "command": command })),
391+ stop_hook_active: false,
392+ }
393+ }
394+
395+ #[test]
396+ fn codex_denies_unwrapped_builds() {
397+ let output = codex_pre_tool_use(&Config::default(), bash("cargo build")).unwrap();
398+ assert_eq!(output["hookSpecificOutput"]["permissionDecision"], "deny");
399+ assert!(
400+ output["hookSpecificOutput"]["permissionDecisionReason"]
401+ .as_str()
402+ .unwrap()
403+ .contains("rabt run")
404+ );
405+ }
406+
407+ #[test]
408+ fn codex_does_not_skip_approval_to_wrap_builds() {
409+ let mut cfg = Config::default();
410+ cfg.enforce.unwrapped = Unwrapped::Wrap;
411+ let output = codex_pre_tool_use(&cfg, bash("nix build .#example")).unwrap();
412+ assert_eq!(output["hookSpecificOutput"]["permissionDecision"], "deny");
413+ assert!(output["hookSpecificOutput"].get("updatedInput").is_none());
414+ }
415+
416+ #[test]
417+ fn codex_ignores_other_tools_and_wrapped_builds() {
418+ assert!(
419+ codex_pre_tool_use(&Config::default(), bash("rabt run -s m -- cargo build")).is_none()
420+ );
421+ let mut input = bash("cargo build");
422+ input.tool_name = Some("apply_patch".into());
423+ assert!(codex_pre_tool_use(&Config::default(), input).is_none());
424+ }
425+}
Added file: tests/mock-rabt.nu+8 additions
@@ -0,0 +1,8 @@
1+def main [hook: string, event: string] {
2+ let input = $in | from json
3+ {event: $event, input: $input} | to json | save --force $env.ROUNDABOUT_TEST_INPUT
4+ if ($env.ROUNDABOUT_TEST_OUTPUT? | is-not-empty) {
5+ print --no-newline $env.ROUNDABOUT_TEST_OUTPUT
6+ }
7+ exit ($env.ROUNDABOUT_TEST_EXIT? | default 0 | into int)
8+}
Added file: tests/omp.test.mjs+107 additions
@@ -0,0 +1,107 @@
1+import assert from "node:assert/strict";
2+import { spawnSync } from "node:child_process";
3+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4+import { tmpdir } from "node:os";
5+import { join } from "node:path";
6+import { after, beforeEach, test } from "node:test";
7+import roundabout from "../plugin/omp/index.js";
8+
9+const temporary = mkdtempSync(join(tmpdir(), "roundabout-omp-"));
10+const captured = join(temporary, "input.json");
11+const mockCli = process.env.ROUNDABOUT_TEST_CLI;
12+const handlers = new Map();
13+const notifications = [];
14+const context = {
15+ cwd: temporary,
16+ hasUI: true,
17+ agent: { kind: "main" },
18+ sessionManager: { getSessionId: () => "omp-session" },
19+ ui: { notify: (...args) => notifications.push(args) },
20+};
21+const bash = { toolName: "bash", input: { command: "cargo build", env: { KEEP: "yes" }, async: true } };
22+
23+roundabout({ on: (name, handler) => handlers.set(name, handler) });
24+
25+beforeEach(() => {
26+ process.env.ROUNDABOUT_CLI = mockCli;
27+ process.env.ROUNDABOUT_TEST_INPUT = captured;
28+ delete process.env.ROUNDABOUT_TEST_OUTPUT;
29+ delete process.env.ROUNDABOUT_TEST_EXIT;
30+ notifications.length = 0;
31+ rmSync(captured, { force: true });
32+});
33+
34+after(() => rmSync(temporary, { recursive: true, force: true }));
35+
36+function output(value) {
37+ process.env.ROUNDABOUT_TEST_OUTPUT = JSON.stringify(value);
38+}
39+
40+function input() {
41+ return JSON.parse(readFileSync(captured, "utf8"));
42+}
43+
44+test("maps build guards and preserves wrapped command arguments", async () => {
45+ output({ hookSpecificOutput: { permissionDecision: "deny", permissionDecisionReason: "use rabt run" } });
46+ assert.deepEqual(await handlers.get("tool_call")(bash, context), { block: true, reason: "use rabt run" });
47+ assert.equal(input().event, "omp-pre-tool-use");
48+ assert.equal(input().input.tool_name, "Bash");
49+ output({ hookSpecificOutput: { updatedInput: { command: "rabt run -s m -- cargo build" } } });
50+ const result = await handlers.get("tool_call")({ ...bash, input: { ...bash.input, cwd: "/project" } }, context);
51+ assert.equal(input().input.cwd, "/project");
52+ assert.equal(result.input.command, "rabt run -s m -- cargo build");
53+ assert.equal(result.input.async, true);
54+ assert.deepEqual(result.input.env, { KEEP: "yes", ROUNDABOUT_SESSION_ID: "omp-session" });
55+});
56+
57+test("fails open for malformed output, nonzero exits and missing CLI", async () => {
58+ process.env.ROUNDABOUT_TEST_OUTPUT = "not JSON";
59+ assert.equal((await handlers.get("tool_call")(bash, context)).input.command, "cargo build");
60+ output({ hookSpecificOutput: { permissionDecision: "deny" } });
61+ process.env.ROUNDABOUT_TEST_EXIT = "1";
62+ assert.equal((await handlers.get("tool_call")(bash, context)).block, undefined);
63+ process.env.ROUNDABOUT_CLI = join(temporary, "missing-rabt");
64+ assert.equal((await handlers.get("tool_call")(bash, context)).block, undefined);
65+});
66+
67+test("maps artifact policies and bounds advisory continuations", async () => {
68+ output({ decision: "block", reason: "clean artifacts" });
69+ assert.deepEqual(await handlers.get("session_stop")({ stop_hook_active: true }, context), {
70+ decision: "block", reason: "clean artifacts",
71+ });
72+ output({ hookSpecificOutput: { additionalContext: "clean artifacts" } });
73+ assert.deepEqual(await handlers.get("session_stop")({ stop_hook_active: false }, context), {
74+ continue: true, additionalContext: "clean artifacts",
75+ });
76+ assert.equal(await handlers.get("session_stop")({ stop_hook_active: true }, context), undefined);
77+ output({ systemMessage: "over cap" });
78+ await handlers.get("session_stop")({ stop_hook_active: false }, context);
79+ assert.deepEqual(notifications, [["over cap", "warning"]]);
80+});
81+
82+test("the CLI tracks OMP sessions ahead of inherited agent identities", () => {
83+ const rabt = process.env.ROUNDABOUT_TEST_RABT;
84+ const config = join(temporary, "config.toml");
85+ writeFileSync(config, '[queue]\nlinger = "1s"\n');
86+ const env = {
87+ ...process.env,
88+ ROUNDABOUT_CONFIG: config,
89+ ROUNDABOUT_STATE_DIR: join(temporary, "state"),
90+ ROUNDABOUT_RUNTIME_DIR: join(temporary, "run"),
91+ ROUNDABOUT_SESSION_ID: "omp-session",
92+ CLAUDE_CODE_SESSION_ID: "inherited-claude",
93+ CODEX_THREAD_ID: "inherited-codex",
94+ };
95+ function run(args, payload) {
96+ const result = spawnSync(rabt, args, { env, input: payload, encoding: "utf8", timeout: 10000 });
97+ assert.equal(result.status, 0, result.stderr);
98+ return result.stdout;
99+ }
100+ run(["expect", "-s", "s"]);
101+ assert.equal(JSON.parse(run(["status", "--json"])).intents[0].session, "omp-session");
102+ run(["hook", "omp-session-end"], JSON.stringify({ session_id: "omp-session" }));
103+ assert.equal(JSON.parse(run(["status", "--json"])).intents.length, 0);
104+ run(["expect", "--session", "explicit", "-s", "s"]);
105+ assert.equal(JSON.parse(run(["status", "--json"])).intents[0].session, "explicit");
106+ run(["expect", "--session", "explicit", "--done"]);
107+});