coding-agent-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,338 @@
1
+ """Asyncio subprocess transport for agent CLIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import time
9
+ from collections import deque
10
+ from collections.abc import Awaitable, Callable, Mapping
11
+ from dataclasses import dataclass
12
+
13
+ from ._platform import kill_process_tree, prepare_command, process_group_kwargs, track_process_tree
14
+
15
+ log = logging.getLogger("coding_agent_cli.process")
16
+
17
+ TAIL_BYTES = 64 * 1024
18
+ LINE_LIMIT_BYTES = 1024 * 1024
19
+ EXIT_GRACE_S = 2.0
20
+ POLL_INTERVAL_S = 0.1
21
+ CLEANUP_S = 10.0
22
+
23
+ LineHandler = Callable[[str], Awaitable[None]]
24
+ SpawnHandler = Callable[[int], Awaitable[None]]
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class ProcessOutcome:
29
+ """Exit status, newest output tail, and the failure that ended the run, if any."""
30
+
31
+ returncode: int | None = None
32
+ tail: str = ""
33
+ error: str = ""
34
+ error_class: str = ""
35
+ spawned: bool = False
36
+
37
+
38
+ class OutputTail:
39
+ """Newest lines within ``TAIL_BYTES``. The newest line is always kept, even when larger."""
40
+
41
+ def __init__(self) -> None:
42
+ self._lines: deque[tuple[str, int]] = deque()
43
+ self._size = 0
44
+
45
+ def append(self, line: str) -> None:
46
+ size = len(line.encode("utf-8")) + 1
47
+ self._lines.append((line, size))
48
+ self._size += size
49
+ while self._size > TAIL_BYTES and len(self._lines) > 1:
50
+ self._size -= self._lines.popleft()[1]
51
+
52
+ def text(self) -> str:
53
+ return "\n".join(line for line, _ in self._lines)
54
+
55
+
56
+ async def _write_stdin(stdin: asyncio.StreamWriter, payload: bytes) -> None:
57
+ stdin.write(payload)
58
+ await stdin.drain()
59
+ stdin.close()
60
+
61
+
62
+ async def _discard_until_eof(stdout: asyncio.StreamReader) -> None:
63
+ """Drain the killed group's leftover output so asyncio can close the pipe transport."""
64
+ try:
65
+ while await asyncio.wait_for(stdout.read(64 * 1024), EXIT_GRACE_S):
66
+ pass
67
+ except (TimeoutError, OSError):
68
+ pass
69
+
70
+
71
+ def _stdin_failure(writer: asyncio.Future[None] | None) -> BaseException | None:
72
+ if writer is None or not writer.done() or writer.cancelled():
73
+ return None
74
+ return writer.exception()
75
+
76
+
77
+ def _stdin_outcome(failure: BaseException) -> tuple[str, str]:
78
+ return f"stdin write failed: {type(failure).__name__}: {failure}", "stdin_failed"
79
+
80
+
81
+ def _expired(deadline: float | None, now: float) -> bool:
82
+ return deadline is not None and now >= deadline
83
+
84
+
85
+ def _stop_reason(
86
+ now: float,
87
+ deadline: float | None,
88
+ cancel: asyncio.Event | None,
89
+ writer: asyncio.Future[None] | None,
90
+ outcome: ProcessOutcome,
91
+ ) -> str:
92
+ """Return the first stop reason in check order: cancel, deadline, stdin failure. Empty means keep going."""
93
+ if cancel is not None and cancel.is_set():
94
+ return "cancelled"
95
+ if _expired(deadline, now):
96
+ return "timeout"
97
+ failure = _stdin_failure(writer)
98
+ if failure is None:
99
+ return ""
100
+ outcome.error, outcome.error_class = _stdin_outcome(failure)
101
+ return "failed"
102
+
103
+
104
+ def _note_exit(process: asyncio.subprocess.Process, exited_at: float | None, now: float) -> float | None:
105
+ """Return when the leader was first seen exited, or ``exited_at`` unchanged while it still runs."""
106
+ if process.returncode is not None and exited_at is None:
107
+ return now
108
+ return exited_at
109
+
110
+
111
+ async def _read_one(
112
+ stdout: asyncio.StreamReader,
113
+ tail: OutputTail,
114
+ on_line: LineHandler,
115
+ outcome: ProcessOutcome,
116
+ ) -> str:
117
+ """Read and handle one line. Return "" to keep looping, "eof" at end of output, or "failed"."""
118
+ try:
119
+ raw = await asyncio.wait_for(stdout.readline(), POLL_INTERVAL_S)
120
+ except TimeoutError:
121
+ return ""
122
+ except ValueError:
123
+ outcome.error, outcome.error_class = "output line exceeds 1 MiB", "execution_error"
124
+ return "failed"
125
+ if not raw:
126
+ return "eof"
127
+ line = raw.decode("utf-8", errors="replace").rstrip("\r\n")
128
+ tail.append(line)
129
+ try:
130
+ await on_line(line)
131
+ except Exception as exc: # noqa: BLE001 - a parser or callback failure fails the attempt
132
+ outcome.error, outcome.error_class = f"{type(exc).__name__}: {exc}", "execution_error"
133
+ return "failed"
134
+ return ""
135
+
136
+
137
+ async def _pump(
138
+ process: asyncio.subprocess.Process,
139
+ writer: asyncio.Future[None] | None,
140
+ deadline: float | None,
141
+ cancel: asyncio.Event | None,
142
+ on_line: LineHandler,
143
+ tail: OutputTail,
144
+ outcome: ProcessOutcome,
145
+ ) -> str:
146
+ """Poll the child until a stop reason, leader exit plus grace, or EOF. Return the stop reason or ""."""
147
+ assert process.stdout is not None
148
+ exited_at: float | None = None
149
+ eof = False
150
+ while True:
151
+ now = time.monotonic()
152
+ stop = _stop_reason(now, deadline, cancel, writer, outcome)
153
+ if stop:
154
+ return stop
155
+ exited_at = _note_exit(process, exited_at, now)
156
+ if exited_at is not None and (eof or now - exited_at >= EXIT_GRACE_S):
157
+ if writer is not None:
158
+ return await _finish_stdin(writer, outcome)
159
+ return ""
160
+ if eof:
161
+ await asyncio.sleep(POLL_INTERVAL_S)
162
+ continue
163
+ step = await _read_one(process.stdout, tail, on_line, outcome)
164
+ if step == "failed":
165
+ return step
166
+ eof = step == "eof"
167
+
168
+
169
+ async def _watch(deadline: float | None, cancel: asyncio.Event | None) -> str:
170
+ """Return "cancelled" or "timeout" when one fires, independent of the line handler."""
171
+ event = cancel if cancel is not None else asyncio.Event()
172
+ timeout = None if deadline is None else max(0.0, deadline - time.monotonic())
173
+ try:
174
+ async with asyncio.timeout(timeout):
175
+ await event.wait()
176
+ except TimeoutError:
177
+ return "timeout"
178
+ return "cancelled"
179
+
180
+
181
+ async def _finish_stdin(writer: asyncio.Future[None], outcome: ProcessOutcome) -> str:
182
+ """Give the stdin writer ``EXIT_GRACE_S`` to finish. Return "failed" when it failed, else ""."""
183
+ await asyncio.wait({writer}, timeout=EXIT_GRACE_S)
184
+ failure = _stdin_failure(writer)
185
+ if failure is None:
186
+ return ""
187
+ outcome.error, outcome.error_class = _stdin_outcome(failure)
188
+ return "failed"
189
+
190
+
191
+ def _map_stop(outcome: ProcessOutcome, stop: str, name: str) -> None:
192
+ """Set the final error from the stop reason, or from a nonzero exit when nothing stopped the run."""
193
+ if stop == "cancelled":
194
+ outcome.error, outcome.error_class = "command cancelled", "cancelled"
195
+ elif stop == "timeout":
196
+ outcome.error, outcome.error_class = f"{name} subprocess timed out", "timeout"
197
+ elif not stop and outcome.returncode:
198
+ outcome.error = f"command exited with status {outcome.returncode}"
199
+ outcome.error_class = "execution_error"
200
+
201
+
202
+ async def _spawn(
203
+ command: list[str],
204
+ payload: bytes | None,
205
+ env: Mapping[str, str] | None,
206
+ cwd: str | None,
207
+ ) -> asyncio.subprocess.Process:
208
+ return await asyncio.create_subprocess_exec(
209
+ *command,
210
+ stdin=asyncio.subprocess.DEVNULL if payload is None else asyncio.subprocess.PIPE,
211
+ stdout=asyncio.subprocess.PIPE,
212
+ stderr=asyncio.subprocess.STDOUT,
213
+ cwd=cwd,
214
+ env=None if env is None else {**os.environ, **env},
215
+ limit=LINE_LIMIT_BYTES,
216
+ **process_group_kwargs(),
217
+ )
218
+
219
+
220
+ async def _supervise(
221
+ process: asyncio.subprocess.Process,
222
+ writer: asyncio.Future[None] | None,
223
+ deadline: float | None,
224
+ cancel: asyncio.Event | None,
225
+ on_line: LineHandler,
226
+ tail: OutputTail,
227
+ outcome: ProcessOutcome,
228
+ ) -> str:
229
+ """Run the pump while an independent watcher enforces cancel and the deadline."""
230
+ pump = asyncio.ensure_future(_pump(process, writer, deadline, cancel, on_line, tail, outcome))
231
+ watcher = asyncio.ensure_future(_watch(deadline, cancel))
232
+ try:
233
+ await asyncio.wait({pump, watcher}, return_when=asyncio.FIRST_COMPLETED)
234
+ if pump.done():
235
+ return pump.result()
236
+ return watcher.result()
237
+ finally:
238
+ for task in (pump, watcher):
239
+ if not task.done():
240
+ task.cancel()
241
+ await asyncio.gather(pump, watcher, return_exceptions=True)
242
+
243
+
244
+ async def _cleanup(process: asyncio.subprocess.Process, writer: asyncio.Future[None] | None) -> None:
245
+ """Kill the tree, reap the leader, and drain output, all within ``CLEANUP_S``."""
246
+ assert process.stdout is not None
247
+ if writer is not None and not writer.done():
248
+ writer.cancel()
249
+ try:
250
+ async with asyncio.timeout(CLEANUP_S):
251
+ await kill_process_tree(process.pid)
252
+ if process.returncode is None:
253
+ try:
254
+ process.kill()
255
+ except ProcessLookupError:
256
+ pass
257
+ await process.wait()
258
+ if process.stdin is not None:
259
+ process.stdin.close()
260
+ await _discard_until_eof(process.stdout)
261
+ except TimeoutError:
262
+ log.warning("process cleanup exceeded %.1fs pid=%d", CLEANUP_S, process.pid)
263
+
264
+
265
+ async def _shielded(cleanup: asyncio.Future[None]) -> None:
266
+ """Await ``cleanup`` to completion even when the caller is cancelled, then re-raise the cancellation."""
267
+ cancelled = False
268
+ while not cleanup.done():
269
+ try:
270
+ await asyncio.shield(cleanup)
271
+ except asyncio.CancelledError:
272
+ if cleanup.done():
273
+ break
274
+ cancelled = True
275
+ if cancelled:
276
+ raise asyncio.CancelledError
277
+
278
+
279
+ async def run_process(
280
+ argv: list[str],
281
+ stdin: str | None,
282
+ env: Mapping[str, str] | None,
283
+ cwd: str | None,
284
+ deadline: float | None,
285
+ cancel: asyncio.Event | None,
286
+ on_line: LineHandler,
287
+ *,
288
+ on_spawn: SpawnHandler | None = None,
289
+ ) -> ProcessOutcome:
290
+ """Run ``argv`` in a new process group and await ``on_line`` for each output line.
291
+
292
+ stderr is merged into stdout. ``stdin`` is UTF-8 encoded before spawn, written, then
293
+ closed. ``env`` overlays ``os.environ``. A ``cancel`` already set prevents the spawn.
294
+ ``deadline`` is a ``time.monotonic`` value. Cancel and the deadline are enforced even
295
+ while ``on_line`` stalls. The process tree is killed on every exit path, and a cancelled
296
+ caller task gets ``CancelledError`` after cleanup. Exceptions from ``on_spawn`` propagate
297
+ after cleanup. A spawn ``OSError`` is returned as ``missing_cli`` or ``spawn_failed``.
298
+ """
299
+ if cancel is not None and cancel.is_set():
300
+ return ProcessOutcome(error="command cancelled", error_class="cancelled")
301
+ try:
302
+ payload = None if stdin is None else stdin.encode("utf-8")
303
+ except UnicodeEncodeError as exc:
304
+ return ProcessOutcome(error=f"prompt is not valid UTF-8: {exc}", error_class="invalid_prompt")
305
+
306
+ name = os.path.basename(argv[0])
307
+ started_at = time.monotonic()
308
+ try:
309
+ process = await _spawn(prepare_command(list(argv)), payload, env, cwd)
310
+ except FileNotFoundError as exc:
311
+ return ProcessOutcome(error=f"{name} could not be started: {exc.strerror or exc}", error_class="missing_cli")
312
+ except OSError as exc:
313
+ return ProcessOutcome(error=f"{name} could not be started: {exc.strerror or exc}", error_class="spawn_failed")
314
+ track_process_tree(process.pid)
315
+ log.debug("spawned %s pid=%d", name, process.pid)
316
+ writer = None
317
+ if payload is not None:
318
+ assert process.stdin is not None
319
+ writer = asyncio.ensure_future(_write_stdin(process.stdin, payload))
320
+ tail = OutputTail()
321
+ outcome = ProcessOutcome(spawned=True)
322
+ stop = ""
323
+ try:
324
+ if on_spawn is not None:
325
+ await on_spawn(process.pid)
326
+ stop = await _supervise(process, writer, deadline, cancel, on_line, tail, outcome)
327
+ finally:
328
+ await _shielded(asyncio.ensure_future(_cleanup(process, writer)))
329
+ outcome.returncode = process.returncode
330
+ outcome.tail = tail.text()
331
+
332
+ _map_stop(outcome, stop, name)
333
+ if outcome.error_class:
334
+ log.info("%s subprocess ended class=%s after %.2fs", name, outcome.error_class, time.monotonic() - started_at)
335
+ return outcome
336
+
337
+
338
+ __all__ = ["LineHandler", "OutputTail", "ProcessOutcome", "SpawnHandler", "run_process"]
@@ -0,0 +1,190 @@
1
+ """Public entry points: ``run``, ``stream``, ``run_sync``, and ``check``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import dataclasses
7
+ import logging
8
+ from collections.abc import Awaitable, Callable
9
+ from types import TracebackType
10
+
11
+ from .events import Event, RunCompleted
12
+ from .registry import DriverRegistry, SpawnHandler
13
+ from .types import Capabilities, Request, Result
14
+
15
+ log = logging.getLogger("coding_agent_cli")
16
+
17
+ STREAM_QUEUE_SIZE = 256
18
+
19
+ _default_registry: DriverRegistry | None = None
20
+
21
+
22
+ def _registry(registry: DriverRegistry | None) -> DriverRegistry:
23
+ global _default_registry
24
+ if registry is not None:
25
+ return registry
26
+ if _default_registry is None:
27
+ _default_registry = DriverRegistry()
28
+ return _default_registry
29
+
30
+
31
+ async def run(
32
+ req: Request,
33
+ *,
34
+ on_event: Callable[[Event], Awaitable[None]] | None = None,
35
+ cancel: asyncio.Event | None = None,
36
+ on_spawn: SpawnHandler | None = None,
37
+ registry: DriverRegistry | None = None,
38
+ ) -> Result:
39
+ """Run ``req`` to completion and return its ``Result``.
40
+
41
+ ``on_event`` receives each event with a strictly increasing ``sequence``, ending with
42
+ exactly one ``RunCompleted`` after the process tree is gone. An exception from
43
+ ``on_event`` or ``on_spawn`` is raised after cleanup. Setting ``cancel`` ends the run
44
+ with a ``cancelled`` result; from another thread use ``loop.call_soon_threadsafe(cancel.set)``.
45
+ Raises ``KeyError`` for an unknown driver.
46
+ """
47
+ driver = _registry(registry).get(req.driver)
48
+ sequence = 0
49
+ failures: list[BaseException] = []
50
+
51
+ async def emit(event: Event) -> None:
52
+ nonlocal sequence
53
+ if isinstance(event, RunCompleted):
54
+ return
55
+ sequence += 1
56
+ if on_event is None:
57
+ return
58
+ try:
59
+ await on_event(dataclasses.replace(event, sequence=sequence))
60
+ except Exception as exc:
61
+ failures.append(exc)
62
+ raise
63
+
64
+ log.debug("run start driver=%s", req.driver)
65
+ result = await driver.run(req, on_event=emit, cancel=cancel or asyncio.Event(), on_spawn=on_spawn)
66
+ if failures:
67
+ raise failures[0]
68
+ log.debug("run done driver=%s outcome=%s", req.driver, result.outcome)
69
+ if on_event is not None:
70
+ await on_event(RunCompleted(result=result, sequence=sequence + 1))
71
+ return result
72
+
73
+
74
+ class EventStream:
75
+ """Async iterator over one run's events, ending after ``RunCompleted``.
76
+
77
+ Use it as ``async with stream(req) as events: async for event in events: ...``.
78
+ Leaving the block or calling ``aclose()`` cancels the run and kills the process tree.
79
+ A bare ``async for`` without ``async with`` is safe only when it runs to the end.
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ req: Request,
85
+ *,
86
+ cancel: asyncio.Event | None,
87
+ on_spawn: SpawnHandler | None,
88
+ registry: DriverRegistry | None,
89
+ ) -> None:
90
+ self._req = req
91
+ self._cancel = cancel
92
+ self._on_spawn = on_spawn
93
+ self._registry = registry
94
+ self._queue: asyncio.Queue[Event] = asyncio.Queue(STREAM_QUEUE_SIZE)
95
+ self._task: asyncio.Task[Result] | None = None
96
+ self._finished = False
97
+
98
+ def _start(self) -> asyncio.Task[Result]:
99
+ if self._task is None:
100
+ self._task = asyncio.ensure_future(
101
+ run(
102
+ self._req,
103
+ on_event=self._queue.put,
104
+ cancel=self._cancel,
105
+ on_spawn=self._on_spawn,
106
+ registry=self._registry,
107
+ )
108
+ )
109
+ return self._task
110
+
111
+ def __aiter__(self) -> EventStream:
112
+ return self
113
+
114
+ async def __anext__(self) -> Event:
115
+ if self._finished:
116
+ raise StopAsyncIteration
117
+ task = self._start()
118
+ while True:
119
+ if not self._queue.empty():
120
+ return self._queue.get_nowait()
121
+ if task.done():
122
+ self._finished = True
123
+ if not task.cancelled() and task.exception() is not None:
124
+ raise task.exception() # type: ignore[misc]
125
+ raise StopAsyncIteration
126
+ getter = asyncio.ensure_future(self._queue.get())
127
+ try:
128
+ await asyncio.wait({getter, task}, return_when=asyncio.FIRST_COMPLETED)
129
+ finally:
130
+ if not getter.done():
131
+ getter.cancel()
132
+ if getter.done() and not getter.cancelled():
133
+ return getter.result()
134
+
135
+ async def aclose(self) -> None:
136
+ """Cancel the run if it is still going and wait for its cleanup."""
137
+ self._finished = True
138
+ task = self._task
139
+ if task is None:
140
+ return
141
+ if not task.done():
142
+ task.cancel()
143
+ try:
144
+ await task
145
+ except asyncio.CancelledError:
146
+ current = asyncio.current_task()
147
+ if current is not None and current.cancelling():
148
+ raise
149
+ except Exception:
150
+ pass
151
+
152
+ async def __aenter__(self) -> EventStream:
153
+ self._start()
154
+ return self
155
+
156
+ async def __aexit__(
157
+ self,
158
+ exc_type: type[BaseException] | None,
159
+ exc: BaseException | None,
160
+ tb: TracebackType | None,
161
+ ) -> None:
162
+ await self.aclose()
163
+
164
+
165
+ def stream(
166
+ req: Request,
167
+ *,
168
+ cancel: asyncio.Event | None = None,
169
+ on_spawn: SpawnHandler | None = None,
170
+ registry: DriverRegistry | None = None,
171
+ ) -> EventStream:
172
+ """Return an ``EventStream`` for ``req``. The run starts on ``async with`` or the first ``__anext__``."""
173
+ return EventStream(req, cancel=cancel, on_spawn=on_spawn, registry=registry)
174
+
175
+
176
+ def run_sync(req: Request, *, registry: DriverRegistry | None = None) -> Result:
177
+ """Run ``req`` with ``asyncio.run``. Raises ``RuntimeError`` inside a running event loop."""
178
+ try:
179
+ asyncio.get_running_loop()
180
+ except RuntimeError:
181
+ return asyncio.run(run(req, registry=registry))
182
+ raise RuntimeError("run_sync() cannot be called from a running event loop; await run() instead")
183
+
184
+
185
+ async def check(req: Request, *, registry: DriverRegistry | None = None, refresh: bool = False) -> Capabilities:
186
+ """Report whether the driver's CLI is available and supports structured streaming."""
187
+ return await _registry(registry).get(req.driver).check(req, refresh=refresh)
188
+
189
+
190
+ __all__ = ["EventStream", "check", "run", "run_sync", "stream"]
@@ -0,0 +1,109 @@
1
+ """Typed run events. Consumers must ignore kinds they do not know."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, ClassVar, Literal
7
+
8
+ from .types import Result, Usage
9
+
10
+ SCHEMA_VERSION = 1
11
+
12
+
13
+ @dataclass(frozen=True, slots=True, kw_only=True)
14
+ class SessionIdentified:
15
+ kind: ClassVar[str] = "session.identified"
16
+ session_id: str
17
+ sequence: int = 0
18
+ entity_id: str | None = None
19
+ schema_version: int = SCHEMA_VERSION
20
+
21
+
22
+ @dataclass(frozen=True, slots=True, kw_only=True)
23
+ class MessageStarted:
24
+ kind: ClassVar[str] = "assistant.message.started"
25
+ sequence: int = 0
26
+ entity_id: str | None = None
27
+ schema_version: int = SCHEMA_VERSION
28
+
29
+
30
+ @dataclass(frozen=True, slots=True, kw_only=True)
31
+ class TextDelta:
32
+ kind: ClassVar[str] = "assistant.text.delta"
33
+ text: str
34
+ sequence: int = 0
35
+ entity_id: str | None = None
36
+ schema_version: int = SCHEMA_VERSION
37
+
38
+
39
+ @dataclass(frozen=True, slots=True, kw_only=True)
40
+ class MessageCompleted:
41
+ kind: ClassVar[str] = "assistant.message.completed"
42
+ sequence: int = 0
43
+ entity_id: str | None = None
44
+ schema_version: int = SCHEMA_VERSION
45
+
46
+
47
+ @dataclass(frozen=True, slots=True, kw_only=True)
48
+ class ToolStarted:
49
+ kind: ClassVar[str] = "tool.started"
50
+ tool: str
51
+ activity: Literal["command", "tool_call"]
52
+ input: Any = None
53
+ sequence: int = 0
54
+ entity_id: str | None = None
55
+ schema_version: int = SCHEMA_VERSION
56
+
57
+
58
+ @dataclass(frozen=True, slots=True, kw_only=True)
59
+ class ToolCompleted:
60
+ kind: ClassVar[str] = "tool.completed"
61
+ status: Literal["completed", "error", "unknown"]
62
+ sequence: int = 0
63
+ entity_id: str | None = None
64
+ schema_version: int = SCHEMA_VERSION
65
+
66
+
67
+ @dataclass(frozen=True, slots=True, kw_only=True)
68
+ class UsageUpdated:
69
+ """A full replacement snapshot of usage so far, not an increment."""
70
+
71
+ kind: ClassVar[str] = "usage.updated"
72
+ usage: Usage
73
+ sequence: int = 0
74
+ entity_id: str | None = None
75
+ schema_version: int = SCHEMA_VERSION
76
+
77
+
78
+ @dataclass(frozen=True, slots=True, kw_only=True)
79
+ class RunCompleted:
80
+ kind: ClassVar[str] = "run.completed"
81
+ result: Result
82
+ sequence: int = 0
83
+ entity_id: str | None = None
84
+ schema_version: int = SCHEMA_VERSION
85
+
86
+
87
+ type Event = (
88
+ SessionIdentified
89
+ | MessageStarted
90
+ | TextDelta
91
+ | MessageCompleted
92
+ | ToolStarted
93
+ | ToolCompleted
94
+ | UsageUpdated
95
+ | RunCompleted
96
+ )
97
+
98
+ __all__ = [
99
+ "SCHEMA_VERSION",
100
+ "Event",
101
+ "MessageCompleted",
102
+ "MessageStarted",
103
+ "RunCompleted",
104
+ "SessionIdentified",
105
+ "TextDelta",
106
+ "ToolCompleted",
107
+ "ToolStarted",
108
+ "UsageUpdated",
109
+ ]
File without changes
@@ -0,0 +1,67 @@
1
+ """Driver protocol and the instance-scoped driver registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Awaitable, Callable
7
+ from typing import Protocol, runtime_checkable
8
+
9
+ from .events import Event
10
+ from .types import Capabilities, Request, Result
11
+
12
+ EventHandler = Callable[[Event], Awaitable[None]]
13
+ SpawnHandler = Callable[[int], Awaitable[None]]
14
+
15
+
16
+ @runtime_checkable
17
+ class Driver(Protocol):
18
+ """Runs one request through one CLI.
19
+
20
+ ``run`` awaits ``on_event`` for each event it produces, leaving ``sequence`` to the API,
21
+ and never emits ``RunCompleted``. It kills the process tree before returning.
22
+ """
23
+
24
+ async def check(self, req: Request, *, refresh: bool = False) -> Capabilities: ...
25
+
26
+ async def run(
27
+ self,
28
+ req: Request,
29
+ *,
30
+ on_event: EventHandler,
31
+ cancel: asyncio.Event,
32
+ on_spawn: SpawnHandler | None,
33
+ ) -> Result: ...
34
+
35
+
36
+ def _builtin_drivers() -> dict[str, Driver]:
37
+ # Imported here: the drivers import this module for the handler aliases.
38
+ from ._drivers.agy import AgyDriver
39
+ from ._drivers.claude import ClaudeDriver
40
+ from ._drivers.codex import CodexDriver
41
+ from ._drivers.pi import PiDriver
42
+
43
+ return {"claude": ClaudeDriver(), "pi": PiDriver(), "codex": CodexDriver(), "agy": AgyDriver()}
44
+
45
+
46
+ class DriverRegistry:
47
+ """Maps driver names to drivers. Each instance is independent."""
48
+
49
+ def __init__(self, *, builtins: bool = True) -> None:
50
+ self._drivers: dict[str, Driver] = _builtin_drivers() if builtins else {}
51
+
52
+ def register(self, name: str, driver: Driver, *, replace: bool = False) -> None:
53
+ if name in self._drivers and not replace:
54
+ raise ValueError(f"driver {name!r} is already registered")
55
+ self._drivers[name] = driver
56
+
57
+ def get(self, name: str) -> Driver:
58
+ try:
59
+ return self._drivers[name]
60
+ except KeyError:
61
+ raise KeyError(f"unknown driver {name!r}") from None
62
+
63
+ def names(self) -> tuple[str, ...]:
64
+ return tuple(sorted(self._drivers))
65
+
66
+
67
+ __all__ = ["Driver", "DriverRegistry", "EventHandler", "SpawnHandler"]