run-harness 0.1.0__py3-none-any.whl

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 @@
1
+ """run-harness: one CLI for coding-agent harnesses."""
@@ -0,0 +1,3 @@
1
+ from run_harness.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,16 @@
1
+ """Harness adapters and their registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from run_harness.adapters.base import Adapter
6
+ from run_harness.adapters.claude import ClaudeAdapter
7
+ from run_harness.adapters.codex import CodexAdapter
8
+ from run_harness.adapters.opencode import OpenCodeAdapter
9
+ from run_harness.adapters.pi import PiAdapter
10
+
11
+ ADAPTERS: dict[str, Adapter] = {
12
+ "claude": ClaudeAdapter(),
13
+ "codex": CodexAdapter(),
14
+ "opencode": OpenCodeAdapter(),
15
+ "pi": PiAdapter(),
16
+ }
@@ -0,0 +1,156 @@
1
+ """Adapter interface shared by every harness (spec section 7)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ from abc import ABC, abstractmethod
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass
11
+ from typing import Any, Protocol, TextIO
12
+
13
+ from run_harness.request import HelperNotFound, RunRequest
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class FinalMessage:
18
+ """The stream produced a final message (possibly empty)."""
19
+
20
+ text: str
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class StreamFailure:
25
+ """The stream reported an error or had no final message. The run exits 1."""
26
+
27
+ error_text: str
28
+
29
+
30
+ Outcome = FinalMessage | StreamFailure
31
+
32
+
33
+ class StreamHandler(Protocol):
34
+ def feed(self, line: str) -> None:
35
+ """Called once per stdout line as it arrives, line ending kept."""
36
+
37
+ def finish(self) -> Outcome:
38
+ """Called once after stdout closes."""
39
+
40
+
41
+ def parse_json_line(line: str) -> dict[str, Any] | None:
42
+ """Parse one JSONL line into an object. None for non-JSON, truncated or non-object lines."""
43
+ try:
44
+ value = json.loads(line)
45
+ except ValueError:
46
+ return None
47
+ return value if isinstance(value, dict) else None
48
+
49
+
50
+ def ensure_newline(text: str) -> str:
51
+ """`text` with a trailing newline added only when it is missing."""
52
+ return text if text.endswith("\n") else text + "\n"
53
+
54
+
55
+ def resolve_executable(name: str, env: Mapping[str, str]) -> str:
56
+ """Resolve `name` on the env's PATH. Raises HelperNotFound when it isn't there."""
57
+ resolved = shutil.which(name, path=env.get("PATH"))
58
+ if resolved is None:
59
+ raise HelperNotFound(f"{name}: not found on PATH")
60
+ return resolved
61
+
62
+
63
+ class ProgressWriter:
64
+ """Writes the progress the JSON-mode handlers render to stderr.
65
+
66
+ Text that stderr can't encode (surrogates from undecodable bytes) is written with
67
+ backslash escapes instead of raising.
68
+ """
69
+
70
+ def __init__(self, stderr: TextIO) -> None:
71
+ self._stderr = stderr
72
+
73
+ def _emit(self, text: str) -> None:
74
+ self._stderr.write(text.encode("utf-8", "backslashreplace").decode("utf-8"))
75
+ self._stderr.flush()
76
+
77
+ def text(self, text: str) -> None:
78
+ """Assistant text. Nothing when empty."""
79
+ if text:
80
+ self._emit(ensure_newline(text))
81
+
82
+ def tool(self, name: object) -> None:
83
+ """A tool call. Nothing when the name is missing or empty."""
84
+ if isinstance(name, str) and name:
85
+ self._emit(f"→ tool: {name}\n")
86
+
87
+ def raw(self, line: str) -> None:
88
+ """A non-JSON line, verbatim. Nothing when it is empty or whitespace-only."""
89
+ if line.strip():
90
+ self._emit(ensure_newline(line))
91
+
92
+
93
+ def run_check(argv: list[str], env: Mapping[str, str], cwd: str) -> tuple[int, str]:
94
+ """Run a pre-launch check helper with the run's env and cwd.
95
+
96
+ Returns (exit code, stdout). A negative exit code means a signal. Raises HelperNotFound
97
+ when argv[0] is not on the env's PATH. The helper's stdin is closed and its stderr dropped.
98
+ """
99
+ resolved = resolve_executable(argv[0], env)
100
+ result = subprocess.run(
101
+ argv,
102
+ executable=resolved,
103
+ cwd=cwd,
104
+ env=dict(env),
105
+ stdin=subprocess.DEVNULL,
106
+ stdout=subprocess.PIPE,
107
+ stderr=subprocess.DEVNULL,
108
+ check=False,
109
+ )
110
+ return result.returncode, result.stdout.decode("utf-8", errors="replace")
111
+
112
+
113
+ class Adapter(ABC):
114
+ """One harness. Subclasses implement `command` and `new_stream_handler`.
115
+
116
+ Facts: `precheck` may return a mapping of facts it detected (e.g. Pi's major version). The
117
+ runner then calls `build` again with them, and `build` hands them to `command`, which every
118
+ adapter accepts (and most ignore).
119
+ """
120
+
121
+ @abstractmethod
122
+ def command(
123
+ self, request: RunRequest, environ: Mapping[str, str], facts: Mapping[str, Any]
124
+ ) -> tuple[list[str], Mapping[str, str]]:
125
+ """Return the full argv (starting with the executable name) and env overrides.
126
+
127
+ Pure. The overrides are applied on top of `environ`; PWD is handled by `build`.
128
+ `facts` is what `precheck` returned (empty when it found nothing).
129
+ """
130
+
131
+ def build(
132
+ self,
133
+ request: RunRequest,
134
+ environ: Mapping[str, str],
135
+ facts: Mapping[str, Any] | None = None,
136
+ ) -> tuple[list[str], dict[str, str]]:
137
+ """Return (argv, child env). Pure; PWD is always set to the request's cwd.
138
+
139
+ `facts` is what `precheck` returned for this request, if anything.
140
+ """
141
+ argv, overrides = self.command(request, environ, facts or {})
142
+ env = dict(environ)
143
+ env.update(overrides)
144
+ env["PWD"] = request.cwd
145
+ return argv, env
146
+
147
+ def precheck(self, request: RunRequest, env: Mapping[str, str]) -> Mapping[str, Any]:
148
+ """Pre-launch checks. Raise UsageError (exit 2) or HelperNotFound (exit 127).
149
+
150
+ Returns facts for `command`; the default checks nothing and returns none.
151
+ """
152
+ return {}
153
+
154
+ @abstractmethod
155
+ def new_stream_handler(self, stderr: TextIO) -> StreamHandler:
156
+ """A fresh handler for one run. JSON-mode handlers render progress to `stderr`."""
@@ -0,0 +1,101 @@
1
+ """Claude Code adapter (spec sections 4.1, 5, 6)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any, TextIO
7
+
8
+ from run_harness.adapters.base import (
9
+ Adapter,
10
+ FinalMessage,
11
+ Outcome,
12
+ ProgressWriter,
13
+ StreamFailure,
14
+ StreamHandler,
15
+ parse_json_line,
16
+ )
17
+ from run_harness.request import RunRequest
18
+
19
+
20
+ class ClaudeStreamHandler:
21
+ """Render progress from stream-json events and keep the last `result` event."""
22
+
23
+ def __init__(self, stderr: TextIO) -> None:
24
+ self._progress = ProgressWriter(stderr)
25
+ self._result: dict[str, Any] | None = None
26
+
27
+ def feed(self, line: str) -> None:
28
+ event = parse_json_line(line)
29
+ if event is None:
30
+ # Not JSON (or a truncated line): forward it raw.
31
+ self._progress.raw(line)
32
+ return
33
+ kind = event.get("type")
34
+ if kind == "result":
35
+ self._result = event
36
+ elif kind == "assistant":
37
+ self._render_assistant(event)
38
+
39
+ def _render_assistant(self, event: dict[str, Any]) -> None:
40
+ message = event.get("message")
41
+ content = message.get("content") if isinstance(message, dict) else None
42
+ if not isinstance(content, list):
43
+ return
44
+ for block in content:
45
+ if not isinstance(block, dict):
46
+ continue
47
+ if block.get("type") == "text":
48
+ text = block.get("text")
49
+ if isinstance(text, str):
50
+ self._progress.text(text)
51
+ elif block.get("type") == "tool_use":
52
+ self._progress.tool(block.get("name"))
53
+
54
+ def finish(self) -> Outcome:
55
+ result = self._result
56
+ if result is None:
57
+ return StreamFailure("claude: stream ended without a result event")
58
+ text = result.get("result")
59
+ if result.get("is_error") is True:
60
+ if isinstance(text, str) and text:
61
+ return StreamFailure(text)
62
+ return StreamFailure(self._describe_error(result))
63
+ if isinstance(text, str):
64
+ return FinalMessage(text)
65
+ return StreamFailure(self._describe_error(result))
66
+
67
+ @staticmethod
68
+ def _describe_error(result: dict[str, Any]) -> str:
69
+ errors = result.get("errors")
70
+ if isinstance(errors, list) and errors:
71
+ return "; ".join(str(e) for e in errors)
72
+ subtype = result.get("subtype")
73
+ return str(subtype) if subtype else "claude: result event has no result text"
74
+
75
+
76
+ class ClaudeAdapter(Adapter):
77
+ def command(
78
+ self, request: RunRequest, environ: Mapping[str, str], facts: Mapping[str, Any]
79
+ ) -> tuple[list[str], Mapping[str, str]]:
80
+ # No positional prompt: it goes in on stdin. In read-only mode the variadic --tools is
81
+ # followed by a non-variadic flag, so later arguments can't be swallowed by it.
82
+ argv = ["claude", "-p", "--output-format", "stream-json", "--verbose"]
83
+ if request.autonomous:
84
+ argv += ["--dangerously-skip-permissions"]
85
+ else:
86
+ argv += [
87
+ "--tools",
88
+ "Read,Grep,Glob",
89
+ "--permission-mode",
90
+ "dontAsk",
91
+ "--strict-mcp-config",
92
+ ]
93
+ if request.model is not None:
94
+ argv += ["--model", request.model]
95
+ if request.effort is not None:
96
+ argv += ["--effort", request.effort]
97
+ argv += request.passthrough
98
+ return argv, {}
99
+
100
+ def new_stream_handler(self, stderr: TextIO) -> StreamHandler:
101
+ return ClaudeStreamHandler(stderr)
@@ -0,0 +1,43 @@
1
+ """Codex adapter (spec section 4.2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any, TextIO
7
+
8
+ from run_harness.adapters.base import Adapter, FinalMessage, Outcome, StreamHandler
9
+ from run_harness.request import RunRequest
10
+
11
+
12
+ class CodexStreamHandler:
13
+ """Codex's stdout, when piped, is the final message. Buffer it verbatim."""
14
+
15
+ def __init__(self) -> None:
16
+ self._parts: list[str] = []
17
+
18
+ def feed(self, line: str) -> None:
19
+ self._parts.append(line)
20
+
21
+ def finish(self) -> Outcome:
22
+ return FinalMessage("".join(self._parts))
23
+
24
+
25
+ class CodexAdapter(Adapter):
26
+ def command(
27
+ self, request: RunRequest, environ: Mapping[str, str], facts: Mapping[str, Any]
28
+ ) -> tuple[list[str], Mapping[str, str]]:
29
+ # No positional prompt: Codex reads it from stdin to EOF.
30
+ argv = ["codex", "exec", "--skip-git-repo-check", "--color", "never"]
31
+ if request.autonomous:
32
+ argv += ["--dangerously-bypass-approvals-and-sandbox"]
33
+ else:
34
+ argv += ["-s", "read-only"]
35
+ if request.model is not None:
36
+ argv += ["-m", request.model]
37
+ if request.effort is not None:
38
+ argv += ["-c", f"model_reasoning_effort={request.effort}"]
39
+ argv += request.passthrough
40
+ return argv, {}
41
+
42
+ def new_stream_handler(self, stderr: TextIO) -> StreamHandler:
43
+ return CodexStreamHandler()
@@ -0,0 +1,243 @@
1
+ """OpenCode adapter (spec section 4.4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping
7
+ from typing import Any, TextIO
8
+
9
+ from run_harness.adapters.base import (
10
+ Adapter,
11
+ FinalMessage,
12
+ Outcome,
13
+ ProgressWriter,
14
+ StreamFailure,
15
+ StreamHandler,
16
+ parse_json_line,
17
+ run_check,
18
+ )
19
+ from run_harness.request import RunRequest, UsageError
20
+
21
+ CONFIG_VAR = "OPENCODE_CONFIG_CONTENT"
22
+ READONLY_AGENT = "run-harness-readonly"
23
+ READONLY_AGENT_CONFIG: dict[str, Any] = {
24
+ "mode": "primary",
25
+ "description": "run-harness read-only",
26
+ "permission": {
27
+ "*": "deny",
28
+ "read": "allow",
29
+ "glob": "allow",
30
+ "grep": "allow",
31
+ "list": "allow",
32
+ "lsp": "allow",
33
+ },
34
+ }
35
+
36
+
37
+ def merged_config(caller_value: str | None) -> str:
38
+ """The caller's config with only `agent.run-harness-readonly` added or replaced."""
39
+ if caller_value is None:
40
+ config: Any = {}
41
+ else:
42
+ try:
43
+ config = json.loads(caller_value)
44
+ except ValueError as exc:
45
+ raise UsageError(f"{CONFIG_VAR} is not valid JSON: {exc}") from exc
46
+ if not isinstance(config, dict):
47
+ raise UsageError(f"{CONFIG_VAR} must be a JSON object")
48
+ agents = config.get("agent", {})
49
+ if not isinstance(agents, dict):
50
+ raise UsageError(f'{CONFIG_VAR}: "agent" must be a JSON object')
51
+ config["agent"] = {**agents, READONLY_AGENT: READONLY_AGENT_CONFIG}
52
+ return json.dumps(config, separators=(",", ":"))
53
+
54
+
55
+ def _expected_permissions() -> list[dict[str, str]]:
56
+ permission = READONLY_AGENT_CONFIG["permission"]
57
+ return [
58
+ {"action": action, "resource": "*", "effect": effect}
59
+ for action, effect in permission.items()
60
+ ]
61
+
62
+
63
+ def check_readonly_agent(output: str) -> None:
64
+ """Confirm `opencode api GET /api/config` shows the injected agent with its deny rules.
65
+
66
+ The output is a JSON list of config documents in merge order. The last document that
67
+ defines `run-harness-readonly` wins, so its permissions must be exactly the injected ones
68
+ (`* deny` first, then the read-only allows). Raises ValueError otherwise.
69
+ """
70
+ try:
71
+ documents = json.loads(output)
72
+ except ValueError as exc:
73
+ raise ValueError(f"unparseable config output: {exc}") from exc
74
+ if not isinstance(documents, list):
75
+ raise ValueError("config output is not a JSON list")
76
+ definition: Any = None
77
+ for document in documents:
78
+ info = document.get("info") if isinstance(document, dict) else None
79
+ agents = info.get("agents") if isinstance(info, dict) else None
80
+ if isinstance(agents, dict) and READONLY_AGENT in agents:
81
+ definition = agents[READONLY_AGENT]
82
+ if definition is None:
83
+ raise ValueError(f"agent {READONLY_AGENT!r} is not in the effective config")
84
+ permissions = definition.get("permissions") if isinstance(definition, dict) else None
85
+ if permissions != _expected_permissions():
86
+ raise ValueError(f"agent {READONLY_AGENT!r} does not have the injected permissions")
87
+
88
+
89
+ def parse_model_variants(output: str) -> dict[str, list[str]]:
90
+ """Parse `opencode api GET /api/model` into {"provider/model": [variant ids]}.
91
+
92
+ Raises ValueError when the output is not the expected `{"data": [model, ...]}` shape.
93
+ """
94
+ try:
95
+ document = json.loads(output)
96
+ except ValueError as exc:
97
+ raise ValueError(f"unparseable model list: {exc}") from exc
98
+ data = document.get("data") if isinstance(document, dict) else None
99
+ if not isinstance(data, list):
100
+ raise ValueError('model list has no "data" list')
101
+ models: dict[str, list[str]] = {}
102
+ for model in data:
103
+ if not isinstance(model, dict):
104
+ raise ValueError("model entry is not a JSON object")
105
+ provider, model_id, variants = (
106
+ model.get("providerID"),
107
+ model.get("modelID"),
108
+ model.get("variants", []),
109
+ )
110
+ if not isinstance(provider, str) or not isinstance(model_id, str):
111
+ raise ValueError("model entry has no providerID/modelID")
112
+ if not isinstance(variants, list):
113
+ raise ValueError(f"variants of {provider}/{model_id} is not a list")
114
+ models[f"{provider}/{model_id}"] = [
115
+ v["id"] for v in variants if isinstance(v, dict) and isinstance(v.get("id"), str)
116
+ ]
117
+ return models
118
+
119
+
120
+ def _error_message(error: Any) -> str:
121
+ if isinstance(error, dict):
122
+ if isinstance(error.get("message"), str) and error["message"]:
123
+ return error["message"]
124
+ data = error.get("data")
125
+ if isinstance(data, dict) and isinstance(data.get("message"), str) and data["message"]:
126
+ return data["message"]
127
+ if isinstance(error.get("name"), str) and error["name"]:
128
+ return error["name"]
129
+ elif isinstance(error, str) and error:
130
+ return error
131
+ return "opencode reported an error"
132
+
133
+
134
+ class OpenCodeStreamHandler:
135
+ """Renders progress to stderr and extracts the last assistant message's text."""
136
+
137
+ def __init__(self, stderr: TextIO) -> None:
138
+ self._progress = ProgressWriter(stderr)
139
+ self._text_parts: list[tuple[Any, str]] = []
140
+ self._saw_step_finish = False
141
+ self._final_message_id: Any = None
142
+ self._errors: list[str] = []
143
+
144
+ def feed(self, line: str) -> None:
145
+ event = parse_json_line(line)
146
+ if event is None:
147
+ self._progress.raw(line)
148
+ return
149
+ kind = event.get("type")
150
+ part = event.get("part")
151
+ if not isinstance(part, dict):
152
+ part = {}
153
+ if kind == "text" and isinstance(part.get("text"), str):
154
+ self._text_parts.append((part.get("messageID"), part["text"]))
155
+ self._final_message_id = part.get("messageID")
156
+ self._progress.text(part["text"])
157
+ elif kind == "tool_use":
158
+ self._progress.tool(part.get("tool"))
159
+ elif kind == "step_finish":
160
+ self._saw_step_finish = True
161
+ self._final_message_id = part.get("messageID")
162
+ elif kind == "error":
163
+ self._errors.append(_error_message(event.get("error")))
164
+
165
+ def finish(self) -> Outcome:
166
+ if self._errors:
167
+ return StreamFailure("\n".join(self._errors))
168
+ if not self._text_parts and not self._saw_step_finish:
169
+ return StreamFailure("opencode finished without a final message")
170
+ # The last text or step_finish event names the final message: 2.x ends with the final
171
+ # text (no step_finish after it), 1.x ends with the step_finish of that message.
172
+ final_id = self._final_message_id
173
+ return FinalMessage(
174
+ "".join(text for message_id, text in self._text_parts if message_id == final_id)
175
+ )
176
+
177
+
178
+ class OpenCodeAdapter(Adapter):
179
+ def command(
180
+ self, request: RunRequest, environ: Mapping[str, str], facts: Mapping[str, Any]
181
+ ) -> tuple[list[str], Mapping[str, str]]:
182
+ # No positional prompt: `opencode run` reads it from stdin to EOF. --standalone runs a
183
+ # private server that sees this child's env (the shared background service would not).
184
+ argv = ["opencode", "run", "--standalone", "--format", "json"]
185
+ overrides: dict[str, str] = {}
186
+ if request.autonomous:
187
+ argv += ["--auto"]
188
+ else:
189
+ argv += ["--agent", READONLY_AGENT]
190
+ overrides[CONFIG_VAR] = merged_config(environ.get(CONFIG_VAR))
191
+ if request.model is not None:
192
+ # 2.x has no --variant: the effort level is the `#variant` suffix of the model.
193
+ suffix = f"#{request.effort}" if request.effort is not None else ""
194
+ argv += ["-m", f"{request.model}{suffix}"]
195
+ argv += request.passthrough
196
+ return argv, overrides
197
+
198
+ def precheck(self, request: RunRequest, env: Mapping[str, str]) -> Mapping[str, Any]:
199
+ if not request.autonomous:
200
+ self._check_readonly_agent(request, env)
201
+ if request.effort is not None:
202
+ self._check_effort(request, env)
203
+ return {}
204
+
205
+ def _check_readonly_agent(self, request: RunRequest, env: Mapping[str, str]) -> None:
206
+ argv = ["opencode", "api", "--standalone", "GET", "/api/config"]
207
+ code, output = run_check(argv, env, request.cwd)
208
+ if code != 0:
209
+ raise UsageError(
210
+ f"read-only mode cannot be guaranteed: `{' '.join(argv)}` exited {code}"
211
+ )
212
+ try:
213
+ check_readonly_agent(output)
214
+ except ValueError as exc:
215
+ raise UsageError(f"read-only mode cannot be guaranteed: {exc}") from exc
216
+
217
+ def _check_effort(self, request: RunRequest, env: Mapping[str, str]) -> None:
218
+ model, effort = request.model, request.effort
219
+ if model is None:
220
+ raise UsageError("--effort requires --model for opencode")
221
+ if "#" in model:
222
+ raise UsageError("--effort cannot be combined with a #variant in --model")
223
+ argv = ["opencode", "api", "--standalone", "GET", "/api/model"]
224
+ code, output = run_check(argv, env, request.cwd)
225
+ if code != 0:
226
+ raise UsageError(f"cannot validate --effort: `{' '.join(argv)}` exited {code}")
227
+ try:
228
+ models = parse_model_variants(output)
229
+ except ValueError as exc:
230
+ raise UsageError(f"cannot validate --effort: unexpected model list: {exc}") from exc
231
+ variants = models.get(model)
232
+ if variants is None:
233
+ raise UsageError(f"cannot validate --effort: model {model!r} is not listed")
234
+ if not variants:
235
+ raise UsageError(f"model {model!r} has no variants, so --effort is not supported")
236
+ if effort not in variants:
237
+ raise UsageError(
238
+ f"--effort {effort!r} is not a variant of {model!r} "
239
+ f"(available: {', '.join(sorted(variants))})"
240
+ )
241
+
242
+ def new_stream_handler(self, stderr: TextIO) -> StreamHandler:
243
+ return OpenCodeStreamHandler(stderr)
@@ -0,0 +1,113 @@
1
+ """Pi adapter (spec sections 4.3, 5 and 6)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping
7
+ from typing import Any, TextIO
8
+
9
+ from run_harness.adapters.base import (
10
+ Adapter,
11
+ FinalMessage,
12
+ Outcome,
13
+ ProgressWriter,
14
+ StreamFailure,
15
+ StreamHandler,
16
+ parse_json_line,
17
+ run_check,
18
+ )
19
+ from run_harness.request import RunRequest, UsageError
20
+
21
+ _VERSION = re.compile(r"\s*v?(\d+)\.(\d+)\.(\d+)")
22
+ MAJOR_FACT = "pi_major"
23
+
24
+
25
+ def parse_major_version(output: str) -> int | None:
26
+ """Major version from the leading MAJOR.MINOR.PATCH of `pi --version`, else None."""
27
+ match = _VERSION.match(output)
28
+ return int(match.group(1)) if match else None
29
+
30
+
31
+ def _text_of(message: dict[str, Any]) -> str:
32
+ content = message.get("content")
33
+ if not isinstance(content, list):
34
+ return ""
35
+ return "".join(
36
+ block["text"]
37
+ for block in content
38
+ if isinstance(block, dict)
39
+ and block.get("type") == "text"
40
+ and isinstance(block.get("text"), str)
41
+ )
42
+
43
+
44
+ class PiStreamHandler:
45
+ """Extracts the last assistant `message_end` and renders progress to stderr."""
46
+
47
+ def __init__(self, stderr: TextIO) -> None:
48
+ self._progress = ProgressWriter(stderr)
49
+ self._last_assistant_message: dict[str, Any] | None = None
50
+
51
+ def feed(self, line: str) -> None:
52
+ event = parse_json_line(line)
53
+ if event is None: # not JSON, or a truncated last line: show it raw
54
+ self._progress.raw(line)
55
+ return
56
+ kind = event.get("type")
57
+ if kind == "message_end":
58
+ message = event.get("message")
59
+ if isinstance(message, dict) and message.get("role") == "assistant":
60
+ self._last_assistant_message = message
61
+ self._progress.text(_text_of(message))
62
+ elif kind == "tool_execution_start":
63
+ self._progress.tool(event.get("toolName"))
64
+
65
+ def finish(self) -> Outcome:
66
+ message = self._last_assistant_message
67
+ if message is None:
68
+ return StreamFailure("pi: no final assistant message in the event stream")
69
+ stop = message.get("stopReason")
70
+ if stop in ("error", "aborted"):
71
+ error = message.get("errorMessage")
72
+ return StreamFailure(error if isinstance(error, str) and error else f"pi: {stop}")
73
+ return FinalMessage(_text_of(message))
74
+
75
+
76
+ class PiAdapter(Adapter):
77
+ def command(
78
+ self,
79
+ request: RunRequest,
80
+ environ: Mapping[str, str],
81
+ facts: Mapping[str, Any],
82
+ ) -> tuple[list[str], Mapping[str, str]]:
83
+ # No positional prompt and no -p: in JSON mode Pi reads the prompt from stdin.
84
+ argv = ["pi", "--mode", "json"]
85
+ if not request.autonomous:
86
+ argv += ["--tools", "read,grep,find,ls"]
87
+ if facts.get(MAJOR_FACT, 0) >= 1:
88
+ argv += ["--no-mcp"]
89
+ if request.model is not None:
90
+ argv += ["--model", request.model]
91
+ if request.effort is not None:
92
+ argv += ["--thinking", request.effort]
93
+ argv += request.passthrough
94
+ return argv, {}
95
+
96
+ def precheck(self, request: RunRequest, env: Mapping[str, str]) -> Mapping[str, Any]:
97
+ if request.autonomous:
98
+ return {}
99
+ code, stdout = run_check(["pi", "--version"], env, request.cwd)
100
+ if code != 0:
101
+ raise UsageError(
102
+ f"`pi --version` exited {code}; cannot tell whether read-only can be enforced"
103
+ )
104
+ major = parse_major_version(stdout)
105
+ if major is None:
106
+ raise UsageError(
107
+ f"could not parse `pi --version` output {stdout.strip()!r}; "
108
+ "cannot tell whether read-only can be enforced"
109
+ )
110
+ return {MAJOR_FACT: major}
111
+
112
+ def new_stream_handler(self, stderr: TextIO) -> StreamHandler:
113
+ return PiStreamHandler(stderr)
run_harness/cli.py ADDED
@@ -0,0 +1,182 @@
1
+ """Command-line surface: argv -> validated RunRequest (spec section 2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from importlib import metadata
10
+ from typing import BinaryIO, NoReturn
11
+
12
+ from run_harness.request import HARNESSES, MAX_PROMPT_BYTES, RunRequest, UsageError
13
+
14
+ EFFORT_LEVELS = ("low", "medium", "high", "max")
15
+
16
+ PROMPT_ADVICE = (
17
+ "A prompt that begins with '-' cannot be given as the PROMPT argument, and neither "
18
+ "should any untrusted text. Send those through -f/--prompt-file or through stdin "
19
+ "(PROMPT = '-')."
20
+ )
21
+
22
+
23
+ class _Parser(argparse.ArgumentParser):
24
+ """Argument parser whose usage errors raise UsageError instead of exiting."""
25
+
26
+ def error(self, message: str) -> NoReturn:
27
+ raise UsageError(f"{self.prog}: {message}")
28
+
29
+
30
+ def _version() -> str:
31
+ try:
32
+ return metadata.version("run-harness")
33
+ except metadata.PackageNotFoundError: # pragma: no cover
34
+ return "unknown"
35
+
36
+
37
+ def build_parser() -> argparse.ArgumentParser:
38
+ parser = _Parser(
39
+ prog="run-harness",
40
+ allow_abbrev=False,
41
+ description="Run one coding-agent harness non-interactively and print its final message.",
42
+ epilog=PROMPT_ADVICE,
43
+ )
44
+ parser.add_argument("--version", action="version", version=f"run-harness {_version()}")
45
+ subparsers = parser.add_subparsers(dest="harness", metavar="HARNESS", required=True)
46
+ for harness in HARNESSES:
47
+ sub = subparsers.add_parser(
48
+ harness,
49
+ allow_abbrev=False,
50
+ help=f"run {harness}",
51
+ description=f"Run {harness} non-interactively and print its final message.",
52
+ epilog=(
53
+ PROMPT_ADVICE + " Arguments after the first '--' are passed to the harness "
54
+ "verbatim."
55
+ ),
56
+ )
57
+ sub.add_argument(
58
+ "prompt",
59
+ metavar="PROMPT",
60
+ nargs="?",
61
+ help="the prompt text, or '-' to read the prompt from stdin",
62
+ )
63
+ sub.add_argument("-f", "--prompt-file", metavar="PATH", help="read the prompt from a file")
64
+ sub.add_argument("-m", "--model", metavar="MODEL", help="model, passed verbatim")
65
+ sub.add_argument("-e", "--effort", choices=EFFORT_LEVELS, help="effort level")
66
+ sub.add_argument(
67
+ "-C", "--cwd", metavar="DIR", help="directory the harness runs in (default: here)"
68
+ )
69
+ sub.add_argument(
70
+ "--autonomous",
71
+ action="store_true",
72
+ help="lift run-harness's read-only restrictions",
73
+ )
74
+ return parser
75
+
76
+
77
+ def _decode(data: bytes, source: str) -> str:
78
+ try:
79
+ return data.decode("utf-8")
80
+ except UnicodeDecodeError as exc:
81
+ raise UsageError(f"prompt from {source} is not valid UTF-8: {exc.reason}") from None
82
+
83
+
84
+ def _read_prompt(args: argparse.Namespace, stdin: BinaryIO, caller_cwd: str) -> str:
85
+ use_stdin = args.prompt == "-"
86
+ has_text = args.prompt is not None and not use_stdin
87
+ sources = [has_text, use_stdin, args.prompt_file is not None]
88
+ if sum(sources) == 0:
89
+ raise UsageError("no prompt: give PROMPT, '-' for stdin, or -f PATH")
90
+ if sum(sources) > 1:
91
+ raise UsageError("give exactly one prompt source: PROMPT, '-' for stdin, or -f PATH")
92
+
93
+ if use_stdin:
94
+ source = "stdin"
95
+ text = _decode(stdin.read(), source)
96
+ elif has_text:
97
+ source = "argument"
98
+ # argv arrives as str via surrogateescape; invalid UTF-8 shows up as lone surrogates.
99
+ try:
100
+ data = args.prompt.encode("utf-8")
101
+ except UnicodeEncodeError:
102
+ raise UsageError("prompt from argument is not valid UTF-8") from None
103
+ text = _decode(data, source)
104
+ else:
105
+ path = args.prompt_file
106
+ if path == "-":
107
+ raise UsageError("--prompt-file - is not supported; use PROMPT '-' to read stdin")
108
+ full = os.path.abspath(os.path.join(caller_cwd, path))
109
+ source = f"file {full}"
110
+ if os.path.isdir(full):
111
+ raise UsageError(f"prompt file {full} is a directory")
112
+ try:
113
+ with open(full, "rb") as handle:
114
+ data = handle.read()
115
+ except OSError as exc:
116
+ raise UsageError(f"cannot read prompt file {full}: {exc.strerror}") from None
117
+ text = _decode(data, source)
118
+
119
+ if not text.strip():
120
+ raise UsageError(f"prompt from {source} is empty")
121
+ return text
122
+
123
+
124
+ def parse_request(
125
+ argv: Sequence[str],
126
+ *,
127
+ stdin: BinaryIO | None = None,
128
+ cwd: str | None = None,
129
+ ) -> RunRequest:
130
+ """Turn argv (without the program name) into a validated RunRequest.
131
+
132
+ `stdin` (a binary stream) and `cwd` (the caller's working directory) are injectable.
133
+ Raises UsageError on any usage or validation error.
134
+ """
135
+ argv = list(argv)
136
+ passthrough: tuple[str, ...] = ()
137
+ if "--" in argv:
138
+ split = argv.index("--")
139
+ argv, passthrough = argv[:split], tuple(argv[split + 1 :])
140
+
141
+ args = build_parser().parse_args(argv)
142
+ caller_cwd = os.getcwd() if cwd is None else cwd
143
+ if stdin is None:
144
+ stdin = sys.stdin.buffer
145
+
146
+ prompt = _read_prompt(args, stdin, caller_cwd)
147
+
148
+ cap = MAX_PROMPT_BYTES.get(args.harness)
149
+ if cap is not None:
150
+ size = len(prompt.encode("utf-8"))
151
+ if size > cap:
152
+ raise UsageError(f"prompt is {size} bytes; {args.harness} accepts at most {cap} bytes")
153
+
154
+ if args.model is not None and args.model == "":
155
+ raise UsageError("--model must not be empty")
156
+
157
+ run_cwd = os.path.abspath(os.path.join(caller_cwd, args.cwd if args.cwd is not None else "."))
158
+ if not os.path.exists(run_cwd):
159
+ raise UsageError(f"--cwd {run_cwd} does not exist")
160
+ if not os.path.isdir(run_cwd):
161
+ raise UsageError(f"--cwd {run_cwd} is not a directory")
162
+
163
+ return RunRequest(
164
+ harness=args.harness,
165
+ prompt=prompt,
166
+ model=args.model,
167
+ effort=args.effort,
168
+ cwd=run_cwd,
169
+ autonomous=args.autonomous,
170
+ passthrough=passthrough,
171
+ )
172
+
173
+
174
+ def main(argv: Sequence[str] | None = None) -> int:
175
+ try:
176
+ request = parse_request(sys.argv[1:] if argv is None else argv)
177
+ except UsageError as exc:
178
+ print(f"run-harness: error: {exc}", file=sys.stderr)
179
+ return 2
180
+ from run_harness.runner import run
181
+
182
+ return run(request)
run_harness/request.py ADDED
@@ -0,0 +1,30 @@
1
+ """Validated run request shared by the CLI and the harness adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ HARNESSES = ("claude", "codex", "pi", "opencode")
8
+
9
+ # Known maximum prompt size in UTF-8 bytes, keyed by harness. Harnesses without
10
+ # a known limit are not checked.
11
+ MAX_PROMPT_BYTES: dict[str, int] = {"claude": 10 * 1024 * 1024}
12
+
13
+
14
+ class UsageError(Exception):
15
+ """A usage or validation error. The CLI reports it and exits 2."""
16
+
17
+
18
+ class HelperNotFound(Exception):
19
+ """A pre-launch check helper is not on PATH. The runner reports it and exits 127."""
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class RunRequest:
24
+ harness: str
25
+ prompt: str
26
+ model: str | None
27
+ effort: str | None
28
+ cwd: str # absolute
29
+ autonomous: bool
30
+ passthrough: tuple[str, ...]
run_harness/runner.py ADDED
@@ -0,0 +1,102 @@
1
+ """Spawn a harness, feed its stdin, pump its stdout and apply the exit rules (spec 3, 5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import threading
9
+ from typing import BinaryIO
10
+
11
+ from run_harness.adapters import ADAPTERS
12
+ from run_harness.adapters.base import (
13
+ FinalMessage,
14
+ ProgressWriter,
15
+ StreamFailure,
16
+ ensure_newline,
17
+ resolve_executable,
18
+ )
19
+ from run_harness.request import HelperNotFound, RunRequest, UsageError
20
+
21
+
22
+ def _write_prompt(pipe: BinaryIO, data: bytes) -> None:
23
+ try:
24
+ pipe.write(data)
25
+ except OSError: # BrokenPipeError: the harness stopped reading
26
+ pass
27
+ finally:
28
+ try:
29
+ pipe.close()
30
+ except OSError:
31
+ pass
32
+
33
+
34
+ def _failure_code(returncode: int) -> int | None:
35
+ """Exit code for a failed child, or None when the child exited 0."""
36
+ if returncode < 0:
37
+ return 128 - returncode
38
+ return returncode or None
39
+
40
+
41
+ def run(request: RunRequest) -> int:
42
+ """Run the harness. Exit codes: UsageError is 2, HelperNotFound is 127."""
43
+ try:
44
+ return _run(request)
45
+ except UsageError as exc:
46
+ print(f"run-harness: error: {exc}", file=sys.stderr)
47
+ return 2
48
+ except HelperNotFound as exc:
49
+ print(f"run-harness: error: {exc}", file=sys.stderr)
50
+ return 127
51
+
52
+
53
+ def _run(request: RunRequest) -> int:
54
+ adapter = ADAPTERS[request.harness]
55
+ # First build: only for the env and the executable the precheck runs against.
56
+ argv, env = adapter.build(request, os.environ)
57
+ resolved = resolve_executable(argv[0], env)
58
+ facts = adapter.precheck(request, env)
59
+ # The argv can depend on what the precheck found (e.g. Pi's major version).
60
+ argv, env = adapter.build(request, os.environ, facts)
61
+
62
+ handler = adapter.new_stream_handler(sys.stderr)
63
+ proc = subprocess.Popen(
64
+ argv,
65
+ executable=resolved,
66
+ cwd=request.cwd,
67
+ env=env,
68
+ stdin=subprocess.PIPE,
69
+ stdout=subprocess.PIPE,
70
+ stderr=None,
71
+ )
72
+ assert proc.stdin is not None and proc.stdout is not None
73
+ writer = threading.Thread(
74
+ target=_write_prompt, args=(proc.stdin, request.prompt.encode("utf-8")), daemon=True
75
+ )
76
+ writer.start()
77
+ try:
78
+ for raw in proc.stdout:
79
+ handler.feed(raw.decode("utf-8", errors="surrogateescape"))
80
+ returncode = proc.wait()
81
+ finally:
82
+ if proc.poll() is None:
83
+ proc.kill()
84
+ proc.wait()
85
+ proc.stdout.close()
86
+ writer.join()
87
+
88
+ # Finish the handler even when the harness failed: error text extracted from the
89
+ # stream (e.g. Claude's not-logged-in result, rc 1) still belongs on stderr.
90
+ outcome = handler.finish()
91
+ failed = _failure_code(returncode)
92
+ if isinstance(outcome, StreamFailure):
93
+ ProgressWriter(sys.stderr).text(outcome.error_text)
94
+ return 1 if failed is None else failed
95
+ if failed is not None:
96
+ return failed
97
+ assert isinstance(outcome, FinalMessage)
98
+ text = outcome.text
99
+ if text:
100
+ sys.stdout.buffer.write(ensure_newline(text).encode("utf-8", "surrogateescape"))
101
+ sys.stdout.buffer.flush()
102
+ return 0
@@ -0,0 +1,254 @@
1
+ Metadata-Version: 2.3
2
+ Name: run-harness
3
+ Version: 0.1.0
4
+ Summary: One CLI for launching coding-agent harnesses non-interactively.
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+
8
+ # run-harness
9
+
10
+ `run-harness` launches one coding-agent harness (Claude Code, Codex, Pi or OpenCode)
11
+ non-interactively, through one set of options, and prints the harness's final message on stdout.
12
+ It builds the command line, starts the harness, extracts the answer and maps the exit code. It is
13
+ not an agent runtime, and it never reads or writes harness config files.
14
+
15
+ Callers are mostly other programs that run it as a subprocess, plus people at a terminal. There is
16
+ no Python library API. The detailed contract is in [docs/spec.md](docs/spec.md).
17
+
18
+ Supported: Linux and macOS, Python 3.12+. Windows is not supported.
19
+
20
+ ## Install and use
21
+
22
+ ```sh
23
+ uvx run-harness claude "Summarise the architecture of this repo"
24
+ uvx run-harness codex "List the public functions in src/" -m gpt-5-codex -e high
25
+ uvx run-harness pi "Where is the config parsed?" -C ~/code/project
26
+ uvx run-harness opencode "Explain the build" -m anthropic/claude-sonnet-4-5 -e high
27
+ ```
28
+
29
+ You can also install it with `uv tool install run-harness`. The harness itself (`claude`, `codex`,
30
+ `pi`, `opencode`) must already be installed, logged in and on `PATH`. run-harness passes the
31
+ environment and auth through untouched.
32
+
33
+ ```
34
+ run-harness <harness> [PROMPT] [-m MODEL] [-e {low,medium,high,max}] [-f PATH] [-C DIR] [--autonomous] [-- PASSTHROUGH...]
35
+ run-harness --version | --help
36
+ run-harness <harness> --help
37
+ ```
38
+
39
+ Only the final message goes to stdout, with a trailing newline added if it is missing. Progress and
40
+ diagnostics go to stderr.
41
+
42
+ | option | meaning |
43
+ |---|---|
44
+ | `PROMPT` | the prompt text, or `-` to read the prompt from run-harness's own stdin |
45
+ | `-f, --prompt-file PATH` | read the prompt from a file |
46
+ | `-m, --model MODEL` | model, passed verbatim and never validated |
47
+ | `-e, --effort LEVEL` | `low`, `medium`, `high` or `max`. If omitted, the harness default applies |
48
+ | `-C, --cwd DIR` | directory the harness runs in (default: the current directory) |
49
+ | `--autonomous` | lift the read-only restrictions |
50
+ | `-- …` | passthrough arguments, handed to the harness verbatim |
51
+
52
+ Options may come before or after `PROMPT`. An unknown option before `--` is an error (exit 2).
53
+ Abbreviated long options are not accepted. `--model ""` is an error.
54
+
55
+ ### Prompt source
56
+
57
+ Give exactly one of: `PROMPT`, `PROMPT` = `-` (read stdin), or `-f PATH`. None, or more than one,
58
+ is exit 2. Empty or whitespace-only prompts and prompts that are not valid UTF-8 are exit 2. A
59
+ Claude Code prompt over 10 MiB is rejected. No other harness limit is checked.
60
+
61
+ - run-harness reads its own stdin only when `PROMPT` is `-`. `-f -` is rejected.
62
+ - `-f PATH` is resolved against the directory you ran run-harness from, not `--cwd`. FIFOs and
63
+ process substitution (`-f <(...)`) work. Directories are rejected.
64
+ - The prompt reaches the harness on its stdin, never as an argv argument. It is passed verbatim.
65
+ - A prompt starting with `-` cannot be given as `PROMPT`. **Send untrusted text, and anything that
66
+ starts with `-`, through `-f` or stdin:**
67
+
68
+ ```sh
69
+ printf '%s' "$UNTRUSTED" | uvx run-harness claude -
70
+ uvx run-harness codex -f prompt.md
71
+ ```
72
+
73
+ ### Working directory
74
+
75
+ `--cwd` must exist and be a directory. It is made absolute without resolving symlinks, and it is
76
+ applied both as the harness's working directory and as `PWD` in its environment. No harness gets
77
+ its own directory flag (`codex -C`, `opencode --dir`).
78
+
79
+ ### Passthrough
80
+
81
+ Everything after the first `--` is appended after the arguments run-harness generates, and is never
82
+ inspected:
83
+
84
+ ```sh
85
+ uvx run-harness claude "Review this" -- --max-turns 5
86
+ ```
87
+
88
+ Conflicts with the normalized options are your responsibility. See "do not override" below. If an
89
+ override breaks final-message extraction, the run exits 1.
90
+
91
+ ## Read-only and autonomous
92
+
93
+ Read-only is the default. The model may read and reason, but cannot change files or reach the
94
+ network. It may run a command only where a sandbox stops that command from writing (Codex).
95
+ `--autonomous`
96
+ lifts that and permits writes, command execution and network access, in whatever way each harness
97
+ does it (see the table below).
98
+
99
+ The limits matter:
100
+
101
+ - run-harness only sets the harness's own flags. It does not sandbox anything itself.
102
+ - **Hooks and extensions still run.** User and project hooks run in Claude Code's read-only mode,
103
+ and Pi extensions still load and run their hooks. Only the tools are restricted.
104
+ - **User deny rules still apply** under `--autonomous` (Claude Code `deny` settings, deny rules in
105
+ the OpenCode config).
106
+ - **Codex read-only still lets the model run commands.** They run inside a sandbox that blocks
107
+ writes and network access.
108
+ - Pi has no permission system. Its read-only mode is a tool allowlist.
109
+ - A permission that is blocked in read-only mode is not an error. OpenCode ends such a run with
110
+ exit 0.
111
+ - Org, managed or MDM OpenCode config merges after the config run-harness injects.
112
+
113
+ ## Per-harness mapping
114
+
115
+ | | Claude Code | Codex | Pi | OpenCode |
116
+ |---|---|---|---|---|
117
+ | base command | `claude -p --output-format stream-json --verbose` | `codex exec --skip-git-repo-check --color never` | `pi --mode json` | `opencode run --standalone --format json` |
118
+ | prompt | stdin | stdin | stdin | stdin |
119
+ | `--model M` | `--model M` | `-m M` | `--model M` | `-m M` |
120
+ | `--effort L` | `--effort L` | `-c model_reasoning_effort=L` | `--thinking L` | `-m M#L` (pre-checked) |
121
+ | read-only (default) | `--tools Read,Grep,Glob --permission-mode dontAsk --strict-mcp-config` | `-s read-only` | `--tools read,grep,find,ls` (plus `--no-mcp` on Pi 1.x and later) | injected agent plus `--agent run-harness-readonly` (pre-checked with `opencode api`) |
122
+ | `--autonomous` | `--dangerously-skip-permissions` | `--dangerously-bypass-approvals-and-sandbox` | nothing added | `--auto` |
123
+ | final message | `result` of the last `result` event | captured stdout | text of the last assistant `message_end` | text parts of the last assistant message |
124
+ | failure despite exit 0 | `is_error: true`, or no `result` | none | `stopReason` is `error` or `aborted` | an `error` event |
125
+ | do not override | `-p`, `--output-format`, `--verbose`, `--tools`, `--permission-mode` | `-s`, `--color`, `--json`, `-o` | `--mode`, `--tools`, `-p` | `--format`, `--agent`, `--dir` |
126
+
127
+ Argument order is: base command, permission arguments, `--model`, effort, passthrough.
128
+
129
+ ### Claude Code
130
+
131
+ - `CLAUDE_CODE_EFFORT_LEVEL` passes through untouched. Whether it or `--effort` wins is unverified.
132
+ - A user `maxEffortLevel` setting can silently cap `max`.
133
+ - Read-only uses a tool allowlist, not `plan` mode, and `--strict-mcp-config` drops MCP servers from
134
+ user and project config. Hooks still run.
135
+ - Bypass mode refuses to run as root outside a sandbox. That refusal comes through with Claude's
136
+ exit code.
137
+ - An auth failure (`Not logged in`) exits 1 with the message on stderr.
138
+
139
+ ### Codex
140
+
141
+ - Codex's stdout, when piped, is the final message. It is buffered and written once at the end.
142
+ Its stderr is its own progress, forwarded unchanged.
143
+ - `-s read-only` is always passed explicitly. Projects marked `trusted` in `~/.codex/config.toml`
144
+ would otherwise default to `workspace-write`.
145
+ - An unknown model makes Codex warn ("fallback metadata") and fail the turn, so the run exits 1.
146
+ - There is no auth pre-check. An auth failure exits 1 after roughly 15 to 20 seconds of retries.
147
+
148
+ ### Pi
149
+
150
+ - Before a read-only run, run-harness runs `pi --version`. Pi 1.x and later gets `--no-mcp`,
151
+ because `--tools` keeps MCP tools otherwise. Pi 0.x does not get it, since the flag does not
152
+ exist there. Unparseable output or a non-zero exit is exit 2, because read-only cannot be
153
+ guaranteed. `--autonomous` skips the check.
154
+ - `--thinking` is clamped silently per model.
155
+ - A `:<thinking>` suffix on `--model` is overridden by an explicit `--effort`.
156
+ - Extensions still load and run their hooks in read-only mode. Only their tools are hidden.
157
+ - Passthrough positional arguments starting with `@` become file attachments.
158
+ - Pi exits 0 when the model errors in JSON mode. run-harness turns that into exit 1.
159
+
160
+ ### OpenCode
161
+
162
+ - Read-only injects an agent named `run-harness-readonly` through `OPENCODE_CONFIG_CONTENT`. It is
163
+ merged into your value if you set one, and invalid JSON there is exit 2. The agent allows only
164
+ `read`, `glob`, `grep`, `list` and `lsp`, and denies everything else, including `task`. Nothing
165
+ is persisted. Apart from `PWD`, this is the only environment variable run-harness changes.
166
+ - Supports OpenCode 2.x only. Every run passes `--standalone`: 2.x otherwise talks to a shared
167
+ background service that never sees the injected config, so the agent would be missing.
168
+ - Before launch, `opencode api --standalone GET /api/config` (same env and cwd) must show the
169
+ agent with exactly the injected permissions (otherwise exit 2). If a run still cannot load the
170
+ agent, OpenCode reports `Agent not found` and run-harness exits non-zero with nothing on stdout.
171
+ - **`--effort` requires `--model` in `provider/model` form.** run-harness passes it as
172
+ `-m provider/model#level` and first reads `opencode api --standalone GET /api/model`. It exits 2
173
+ if the model is not listed, has no variants, or the level is not one of its variants.
174
+ - **The model is validated only when `--effort` is given.** Without `--effort`, an unlisted model
175
+ fails at run time, and the run exits 1.
176
+ - A permission rejected in read-only mode ends the run with exit 0.
177
+ - OpenCode creates `.opencode/.gitignore` and installs its plugin package at startup. Those are its
178
+ own side effects.
179
+
180
+ ### Effort
181
+
182
+ `low`, `medium`, `high` and `max` map one to one onto every harness, with no remapping (`max` is
183
+ never turned into `xhigh`). Claude Code, Codex and Pi adjust a level the model does not support to
184
+ the nearest one it does, and run-harness does not detect that. OpenCode rejects unknown variants
185
+ at run time, but run-harness checks them first so you get exit 2 and the valid levels. Native-only levels (`xhigh`, `minimal`, `off`, `none`
186
+ and so on) are reachable only through passthrough.
187
+
188
+ ## Exit codes
189
+
190
+ | situation | exit | stdout |
191
+ |---|---|---|
192
+ | usage or validation error, or a failed pre-launch check | `2` | empty |
193
+ | harness executable (or a pre-check helper) not on `PATH` | `127` | empty |
194
+ | harness exits non-zero | the harness's code, verbatim | empty |
195
+ | harness killed by signal N | `128 + N` | empty |
196
+ | harness exits 0, but the event stream reports an error | `1` | empty |
197
+ | harness exits 0 with no final-message event | `1` | empty |
198
+ | harness exits 0 with a final message | `0` | the final message |
199
+ | harness exits 0 with an empty final message | `0` | empty (no newline added) |
200
+ | Codex exits 0 | `0` | the captured stdout |
201
+
202
+ stdout is written once, after the harness exits, and only when run-harness exits 0. On failure,
203
+ error text taken from the stream goes to stderr.
204
+
205
+ ## stderr
206
+
207
+ stderr is for people. Its format is not stable and is not a contract. The harness's own stderr is
208
+ forwarded unchanged. For Claude Code, Pi and OpenCode, run-harness also prints the assistant text
209
+ and a `→ tool: <name>` line per tool call as events arrive. Lines that are not JSON are forwarded
210
+ raw. Codex has no renderer, since its stderr already is its progress.
211
+
212
+ ## Tested harness versions
213
+
214
+ run-harness declares tested versions, not minimums. The fixtures come from these versions run
215
+ against a local fake model server (OpenCode: observed on 2.0.32 with a free model), plus
216
+ hand-written edge cases. Claude Code's and OpenCode's `success` and `tool_call` fixtures, and
217
+ Claude Code's `not_logged_in`, are recorded against a real model with the recorder script below.
218
+ Codex was checked with the live suite:
219
+
220
+ | harness | version |
221
+ |---|---|
222
+ | Claude Code | 2.1.303 |
223
+ | Codex | codex-cli 0.163.0 |
224
+ | Pi | 0.85.1 |
225
+ | OpenCode | 2.0.32 (2.x only) |
226
+
227
+ There are no general runtime version checks. The one exception is Pi's `--no-mcp`, because a
228
+ version difference there would make read-only fail open. Other versions may work, or may break
229
+ extraction. The opt-in live suite (below) is how drift is detected.
230
+
231
+ ## Development
232
+
233
+ ```sh
234
+ uv run pytest # unit and fake-harness tests; no real harness needed
235
+ uv run ruff check
236
+ uv run ruff format --check
237
+ uv run pytest -m live # opt-in smoke run against the real harnesses on PATH
238
+ uv run scripts/record_fixtures.py # re-record the fixtures from the real harnesses
239
+ ```
240
+
241
+ The live suite never runs in CI. Run it locally before a release, and re-record the fixtures if it
242
+ fails.
243
+
244
+ ## Release
245
+
246
+ Releases run from the Actions tab: run the **Release** workflow on `master`, choosing a version
247
+ `bump` (`none` releases the version already in `pyproject.toml`) and whether to publish to `pypi`.
248
+
249
+ The workflow runs lint and tests, applies the bump and builds, refusing a version that is already
250
+ tagged. It publishes to TestPyPI and smoke-tests the install on Linux and macOS
251
+ (`scripts/smoke_release.sh`). With `pypi` ticked it then publishes the same files to PyPI, commits
252
+ the bump, tags `vX.Y.Z`, creates a GitHub Release with generated notes and smoke-tests the PyPI
253
+ install. With `pypi` unticked it stops after TestPyPI, as a dry run. Both indexes use trusted
254
+ publishing for `release.yml`, with no environment.
@@ -0,0 +1,15 @@
1
+ run_harness/__init__.py,sha256=mWgtTOOSvrg7sntYi2jCCzuYbB8WdWAk1xz6KtQ0Hdk,55
2
+ run_harness/__main__.py,sha256=PtAfSwJBtzEWRzoF1Qmm4KdBi5W18zVxnuneXpmU3WQ,59
3
+ run_harness/adapters/__init__.py,sha256=rT20RhVy3OVAj0bE2R2A6H3J5RPasnJgBq9jOso8sc0,490
4
+ run_harness/adapters/base.py,sha256=zBPGRtvBHUD-Dpx2ajxU-jYY-Ya7GC5Hn1M4S98dOlc,5187
5
+ run_harness/adapters/claude.py,sha256=9kwuh3ABftPqz0JQKuQUQBu9CsbIFv4jCisbVzXduVQ,3605
6
+ run_harness/adapters/codex.py,sha256=3y2A3Qc820CLEf2yN4wvG7TuD-Z4dkCB3KEuaPagEG4,1438
7
+ run_harness/adapters/opencode.py,sha256=GlYR5-hM_WAB8LmZWwYBi6fPjbShRDwyJojTUa2U31o,10013
8
+ run_harness/adapters/pi.py,sha256=nOSMP1CvOuhUKnc3wDPxA4IDma0s5f95vusYP1NZ9Lk,3906
9
+ run_harness/cli.py,sha256=SXGA33en_ky6wY9J61R562eKojtyQJaOl5cifSzlpyw,6387
10
+ run_harness/request.py,sha256=aBeeFK_rPuxoBxIU7YI6E1s-2_-zkL-TU9_nJhw2WIs,799
11
+ run_harness/runner.py,sha256=1qm5G0oZeCKeRfmPloGGTs0lz5NMFzfF8-FnuCinJoM,3174
12
+ run_harness-0.1.0.dist-info/WHEEL,sha256=3NO1oYsF7mANFUWVdyEQLM-zmecczWWEmmF5rEbXrWk,80
13
+ run_harness-0.1.0.dist-info/entry_points.txt,sha256=_Mzhp1zT2ysEqnEwkC-i2jBRNKuW3rcFOxyFdXqeGcM,54
14
+ run_harness-0.1.0.dist-info/METADATA,sha256=mzOJoL2zizK_EFUy957dYDblRXDiPeD-ILyxrycag_Y,13394
15
+ run_harness-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.6
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ run-harness = run_harness.cli:main
3
+