Skip to main content

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 buildSpawnRequestagent.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 command becomes cd <cwd>; <exports> cat <promptfile> | <agent argv>. The run-op shape ({op, id, command}) and sidecar.py are 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.
  • RemotebuildRemoteRunCommand emits <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.env is scoped, not the raw host env. Every spawn site builds the request env from scopeAgentEnv(process.env) (see Agent environment allowlist) overlaying RATCHET_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).

SourceExact namesPrefix patterns
Baseline process varsPATH, HOME, TMPDIR, LANG, TERM, SYSTEMROOT, COMSPEC, PATHEXT, USERPROFILE, TEMP, TMP, APPDATA, LOCALAPPDATA, PROGRAMDATALC_
ProxyHTTP_PROXY, HTTPS_PROXY, NO_PROXY, http_proxy, https_proxy, no_proxy
Forge authGH_TOKEN, GITHUB_TOKEN
Ratchet controlRATCHET_
Adapter passthroughthe 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:

AdapterenvPassthrough
claudeANTHROPIC_API_KEY, CLAUDE_CODE_*
codexOPENAI_API_KEY, CODEX_HOME
geminiGEMINI_API_KEY, GOOGLE_API_KEY
cursorCURSOR_API_KEY, CURSOR_*
opencodeOPENCODE_*

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

SiteFileOverlay
Change-transition spawnengine.ts (runStep)RATCHET_BATCH_NAME (when a batch is active)
Decompose spawnengine.ts (runDecompositionStep)RATCHET_BATCH_NAME
PR spawnengine.ts (runPrStep)RATCHET_BATCH_NAME
ReX sidecar bootstrapruntime/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 apply prints ⚠ agent overridden by RATCHET_BATCH_AGENT_CMD as the first line of the rendered step result; ratchet eval run prints the matching ⚠ agent overridden by RATCHET_EVAL_AGENT_CMD atop 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 --json carries agentOverride: true on the step-result JSON; eval run --json carries it at the top level. Absent when no override is active — byte-identical output for override-free runs.
  • via: env-override provenance. Every journal entry and eval run record produced under an active override is stamped via: "env-override":
    • the engine stamps the transition-outcome journal entry it appends;
    • batch report stamps every entry it appends from its own process env (the spawned stand-in inherits RATCHET_BATCH_AGENT_CMD, so a stub-reported completion/blocker/progress/needs-input is auditable even though the engine never sees that append);
    • eval run stamps via on the persisted run record. The field is optional and only ever 'env-override' today; readers ignore it, so override-free entries/runs carry no via and 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:

  1. The venv lives at $XDG_CACHE_HOME/ratchet/rex/venv (falling back to ~/.cache/ratchet/rex/venv when XDG_CACHE_HOME is unset). It never touches the user's global Python environment.
  2. 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 (wrong sweRexVersion or missing required extras) triggers a full rebuild after clearing the directory so no partial venv is mistaken for ready.
  3. The pinned swe-rex version is 1.4.0 (SWE_REX_VERSION). A version change forces a rebuild.
  4. uv is preferred for creating the venv and installing packages. When uv is not on PATH, bootstrap falls back to python -m venv and the venv's own pip.
  5. 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 pythonOverride skips the probe.

  6. After install, bootstrap verifies import swerex succeeds from the venv interpreter before writing the marker. For the docker locus it additionally verifies import swerex.deployment.docker.
  7. The venv's bin directory is prepended to PATH and VIRTUAL_ENV is set in the sidecar's environment.

Environment variables threaded to the sidecar:

VariableSet whenValue
REX_LOCUSalways'local' or 'docker'
REX_WORKDIRalwaysproject root (local) or /workspace (docker)
REX_IMAGEdocker onlyconfigured image, or DEFAULT_DOCKER_IMAGE (python:3.12)
REX_MOUNT_HOSTdocker onlyproject root (host path bind-mounted into the container)
REX_MOUNT_CONTAINERdocker only/workspace (in-container mount point)
REX_DOCKER_USERdocker only, when dockerUser is sethost uid:gid for docker run --user; when unset the sidecar resolves the current host uid:gid
REX_DOCKER_MEMORYdocker onlyconfigured dockerMemory, or DEFAULT_DOCKER_MEMORY (2g)
REX_DOCKER_PIDS_LIMITdocker onlyconfigured dockerPidsLimit, or DEFAULT_DOCKER_PIDS_LIMIT (512)
REX_DOCKER_CPUSdocker only, when dockerCpus is setconfigured dockerCpus (e.g. 1.5); no flag when unset
REX_DOCKER_NETWORKdocker onlyconfigured 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):

MessageEffect
{"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):

EventEmitted 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).

LocusReal isolationThreat model
localAdvisory — 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.
dockerPartial 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.
remoteServer'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 (the deny list merged for repo-sandboxed-permissive and curated-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 via network: none). The denylist exists to catch accidental foot-guns on the local locus, not to stop a determined agent.

