Agent runtime (SWE-ReX)
The agent runtime is the layer beneath the engine that actually spawns coding
agents. When the engine runs a step it selects exactly one runtime implementation
based on the resolved locus setting, then drives exactly one agent through that
runtime for the transition. The runtime is injected into the engine as a seam so
tests can supply a fake without starting Python.
Defined in src/core/batch/engine/runtime/.
Runtime contract
Defined in src/core/batch/engine/runtime/contract.ts.
interface AgentEvent {
kind: 'stdout' | 'exit' | 'error';
/** Present for kind 'stdout' — one line of agent output (no trailing newline). */
line?: string;
/** Present for kind 'exit' — the agent's process exit code. */
exitCode?: number;
/** Present for kind 'error' — an actionable failure message. */
message?: string;
}
type AgentRuntime = (
req: AgentSpawnRequest,
onEvent: (e: AgentEvent) => void
) => Promise<AgentSpawnResult>;
The spawn request and result types are defined in
src/core/batch/engine/agent.ts:
interface AgentSpawnRequest {
command: string;
args: string[];
/** Instructions passed to the agent via stdin. */
instructions: string;
cwd: string;
env: NodeJS.ProcessEnv;
}
interface AgentSpawnResult {
/** Process exit code (null if killed by signal). */
exitCode: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
}
AgentRuntime streams AgentEvents live to the onEvent callback as the agent
runs and simultaneously accumulates the full AgentSpawnResult (stdout
newline-joined, exitCode from the exit event). A bootstrap failure or sidecar
error event resolves with a non-zero exitCode and the message in stderr —
no new outcome states — so the engine maps it to blocked/failed and the step
remains resumable.
Per-step environment (AgentSpawnRequest.env)
The engine builds a per-step AgentSpawnRequest.env for every transition (see
buildSpawnRequest → agent.ts). Both rex runtimes export that env before
launching the agent so it is actually in effect inside the spawned command —
honoring the spawn contract this runtime layer presents. Env serialization
lives in one shared helper (runtime/spawn-command.ts: shquote +
buildEnvExports), consumed by both runtimes, so the two cannot drift apart in
how they apply env.
Merge semantics are overlay, not replace. The runtimes prefix the agent
launch command with export NAME='value'; statements (single-quoted via
shquote), which run on top of the runtime session's base environment:
- A request value wins on collision with a session base variable.
- A base variable absent from the request env remains visible to the agent.
A shell-session runtime cannot sanely replace the whole environment (env -i
would strip the session PATH the agent needs for command resolution on
docker/remote loci), so exports-on-top is the deterministic, documentable
contract. The legacy in-process realSpawner (agent.ts) replaces the child
env wholesale instead — that path spawns the agent binary directly as a Node
child, not through a shell session, so it controls env by passing it to the
spawn call rather than exporting it.
Serialization safety. buildEnvExports single-quotes every value so
metacharacters ($, spaces, quotes, newlines, backticks) survive byte-for-byte
and are not interpreted by the shell. Entries whose name is not a valid shell
identifier (/^[A-Za-z_][A-Za-z0-9_]*$/ — unreachable in shell and would break
export) and entries with undefined values are skipped; an empty/absent env
yields an empty prefix.
No protocol change. Env rides inside the command string:
- Sidecar — the run-op
commandbecomescd <cwd>; <exports> cat <promptfile> | <agent argv>. The run-op shape ({op, id, command}) andsidecar.pyare untouched; the command is JSON-encoded on the wire and the sidecar re-wraps it via its own_shquote, which preserves single-quote escaping byte-for-byte. - Remote —
buildRemoteRunCommandemits<exports> cat <serverPromptPath> | <agent argv>, and the existing nohup/log/exit-sentinel launcher wraps that unchanged; the exports sit inside its( … )subshell, so they apply to the agent pipeline without touching the REST protocol.
What the engine places in
AgentSpawnRequest.envis scoped, not the raw host env. Every spawn site builds the request env fromscopeAgentEnv(process.env)(see Agent environment allowlist) overlayingRATCHET_BATCH_NAME, so non-allowlisted host secrets do not reach the agent session. The sidecar bootstrap independently scopes its own launch env the same way.
Agent environment allowlist
Defined in src/core/batch/engine/agent-env.ts (scopeAgentEnv,
buildAgentEnvAllowlist, adapterEnvPassthroughKeys, AGENT_ENV_ALLOW_VAR).
Every spawn site — the engine's change-transition, decompose, and PR spawns
(engine.ts) and the ReX sidecar bootstrap (runtime/rex-bootstrap.ts) —
builds the environment it hands to the agent from scopeAgentEnv(hostEnv) rather
than from the raw host environment. A non-allowlisted host variable is dropped
before it can reach an agent session, so a secret the operator holds in their
shell (AWS_SECRET_ACCESS_KEY, a personal GITHUB_TOKEN, etc.) cannot leak into
a spawned agent regardless of locus.
Allowlist composition
buildAgentEnvAllowlist(hostEnv) returns { exact: Set<string>, prefixes: string[] }.
A host variable passes through when its name is in exact OR starts with one of
the prefixes (a prefix entry ending in _ matches any var beginning with it).
| Source | Exact names | Prefix patterns |
|---|---|---|
| Baseline process vars | PATH, HOME, TMPDIR, LANG, TERM, SYSTEMROOT, COMSPEC, PATHEXT, USERPROFILE, TEMP, TMP, APPDATA, LOCALAPPDATA, PROGRAMDATA | LC_ |
| Proxy | HTTP_PROXY, HTTPS_PROXY, NO_PROXY, http_proxy, https_proxy, no_proxy | — |
| Forge auth | GH_TOKEN, GITHUB_TOKEN | — |
| Ratchet control | — | RATCHET_ |
| Adapter passthrough | the union of every registered adapter's envPassthrough (see below) | adapter PREFIX_* entries |
RATCHET_* control variables pass through as a prefix so engine/runtime knobs
(e.g. RATCHET_BATCH_AGENT_CMD, RATCHET_BATCH_NAME) ride through. The ReX
sidecar's own REX_* threading vars (REX_LOCUS, REX_WORKDIR, REX_IMAGE,
…) are NOT in the baseline allowlist; the bootstrap sets them explicitly AFTER
scoping, so they survive by construction rather than by allowlist entry.
Adapter envPassthrough
Every AgentAdapter declares readonly envPassthrough: readonly string[] — the
provider/auth variables that adapter's agent needs. scopeAgentEnv takes the
union across all registered adapters (adapterEnvPassthroughKeys), so an
agent's own credentials reach it without any agent being special-cased in a
shared path. An entry may be an exact name (ANTHROPIC_API_KEY) or a PREFIX_*
glob (CLAUDE_CODE_* matches any var starting with CLAUDE_CODE_).
The builtin adapter declarations:
| Adapter | envPassthrough |
|---|---|
| claude | ANTHROPIC_API_KEY, CLAUDE_CODE_* |
| codex | OPENAI_API_KEY, CODEX_HOME |
| gemini | GEMINI_API_KEY, GOOGLE_API_KEY |
| cursor | CURSOR_API_KEY, CURSOR_* |
| opencode | OPENCODE_* |
A drift guard in test/core/batch/agent-init-link.test.ts asserts every
spawnable adapter declares a non-empty envPassthrough, so a newly added agent
cannot silently widen or narrow the allowlist.
Operator escape hatch
RATCHET_AGENT_ENV_ALLOW (exported as AGENT_ENV_ALLOW_VAR) is a
comma-separated list of extra host env names read from the host environment at
spawn time. Each named var is added to the exact allowlist for that spawn
only. It is intended for operator-supplied site credentials that are not a
known provider key; it is read from the host env and never committed to a
repo's batch manifest.
Where scoping is applied
| Site | File | Overlay |
|---|---|---|
| Change-transition spawn | engine.ts (runStep) | RATCHET_BATCH_NAME (when a batch is active) |
| Decompose spawn | engine.ts (runDecompositionStep) | RATCHET_BATCH_NAME |
| PR spawn | engine.ts (runPrStep) | RATCHET_BATCH_NAME |
| ReX sidecar bootstrap | runtime/rex-bootstrap.ts (bootstrapRexRuntime) | PATH (venv bin prepended), VIRTUAL_ENV, REX_* threading vars |
The per-step request env is then serialized into the agent command by
buildEnvExports (see Per-step environment)
as exports-on-top of the session base; a var dropped by scoping is absent from
the request env and so is never exported.
Agent-cmd override seam
RATCHET_BATCH_AGENT_CMD (and RATCHET_EVAL_AGENT_CMD on the eval side) stand
in for the configured coding-agent binary when set: the engine launches
bash -c <override> (instructions on stdin) instead of resolving the adapter.
This is the deterministic spawn path the e2e/eval harnesses exercise the
orchestration through without a real agent. An active override is loud and
auditable — a leftover value (from an eval session, CI, a .envrc) can never
silently replace the configured agent:
- One-line notice (text output).
ratchet batch applyprints⚠ agent overridden by RATCHET_BATCH_AGENT_CMDas the first line of the rendered step result;ratchet eval runprints the matching⚠ agent overridden by RATCHET_EVAL_AGENT_CMDatop the scorecard. The notice rides the result, not a side-channel print — it appears exactly when a spawn ran under the override. Pre-spawn parks print no notice (nothing was spawned). agentOverride: true(--json).batch apply --jsoncarriesagentOverride: trueon the step-result JSON;eval run --jsoncarries it at the top level. Absent when no override is active — byte-identical output for override-free runs.via: env-overrideprovenance. Every journal entry and eval run record produced under an active override is stampedvia: "env-override":- the engine stamps the transition-outcome journal entry it appends;
batch reportstamps every entry it appends from its own process env (the spawned stand-in inheritsRATCHET_BATCH_AGENT_CMD, so a stub-reported completion/blocker/progress/needs-input is auditable even though the engine never sees that append);eval runstampsviaon the persisted run record. The field is optional and only ever'env-override'today; readers ignore it, so override-free entries/runs carry noviaand need no migration.
The override gate and spawn-request construction live in one shared helper
(buildAgentSpawnRequest in src/core/batch/engine/agent.ts): the batch
engine's buildSpawnRequest, the eval judge's buildVoteRequest, and the
mutation harness's buildSeedRequest all delegate their override branch to it,
so the three spawn seams cannot drift apart. The helper owns only the shared
override semantics (the trim check, the bash -c <override> request shape, the
agentOverride flag); each call site's buildAdapterRequest closure owns its
own adapter resolution, so genuinely different adapter paths are not flattened.
SWE-ReX sidecar
For the local and docker loci, ratchet bootstraps an isolated Python sidecar
(sidecar.py) that wraps SWE-ReX. The Node side manages the sidecar lifecycle;
the Python side drives the SWE-ReX deployment.
Bootstrap (rex-bootstrap.ts)
bootstrapRexRuntime provisions a ratchet-owned virtual environment on first use
and returns a ResolvedLaunch (the command, args, and env to spawn the sidecar).
It is lazy and idempotent: a ready venv is reused without a rebuild.
interface ResolvedLaunch {
/** The venv's Python interpreter (absolute path). */
command: string;
/** Arguments — the resolved sidecar.py path. */
args: string[];
/** Environment for the sidecar (REX_* passthrough + venv on PATH). */
env: NodeJS.ProcessEnv;
}
Bootstrap behavior:
- The venv lives at
$XDG_CACHE_HOME/ratchet/rex/venv(falling back to~/.cache/ratchet/rex/venvwhenXDG_CACHE_HOMEis unset). It never touches the user's global Python environment. - A JSON readiness marker (
.ratchet-rex-ready.json) inside the venv dir is written last, after a successful import check. A missing or stale marker (wrongsweRexVersionor missing required extras) triggers a full rebuild after clearing the directory so no partial venv is mistaken for ready. - The pinned swe-rex version is
1.4.0(SWE_REX_VERSION). A version change forces a rebuild. uvis preferred for creating the venv and installing packages. Whenuvis not on PATH, bootstrap falls back topython -m venvand the venv's own pip.- Python candidates are probed in order —
python3,python,python3.12,python3.11,python3.10— and the first interpreter that reports Python= 3.10 is used. A
pythonOverrideskips the probe. - After install, bootstrap verifies
import swerexsucceeds from the venv interpreter before writing the marker. For the docker locus it additionally verifiesimport swerex.deployment.docker. - The venv's
bindirectory is prepended toPATHandVIRTUAL_ENVis set in the sidecar's environment.
Environment variables threaded to the sidecar:
| Variable | Set when | Value |
|---|---|---|
REX_LOCUS | always | 'local' or 'docker' |
REX_WORKDIR | always | project root (local) or /workspace (docker) |
REX_IMAGE | docker only | configured image, or DEFAULT_DOCKER_IMAGE (python:3.12) |
REX_MOUNT_HOST | docker only | project root (host path bind-mounted into the container) |
REX_MOUNT_CONTAINER | docker only | /workspace (in-container mount point) |
REX_DOCKER_USER | docker only, when dockerUser is set | host uid:gid for docker run --user; when unset the sidecar resolves the current host uid:gid |
REX_DOCKER_MEMORY | docker only | configured dockerMemory, or DEFAULT_DOCKER_MEMORY (2g) |
REX_DOCKER_PIDS_LIMIT | docker only | configured dockerPidsLimit, or DEFAULT_DOCKER_PIDS_LIMIT (512) |
REX_DOCKER_CPUS | docker only, when dockerCpus is set | configured dockerCpus (e.g. 1.5); no flag when unset |
REX_DOCKER_NETWORK | docker only | configured network, or DEFAULT_DOCKER_NETWORK (bridge) |
Sidecar process (sidecar.py)
The sidecar is a Python script driven over a newline-delimited JSON protocol on stdio. It starts a SWE-ReX deployment (local or docker), runs shell commands through it on demand, streams output back, and shuts down cleanly.
The sidecar suppresses SWE-ReX's Rich console logger before import
(SWE_REX_LOG_STREAM_LEVEL=CRITICAL) so no non-JSON output contaminates the
protocol channel.
Node → sidecar (stdin):
| Message | Effect |
|---|---|
{"op":"run","id":N,"command":"<shell>"} | Launch the shell command, stream stdout, emit exit. |
{"op":"shutdown"} | Stop the deployment and exit 0. |
Sidecar → Node (stdout):
| Event | Emitted when |
|---|---|
{"event":"ready","locus":"local"|"docker"} | Deployment started; emitted once before any run op. |
{"event":"stdout","id":N,"line":"..."} | One complete line of command output. |
{"event":"exit","id":N,"exit_code":N} | Command finished; exactly once per run op. |
{"event":"closed"} | Clean shutdown complete. |
{"event":"error","id":N|null,"message":"...",...} | Any caught exception. |
Streaming model: the sidecar launches each shell command detached to a per-run
logfile and tail-polls that logfile at 300 ms intervals (POLL_INTERVAL = 0.3)
via the SWE-ReX execute() API. It advances a byte cursor over the logfile so
only new bytes are read each poll, and emits complete lines as stdout events.
The exit sentinel file signals completion; the sidecar drains any final bytes
before emitting the exit event.
Execution loci
The locus setting selects the runtime implementation. The default locus is
local.
Threat model per locus
The resolved locus determines what real isolation backs a run. ratchet batch config and ratchet batch view render this honestly (see
isolation rendering),
and ratchet doctor nudges toward the docker locus when a permissive posture
runs on local (see doctor).
| Locus | Real isolation | Threat model |
|---|---|---|
local | Advisory — none. The agent runs as a direct child of the operator's shell session. No filesystem or network isolation; the argv denylist (REPO_SANDBOX_DENY_PATTERNS) is best-effort damage reduction, not containment. Env is scoped to the allowlist so non-allowlisted host secrets are dropped, but that is scoping, not isolation. | A full-autonomy (or even permissive) agent on local can do anything the launching user can — read any file the user can read, reach any network the user can reach, write anywhere the user can write. The posture flags are the only gate, and for cursor/opencode even those are a no-op (agent defaults apply). This is the right locus for trusted, operator-supervised runs. |
docker | Partial container isolation. The agent runs in a Docker container with a read-write bind mount of the project root at /workspace. Resource bounds (memory, pids, optional cpus) and a --user mapping keep runaway and file-ownership in check. See the honest isolation contract below for what is and is NOT bounded. | The container shares the host kernel (no VM boundary) and has outbound network by default (network: bridge). The repo bind mount is read-write by design so the agent can edit files and the engine can read the journal back. The docker locus bounds resource runaway and ensures file ownership — it is not a hardened sandbox for untrusted agents, but it is real containment where local has none. |
remote | Server's boundary. The agent runs on an external swerex-remote server over REST; ratchet's process never touches the agent's filesystem or process tree. | The isolation boundary is whatever the server operator configures — ratchet does not own it. The transport is authenticated (X-API-Key) and scheme-selected (loopback → http, non-local → https unless insecure). This is the locus for shared/CI runners and hardened remote sandboxes. |
The argv denylist is not containment.
REPO_SANDBOX_DENY_PATTERNS(thedenylist merged forrepo-sandboxed-permissiveandcurated-allowlist) blocks a handful of destructively-shaped shell commands at the agent's own permission layer. It is best-effort damage reduction — an agent that can run arbitrary shell can trivially evade a pattern denylist. Real containment is the docker locus (resource bounds, filesystem ownership, optional network isolation vianetwork: none). The denylist exists to catch accidental foot-guns on thelocallocus, not to stop a determined agent.
local — RexSidecarRuntime (rex-sidecar-runtime.ts)
The local runtime bootstraps the SWE-ReX sidecar, spawns it as a child process, and drives the JSON-lines protocol described above.
Prompt delivery: the agent's instructions are written to a temporary prompt file
at .ratchet/batches/<batch>/.run/<id>/prompt.txt on the host. The run command
sent to the sidecar is cd <cwd>; export NAME='value'; …; cat <promptfile> | <agent argv>
— the per-step AgentSpawnRequest.env is exported before the pipeline (after the
cd prefix) via the shared buildEnvExports helper. The prompt file is removed
after the run (in a finally block).
The overall run timeout defaults to 600000 ms (10 minutes) and is configurable
via batch.agentTimeoutMs or the RATCHET_AGENT_TIMEOUT_MS environment variable
(see Per-agent timeout below). On completion or timeout the
sidecar is torn down via the process-group reaping sequence
— a shutdown op first, then a group SIGKILL after a short grace — so the agent
process is never left alive.
docker — RexSidecarRuntime with DockerDeployment
The docker locus runs the same local sidecar but selects DockerDeployment
(via REX_LOCUS=docker). The project root is bind-mounted into the container
at /workspace (DOCKER_MOUNT_CONTAINER).
Additional behavior specific to the docker locus:
-
A
docker infopre-flight runs before any venv work. A non-zero result throwsRexBootstrapErrorimmediately, so the run never hangs on SWE-ReX's own startup timeout. -
The venv must carry the
dockerextra, which installsaiohttpexplicitly. (swe-rex1.4.0 does not declareaiohttpin its package metadata, butswerex.deployment.dockerimports it at runtime.) A local-only venv is rebuilt the first time the docker locus is requested. -
REX_WORKDIRis set to/workspace(the in-container path), not the host project root. The prompt file is written on the host and its path is translated to the in-container equivalent before it is passed to the sidecar. -
REX_IMAGEis set to the configuredimage, orDEFAULT_DOCKER_IMAGE(python:3.12) when none is configured. -
Docker hardening knobs are threaded as
REX_DOCKER_*env vars and spliced intodocker runargv by the sidecar:Knob Flag Default when unset Source of truth dockerUser--usercurrent host uid:gid(resolved by the sidecar)REX_DOCKER_USER(Node); sidecar fallback resolvesos.getuid():os.getgid()dockerMemory--memory2g(DEFAULT_DOCKER_MEMORY)config.ts/sidecar.pymirrordockerPidsLimit--pids-limit512(DEFAULT_DOCKER_PIDS_LIMIT)config.ts/sidecar.pymirrordockerCpus--cpus(no flag — opt-in only) REX_DOCKER_CPUSonly when setnetwork--networkbridge(DEFAULT_DOCKER_NETWORK)config.ts/sidecar.pymirrorThe
--userdefault ensures container file writes land as the host user, not root, so journal writes on the bind mount are owned by the operator. Each knob is overridable via the nearest-wins settings cascade (project config ← per-change manifest), exactly likeimage/locus.
Honest isolation contract
The docker locus is a partial isolation boundary, not a security sandbox. Operators should understand what it does and does NOT contain:
What is bounded:
- File writes — land on the host bind mount (the project root, mounted
read-write at
/workspace) as the hostuid:gid(via--user), not as root. - Memory — capped at
dockerMemory(default2g) via--memory. - Process count — capped at
dockerPidsLimit(default512) via--pids-limit, bounding fork-bomb-style runaway. - CPU — optionally capped via
dockerCpus(--cpus); no cap by default.
What is NOT bounded (by default):
- Network — the default
network: bridgemeans the container HAS outbound network access. To fully isolate, setnetwork: none. A custom network name is also accepted (passed verbatim todocker run --network). - Root filesystem — the container's own rootfs is Docker's default (not read-only); only the bind mount is shared with the host.
- Kernel surface — the container shares the host kernel (no VM boundary).
The repo bind mount stays read-write by design so the agent can edit files and the engine can read the journal back over the mount. This is the documented contract: the docker locus bounds resource runaway and ensures file ownership, but it is not a substitute for a hardened sandbox when running untrusted agents.
remote — RexRemoteRuntime (rex-remote-runtime.ts)
The remote runtime drives an external swerex-remote server over its REST API
using the Node global fetch. No local Python sidecar is started; the Python
lives on the server.
Required settings: host, port, and authToken. The auth token is sent as
the X-API-Key request header and is never printed in any error message.
Transport scheme selection:
- A bare loopback host (
localhost,127.x.x.x,::1) defaults tohttp. - A bare non-local host defaults to
https. - An explicit
https://prefix is honored. - An explicit
http://prefix to a non-local host is refused unlessallowInsecureis set. The settings key isinsecure(a boolean under the resolvedbatch:/ remote scope); the engine mapsinsecure: trueto the runtime optionallowInsecure(rex-remote-runtime.ts, threaded atengine.ts). The two names refer to the same opt-in — the settings schema names itinsecure, the runtime surface names itallowInsecure.
The remote runtime reproduces the sidecar's tail-poll streaming over REST:
GET /is_alive— health check with a short per-request timeout (30 s default).POST /create_session— create the bash session.- Write the prompt onto the server filesystem via
POST /execute(mkdir +POST /write_file). POST /execute(non-blocking) — detach the agent command to a logfile + exit sentinel.POST /executein a poll loop (300 ms default) —tail -c +<offset+1>to advance a byte cursor; emitstdoutevents as complete lines arrive.- Read the exit sentinel. Drain final bytes, emit
exit, then close the session and runtime (POST /close_session,POST /close). - Teardown reaps the agent group before close. After the run completes, the
runtime kills the agent's process group (TERM → 1 s grace → KILL on the
negative pgid recorded in
agent.pid) BEFORErm -rfof the run dir and the/close_session+/closecalls, so a detached agent is never left alive on the server. See Agent process-group reaping on teardown.
The overall run timeout defaults to 600000 ms (10 minutes) and is configurable
via batch.agentTimeoutMs or the RATCHET_AGENT_TIMEOUT_MS environment variable
(see Per-agent timeout below). A swerexception body on any
response is surfaced as an actionable AgentEvent{kind:'error'} with the engine
mapped to blocked/failed and no hang.
Per-agent timeout
Every runtime applies a per-agent timeout — the guard against a hung agent. It is
locus-uniform and agent-neutral: the same resolved value applies to local,
docker, and remote, and to every coding agent. The built-in default is
600000 ms (10 minutes); each runtime keeps applying that default whenever no
value is configured.
selectRuntime resolves the effective timeout once (via resolveAgentTimeoutMs)
and threads it into the chosen runtime's timeoutMs option, omitting the option
entirely when nothing is set so the runtime's own default applies. Resolution
precedence is env > manifest > project config > built-in default:
RATCHET_AGENT_TIMEOUT_MSwins when it parses to a positive integer. A zero, negative, non-numeric, or empty value is ignored (a typo never shortens or removes the guard) and resolution falls through to the config layers.- Otherwise
batch.agentTimeoutMsfrom the per-change manifest, then the project config (.ratchet/config.yaml). - Otherwise the runtime's built-in
DEFAULT_TIMEOUT_MS(600000ms).
Agent process-group reaping on teardown
Every runtime now deterministically reaps the spawned agent's process group
on teardown, so a detached agent can never outlive the runtime that launched it.
This fixes the leak where nohup-style launchers (remote) and SWE-ReX's own
execute() (sidecar) left the agent alive after the runtime session closed.
Launch (job control)
Each runtime launches the agent under job control (set -m) so the agent
pipeline becomes its own process-group leader, and records that group's pid to a
pidfile so teardown can target the whole group:
- Sidecar (
sidecar.py) — the run-op shell command is wrapped asset -m; bash -c '<cmd> >log 2>&1 & echo $! >pid; wait; echo $? >done. The backgrounded pipeline's pid is written to<run_dir>/agent.pid; the sidecar tracks it viaself.run_pidfile. Therun_diris threaded from the Node side (host runDir forlocal, the in-container equivalent fordocker), falling back to the sidecar workdir when absent. - Remote (
rex-remote-runtime.ts) —buildRemoteRunCommandemitsset -m; nohup … & echo $! ><runDir>/agent.pidinside its existing( … )subshell, so the detached agent's pid is recorded on the server. - Legacy in-process (
agent.ts:makeRealSpawner) — spawns the agent withdetached: true(POSIX) so the child is its own group leader;reapGrouptargets the negative pid.
Teardown (group reap before close)
Teardown never just closes the session and leaves — it reaps the group first:
- Sidecar —
teardownChildsends{op:"shutdown"}so the sidecar stops its SWE-ReX deployment and reaps the agent group (_reap_agent_group: TERM → grace → KILL on the negative pgid from the pidfile) as part of shutdown. Only after the grace does the Node sidekillGroup(SIGKILL)the sidecar's own process group if it has not exited. NoSIGTERMis sent to the sidecar directly — that would kill it before it could reap the agent. - Remote —
teardown()kills the group (kill -TERM -- -$(cat agent.pid)→ sleep 1 →kill -KILL -- -$(cat agent.pid)) BEFORErm -rfof the run dir and thePOST /close_session+POST /closecalls. - Legacy in-process — on overall timeout,
reapGroupsends TERM → grace → KILL to the negative pid.
The sidecar's SIGTERM handler raises SystemExit(0) after shutdown, so a
SIGTERM sent to the sidecar process itself still reaps the agent group and
exits cleanly rather than dying mid-teardown.
Idempotency
_reap_agent_group claims the pidfile (sets self.run_pidfile = None) on entry,
so a concurrent shutdown + SIGTERM is safe — only one call performs the kill;
the second is a no-op. Missing, empty, or non-numeric pidfiles skip the kill
(best-effort, never throws).
An adapter knows how to build the spawn request for one coding agent. The engine
resolves the adapter by name from the resolved settings before any spawn, and
throws UnknownAgentError (listing available adapters) when the name is not
registered.
The default agent when no adapter is configured is claude.
Built-in adapters:
| Agent | Command | Base args | Stream-JSON |
|---|---|---|---|
claude | claude binary | -p --output-format stream-json --verbose --include-partial-messages | yes |
codex | codex binary | exec - | no |
gemini | gemini binary | -p | no |
cursor | cursor binary | -p | no |
opencode | opencode binary | run --format json | yes |
All adapters pass the agent instructions on stdin. The binary name for each
adapter is read from the AI_TOOLS registry in src/core/config.ts
(agentBinary field); the adapter code does not hardcode binary names. The same
registry drives ratchet doctor's PATH checks, so the two cannot drift.
Permission flags resolved from the active policy (see Agent permissions below) are appended to the base args after the adapter's own argv.
Skill-in-spawn-locus guarantee
Defined in src/core/batch/engine/skill-locus.ts.
Engine-spawned change-verb agents delegate the lifecycle to the canonical ratchet
skill rather than re-authoring it inline (see the delegated-lifecycle standard):
the spawned-agent prompt tells a headless agent to invoke /rct:<transition> <change>. That delegation is only safe if the rct command for the transition
actually exists in the locus where the agent runs. Before spawning, the engine
therefore guarantees that command is present — rendering it when absent, verifying
it when present, and failing loudly (never spawning) when it cannot. This is a
render-or-fail precondition, evaluated inside runChangeStep before the
spawn request is built and before the runtime is selected/invoked.
The engine drives three spawn kinds, all routed through this one guarantee
(ensureCommandInSpawnLocus, with ensureSkillInSpawnLocus as the per-change
wrapper):
- Change-scoped propose/apply/verify spawns (
runChangeStep) — delegate to/rct:<transition> <change>. - Phase-scoped decomposition spawns (
runDecompositionStep) — delegate to/rct:decompose-phase <phase>to author a reachable empty phase's concrete change intents intobatch.yaml. The decomposition spawn routes through thedecomposeagent stage (soagent.decomposeor a scalaragentselects which agent runs it), and the guarantee renders/verifies thedecompose-phasecommand for that stage the same way before that spawn. - Group-scoped PR spawns (
runPrStep) — delegate to/rct:open-prto commit the prior stage agents' accumulated work and open one pull request per fired group boundary (the whole batch underwhole-batch, or one stacked PR per phase / change underper-phase/per-change). The guarantee renders/verifies theopen-prcommand for theprstage the same way before that spawn (see PR step below).
The prompt itself now delegates to that command: buildAgentInstructions
emits the /rct:<transition> <change> invocation instead of a hand-built inline
recipe — see Agent instructions below. This guarantee is
its precondition: it ensures the command the prompt names actually exists in the
spawn locus before the agent is told to run it.
The caller's -m guidance and any resolved resume answer are injected as
ARGUMENTS of that /rct:<transition> <change> invocation — handed to the skill
as $ARGUMENTS rather than floated off in a detached block (see
Agent instructions below for the argument-injection
contract).
Step kind → rct command
The forced transition selects exactly its own canonical rct command, kept in one
place (rctCommandIdForTransition) so it stays aligned with the prompt-delegation
change; the phase-scoped decomposition step uses its own single-source id
(DECOMPOSE_COMMAND_ID), and the whole-batch PR step uses PR_OPEN_COMMAND_ID:
| Step kind | rct command |
|---|---|
propose | /rct:propose |
apply | /rct:apply |
verify | /rct:verify |
decompose (phase) | /rct:decompose-phase |
open-pr (per-group PR) | /rct:open-pr |
open-pr is the command id (what instruction the PR agent runs), single-sourced in
PR_OPEN_COMMAND_ID beside DECOMPOSE_COMMAND_ID — deliberately distinct from the
pr routable agent stage (which agent runs the PR step). The same render-or-fail
guarantee renders/verifies the open-pr command into the spawn locus exactly as it
does the others, via ensureCommandInSpawnLocus(PR_OPEN_COMMAND_ID, …, 'pr'), so it
is present before the PR agent is told to run it. The engine step that drives this
command at each fired group boundary is runPrStep (see PR step
below).
Per-agent command path
The command file path is resolved through the command-generation registry
(src/core/command-generation/), never a hard-coded single-agent path. The
guarantee resolves the configured spawn agent's adapter (default claude) and
computes the path via adapter.getFilePath(<command-id>), then renders the file
from the shared command definition (getCommandContents → adapter.formatFile)
— the same content ratchet init writes, so there is one author of the lifecycle
text. The applicable set is the batch-engine spawnable agents (the BUILTIN_ADAPTERS):
| Agent | Rendered command path (apply) |
|---|---|
claude | .claude/commands/rct/apply.md |
cursor | .cursor/commands/rct-apply.md |
gemini | .gemini/commands/rct-apply.md |
codex | <CODEX_HOME>/prompts/rct-apply.md (global-scoped, absolute) |
opencode | .opencode/commands/rct-apply.md |
github-copilot has a command-generation adapter but no batch-engine spawn
adapter, so resolveAdapter rejects it with UnknownAgentError before any
spawn — it can never be the spawn agent and is out of scope for this spawn-time
guarantee. (opencode is now a full spawnable agent and is in scope.)
Behavior and the bootstrap-failure contract
ensureSkillInSpawnLocus(ctx, projectRoot, deps) (side effects through an
injectable exists/writeText seam, mirroring rex-bootstrap.ts):
- Absent → render the command from the shared definition into the spawn locus, then spawn.
- Present → verify and leave the existing file untouched (no re-render), then spawn.
- Unrenderable locus (
remote— the agent runs in a remote workdir the engine does not control on disk) → throwSkillLocusError.localanddockerare renderable (docker bind-mounts the project root, so a file rendered there is visible in the container). - Render/write failure → throw
SkillLocusErrornaming the failing path and the underlying detail.
A SkillLocusError short-circuits the step to a blocked/failed StepResult
carrying the actionable message — the same bootstrap-error contract as
UnknownAgentError / the locus failingRuntime path: no agent is spawned, the
message is surfaced live on the engine's line sink, no new outcome state is
introduced, and the step stays resumable. The message names the missing rct
command, the spawn locus, and the remedy, and never instructs the agent to invoke
a skill it cannot run.
Spawn flow
The guarantee adds no user-facing command, flag, or config key — it is internal
engine behavior — so README.md needs no edit.
PR step
Overview
At each fired group boundary, runPrStep gates on the active grouping mode
(isPrGroupingActive) and the group's own already-opened flag
(hasJournaledPrForGroup), then spawns one pr-stage agent that delegates to
/rct:open-pr to commit the accumulated work in the repo's git log style, open
exactly one PR to the group's stacked base branch, and record the outcome in
run-state keyed by group — with off/unset and an already-opened group both
short-circuiting to nothing-ready.
Defined in src/core/batch/engine/engine.ts (runPrStep).
runPrStep spawns exactly one PR agent that delegates to /rct:open-pr to
commit the prior stage agents' accumulated work in the repository's own git log
commit style (defaulting to semantic / Conventional Commits) and open a single
pull request from the work branch to its base branch, using whichever forge CLI the
environment provides. It is the structural twin of runDecompositionStep: it takes
the per-batch lock, guarantees the shared command in the spawn locus, builds
instructions, and hands the request to the shared spawnAndMap tail — the engine
spawns one agent and JOURNALS the outcome, but never re-authors the
commit/push/PR-open steps inline (those live once in the /rct:open-pr body). The
resolved work/base branch are supplied to the engine as data (PrStepContext)
and injected into the prompt as the open-pr "Input" — the engine never derives
which git branch is base/work.
Under a stacked grouping mode (per-phase / per-change) the host loop calls
runPrStep once per detected boundary, passing that group's boundary and its
resolved stacked base: group 0 targets the batch base branch, and group N (N ≥ 1)
targets group N-1's branch, so each PR's diff stays scoped to its own unit while
dependent code still compiles. The two policies that resolve "where are the
boundaries" (detectPrGroupBoundaries) and "what does each group stack on"
(selectStackedBases) remain the single home of those rules; runPrStep consumes
their output as data (PrStepContext.boundary + baseBranch/workBranch) and
never re-derives boundary or stacking rules inline. A whole-batch step carries no
boundary and behaves exactly as before.
- Surfaced and routed by
batch apply.batch applyis what selects a PR step:pickNextStepnames it as theprApplyTarget(a fourth kind alongsidechange | decompose | proof-of-work) once the batch is otherwisedone— every change done AND the terminal boundary proof recorded and passed — andbatchApplyCommandroutes that target toengine.runPrStep, persisting and rendering its outcome through the same paths a change step uses. Underwhole-batchthe target is the single boundary-less completion PR, gated in the CLI onprGrouping === 'whole-batch'and!hasJournaledPr(...). Under a stacked mode (per-phase/per-change)batch applysurfaces oneprtarget per detected group boundary, in boundary order: the selector derives the ordered boundaries from the manifest viadetectPrGroupBoundariesand returns the first boundary whose per-group key (prJournalKey(batch, boundary)) is not yet in the recorded-state set (openedGroupKeys), so each apply opens the next unopened group and a resumed loop opens every group exactly once. Anoff/unset batch and a fully-opened batch (whole-batch PR opened, or every group's key recorded) surface no target: the existing terminal "Nothing to do — all changes are done." output is unchanged. The gating inputs (the resolvedprGroupingmode, the whole-batch already-opened flag, and the per-groupopenedGroupKeysset — thechangekeys of the journal'sprcompletions, exactly whathasJournaledPrForGroupmatches) are resolved once at the command seam and passed to the purepickNextStepselector as data, which reads neither config nor the journal itself (instruction-fed-config). - Branches resolved by the CLI. For the whole-batch PR,
batch applyresolves the work branch (the current branch) and base branch (the repo's default branch via the remote HEAD, falling back tomainwith no remote) from git only — no forge CLI — and hands them torunPrStepasPrStepContextdata. For a stacked boundary it composes the two pure policies —detectPrGroupBoundariesorders the groups andselectStackedBasesmaps group 0's base to the repo base branch and group N's base to group N-1's own branch — and hands the fired group's resolvedbaseBranch/workBranch(the group's own branch, named from its stablegroupId) torunPrStepthe same way; the engine never derives which branch is base/work. Both seams (branches,groupBranch) are injectable so tests supply fake branch names without a real git repo. - Gated on an active
prGrouping. The step runs for any active grouping mode —whole-batch,per-phase, orper-change— resolved through the singleisPrGroupingActivepredicate. WithprGrouping: off(the default) or unset,runPrStepreturns anothing-readyresult and no PR agent is ever spawned — existing batches behave exactly as before. This engine precondition holds independent of any CLI wiring, so a directrunPrStepcaller is guarded even before per-boundary CLI surfacing lands. - Routed through the
prstage. The spawn agent is resolved viaresolveAgentForStage(settings.agent, 'pr')— a stage-map routesprto a specific agent (spawned with that agent's own/rct:open-prinvocation syntax), a scalaragentroutes every stage (includingpr) to it, and an unset/unmappedprfalls back toDEFAULT_AGENT. An unknown mapped agent is rejected (UnknownAgentError) before any spawn. - Per-group idempotent resume. Each group's PR-open outcome is recorded in
run-state under its own key —
pr:<batch>for a whole-batch step,pr:<batch>:<groupId>for a stacked phase/change group (prJournalKey(batch, boundary)). Only a successful open journals acompletionentry withtransition: 'pr'for that key, and the group-scoped rulehasJournaledPrForGroup(journal, key)— the single home of "this group's PR is open" — guards the spawn: a resumed run that sees the entry returnsnothing-readyand never double-opens the group, while distinct groups (distinct keys) are guarded independently, so a resumed loop opens every group exactly once. - Failures surface as reported step failures. A commit/push/PR-open failure (a
non-zero exit without a reported completion, or a reported blocker) maps through
the shared outcome path to a failed/blocked step and records a
blocker, NOT acompletion— sohasJournaledPrForGroupstays UNSET for that group and a subsequent run is free to retry it (other groups are unaffected). An unrenderable spawn locus (remote) throwsSkillLocusErrorand fails the step with an actionable message before any agent is spawned, the same bootstrap-failure contract as the change/decomposition paths.
The pr stage is a fourth routable agent stage alongside propose | apply | verify (see the agent setting); pr is a StepKind (for the
result/journal), never a per-change Transition, so it is never derived by
computeNextTransition and never keys a change's done-rule.
PR group boundary detection
Defined in src/core/batch/engine/boundary.ts (detectPrGroupBoundaries).
detectPrGroupBoundaries(batch, mode) is a pure function that maps the batch's
ordered phases/changes and the resolved prGrouping mode to the ordered list of
PR group boundaries — the single authoritative answer to "where are the PR groups
and what identifies each one?" It is a purely structural mapping over the batch
plan (the phases and, within each, its ordered change names); it reads no
filesystem, consults no run-state journal, and spawns no agent, which makes it
exhaustively unit-testable over tiny in-memory inputs.
Each mode maps as follows:
off→ no boundaries ([]) — no PR is ever opened.whole-batch→ exactly one boundary at batch completion, spanning every change across all phases in order; its identity is the batch name.per-phase→ one boundary per phase that has at least one change (empty phases are skipped); each is identified by its phase name, and its members are that phase's changes.per-change→ one boundary per change across all phases in order; each is identified by its change name.
A batch with no changes in any phase yields no boundaries under every mode. Every
boundary carries a stable group identity (batch, phase, or change name — each
unique within a batch) and a contiguous 0-based index ("group N"), assigned
sequentially over the produced boundaries so skipped empty phases leave no gap.
Each boundary also names its triggerChange — the group's last change, whose
completion a later consumer matches to decide when the boundary fires — while
the boundary set itself stays fixed by the plan, independent of run progress.
The engine's PR step consumes each boundary as data: the host loop
resolves the fired boundary (from detectPrGroupBoundaries) and its stacked base
(from selectStackedBases) and hands them to
runPrStep via PrStepContext.boundary + baseBranch/workBranch, which keys the
group's run-state entry (pr:<batch>:<groupId>) and injects the stacked base into
the /rct:open-pr payload. detectPrGroupBoundaries stays the single home of the
boundary rules; the engine reads its output and never re-derives boundaries inline.
Stacked PR base selection
Defined in src/core/batch/engine/stacked-base.ts (selectStackedBases).
Overview
Reading top-down: group 0's PR targets the batch base branch (main), and each
later group's PR targets the previous group's own branch, so the groups stack.
per-phase makes one group per completed phase; per-change makes one group per
change — the stacking rule is identical, only the group unit differs. The diagram
matches selectStackedBases exactly: group 0 → batchBaseBranch, group N →
branchForGroup(boundary N-1).
selectStackedBases(boundaries, batchBaseBranch, branchForGroup) is a pure
function that answers the next question after boundary detection: "what branch
does each PR group's PR target?" Given the ordered PrGroupBoundary[] (from
detectPrGroupBoundaries), the batch's base branch, and a branchForGroup
resolver that names each group's own branch, it returns one StackedBase per
boundary, in order:
- group 0 →
baseBranchis the batch base branch;headBranchisbranchForGroup(boundary0). - group N (N ≥ 1) →
baseBranchisbranchForGroup(boundary N-1)— the previous group's own branch;headBranchisbranchForGroup(boundary N). - an empty boundary list yields
[]— nothing to stack.
So the base chain for groups a, b, c with branches pr/<id> off main is
main -> pr/a -> pr/b: each stacked PR's diff stays scoped to its own unit while
dependent code still compiles. Each StackedBase carries its originating
boundary, so a consumer keeps the group's identity/triggerChange/member
changes alongside the resolved base without a second lookup. The mapping runs
purely over the array's ordering (not boundary.index), which keeps it correct
for any ordered input and — like detectPrGroupBoundaries — exhaustively
unit-testable over tiny in-memory inputs.
Branch naming is supplied, not baked in: the concrete group-branch scheme is
the caller's (engine's) concern, threaded in as the branchForGroup resolver, so
there is no git/gh/origin literal in the policy and no premature commitment
to a naming convention (instruction-fed-config, generalizable-defaults).
This policy is wired into batch apply: at genuine batch completion under a
stacked mode, runStackedPr composes the two pure policies — detectPrGroupBoundaries
orders the groups and selectStackedBases maps group 0's base to the batch base
branch and group N's base to group N-1's branch — then hands the fired boundary's
resolved base/head branch to the /rct:open-pr payload as Input. pickNextStep
surfaces the first group whose per-group run-state key (pr:<batch>:<groupId>) is
unrecorded, so each apply opens exactly the next unopened group in boundary order
and a resumed loop opens every group exactly once, idempotently per group.
Agent instructions
Defined in src/core/batch/engine/instructions.ts (buildAgentInstructions).
The spawned-agent prompt delegates each transition to the canonical ratchet
skill rather than re-authoring the lifecycle inline (delegated-lifecycle
standard: the engine orchestrates the lifecycle, it does not re-author it).
transitionGuidance no longer hand-builds the propose/apply/verify steps (no
"write files directly on disk", no inline ## Tasks recipe); instead it emits a
single instruction to invoke /rct:<transition> <change>, which loads
.ratchet/standards/ and authors/advances the change to its canonical definition
of done.
- Command id comes from the single-source
rctCommandIdForTransitionmap (shared with the skill-in-spawn-locus guarantee), so the invocation and the rendered command can never drift. - Invocation token is resolved from the configured spawn agent's command
adapter via
adapter.getInvocation(<command-id>)— claude/rct:<id>, cursor/gemini/codex/rct-<id>— never a hard-coded literal, because the syntax genuinely differs per agent (multi-agent-support). The surrounding prose names no coding agent. - Delegation is context-preserving. The invocation sits alongside the prompt's
existing top block — phase goal/success/proof-of-work and the per-change
Definition of done:line — plus any strategy guidance. It is never reduced to a bare, context-free skill call. - Caller guidance and resume answer ride along as invocation ARGUMENTS. The
caller's
-mguidance and any resolved resume answer/feedback are appended to the invocation byinvocationArguments, so the agent passes them to the skill as$ARGUMENTSrather than reading them from a detached prose block (delegated-lifecycle: "it hands that context to the skill as arguments"). The parts join with a single newline into one contiguous block glued to the invocation, distinct from the blank-line-separated sections around it. When both a-mmessage and a resume answer exist, both are injected (neither dropped); the answer/feedback no longer re-appears in the resume block, which keeps only the intent framing (the original question/proposal plus the incorporate-the-answer / revise-don't-restart directive). With no guidance and no resume context — the plainbatch applypath — the invocation stays the bare/rct:<transition> <change>with no trailing argument noise. - Argument injection stays agent-neutral. Only the trailing arguments are
appended; the invocation TOKEN still resolves from the configured spawn agent's
adapter (claude
/rct:<id>, cursor/gemini/codex/rct-<id>), so injecting a guidance argument never smuggles in another agent's syntax.
The decomposition spawn has its own prompt builder,
buildDecompositionInstructions, following the same delegation contract: it emits
/rct:decompose-phase <phase> (token resolved through the configured agent's
adapter) and injects the empty phase's goal/success/proof-of-work plus the prior
phases' shipped results as the delegation context — never an inline re-description
of the decomposition steps. A decomposition has no change, so its report channel
and journal key are the phase name (decompositionJournalKey).
This change adds no user-facing command, flag, or config key — it is internal
prompt-builder behavior — so README.md needs no edit.
Streaming
Defined in src/core/batch/engine/runtime/stream-json-renderer.ts.
When an adapter's emitsStreamJson capability is true, the engine routes each
stdout line through makeStreamJsonRenderer rather than printing it raw. The
renderer parses one-event-per-line NDJSON and writes polished output to the
engine's line printer. The gating is on the adapter capability flag, not on the
agent name, so any future adapter that sets emitsStreamJson: true reuses the
same renderer. The renderer is multi-schema, not agent-named: its dispatch
switch parses multiple event envelopes (claude's and opencode's) side by side,
gated solely on the capability flag.
Event handling:
Top-level type | Behavior |
|---|---|
system, rate_limit_event | Recognized control noise; silently skipped. |
stream_event | content_block_delta with text_delta → text streamed live and accumulated. |
assistant | text items printed as prose; tool_use items printed with glyph + name + target field. |
user | tool_result items printed (truncated to 200 chars / 3 lines; errors in red). |
result | Closing summary line with success/error, token counts, and USD cost. |
step_start | OpenCode control noise; silently skipped (mirrors system). |
text | OpenCode prose: part.text accumulated and streamed live, like claude's deltas. |
step_finish | OpenCode closing summary from part.reason/part.tokens/part.cost; an error reason is flagged. |
| Unknown or non-JSON | Raw line printed as fallback; renderer never throws. |
An opencode event whose top-level type is recognized but whose part.type is
unrecognized falls through to the raw-line fallback (no new failure mode).
The renderer never mutates the accumulated AgentSpawnResult.stdout; the raw
NDJSON transcript that mapSessionToOutcome reads is byte-identical with or
without rendering.
Agent permissions
Defined in src/core/batch/permissions-policy.ts (policy schema and types) and
src/core/batch/runtime/agent-permissions.ts (per-agent translator).
Policy shape
interface ResolvedPermissionsPolicy {
posture: PermissionPosture; // 'repo-sandboxed-permissive' | 'curated-allowlist' | 'full-autonomy'
allow: string[]; // tool-pattern allowlist (agent-neutral)
deny: string[]; // tool-pattern denylist (agent-neutral)
raw: Partial<Record<'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode', string[]>>;
}
The default posture is repo-sandboxed-permissive.
Postures
repo-sandboxed-permissive(default): edits and ordinary build/test shell commands run unprompted; the agent is scoped to the repo; a baseline denylist forbids destructive/exfiltrating operations.curated-allowlist: nothing runs unprompted except an explicitallowlist; the deny list still applies. Operators must include aBash(...)entry inallowor any shell step will stall headless.full-autonomy: all permission checks are bypassed.
Baseline deny patterns (best-effort damage reduction)
The following patterns are merged into the effective denylist for the sandboxed
and curated postures (not for full-autonomy). They block a handful of
destructively-shaped shell commands at the agent's own permission layer — this is
best-effort damage reduction, not containment (see the
threat model per locus above). Real containment is the
docker locus; the denylist catches accidental foot-guns on local:
Bash(rm -rf *)
Bash(sudo *)
Bash(* > /*)
Bash(curl * | sh)
Bash(curl * | bash)
Bash(wget * | sh)
Bash(wget * | bash)
Per-agent flag translation
resolvePermissionFlags(agentName, policy, repoRoot) returns a concrete argv
fragment appended to each adapter's base args. The translation is pure (no I/O).
claude:
| Posture | Flags emitted |
|---|---|
repo-sandboxed-permissive | --permission-mode acceptEdits --add-dir <repoRoot> --allowedTools Bash [<allow>] --disallowedTools <deny> |
curated-allowlist | --permission-mode default [--allowedTools <allow>] [--disallowedTools <deny>] |
full-autonomy | --dangerously-skip-permissions |
For repo-sandboxed-permissive, --allowedTools defaults to ['Bash'] when
the operator's allow list is empty. acceptEdits covers file edits only;
Bash must be explicitly allowed or headless shell calls are denied.
gemini:
| Posture | Flags emitted |
|---|---|
repo-sandboxed-permissive | --approval-mode auto_edit |
curated-allowlist | --approval-mode default |
full-autonomy | --yolo |
Known limitation: gemini's auto_edit covers file edits only and does not
unblock headless shell calls. An argv-only bounded shell allowance is not
available for gemini (the --allowed-tools flag is deprecated; the Policy Engine
is file-based). The sandboxed mapping stays auto_edit — it may prompt or stall
on shell steps in headless mode.
codex:
| Posture | Flags emitted |
|---|---|
repo-sandboxed-permissive | --sandbox workspace-write --ask-for-approval never |
curated-allowlist | --sandbox workspace-write --ask-for-approval on-request |
full-autonomy | --full-auto |
cursor:
| Posture | Flags emitted |
|---|---|
repo-sandboxed-permissive | (none — cursor's default per-action gating applies; a one-time warning is emitted) |
curated-allowlist | (none — same) |
full-autonomy | --force |
cursor's allow/deny is config-file only and cannot be injected via argv; the sandboxed and curated postures rely on cursor's built-in approval prompting.
opencode:
| Posture | Flags emitted |
|---|---|
repo-sandboxed-permissive | (none — opencode's default per-action gating applies; a one-time warning is emitted) |
curated-allowlist | (none — same) |
full-autonomy | --dangerously-skip-permissions |
opencode exposes a single permission knob, --dangerously-skip-permissions
("auto-approve permissions that are not explicitly denied"), and no argv-only
bounded auto-edit mode analogous to claude's acceptEdits or gemini's
auto_edit. Like cursor, the sandboxed/curated postures therefore rely on
opencode's own default per-action approval gating — strictly narrower than the
bypass, not silently equivalent to full autonomy.
Raw override escape hatch
The raw field carries per-agent argv fragments appended after the posture-derived
flags for that specific agent. Entries for other agents are ignored. An
unrecognized agent name receives no posture flags but honors its raw entry.
Batch config permissions feed-in
batch config permissions sets the permissions key in .ratchet/config.yaml
under the batch: section. The resolved policy from that config is merged with
the user/global scope and per-manifest scope before being injected into the spawn
request. See batch command.
Settings resolution
Agent, locus, and image resolve across four scopes in increasing precedence:
built-in default ← user/global ← project config (.ratchet/config.yaml batch:) ← per-change manifest
Scalar settings (including locus, agent, image, host, port,
authToken, insecure) are nearest-wins. Permissions use per-field merge
semantics: posture nearest-wins across the operator-owned scopes
(default/user/project); the manifest scope is narrow-only — it may only
LOWER the posture, never raise it above the operator-owned scopes' accumulated
value (a raise is clamped unless batch apply --allow-manifest-escalation is
passed); deny is the union of all scopes; allow is replaced by the nearest
defining scope, and each agent's raw entry is nearest-wins.
Built-in defaults:
| Setting | Default |
|---|---|
locus | local |
agent | claude (via DEFAULT_AGENT when settings name none) |
image | python:3.12 (DEFAULT_DOCKER_IMAGE, docker locus only) |
permissions.posture | repo-sandboxed-permissive |
For standalone (headless) steps the cascade is flag → project config → default
with no manifest scope. See Standalone settings and
batch command.
Requirements
- Agent CLI on PATH: one of
claude,codex,gemini,cursor, oropencodematching the configuredagent. See doctor, which probes each registered agent binary. - Python >= 3.10: required for the
localanddockerloci. Bootstrap probespython3,python,python3.12,python3.11,python3.10in order.uvis preferred for venv creation and package install; it falls back topython -m venv+ pip whenuvis not available. - Docker daemon: required for
locus: dockeronly. The bootstrap runs adocker infopre-flight before any other work; a non-zero result surfaces as an actionable error. - No local Python is required for
locus: remote; all Python runs on the remote server.
See doctor for the full runtime requirements check.