whiletrue 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.
- whiletrue/__init__.py +0 -0
- whiletrue/__main__.py +3 -0
- whiletrue/abort.py +79 -0
- whiletrue/basics/__init__.py +99 -0
- whiletrue/basics/agent.py +249 -0
- whiletrue/basics/comments.py +113 -0
- whiletrue/basics/diff_size.py +80 -0
- whiletrue/basics/exec_.py +84 -0
- whiletrue/basics/history.py +117 -0
- whiletrue/basics/human.py +201 -0
- whiletrue/basics/json_source.py +35 -0
- whiletrue/basics/markdown_source.py +55 -0
- whiletrue/basics/process.py +99 -0
- whiletrue/basics/sessions.py +62 -0
- whiletrue/basics/set_.py +29 -0
- whiletrue/basics/shell.py +98 -0
- whiletrue/basics/wake.py +27 -0
- whiletrue/claude_code/__init__.py +58 -0
- whiletrue/claude_code/endpoint.py +143 -0
- whiletrue/claude_code/headless.py +181 -0
- whiletrue/claude_code/interactive.py +344 -0
- whiletrue/claude_code/learnings.py +32 -0
- whiletrue/claude_code/panes.py +164 -0
- whiletrue/claude_code/plugin/.claude-plugin/plugin.json +5 -0
- whiletrue/claude_code/plugin/hook.py +44 -0
- whiletrue/claude_code/plugin/hooks/hooks.json +81 -0
- whiletrue/claude_code/plugin/mcp_server.py +98 -0
- whiletrue/claude_code/session.py +345 -0
- whiletrue/claude_code/transcript.py +123 -0
- whiletrue/cli/__init__.py +15 -0
- whiletrue/cli/__main__.py +3 -0
- whiletrue/cli/app.py +550 -0
- whiletrue/cli/common.py +36 -0
- whiletrue/cli/gates.py +156 -0
- whiletrue/cli/ops.py +339 -0
- whiletrue/cli/panel.py +198 -0
- whiletrue/cli/registry.py +143 -0
- whiletrue/cli/templates.py +24 -0
- whiletrue/codex/__init__.py +14 -0
- whiletrue/codex/runner.py +243 -0
- whiletrue/control/__init__.py +38 -0
- whiletrue/control/chain.py +61 -0
- whiletrue/control/loop.py +139 -0
- whiletrue/control/map_.py +101 -0
- whiletrue/control/parallel.py +32 -0
- whiletrue/control/retry.py +100 -0
- whiletrue/control/sequence.py +40 -0
- whiletrue/control/switch.py +67 -0
- whiletrue/core/__init__.py +260 -0
- whiletrue/core/caps.py +139 -0
- whiletrue/core/comments.py +80 -0
- whiletrue/core/composite.py +140 -0
- whiletrue/core/config.py +461 -0
- whiletrue/core/context.py +177 -0
- whiletrue/core/duration.py +19 -0
- whiletrue/core/events.py +94 -0
- whiletrue/core/expr.py +85 -0
- whiletrue/core/history.py +83 -0
- whiletrue/core/lock.py +86 -0
- whiletrue/core/memory_store.py +352 -0
- whiletrue/core/module.py +146 -0
- whiletrue/core/outcomes.py +82 -0
- whiletrue/core/registry.py +129 -0
- whiletrue/core/replay.py +58 -0
- whiletrue/core/runner.py +98 -0
- whiletrue/core/runtime.py +538 -0
- whiletrue/core/source.py +45 -0
- whiletrue/core/store.py +207 -0
- whiletrue/core/template.py +60 -0
- whiletrue/core/validate.py +142 -0
- whiletrue/core/wake.py +117 -0
- whiletrue/core/workspace.py +55 -0
- whiletrue/git/__init__.py +31 -0
- whiletrue/git/modules.py +128 -0
- whiletrue/git/workspaces.py +77 -0
- whiletrue/github/__init__.py +30 -0
- whiletrue/github/client.py +269 -0
- whiletrue/github/filebacked.py +87 -0
- whiletrue/github/memory.py +96 -0
- whiletrue/github/plugin.py +343 -0
- whiletrue/github/specs.py +39 -0
- whiletrue/opencode/__init__.py +25 -0
- whiletrue/opencode/runner.py +186 -0
- whiletrue/py.typed +0 -0
- whiletrue/recipes/issue-loop.yaml +59 -0
- whiletrue/recipes/ralph.yaml +14 -0
- whiletrue/recipes/review-pass.yaml +27 -0
- whiletrue/recipes/review-respond.yaml +22 -0
- whiletrue/store_sqlite/__init__.py +6 -0
- whiletrue/store_sqlite/migrations.py +132 -0
- whiletrue/store_sqlite/sqlite_store.py +537 -0
- whiletrue/tmux/__init__.py +11 -0
- whiletrue/tmux/controller.py +197 -0
- whiletrue-0.1.0.dist-info/METADATA +48 -0
- whiletrue-0.1.0.dist-info/RECORD +98 -0
- whiletrue-0.1.0.dist-info/WHEEL +4 -0
- whiletrue-0.1.0.dist-info/entry_points.txt +2 -0
- whiletrue-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from whiletrue.basics.process import run_command, tail
|
|
9
|
+
from whiletrue.core import (
|
|
10
|
+
ActionModule,
|
|
11
|
+
ConfigIssue,
|
|
12
|
+
Emit,
|
|
13
|
+
Ports,
|
|
14
|
+
PortSpec,
|
|
15
|
+
StepContext,
|
|
16
|
+
define_action,
|
|
17
|
+
parse_duration,
|
|
18
|
+
)
|
|
19
|
+
from whiletrue.core.module import PortValues
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ShellConfig(BaseModel):
|
|
23
|
+
run: str = Field(min_length=1)
|
|
24
|
+
cwd: str | None = None
|
|
25
|
+
env: dict[str, str] | None = None
|
|
26
|
+
timeout: str | int | float | None = None
|
|
27
|
+
#: On success, pass the step's input on instead of the command's output (for gates in a chain).
|
|
28
|
+
pass_input: bool = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GateTestsConfig(ShellConfig):
|
|
32
|
+
run: str = Field(default="pytest", min_length=1)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _make_shell[Cfg: ShellConfig](
|
|
36
|
+
module_id: str, effects: Literal["side-effect", "deterministic"], config_cls: type[Cfg]
|
|
37
|
+
) -> ActionModule[Cfg]:
|
|
38
|
+
def validate(config: Cfg, _vctx: object) -> list[ConfigIssue]:
|
|
39
|
+
if config.timeout is None:
|
|
40
|
+
return []
|
|
41
|
+
try:
|
|
42
|
+
parse_duration(config.timeout)
|
|
43
|
+
except ValueError as error:
|
|
44
|
+
return [ConfigIssue("error", str(error))]
|
|
45
|
+
return []
|
|
46
|
+
|
|
47
|
+
async def run(input: PortValues, ctx: StepContext[Cfg]) -> Emit:
|
|
48
|
+
cfg = ctx.config
|
|
49
|
+
result = await run_command(
|
|
50
|
+
"sh",
|
|
51
|
+
["-c", cfg.run],
|
|
52
|
+
cwd=str((Path(ctx.cwd) / (cfg.cwd or ".")).resolve()),
|
|
53
|
+
env=cfg.env,
|
|
54
|
+
signal=ctx.signal,
|
|
55
|
+
timeout_ms=None if cfg.timeout is None else parse_duration(cfg.timeout),
|
|
56
|
+
)
|
|
57
|
+
if result.code == 0:
|
|
58
|
+
return Emit("ok", input.get("in") if cfg.pass_input else result.stdout)
|
|
59
|
+
if result.timed_out:
|
|
60
|
+
reason = "timed out"
|
|
61
|
+
elif result.aborted:
|
|
62
|
+
reason = "aborted"
|
|
63
|
+
else:
|
|
64
|
+
reason = f"exited with code {'unknown' if result.code is None else result.code}"
|
|
65
|
+
return Emit(
|
|
66
|
+
"fail",
|
|
67
|
+
{
|
|
68
|
+
"error": f"`{cfg.run}` {reason}",
|
|
69
|
+
"exit_code": result.code,
|
|
70
|
+
"output": tail(result.stdout + result.stderr),
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return define_action(
|
|
75
|
+
ActionModule(
|
|
76
|
+
id=module_id,
|
|
77
|
+
config=config_cls,
|
|
78
|
+
ports=Ports(
|
|
79
|
+
inputs=[PortSpec("in", "Any")],
|
|
80
|
+
outputs=[PortSpec("out", "Any")],
|
|
81
|
+
),
|
|
82
|
+
effects=effects,
|
|
83
|
+
validate=validate, # pyright: ignore[reportArgumentType]
|
|
84
|
+
cacheable=False,
|
|
85
|
+
idempotency_key=(lambda _i, ctx: f"shell:{ctx.step_key}")
|
|
86
|
+
if effects == "side-effect"
|
|
87
|
+
else None,
|
|
88
|
+
run=run,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
#: Runs a shell command. Fires `ok` on exit 0, otherwise `fail` with the output tail.
|
|
94
|
+
shell_module = _make_shell("shell", "side-effect", ShellConfig)
|
|
95
|
+
#: A deterministic gate: pass/fail by exit code only.
|
|
96
|
+
gate_shell_module = _make_shell("gate.shell", "deterministic", ShellConfig)
|
|
97
|
+
#: A deterministic gate for the test suite: `run` defaults to `pytest`.
|
|
98
|
+
gate_tests_module = _make_shell("gate.tests", "deterministic", GateTestsConfig)
|
whiletrue/basics/wake.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from whiletrue.core import WakeContext, WakeResult, parse_duration
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FileExistsWake:
|
|
10
|
+
"""Approves when a file appears (relative to the project): a build artifact, a sign-off file."""
|
|
11
|
+
|
|
12
|
+
async def check(self, arg: Any, ctx: WakeContext) -> WakeResult | None:
|
|
13
|
+
if not isinstance(arg, str) or not arg:
|
|
14
|
+
return None
|
|
15
|
+
if (Path(ctx.cwd) / arg).exists():
|
|
16
|
+
return WakeResult("approved", f"{arg} exists", by="file_exists")
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TimerWake:
|
|
21
|
+
"""Approves once a duration has passed since the item was parked (a cooling-off period)."""
|
|
22
|
+
|
|
23
|
+
async def check(self, arg: Any, ctx: WakeContext) -> WakeResult | None:
|
|
24
|
+
waited = ctx.now() - ctx.park.created_at
|
|
25
|
+
if waited >= parse_duration(arg):
|
|
26
|
+
return WakeResult("approved", f"{arg} elapsed", by="timer")
|
|
27
|
+
return None
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Claude Code runners: headless (`claude -p`) and interactive (a live pane)."""
|
|
2
|
+
|
|
3
|
+
from whiletrue.claude_code.endpoint import EventEndpoint, SessionHandler, ToolResult
|
|
4
|
+
from whiletrue.claude_code.headless import (
|
|
5
|
+
HeadlessOptions,
|
|
6
|
+
HeadlessRunner,
|
|
7
|
+
build_headless_args,
|
|
8
|
+
create_headless_runner,
|
|
9
|
+
)
|
|
10
|
+
from whiletrue.claude_code.interactive import (
|
|
11
|
+
PLUGIN_DIR,
|
|
12
|
+
InteractiveOptions,
|
|
13
|
+
InteractiveRunner,
|
|
14
|
+
build_interactive_args,
|
|
15
|
+
create_interactive_runner,
|
|
16
|
+
)
|
|
17
|
+
from whiletrue.claude_code.panes import (
|
|
18
|
+
Pane,
|
|
19
|
+
PaneHost,
|
|
20
|
+
ProcessPaneHost,
|
|
21
|
+
StreamMirror,
|
|
22
|
+
TmuxPaneHost,
|
|
23
|
+
)
|
|
24
|
+
from whiletrue.core import PluginApi, SimplePlugin
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def claude_code_plugin(
|
|
28
|
+
options: HeadlessOptions | None = None, interactive: InteractiveOptions | None = None
|
|
29
|
+
) -> SimplePlugin:
|
|
30
|
+
"""Registers the Claude Code runners."""
|
|
31
|
+
|
|
32
|
+
def register(api: PluginApi) -> None:
|
|
33
|
+
api.register_runner(create_headless_runner(options))
|
|
34
|
+
api.register_runner(create_interactive_runner(interactive))
|
|
35
|
+
|
|
36
|
+
return SimplePlugin(name="whiletrue.claude_code", register=register)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"PLUGIN_DIR",
|
|
41
|
+
"EventEndpoint",
|
|
42
|
+
"HeadlessOptions",
|
|
43
|
+
"HeadlessRunner",
|
|
44
|
+
"InteractiveOptions",
|
|
45
|
+
"InteractiveRunner",
|
|
46
|
+
"Pane",
|
|
47
|
+
"PaneHost",
|
|
48
|
+
"ProcessPaneHost",
|
|
49
|
+
"SessionHandler",
|
|
50
|
+
"StreamMirror",
|
|
51
|
+
"TmuxPaneHost",
|
|
52
|
+
"ToolResult",
|
|
53
|
+
"build_headless_args",
|
|
54
|
+
"build_interactive_args",
|
|
55
|
+
"claude_code_plugin",
|
|
56
|
+
"create_headless_runner",
|
|
57
|
+
"create_interactive_runner",
|
|
58
|
+
]
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import secrets
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Protocol, cast
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ToolResult:
|
|
13
|
+
text: str
|
|
14
|
+
is_error: bool = False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SessionHandler(Protocol):
|
|
18
|
+
"""What the endpoint needs from one live agent session."""
|
|
19
|
+
|
|
20
|
+
async def on_hook(self, event: str, payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
21
|
+
"""Handles a Claude Code hook. The returned object is printed back to the hook."""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
def tools(self) -> list[dict[str, Any]]:
|
|
25
|
+
"""MCP tool descriptors (`name`, `description`, `inputSchema`) for this session."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
async def on_tool(self, name: str, args: dict[str, Any]) -> ToolResult: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_MAX_BODY = 8 * 1024 * 1024
|
|
32
|
+
_REASONS = {200: "OK", 400: "Bad Request", 401: "Unauthorized", 404: "Not Found", 500: "Error"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class EventEndpoint:
|
|
36
|
+
"""A loopback HTTP server inside the whiletrue process.
|
|
37
|
+
|
|
38
|
+
The bundled plugin's hook script and MCP server post here. Requests carry a shared secret
|
|
39
|
+
(`X-WT-Token`) and the id of the session they belong to (`X-WT-Session`); the endpoint routes
|
|
40
|
+
them to whatever handler registered under that id.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, host: str = "127.0.0.1", port: int = 0) -> None:
|
|
44
|
+
self._host = host
|
|
45
|
+
self._port = port
|
|
46
|
+
self.token = secrets.token_hex(16)
|
|
47
|
+
self._handlers: dict[str, SessionHandler] = {}
|
|
48
|
+
self._server: asyncio.Server | None = None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def port(self) -> int:
|
|
52
|
+
assert self._server is not None, "endpoint not started"
|
|
53
|
+
return int(self._server.sockets[0].getsockname()[1])
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def started(self) -> bool:
|
|
57
|
+
return self._server is not None
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def url(self) -> str:
|
|
61
|
+
return f"http://{self._host}:{self.port}"
|
|
62
|
+
|
|
63
|
+
async def start(self) -> None:
|
|
64
|
+
self._server = await asyncio.start_server(self._serve, self._host, self._port)
|
|
65
|
+
|
|
66
|
+
async def stop(self) -> None:
|
|
67
|
+
if self._server is not None:
|
|
68
|
+
self._server.close()
|
|
69
|
+
await self._server.wait_closed()
|
|
70
|
+
self._server = None
|
|
71
|
+
|
|
72
|
+
def register(self, key: str, handler: SessionHandler) -> Callable[[], None]:
|
|
73
|
+
if key in self._handlers:
|
|
74
|
+
raise ValueError(f'Session "{key}" is already registered')
|
|
75
|
+
self._handlers[key] = handler
|
|
76
|
+
|
|
77
|
+
def unregister() -> None:
|
|
78
|
+
self._handlers.pop(key, None)
|
|
79
|
+
|
|
80
|
+
return unregister
|
|
81
|
+
|
|
82
|
+
def env_for(self, key: str) -> dict[str, str]:
|
|
83
|
+
"""Environment that points the plugin's scripts at this endpoint for one session."""
|
|
84
|
+
return {"WT_ENDPOINT": self.url, "WT_TOKEN": self.token, "WT_SESSION": key}
|
|
85
|
+
|
|
86
|
+
async def _serve(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
87
|
+
try:
|
|
88
|
+
status, body = await self._handle(reader)
|
|
89
|
+
except (ValueError, asyncio.IncompleteReadError, asyncio.LimitOverrunError):
|
|
90
|
+
status, body = 400, {"error": "bad request"}
|
|
91
|
+
except Exception as error:
|
|
92
|
+
status, body = 500, {"error": str(error)}
|
|
93
|
+
payload = json.dumps(body).encode()
|
|
94
|
+
head = (
|
|
95
|
+
f"HTTP/1.1 {status} {_REASONS.get(status, 'Error')}\r\n"
|
|
96
|
+
"Content-Type: application/json\r\n"
|
|
97
|
+
f"Content-Length: {len(payload)}\r\nConnection: close\r\n\r\n"
|
|
98
|
+
)
|
|
99
|
+
try:
|
|
100
|
+
writer.write(head.encode() + payload)
|
|
101
|
+
await writer.drain()
|
|
102
|
+
except ConnectionError: # pragma: no cover (the client went away)
|
|
103
|
+
pass
|
|
104
|
+
finally:
|
|
105
|
+
writer.close()
|
|
106
|
+
|
|
107
|
+
async def _handle(self, reader: asyncio.StreamReader) -> tuple[int, Any]:
|
|
108
|
+
request_line = (await reader.readline()).decode("latin-1").split()
|
|
109
|
+
if len(request_line) != 3:
|
|
110
|
+
raise ValueError("malformed request line")
|
|
111
|
+
method, path = request_line[0], request_line[1]
|
|
112
|
+
headers: dict[str, str] = {}
|
|
113
|
+
while (line := (await reader.readline()).decode("latin-1").strip()) != "":
|
|
114
|
+
name, _, value = line.partition(":")
|
|
115
|
+
headers[name.strip().lower()] = value.strip()
|
|
116
|
+
length = int(headers.get("content-length", "0"))
|
|
117
|
+
if length > _MAX_BODY:
|
|
118
|
+
raise ValueError("body too large")
|
|
119
|
+
raw = await reader.readexactly(length) if length else b""
|
|
120
|
+
if method != "POST":
|
|
121
|
+
return 404, {"error": "not found"}
|
|
122
|
+
if not secrets.compare_digest(headers.get("x-wt-token", ""), self.token):
|
|
123
|
+
return 401, {"error": "bad token"}
|
|
124
|
+
handler = self._handlers.get(headers.get("x-wt-session", ""))
|
|
125
|
+
if handler is None:
|
|
126
|
+
return 404, {"error": "unknown session"}
|
|
127
|
+
parsed: Any = json.loads(raw) if raw else {}
|
|
128
|
+
if not isinstance(parsed, dict):
|
|
129
|
+
raise ValueError("body must be an object")
|
|
130
|
+
request = cast(dict[str, Any], parsed)
|
|
131
|
+
if path == "/hook":
|
|
132
|
+
reply = await handler.on_hook(
|
|
133
|
+
str(request.get("event", "")), request.get("payload") or {}
|
|
134
|
+
)
|
|
135
|
+
return 200, reply or {}
|
|
136
|
+
if path == "/tools/list":
|
|
137
|
+
return 200, {"tools": handler.tools()}
|
|
138
|
+
if path == "/tools/call":
|
|
139
|
+
result = await handler.on_tool(
|
|
140
|
+
str(request.get("name", "")), request.get("arguments") or {}
|
|
141
|
+
)
|
|
142
|
+
return 200, {"text": result.text, "is_error": result.is_error}
|
|
143
|
+
return 404, {"error": "not found"}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from whiletrue.basics.process import kill_process_group
|
|
10
|
+
from whiletrue.core import AgentRequest, AgentResult, RunContext, RunnerCapabilities
|
|
11
|
+
|
|
12
|
+
#: Stream-json lines can be large (tool results); lift asyncio's 64 KiB default.
|
|
13
|
+
_LINE_LIMIT = 64 * 1024 * 1024
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class HeadlessOptions:
|
|
18
|
+
#: The claude binary. Defaults to `claude` on PATH.
|
|
19
|
+
command: str = "claude"
|
|
20
|
+
#: Extra args appended after the generated ones (e.g. `--bare` for reproducible runs).
|
|
21
|
+
extra_args: list[str] = field(default_factory=lambda: [])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _num(value: float) -> str:
|
|
25
|
+
return str(int(value)) if float(value).is_integer() else str(value)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_headless_args(req: AgentRequest, extra_args: list[str] | None = None) -> list[str]:
|
|
29
|
+
"""Builds the argv for `claude -p`. The prompt itself goes over stdin."""
|
|
30
|
+
args = ["-p", "--output-format", "stream-json", "--verbose", "--permission-prompts", "none"]
|
|
31
|
+
if req.model:
|
|
32
|
+
args += ["--model", req.model]
|
|
33
|
+
if req.allowed_tools:
|
|
34
|
+
args += ["--allowedTools", ",".join(req.allowed_tools)]
|
|
35
|
+
if req.disallowed_tools:
|
|
36
|
+
args += ["--disallowedTools", ",".join(req.disallowed_tools)]
|
|
37
|
+
if req.tools:
|
|
38
|
+
args += ["--tools", ",".join(req.tools)]
|
|
39
|
+
if req.permission_mode:
|
|
40
|
+
args += ["--permission-mode", req.permission_mode]
|
|
41
|
+
if req.system_prompt_append:
|
|
42
|
+
args += ["--append-system-prompt", req.system_prompt_append]
|
|
43
|
+
if req.output_schema:
|
|
44
|
+
args += ["--json-schema", json.dumps(req.output_schema, separators=(",", ":"))]
|
|
45
|
+
if req.limits is not None and req.limits.turns is not None:
|
|
46
|
+
args += ["--max-turns", str(req.limits.turns)]
|
|
47
|
+
if req.limits is not None and req.limits.budget_usd is not None:
|
|
48
|
+
args += ["--max-budget-usd", _num(req.limits.budget_usd)]
|
|
49
|
+
if req.resume:
|
|
50
|
+
args += ["--resume", req.resume]
|
|
51
|
+
elif req.session_id:
|
|
52
|
+
args += ["--session-id", req.session_id]
|
|
53
|
+
return [*args, *(extra_args or [])]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _to_result(msg: dict[str, Any]) -> AgentResult:
|
|
57
|
+
ok = msg.get("is_error") is not True and msg.get("subtype") == "success"
|
|
58
|
+
errors = msg.get("errors")
|
|
59
|
+
error: str | None = None
|
|
60
|
+
if not ok:
|
|
61
|
+
joined = (
|
|
62
|
+
"; ".join(str(e) for e in cast(list[Any], errors)) if isinstance(errors, list) else ""
|
|
63
|
+
)
|
|
64
|
+
error = joined or msg.get("subtype") or "claude reported an error"
|
|
65
|
+
return AgentResult(
|
|
66
|
+
ok=ok,
|
|
67
|
+
text=msg.get("result") or "",
|
|
68
|
+
output=msg.get("structured_output"),
|
|
69
|
+
session_id=msg.get("session_id"),
|
|
70
|
+
cost_usd=msg.get("total_cost_usd"),
|
|
71
|
+
turns=msg.get("num_turns"),
|
|
72
|
+
error=error,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class HeadlessRunner:
|
|
77
|
+
"""Unattended runner: `claude -p --output-format stream-json` (CI, cheap utility steps)."""
|
|
78
|
+
|
|
79
|
+
id = "claude-code-headless"
|
|
80
|
+
capabilities = RunnerCapabilities(
|
|
81
|
+
interactive=False, structured_output=True, resume=True, fork=False, budget=True
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def __init__(self, options: HeadlessOptions | None = None) -> None:
|
|
85
|
+
self._options = options or HeadlessOptions()
|
|
86
|
+
|
|
87
|
+
async def run(self, request: AgentRequest, ctx: RunContext) -> AgentResult:
|
|
88
|
+
command = self._options.command
|
|
89
|
+
if ctx.signal.aborted:
|
|
90
|
+
return AgentResult(ok=False, text="", error="aborted")
|
|
91
|
+
try:
|
|
92
|
+
proc = await asyncio.create_subprocess_exec(
|
|
93
|
+
command,
|
|
94
|
+
*build_headless_args(request, self._options.extra_args),
|
|
95
|
+
cwd=request.cwd,
|
|
96
|
+
stdin=asyncio.subprocess.PIPE,
|
|
97
|
+
stdout=asyncio.subprocess.PIPE,
|
|
98
|
+
stderr=asyncio.subprocess.PIPE,
|
|
99
|
+
start_new_session=True,
|
|
100
|
+
limit=_LINE_LIMIT,
|
|
101
|
+
)
|
|
102
|
+
except OSError as error:
|
|
103
|
+
return AgentResult(ok=False, text="", error=f"Failed to start {command}: {error}")
|
|
104
|
+
|
|
105
|
+
async def drive() -> AgentResult:
|
|
106
|
+
assert proc.stdin is not None
|
|
107
|
+
assert proc.stdout is not None
|
|
108
|
+
assert proc.stderr is not None
|
|
109
|
+
stderr_task = asyncio.ensure_future(proc.stderr.read())
|
|
110
|
+
try:
|
|
111
|
+
with contextlib.suppress(BrokenPipeError, ConnectionResetError):
|
|
112
|
+
proc.stdin.write(request.prompt.encode())
|
|
113
|
+
await proc.stdin.drain()
|
|
114
|
+
proc.stdin.close()
|
|
115
|
+
final: AgentResult | None = None
|
|
116
|
+
while line := await proc.stdout.readline():
|
|
117
|
+
msg = _parse_line(line)
|
|
118
|
+
if msg is None:
|
|
119
|
+
continue
|
|
120
|
+
if msg.get("type") == "result":
|
|
121
|
+
final = _to_result(msg)
|
|
122
|
+
_emit(ctx, {"kind": "result", "subtype": msg.get("subtype")})
|
|
123
|
+
elif msg.get("type") == "assistant":
|
|
124
|
+
message: dict[str, Any] = msg.get("message") or {}
|
|
125
|
+
blocks: list[dict[str, Any]] = message.get("content") or []
|
|
126
|
+
for block in blocks:
|
|
127
|
+
if block.get("type") == "tool_use":
|
|
128
|
+
_emit(ctx, {"kind": "tool_use", "name": block.get("name") or ""})
|
|
129
|
+
elif block.get("type") == "text":
|
|
130
|
+
_emit(ctx, {"kind": "text"})
|
|
131
|
+
code = await proc.wait()
|
|
132
|
+
if final is not None:
|
|
133
|
+
return final
|
|
134
|
+
stderr = (await stderr_task).decode(errors="replace").strip()
|
|
135
|
+
detail = f": {stderr[-500:]}" if stderr else ""
|
|
136
|
+
return AgentResult(
|
|
137
|
+
ok=False,
|
|
138
|
+
text="",
|
|
139
|
+
error=f"{command} exited with code {code} without a result{detail}",
|
|
140
|
+
)
|
|
141
|
+
finally:
|
|
142
|
+
stderr_task.cancel()
|
|
143
|
+
|
|
144
|
+
drive_task = asyncio.ensure_future(drive())
|
|
145
|
+
abort_task = asyncio.ensure_future(ctx.signal.wait())
|
|
146
|
+
wall_ms = request.limits.wall_ms if request.limits is not None else None
|
|
147
|
+
try:
|
|
148
|
+
done, _ = await asyncio.wait(
|
|
149
|
+
{drive_task, abort_task},
|
|
150
|
+
timeout=None if wall_ms is None else wall_ms / 1000,
|
|
151
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
152
|
+
)
|
|
153
|
+
if drive_task in done:
|
|
154
|
+
return drive_task.result()
|
|
155
|
+
# Kill the whole process group so grandchildren cannot keep our pipes open.
|
|
156
|
+
kill_process_group(proc.pid)
|
|
157
|
+
drive_task.cancel()
|
|
158
|
+
await asyncio.gather(drive_task, return_exceptions=True)
|
|
159
|
+
await proc.wait()
|
|
160
|
+
if abort_task in done:
|
|
161
|
+
return AgentResult(ok=False, text="", error="aborted")
|
|
162
|
+
return AgentResult(ok=False, text="", error="wall-time limit exceeded")
|
|
163
|
+
finally:
|
|
164
|
+
abort_task.cancel()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _parse_line(line: bytes) -> dict[str, Any] | None:
|
|
168
|
+
try:
|
|
169
|
+
msg = json.loads(line)
|
|
170
|
+
except ValueError:
|
|
171
|
+
return None
|
|
172
|
+
return msg if isinstance(msg, dict) else None # pyright: ignore[reportUnknownVariableType]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _emit(ctx: RunContext, event: dict[str, Any]) -> None:
|
|
176
|
+
if ctx.on_event is not None:
|
|
177
|
+
ctx.on_event(event)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def create_headless_runner(options: HeadlessOptions | None = None) -> HeadlessRunner:
|
|
181
|
+
return HeadlessRunner(options)
|