Commit 4e5ca2e20401
tarball14 files changed+563-20
@@ -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
+## codex50
+51
+```52
+nix build .#codex-plugin53
+codex plugin marketplace add ./result54
+codex plugin add roundabout@roundabout55
+```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
+## omp62
+63
+```64
+nix build .#omp-plugin65
+omp plugin link ./result66
+```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
## config5071
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@@ -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 ];@@ -0,0 +1,21 @@
1
+{ lib, roundabout }:2
+3
+let4
+ hook = event: {5
+ type = "command";6
+ command = "${lib.getExe roundabout} hook codex-${event}";7
+ };8
+in9
+{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
+}@@ -0,0 +1,60 @@
1
+{2
+ lib,3
+ runCommand,4
+ writeText,5
+ roundabout,6
+}:7
+8
+let9
+ 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
+in53
+runCommand "roundabout-codex-plugin-${roundabout.version}" { } ''54
+ p=$out/plugins/roundabout55
+ mkdir -p $out/.agents/plugins $p/.codex-plugin $p/hooks $p/skills56
+ cp ${marketplace} $out/.agents/plugins/marketplace.json57
+ cp ${plugin} $p/.codex-plugin/plugin.json58
+ cp ${hooks} $p/hooks/hooks.json59
+ cp -r ${../plugin/skills}/. $p/skills/60
+''@@ -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
in1416
{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 {@@ -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/skills9
+ cp ${../plugin/omp/package.json} $out/package.json10
+ cp ${../plugin/omp/index.js} $out/index.js11
+ cp -r ${../plugin/skills}/. $out/skills/12
+ ln -s ${lib.getExe roundabout} $out/bin/rabt13
+''@@ -0,0 +1,32 @@
1
+{2
+ lib,3
+ nodejs,4
+ nushell,5
+ runCommand,6
+ writeScript,7
+ roundabout,8
+}:9
+10
+let11
+ source = lib.fileset.toSource {12
+ root = ../.;13
+ fileset = lib.fileset.unions [14
+ ../tests15
+ ../plugin/omp16
+ ];17
+ };18
+ mockCli = writeScript "roundabout-test-cli" ''19
+ #!${lib.getExe nushell} --stdin20
+ ${builtins.readFile ../tests/mock-rabt.nu}21
+ '';22
+in23
+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.mjs31
+ touch $out32
+ ''@@ -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
+}@@ -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
+}@@ -1,6 +1,6 @@
11
---22
name: roundabout3
-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 cap3
+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 cap44
---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
## waiting4545
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 minutes46
+a queued build prints its place and why to stderr. for anything likely over a couple of minutes4747
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 up5151
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@@ -36,7 +36,7 @@ pub enum Rabt {
3636
/// stop queueing past this, exit 753737
#[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 spares4242
#[pound(long)]@@ -55,7 +55,7 @@ pub enum Rabt {
5555
/// changed your mind5656
#[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 stdin82
+ /// agent hook entrypoint, json on stdin8383
Hook {84
- /// pre-tool-use post-tool-use prompt-submit stop subagent-stop session-end84
+ /// pre-tool-use post-tool-use prompt-submit stop subagent-stop session-end, or codex-prefixed variants8585
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,@@ -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 the244271
/// startup id while the json moves on, and we need both ends to agree245
-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 about253283
/// which session just ended. never spawns, a missing daemon holds no intents254
-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
0339379
}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
+}@@ -0,0 +1,8 @@
1
+def main [hook: string, event: string] {2
+ let input = $in | from json3
+ {event: $event, input: $input} | to json | save --force $env.ROUNDABOUT_TEST_INPUT4
+ if ($env.ROUNDABOUT_TEST_OUTPUT? | is-not-empty) {5
+ print --no-newline $env.ROUNDABOUT_TEST_OUTPUT6
+ }7
+ exit ($env.ROUNDABOUT_TEST_EXIT? | default 0 | into int)8
+}@@ -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
+});