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,340 @@
1
+ """Agy CLI driver: stream-json on stdout, a temporary log file for the session ID, and the prompt in argv."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import os
8
+ import re
9
+ import subprocess
10
+ import tempfile
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .._classify import Facts, keyword_code
15
+ from .._platform import prepare_command
16
+ from .._process import ProcessOutcome, run_process
17
+ from ..events import SessionIdentified, TextDelta, ToolCompleted, UsageUpdated
18
+ from ..registry import EventHandler, SpawnHandler
19
+ from ..types import Diagnostic, Request, Result
20
+ from ._common import (
21
+ CliDriver,
22
+ Diagnostics,
23
+ EventBuffer,
24
+ as_int,
25
+ build_result,
26
+ deadline_for,
27
+ parse_json_object,
28
+ process_facts,
29
+ read_and_unlink,
30
+ request_failure,
31
+ tool_started,
32
+ )
33
+ from ._usage import TokenCounters
34
+
35
+ MAX_DIAGNOSTICS = 50
36
+ PROMPT_LIMIT_BYTES = 100 * 1024
37
+ WINDOWS_COMMAND_LINE_LIMIT_CHARS = 32767
38
+ _WINDOWS = os.name == "nt"
39
+ _UUID_PATTERN = r"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"
40
+ _CONVERSATION_ID_RE = re.compile(rf"(?:Created|Streaming) conversation {_UUID_PATTERN}")
41
+ _SUCCESS_STATUS = "SUCCESS"
42
+
43
+
44
+ def _event_model(event: dict[str, Any]) -> str | None:
45
+ """Return the model named by one event: its top-level ``model``, else its ``result.model``."""
46
+ model = event.get("model")
47
+ if isinstance(model, str) and model:
48
+ return model
49
+ result = event.get("result")
50
+ if isinstance(result, dict):
51
+ model = result.get("model")
52
+ if isinstance(model, str) and model:
53
+ return model
54
+ return None
55
+
56
+
57
+ def _prompt_error(prompt: str) -> Diagnostic | None:
58
+ """Reject a prompt that cannot be passed in argv: NUL bytes, invalid UTF-8, or more than the size limit."""
59
+ if "\x00" in prompt:
60
+ return Diagnostic(code="invalid_prompt", message="prompt cannot contain NUL bytes")
61
+ try:
62
+ size = len(prompt.encode("utf-8"))
63
+ except UnicodeEncodeError as exc:
64
+ return Diagnostic(code="invalid_prompt", message=f"prompt is not valid UTF-8: {exc}")
65
+ if size > PROMPT_LIMIT_BYTES:
66
+ return Diagnostic(
67
+ code="invalid_prompt",
68
+ message=(
69
+ f"agy prompt is {size} bytes, "
70
+ f"above the 100 KiB ({PROMPT_LIMIT_BYTES} bytes) limit for the prompt argument"
71
+ ),
72
+ )
73
+ return None
74
+
75
+
76
+ def _command_line_error(argv: list[str]) -> Diagnostic | None:
77
+ """On Windows, reject an argv whose command line exceeds the CreateProcess limit."""
78
+ if not _WINDOWS:
79
+ return None
80
+ chars = len(subprocess.list2cmdline(prepare_command(list(argv))))
81
+ if chars <= WINDOWS_COMMAND_LINE_LIMIT_CHARS:
82
+ return None
83
+ return Diagnostic(
84
+ code="invalid_prompt",
85
+ message=(
86
+ f"agy command line is {chars} characters, "
87
+ f"above the Windows limit of {WINDOWS_COMMAND_LINE_LIMIT_CHARS} characters"
88
+ ),
89
+ )
90
+
91
+
92
+ class AgyRun:
93
+ """Parse one Agy run line by line with bounded state. Raw lines are never retained after the first JSON event.
94
+
95
+ Plain-text lines are the reply only for a run that emits no JSON events, so they are kept until the first event.
96
+ """
97
+
98
+ def __init__(self, sink: EventHandler, *, requested_session: str | None) -> None:
99
+ self.events = EventBuffer(sink)
100
+ self.diagnostics = Diagnostics(MAX_DIAGNOSTICS)
101
+ self.counters = TokenCounters()
102
+ self.requested_session = requested_session or ""
103
+ self.stream_session_id = ""
104
+ self.conversation_id = ""
105
+ self.tool_counter = 0
106
+ self.active_tool_id = ""
107
+ self.step_counter = 0
108
+ self.deltas: list[str] = []
109
+ self.final_response = ""
110
+ self.status = ""
111
+ self.model: str | None = None
112
+ self.saw_event = False
113
+ self.plain_lines: list[str] = []
114
+
115
+ async def feed(self, line: str) -> None:
116
+ stripped = line.strip()
117
+ match = _CONVERSATION_ID_RE.search(stripped)
118
+ if match and not self.stream_session_id:
119
+ self.stream_session_id = match.group(1)
120
+ self._identify(self.stream_session_id)
121
+ event = parse_json_object(stripped)
122
+ if event is None:
123
+ if stripped:
124
+ code = keyword_code(stripped)
125
+ self.diagnostics.add(stripped, marks=(code,) if code else ())
126
+ if match is None and not self.saw_event:
127
+ self.plain_lines.append(stripped)
128
+ else:
129
+ self.saw_event = True
130
+ self.plain_lines.clear()
131
+ model = _event_model(event)
132
+ if model:
133
+ self.model = model
134
+ self._dispatch(event)
135
+ await self.events.flush()
136
+
137
+ def _identify(self, value: Any) -> None:
138
+ if isinstance(value, str) and value and value != self.conversation_id:
139
+ self.conversation_id = value
140
+ self.events.emit(SessionIdentified(session_id=value))
141
+
142
+ def _usage_updated(self) -> None:
143
+ self.events.emit(UsageUpdated(usage=self.counters.usage()))
144
+
145
+ def _dispatch(self, event: dict[str, Any]) -> None:
146
+ self._identify(event.get("conversation_id"))
147
+ kind = event.get("event", "")
148
+ if kind == "step_update":
149
+ self._step_update(event.get("step_update"))
150
+ elif kind == "result":
151
+ self._result(event.get("result"))
152
+
153
+ def _step_update(self, update: Any) -> None:
154
+ if not isinstance(update, dict):
155
+ return
156
+ self._identify(update.get("conversation_id"))
157
+ step_type = update.get("step_type", "")
158
+ if step_type == "agent_response":
159
+ self._append_delta(update.get("text_delta"))
160
+ elif step_type == "tool":
161
+ self._tool_update(update)
162
+
163
+ usage = update.get("usage")
164
+ if update.get("state", "") == "DONE" and isinstance(usage, dict):
165
+ self._step_usage(update, usage)
166
+
167
+ def _append_delta(self, value: Any) -> None:
168
+ if isinstance(value, str) and value:
169
+ self.deltas.append(value)
170
+ self.events.emit(TextDelta(text=value, entity_id="msg-1"))
171
+
172
+ def _tool_update(self, update: dict[str, Any]) -> None:
173
+ tool_info = update.get("tool_info")
174
+ if not isinstance(tool_info, dict):
175
+ tool_info = {}
176
+ state = update.get("state", "")
177
+ if state == "ACTIVE":
178
+ self.tool_counter += 1
179
+ self.active_tool_id = f"tool-{self.tool_counter}"
180
+ tool_name = str(update.get("tool_name", "") or tool_info.get("name", ""))
181
+ self.events.emit(
182
+ tool_started(self.active_tool_id, "agy", tool_name, tool_input=tool_info.get("parameters"))
183
+ )
184
+ elif state == "DONE":
185
+ self.events.emit(
186
+ ToolCompleted(status="completed", entity_id=self.active_tool_id or f"tool-{self.tool_counter or 1}")
187
+ )
188
+ self.active_tool_id = ""
189
+
190
+ def _step_usage(self, update: dict[str, Any], usage: dict[str, Any]) -> None:
191
+ raw_in = as_int(usage.get("input_tokens"))
192
+ raw_out = as_int(usage.get("output_tokens"))
193
+ cache_read = as_int(usage.get("cache_read_tokens"))
194
+ cache_write = as_int(usage.get("cache_write_tokens"))
195
+ if not any(v is not None for v in (raw_in, raw_out, cache_read, cache_write)):
196
+ return
197
+ self.step_counter += 1
198
+ step_index = update.get("step_index")
199
+ step_id = f"step_{step_index}" if step_index is not None else f"step_{self.step_counter}"
200
+ self.counters.record_response(
201
+ step_id, input=raw_in, output=raw_out, cache_read=cache_read, cache_write=cache_write
202
+ )
203
+ window = as_int(
204
+ usage.get("model_context_window")
205
+ or usage.get("context_window_size")
206
+ or update.get("model_context_window")
207
+ or update.get("context_window_size")
208
+ )
209
+ if raw_in is not None and raw_out is not None:
210
+ used = raw_in + raw_out - (as_int(usage.get("thinking_tokens")) or 0)
211
+ if used >= 0:
212
+ self.counters.record_context(used=used)
213
+ if window is not None:
214
+ self.counters.record_context(window=window)
215
+ self._usage_updated()
216
+
217
+ def _result(self, result: Any) -> None:
218
+ if not isinstance(result, dict):
219
+ return
220
+ self._identify(result.get("conversation_id"))
221
+ response = result.get("response")
222
+ if isinstance(response, str) and response:
223
+ self.final_response = response
224
+ status = result.get("status")
225
+ if isinstance(status, str):
226
+ self.status = status
227
+ usage = result.get("usage")
228
+ if not isinstance(usage, dict):
229
+ return
230
+ raw_in = as_int(usage.get("input_tokens"))
231
+ raw_out = as_int(usage.get("output_tokens"))
232
+ cache_read = as_int(usage.get("cache_read_tokens"))
233
+ cache_write = as_int(usage.get("cache_write_tokens"))
234
+ totals = any(v is not None for v in (raw_in, raw_out, cache_read, cache_write))
235
+ if totals:
236
+ self.counters.record_totals(input=raw_in, output=raw_out, cache_read=cache_read, cache_write=cache_write)
237
+ window = as_int(usage.get("model_context_window") or usage.get("context_window_size"))
238
+ if window is not None:
239
+ self.counters.record_context(window=window)
240
+ if totals or window is not None:
241
+ self._usage_updated()
242
+
243
+ def finish(self, process: ProcessOutcome, cancel: asyncio.Event, log_text: str) -> Result:
244
+ """Map the parsed stream, the log file text, and the transport outcome to a result."""
245
+ match = _CONVERSATION_ID_RE.search(log_text)
246
+ session_id = (
247
+ match.group(1) if match else (self.stream_session_id or self.conversation_id or self.requested_session)
248
+ )
249
+ if not self.saw_event:
250
+ text = "\n".join(self.plain_lines).strip()
251
+ else:
252
+ text = (self.final_response or "".join(self.deltas)).strip()
253
+
254
+ facts: Facts = process_facts(process, cancel)
255
+ facts.text = text
256
+ facts.session_id = session_id or None
257
+ if self.status and self.status != _SUCCESS_STATUS:
258
+ facts.cli_error = Diagnostic(code="execution_error", message=f"agy reported status {self.status}")
259
+ # Diagnostics classify the run only when there is no agent output, or when the process exited nonzero.
260
+ if not text or facts.returncode:
261
+ facts.diagnostics = self.diagnostics.text()
262
+ return build_result(facts, model=self.model, usage=self.counters.usage())
263
+
264
+
265
+ class AgyDriver(CliDriver):
266
+ """Runs ``agy --print`` with the prompt in argv, in ``Request.cwd``. The driver never adds a permission flag."""
267
+
268
+ name = "agy"
269
+ executable = "agy"
270
+ owned_flags = (
271
+ "--output-format",
272
+ "--log-file",
273
+ "--conversation",
274
+ "--new-project",
275
+ "--print",
276
+ "-p",
277
+ "--prompt",
278
+ "--continue",
279
+ "-c",
280
+ )
281
+ portable_flags = {"model": ("--model",)}
282
+ help_markers = ("--output-format", "stream-json", "--log-file")
283
+
284
+ def validate(self, req: Request) -> Diagnostic | None:
285
+ return _prompt_error(req.prompt)
286
+
287
+ def _argv(self, executable: Path, req: Request, log_path: Path) -> list[str]:
288
+ argv = [os.fspath(executable), "--output-format", "stream-json", "--log-file", os.fspath(log_path)]
289
+ if req.session_id:
290
+ argv.extend(["--conversation", req.session_id])
291
+ else:
292
+ argv.append("--new-project")
293
+ argv.extend(self.portable_argv(req))
294
+ argv.extend(req.args)
295
+ # The prompt comes last so no caller arg can take it as a flag value.
296
+ argv.extend(["--print", req.prompt])
297
+ return argv
298
+
299
+ async def run(
300
+ self,
301
+ req: Request,
302
+ *,
303
+ on_event: EventHandler,
304
+ cancel: asyncio.Event,
305
+ on_spawn: SpawnHandler | None,
306
+ ) -> Result:
307
+ prepared = self.preflight(req, cancel)
308
+ if isinstance(prepared, Result):
309
+ return prepared
310
+ cwd, executable = prepared
311
+ fd, name = tempfile.mkstemp(prefix="coding-agent-cli-", suffix=".log")
312
+ os.close(fd)
313
+ log_path = Path(name)
314
+ self.log.info("run start resume=%s prompt_len=%d", bool(req.session_id), len(req.prompt))
315
+ run = AgyRun(on_event, requested_session=req.session_id)
316
+ try:
317
+ argv = self._argv(executable, req, log_path)
318
+ error = _command_line_error(argv)
319
+ if error is not None:
320
+ return request_failure(req, error, cancel)
321
+ process = await run_process(
322
+ argv,
323
+ None,
324
+ dict(req.env),
325
+ os.fspath(cwd),
326
+ deadline_for(req),
327
+ cancel,
328
+ run.feed,
329
+ on_spawn=on_spawn,
330
+ )
331
+ log_text = read_and_unlink(log_path, errors="ignore")
332
+ finally:
333
+ with contextlib.suppress(OSError):
334
+ log_path.unlink()
335
+ result = run.finish(process, cancel, log_text)
336
+ self.log.info("run done outcome=%s", result.outcome)
337
+ return result
338
+
339
+
340
+ __all__ = ["MAX_DIAGNOSTICS", "PROMPT_LIMIT_BYTES", "AgyDriver", "AgyRun"]
@@ -0,0 +1,304 @@
1
+ """Claude Code CLI driver using print-mode stream-json output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .._classify import http_status_code, keyword_code
12
+ from .._process import ProcessOutcome, run_process
13
+ from ..events import SessionIdentified, TextDelta, ToolCompleted, UsageUpdated
14
+ from ..registry import EventHandler, SpawnHandler
15
+ from ..types import Diagnostic, Request, Result
16
+ from ._common import (
17
+ NO_USAGE,
18
+ CliDriver,
19
+ Diagnostics,
20
+ EventBuffer,
21
+ as_int,
22
+ build_result,
23
+ deadline_for,
24
+ parse_json_object,
25
+ process_facts,
26
+ read_usage,
27
+ tool_started,
28
+ )
29
+ from ._usage import TokenCounters
30
+
31
+ MAX_DIAGNOSTICS = 50
32
+ _RESULT_FIELDS = ("result", "session_id", "is_error", "subtype", "api_error_status", "modelUsage")
33
+
34
+
35
+ def _value_text(value: Any) -> str:
36
+ if value is None:
37
+ return ""
38
+ if isinstance(value, str):
39
+ return value
40
+ try:
41
+ return json.dumps(value, ensure_ascii=False, sort_keys=True)
42
+ except (TypeError, ValueError):
43
+ return str(value)
44
+
45
+
46
+ def _unwrap(event: dict[str, Any]) -> dict[str, Any]:
47
+ inner = event.get("event")
48
+ return inner if event.get("type") == "stream_event" and isinstance(inner, dict) else event
49
+
50
+
51
+ def _http_status(value: Any) -> int | None:
52
+ if isinstance(value, str) and value.isdigit():
53
+ return int(value)
54
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
55
+
56
+
57
+ def _result_failed(final: dict[str, Any]) -> bool:
58
+ return bool(final.get("is_error")) or final.get("subtype") != "success"
59
+
60
+
61
+ def _result_error_text(final: dict[str, Any]) -> str:
62
+ parts = [_value_text(final.get("result"))]
63
+ status = _value_text(final.get("api_error_status"))
64
+ if status:
65
+ parts.append(f"api_error_status={status}")
66
+ return "\n".join(part for part in parts if part).strip()
67
+
68
+
69
+ class ClaudeRun:
70
+ """Parse one Claude run line by line. Raw lines are never retained.
71
+
72
+ Diagnostics keep the first ``MAX_DIAGNOSTICS`` lines, plus the first line for each CLI error code
73
+ beyond that bound, so the classification matches an unbounded run.
74
+ """
75
+
76
+ def __init__(self, sink: EventHandler) -> None:
77
+ self.events = EventBuffer(sink)
78
+ self.diagnostics = Diagnostics(MAX_DIAGNOSTICS)
79
+ self.counters = TokenCounters()
80
+ self.final: dict[str, Any] | None = None
81
+ self.session_id = ""
82
+ self.active_model = ""
83
+ self.response_counter = 0
84
+ self.message_counter = 0
85
+ self.tool_counter = 0
86
+ self.tool_entities: dict[str, str] = {}
87
+
88
+ async def feed(self, line: str) -> None:
89
+ stripped = line.strip()
90
+ event = parse_json_object(stripped)
91
+ if event is None:
92
+ if stripped:
93
+ self._keep_diagnostic(stripped)
94
+ else:
95
+ self._record(event)
96
+ await self.events.flush()
97
+
98
+ def finish(self, process: ProcessOutcome, cancel: asyncio.Event, requested_session: str | None) -> Result:
99
+ """Map the parsed stream and the transport outcome to a result."""
100
+ facts = process_facts(process, cancel)
101
+ final = self.final
102
+ if final is not None and _result_failed(final):
103
+ code = http_status_code(_http_status(final.get("api_error_status"))) or "execution_error"
104
+ facts.cli_error = Diagnostic(code=code, message=_result_error_text(final))
105
+ elif final is not None:
106
+ facts.text = _value_text(final.get("result"))
107
+ facts.session_id = self.session_id or requested_session or ""
108
+ facts.diagnostics = self.diagnostics.text()
109
+ return build_result(facts, model=self._model(), usage=self.counters.usage())
110
+
111
+ def _keep_diagnostic(self, line: str) -> None:
112
+ code = keyword_code(line)
113
+ self.diagnostics.add(line, marks=(code,) if code else ())
114
+
115
+ def _record(self, raw: dict[str, Any]) -> None:
116
+ event = _unwrap(raw)
117
+ if event.get("type") == "result":
118
+ self.final = {key: event.get(key) for key in _RESULT_FIELDS}
119
+ self._identify(raw.get("session_id"))
120
+
121
+ kind = event.get("type")
122
+ if kind == "message_start":
123
+ self.message_counter += 1
124
+ elif kind == "content_block_delta":
125
+ self._text_delta(event)
126
+ elif kind == "content_block_start":
127
+ self._start_tool(event)
128
+ elif kind == "user":
129
+ self._tool_results(event)
130
+ elif kind == "assistant":
131
+ self._assistant(event)
132
+ elif kind == "result":
133
+ self._result(event)
134
+
135
+ def _identify(self, value: Any) -> None:
136
+ if isinstance(value, str) and value and value != self.session_id:
137
+ self.session_id = value
138
+ self.events.emit(SessionIdentified(session_id=value))
139
+
140
+ def _message_entity(self) -> str:
141
+ return f"msg-{self.message_counter or 1}"
142
+
143
+ def _usage_updated(self) -> None:
144
+ self.events.emit(UsageUpdated(usage=self.counters.usage()))
145
+
146
+ def _text_delta(self, event: dict[str, Any]) -> None:
147
+ delta = event.get("delta")
148
+ if not isinstance(delta, dict) or delta.get("type") != "text_delta":
149
+ return
150
+ text = delta.get("text")
151
+ if isinstance(text, str) and text:
152
+ self.events.emit(TextDelta(text=text, entity_id=self._message_entity()))
153
+
154
+ def _start_tool(self, event: dict[str, Any]) -> None:
155
+ block = event.get("content_block")
156
+ if not isinstance(block, dict) or block.get("type") != "tool_use":
157
+ return
158
+ self.tool_counter += 1
159
+ entity_id = f"tool-{self.tool_counter}"
160
+ tool_use_id = block.get("id")
161
+ if isinstance(tool_use_id, str) and tool_use_id:
162
+ self.tool_entities[tool_use_id] = entity_id
163
+ self.events.emit(tool_started(entity_id, "claude", str(block.get("name", "")), tool_input=block.get("input")))
164
+
165
+ def _tool_results(self, event: dict[str, Any]) -> None:
166
+ message = event.get("message")
167
+ content = message.get("content") if isinstance(message, dict) else None
168
+ if not isinstance(content, list):
169
+ return
170
+ for block in content:
171
+ if not isinstance(block, dict) or block.get("type") != "tool_result":
172
+ continue
173
+ tool_use_id = block.get("tool_use_id")
174
+ entity_id = self.tool_entities.pop(tool_use_id, None) if isinstance(tool_use_id, str) else None
175
+ if entity_id is None:
176
+ continue
177
+ status = "error" if block.get("is_error") is True else "completed"
178
+ self.events.emit(ToolCompleted(status=status, entity_id=entity_id))
179
+
180
+ def _assistant(self, event: dict[str, Any]) -> None:
181
+ message = event.get("message")
182
+ if not isinstance(message, dict):
183
+ return
184
+ model = message.get("model")
185
+ if isinstance(model, str) and model:
186
+ self.active_model = model
187
+ usage = message.get("usage")
188
+ if not isinstance(usage, dict):
189
+ return
190
+ tok = read_usage(usage)
191
+ if not tok.observed:
192
+ return
193
+
194
+ msg_id = message.get("id")
195
+ response_id = str(msg_id) if msg_id else f"msg-{self.response_counter + 1}"
196
+ self.response_counter += 1
197
+ self.counters.record_response(
198
+ response_id,
199
+ input=tok.input,
200
+ output=tok.output,
201
+ cache_read=tok.cache_read,
202
+ cache_write=tok.cache_write,
203
+ )
204
+ self._usage_updated()
205
+ if tok.input is not None:
206
+ self.counters.record_context(used=tok.input + (tok.output or 0))
207
+ self._usage_updated()
208
+
209
+ def _context_capacity(self, event: dict[str, Any]) -> int | None:
210
+ model_usage = event.get("modelUsage")
211
+ if not isinstance(model_usage, dict):
212
+ return None
213
+ target_model = self.active_model
214
+ if not target_model and len(model_usage) == 1:
215
+ target_model = next(iter(model_usage))
216
+ if not target_model or target_model not in model_usage:
217
+ return None
218
+ stats = model_usage[target_model]
219
+ if not isinstance(stats, dict):
220
+ return None
221
+ cap = as_int(stats.get("contextWindow"))
222
+ return cap if cap and cap > 0 else None
223
+
224
+ def _result(self, event: dict[str, Any]) -> None:
225
+ capacity = self._context_capacity(event)
226
+
227
+ usage = event.get("usage")
228
+ tok = read_usage(usage) if isinstance(usage, dict) else NO_USAGE
229
+ if tok.observed:
230
+ self.counters.record_totals(
231
+ input=tok.input,
232
+ output=tok.output,
233
+ cache_read=tok.cache_read,
234
+ cache_write=tok.cache_write,
235
+ )
236
+ self._usage_updated()
237
+ if capacity is not None:
238
+ self.counters.record_context(window=capacity)
239
+ self._usage_updated()
240
+
241
+ def _model(self) -> str | None:
242
+ final = self.final
243
+ if final is not None:
244
+ model_usage = final.get("modelUsage")
245
+ if isinstance(model_usage, dict) and model_usage:
246
+ if self.active_model and self.active_model in model_usage:
247
+ return self.active_model
248
+ first = next(iter(model_usage))
249
+ if isinstance(first, str) and first:
250
+ return first
251
+ return self.active_model or None
252
+
253
+
254
+ class ClaudeDriver(CliDriver):
255
+ """Runs ``claude -p`` with the prompt on stdin. The driver never adds a permission flag."""
256
+
257
+ name = "claude"
258
+ executable = "claude"
259
+ owned_flags = ("-p", "--output-format", "--include-partial-messages", "--resume")
260
+ portable_flags = {
261
+ "system_prompt": ("--system-prompt",),
262
+ "model": ("--model",),
263
+ "reasoning": ("--effort",),
264
+ }
265
+ help_markers = ("--output-format", "stream-json", "--include-partial-messages")
266
+
267
+ def _argv(self, executable: Path, req: Request) -> list[str]:
268
+ argv = [os.fspath(executable), "-p", "--output-format", "stream-json", "--include-partial-messages"]
269
+ if req.session_id:
270
+ argv.extend(["--resume", req.session_id])
271
+ argv.extend(self.portable_argv(req))
272
+ argv.extend(req.args)
273
+ return argv
274
+
275
+ async def run(
276
+ self,
277
+ req: Request,
278
+ *,
279
+ on_event: EventHandler,
280
+ cancel: asyncio.Event,
281
+ on_spawn: SpawnHandler | None,
282
+ ) -> Result:
283
+ prepared = self.preflight(req, cancel)
284
+ if isinstance(prepared, Result):
285
+ return prepared
286
+ cwd, executable = prepared
287
+ self.log.info("run start resume=%s prompt_len=%d", bool(req.session_id), len(req.prompt))
288
+ run = ClaudeRun(on_event)
289
+ process = await run_process(
290
+ self._argv(executable, req),
291
+ req.prompt,
292
+ dict(req.env),
293
+ os.fspath(cwd),
294
+ deadline_for(req),
295
+ cancel,
296
+ run.feed,
297
+ on_spawn=on_spawn,
298
+ )
299
+ result = run.finish(process, cancel, req.session_id)
300
+ self.log.info("run done outcome=%s", result.outcome)
301
+ return result
302
+
303
+
304
+ __all__ = ["MAX_DIAGNOSTICS", "ClaudeDriver", "ClaudeRun"]