Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
45d2a12
feat(agent_sys): per-agent components at three install levels, and ex…
dorado269 Sep 3, 2026
5834cc0
docs: update the running work log for the per-agent components round
dorado269 Sep 3, 2026
04e8f97
fix(env_mgr): pin the CLI, give the child a PATH, and place every mem…
dorado269 Sep 3, 2026
99d3aea
fix(env_mgr): import a tooldef from the zone copy, not the component …
dorado269 Sep 3, 2026
205ff8f
fix(env_mgr): a marketplace name must be a single directory name, and…
dorado269 Sep 3, 2026
e1b9f54
fix(env_mgr): key a tooldef module on its source, and report a cross-…
dorado269 Sep 3, 2026
c92063a
fix(env_mgr): resolve symlinks when placing a .claude/ tree, at every…
dorado269 Sep 3, 2026
9a9fdff
feat(env_mgr): the install report names every MCP server and tool it …
dorado269 Sep 3, 2026
13154c6
fix(env_mgr): a bundled MCP entry states the run environment; record …
dorado269 Sep 3, 2026
90b34fe
docs(env_checker): record which tree run 2 executed
dorado269 Sep 3, 2026
0a5840b
docs(env_checker): the instrument lessons, and ignore serena's by-pro…
dorado269 Sep 3, 2026
b299bf2
docs(env_checker): record the launch gates' operating rule in the repo
dorado269 Sep 3, 2026
50105f4
docs(env_checker): correct a false strength claim in ACCEPTANCE.md se…
dorado269 Sep 3, 2026
7d25011
docs(env_mgr): the positive rule for an in-process tool's per-run con…
dorado269 Sep 3, 2026
1f3fef0
feat(env_checker): row 6 takes no input; add 6b, the placed-copy guard
dorado269 Sep 3, 2026
47de7ed
docs(env_checker): tell the agent what row 6's tool now returns
dorado269 Sep 3, 2026
65754ba
fix(env_checker): the placeholder rule failed on correct input
dorado269 Sep 3, 2026
8cac792
docs(env_checker): the follow-up list, as items with triggers
dorado269 Sep 3, 2026
bc1a284
test(env_mgr): the serena-recipe test was green whether the recipe pa…
dorado269 Sep 4, 2026
562be04
docs(agent_sys): forbid a second mechanism for a job that has an owner
dorado269 Sep 4, 2026
33b9f56
docs(env_mgr): one rule for where a declared install lands
dorado269 Sep 4, 2026
c136d1c
docs(checkpoint): third effort baseline and T+39
dorado269 Sep 4, 2026
30e958b
docs(bugs): the claude plugin check cannot pass, and its tests keep i…
dorado269 Sep 4, 2026
630b5dc
docs(checkpoint): T+66, the block breaks
dorado269 Sep 4, 2026
88dba47
docs(checkpoint): withdraw the pin-stronger-than-criterion species
dorado269 Sep 4, 2026
5d334a3
docs(env_mgr): design.md stops paraphrasing criterion 22
dorado269 Sep 4, 2026
74c331f
docs(env_mgr): no layers at all; adopt Claude Code's user/project split
dorado269 Sep 4, 2026
fc200a2
test(env_mgr): retire the byte-identity fence; criterion 22 keeps its…
dorado269 Sep 4, 2026
93bf0ac
fix(env_mgr): claude plugin check could not pass; retest on captured …
dorado269 Sep 4, 2026
fcf325e
docs(env_mgr): README criterion 22 row follows the spec; my fc200a2 l…
dorado269 Sep 4, 2026
3db1ed5
refactor(env_mgr): remove the layer model
dorado269 Sep 4, 2026
b83eb2a
feat(spec_loader): find an agent's env_recipe by convention under ass…
dorado269 Sep 4, 2026
ac05ec9
docs(checkpoint): T+102, implementation starts and the layer model goes
dorado269 Sep 4, 2026
0295391
test(env_mgr): drop the removed `layer=` kwarg from test_installers
dorado269 Sep 4, 2026
5454663
fix(env_mgr): reject a stale `layer:` instead of letting it fall through
dorado269 Sep 4, 2026
cb5b870
docs(env_mgr): principle 7 named one reason the fence went; there wer…
dorado269 Sep 4, 2026
ee3ad0a
refactor(agent_sys): components/ becomes agent_plugins/; the levels b…
dorado269 Sep 4, 2026
75edb7c
docs(agent_sys): the agent_plugins rename left seven stale references
dorado269 Sep 4, 2026
5477c37
docs(agent_sys): finish retiring the level vocabulary outside env_che…
dorado269 Sep 4, 2026
adf319d
docs(bugs): two declaration routes resolve to paths a wheel does not …
dorado269 Sep 4, 2026
1c75de1
docs(checkpoint): T+129, implementation complete; one decision open
dorado269 Sep 4, 2026
f7758c1
docs(checkpoint): T+191, the design turned twice and the scope grew
dorado269 Sep 4, 2026
5f3499c
feat(env_mgr): run_server, a server registry, and stopping what a run…
dorado269 Sep 4, 2026
75b54a3
feat(env_mgr): recipes come in three layers, and the layer is where t…
dorado269 Sep 4, 2026
0ea5b41
feat(env_mgr): a duplicate server declaration warns, from the registr…
dorado269 Sep 4, 2026
f754a6e
test(env_mgr): prove the pid guard with a sacrifice, not with the tes…
dorado269 Sep 4, 2026
ea5253d
refactor(env_mgr): addons move INSIDE env_mgr, and that is what ships…
dorado269 Sep 4, 2026
502a3cd
docs(env_mgr): in-process MCP needs a justification; remote/tools.py …
dorado269 Sep 4, 2026
6d281fd
docs(checkpoint): T+246, written by the lead after the team was stopped
dorado269 Sep 4, 2026
88a4b5d
docs(agent_sys): one spec for how an agent gets its environment
dorado269 Sep 4, 2026
6e72e27
docs(spec,design): the in-process ToolDef route for component tools i…
dorado269 Sep 4, 2026
3dca5f0
docs(roadmap): serving MCP ourselves, and what actually closes spec 6…
dorado269 Sep 4, 2026
29a39ae
refactor(agent_sys): addons are installed by recipe; agent_plugins is…
dorado269 Sep 4, 2026
8be9204
refactor(env_mgr): delete the in-process ToolDef route for component …
dorado269 Sep 4, 2026
67cbdcd
refactor(env_mgr): drop the import left dead by the ToolDef removal
dorado269 Sep 4, 2026
fc05b80
refactor(env_checker): six capabilities, two install routes; the leve…
dorado269 Sep 4, 2026
c5ad2dd
docs(checkpoint): T+281, stalled to converging, and three corrections…
dorado269 Sep 4, 2026
6043512
docs(env_mgr): run_server is for port-based servers; reap the test's …
dorado269 Sep 4, 2026
1d6a37f
fix(agent_sys): stale tooldef schema text; serena redirects with SERE…
dorado269 Sep 4, 2026
f2c4321
docs(env_mgr): dropping the HOME override gives nothing up
dorado269 Sep 4, 2026
e87ac98
docs(checkpoint): T+311, implementation complete and three correction…
dorado269 Sep 4, 2026
3ca3618
docs(env_checker): a test guards the deleted isolation property, not …
dorado269 Sep 4, 2026
6515c85
test(env_mgr): a .claude/ tree is data, never code -- and the guard p…
dorado269 Sep 4, 2026
7324af3
docs(env_checker): point both records at the wide import guard too
dorado269 Sep 4, 2026
cce5c5d
build(env_mgr): ship both recipe layers -- the third instance of one …
dorado269 Sep 4, 2026
447c23f
docs(checkpoint): T+338, a stale read used to correct a stale read
dorado269 Sep 4, 2026
28f8ac1
refactor(env_mgr): a recipe reference names its root
dorado269 Sep 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,11 @@ mission.md
.spur_job_*.sh
.spur_ns_*.sh
spur-*.out