localRexSidecarRuntime (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.

dockerRexSidecarRuntime 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:

  1. A docker info pre-flight runs before any venv work. A non-zero result throws RexBootstrapError immediately, so the run never hangs on SWE-ReX's own startup timeout.

  2. The venv must carry the docker extra, which installs aiohttp explicitly. (swe-rex 1.4.0 does not declare aiohttp in its package metadata, but swerex.deployment.docker imports it at runtime.) A local-only venv is rebuilt the first time the docker locus is requested.

  3. REX_WORKDIR is 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.

  4. REX_IMAGE is set to the configured image, or DEFAULT_DOCKER_IMAGE (python:3.12) when none is configured.

  5. Docker hardening knobs are threaded as REX_DOCKER_* env vars and spliced into docker run argv by the sidecar:

    KnobFlagDefault when unsetSource of truth
    dockerUser--usercurrent host uid:gid (resolved by the sidecar)REX_DOCKER_USER (Node); sidecar fallback resolves os.getuid():os.getgid()
    dockerMemory--memory2g (DEFAULT_DOCKER_MEMORY)config.ts / sidecar.py mirror
    dockerPidsLimit--pids-limit512 (DEFAULT_DOCKER_PIDS_LIMIT)config.ts / sidecar.py mirror
    dockerCpus--cpus(no flag — opt-in only)REX_DOCKER_CPUS only when set
    network--networkbridge (DEFAULT_DOCKER_NETWORK)config.ts / sidecar.py mirror

    The --user default 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 like image/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 host uid:gid (via --user), not as root.
  • Memory — capped at dockerMemory (default 2g) via --memory.
  • Process count — capped at dockerPidsLimit (default 512) 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: bridge means the container HAS outbound network access. To fully isolate, set network: none. A custom network name is also accepted (passed verbatim to docker 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.

remoteRexRemoteRuntime (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 to http.
  • 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 unless allowInsecure is set. The settings key is insecure (a boolean under the resolved batch: / remote scope); the engine maps insecure: true to the runtime option allowInsecure (rex-remote-runtime.ts, threaded at engine.ts). The two names refer to the same opt-in — the settings schema names it insecure, the runtime surface names it allowInsecure.

The remote runtime reproduces the sidecar's tail-poll streaming over REST:

  1. GET /is_alive — health check with a short per-request timeout (30 s default).
  2. POST /create_session — create the bash session.
  3. Write the prompt onto the server filesystem via POST /execute (mkdir + POST /write_file).
  4. POST /execute (non-blocking) — detach the agent command to a logfile + exit sentinel.
  5. POST /execute in a poll loop (300 ms default) — tail -c +<offset+1> to advance a byte cursor; emit stdout events as complete lines arrive.
  6. Read the exit sentinel. Drain final bytes, emit exit, then close the session and runtime (POST /close_session, POST /close).
  7. 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) BEFORE rm -rf of the run dir and the /close_session + /close calls, 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_MS wins 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.agentTimeoutMs from the per-change manifest, then the project config (.ratchet/config.yaml).
  • Otherwise the runtime's built-in DEFAULT_TIMEOUT_MS (600000 ms).

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 as set -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 via self.run_pidfile. The run_dir is threaded from the Node side (host runDir for local, the in-container equivalent for docker), falling back to the sidecar workdir when absent.
  • Remote (rex-remote-runtime.ts)buildRemoteRunCommand emits set -m; nohup … & echo $! ><runDir>/agent.pid inside its existing ( … ) subshell, so the detached agent's pid is recorded on the server.
  • Legacy in-process (agent.ts:makeRealSpawner) — spawns the agent with detached: true (POSIX) so the child is its own group leader; reapGroup targets the negative pid.

Teardown (group reap before close)

Teardown never just closes the session and leaves — it reaps the group first:

  1. SidecarteardownChild sends {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 side killGroup(SIGKILL) the sidecar's own process group if it has not exited. No SIGTERM is sent to the sidecar directly — that would kill it before it could reap the agent.
  2. Remoteteardown() kills the group (kill -TERM -- -$(cat agent.pid) → sleep 1 → kill -KILL -- -$(cat agent.pid)) BEFORE rm -rf of the run dir and the POST /close_session + POST /close calls.
  3. Legacy in-process — on overall timeout, reapGroup sends 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:

AgentCommandBase argsStream-JSON
claudeclaude binary-p --output-format stream-json --verbose --include-partial-messagesyes
codexcodex binaryexec -no
geminigemini binary-pno
cursorcursor binary-pno
opencodeopencode binaryrun --format jsonyes

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 into batch.yaml. The decomposition spawn routes through the decompose agent stage (so agent.decompose or a scalar agent selects which agent runs it), and the guarantee renders/verifies the decompose-phase command for that stage the same way before that spawn.
  • Group-scoped PR spawns (runPrStep) — delegate to /rct:open-pr to commit the prior stage agents' accumulated work and open one pull request per fired group boundary (the whole batch under whole-batch, or one stacked PR per phase / change under per-phase / per-change). The guarantee renders/verifies the open-pr command for the pr stage 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 kindrct 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 (getCommandContentsadapter.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):

AgentRendered 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) → throw SkillLocusError. local and docker are renderable (docker bind-mounts the project root, so a file rendered there is visible in the container).
  • Render/write failure → throw SkillLocusError naming 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 apply is what selects a PR step: pickNextStep names it as the pr ApplyTarget (a fourth kind alongside change | decompose | proof-of-work) once the batch is otherwise done — every change done AND the terminal boundary proof recorded and passed — and batchApplyCommand routes that target to engine.runPrStep, persisting and rendering its outcome through the same paths a change step uses. Under whole-batch the target is the single boundary-less completion PR, gated in the CLI on prGrouping === 'whole-batch' and !hasJournaledPr(...). Under a stacked mode (per-phase/per-change) batch apply surfaces one pr target per detected group boundary, in boundary order: the selector derives the ordered boundaries from the manifest via detectPrGroupBoundaries and 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. An off/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 resolved prGrouping mode, the whole-batch already-opened flag, and the per-group openedGroupKeys set — the change keys of the journal's pr completions, exactly what hasJournaledPrForGroup matches) are resolved once at the command seam and passed to the pure pickNextStep selector as data, which reads neither config nor the journal itself (instruction-fed-config).
  • Branches resolved by the CLI. For the whole-batch PR, batch apply resolves the work branch (the current branch) and base branch (the repo's default branch via the remote HEAD, falling back to main with no remote) from git only — no forge CLI — and hands them to runPrStep as PrStepContext data. For a stacked boundary it composes the two pure policies — detectPrGroupBoundaries orders the groups and selectStackedBases maps 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 resolved baseBranch/workBranch (the group's own branch, named from its stable groupId) to runPrStep the 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, or per-change — resolved through the single isPrGroupingActive predicate. With prGrouping: off (the default) or unset, runPrStep returns a nothing-ready result and no PR agent is ever spawned — existing batches behave exactly as before. This engine precondition holds independent of any CLI wiring, so a direct runPrStep caller is guarded even before per-boundary CLI surfacing lands.
  • Routed through the pr stage. The spawn agent is resolved via resolveAgentForStage(settings.agent, 'pr') — a stage-map routes pr to a specific agent (spawned with that agent's own /rct:open-pr invocation syntax), a scalar agent routes every stage (including pr) to it, and an unset/unmapped pr falls back to DEFAULT_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 keypr:<batch> for a whole-batch step, pr:<batch>:<groupId> for a stacked phase/change group (prJournalKey(batch, boundary)). Only a successful open journals a completion entry with transition: 'pr' for that key, and the group-scoped rule hasJournaledPrForGroup(journal, key) — the single home of "this group's PR is open" — guards the spawn: a resumed run that sees the entry returns nothing-ready and 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 a completion — so hasJournaledPrForGroup stays UNSET for that group and a subsequent run is free to retry it (other groups are unaffected). An unrenderable spawn locus (remote) throws SkillLocusError and 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 → baseBranch is the batch base branch; headBranch is branchForGroup(boundary0).
  • group N (N ≥ 1) → baseBranch is branchForGroup(boundary N-1) — the previous group's own branch; headBranch is branchForGroup(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 rctCommandIdForTransition map (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 -m guidance and any resolved resume answer/feedback are appended to the invocation by invocationArguments, so the agent passes them to the skill as $ARGUMENTS rather 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 -m message 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 plain batch apply path — 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 typeBehavior
system, rate_limit_eventRecognized control noise; silently skipped.
stream_eventcontent_block_delta with text_delta → text streamed live and accumulated.
assistanttext items printed as prose; tool_use items printed with glyph + name + target field.
usertool_result items printed (truncated to 200 chars / 3 lines; errors in red).
resultClosing summary line with success/error, token counts, and USD cost.
step_startOpenCode control noise; silently skipped (mirrors system).
textOpenCode prose: part.text accumulated and streamed live, like claude's deltas.
step_finishOpenCode closing summary from part.reason/part.tokens/part.cost; an error reason is flagged.
Unknown or non-JSONRaw 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 explicit allow list; the deny list still applies. Operators must include a Bash(...) entry in allow or 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:

PostureFlags 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:

PostureFlags 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:

PostureFlags 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:

PostureFlags 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:

PostureFlags 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:

SettingDefault
locuslocal
agentclaude (via DEFAULT_AGENT when settings name none)
imagepython:3.12 (DEFAULT_DOCKER_IMAGE, docker locus only)
permissions.posturerepo-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, or opencode matching the configured agent. See doctor, which probes each registered agent binary.
  • Python >= 3.10: required for the local and docker loci. Bootstrap probes python3, python, python3.12, python3.11, python3.10 in order. uv is preferred for venv creation and package install; it falls back to python -m venv + pip when uv is not available.
  • Docker daemon: required for locus: docker only. The bootstrap runs a docker info pre-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.