Releases: chapmanjw/rutherford-mcp-server
Release list
v3.2.0
Added
-
direct_workspace_mutationondelegate, behind an operator opt-in — awrite/yoloagent can
editworking_diritself, with live terminal access there, instead of the isolated worktree. This is for
work whose product is the effect on the tree rather than a diff: installing dependencies, running local
tooling, letting an agent see its own side effects. Contributed by
@Artemonim in [#21].It gives up everything the sandbox provides — the clobber and concurrent-edit guards, symlink containment,
the committed-HEADstarting point, and the diff itself — so the run leaves no record of what it changed
and a failure may leave a partial one. The party asking for that is also the party least able to judge it,
so a request never suffices on its own. An operator has to enable
allow_direct_workspace_mutationin config, and the directory has to be on thetrusted_workspaces
allowlist: a per-calltrust_workspace=truedeliberately does NOT qualify, because a caller that can set
it could otherwise authorise its own unsandboxed writes and the allowlist would decide nothing.
working_dirmust be named explicitly rather than inherited from the server's own directory, the call
must not be nested inside another delegation, andproposecannot use it at all.Each admitted run logs before launch and again on completion, and the result carries
direct_mutation=trueso a reader can tell the absent diff means "never captured" rather than "nothing
was written". That record is best effort: it goes through the ordinary structured logger, whose
stderr sink is asynchronous so a host that never drains its pipe cannot freeze the event loop, which
means the record can be dropped under log saturation or lost if the process dies before it is written.
docs/security.mdsays so plainly and points an operator who needs a durable trail at collecting stderr
or enabling persistence, both of which live outside this process. Refusing to launch unless the write was
confirmed was built and removed; the reasoning is recorded indocs/security.mdrather than the code.The nesting condition is documented as defence in depth rather than a boundary. Depth crosses the process
boundary in an environment variable, and anything able to spawn a nested Rutherford controls that child's
environment, so it stops an accident rather than a hostile agent — which, perdocs/security.md, gains
nothing here it did not already have, since awrite/yoloagent was never OS-jailed.
Changed
-
FastMCP is bounded at the major, and the dependency set users actually resolve is now tested. The
pin wasfastmcp>=3.3with no ceiling. CI installs withuv sync --locked, but the lock is not shipped
and the published install instructions carry no constraint, so a freshuvx rutherford-mcp-server
resolved fastmcp 4.0.3 while the lock held 3.3.1 — every release so far ran on a dependency major its
own CI never executed. It worked, with one observed regression: under 4.0.3 a tool error writes a
rich-formatted, non-JSON line to stderr, breaking the one-JSON-object-per-line contract the structured
logger maintains deliberately. The forward risk is larger, because the server calls
mcp.run(transport=…, show_banner=…)with keywords a major is free to rename — a failure that lands at
boot, on every install, with a green build.The bound claims only the major that has been run, and the lock now matches what a fresh resolve picks,
so the two are no longer describing different software. A new CI job resolves unlocked and boots the
built wheel over stdio, which is the only check here that exercises what ships. -
The ACP SDK pin moves to 0.12.1, and Dependabot now tracks it through the
uvecosystem. The
release is a much larger change than its patch number suggests: it deletes the pluggable dispatcher,
queue and state-store layer outright and drops four keyword arguments fromConnection.__init__.
None of that reaches this package, which drives the SDK throughspawn_agent_processrather than
assembling a connection by hand, so no removed symbol is named here.What made the bump acceptable is that
_deserialize.pyis byte-identical to 0.12.0 — the
salvage-instead-of-reject behaviour that is the real hazard of this dependency, and the reason the pin
exists, is unchanged, so the guards written against it still hold.Connection.closealso got safer:
it rejects pending requests first and moves the task shutdown into afinally, while still latching on
an already-closed flag, which is the property the non-cancellable teardown stage depends on. The
regenerated schema is the part to watch next time — fouridfields became required, the content-block
and config-option unions gained discriminators, and several openstrfields narrowed toLiteral
unions, including the config-option category that the model and effort channels read.The Dependabot ecosystem changes from
piptouvbecause thepipecosystem updates the manifest
without regeneratinguv.lock, and CI installs with--locked. Every dependency PR therefore failed
all nine matrix cells on a stale lockfile rather than on anything about the dependency. -
The ACP SDK is now pinned to 0.12, and read as an untrusted source rather than a validating one. The
0.11 pin was raised after checking what actually changed: the protocol version is unchanged, both model
channels are intact, and one unused public name went away. What did change is deserialization, and it
changes what the library promises rather than what it exposes. A field the SDK cannot parse is no longer
rejected — it is salvaged into a raw dictionary and returned through an attribute still annotated as a
model — and an unparseable item in a list is now skipped so the rest of the list can parse, where the whole
response used to fail.Both behaviours are reasonable for a protocol library and neither is announced at a call site, which is the
problem: they silently retire validation this client was leaning on. Nothing here reads an agent-controlled
response field off its annotation any more. The pin is one exact release rather than a range or a minor
wildcard, because the lock file is not shipped and the published install command carries no constraint of
its own, so this is the only bound that reaches a user. A minor wildcard would not have helped:==0.12.*
and<0.13admit the same unreleased 0.12.z patches, and the lesson of this very bump is that a
compatible-looking release can retire validation without touching a signature. Only the release actually
run through the malformed-payload suite is claimed.
Fixed
-
An agent's launch path now resolves to its real on-disk filename case.
shutil.whichnever reads the
directory entry: it returns the caller's own spelling joined to the directory, plus — on Windows — each
PATHEXTentry appended verbatim. An uppercasePATHEXTtherefore nameskiro-cli.EXEfor a file whose
dirent iskiro-cli.exe. Windows opens either spelling, so the process starts and nothing looks wrong;
but a launcher shim that looks its ownargv[0]basename up in a case-sensitive table finds no entry,
prints one line, and exits before reading a byte of stdin. Every seat behind such a shim failed in under
0.15s, andconnect_onlyfailed identically, because the death is at spawn and never reachesinitialize.The normalization runs on every platform rather than under a Windows guard. The
PATHEXTmechanism is
Windows-only but the defect is not: macOS ships a case-insensitive filesystem by default, wherewhich
likewise returns the caller's spelling for a differently-spelled dirent andexecvepasses it through
unchanged. On a case-sensitive filesystem the exact-match branch returns the input untouched, so it is
self-neutralizing there. It deliberately does not usePath.resolve()/realpath, which would also
follow links and pin a version-managednodeshim to one concrete install directory, and it cannot use an
exists()probe, which is case-insensitive on exactly the platforms carrying the bug. Where two entries
differ only in case and neither matches exactly, the input is returned rather than guessing at a different
binary. The same normalization is applied to thenodefallback inside npm-shim resolution, which was a
secondwhichcall with the same exposure. -
An agent's stderr is captured and a bounded excerpt included in handshake failure details. It was previously
discarded, so a child that explained itself precisely and died surfaced only as "Connection lost" — a
description of the socket, not of the cause — and diagnosing one meant reproducing it by hand outside
Rutherford. The pipe is owned by Rutherford and drained continuously from spawn to EOF, which is what makes
it safe: inheriting the host's stderr is what once let an undrained pipe wedge the MCP host, and discarding
it was the previous fix. Retention is head-bounded, because the failure this exists to explain prints its
one useful line first, and draining continues past the cap so the child can never block on a write. The
text is agent-authored, so it is stripped of ANSI/OSC escape sequences and control characters — which can
retitle a terminal, forge a hyperlink, or write the clipboard — then masked for credential shapes, then
capped by line and byte count and fenced, so where Rutherford's own words stop is unambiguous. It is
attached only where a process actually existed;ACP_SPAWN_FAILEDmeans the spawn itself failed, so there
is no child and never a tail.The masking exists because the subprocess inherits a credential-bearing environment, so an agent that
prints a token on the way out would otherwise put it in a result the caller reads and a durable job keeps....
v3.1.0
Added
trust/untrustCLI for the global workspace allowlist — from a repo root,
python -m rutherford trustregisters (oruntrustremoves) the current directory in the platform
globaltrusted_workspacesallowlist, sowrite/yolodelegations pass the trusted-workspace gate
without a per-calltrust_workspace=true. Takes an optional path argument;trust --listprints the
global list. Creates the globalconfig.tomlwhen missing, preserves unrelated keys and comments, and
refuses to run against a config that is already malformed. Rutherford reads config once at server start,
so restart or reconnect the server after atrustfor it to take effect. Contributed by
@Artemonim in #12.- The allowlist writer validates before it writes. The rewritten
config.tomlis rendered, parsed, and
round-trip-checked in memory and only then swapped into place with an atomic replace, so a path that
cannot be represented in TOML is refused with the existing config untouched rather than truncated. The
assignment scanner is string- and comment-aware, so a[or]inside a trusted path can no longer walk
past the end of the array and drop the[agents.*]tables below it. Unrelated keys and comments are kept
as written, while the block's own managed header is rewritten in place instead of accumulating a copy per
edit. Path quoting goes through the one shared hardened quoter (io/tomltext.py), and the file mode is
carried across the replace so an owner-only config does not widen to the umask default.
Fixed
setupcould write aconfig.tomlit would then refuse to load. Its TOML quoter escaped only
backslashes and double quotes, but on Linux and macOS a control character is a legal filename byte, so
runningsetup --write --trust-workspacefrom a directory holding one emitted an unparseable file --
and becausesetupnever clobbers, it could not repair the file it had just written. Quoting is now a
single hardened implementation (io/tomltext.py) shared by every writer, escaping the full control
range and refusing outright a path with no TOML representation at all, before anything is opened.
Changed
discover's registry-directed-execution guard covers program runners, not just interpreters. It
previously refused to launch an agent resolved to a shell or language runtime, but not topip,
cargo,go,git,docker,make,gh,kubectl,curlorxargs-- each of which executes
attacker-chosen work from its own arguments as directly assh -cdoes, and those arguments come from
the registry. All are now matched by the same leading-family classifier, which leaves longer names
alone (goose,ghostandatlasare unaffected). The guard is a denylist and its docstrings now say
so: it is defense in depth, and a name it does not match is unrecognized rather than vouched for.- The ACP registry cache is written only after the response parses. It was previously persisted
before validation, so a single malformed or hostile body became the fallback replayed on every later
network failure. A bad response now fails once and leaves a previously good cache intact.
Security
- Dependency advisories closed in the development lockfile (
cryptography,mcp,
pydantic-settings,python-multipart,starlette). Reported severity overstates the exposure here:
these are HTTP-server-stack advisories reached throughfastmcp's transitive dependencies, and
Rutherford serves over stdio, so none is reachable in a default deployment.uv.lockships in neither
the wheel nor the sdist, so this affects contributors and CI rather than installed users. - CI workflows pin an explicit
permissions: contents: readceiling. The repository default is
already read-only, so nothing changes today; the block keeps a later settings change, or a job added to
those files, from silently gaining write. The code-review workflow now also skips cleanly on pull
requests from forks, which never receive repository secrets and so could only ever fail.
Documentation
- The trusted-workspace allowlist is documented end to end —
docs/security.mdcovers the
trust/untrustcommands, the platform global config path, and the fact that a project-local
trusted_workspacesreplaces rather than unions the global list at load time (previously undocumented
anywhere).docs/troubleshooting.mdpointsWORKSPACE_NOT_TRUSTEDat the one-shot CLI, andREADME.md
anddocs/configuration.mdfollow.
Thanks to @Artemonim for the trusted-workspace CLI contribution in #12.
v3.0.7
Changed
- Migrated to
agent-client-protocol0.11 (pinned>=0.11,<0.12). ACP 0.11 removed model channel 1
(session.models/SessionModelState/ModelInfo/session/set_model) outright. Rutherford now reads
the legacy channel defensively (a config-only 0.11 response no longerAttributeErrors at session open) and
selects models through the survivingconfigOptionschannel and, for launch-flag agents, the process argv.
The ACP client conforms to the 0.11Clientprotocol — it adds the elicitation callbacks
(create_elicitationis declined, since Rutherford drives agents headless) and matches the reordered
filesystem / terminal / permission signatures. The upper cap is load-bearing: it keeps a future breaking
minor from resolving into a runtime break rather than an install-time error. - Provenance is stricter.
DelegationResultnow tracksrequested_model(the pre-effort request) versus
selected_model(the model an in-session ACP selection actually confirmed), andprovenance.confirmedis
Trueonly after a verified in-session selection — never a config echo or a launch-argv intent.
provenance.modelremains the effective model that ran, so cross-model diversity and the correlation
discount keep their lineage key.
Added
- Cursor model selection via a launch
--modelflag (newAgentDescriptor.model_launch_flag). Cursor
applies its model from the process argv rather than an in-session ACP call, including effort-encoded
compound ids (…[effort=high,fast=false]), and inherits the flag on a config clone that reuses the built-in
launch command. Launch-flag selection is validated advisorily and never blocks a turn on a missing ACP
advertisement (the model is on the argv regardless). Contributed by
@Artemonim in #10. capabilitiesreports static per-agent model metadata —default_model,fallback_model,
model_selection(launch_argvfor launch-flag agents, elsein_session), andeffort_capable— without
spawning the agent; usedoctor(connect_only=true)for live advertised model ids.
Fixed
- An unconfirmable requested model fails loudly instead of silently running the wrong one. When a caller
names a model (or effort rewrites one) that the agent advertises on no ACP channel, the turn fails
MODEL_UNAVAILABLErather than quietly falling back to the agent's default. A descriptor default the agent
does not advertise — for example a Bedrock/Vertex provider id applied via an injectedANTHROPIC_MODEL,
never on an ACP channel — remains a soft-skip, so a Bedrock/Vertex Claude Code seat is unaffected. - A model-selection failure can no longer leak the spawned agent process. The session tears the agent down
before a post-handshakeMODEL_UNAVAILABLEpropagates, so a rejected model does not orphan a process tree.
Thanks to @Artemonim for the Cursor ACP model-routing contribution in #10.
v3.0.6
Added
- New built-in agent:
fast_agent(evalstate's fast-agent, Apache-2.0), run as its own ACP server via
uvx fast-agent-acp==0.8.3— bringing the built-in roster to 20. The version is pinned (not@latest) so
the built-in launches the exact release whose ACP handshake was verified rather than a moving remote spec;
override with[agents.fast_agent] command = [...]to track a newer release. It is bring-your-own-model /
multi-provider (provider=None): a turn needs a provider key in the environment (ANTHROPIC_API_KEY/
OPENAI_API_KEY/ ...) or afast-agent.secrets.yaml, which the agent advertises as its ACPauthMethod.
ACP conformance is verified: it spawns, handshakes (agentInfofast-agent-acpv0.8.3,protocolVersion
1), and reaches a turn that cleanly reportsAuthentication requiredwith no key. Likekimi/openhands
it is a conformance-verified seat whose full answering turn is gated on the user's own provider key.
Fixed
- A config clone of an effort-capable built-in now keeps its reasoning-effort tier. Effort is a
capability of the launched ACP adapter, not the agent id, but the per-call effort dispatch keyed on the
agent id alone — so abase=clone (or a localbackend=clone) ofcodex/claude_code/cursor/
cline/kiro/juniegot a new id, fell through the dispatch table, and had its requested tier
silently dropped to a no-op (effort_appliednull) across every channel (themodel[effort]model-id, the
--thinking/--effortlaunch flag, theJUNIE_EFFORTenv, and theeffort/reasoning_effortconfig
options). The descriptor now records the built-in whose knob it inherits (AgentDescriptor.effort_base,
stamped when a clone reuses a built-in's launch command) and effort dispatches oneffort_base or id, so a
clone resolves through the adapter it actually launches. Built-ins are unaffected (effort_baseisNone,
resolving by id); a clone that supplies its own rawcommandstays an honest no-op by design (arbitrary
argv — the lineage is never inferred fromcommand[0]). Non-breaking; no config-schema change. Thanks to
Tony Stone (@Tasktivity) for reporting and fixing this in #7.
v3.0.5
Added
doctorremediation hint for Claude Code on AWS Bedrock / Google Vertex / enterprise wrappers. When a
Claude Code seat's turn is rejected for its model id (400 The provided model identifier is invalid) and a
Bedrock/Vertex indicator is present, the conformance report carries aremediation_hintdescribing the
per-agent[agents.<id>.env]fix — pinning a valid provider model id (withANTHROPIC_CUSTOM_MODEL_OPTION,
which survives an enterprise wrapper that rewritessettings.jsonand an enforced model allowlist).
doctorstays read-only; the hint is advisory text, gated to the Claude Code adapter seat.setupdetects
a Bedrock/Vertex host and scaffolds the commented[agents.claude_code.env]block into the starter config.- Docs:
docs/bedrock.md— "Claude Code on Bedrock / enterprise wrappers": the allowlist-rewrite
mechanism, the approaches that do not work, the working env-injection fix, and the
ANTHROPIC_CUSTOM_MODEL_OPTIONexemption.[agents.<id>.env]is now documented first-class in
docs/configuration.md, anddocs/troubleshooting.mdgains amodel_unavailableentry.
Fixed
- Hardened a flaky concurrency test.
test_semaphore_serializes_a_wide_paneldropped itsserial > 1.5x parallelratio assertion — a loaded CI runner's fixed spawn overhead adds to both the serial and parallel
runs and compresses the ratio toward 1, which flaked on a busy Windows / Python 3.11 cell. It now asserts
only the spawn-overhead-invariant absolute serialization gap (serial - parallel > 0.2s), which is the
sound measure (the overhead cancels in the difference). No production code changed.
v3.0.4
Fixed
- Claude Code now drives on AWS Bedrock / Google Vertex. When the host has
CLAUDE_CODE_USE_BEDROCK(or
CLAUDE_CODE_USE_VERTEX) set, theclaude-agent-acpadapter would fall back to the bare cloud alias
claude-opus-4-8, which the provider rejects (400 The provided model identifier is invalid) — so
delegate / consensus / doctor turns failed even though the seat was reachable. Rutherford now resolves a
valid provider model id and injects it asANTHROPIC_MODEL(plusANTHROPIC_SMALL_FAST_MODELwhen
available) into the adapter's environment, so the SDK uses the real inference-profile id instead of the
rejected alias. The id is resolved, in order, from an already-setANTHROPIC_MODEL, a[agents.claude_code] modelpinned to a raw provider id,ANTHROPIC_DEFAULT_OPUS_MODEL, then theenvblock of the host's
~/.claude/settings.jsonand<cwd>/.claude/settings.json(ANTHROPIC_MODELthen
ANTHROPIC_DEFAULT_OPUS_MODEL). It is gated to the Claude Code adapter seat and to a Bedrock/Vertex host, so
a normal API-key Claude Code and every other agent are untouched, and it never overrides an
already-configured model. Works with zero Rutherford config when the id lives insettings.json. doctorrecognizes the Bedrock "invalid model identifier" rejection. The model-unavailable classifier
now matches "model identifier is invalid" / "provided model identifier", so a provider model rejection is
reported asmodel_unavailable(the connection is healthy; the model/provider config is wrong) rather than
a genericerror.
v3.0.3
Fixed
doctorno longer reports Claude Code (or any agent) as broken just because its model id is not a plain
cloud id. When an agent is configured for a non-cloud provider -- AWS Bedrock or Vertex, where the model id
is e.g.global.anthropic.claude-opus-4-8[1m]rather thanclaude-opus-4-8-- a probe turn that fails
because the provider rejected the model is now reported with a newmodel_unavailablestatus (spawn +
handshake succeeded; only the model/provider config is wrong) instead of a genericerror. The detail points
at the model/provider config to fix (the Bedrock/Vertex model id orANTHROPIC_MODEL), so a recognizable
model rejection never reads as a broken agent.- Model selection now reads BOTH ACP model channels. Rutherford previously honored a requested model only
throughsession.models(SessionModelState). Claude Code'sclaude-agent-acpadapter advertises its
selectable models on the OTHER channel -- asession.configOptionsselect option whosecategoryis
model(itssession.modelsis empty) -- so a model could never be selected for it anddoctor connect_onlymisleadingly reported an empty model list.session/set_model(channel 1) and
session/set_config_optionon a "model" option (channel 2) are both supported now, andavailable_models
reports the union of the two (SessionModelState ids first). As before, a model is sent only when the agent
advertised that exact value, so a Bedrock/Vertex harness is left on its provider's own configured model
rather than handed a rejected cloud id.
v3.0.2
Added
- Reasoning effort now works in panel configs for
codex,claude_code, andkiro, and can be pinned per
seat. A panel seat (and aTarget) takes anefforttier, so one voice can run atxhighwhile another
runs athigh; a per-seat tier overrides the call-leveleffort, which overrides the per-agent / global
config default. A newmaxtier joins the scale (claude_codeandkiroreach it;codexandcursor
clamp it toxhigh). - Effort is delivered over ACP through each agent's real, self-described knob.
codexandclaude_code
expose effort as an ACP config option (reasoning_effort/effort), so Rutherford reads the agent's
advertised option at session open, clamps the requested tier to the values it actually offers, and sets it
viasession/set_config_option-- coveringclaude_code(previously a silent no-op) and acodexseat
with no pinned model.kirotakes the--effortlaunch flag;codexwith a pinned model keeps encoding
the tier in the model id (model[effort]). An agent that advertises no effort knob is an honest no-op
(effort_appliedstays null), never a false claim.
Fixed
- Windows: an agent whose npm launcher resolved to its extensionless bin failed to start (
WinError 193
-- e.g.codex-acp/claude-agent-acpwhenshutil.whichreturned the Unix shell-script bin that shadows
the.cmd/.ps1siblings onPATHEXTresolution).prepare_argvnow resolves the sibling shim, so the
agent launches with clean JSON-RPC stdio instead of reportingnot_installed.
v3.0.1
Added
doctorandsetuprecognize a missing npm ACP adapter shim and offer to install it. A few agents
launch a SEPARATE npm adapter that fronts an underlying CLI --codex->codex-acp,claude_code->
claude-agent-acp,pi->pi-acp. When that CLI is installed but the adapter shim is not,doctorno
longer reports a barenot_installed: it adds aninstall_hintwith the exactnpm i -g <package>command.
setuplists every such gap underadapters.installable, andsetup install_adapters=trueruns the
install for each (an explicit, opt-in machine change; off by default). The install argv is built only from a
curated package constant, never caller input. Covers codex / claude_code / pi today; the mechanism is
generic (anAgentDescriptordeclares itsunderlying_cli+adapter_package).
Fixed
- Restored the PyPI downloads badge as the first badge in the README badge row.
v3.0.0
A ground-up, ACP-native rewrite. Rutherford is now the Agent Client Protocol
client and every coding agent is an ACP server it spawns and drives through a real
initialize / session/new / session/prompt exchange, so the protocol negotiates output, system prompts,
file context, permissions, and resume, and there is no per-agent output parser to maintain. The persistent
session is the foundation a debate runs on (one session per voice across rounds, sending only the delta).
Breaking. The entire v2 subprocess-adapter architecture is gone: no ProcessRunner, no
build_invocation / parse_output, no adapters/ package, no hand-written code adapter per CLI. Adding an
agent is now a config-driven AgentDescriptor ([agents.<id>]) or a built-in descriptor, never an adapter
(see docs/adding-an-agent.md). The built-in roster is 19 ACP-native agents; a few v2 CLIs without a working
headless ACP mode are not carried over and can be added back via discover or config once their ACP support
lands. A write / yolo / propose delegation now runs inside an isolated git-worktree (or copy) sandbox
with only a reviewed diff applied back; consensus and debate are read-only deliberation and refuse a
mutating mode at the service boundary.
Added
grokbuilt-in agent (19 total) + a handshake-only connection check.grokis xAI's Grok CLI
(grok agent stdio, providerxai), ACP-native with--model/--reasoning-effortknobs.
Connection-verified live: Rutherford spawns it, completes the ACP handshake, opens a session, and reads its
advertised models (grok-build,grok-composer-2.5-fast) — proving it can communicate with and configure
Grok. A completed turn additionally needs a SuperGrok subscription; without one the model call returns
403 SuperGrok Heavy subscription required. To make "reachable but not entitled" legible,doctorgains a
connect_onlyoption (doctor connect_only=true) backed by a newprobe_connectionprimitive: it does
the spawn + handshake +new_sessiononly (no prompt) and reportsreachable/handshake_failed/
not_installedplus each agent's advertised models — so an agent that connects but can't complete a turn
for a reason outside ACP (auth / entitlement / quota) shows asreachable, not a turnerror. (Grok's
headless handshake auth can transiently fail "Authentication required" under rapid back-to-back spawns — an
xAI auth-refresh race, not a Rutherford fault; the live test retries it.)discover: registry-driven detection of installed ACP agents (a new tool +python -m rutherford discoverCLI). Fetches the community ACP agent registry
(cached at~/.rutherford/acp-registry.jsonfor offline reuse; the CDN needs a real User-Agent), detects
which registry agents are ALREADY installed on this machine, probes the ones it finds with a real
read-only ACP round trip, and proposes a reviewable[agents.<id>]config block for every new agent that
drives. Detection is detect-only: it scans PATH plus curated install dirs (~/.local/bin,
~/.cargo/bin, and every~/.<vendor>/bin, one subdir deep — which is how it finds a custom-path install
like Qoder at~/.qoder/bin/qodercli/) and never downloads or runsnpx. A registry id that aliases a
built-in (e.g.codex-acp→codex,mistral-vibe→vibe) is recognized as already-in-roster, so it is
never proposed as a duplicate.write=true(CLI--write) appends the proposal to the project config the
loader actually reads (--globalfor the global one), creating the file if needed and never overwriting an
existing section;probe=false(--no-probe) returns the raw detection without spawning anything. Safety
posture (hardened over an adversarial review): probing only ever spawns a resolved agent binary, never a
shell/interpreter — a structural family classifier refusespowershell/python/node/Rand their
versioned,-dbg/-preview,.cmd-shim, andpythonw-variant forms, so a tampered registry cannot get
code-bearing args executed; the written config is TOML-injection-safe (a registry id is only kept if it is a
safe bare key, comment text and command args are fully escaped, and a write into a malformed config is
refused rather than risked). Use it to adopt any ACP agent or bridge Rutherford does not ship as a built-in.- Two more built-in agents (18 total):
geminiandqoder.geminiis Google's official Gemini CLI
(gemini --acp, providergoogle) — live-verified driving over ACP (status=ok, ~2.2s), which supersedes
the earlier "headless ACP known-issue" note (fixed by Gemini CLI 0.46.0); it adds a Google/Gemini voice to
the crew.qoderis Qoder AI'sqodercli(qodercli --acp; the--acpflag is real but hidden from
--help, like Cursor'sacp) — live-verified (status=ok, ~2.9s). Qoder AI's installer drops the binary at
~/.qoder/bin/qodercli/rather than on PATH, so on such a machine point[agents.qoder] commandat the
full path (or add the dir to PATH). delegatecan resume a prior agent session via asession_idparameter (v2 parity). Pass the
session_idfrom an earlier delegate result and the agent reloads that conversation over ACP
(session/load) instead of opening a fresh one (session/new), so a follow-up turn continues where the
last left off. It is gated on the agent advertising the ACPloadSessioncapability atinitialize; a
resume against an agent that does not persist its own sessions fails cleanly withRESUME_FAILEDrather
than silently starting fresh (wiring the previously-unreachable error code). The resume restores the
conversation, not the filesystem — awrite/yoloresume still runs in a fresh isolated sandbox. The
DelegationRequest.session_idfield existed but was inert; it is now threaded tool → service →
run_acp_turn→ACPSession.- Local-model support for opencode on both Ollama and LM Studio (
[agents.<id>] base="opencode" backend="ollama"|"lmstudio" model=...). opencode is configured entirely through one inline-JSON
environment variable (OPENCODE_CONFIG_CONTENT) that declares an@ai-sdk/openai-compatibleprovider
pointed at the runtime's/v1endpoint, so there is no config file on disk — one source of truth in
roster._opencode_openai. Vetted live (2026-06-14): a real ACP turn answered on Ollama (qwen3:8b) and
LM Studio (openai/gpt-oss-20b). This supersedes the earlier "opencode's acp turn returns empty" finding,
which was an unconfigured-provider artifact, not an opencode limitation. - Documented the full, honestly-vetted local-backend support matrix in
docs/local-models.md: which
agent × {ollama, lmstudio} pairs work, and — for the ones that do not — the concrete reason.codex
has no local pair (its custom providers now require the OpenAI Responses API wire that local runtimes don't
speak, andcodex-acpis auth-gated);hermescan talk to Ollama but only via its ownconfig.yaml
provider (itsacpmode ignores the inference-provider env), so it is a config-file change, not an
env-keyedbackend.claude_codeon Ollama works but is slow and needs a generous timeout + a capable
model. A new-m integrationsuite (tests/integration/test_local_backends.py) drives every supported
pair live and skips a runtime that is down. - Durable runs (F2): a
delegate/consensus/debatecall can now be kept as a job on disk. Each of
the three tools takes apersistflag (true/false, orNoneto follow the configured
default_persistence—ephemeralout of the box, so nothing is written unless asked), and the
previously-inertdefault_persistence/jobs_dirconfig is now wired. A persisted run is written under
<jobs_dir>/<run_id>/(jobs_dirdefaults to<cwd>/.rutherford/jobs):state.json— a versioned, replay-completeRunRecordas JSON (an internal record only Rutherford's
own reader consumes, so it round-trips losslessly rather than using the token-optimized TOON of the
tool wire): the resolved launchargv, requested-vs-resolved model, provenance, safety mode, requested/applied
effort, topology,cwd, prompt, role, files, ok / error code, changed files, cost, stop reason, and a
rollup. The child processenvis never persisted (it can carry secrets); replay recomposes it.artifacts/answer.md(the answer / synthesis) and, for a write run,artifacts/diff.md(the sandbox
diff, including created / untracked files).- A persisted
consensuswrites a parent record linking a child record per voice (child_run_ids), with
oneartifacts/voices/voice-N.mdper voice and avoices/skipped.mdfor an auto-panel's left-out agents;
the parent rolls up status / cost / changed-file union and carries the resolvedPanelInputs(roster +
per-seat stance + session handle, strategy, synthesize, judge). A persisteddebatewrites a parent
record plus the fullartifacts/transcript.md(a debate drives its turns over persistent sessions, so the
transcript carries the run rather than per-turn child records). io/ledger.py(RunLedger) is the one writer of the jobs directory; persistence is best-effort — a write
failure logs and degrades to an unpersisted result, never failing a run that already produced an answer.
io.ledger.read_record/iter_recordsare the reader side (job continuation and theanalyzereport).
- The write/propose sandbox substrate, so Rutherford can safely delegate file-writing work to an agent over
ACP. A mutating delegation (write/propose/yolo) with aworking_dirno longer runs the agent in
the user's tree — it runs in an isolated execution root and only a reviewed diff is ever applied back.- SandboxManager (
acp/sandbox.py). ...
- SandboxManager (