# serena writes `.serena/` into whatever directory it is given as `--project`,
# and `examples/env_checker`'s capability-7 measurements point it at the agent
# asset directory. Config and symbol cache, regenerated on demand, belonging to
# no package. Ignored rather than remembered: `launch.sh` refuses to start a run
# while the package tree is dirty, so an untracked by-product that everybody
# knows to skip would block the gate and teach people to bypass it.
.serena/
15 changes: 15 additions & 0 deletions agent_sys/agent/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,21 @@ class Assignment(BaseModel):
#: which is what every backend but `claude_sdk` does today.
tools: tuple[Any, ...] = ()

#: **External MCP servers this agent's components declared**, keyed by the
#: name the model addresses them under. `env_mgr.Prepared.mcp_servers`,
#: straight through.
#:
#: Typed loosely for `tools`' reason and one more of its own: the values are
#: the *SDK's* server vocabulary — `{"type": "stdio", "command": …}` and the
#: rest — so a type here would be `agent` declaring a shape it does not own
#: and cannot check, on behalf of one of its backends.
#:
#: **A field and not prose in the readme**, for the reason spec §5.5 gives
#: `tools`: an agent told in English to start an MCP server will improvise.
#: A backend that cannot express external servers ignores this entirely,
#: which is every backend but `claude_sdk` today.
mcp_servers: dict[str, Any] = Field(default_factory=dict)

