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.
Files changed (98) hide show
  1. whiletrue/__init__.py +0 -0
  2. whiletrue/__main__.py +3 -0
  3. whiletrue/abort.py +79 -0
  4. whiletrue/basics/__init__.py +99 -0
  5. whiletrue/basics/agent.py +249 -0
  6. whiletrue/basics/comments.py +113 -0
  7. whiletrue/basics/diff_size.py +80 -0
  8. whiletrue/basics/exec_.py +84 -0
  9. whiletrue/basics/history.py +117 -0
  10. whiletrue/basics/human.py +201 -0
  11. whiletrue/basics/json_source.py +35 -0
  12. whiletrue/basics/markdown_source.py +55 -0
  13. whiletrue/basics/process.py +99 -0
  14. whiletrue/basics/sessions.py +62 -0
  15. whiletrue/basics/set_.py +29 -0
  16. whiletrue/basics/shell.py +98 -0
  17. whiletrue/basics/wake.py +27 -0
  18. whiletrue/claude_code/__init__.py +58 -0
  19. whiletrue/claude_code/endpoint.py +143 -0
  20. whiletrue/claude_code/headless.py +181 -0
  21. whiletrue/claude_code/interactive.py +344 -0
  22. whiletrue/claude_code/learnings.py +32 -0
  23. whiletrue/claude_code/panes.py +164 -0
  24. whiletrue/claude_code/plugin/.claude-plugin/plugin.json +5 -0
  25. whiletrue/claude_code/plugin/hook.py +44 -0
  26. whiletrue/claude_code/plugin/hooks/hooks.json +81 -0
  27. whiletrue/claude_code/plugin/mcp_server.py +98 -0
  28. whiletrue/claude_code/session.py +345 -0
  29. whiletrue/claude_code/transcript.py +123 -0
  30. whiletrue/cli/__init__.py +15 -0
  31. whiletrue/cli/__main__.py +3 -0
  32. whiletrue/cli/app.py +550 -0
  33. whiletrue/cli/common.py +36 -0
  34. whiletrue/cli/gates.py +156 -0
  35. whiletrue/cli/ops.py +339 -0
  36. whiletrue/cli/panel.py +198 -0
  37. whiletrue/cli/registry.py +143 -0
  38. whiletrue/cli/templates.py +24 -0
  39. whiletrue/codex/__init__.py +14 -0
  40. whiletrue/codex/runner.py +243 -0
  41. whiletrue/control/__init__.py +38 -0
  42. whiletrue/control/chain.py +61 -0
  43. whiletrue/control/loop.py +139 -0
  44. whiletrue/control/map_.py +101 -0
  45. whiletrue/control/parallel.py +32 -0
  46. whiletrue/control/retry.py +100 -0
  47. whiletrue/control/sequence.py +40 -0
  48. whiletrue/control/switch.py +67 -0
  49. whiletrue/core/__init__.py +260 -0
  50. whiletrue/core/caps.py +139 -0
  51. whiletrue/core/comments.py +80 -0
  52. whiletrue/core/composite.py +140 -0
  53. whiletrue/core/config.py +461 -0
  54. whiletrue/core/context.py +177 -0
  55. whiletrue/core/duration.py +19 -0
  56. whiletrue/core/events.py +94 -0
  57. whiletrue/core/expr.py +85 -0
  58. whiletrue/core/history.py +83 -0
  59. whiletrue/core/lock.py +86 -0
  60. whiletrue/core/memory_store.py +352 -0
  61. whiletrue/core/module.py +146 -0
  62. whiletrue/core/outcomes.py +82 -0
  63. whiletrue/core/registry.py +129 -0
  64. whiletrue/core/replay.py +58 -0
  65. whiletrue/core/runner.py +98 -0
  66. whiletrue/core/runtime.py +538 -0
  67. whiletrue/core/source.py +45 -0
  68. whiletrue/core/store.py +207 -0
  69. whiletrue/core/template.py +60 -0
  70. whiletrue/core/validate.py +142 -0
  71. whiletrue/core/wake.py +117 -0
  72. whiletrue/core/workspace.py +55 -0
  73. whiletrue/git/__init__.py +31 -0
  74. whiletrue/git/modules.py +128 -0
  75. whiletrue/git/workspaces.py +77 -0
  76. whiletrue/github/__init__.py +30 -0
  77. whiletrue/github/client.py +269 -0
  78. whiletrue/github/filebacked.py +87 -0
  79. whiletrue/github/memory.py +96 -0
  80. whiletrue/github/plugin.py +343 -0
  81. whiletrue/github/specs.py +39 -0
  82. whiletrue/opencode/__init__.py +25 -0
  83. whiletrue/opencode/runner.py +186 -0
  84. whiletrue/py.typed +0 -0
  85. whiletrue/recipes/issue-loop.yaml +59 -0
  86. whiletrue/recipes/ralph.yaml +14 -0
  87. whiletrue/recipes/review-pass.yaml +27 -0
  88. whiletrue/recipes/review-respond.yaml +22 -0
  89. whiletrue/store_sqlite/__init__.py +6 -0
  90. whiletrue/store_sqlite/migrations.py +132 -0
  91. whiletrue/store_sqlite/sqlite_store.py +537 -0
  92. whiletrue/tmux/__init__.py +11 -0
  93. whiletrue/tmux/controller.py +197 -0
  94. whiletrue-0.1.0.dist-info/METADATA +48 -0
  95. whiletrue-0.1.0.dist-info/RECORD +98 -0
  96. whiletrue-0.1.0.dist-info/WHEEL +4 -0
  97. whiletrue-0.1.0.dist-info/entry_points.txt +2 -0
  98. 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)
@@ -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)