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.
- run_harness/__init__.py +1 -0
- run_harness/__main__.py +3 -0
- run_harness/adapters/__init__.py +16 -0
- run_harness/adapters/base.py +156 -0
- run_harness/adapters/claude.py +101 -0
- run_harness/adapters/codex.py +43 -0
- run_harness/adapters/opencode.py +243 -0
- run_harness/adapters/pi.py +113 -0
- run_harness/cli.py +182 -0
- run_harness/request.py +30 -0
- run_harness/runner.py +102 -0
- run_harness-0.1.0.dist-info/METADATA +254 -0
- run_harness-0.1.0.dist-info/RECORD +15 -0
- run_harness-0.1.0.dist-info/WHEEL +4 -0
- run_harness-0.1.0.dist-info/entry_points.txt +3 -0
run_harness/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""run-harness: one CLI for coding-agent harnesses."""
|
run_harness/__main__.py
ADDED
|
@@ -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,,
|