#: `env_mgr.Confinement`, carried for an executor that wants to report what
#: it will run under. Typed loosely because `agent` may not import `env_mgr`.
#:
Expand Down
28 changes: 28 additions & 0 deletions agent_sys/agent/backends/claude_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,34 @@ def _options(self) -> dict[str, Any]:
options.setdefault("permission_mode", "bypassPermissions")
if self.assignment.environment:
options.setdefault("env", dict(self.assignment.environment))
if self.assignment.mcp_servers:
# **The per-agent components' external servers, under the tool
# server's own collision policy.** Not a second policy: the reason a
# name may not be taken twice is that `mcp__<server>__<tool>` is what
# the model calls, and that is true of a component's server exactly
# as it is of `env_mgr`'s. So the refusal below is the same refusal,
# said about a different name.
#
# **Refused rather than merged, and the direction matters.** The
# caller's `options["mcp_servers"]` is an operator's configuration;
# `assignment.mcp_servers` is what a package declared. Letting either
# win silently means one of the two gets different tools than the
# ones they wrote, with nothing said — the defect the `env_mgr` key
# already carries a comment about, one collision wider.
servers = dict(options.get("mcp_servers") or {})
clash = sorted(set(servers) & set(self.assignment.mcp_servers))
if clash:
raise BackendUnsupported(
self.key,
"mcp_servers",
f"this config and this agent's components both declare MCP "
f"server(s) {clash}. The model addresses these as "
f"mcp__<server>__<tool>, so two servers cannot share a name — "
f"rename one side rather than letting the other's tools "
f"disappear.",
)
servers.update(self.assignment.mcp_servers)
options["mcp_servers"] = servers
if self.assignment.tools:
# **Spec §5.5's remote surface, and the only place that knows the
# SDK.** `env_mgr` may not import the SDK and `agent/backend.py` is
Expand Down
2 changes: 1 addition & 1 deletion agent_sys/agent/docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -1297,7 +1297,7 @@ test the chain against nothing that can actually fail to be available.
| **O1** | **Criterion 13 is not testable as written.** "Losslessly for what both support" requires knowing the intersection of two harnesses' feature sets, and no converter computes it — everyone hand-maintains a table. Both reference implementations' tables fail invisibly: **pandoc** classifies a dropped block as `INFO`, so `--fail-if-warnings` exits 0 with the content gone, and attribute-level loss (a link title) is logged at *no* level; **kompose**'s 25-entry unsupported-key table has **no production caller** — its only caller is its own unit test, and the exported sibling is invoked with an empty map, so eight declared-unsupported keys converted clean with exit 0. The only executable formulation found anywhere is `rulesync`'s per-(target, feature) fixtures asserting the *canonical* value. **The criterion needs to name the artefact that defines "what both support" and the test that keeps it honest** — and to separate *unsupported* (the target cannot express it) from *unknown* (the converter did not handle it), which GitHub Actions Importer does and which criterion 13 conflates |
| **O2** | **Which `claude` CLI the backend runs is undecided, and it is `env_mgr`'s call.** The SDK prefers its bundled 328 MB executable; `env_mgr` installs plugins into whatever is on `PATH`. Unless `cli_path` is set from the prepared environment, an agent does not see the plugins its own recipe installed. §8.7 |
| **O3** | **The backend's transcript lands outside the confinement zone.** `~/.claude/projects/<encoded-cwd>/*.jsonl`, containing prompts and reasoning, written by default. Criterion 16 is about the system's record and stays true, but "an agent reaches only its own zone" does not. Three levers exist (`CLAUDE_CONFIG_DIR`, `CLAUDE_CODE_SKIP_PROMPT_HISTORY`, a `SessionStore`); choosing one is `env_mgr`'s, and its spec does not mention the directory |
| **O4** | **§4.5's "Claude Code's format" is ambiguous.** The declarative `.claude/settings.json` surface and the SDK's `ClaudeAgentOptions(hooks={...})` callbacks are different execution models. Every surveyed converter targets the first; **nobody converts programmatic callbacks at all.** §4.5 must name which surface is canonical, and if it is the callbacks, criterion 13 has no prior art of any kind behind it |
| **O4** | **ANSWERED — the declarative `settings.json` surface is canonical.** The question was whether *"Claude Code's format"* meant the `.claude/settings.json` tree or the SDK's `ClaudeAgentOptions(hooks={...})` callbacks; every surveyed converter targets the first and **nobody converts programmatic callbacks at all.** Per-agent components forced the ruling, because they had to be *stored* somewhere: L2 and L3 are `.claude/` trees, `env_mgr/agent_assets.py` writes `<zone>/config/settings.json` and points `CLAUDE_CONFIG_DIR` at it, and `spec.md` §4.5 now says so. The warned-of consequence is therefore not incurred — criterion 13 rests on the surface the prior art covers. The callback form stays legal in a backend `config` and is passed through; it is simply not the stored form. **What is still open is narrower and belongs to O1**: no converter exists, so "canonical" is currently a claim about one harness rather than a demonstrated N-to-1 |
| **O5** | **Two objects hold "the agent spec table".** `AgentSpecRegistry` here, and `AgentMgr.register(spec, **config)` in `task_graph`, which copies its dict onto every minted `Agent.config`. This design assumes the loader feeds the second from the first and that nothing else writes either — but the direction is not stated in any spec, and `engineer_principle.md` §1 forbids two writers for one fact |
| **O6** | **How each phase becomes separately attributable** — narrowed twice, and the question is now smaller than rev. 3 stated it. `validator` design §8.2 rev. 2 owns the **requirement**: a phase must carry an `agent_id`, because criterion 10 there is untestable otherwise, and the SDK's `agent_id` is *"absent on the main thread"*. This module owns the **mechanism**, and one candidate is ruled out: not one client with several `session_id`s, because `interrupt()` takes no `session_id` and acts on the whole connection (§8.4). `fork_session`, `resume`, a subagent per phase, and a second client remain, and none was tested. The stage-three consistency pass found this document and `validator`'s giving different answers to what turned out to be two different questions; splitting them is what made the residue this small |
| **O7** | **Mid-run backend failure.** §3.3's "pins the whole run" implies no fallback after the chosen backend dies, and every surveyed project except LiteLLM agrees. LiteLLM's cost is on record — a depth bound, an attempted-targets set against looping graphs, a pin predicate, cooldown feedback and per-failure-class chains, threaded through a loosely-typed `kwargs` at four call sites. Worth knowing before anyone proposes it, and worth stating in the spec either way |
66 changes: 66 additions & 0 deletions agent_sys/agent/docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,16 @@ spec** — the two are declared together in a closure.
| `env` | Environment requirements, resolved by `env_mgr` |
| `knowledge` | §3.4 |
| `rules` / `hooks` / `skills` | Configuration, stored in canonical form. §4.5 |
| `assets` | **Filled by `spec_loader`, not written.** This agent's own directory under the package's `assets/`, found by the same three folder spellings a body lookup uses — `X`, `X.agent`, `agent.X`. Two matching directories is `SpecInconsistent`; an explicit binding is legal and warns. §4.5a |
| `recipes` | The **agent layer** of three recipe layers; `env_mgr` recipe YAMLs as `agent_sys:<name>` or `package:<relpath>` — a reference names its root. §4.5a |

