orchestrator-runner 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ venv/
10
+
11
+ # Tooling caches
12
+ .pytest_cache/
13
+ .ruff_cache/
14
+ .mypy_cache/
15
+
16
+ # Local
17
+ *.db
18
+ .loop/
19
+ .DS_Store
20
+ dogfood/
21
+
22
+ ui/node_modules/
23
+ ui/dist/
24
+
25
+ # Secrets & local env (never commit real secrets)
26
+ .env
27
+ .env.*
28
+ !.env.example
29
+ *.key
30
+ # per-deployment TLS certs (Tailscale/Let's Encrypt) — never commit
31
+ deploy/certs/
32
+ # throwaway `docker compose config` render
33
+ compose-rendered.json
34
+ *.pem
35
+
36
+ # Editors / OS
37
+ .idea/
38
+ .vscode/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BizEnrich
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.5
2
+ Name: orchestrator-runner
3
+ Version: 0.1.0
4
+ Summary: Local runner for the SDD Orchestrator — run your own LLM subscription (Claude/Codex/Gemini) from your machine.
5
+ Project-URL: Homepage, https://github.com/Aitrich-AI-Dev-Platform/sdd-orchestrator
6
+ Project-URL: Repository, https://github.com/Aitrich-AI-Dev-Platform/sdd-orchestrator
7
+ Project-URL: Issues, https://github.com/Aitrich-AI-Dev-Platform/sdd-orchestrator/issues
8
+ Author: BizEnrich
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+
23
+ # orchestrator-runner
24
+
25
+ The **local runner** for the SDD Orchestrator. It lets you run your **own LLM subscription**
26
+ (Claude / Codex / Gemini) from **your own machine** — your subscription login and your code never leave your
27
+ computer. The orchestrator server only ever receives the generated text, or (for builds) a patch it
28
+ independently verifies.
29
+
30
+ It's a standalone client: pure Python stdlib, **no dependency on the orchestrator server**.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pipx install orchestrator-runner
36
+ ```
37
+
38
+ You also need, on this machine:
39
+
40
+ - The provider's own CLI, installed **and logged in** — `claude` (Pro/Max), `codex` (Sign-in-with-ChatGPT),
41
+ or `gemini` (Google). The runner only serves providers whose CLI is present.
42
+ - `git`, if you want this runner to also **build** (`--allow-build`).
43
+
44
+ ## Use
45
+
46
+ 1. In the orchestrator web app: **Integrations → Local runners → Pair a new runner** (gives a one-time code,
47
+ valid ~10 minutes).
48
+ 2. On this machine, in a terminal:
49
+
50
+ ```bash
51
+ orchestrator-runner pair --url https://<your-orchestrator-site> --code <code> # one-time
52
+ # add --allow-build to also run builds here (runs agent-written code + tests on this machine)
53
+ orchestrator-runner run
54
+ ```
55
+
56
+ Keep `run` going while your projects work. Closing it takes your runner offline; subscription-backed
57
+ stages then pause until you start it again.
58
+
59
+ 3. `orchestrator-runner whoami` verifies the stored credential.
60
+
61
+ The credential is saved to `~/.orchestrator-runner/config.json` (mode 0600). Revoke a runner any time from
62
+ the web app.
63
+
64
+ ## Security
65
+
66
+ - Your provider **seat login** stays in your provider CLI on this machine — the server stores only a
67
+ revocable runner credential (its hash).
68
+ - For builds, the runner clones **read-only** with your own git and returns a **patch**; it never pushes.
69
+ The server applies the patch and runs the authoritative verification + review + delivery.
@@ -0,0 +1,56 @@
1
+ # Publishing `orchestrator-runner` to PyPI
2
+
3
+ Your users install this with `pipx install orchestrator-runner`, which requires it on PyPI. Do this once.
4
+
5
+ > The publish **workflow** is provided below as text rather than committed to `.github/workflows/`, because
6
+ > creating a workflow file needs a token with the `workflow` scope (the bot token used here doesn't have it).
7
+ > Add it via the GitHub web UI ("Add file → Create new file"), or push it from a local checkout with your own
8
+ > credentials.
9
+
10
+ ## 1. One-time setup
11
+ - Create the PyPI project **`orchestrator-runner`**.
12
+ - Either configure **Trusted Publishing** (recommended) on that project, pointing at this repo + the workflow
13
+ file `publish-runner.yml`; **or** create a PyPI API token and add it as the repo secret `PYPI_API_TOKEN`.
14
+ - Choose a **license** (see the TODO in `pyproject.toml`) before the first publish.
15
+
16
+ ## 2. Add the publish workflow
17
+ Create `.github/workflows/publish-runner.yml` with:
18
+
19
+ ```yaml
20
+ name: publish-runner
21
+ on:
22
+ push:
23
+ tags: ["runner-v*"]
24
+ workflow_dispatch: {}
25
+ jobs:
26
+ build-and-publish:
27
+ runs-on: ubuntu-latest
28
+ permissions:
29
+ id-token: write # PyPI Trusted Publishing (OIDC); drop this if you use a token instead
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+ - uses: actions/setup-python@v5
33
+ with:
34
+ python-version: "3.12"
35
+ - name: Build
36
+ working-directory: packages/orchestrator-runner
37
+ run: |
38
+ python -m pip install --upgrade build
39
+ python -m build
40
+ - name: Publish to PyPI
41
+ uses: pypa/gh-action-pypi-publish@release/v1
42
+ with:
43
+ packages-dir: packages/orchestrator-runner/dist
44
+ # For an API token instead of trusted publishing, add:
45
+ # password: ${{ secrets.PYPI_API_TOKEN }}
46
+ ```
47
+
48
+ ## 3. Publish a version
49
+ Bump `version` in both `pyproject.toml` and `src/orchestrator_runner/__init__.py`, commit, then tag:
50
+
51
+ ```bash
52
+ git tag runner-v0.1.0 && git push origin runner-v0.1.0
53
+ ```
54
+
55
+ The workflow builds the sdist + wheel and publishes. After that, `pipx install orchestrator-runner` works
56
+ for everyone. (You can also publish manually: `cd packages/orchestrator-runner && python -m build && twine upload dist/*`.)
@@ -0,0 +1,47 @@
1
+ # orchestrator-runner
2
+
3
+ The **local runner** for the SDD Orchestrator. It lets you run your **own LLM subscription**
4
+ (Claude / Codex / Gemini) from **your own machine** — your subscription login and your code never leave your
5
+ computer. The orchestrator server only ever receives the generated text, or (for builds) a patch it
6
+ independently verifies.
7
+
8
+ It's a standalone client: pure Python stdlib, **no dependency on the orchestrator server**.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pipx install orchestrator-runner
14
+ ```
15
+
16
+ You also need, on this machine:
17
+
18
+ - The provider's own CLI, installed **and logged in** — `claude` (Pro/Max), `codex` (Sign-in-with-ChatGPT),
19
+ or `gemini` (Google). The runner only serves providers whose CLI is present.
20
+ - `git`, if you want this runner to also **build** (`--allow-build`).
21
+
22
+ ## Use
23
+
24
+ 1. In the orchestrator web app: **Integrations → Local runners → Pair a new runner** (gives a one-time code,
25
+ valid ~10 minutes).
26
+ 2. On this machine, in a terminal:
27
+
28
+ ```bash
29
+ orchestrator-runner pair --url https://<your-orchestrator-site> --code <code> # one-time
30
+ # add --allow-build to also run builds here (runs agent-written code + tests on this machine)
31
+ orchestrator-runner run
32
+ ```
33
+
34
+ Keep `run` going while your projects work. Closing it takes your runner offline; subscription-backed
35
+ stages then pause until you start it again.
36
+
37
+ 3. `orchestrator-runner whoami` verifies the stored credential.
38
+
39
+ The credential is saved to `~/.orchestrator-runner/config.json` (mode 0600). Revoke a runner any time from
40
+ the web app.
41
+
42
+ ## Security
43
+
44
+ - Your provider **seat login** stays in your provider CLI on this machine — the server stores only a
45
+ revocable runner credential (its hash).
46
+ - For builds, the runner clones **read-only** with your own git and returns a **patch**; it never pushes.
47
+ The server applies the patch and runs the authoritative verification + review + delivery.
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "orchestrator-runner"
3
+ version = "0.1.0"
4
+ description = "Local runner for the SDD Orchestrator — run your own LLM subscription (Claude/Codex/Gemini) from your machine."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ dependencies = []
10
+ authors = [{ name = "BizEnrich" }]
11
+
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Environment :: Console",
15
+ "Intended Audience :: Developers",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Topic :: Software Development :: Libraries",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/Aitrich-AI-Dev-Platform/sdd-orchestrator"
26
+ Repository = "https://github.com/Aitrich-AI-Dev-Platform/sdd-orchestrator"
27
+ Issues = "https://github.com/Aitrich-AI-Dev-Platform/sdd-orchestrator/issues"
28
+
29
+ [project.scripts]
30
+ orchestrator-runner = "orchestrator_runner.cli:main"
31
+
32
+ [build-system]
33
+ requires = ["hatchling"]
34
+ build-backend = "hatchling.build"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/orchestrator_runner"]
38
+
39
+ [tool.pytest.ini_options]
40
+ pythonpath = ["src"]
41
+ testpaths = ["tests"]
42
+ addopts = "-ra"
43
+
44
+ [tool.ruff]
45
+ line-length = 110
46
+ src = ["src", "tests"]
47
+ target-version = "py311"
48
+
49
+ [tool.ruff.lint]
50
+ select = ["E", "F", "I", "UP", "B", "SIM"]
51
+
52
+ [tool.mypy]
53
+ python_version = "3.11"
54
+ strict = true
55
+ mypy_path = "src"
56
+ files = ["src", "tests"]
@@ -0,0 +1,12 @@
1
+ """orchestrator-runner — the local runner for the SDD Orchestrator.
2
+
3
+ Run your own LLM subscription (Claude / Codex / Gemini) from your own machine: pair once against your
4
+ orchestrator deployment, then ``orchestrator-runner run`` leases generation (and, with ``--allow-build``,
5
+ build) tasks and runs them on your local seat CLIs — your subscription login and your code never leave your
6
+ computer. The server only ever receives generated text (or a patch it independently verifies).
7
+
8
+ A standalone client with **no dependency on the (private) orchestrator server package** — pure stdlib, so it
9
+ installs cleanly from PyPI with ``pipx install orchestrator-runner``.
10
+ """
11
+
12
+ __version__ = "0.1.0"
@@ -0,0 +1,273 @@
1
+ """Model backends — the LLM/harness-agnostic seam.
2
+
3
+ A backend turns ``(system, prompt)`` into text + real token usage. This protocol is where
4
+ "LLM-agnostic, harness-agnostic, works with everyone's subscription" actually lives: swap the
5
+ backend, the rest of the orchestrator is unchanged.
6
+
7
+ Shipped here:
8
+ - ``ClaudeCliBackend`` — shells to the ``claude`` CLI (uses the user's subscription, no API key),
9
+ the proven invocation the loop uses: ``claude -p --output-format json --model <id>`` with the
10
+ nested-session env markers stripped so the spawn runs at full strength.
11
+ - ``FakeBackend`` — deterministic, for unit tests (a test double, not a product path).
12
+
13
+ Next adapters plug in here with no other change: an Anthropic-API backend (SDK + key), OpenAI,
14
+ or a local model (Ollama). That is the "primitives for everyone" promise, made concrete.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import subprocess
22
+ from dataclasses import dataclass
23
+ from typing import Any, Protocol, runtime_checkable
24
+
25
+
26
+ class BackendError(RuntimeError):
27
+ """A backend failed to produce a generation (bad invocation, API error, timeout, no output)."""
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class Generation:
32
+ """One model response with real usage (what the cost ledger and router training consume)."""
33
+
34
+ text: str
35
+ model: str
36
+ input_tokens: int
37
+ output_tokens: int
38
+ cost_usd: float | None = None
39
+ cache_read_tokens: int = 0 # prompt tokens served from cache (a cache hit)
40
+ cache_write_tokens: int = 0 # prompt tokens written to cache (a cache miss that populates it)
41
+
42
+
43
+ @runtime_checkable
44
+ class ModelBackend(Protocol):
45
+ name: str
46
+
47
+ def generate(self, *, system: str, prompt: str, model: str | None = None) -> Generation: ...
48
+
49
+
50
+ def _clean_claude_env() -> dict[str, str]:
51
+ """``os.environ`` without Claude Code's own session markers. If the orchestrator runs inside a
52
+ Claude Code session, a nested ``claude -p`` that inherits ``CLAUDECODE`` / ``CLAUDE_CODE_*`` runs
53
+ in a degraded nested mode and under-drives the agent; stripping them makes it a top-level run."""
54
+ return {k: v for k, v in os.environ.items() if k != "CLAUDECODE" and not k.startswith("CLAUDE_CODE_")}
55
+
56
+
57
+ class ClaudeCliBackend:
58
+ """Real backend over the ``claude`` CLI (subscription auth; no API key required)."""
59
+
60
+ name = "claude-cli"
61
+
62
+ def __init__(
63
+ self,
64
+ *,
65
+ runtime: str = "claude",
66
+ model: str | None = None,
67
+ timeout_s: int = 1200,
68
+ permission_mode: str | None = None,
69
+ cwd: str | None = None,
70
+ ) -> None:
71
+ self.runtime = runtime
72
+ self.model = model or os.environ.get("ORCHESTRATOR_MODEL", "claude-sonnet-5")
73
+ self.timeout_s = timeout_s
74
+ self.permission_mode = permission_mode
75
+ self.cwd = cwd
76
+
77
+ def generate(self, *, system: str, prompt: str, model: str | None = None) -> Generation:
78
+ chosen = model or self.model
79
+ full_prompt = f"{system}\n\n{prompt}" if system else prompt
80
+ argv = [self.runtime, "-p", "--output-format", "json", "--model", chosen]
81
+ if self.permission_mode:
82
+ argv += ["--permission-mode", self.permission_mode]
83
+ argv.append(full_prompt)
84
+ try:
85
+ proc = subprocess.run(
86
+ argv,
87
+ capture_output=True,
88
+ text=True,
89
+ encoding="utf-8",
90
+ errors="replace",
91
+ check=False,
92
+ env=_clean_claude_env(),
93
+ cwd=self.cwd,
94
+ timeout=self.timeout_s,
95
+ )
96
+ except FileNotFoundError as exc:
97
+ raise BackendError(f"`{self.runtime}` not found on PATH") from exc
98
+ except subprocess.TimeoutExpired as exc:
99
+ raise BackendError(f"`{self.runtime}` timed out after {self.timeout_s}s") from exc
100
+
101
+ stdout = (proc.stdout or "").strip()
102
+ if not stdout:
103
+ raise BackendError((proc.stderr or "claude produced no output").strip()[:300])
104
+ try:
105
+ obj: dict[str, Any] = json.loads(stdout)
106
+ except (json.JSONDecodeError, ValueError) as exc:
107
+ raise BackendError(f"claude output was not JSON: {stdout[:200]}") from exc
108
+ if obj.get("is_error"):
109
+ raise BackendError(str(obj.get("result") or "claude reported an error")[:300])
110
+
111
+ usage = obj.get("usage") or {}
112
+ return Generation(
113
+ text=str(obj.get("result") or ""),
114
+ model=chosen,
115
+ input_tokens=int(usage.get("input_tokens", 0)),
116
+ output_tokens=int(usage.get("output_tokens", 0)),
117
+ cost_usd=_as_float(obj.get("total_cost_usd")),
118
+ cache_read_tokens=int(usage.get("cache_read_input_tokens", 0)),
119
+ cache_write_tokens=int(usage.get("cache_creation_input_tokens", 0)),
120
+ )
121
+
122
+
123
+ def _run_cli(argv: list[str], *, timeout_s: int, cwd: str | None) -> subprocess.CompletedProcess[str]:
124
+ """Run a vendor agent CLI, mapping the two expected failures to a clear ``BackendError`` (fail-closed):
125
+ a missing binary (the CLI isn't installed where the backend runs) and a timeout."""
126
+ try:
127
+ return subprocess.run(
128
+ argv,
129
+ capture_output=True,
130
+ text=True,
131
+ encoding="utf-8",
132
+ errors="replace",
133
+ check=False,
134
+ env=_clean_claude_env(),
135
+ cwd=cwd,
136
+ timeout=timeout_s,
137
+ )
138
+ except FileNotFoundError as exc:
139
+ raise BackendError(f"`{argv[0]}` not found on PATH") from exc
140
+ except subprocess.TimeoutExpired as exc:
141
+ raise BackendError(f"`{argv[0]}` timed out after {timeout_s}s") from exc
142
+
143
+
144
+ def _int(value: Any) -> int:
145
+ try:
146
+ return int(value)
147
+ except (TypeError, ValueError):
148
+ return 0
149
+
150
+
151
+ class CodexCliBackend:
152
+ """Real backend over the OpenAI ``codex`` CLI (ChatGPT subscription auth; no API key).
153
+
154
+ Runs ``codex exec`` read-only (no file edits) and takes the final agent message from stdout — the
155
+ documented non-interactive contract (progress goes to stderr, the result to stdout). ``codex``'s
156
+ ``--json`` event schema varies by version, so we read plain stdout and keep usage best-effort (0);
157
+ the subscription path is flat-billed, so precise token accounting is not needed here. ``runtime`` and
158
+ ``extra_args`` are overridable so a deployment/runner can match its installed CLI without a code change.
159
+ """
160
+
161
+ name = "codex-cli"
162
+
163
+ def __init__(
164
+ self,
165
+ *,
166
+ runtime: str = "codex",
167
+ model: str | None = None,
168
+ timeout_s: int = 1200,
169
+ cwd: str | None = None,
170
+ extra_args: tuple[str, ...] = (),
171
+ ) -> None:
172
+ self.runtime = runtime
173
+ self.model = model or os.environ.get("ORCHESTRATOR_CODEX_MODEL", "")
174
+ self.timeout_s = timeout_s
175
+ self.cwd = cwd
176
+ self.extra_args = tuple(extra_args)
177
+
178
+ def generate(self, *, system: str, prompt: str, model: str | None = None) -> Generation:
179
+ chosen = model or self.model
180
+ full_prompt = f"{system}\n\n{prompt}" if system else prompt
181
+ argv = [self.runtime, "exec", "--skip-git-repo-check"]
182
+ if chosen:
183
+ argv += ["--model", chosen]
184
+ argv += [*self.extra_args, full_prompt]
185
+ proc = _run_cli(argv, timeout_s=self.timeout_s, cwd=self.cwd)
186
+ text = (proc.stdout or "").strip()
187
+ if not text:
188
+ raise BackendError((proc.stderr or "codex produced no output").strip()[:300])
189
+ return Generation(text=text, model=chosen or "codex", input_tokens=0, output_tokens=0)
190
+
191
+
192
+ class GeminiCliBackend:
193
+ """Real backend over the Google ``gemini`` CLI (Google-account/subscription auth; no API key).
194
+
195
+ Runs headless with ``--output-format json`` and parses the single result object (``response`` +
196
+ usage stats), falling back to raw stdout when the output is not JSON — the CLI's flags/schema vary by
197
+ version, so parsing is tolerant and never hard-fails on a schema change. ``runtime`` is overridable so
198
+ a runner can match its installed CLI.
199
+ """
200
+
201
+ name = "gemini-cli"
202
+
203
+ def __init__(
204
+ self,
205
+ *,
206
+ runtime: str = "gemini",
207
+ model: str | None = None,
208
+ timeout_s: int = 1200,
209
+ cwd: str | None = None,
210
+ ) -> None:
211
+ self.runtime = runtime
212
+ self.model = model or os.environ.get("ORCHESTRATOR_GEMINI_MODEL", "")
213
+ self.timeout_s = timeout_s
214
+ self.cwd = cwd
215
+
216
+ def generate(self, *, system: str, prompt: str, model: str | None = None) -> Generation:
217
+ chosen = model or self.model
218
+ full_prompt = f"{system}\n\n{prompt}" if system else prompt
219
+ argv = [self.runtime, "--output-format", "json"]
220
+ if chosen:
221
+ argv += ["--model", chosen]
222
+ argv += ["-p", full_prompt]
223
+ proc = _run_cli(argv, timeout_s=self.timeout_s, cwd=self.cwd)
224
+ stdout = (proc.stdout or "").strip()
225
+ text = ""
226
+ in_tok = out_tok = 0
227
+ if stdout:
228
+ try:
229
+ obj: Any = json.loads(stdout)
230
+ except (json.JSONDecodeError, ValueError):
231
+ obj = None
232
+ if isinstance(obj, dict):
233
+ if obj.get("error"):
234
+ raise BackendError(str(obj.get("error"))[:300])
235
+ text = str(obj.get("response") or obj.get("text") or "")
236
+ stats = obj.get("stats") or obj.get("usage") or {}
237
+ if isinstance(stats, dict):
238
+ in_tok = _int(stats.get("input_tokens") or stats.get("promptTokenCount"))
239
+ out_tok = _int(stats.get("output_tokens") or stats.get("candidatesTokenCount"))
240
+ else:
241
+ text = stdout # not JSON (older/text mode) -> treat raw stdout as the completion
242
+ if not text:
243
+ raise BackendError((proc.stderr or "gemini produced no output").strip()[:300])
244
+ return Generation(text=text, model=chosen or "gemini", input_tokens=in_tok, output_tokens=out_tok)
245
+
246
+
247
+ def _as_float(value: Any) -> float | None:
248
+ return float(value) if isinstance(value, int | float) else None
249
+
250
+
251
+ class FakeBackend:
252
+ """Deterministic backend for unit tests. Records the last call so tests can assert the prompt
253
+ was built correctly. Not a product path — real runs use a live backend."""
254
+
255
+ name = "fake"
256
+
257
+ def __init__(self, reply: str = "# Draft\n\nGenerated by the fake backend.\n") -> None:
258
+ self.reply = reply
259
+ self.last_system = ""
260
+ self.last_prompt = ""
261
+ self.calls = 0
262
+
263
+ def generate(self, *, system: str, prompt: str, model: str | None = None) -> Generation:
264
+ self.last_system = system
265
+ self.last_prompt = prompt
266
+ self.calls += 1
267
+ return Generation(
268
+ text=self.reply,
269
+ model=model or "fake/deterministic",
270
+ input_tokens=len(prompt.split()),
271
+ output_tokens=len(self.reply.split()),
272
+ cost_usd=0.0,
273
+ )
@@ -0,0 +1,352 @@
1
+ """``orchestrator-runner`` — connect a local runner so your own LLM subscription powers your work.
2
+
3
+ Commands:
4
+ - ``pair --url <site> --code <code>`` — exchange a one-time pairing code (from the UI) for a runner
5
+ credential, saved locally (0600). Reports which provider CLIs (claude/codex/gemini) are present.
6
+ - ``run`` — poll for tasks and run them on the local seat CLIs: generation (2b) and, when paired with
7
+ ``--allow-build``, builds (2c — clone, run the coding agent, return a patch the server verifies).
8
+ - ``whoami`` — verify the stored credential against the server.
9
+
10
+ This runs on the user's machine; the provider seat logins never leave it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import contextlib
17
+ import json
18
+ import os
19
+ import shutil
20
+ import sys
21
+ import tempfile
22
+ import time
23
+ import urllib.error
24
+ import urllib.request
25
+ from collections.abc import Callable
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from orchestrator_runner.backends import (
30
+ ClaudeCliBackend,
31
+ CodexCliBackend,
32
+ GeminiCliBackend,
33
+ ModelBackend,
34
+ )
35
+ from orchestrator_runner.coding import (
36
+ ImplementationBrief,
37
+ agent_available,
38
+ agent_spec,
39
+ run_coding_agent,
40
+ )
41
+ from orchestrator_runner.command import LocalCommandRunner
42
+
43
+ CONFIG_PATH = Path.home() / ".orchestrator-runner" / "config.json"
44
+
45
+ # provider -> the seat CLI that serves it (presence here = a subscription this runner can run)
46
+ _PROVIDER_CLIS = {"anthropic": "claude", "openai": "codex", "google": "gemini"}
47
+
48
+
49
+ def detect_capabilities(*, allow_build: bool = False) -> dict[str, Any]:
50
+ """Which provider seat CLIs are installed on this machine (presence only — login is checked at run time),
51
+ plus whether this runner may BUILD — an explicit opt-in (``--allow-build``) AND git present, since a
52
+ build runs agent-written code + tests on this machine."""
53
+ providers = [name for name, cli in _PROVIDER_CLIS.items() if shutil.which(cli)]
54
+ return {"providers": providers, "build": bool(allow_build and shutil.which("git"))}
55
+
56
+
57
+ def _request(
58
+ url: str, *, method: str, payload: dict[str, Any] | None = None, token: str = ""
59
+ ) -> dict[str, Any]:
60
+ data = json.dumps(payload).encode("utf-8") if payload is not None else None
61
+ req = urllib.request.Request(url, data=data, method=method)
62
+ if payload is not None:
63
+ req.add_header("Content-Type", "application/json")
64
+ if token:
65
+ req.add_header("Authorization", f"Bearer {token}")
66
+ with urllib.request.urlopen(req, timeout=30) as resp:
67
+ obj: Any = json.loads(resp.read().decode("utf-8"))
68
+ if not isinstance(obj, dict):
69
+ raise ValueError("expected a JSON object from the server")
70
+ return obj
71
+
72
+
73
+ def _save_config(cfg: dict[str, Any]) -> None:
74
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
75
+ CONFIG_PATH.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
76
+ with contextlib.suppress(OSError):
77
+ CONFIG_PATH.chmod(0o600)
78
+
79
+
80
+ def _load_config() -> dict[str, Any]:
81
+ try:
82
+ obj: Any = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
83
+ except (OSError, json.JSONDecodeError):
84
+ return {}
85
+ return obj if isinstance(obj, dict) else {}
86
+
87
+
88
+ def cmd_pair(args: argparse.Namespace) -> int:
89
+ url = str(args.url).rstrip("/")
90
+ caps = detect_capabilities(allow_build=bool(args.allow_build))
91
+ try:
92
+ result = _request(
93
+ f"{url}/runners/pair",
94
+ method="POST",
95
+ payload={"code": str(args.code), "name": str(args.name), "capabilities": caps},
96
+ )
97
+ except urllib.error.HTTPError as exc:
98
+ sys.stderr.write(f"pairing failed: {exc.code} {exc.read().decode('utf-8', 'replace')}\n")
99
+ return 1
100
+ except urllib.error.URLError as exc:
101
+ sys.stderr.write(f"could not reach {url}: {exc}\n")
102
+ return 1
103
+ _save_config(
104
+ {
105
+ "url": url,
106
+ "token": result["token"],
107
+ "runner_id": result["runner_id"],
108
+ "user_id": result["user_id"],
109
+ }
110
+ )
111
+ print(f"paired: runner {result['runner_id']} for user {result['user_id']}")
112
+ print(f"capabilities: providers={caps['providers']} build={caps['build']}")
113
+ if not caps["providers"]:
114
+ print(
115
+ "warning: no provider CLI (claude/codex/gemini) found on PATH — this runner can't serve a "
116
+ "subscription yet. Install and log into at least one."
117
+ )
118
+ print(f"credential saved to {CONFIG_PATH}")
119
+ return 0
120
+
121
+
122
+ def cmd_whoami(_args: argparse.Namespace) -> int:
123
+ cfg = _load_config()
124
+ if not cfg.get("token"):
125
+ sys.stderr.write("not paired — run `orchestrator-runner pair --url <site> --code <code>`\n")
126
+ return 1
127
+ try:
128
+ me = _request(f"{str(cfg['url']).rstrip('/')}/runners/me", method="GET", token=str(cfg["token"]))
129
+ except urllib.error.HTTPError as exc:
130
+ sys.stderr.write(f"credential rejected: {exc.code} (revoked? re-pair)\n")
131
+ return 1
132
+ except urllib.error.URLError as exc:
133
+ sys.stderr.write(f"could not reach {cfg.get('url')}: {exc}\n")
134
+ return 1
135
+ print(f"runner {me['runner_id']} for user {me['user_id']} — capabilities {me.get('capabilities')}")
136
+ return 0
137
+
138
+
139
+ _BACKEND_BUILDERS: dict[str, Callable[[str | None], ModelBackend]] = {
140
+ "anthropic": lambda model: ClaudeCliBackend(model=model),
141
+ "openai": lambda model: CodexCliBackend(model=model),
142
+ "google": lambda model: GeminiCliBackend(model=model),
143
+ }
144
+
145
+
146
+ def _local_backends() -> dict[str, ModelBackend]:
147
+ """The seat-CLI backends installed on this machine, by provider (only CLIs actually on PATH)."""
148
+ out: dict[str, ModelBackend] = {}
149
+ for provider, cli in _PROVIDER_CLIS.items():
150
+ if shutil.which(cli):
151
+ out[provider] = _BACKEND_BUILDERS[provider](None)
152
+ return out
153
+
154
+
155
+ def _post_fail(url: str, token: str, task_id: str, detail: str) -> None:
156
+ with contextlib.suppress(urllib.error.HTTPError, urllib.error.URLError):
157
+ _request(
158
+ f"{url}/runners/tasks/{task_id}/fail", method="POST", payload={"detail": detail}, token=token
159
+ )
160
+
161
+
162
+ def _run_one(task: dict[str, Any], backends: dict[str, ModelBackend], url: str, token: str) -> None:
163
+ task_id = str(task.get("task_id", ""))
164
+ provider = str(task.get("provider", ""))
165
+ backend = backends.get(provider)
166
+ if backend is None:
167
+ _post_fail(url, token, task_id, f"runner has no local backend for provider {provider!r}")
168
+ return
169
+ try:
170
+ gen = backend.generate(
171
+ system=str(task.get("system", "")),
172
+ prompt=str(task.get("prompt", "")),
173
+ model=(str(task["model"]) if task.get("model") else None),
174
+ )
175
+ except Exception as exc: # a failed generation (CLI missing/unauth'd, timeout) -> report, stay alive
176
+ _post_fail(url, token, task_id, str(exc)[:500])
177
+ return
178
+ try:
179
+ _request(
180
+ f"{url}/runners/tasks/{task_id}/result",
181
+ method="POST",
182
+ payload={
183
+ "text": gen.text,
184
+ "input_tokens": gen.input_tokens,
185
+ "output_tokens": gen.output_tokens,
186
+ "model": gen.model,
187
+ },
188
+ token=token,
189
+ )
190
+ print(f"task {task_id}: done ({gen.output_tokens} output tokens)")
191
+ except (urllib.error.HTTPError, urllib.error.URLError) as exc:
192
+ sys.stderr.write(f"task {task_id}: could not post result: {exc}\n")
193
+
194
+
195
+ def _post_build_result(
196
+ url: str, token: str, task_id: str, *, ok: bool, patch: str, changed_files: list[str], detail: str
197
+ ) -> None:
198
+ with contextlib.suppress(urllib.error.HTTPError, urllib.error.URLError):
199
+ _request(
200
+ f"{url}/runners/tasks/{task_id}/build-result",
201
+ method="POST",
202
+ payload={"ok": ok, "patch": patch, "changed_files": changed_files, "detail": detail},
203
+ token=token,
204
+ )
205
+
206
+
207
+ def _run_build(task: dict[str, Any], url: str, token: str) -> None:
208
+ """Clone the repo (read-only, this machine's git), run the coding agent on the seat, return a PATCH.
209
+ The server applies + authoritatively verifies it — this runner never pushes."""
210
+ task_id = str(task.get("task_id", ""))
211
+ runtime = str(task.get("runtime") or "claude-code")
212
+ spec = agent_spec(runtime)
213
+ if spec is None:
214
+ _post_build_result(
215
+ url, token, task_id, ok=False, patch="", changed_files=[], detail=f"unknown runtime {runtime!r}"
216
+ )
217
+ return
218
+ cmd = LocalCommandRunner()
219
+ repo_url = str(task.get("repo_url", ""))
220
+ base = str(task.get("base_branch") or "main")
221
+ with tempfile.TemporaryDirectory(prefix="orch-runner-build-") as tmp:
222
+ clone_dir = os.path.join(tmp, "repo")
223
+ cloned = cmd.run(
224
+ ["git", "clone", "--depth", "1", "--branch", base, repo_url, clone_dir], timeout_s=600
225
+ )
226
+ if cloned.returncode != 0:
227
+ _post_build_result(
228
+ url,
229
+ token,
230
+ task_id,
231
+ ok=False,
232
+ patch="",
233
+ changed_files=[],
234
+ detail=f"clone failed: {cloned.stderr[:300]}",
235
+ )
236
+ return
237
+ if not agent_available(spec, cmd, cwd=clone_dir):
238
+ _post_build_result(
239
+ url,
240
+ token,
241
+ task_id,
242
+ ok=False,
243
+ patch="",
244
+ changed_files=[],
245
+ detail=f"{spec.name} CLI not available/logged in on this runner",
246
+ )
247
+ return
248
+ brief = ImplementationBrief(
249
+ goal=str(task.get("goal", "")),
250
+ plan=str(task.get("plan", "")),
251
+ specs=tuple(str(s) for s in (task.get("specs") or [])),
252
+ )
253
+ result = run_coding_agent(spec, prompt=brief.to_prompt(), cwd=clone_dir, runner=cmd, timeout_s=3600)
254
+ if not result.ok:
255
+ _post_build_result(
256
+ url,
257
+ token,
258
+ task_id,
259
+ ok=False,
260
+ patch="",
261
+ changed_files=list(result.changed_files),
262
+ detail=f"{spec.name}: {result.detail}",
263
+ )
264
+ return
265
+ cmd.run(["git", "add", "-A"], cwd=clone_dir, timeout_s=120)
266
+ diff = cmd.run(["git", "diff", "--cached"], cwd=clone_dir, timeout_s=120)
267
+ _post_build_result(
268
+ url,
269
+ token,
270
+ task_id,
271
+ ok=True,
272
+ patch=diff.stdout,
273
+ changed_files=list(result.changed_files),
274
+ detail="",
275
+ )
276
+ print(f"build task {task_id}: done ({len(result.changed_files)} files)")
277
+
278
+
279
+ def cmd_run(args: argparse.Namespace) -> int:
280
+ cfg = _load_config()
281
+ if not cfg.get("token"):
282
+ sys.stderr.write("not paired — run `orchestrator-runner pair --url <site> --code <code>`\n")
283
+ return 1
284
+ url = str(cfg["url"]).rstrip("/")
285
+ token = str(cfg["token"])
286
+ backends = _local_backends()
287
+ if not backends:
288
+ sys.stderr.write("no provider CLI (claude/codex/gemini) on PATH — install and log into one\n")
289
+ return 1
290
+ poll = float(args.poll)
291
+ once = bool(args.once)
292
+ print(f"runner online for {sorted(backends)} — polling {url} every {poll}s (Ctrl-C to stop)")
293
+ while True:
294
+ try:
295
+ resp = _request(f"{url}/runners/tasks", method="GET", token=token)
296
+ except urllib.error.HTTPError as exc:
297
+ if exc.code == 401:
298
+ sys.stderr.write("credential rejected (revoked?) — re-pair\n")
299
+ return 1
300
+ sys.stderr.write(f"poll error: {exc.code}\n")
301
+ resp = {}
302
+ except urllib.error.URLError as exc:
303
+ sys.stderr.write(f"could not reach {url}: {exc}\n")
304
+ if once:
305
+ return 1
306
+ time.sleep(poll)
307
+ continue
308
+ task = resp.get("task")
309
+ if task:
310
+ if str(task.get("kind")) == "build":
311
+ _run_build(task, url, token)
312
+ else:
313
+ _run_one(task, backends, url, token)
314
+ if once:
315
+ return 0
316
+ if not task:
317
+ time.sleep(poll)
318
+
319
+
320
+ def main(argv: list[str] | None = None) -> int:
321
+ parser = argparse.ArgumentParser(
322
+ prog="orchestrator-runner",
323
+ description="Connect a local runner so your own LLM subscription powers your orchestrator work.",
324
+ )
325
+ sub = parser.add_subparsers(dest="command", required=True)
326
+
327
+ p_pair = sub.add_parser("pair", help="exchange a one-time pairing code for a runner credential")
328
+ p_pair.add_argument("--url", required=True, help="the orchestrator site, e.g. https://host")
329
+ p_pair.add_argument("--code", required=True, help="the one-time pairing code shown in the UI")
330
+ p_pair.add_argument("--name", default="", help="a name for this runner (optional)")
331
+ p_pair.add_argument(
332
+ "--allow-build",
333
+ action="store_true",
334
+ help="let this runner BUILD (run agent-written code + tests on this machine), not just generate",
335
+ )
336
+ p_pair.set_defaults(func=cmd_pair)
337
+
338
+ p_run = sub.add_parser("run", help="run queued generation tasks on this machine's seat CLIs")
339
+ p_run.add_argument("--poll", type=float, default=3.0, help="seconds between polls when idle")
340
+ p_run.add_argument("--once", action="store_true", help="handle at most one poll cycle, then exit")
341
+ p_run.set_defaults(func=cmd_run)
342
+
343
+ p_who = sub.add_parser("whoami", help="verify the stored runner credential against the server")
344
+ p_who.set_defaults(func=cmd_whoami)
345
+
346
+ args = parser.parse_args(argv)
347
+ func: Any = args.func
348
+ return int(func(args))
349
+
350
+
351
+ if __name__ == "__main__":
352
+ raise SystemExit(main())
@@ -0,0 +1,199 @@
1
+ """Third-party coding agents — run an EXTERNAL agent CLI (Claude Code, Codex, Kiro) as the implementer.
2
+
3
+ The orchestrator's own builder is a TDD cohort loop over a model backend. Some teams would rather drive the
4
+ implementation with a full coding agent they already use. This module makes that a drop-in: a
5
+ :class:`CodingAgentSpec` describes how to invoke one as a non-interactive CLI, and :func:`run_coding_agent`
6
+ runs it IN THE RUN SANDBOX (through the same ``CommandRunner`` the verification ladder uses), so the agent
7
+ edits the run's worktree and the SAME gates + verification ladder + dual review then judge its output — the
8
+ agent is swappable, the governance around it is not.
9
+
10
+ Execution needs the chosen CLI present in the run's devbox (like the Docker/K8s sandbox backends, it is
11
+ infra-gated): :func:`agent_available` probes for it and the external path fails CLOSED with a clear message
12
+ when it is missing, so a run never silently falls back to something the operator did not choose.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import subprocess
18
+ from collections.abc import Mapping
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+
22
+ from orchestrator_runner.command import CommandRunner
23
+
24
+ # the default: the orchestrator's own TDD cohort loop (no external CLI). Selecting this is "no third party".
25
+ BUILTIN_RUNTIME = "builtin"
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class CodingAgentSpec:
30
+ """How to invoke one external coding agent as a non-interactive CLI. ``prefix`` is the fixed argv; the
31
+ task prompt is appended as a single final argument. ``version_argv`` is the availability probe."""
32
+
33
+ id: str
34
+ name: str
35
+ prefix: tuple[str, ...]
36
+ version_argv: tuple[str, ...]
37
+ description: str = ""
38
+
39
+ def command(self, prompt: str) -> list[str]:
40
+ """The full argv to run ``prompt`` — the prompt is one argument, never shell-interpolated."""
41
+ return [*self.prefix, prompt]
42
+
43
+
44
+ # The built-in catalog. Each entry is the documented non-interactive invocation of a real agent CLI; the
45
+ # exact flags can be tuned per deployment. These run only where the CLI is installed in the devbox.
46
+ BUILTIN_AGENTS: tuple[CodingAgentSpec, ...] = (
47
+ CodingAgentSpec(
48
+ id="claude-code",
49
+ name="Claude Code",
50
+ prefix=("claude", "-p"), # -p = print/non-interactive: run the prompt headless and exit
51
+ version_argv=("claude", "--version"),
52
+ description="Anthropic's Claude Code CLI, run headless over the run's worktree.",
53
+ ),
54
+ CodingAgentSpec(
55
+ id="codex",
56
+ name="OpenAI Codex",
57
+ prefix=("codex", "exec"), # exec = non-interactive one-shot
58
+ version_argv=("codex", "--version"),
59
+ description="OpenAI's Codex CLI, run non-interactively over the run's worktree.",
60
+ ),
61
+ CodingAgentSpec(
62
+ id="kiro",
63
+ name="Kiro",
64
+ prefix=("kiro", "run", "--prompt"),
65
+ version_argv=("kiro", "--version"),
66
+ description="AWS Kiro agent CLI, run over the run's worktree.",
67
+ ),
68
+ )
69
+
70
+ _BY_ID: dict[str, CodingAgentSpec] = {a.id: a for a in BUILTIN_AGENTS}
71
+
72
+
73
+ def agent_spec(runtime_id: str) -> CodingAgentSpec | None:
74
+ """The spec for an external runtime id, or None (``builtin`` / unknown has no external CLI)."""
75
+ return _BY_ID.get(runtime_id)
76
+
77
+
78
+ def is_external(runtime_id: str) -> bool:
79
+ return runtime_id in _BY_ID
80
+
81
+
82
+ def list_runtimes() -> list[dict[str, str]]:
83
+ """The selectable implementation runtimes: the built-in loop + every external agent (id + name)."""
84
+ out = [
85
+ {
86
+ "id": BUILTIN_RUNTIME,
87
+ "name": "Built-in (TDD loop)",
88
+ "description": "The orchestrator's own builder.",
89
+ }
90
+ ]
91
+ out += [{"id": a.id, "name": a.name, "description": a.description} for a in BUILTIN_AGENTS]
92
+ return out
93
+
94
+
95
+ @dataclass(frozen=True, slots=True)
96
+ class CodingAgentResult:
97
+ ok: bool
98
+ runtime: str
99
+ exit_code: int
100
+ changed_files: tuple[str, ...] = ()
101
+ output_tail: str = ""
102
+ detail: str = ""
103
+
104
+
105
+ def agent_available(spec: CodingAgentSpec, runner: CommandRunner, *, cwd: str | Path | None = None) -> bool:
106
+ """Whether the agent's CLI is runnable in the sandbox (the version probe exits 0). Fail-closed: a missing
107
+ binary (FileNotFoundError), a timeout, or a non-zero exit all mean 'not available'."""
108
+ try:
109
+ res = runner.run(list(spec.version_argv), cwd=cwd, timeout_s=30)
110
+ except (FileNotFoundError, subprocess.TimeoutExpired):
111
+ return False
112
+ return res.returncode == 0
113
+
114
+
115
+ def _changed_files(runner: CommandRunner, cwd: str | Path) -> tuple[str, ...]:
116
+ """Files the agent created/modified, via ``git status --porcelain`` in the worktree (best-effort)."""
117
+ try:
118
+ res = runner.run(["git", "status", "--porcelain"], cwd=cwd, timeout_s=60)
119
+ except (FileNotFoundError, subprocess.TimeoutExpired):
120
+ return ()
121
+ if res.returncode != 0:
122
+ return ()
123
+ files: list[str] = []
124
+ for line in res.stdout.splitlines():
125
+ path = line[3:].strip() if len(line) > 3 else line.strip()
126
+ if path:
127
+ files.append(path)
128
+ return tuple(files)
129
+
130
+
131
+ def run_coding_agent(
132
+ spec: CodingAgentSpec,
133
+ *,
134
+ prompt: str,
135
+ cwd: str | Path,
136
+ runner: CommandRunner,
137
+ timeout_s: int = 1800,
138
+ env: Mapping[str, str] | None = None,
139
+ ) -> CodingAgentResult:
140
+ """Run the external agent over the worktree at ``cwd`` with ``prompt``, then report what it changed.
141
+
142
+ Fail-closed: a missing CLI (FileNotFoundError) or a timeout returns ``ok=False`` with a clear reason
143
+ rather than raising, so the executor can surface it as a run failure the operator can act on."""
144
+ argv = spec.command(prompt)
145
+ try:
146
+ res = runner.run(argv, cwd=cwd, timeout_s=timeout_s, env=env)
147
+ except FileNotFoundError:
148
+ return CodingAgentResult(
149
+ ok=False,
150
+ runtime=spec.id,
151
+ exit_code=127,
152
+ detail=f"{spec.name} CLI ({spec.prefix[0]!r}) is not installed in this run's devbox",
153
+ )
154
+ except subprocess.TimeoutExpired:
155
+ return CodingAgentResult(
156
+ ok=False, runtime=spec.id, exit_code=124, detail=f"{spec.name} timed out after {timeout_s}s"
157
+ )
158
+ changed = _changed_files(runner, cwd)
159
+ output = f"{res.stdout}\n{res.stderr}".strip()
160
+ tail = "\n".join(output.splitlines()[-20:])
161
+ ok = res.returncode == 0 and bool(changed)
162
+ detail = ""
163
+ if res.returncode != 0:
164
+ detail = f"{spec.name} exited {res.returncode}"
165
+ elif not changed:
166
+ detail = f"{spec.name} made no file changes"
167
+ return CodingAgentResult(
168
+ ok=ok,
169
+ runtime=spec.id,
170
+ exit_code=res.returncode,
171
+ changed_files=changed,
172
+ output_tail=tail,
173
+ detail=detail,
174
+ )
175
+
176
+
177
+ @dataclass(frozen=True, slots=True)
178
+ class ImplementationBrief:
179
+ """What an external agent is told to build — assembled from the approved product + engineering tiers."""
180
+
181
+ goal: str
182
+ plan: str = ""
183
+ specs: tuple[str, ...] = field(default_factory=tuple)
184
+
185
+ def to_prompt(self) -> str:
186
+ parts = [
187
+ "Implement the following feature in this repository. Make the changes directly in the working "
188
+ "tree; write real, tested, production-quality code that satisfies the specification and plan.",
189
+ f"\n## Goal\n{self.goal}",
190
+ ]
191
+ if self.plan:
192
+ parts.append(f"\n## Engineering plan\n{self.plan}")
193
+ for i, spec in enumerate(self.specs, 1):
194
+ parts.append(f"\n## Specification {i}\n{spec}")
195
+ parts.append(
196
+ "\nWhen done, ensure the code lints, type-checks, and its tests pass. Do not open a pull "
197
+ "request — the orchestrator handles delivery after its own review."
198
+ )
199
+ return "\n".join(parts)
@@ -0,0 +1,97 @@
1
+ """The command-execution seam — how a run's build/verify commands actually run.
2
+
3
+ The verification ladder (and anything else that shells out during a run) executes commands through a
4
+ ``CommandRunner`` instead of calling ``subprocess`` directly. The default ``LocalCommandRunner`` runs them
5
+ as subprocesses on the host — exactly today's behavior. A containerized runner is a drop-in behind the same
6
+ port (the per-run sandbox), so a run's lint/typecheck/tests can run inside an isolated devbox without
7
+ touching the ladder or the executor above it — the same agnostic-seam pattern as the model backends and the
8
+ filesystem ``Sandbox``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import subprocess
15
+ from collections.abc import Mapping, Sequence
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Protocol, runtime_checkable
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class CommandResult:
23
+ """The outcome of one command: its exit code and captured output."""
24
+
25
+ returncode: int
26
+ stdout: str
27
+ stderr: str
28
+
29
+
30
+ @runtime_checkable
31
+ class CommandRunner(Protocol):
32
+ """Runs one command and returns its result.
33
+
34
+ Implementations run it wherever isolation requires — on the host (local) or inside a per-run container
35
+ (sandbox). ``run`` may raise ``FileNotFoundError`` (the executable is not present) or
36
+ ``subprocess.TimeoutExpired`` (it exceeded ``timeout_s``); callers that must fail closed catch those
37
+ (the verification ladder does). ``env`` is ADDITIVE — the given vars are added to (not a replacement of)
38
+ the ambient environment, so a run's per-project secrets layer on top of the toolchain's own PATH/env;
39
+ ``None`` means no additions.
40
+ """
41
+
42
+ def run(
43
+ self,
44
+ argv: Sequence[str],
45
+ *,
46
+ cwd: str | Path | None = None,
47
+ timeout_s: int = 300,
48
+ env: Mapping[str, str] | None = None,
49
+ ) -> CommandResult: ...
50
+
51
+
52
+ class LocalCommandRunner:
53
+ """Runs commands as subprocesses on the local host — the default, today's behavior."""
54
+
55
+ def run(
56
+ self,
57
+ argv: Sequence[str],
58
+ *,
59
+ cwd: str | Path | None = None,
60
+ timeout_s: int = 300,
61
+ env: Mapping[str, str] | None = None,
62
+ ) -> CommandResult:
63
+ proc = subprocess.run(
64
+ list(argv),
65
+ capture_output=True,
66
+ text=True,
67
+ encoding="utf-8",
68
+ errors="replace",
69
+ cwd=cwd,
70
+ # additive: merge onto the ambient env so PATH/etc survive; a bare replacement would strip them
71
+ env={**os.environ, **env} if env is not None else None,
72
+ timeout=timeout_s,
73
+ check=False,
74
+ )
75
+ return CommandResult(proc.returncode, proc.stdout, proc.stderr)
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class ScopedEnvRunner:
80
+ """Wraps a ``CommandRunner`` to inject a fixed base environment into EVERY command it runs — a run's
81
+ per-project secrets, applied to each build/verify step. A per-call ``env`` overrides the base key-wise.
82
+ The base is additive wherever the inner runner is (the local runner merges onto the host env; the
83
+ container runner adds ``-e`` flags), so the toolchain's own environment is preserved."""
84
+
85
+ inner: CommandRunner
86
+ base_env: Mapping[str, str]
87
+
88
+ def run(
89
+ self,
90
+ argv: Sequence[str],
91
+ *,
92
+ cwd: str | Path | None = None,
93
+ timeout_s: int = 300,
94
+ env: Mapping[str, str] | None = None,
95
+ ) -> CommandResult:
96
+ merged = {**self.base_env, **(env or {})}
97
+ return self.inner.run(argv, cwd=cwd, timeout_s=timeout_s, env=merged or None)
@@ -0,0 +1,50 @@
1
+ """orchestrator-runner — the standalone client is importable + self-contained, and its capability detection
2
+ + backend selection behave. The network/subprocess paths (pair/run/build) are exercised out-of-band."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Callable
7
+
8
+ import pytest
9
+
10
+ from orchestrator_runner import cli
11
+ from orchestrator_runner.backends import ClaudeCliBackend, CodexCliBackend, GeminiCliBackend
12
+ from orchestrator_runner.coding import agent_spec
13
+
14
+
15
+ def _which(*installed: str) -> Callable[[str], str | None]:
16
+ """A ``shutil.which`` stand-in: a path for the named tools, None otherwise."""
17
+ return lambda name: f"/usr/bin/{name}" if name in installed else None
18
+
19
+
20
+ def test_detect_capabilities_build_is_opt_in(monkeypatch: pytest.MonkeyPatch) -> None:
21
+ monkeypatch.setattr("orchestrator_runner.cli.shutil.which", _which("claude", "git"))
22
+ caps = cli.detect_capabilities()
23
+ assert caps["providers"] == ["anthropic"]
24
+ assert caps["build"] is False # git present, but build not opted in
25
+ assert cli.detect_capabilities(allow_build=True)["build"] is True
26
+
27
+
28
+ def test_detect_capabilities_no_build_without_git(monkeypatch: pytest.MonkeyPatch) -> None:
29
+ monkeypatch.setattr("orchestrator_runner.cli.shutil.which", _which("claude"))
30
+ assert cli.detect_capabilities(allow_build=True)["build"] is False
31
+
32
+
33
+ def test_local_backends_only_installed_clis(monkeypatch: pytest.MonkeyPatch) -> None:
34
+ monkeypatch.setattr("orchestrator_runner.cli.shutil.which", _which("claude", "codex"))
35
+ backends = cli._local_backends()
36
+ assert set(backends) == {"anthropic", "openai"}
37
+ assert isinstance(backends["anthropic"], ClaudeCliBackend)
38
+ assert isinstance(backends["openai"], CodexCliBackend)
39
+
40
+
41
+ def test_backends_construct() -> None:
42
+ assert ClaudeCliBackend().name == "claude-cli"
43
+ assert CodexCliBackend().name == "codex-cli"
44
+ assert GeminiCliBackend().name == "gemini-cli"
45
+
46
+
47
+ def test_agent_spec_known_and_unknown() -> None:
48
+ assert agent_spec("claude-code") is not None
49
+ assert agent_spec("codex") is not None
50
+ assert agent_spec("not-a-runtime") is None