agentshim 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.
agentshim/cli_agent.py ADDED
@@ -0,0 +1,433 @@
1
+ import io
2
+ import os
3
+ import shutil
4
+ import signal
5
+ import subprocess
6
+ import sys
7
+ import threading
8
+ from abc import abstractmethod
9
+ from collections.abc import Callable
10
+ from typing import Any
11
+
12
+ from loguru import logger
13
+
14
+ from agentshim.trajectory import NullTrajectoryRecorder, TrajectoryRecorderProtocol
15
+
16
+ from .base import CodingAgent
17
+ from .events import AgentEventHandler
18
+ from .mcp_config import McpServerConfig
19
+ from .usage import ProviderUsage
20
+ from .utils import get_interactive_env
21
+
22
+
23
+ class CLIGenerationSession:
24
+ """Handles a single generation request lifecycle."""
25
+
26
+ def __init__(
27
+ self,
28
+ binary_name: str,
29
+ env: dict[str, str],
30
+ log_prefix: str,
31
+ cmd: list[str],
32
+ logger: Any,
33
+ cwd: str | None = None,
34
+ timeout: int = 300,
35
+ silent: bool = False,
36
+ recorder: TrajectoryRecorderProtocol | None = None,
37
+ event_handler: AgentEventHandler | None = None,
38
+ on_process_started: Callable[[subprocess.Popen[str]], None] | None = None,
39
+ ):
40
+ self.binary_name = binary_name
41
+ self.env = env
42
+ self.log_prefix = log_prefix
43
+ self.cmd = cmd
44
+ self.logger = logger
45
+ self.cwd = cwd
46
+ self.timeout = timeout
47
+ self.silent = silent
48
+ self.recorder = recorder or NullTrajectoryRecorder()
49
+ self.event_handler = event_handler
50
+ self.on_process_started = on_process_started
51
+
52
+ # State initialization
53
+ self.stdout_lines: list[str] = []
54
+ self.stderr_lines: list[str] = []
55
+ self._at_line_start = True
56
+ # Providers populate this during event handling; stays at the
57
+ # empty default if the session crashes before any terminal event.
58
+ self.usage: ProviderUsage = ProviderUsage()
59
+ # Provider session id captured from the event stream (set by subclasses
60
+ # that parse JSON events). ``None`` if the underlying CLI did not emit
61
+ # an id during this run.
62
+ self.session_id: str | None = None
63
+
64
+ def _log_raw(self, message: str) -> None:
65
+ """Log a raw message directly to output if not silent."""
66
+ if not self.silent:
67
+ self.logger.opt(raw=True).info(message)
68
+
69
+ def _process_stdout(self, line: str) -> None:
70
+ """Process a line from stdout."""
71
+ line_stripped = line.rstrip("\n")
72
+ if not self.silent:
73
+ self.logger.info(line_stripped)
74
+
75
+ if self.event_handler and line_stripped:
76
+ self.event_handler.on_thinking(line_stripped + "\n")
77
+
78
+ self.stdout_lines.append(line)
79
+
80
+ def _print_stream_content(self, content: str):
81
+ """Print streaming content with prefix handling."""
82
+ if not content:
83
+ return
84
+
85
+ lines = content.split("\n")
86
+
87
+ for i, line in enumerate(lines):
88
+ is_last = i == len(lines) - 1
89
+
90
+ if is_last:
91
+ if line:
92
+ if self._at_line_start:
93
+ self._log_raw(f"{self.log_prefix} ")
94
+ self._at_line_start = False
95
+ self._log_raw(line)
96
+ else:
97
+ if self._at_line_start:
98
+ self._log_raw(f"{self.log_prefix} ")
99
+ self._log_raw(line)
100
+ self._log_raw("\n")
101
+ self._at_line_start = True
102
+
103
+ def _process_stderr(self, line: str) -> None:
104
+ """Process a line from stderr."""
105
+ line_stripped = line.rstrip("\n")
106
+ if not self.silent:
107
+ self.logger.bind(stderr=True).info(f"[STDERR] {line_stripped}")
108
+ self.stderr_lines.append(line)
109
+
110
+ def run(self, prompt: str) -> str:
111
+ """Execute the generation process."""
112
+ if not self.silent:
113
+ self.logger.info(f"Running command: {' '.join(self.cmd)}")
114
+ self._log_raw("=" * 80 + "\n")
115
+ sys.stdout.flush()
116
+
117
+ def read_stdout(pipe: io.TextIOWrapper) -> None:
118
+ for line in iter(pipe.readline, ""):
119
+ if not line:
120
+ break
121
+ self._process_stdout(line)
122
+ pipe.close()
123
+
124
+ def read_stderr(pipe: io.TextIOWrapper) -> None:
125
+ for line in iter(pipe.readline, ""):
126
+ if not line:
127
+ break
128
+ self._process_stderr(line)
129
+ pipe.close()
130
+
131
+ process = subprocess.Popen(
132
+ self.cmd,
133
+ stdin=subprocess.PIPE,
134
+ stdout=subprocess.PIPE,
135
+ stderr=subprocess.PIPE,
136
+ text=True,
137
+ bufsize=1,
138
+ cwd=self.cwd,
139
+ env=self.env,
140
+ start_new_session=True,
141
+ )
142
+
143
+ if self.on_process_started is not None:
144
+ try:
145
+ self.on_process_started(process)
146
+ except Exception as exc:
147
+ self.logger.warning(f"on_process_started callback raised: {exc}")
148
+
149
+ stdout_thread = threading.Thread(target=read_stdout, args=(process.stdout,))
150
+ stderr_thread = threading.Thread(target=read_stderr, args=(process.stderr,))
151
+
152
+ stdout_thread.daemon = True
153
+ stderr_thread.daemon = True
154
+
155
+ stdout_thread.start()
156
+ stderr_thread.start()
157
+
158
+ try:
159
+ if process.stdin:
160
+ process.stdin.write(prompt)
161
+ process.stdin.close()
162
+ except BrokenPipeError:
163
+ pass
164
+
165
+ try:
166
+ process.wait(timeout=self.timeout)
167
+ except subprocess.TimeoutExpired:
168
+ try:
169
+ os.killpg(os.getpgid(process.pid), signal.SIGKILL)
170
+ except ProcessLookupError:
171
+ pass
172
+ process.wait()
173
+ raise subprocess.TimeoutExpired(self.cmd, self.timeout) from None
174
+ finally:
175
+ if process.poll() is None:
176
+ try:
177
+ os.killpg(os.getpgid(process.pid), signal.SIGKILL)
178
+ process.wait()
179
+ except (ProcessLookupError, OSError):
180
+ pass
181
+
182
+ stdout_thread.join(timeout=1)
183
+ stderr_thread.join(timeout=1)
184
+
185
+ stdout_data = "".join(self.stdout_lines)
186
+ stderr_data = "".join(self.stderr_lines)
187
+
188
+ self._log_raw("=" * 80 + "\n")
189
+
190
+ if process.returncode != 0:
191
+ raise RuntimeError(f"{self.binary_name} exited with code {process.returncode}: {stderr_data}")
192
+
193
+ return stdout_data.strip()
194
+
195
+
196
+ class CLICodingAgent(CodingAgent):
197
+ """Base class for CLI-based coding agents."""
198
+
199
+ CLI_CHECK_TIMEOUT_SECONDS = 15
200
+
201
+ def __init__(
202
+ self,
203
+ binary_name: str,
204
+ model: str | None = None,
205
+ recorder: TrajectoryRecorderProtocol | None = None,
206
+ event_handler: AgentEventHandler | None = None,
207
+ mcp_servers: list[McpServerConfig] | None = None,
208
+ ):
209
+ """Initialize the CLI coding agent.
210
+
211
+ Args:
212
+ binary_name: The name of the executable to use.
213
+ model: Optional model name to use.
214
+ recorder: Trajectory recorder instance.
215
+ event_handler: Optional event handler for UI updates.
216
+ mcp_servers: Optional list of MCP server configurations.
217
+
218
+ Raises:
219
+ RuntimeError: If binary is not found in PATH or is not working.
220
+ """
221
+ self.env = get_interactive_env()
222
+ self.binary_name = binary_name
223
+ self.model = model
224
+ self.recorder: TrajectoryRecorderProtocol = recorder or NullTrajectoryRecorder()
225
+ self.event_handler = event_handler
226
+ self.mcp_servers: list[McpServerConfig] = mcp_servers or []
227
+
228
+ # Search for binary in the captured environment's PATH
229
+ binary_path = shutil.which(binary_name, path=self.env.get("PATH"))
230
+
231
+ if not binary_path:
232
+ # Fallback to current PATH if not found in interactive env
233
+ binary_path = shutil.which(binary_name)
234
+
235
+ if not binary_path:
236
+ raise RuntimeError(
237
+ f"{binary_name} binary not found in PATH. Please ensure {binary_name} is installed and available."
238
+ )
239
+ self.binary_path = binary_path
240
+ self._check_cli()
241
+ self.logger = logger.bind(agent_prefix=self._log_prefix)
242
+ # Populated after each generate() call from the session's usage.
243
+ self.last_usage: ProviderUsage = ProviderUsage()
244
+
245
+ def _check_cli(self):
246
+ """Check if the CLI tool is available and executable."""
247
+ try:
248
+ result = subprocess.run(
249
+ [self.binary_path, "--help"],
250
+ capture_output=True,
251
+ text=True,
252
+ check=False,
253
+ env=self.env,
254
+ stdin=subprocess.DEVNULL,
255
+ timeout=self.CLI_CHECK_TIMEOUT_SECONDS,
256
+ )
257
+ if result.returncode != 0:
258
+ raise RuntimeError(
259
+ f"{self.binary_name} CLI tool at '{self.binary_path}' is not working correctly. "
260
+ f"'{self.binary_path} --help' exited with code {result.returncode}. "
261
+ f"Stderr: {result.stderr}"
262
+ )
263
+ except subprocess.TimeoutExpired as e:
264
+ raise RuntimeError(
265
+ f"{self.binary_name} CLI tool at '{self.binary_path}' did not respond to "
266
+ f"'--help' within {self.CLI_CHECK_TIMEOUT_SECONDS}s."
267
+ ) from e
268
+ except FileNotFoundError as e:
269
+ raise RuntimeError(
270
+ f"{self.binary_name} CLI tool not found at '{self.binary_path}'. "
271
+ f"Please ensure {self.binary_name} is installed and in your PATH."
272
+ ) from e
273
+ except Exception as e:
274
+ raise RuntimeError(f"Failed to check {self.binary_name} CLI tool: {e}") from e
275
+
276
+ @abstractmethod
277
+ def _get_command(self, prompt: str, resume_session_id: str | None = None) -> list[str]:
278
+ """Construct the command line arguments.
279
+
280
+ Args:
281
+ prompt: The prompt to send to the agent.
282
+ resume_session_id: If set, the provider session id to resume.
283
+ """
284
+
285
+ @property
286
+ def _log_prefix(self) -> str:
287
+ """Return the log prefix for this agent."""
288
+ return f"[{self.__class__.__name__}]"
289
+
290
+ def _create_session(
291
+ self,
292
+ cmd: list[str],
293
+ cwd: str | None = None,
294
+ timeout: int = 300,
295
+ silent: bool = False,
296
+ recorder: TrajectoryRecorderProtocol | None = None,
297
+ on_process_started: Callable[[subprocess.Popen[str]], None] | None = None,
298
+ ) -> CLIGenerationSession:
299
+ """Create a session for a single generation request.
300
+
301
+ Can be overridden by subclasses to return specialized sessions.
302
+ """
303
+ return CLIGenerationSession(
304
+ binary_name=self.binary_name,
305
+ env=self.env,
306
+ log_prefix=self._log_prefix,
307
+ cmd=cmd,
308
+ logger=self.logger,
309
+ cwd=cwd,
310
+ timeout=timeout,
311
+ silent=silent,
312
+ recorder=recorder,
313
+ event_handler=self.event_handler,
314
+ on_process_started=on_process_started,
315
+ )
316
+
317
+ def start_session(
318
+ self,
319
+ cwd: str | None = None,
320
+ timeout: int = 300,
321
+ silent: bool = False,
322
+ ) -> "CLIAgentSession":
323
+ """Open a stateful conversation with the underlying CLI.
324
+
325
+ Returns a :class:`CLIAgentSession` whose ``generate(prompt)`` may be
326
+ called repeatedly; each call after the first automatically resumes
327
+ the prior conversation via the provider's native resume flag.
328
+
329
+ Args:
330
+ cwd: Default working directory for ``session.generate`` calls.
331
+ timeout: Default timeout in seconds.
332
+ silent: If True, suppress stdout printing of the agent's output.
333
+ """
334
+ return CLIAgentSession(self, cwd=cwd, timeout=timeout, silent=silent)
335
+
336
+ def generate(
337
+ self,
338
+ prompt: str,
339
+ cwd: str | None = None,
340
+ timeout: int = 300,
341
+ silent: bool = False,
342
+ on_process_started: Callable[[subprocess.Popen[str]], None] | None = None,
343
+ ) -> str:
344
+ """One-shot prompt → reply. Convenience wrapper around
345
+ ``start_session(...).generate(prompt)``; no conversation state is
346
+ retained across calls. Use :meth:`start_session` for multi-turn flows.
347
+ """
348
+ return self.start_session(cwd=cwd, timeout=timeout, silent=silent).generate(
349
+ prompt, on_process_started=on_process_started
350
+ )
351
+
352
+
353
+ class CLIAgentSession:
354
+ """Stateful, resumable conversation with a CLI agent.
355
+
356
+ Holds the provider session id captured from the first ``generate`` call
357
+ so subsequent calls automatically pass the right native resume flag
358
+ (``claude --resume``, ``codex exec resume``, ``gemini --resume``,
359
+ ``opencode run --session``). Callers do not see provider-specific
360
+ plumbing.
361
+
362
+ A session is single-threaded — concurrent calls into ``generate`` on the
363
+ same instance are not supported.
364
+ """
365
+
366
+ def __init__(
367
+ self,
368
+ agent: CLICodingAgent,
369
+ *,
370
+ cwd: str | None = None,
371
+ timeout: int = 300,
372
+ silent: bool = False,
373
+ ):
374
+ self.agent = agent
375
+ self._cwd = cwd
376
+ self._timeout = timeout
377
+ self._silent = silent
378
+ # Provider session id, set after the first ``generate`` call (None
379
+ # if the underlying CLI did not emit one).
380
+ self.session_id: str | None = None
381
+
382
+ def generate(
383
+ self,
384
+ prompt: str,
385
+ cwd: str | None = None,
386
+ timeout: int | None = None,
387
+ silent: bool | None = None,
388
+ on_process_started: Callable[[subprocess.Popen[str]], None] | None = None,
389
+ ) -> str:
390
+ """Send ``prompt``, returning the assistant's text reply.
391
+
392
+ Per-call ``cwd`` / ``timeout`` / ``silent`` override the defaults
393
+ captured by :meth:`CLICodingAgent.start_session`. The provider's
394
+ native resume flag is added automatically on every call after the
395
+ first.
396
+
397
+ Args:
398
+ prompt: The prompt to send.
399
+ cwd: Override the session's default working directory.
400
+ timeout: Override the session's default timeout (seconds).
401
+ silent: Override the session's default silent flag.
402
+ on_process_started: Optional callback invoked with the spawned
403
+ ``subprocess.Popen`` immediately after the CLI subprocess
404
+ starts (used by callers that need to kill it externally,
405
+ e.g. crucible's short-circuit).
406
+ """
407
+ effective_cwd = cwd if cwd is not None else self._cwd
408
+ effective_timeout = timeout if timeout is not None else self._timeout
409
+ effective_silent = silent if silent is not None else self._silent
410
+
411
+ self.agent.recorder.add_user_message(prompt)
412
+
413
+ cmd = self.agent._get_command(prompt, resume_session_id=self.session_id) # pyright: ignore[reportPrivateUsage]
414
+ run_session = self.agent._create_session( # pyright: ignore[reportPrivateUsage]
415
+ cmd,
416
+ effective_cwd,
417
+ effective_timeout,
418
+ effective_silent,
419
+ recorder=self.agent.recorder,
420
+ on_process_started=on_process_started,
421
+ )
422
+ result = run_session.run(prompt)
423
+ self.agent.last_usage = getattr(run_session, "usage", ProviderUsage())
424
+
425
+ # Capture the id on first run; refresh on later runs only if the
426
+ # underlying CLI actually emitted one (defensive — providers always
427
+ # echo the same id back when resumed).
428
+ captured = getattr(run_session, "session_id", None)
429
+ if captured:
430
+ self.session_id = captured
431
+
432
+ self.agent.recorder.add_assistant_message(result)
433
+ return result
@@ -0,0 +1,3 @@
1
+ from .agent import CodexCodingAgent, CodexGenerationSession
2
+
3
+ __all__ = ["CodexCodingAgent", "CodexGenerationSession"]
@@ -0,0 +1,236 @@
1
+ import json
2
+ import subprocess
3
+ import time
4
+ from collections.abc import Callable
5
+ from typing import Any
6
+
7
+ from agentshim.trajectory import TrajectoryRecorderProtocol
8
+
9
+ from ..base import register_provider
10
+ from ..cli_agent import CLICodingAgent, CLIGenerationSession
11
+ from ..events import AgentEventHandler
12
+ from ..mcp_config import HttpMcpServer, McpServerConfig
13
+ from ..sandbox import SandboxConfig
14
+ from ..usage import ProviderUsage, TokenUsage
15
+ from .events import (
16
+ CodexEvent,
17
+ ErrorEvent,
18
+ TextEvent,
19
+ ThreadStartedEvent,
20
+ ToolResultEvent,
21
+ ToolUseEvent,
22
+ TurnCompletedEvent,
23
+ )
24
+
25
+
26
+ class CodexGenerationSession(CLIGenerationSession):
27
+ """Session that parses Codex ``--json`` event stream."""
28
+
29
+ def __init__(self, **kwargs: Any):
30
+ super().__init__(**kwargs)
31
+ self.tool_map: dict[str, str] = {}
32
+ self.tool_start_times: dict[str, float] = {}
33
+ self.tool_args: dict[str, Any] = {}
34
+ # Codex has no single "final message" frame; track the most recent
35
+ # agent_message text so run() can return it as the final result.
36
+ self.final_result: str | None = None
37
+ # Accumulator for per-turn usage; finalized into self.usage at end.
38
+ self._accumulated_tokens = TokenUsage()
39
+
40
+ def _process_stdout(self, line: str) -> None:
41
+ if not line:
42
+ return
43
+ try:
44
+ data = json.loads(line)
45
+ except json.JSONDecodeError:
46
+ self.stdout_lines.append(line.rstrip())
47
+ if not self.silent:
48
+ if self._at_line_start:
49
+ self._log_raw(f"{self.log_prefix} ")
50
+ self._log_raw(line.rstrip() + "\n")
51
+ self._at_line_start = True
52
+ return
53
+
54
+ event = CodexEvent.from_dict(data)
55
+ if event is None:
56
+ return
57
+ self._handle_event(event)
58
+
59
+ def _handle_event(self, event: CodexEvent):
60
+ self._update_state(event)
61
+ if not self.silent:
62
+ self._render_event(event)
63
+
64
+ def _update_state(self, event: CodexEvent):
65
+ if isinstance(event, ThreadStartedEvent):
66
+ if self.session_id is None and event.thread_id:
67
+ self.session_id = event.thread_id
68
+ return
69
+
70
+ if isinstance(event, TextEvent):
71
+ if event.text:
72
+ self.stdout_lines.append(event.text)
73
+ self.final_result = event.text
74
+ if self.event_handler:
75
+ self.event_handler.on_thinking(event.text)
76
+ return
77
+
78
+ if isinstance(event, ToolUseEvent):
79
+ if event.tool_id:
80
+ self.tool_map[event.tool_id] = event.tool_name
81
+ self.tool_start_times[event.tool_id] = time.time()
82
+ self.tool_args[event.tool_id] = event.parameters
83
+ if self.event_handler:
84
+ self.event_handler.on_tool_call(event.tool_name, event.parameters)
85
+ return
86
+
87
+ if isinstance(event, TurnCompletedEvent):
88
+ self._accumulated_tokens = self._accumulated_tokens + TokenUsage(
89
+ input_tokens=event.input_tokens,
90
+ output_tokens=event.output_tokens,
91
+ cached_input_tokens=event.cached_input_tokens,
92
+ turns=1,
93
+ )
94
+ self.usage = ProviderUsage(
95
+ tokens=self._accumulated_tokens,
96
+ total_cost_usd=None,
97
+ provider="codex",
98
+ )
99
+ return
100
+
101
+ if isinstance(event, ToolResultEvent):
102
+ if not event.tool_id:
103
+ return
104
+ event.tool_name_resolved = self.tool_map.get(event.tool_id, "Tool")
105
+
106
+ start_time = self.tool_start_times.get(event.tool_id)
107
+ duration = time.time() - start_time if start_time else None
108
+ args = self.tool_args.get(event.tool_id, {})
109
+
110
+ self.recorder.add_tool_call(
111
+ tool=event.tool_name_resolved,
112
+ args=args,
113
+ stdout=event.output,
114
+ exit_code=event.exit_code,
115
+ duration=duration,
116
+ )
117
+ if self.event_handler:
118
+ self.event_handler.on_tool_result(
119
+ tool=event.tool_name_resolved,
120
+ stdout=event.output,
121
+ exit_code=event.exit_code,
122
+ duration=duration,
123
+ )
124
+ return
125
+
126
+ def _render_event(self, event: CodexEvent):
127
+ if isinstance(event, TextEvent):
128
+ if event.text:
129
+ self._print_stream_content(event.text)
130
+ return
131
+
132
+ if not self._at_line_start:
133
+ self._log_raw("\n")
134
+ self._at_line_start = True
135
+
136
+ output = event.render(self.log_prefix)
137
+ if output:
138
+ self._log_raw(output + "\n")
139
+
140
+ if isinstance(event, ErrorEvent):
141
+ self.stdout_lines.append(event.message)
142
+
143
+ def run(self, prompt: str) -> str:
144
+ super().run(prompt)
145
+ if self.final_result:
146
+ return self.final_result
147
+ return "\n".join(self.stdout_lines)
148
+
149
+
150
+ @register_provider("openai", "codex")
151
+ class CodexCodingAgent(CLICodingAgent):
152
+ """Coding agent implementation using the Codex CLI tool."""
153
+
154
+ def __init__(
155
+ self,
156
+ model: str | None = None,
157
+ recorder: TrajectoryRecorderProtocol | None = None,
158
+ event_handler: AgentEventHandler | None = None,
159
+ mcp_servers: list[McpServerConfig] | None = None,
160
+ sandbox: bool | SandboxConfig = False,
161
+ ):
162
+ """Initialize the Codex coding agent.
163
+
164
+ Args:
165
+ model: Optional model name to use with codex. If None, uses default.
166
+ recorder: Trajectory recorder instance.
167
+ event_handler: Optional event handler for UI updates.
168
+ mcp_servers: Optional list of MCP server configurations.
169
+ sandbox: Not supported for Codex; must be False.
170
+ """
171
+ if sandbox:
172
+ raise NotImplementedError("sandbox is not supported for CodexCodingAgent")
173
+ super().__init__("codex", model, recorder, event_handler, mcp_servers)
174
+
175
+ @property
176
+ def codex_path(self) -> str:
177
+ """Return path to codex binary (for backward compatibility)."""
178
+ return self.binary_path
179
+
180
+ @property
181
+ def _log_prefix(self) -> str:
182
+ """Return the log prefix for this agent."""
183
+ return "[Codex]"
184
+
185
+ def _build_mcp_args(self) -> list[str]:
186
+ """Build -c flag arguments for MCP server configuration."""
187
+ args: list[str] = []
188
+ for server in self.mcp_servers:
189
+ prefix = f"mcp_servers.{server.name}"
190
+ if isinstance(server, HttpMcpServer):
191
+ args.extend(["-c", f'{prefix}.url="{server.url}"'])
192
+ else:
193
+ args.extend(["-c", f'{prefix}.command="{server.command}"'])
194
+ if server.args:
195
+ toml_arr = "[" + ", ".join(f'"{arg}"' for arg in server.args) + "]"
196
+ args.extend(["-c", f"{prefix}.args={toml_arr}"])
197
+ for key, value in server.env.items():
198
+ args.extend(["-c", f'{prefix}.env.{key}="{value}"'])
199
+ return args
200
+
201
+ def _get_command(self, prompt: str, resume_session_id: str | None = None) -> list[str]:
202
+ cmd: list[str] = [self.binary_path, "exec"]
203
+ if resume_session_id:
204
+ cmd.extend(["resume", resume_session_id])
205
+ cmd.extend(["--dangerously-bypass-approvals-and-sandbox", "--json"])
206
+ if self.model:
207
+ cmd.extend(["--model", self.model])
208
+ if self.mcp_servers:
209
+ cmd.extend(self._build_mcp_args())
210
+ # Tell Codex to read the prompt from stdin instead of expecting an
211
+ # inline positional prompt argument.
212
+ cmd.append("-")
213
+ return cmd
214
+
215
+ def _create_session(
216
+ self,
217
+ cmd: list[str],
218
+ cwd: str | None = None,
219
+ timeout: int = 300,
220
+ silent: bool = False,
221
+ recorder: TrajectoryRecorderProtocol | None = None,
222
+ on_process_started: Callable[[subprocess.Popen[str]], None] | None = None,
223
+ ) -> CodexGenerationSession:
224
+ return CodexGenerationSession(
225
+ binary_name=self.binary_name,
226
+ env=self.env,
227
+ log_prefix=self._log_prefix,
228
+ cmd=cmd,
229
+ logger=self.logger,
230
+ cwd=cwd,
231
+ timeout=timeout,
232
+ silent=silent,
233
+ recorder=recorder,
234
+ event_handler=self.event_handler,
235
+ on_process_started=on_process_started,
236
+ )