Nine keys became eleven, and both additions are one thing: **an agent may now
carry components, not only files.** §4.5a is why that needed new keys instead of
a longer `skills` list.

**There was a third, `agent_plugins:`, and it is deleted** —
`docs/spec.provisioning.md` §4: what this repository ships under
`env_mgr/addons/` is installed by a recipe, and no declaration key reaches it.

### 3.2 Permissions are not here

Expand Down Expand Up @@ -377,6 +387,62 @@ Picking one canonical format matters more than which one is picked: with N
harnesses, storing each in its own format needs N² converters, and storing one
canonical form needs N.

**Which Claude Code surface is canonical: the declarative one.** Design O4 asked,
because *"Claude Code's format"* named two different execution models — the
`.claude/settings.json` tree and the SDK's `ClaudeAgentOptions(hooks={...})`
callbacks — and every surveyed converter targets the first while nobody converts
programmatic callbacks at all. The answer is the first, and it is now what the
code does: `env_mgr` writes `<zone>/config/settings.json` and points
`CLAUDE_CONFIG_DIR` at it. The consequence O4 warned about is therefore not
incurred — criterion 13 rests on the surface the prior art actually covers.

The callback form is not forbidden; a backend config may still carry `hooks`, and
`claude_sdk` passes it through. What it is not is the **stored** form, so nothing
in a package or a component is written that way.

### 4.5a A component is a tree, and that needed three keys

§4.5's three lists are lists of *files*. A Claude Code component is a directory:
a skill is a directory, a plugin marketplace is a directory of directories, and
an MCP server is a process to register rather than a file to place. Naming every
file would make a package author restate a layout the harness already fixes.

**Two routes, and only one copies a tree.** `docs/spec.provisioning.md` is
normative here and supersedes both the L1/L2/L3 numbering and the three-origins
table that replaced it.

| what | declared how |
|---|---|
| anything upstream ships, and anything `agent_sys` ships under `env_mgr/addons/` | `recipes: [...]`, or the package/default recipe layer; `tags: [internal]` marks an item as ours |
| what one task package carries for one agent | **undeclared** — `<assets>/.claude/`, copied |

**The copied tree is in Claude Code's canonical layout**:
`settings.json`, `skills/<name>/`, `plugins/` (a local marketplace),
`.mcp.json`, and `tools/*.mcp.py`. It is the harness's own layout rather than
ours, so a file is placed and not converted. (A `tools/*.tooldef.py` was a fourth
member until 2026-09-04, when the in-process route it used was deleted —
`docs/spec.provisioning.md` §6.)

**A package's own material is undeclared on purpose.** A declaration would be a
second statement of what the directory already says, and the two would drift the
first time somebody moved it without editing the YAML.

`env_mgr/agent_assets.py` installs both; `env_mgr/docs/design.md` §11.5a is
the mechanism, including the measured ordering constraint that decides when
`settings.json` is written, the marketplace copy probe F forced, and why a
recipe runs the shipped machinery as a subprocess. What reaches this package
from a component is `Assignment.mcp_servers`. `Assignment.tools` also exists,
but nothing a component ships arrives through it — it carries `env_mgr`'s own
remote surface and nothing else (§5.5).

**A component names a binary through `${VAR}`, never through `PATH`.** An
`.mcp.json` entry is expanded against the zone environment before it becomes an
`mcp_servers` entry, and an unresolved name is an error. That is not a
convenience: `PATH` is derived from the granted policy at prepare step 2, and a
directory a recipe installs into does not exist until step 6b — so
`"${UV_TOOL_BIN_DIR}/serena"` is the only spelling that works, and it is the one
measured working.

---

## 5. `claude-agent-sdk` as the first backend
Expand Down
4 changes: 4 additions & 0 deletions agent_sys/agent/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,10 @@ def _deploy(self, spec: Any) -> Executor:
# `Prepared` from before this field existed is still a valid one,
# which is the same allowance every other optional field here gets.
tools=tuple(getattr(prepared, "tools", ()) or ()),
# The per-agent components' external servers, same allowance and
# for the same reason: a `Prepared` built before this field existed
# is still a valid one.
mcp_servers=dict(getattr(prepared, "mcp_servers", None) or {}),
confinement=getattr(prepared, "confinement", None),
agent_cli=prepared.agent_cli,
# **Read, not inferred**, and that is the field's whole reason.
Expand Down
28 changes: 28 additions & 0 deletions agent_sys/agent/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,34 @@ class AgentSpec(_Model):
hooks: list[str] = Field(default_factory=list)
skills: list[str] = Field(default_factory=list)

#: This agent's own directory under the package's `assets/`, package-relative,
#: or `""` when it has none. **Filled by `spec_loader`, not written**, from the
#: same folder convention that scopes a task's body lookup.
#:
#: Spec §3.1 listed nine keys and this is a tenth, so it is a spec change and
#: not a model detail: what an agent carries turned out not to fit in `rules` /
#: `hooks` / `skills`, which are three lists of *paths to individual files*. A
#: Claude Code component is a **tree** — a skill is a directory, a plugin
#: marketplace is a directory of directories — and naming each file would make
#: the package author restate a layout the harness already fixes.
assets: str = ""

#: `env_mgr` recipe YAMLs run before the session, each written
#: ``<scheme>:<ref>`` — ``agent_sys:<name>`` for one this repository ships
#: under `env_mgr/recipes/`, ``package:<relpath>`` for one this task package
#: carries. **A reference names its root**: there is no bare form and no
#: fallback, because until 2026-09-04 the root was decided by which candidate
#: happened to exist. The
#: route by which an agent asks for components this repository does not ship
#: and should not vendor (serena, marketplace plugins, apt/pip tools) — and,
#: with an item carrying ``tags: [internal]``, for one that it does.
#:
#: **Also filled by convention** when the agent carries its own recipe:
#: `assets/env_recipe.<name>.yaml` and every other permutation of those
#: tokens (`spec_loader/assets.py`, `fill_agent_env_recipe`). Declaring it
#: by hand is legal, warns, and wins whole.
recipes: list[str] = Field(default_factory=list)

@field_validator("backends", mode="before")
@classmethod
def _normalise(cls, value: Any) -> Any:
Expand Down
31 changes: 27 additions & 4 deletions agent_sys/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import argparse
import logging
import os
import shutil
import sys
from collections.abc import Sequence
Expand Down Expand Up @@ -49,6 +50,7 @@
from env_mgr.prepare import EnvManager, permissions_enforced
from env_mgr.protocols import NoConfinement, PrepareRefused, UnresolvedGrant
from env_mgr.remote.connection import sync_transport
from env_mgr.servers import REGISTRY_ENV_VAR, owned_servers
from env_mgr.sync import check_delete_scope
from monitor import (
NullUserSink,
Expand Down Expand Up @@ -209,7 +211,7 @@ def main(argv: Sequence[str] | None = None) -> int:
try:
if args.verb == "show":
return _show(args, stream)
return _run(args, stream)
return _run(args, stream, stack)
except package.PackageNotFound as exc:
return _fail(stream, PRECONDITION, str(exc))
except SpecInvalid as exc:
Expand Down Expand Up @@ -259,12 +261,16 @@ def _show(args: argparse.Namespace, stream: Stream) -> int:
# run


def _run(args: argparse.Namespace, stream: Stream) -> int:
def _run(args: argparse.Namespace, stream: Stream, stack: ExitStack) -> int:
if args.clean:
return _clean(args, stream)
if args.dry_run:
return _dry_run(args, stream)
return _real_run(args, stream)
# `stack` reaches only `_real_run`: it is what stops the servers a run
# started, and the other two verbs start none. `clean` removes a directory;
# `dry-run` dispatches nothing, which its own body asserts rather than
# assumes.
return _real_run(args, stream, stack)


def _clean(args: argparse.Namespace, stream: Stream) -> int:
Expand Down Expand Up @@ -326,7 +332,7 @@ def _layout(args: argparse.Namespace) -> Layout:
return layout_for(root).create()


def _real_run(args: argparse.Namespace, stream: Stream) -> int:
def _real_run(args: argparse.Namespace, stream: Stream, stack: ExitStack) -> int:
"""Everything. Needs credentials, a sandbox, and a model.

The order of the two preconditions is measured rather than aesthetic: the
Expand All @@ -351,6 +357,23 @@ def _real_run(args: argparse.Namespace, stream: Stream) -> int:
promises = expectations.for_package(package.locate(args.package))

layout = _layout(args)
# **The servers this run starts are stopped when this block unwinds.**
# `env_mgr` starts them, so `env_mgr` stops them: this is a call site, not a
# transfer of responsibility, and nothing below has to remember to clean up.
#
# The path is set on `os.environ` and not passed, because that is the only
# channel that reaches the installer: a recipe runs as a grandchild
# (`agent_assets._run_recipe` shells `python -m env_mgr`), and its
# environment is built from this process's. It is a **per-run** constant, so
# unlike `CLAUDE_CONFIG_DIR` -- which `agent_assets._child_env` refuses to
# set globally because it is per-attempt and the runner is threaded -- there
# is no value here for two threads to take from each other.
#
# What this does and does not promise is in `env_mgr/servers.py`: servers
# stop on normal and handled-error exit, and leak on SIGTERM and SIGKILL.
registry_file = layout.run / "servers.json"
os.environ[REGISTRY_ENV_VAR] = str(registry_file)
stack.enter_context(owned_servers(registry_file))
root = package.locate(args.package)
# **Read once, at start-up, and it is the run's fact rather than a task's.**
# `env_mgr.prepare.permissions_enforced()` is the single reader of the
Expand Down
Loading