codex-python-sdk 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,1313 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import json
6
+ import os
7
+ from typing import TYPE_CHECKING, Any, AsyncIterator, Awaitable, Callable
8
+
9
+ from ._shared import (
10
+ diff_change_counts,
11
+ first_nonempty_text,
12
+ token_usage_summary,
13
+ turn_plan_summary,
14
+ )
15
+ from .errors import (
16
+ AppServerConnectionError,
17
+ CodexAgenticError,
18
+ NotAuthenticatedError,
19
+ SessionNotFoundError,
20
+ )
21
+ from .types import AgentResponse, ResponseEvent
22
+
23
+ if TYPE_CHECKING:
24
+ from .policy import PolicyContext, PolicyEngine, PolicyJudgeConfig, PolicyRubric
25
+
26
+ DEFAULT_CLI_COMMAND = "codex"
27
+ DEFAULT_APP_SERVER_ARGS = ["app-server"]
28
+ DEFAULT_NOTIFICATION_BUFFER_LIMIT = 1024
29
+ DEFAULT_STDERR_BUFFER_LIMIT = 500
30
+ DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS = 60.0
31
+
32
+
33
+ class AsyncCodexAgenticClient:
34
+ """Async wrapper around `codex app-server` using JSON-RPC over stdio."""
35
+
36
+ def __init__(
37
+ self,
38
+ *,
39
+ codex_command: str = DEFAULT_CLI_COMMAND,
40
+ app_server_args: list[str] | None = None,
41
+ env: dict[str, str] | None = None,
42
+ process_cwd: str | None = None,
43
+ default_thread_params: dict[str, Any] | None = None,
44
+ default_turn_params: dict[str, Any] | None = None,
45
+ enable_web_search: bool = True,
46
+ server_config_overrides: dict[str, Any] | None = None,
47
+ stream_idle_timeout_seconds: float | None = DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS,
48
+ on_command_approval: Callable[[dict[str, Any]], dict[str, Any] | Awaitable[dict[str, Any]]] | None = None,
49
+ on_file_change_approval: Callable[[dict[str, Any]], dict[str, Any] | Awaitable[dict[str, Any]]] | None = None,
50
+ on_tool_request_user_input: Callable[[dict[str, Any]], dict[str, Any] | Awaitable[dict[str, Any]]] | None = None,
51
+ on_tool_call: Callable[[dict[str, Any]], dict[str, Any] | Awaitable[dict[str, Any]]] | None = None,
52
+ policy_engine: "PolicyEngine | None" = None,
53
+ policy_rubric: "PolicyRubric | dict[str, Any] | None" = None,
54
+ policy_judge_config: "PolicyJudgeConfig | None" = None,
55
+ ) -> None:
56
+ """Create an async app-server client.
57
+
58
+ Args:
59
+ codex_command: Executable name/path, usually ``"codex"``.
60
+ app_server_args: CLI args passed after ``codex``; defaults to ``["app-server"]``.
61
+ env: Environment for the child process.
62
+ process_cwd: Working directory used when launching app-server.
63
+ default_thread_params: Baseline params for thread-level requests.
64
+ default_turn_params: Baseline params for turn-level requests.
65
+ enable_web_search: If true, appends ``--enable web_search`` at launch.
66
+ server_config_overrides: Config key-values serialized to ``-c key=value``.
67
+ stream_idle_timeout_seconds: Max consecutive seconds without matching turn events
68
+ before stream wait fails. Set ``None`` to disable this guard.
69
+ on_command_approval: Handler for ``item/commandExecution/requestApproval``.
70
+ on_file_change_approval: Handler for ``item/fileChange/requestApproval``.
71
+ on_tool_request_user_input: Handler for ``item/tool/requestUserInput``.
72
+ on_tool_call: Handler for ``item/tool/call``.
73
+ policy_engine: Optional policy engine used when explicit hooks are absent.
74
+ policy_rubric: Optional rubric used to auto-build a policy engine when
75
+ ``policy_engine`` is not provided.
76
+ policy_judge_config: Optional LLM-judge settings when rubric builds an LLM policy.
77
+ """
78
+
79
+ self.codex_command = codex_command
80
+ self.app_server_args = app_server_args[:] if app_server_args else DEFAULT_APP_SERVER_ARGS[:]
81
+ self.env = os.environ.copy() if env is None else env.copy()
82
+
83
+ self.process_cwd = os.path.abspath(process_cwd or os.getcwd())
84
+ self.default_thread_params = dict(default_thread_params or {})
85
+ self.default_turn_params = dict(default_turn_params or {})
86
+ self.enable_web_search = enable_web_search
87
+ self.server_config_overrides = dict(server_config_overrides or {})
88
+ self.stream_idle_timeout_seconds = stream_idle_timeout_seconds
89
+ self.on_command_approval = on_command_approval
90
+ self.on_file_change_approval = on_file_change_approval
91
+ self.on_tool_request_user_input = on_tool_request_user_input
92
+ self.on_tool_call = on_tool_call
93
+ self.policy_engine = policy_engine
94
+ if self.policy_engine is None and policy_rubric is not None:
95
+ from .policy import build_policy_engine_from_rubric
96
+
97
+ self.policy_engine = build_policy_engine_from_rubric(
98
+ policy_rubric,
99
+ judge_config=policy_judge_config,
100
+ codex_command=codex_command,
101
+ app_server_args=app_server_args,
102
+ env=env,
103
+ process_cwd=self.process_cwd,
104
+ server_config_overrides=server_config_overrides,
105
+ )
106
+
107
+ self._proc: asyncio.subprocess.Process | None = None
108
+ self._reader_task: asyncio.Task[None] | None = None
109
+ self._stderr_task: asyncio.Task[None] | None = None
110
+ self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
111
+ self._next_id = 1
112
+ self._connected = False
113
+ self._runtime_loop: asyncio.AbstractEventLoop | None = None
114
+ self._connect_lock_ref: asyncio.Lock | None = None
115
+ self._write_lock_ref: asyncio.Lock | None = None
116
+ self._notification_queue_ref: asyncio.Queue[dict[str, Any]] | None = None
117
+ self._notification_buffer: list[dict[str, Any]] = []
118
+ self._notification_buffer_limit = DEFAULT_NOTIFICATION_BUFFER_LIMIT
119
+ self._notification_condition_ref: asyncio.Condition | None = None
120
+ self._notification_router_task: asyncio.Task[None] | None = None
121
+ self._stderr_buffer_limit = DEFAULT_STDERR_BUFFER_LIMIT
122
+ self._stderr_lines: list[str] = []
123
+ self._user_agent: str | None = None
124
+
125
+ async def __aenter__(self) -> "AsyncCodexAgenticClient":
126
+ await self.connect()
127
+ return self
128
+
129
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
130
+ await self.close()
131
+
132
+ def _ensure_runtime_primitives(self) -> None:
133
+ loop = asyncio.get_running_loop()
134
+ if self._runtime_loop is None:
135
+ self._runtime_loop = loop
136
+ elif self._runtime_loop is not loop:
137
+ raise CodexAgenticError("Async client cannot be shared across different event loops.")
138
+
139
+ if self._connect_lock_ref is None:
140
+ self._connect_lock_ref = asyncio.Lock()
141
+ if self._write_lock_ref is None:
142
+ self._write_lock_ref = asyncio.Lock()
143
+ if self._notification_queue_ref is None:
144
+ self._notification_queue_ref = asyncio.Queue()
145
+ if self._notification_condition_ref is None:
146
+ self._notification_condition_ref = asyncio.Condition()
147
+
148
+ @property
149
+ def _connect_lock(self) -> asyncio.Lock:
150
+ if self._connect_lock_ref is None:
151
+ self._ensure_runtime_primitives()
152
+ assert self._connect_lock_ref is not None
153
+ return self._connect_lock_ref
154
+
155
+ @property
156
+ def _write_lock(self) -> asyncio.Lock:
157
+ if self._write_lock_ref is None:
158
+ self._ensure_runtime_primitives()
159
+ assert self._write_lock_ref is not None
160
+ return self._write_lock_ref
161
+
162
+ @property
163
+ def _notification_queue(self) -> asyncio.Queue[dict[str, Any]]:
164
+ if self._notification_queue_ref is None:
165
+ self._ensure_runtime_primitives()
166
+ assert self._notification_queue_ref is not None
167
+ return self._notification_queue_ref
168
+
169
+ @property
170
+ def _notification_condition(self) -> asyncio.Condition:
171
+ if self._notification_condition_ref is None:
172
+ self._ensure_runtime_primitives()
173
+ assert self._notification_condition_ref is not None
174
+ return self._notification_condition_ref
175
+
176
+ async def connect(self) -> None:
177
+ if self._connected:
178
+ return
179
+ async with self._connect_lock:
180
+ if self._connected:
181
+ return
182
+ try:
183
+ self._proc = await asyncio.create_subprocess_exec(
184
+ self.codex_command,
185
+ *self._build_server_args(),
186
+ cwd=self.process_cwd,
187
+ env=self.env,
188
+ stdin=asyncio.subprocess.PIPE,
189
+ stdout=asyncio.subprocess.PIPE,
190
+ stderr=asyncio.subprocess.PIPE,
191
+ )
192
+ except Exception as exc:
193
+ self._raise_connection_error(exc)
194
+
195
+ assert self._proc is not None
196
+ self._reader_task = asyncio.create_task(self._reader_loop())
197
+ self._stderr_task = asyncio.create_task(self._stderr_loop())
198
+ self._ensure_notification_router()
199
+
200
+ try:
201
+ init_result = await self._request(
202
+ "initialize",
203
+ {
204
+ "clientInfo": {"name": "codex-python-sdk", "version": "0.1"},
205
+ "capabilities": {"experimentalApi": True},
206
+ },
207
+ ensure_connected=False,
208
+ )
209
+ await self._notify("initialized", None)
210
+ self._user_agent = first_nonempty_text(init_result)
211
+ self._connected = True
212
+ except Exception:
213
+ await self.close()
214
+ raise
215
+
216
+ async def close(self) -> None:
217
+ self._connected = False
218
+ await self._close_policy_engine()
219
+
220
+ if self._proc is not None:
221
+ proc = self._proc
222
+ self._proc = None
223
+ try:
224
+ if proc.stdin is not None and not proc.stdin.is_closing():
225
+ proc.stdin.close()
226
+ except Exception:
227
+ pass
228
+ try:
229
+ await asyncio.wait_for(proc.wait(), timeout=1.0)
230
+ except Exception:
231
+ proc.terminate()
232
+ try:
233
+ await asyncio.wait_for(proc.wait(), timeout=1.0)
234
+ except Exception:
235
+ proc.kill()
236
+ try:
237
+ await asyncio.wait_for(proc.wait(), timeout=1.0)
238
+ except Exception:
239
+ pass
240
+
241
+ for fut in self._pending.values():
242
+ if not fut.done():
243
+ fut.set_exception(AppServerConnectionError("App-server transport closed."))
244
+ self._pending.clear()
245
+
246
+ for task in (self._reader_task, self._stderr_task):
247
+ if task is not None:
248
+ task.cancel()
249
+ self._reader_task = None
250
+ self._stderr_task = None
251
+ if self._notification_router_task is not None:
252
+ self._notification_router_task.cancel()
253
+ self._notification_router_task = None
254
+ while True:
255
+ try:
256
+ self._notification_queue.get_nowait()
257
+ except asyncio.QueueEmpty:
258
+ break
259
+ async with self._notification_condition:
260
+ self._notification_buffer.clear()
261
+ self._notification_condition.notify_all()
262
+ self._stderr_lines.clear()
263
+
264
+ async def _close_policy_engine(self) -> None:
265
+ engine = self.policy_engine
266
+ if engine is None:
267
+ return
268
+
269
+ for close_name in ("aclose", "close"):
270
+ closer = getattr(engine, close_name, None)
271
+ if not callable(closer):
272
+ continue
273
+ maybe_result = closer()
274
+ if inspect.isawaitable(maybe_result):
275
+ await maybe_result
276
+ return
277
+
278
+ def _build_server_args(self) -> list[str]:
279
+ args = self.app_server_args[:]
280
+ if self.enable_web_search:
281
+ args.extend(["--enable", "web_search"])
282
+ for key, value in self.server_config_overrides.items():
283
+ args.extend(["-c", f"{key}={self._to_toml_literal(value)}"])
284
+ return args
285
+
286
+ @staticmethod
287
+ def _to_toml_literal(value: Any) -> str:
288
+ if isinstance(value, bool):
289
+ return "true" if value else "false"
290
+ if isinstance(value, (int, float)):
291
+ return str(value)
292
+ if isinstance(value, list):
293
+ return "[" + ", ".join(AsyncCodexAgenticClient._to_toml_literal(v) for v in value) + "]"
294
+ if isinstance(value, dict):
295
+ fields: list[str] = []
296
+ for key, nested in value.items():
297
+ quoted_key = json.dumps(str(key))
298
+ fields.append(f"{quoted_key} = {AsyncCodexAgenticClient._to_toml_literal(nested)}")
299
+ return "{" + ", ".join(fields) + "}"
300
+ return json.dumps(str(value))
301
+
302
+ async def _reader_loop(self) -> None:
303
+ assert self._proc is not None
304
+ assert self._proc.stdout is not None
305
+ try:
306
+ while True:
307
+ line = await self._proc.stdout.readline()
308
+ if not line:
309
+ break
310
+ text = line.decode("utf-8", errors="replace").strip()
311
+ if not text:
312
+ continue
313
+ try:
314
+ msg = json.loads(text)
315
+ except json.JSONDecodeError:
316
+ continue
317
+
318
+ if isinstance(msg, dict) and "id" in msg and "method" not in msg:
319
+ msg_id = msg.get("id")
320
+ if isinstance(msg_id, int) and msg_id in self._pending:
321
+ fut = self._pending.pop(msg_id)
322
+ if not fut.done():
323
+ fut.set_result(msg)
324
+ continue
325
+
326
+ if isinstance(msg, dict) and "method" in msg and "id" in msg:
327
+ asyncio.create_task(self._dispatch_server_request(msg))
328
+ continue
329
+
330
+ if isinstance(msg, dict) and "method" in msg:
331
+ await self._notification_queue.put(msg)
332
+ finally:
333
+ self._connected = False
334
+ for fut in self._pending.values():
335
+ if not fut.done():
336
+ fut.set_exception(AppServerConnectionError("App-server stream ended unexpectedly."))
337
+ self._pending.clear()
338
+ async with self._notification_condition:
339
+ self._notification_condition.notify_all()
340
+
341
+ async def _stderr_loop(self) -> None:
342
+ assert self._proc is not None
343
+ assert self._proc.stderr is not None
344
+ while True:
345
+ line = await self._proc.stderr.readline()
346
+ if not line:
347
+ break
348
+ text = line.decode("utf-8", errors="replace").rstrip()
349
+ if text:
350
+ self._stderr_lines.append(text)
351
+ overflow = len(self._stderr_lines) - self._stderr_buffer_limit
352
+ if overflow > 0:
353
+ del self._stderr_lines[:overflow]
354
+
355
+ def _ensure_notification_router(self) -> None:
356
+ task = self._notification_router_task
357
+ if task is not None and not task.done():
358
+ return
359
+ self._notification_router_task = asyncio.create_task(self._notification_router_loop())
360
+
361
+ async def _notification_router_loop(self) -> None:
362
+ try:
363
+ while True:
364
+ msg = await self._notification_queue.get()
365
+ async with self._notification_condition:
366
+ self._notification_buffer.append(msg)
367
+ overflow = len(self._notification_buffer) - self._notification_buffer_limit
368
+ if overflow > 0:
369
+ del self._notification_buffer[:overflow]
370
+ self._notification_condition.notify_all()
371
+ except asyncio.CancelledError:
372
+ return
373
+
374
+ async def _dispatch_server_request(self, msg: dict[str, Any]) -> None:
375
+ try:
376
+ await self._handle_server_request(msg)
377
+ except asyncio.CancelledError:
378
+ raise
379
+ except CodexAgenticError as exc:
380
+ req_id = msg.get("id")
381
+ if not self._is_jsonrpc_request_id(req_id):
382
+ return
383
+ await self._send_server_request_error(req_id=req_id, code=-32000, message=str(exc))
384
+ except Exception:
385
+ req_id = msg.get("id")
386
+ if not self._is_jsonrpc_request_id(req_id):
387
+ return
388
+ await self._send_server_request_error(req_id=req_id, code=-32603, message="Server request handler failed.")
389
+
390
+ async def _send_server_request_error(self, *, req_id: str | int, code: int, message: str) -> None:
391
+ try:
392
+ await self._send_json(
393
+ {
394
+ "jsonrpc": "2.0",
395
+ "id": req_id,
396
+ "error": {"code": code, "message": message},
397
+ }
398
+ )
399
+ except Exception:
400
+ return
401
+
402
+ @staticmethod
403
+ def _notification_matches_stream(notification: dict[str, Any], thread_id: str, turn_id: str | None) -> bool:
404
+ note_thread_id = AsyncCodexAgenticClient._extract_thread_id_from_payload(notification)
405
+ if note_thread_id and note_thread_id != thread_id:
406
+ return False
407
+
408
+ note_turn_id = AsyncCodexAgenticClient._extract_turn_id(notification)
409
+ if turn_id and note_turn_id and note_turn_id != turn_id:
410
+ return False
411
+ return True
412
+
413
+ @staticmethod
414
+ def _is_jsonrpc_request_id(value: Any) -> bool:
415
+ if isinstance(value, bool):
416
+ return False
417
+ return isinstance(value, (str, int))
418
+
419
+ async def _wait_for_matching_notification(self, thread_id: str, turn_id: str | None) -> dict[str, Any]:
420
+ self._ensure_notification_router()
421
+ timeout = 5.0
422
+ deadline = asyncio.get_running_loop().time() + timeout
423
+
424
+ while True:
425
+ async with self._notification_condition:
426
+ for index, notification in enumerate(self._notification_buffer):
427
+ if self._notification_matches_stream(notification, thread_id, turn_id):
428
+ return self._notification_buffer.pop(index)
429
+
430
+ remaining = deadline - asyncio.get_running_loop().time()
431
+ if remaining <= 0:
432
+ raise asyncio.TimeoutError
433
+ await asyncio.wait_for(self._notification_condition.wait(), timeout=remaining)
434
+
435
+ async def _handle_server_request(self, msg: dict[str, Any]) -> None:
436
+ method = str(msg.get("method", ""))
437
+ req_id = msg.get("id")
438
+ if not self._is_jsonrpc_request_id(req_id):
439
+ return
440
+ params = msg.get("params")
441
+ if not isinstance(params, dict):
442
+ params = {}
443
+ handlers: dict[str, Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]]] = {
444
+ "item/commandExecution/requestApproval": self._handle_command_approval_request,
445
+ "item/fileChange/requestApproval": self._handle_file_change_request,
446
+ "item/tool/requestUserInput": self._handle_tool_user_input_request,
447
+ "item/tool/call": self._handle_tool_call_request,
448
+ }
449
+ handler = handlers.get(method)
450
+ if handler is None:
451
+ await self._send_json(
452
+ {
453
+ "jsonrpc": "2.0",
454
+ "id": req_id,
455
+ "error": {"code": -32601, "message": f"Unsupported server request method: {method}"},
456
+ }
457
+ )
458
+ return
459
+
460
+ result = await handler(method, params)
461
+ await self._send_json({"jsonrpc": "2.0", "id": req_id, "result": result})
462
+
463
+ async def _handle_command_approval_request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
464
+ del method
465
+ handler = self.on_command_approval
466
+ if handler is None and self.policy_engine is not None:
467
+ context = self._build_policy_context("command", "item/commandExecution/requestApproval", params)
468
+
469
+ def _policy_handler(payload: dict[str, Any]) -> dict[str, Any] | Awaitable[dict[str, Any]]:
470
+ assert self.policy_engine is not None
471
+ return self.policy_engine.on_command_approval(payload, context)
472
+
473
+ handler = _policy_handler
474
+ return await self._resolve_server_request(
475
+ "item/commandExecution/requestApproval",
476
+ params,
477
+ handler,
478
+ {"decision": "accept"},
479
+ )
480
+
481
+ async def _handle_file_change_request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
482
+ del method
483
+ handler = self.on_file_change_approval
484
+ if handler is None and self.policy_engine is not None:
485
+ context = self._build_policy_context("file_change", "item/fileChange/requestApproval", params)
486
+
487
+ def _policy_handler(payload: dict[str, Any]) -> dict[str, Any] | Awaitable[dict[str, Any]]:
488
+ assert self.policy_engine is not None
489
+ return self.policy_engine.on_file_change_approval(payload, context)
490
+
491
+ handler = _policy_handler
492
+ return await self._resolve_server_request(
493
+ "item/fileChange/requestApproval",
494
+ params,
495
+ handler,
496
+ {"decision": "accept"},
497
+ )
498
+
499
+ async def _handle_tool_user_input_request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
500
+ del method
501
+ handler = self.on_tool_request_user_input
502
+ if handler is None and self.policy_engine is not None:
503
+ context = self._build_policy_context("tool_user_input", "item/tool/requestUserInput", params)
504
+
505
+ def _policy_handler(payload: dict[str, Any]) -> dict[str, Any] | Awaitable[dict[str, Any]]:
506
+ assert self.policy_engine is not None
507
+ return self.policy_engine.on_tool_request_user_input(payload, context)
508
+
509
+ handler = _policy_handler
510
+ return await self._resolve_server_request(
511
+ "item/tool/requestUserInput",
512
+ params,
513
+ handler,
514
+ {"answers": {}},
515
+ )
516
+
517
+ async def _handle_tool_call_request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
518
+ del method
519
+ return await self._resolve_server_request(
520
+ "item/tool/call",
521
+ params,
522
+ self.on_tool_call,
523
+ {
524
+ "success": False,
525
+ "contentItems": [
526
+ {
527
+ "type": "inputText",
528
+ "text": "No tool-call handler configured.",
529
+ }
530
+ ],
531
+ },
532
+ )
533
+
534
+ async def _resolve_server_request(
535
+ self,
536
+ method: str,
537
+ params: dict[str, Any],
538
+ handler: Callable[[dict[str, Any]], dict[str, Any] | Awaitable[dict[str, Any]]] | None,
539
+ default_result: dict[str, Any],
540
+ ) -> dict[str, Any]:
541
+ if handler is None:
542
+ return default_result
543
+ try:
544
+ maybe_result = handler(params)
545
+ if inspect.isawaitable(maybe_result):
546
+ maybe_result = await maybe_result
547
+ except Exception as exc:
548
+ raise CodexAgenticError(f"Handler failed for server request '{method}': {exc}") from exc
549
+ if not isinstance(maybe_result, dict):
550
+ raise CodexAgenticError(
551
+ f"Handler for server request '{method}' must return a dict, got {type(maybe_result).__name__}."
552
+ )
553
+ return maybe_result
554
+
555
+ @staticmethod
556
+ def _build_policy_context(request_type: str, method: str, params: dict[str, Any]) -> "PolicyContext":
557
+ from .policy import PolicyContext
558
+
559
+ thread_id = params.get("threadId")
560
+ turn_id = params.get("turnId")
561
+ item_id = params.get("itemId")
562
+ return PolicyContext(
563
+ request_type=request_type, # type: ignore[arg-type]
564
+ method=method,
565
+ thread_id=thread_id if isinstance(thread_id, str) else None,
566
+ turn_id=turn_id if isinstance(turn_id, str) else None,
567
+ item_id=item_id if isinstance(item_id, str) else None,
568
+ params=dict(params),
569
+ )
570
+
571
+ async def _send_json(self, payload: dict[str, Any]) -> None:
572
+ if self._proc is None or self._proc.stdin is None:
573
+ raise AppServerConnectionError("App-server process is not running.")
574
+ data = (json.dumps(payload, separators=(",", ":")) + "\n").encode("utf-8")
575
+ async with self._write_lock:
576
+ self._proc.stdin.write(data)
577
+ await self._proc.stdin.drain()
578
+
579
+ async def _notify(self, method: str, params: dict[str, Any] | None) -> None:
580
+ payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
581
+ if params is not None:
582
+ payload["params"] = params
583
+ await self._send_json(payload)
584
+
585
+ async def _request(
586
+ self,
587
+ method: str,
588
+ params: dict[str, Any] | None,
589
+ *,
590
+ ensure_connected: bool = True,
591
+ timeout_seconds: float | None = None,
592
+ ) -> dict[str, Any]:
593
+ if ensure_connected:
594
+ await self.connect()
595
+ elif self._proc is None or self._proc.stdin is None:
596
+ raise AppServerConnectionError("App-server process is not running.")
597
+ req_id = self._next_id
598
+ self._next_id += 1
599
+
600
+ fut: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
601
+ self._pending[req_id] = fut
602
+
603
+ payload: dict[str, Any] = {"jsonrpc": "2.0", "id": req_id, "method": method}
604
+ if params is not None:
605
+ payload["params"] = params
606
+
607
+ try:
608
+ await self._send_json(payload)
609
+ if timeout_seconds is None:
610
+ raw_resp = await fut
611
+ else:
612
+ raw_resp = await asyncio.wait_for(fut, timeout=timeout_seconds)
613
+ except Exception:
614
+ self._pending.pop(req_id, None)
615
+ raise
616
+
617
+ if "error" in raw_resp:
618
+ self._raise_rpc_error(raw_resp.get("error"))
619
+ result = raw_resp.get("result")
620
+ if isinstance(result, dict):
621
+ return result
622
+ return {"value": result}
623
+
624
+ @staticmethod
625
+ def _raise_rpc_error(error: Any) -> None:
626
+ msg = first_nonempty_text(error) or "App-server returned an RPC error."
627
+ lower = msg.lower()
628
+ if AsyncCodexAgenticClient._is_auth_error_text(msg):
629
+ raise NotAuthenticatedError(AsyncCodexAgenticClient._not_authenticated_message(msg))
630
+ if "not found" in lower and ("thread" in lower or "session" in lower):
631
+ raise SessionNotFoundError(msg)
632
+ raise CodexAgenticError(msg)
633
+
634
+ @staticmethod
635
+ def _is_no_rollout_found_error(exc: Exception) -> bool:
636
+ lower = str(exc).lower()
637
+ return "no rollout found" in lower or "rollout not found" in lower
638
+
639
+ @staticmethod
640
+ def _is_auth_error_text(text: str) -> bool:
641
+ lower = text.lower()
642
+ return any(token in lower for token in ("login", "auth", "credential", "unauthorized"))
643
+
644
+ @staticmethod
645
+ def _not_authenticated_message(detail: str) -> str:
646
+ base = detail.strip() or "Codex authentication is unavailable or invalid."
647
+ guidance = "Run 'codex login' in your terminal and retry."
648
+ return f"{base} {guidance}"
649
+
650
+ @staticmethod
651
+ def _phase_for_method(method: str) -> str:
652
+ if method == "turn/completed":
653
+ return "completed"
654
+ if method == "error":
655
+ return "error"
656
+ if method.startswith("item/agentMessage"):
657
+ return "assistant"
658
+ if method == "item/reasoning/summaryPartAdded":
659
+ return "planning"
660
+ if "reasoning" in method or method.startswith("turn/plan"):
661
+ return "planning"
662
+ if "/commandExecution/" in method or "/mcpToolCall/" in method or "/fileChange/" in method:
663
+ return "tool"
664
+ return "system"
665
+
666
+ @staticmethod
667
+ def _extract_thread_id_from_payload(payload: dict[str, Any]) -> str | None:
668
+ params = payload.get("params") if isinstance(payload.get("params"), dict) else {}
669
+ for key in ("threadId", "conversationId", "sessionId"):
670
+ value = params.get(key)
671
+ if isinstance(value, str) and value:
672
+ return value
673
+ thread = params.get("thread")
674
+ if isinstance(thread, dict):
675
+ value = thread.get("id")
676
+ if isinstance(value, str) and value:
677
+ return value
678
+ return None
679
+
680
+ @staticmethod
681
+ def _extract_turn_id(payload: dict[str, Any]) -> str | None:
682
+ params = payload.get("params") if isinstance(payload.get("params"), dict) else {}
683
+ value = params.get("turnId")
684
+ if isinstance(value, str) and value:
685
+ return value
686
+ turn = params.get("turn")
687
+ if isinstance(turn, dict):
688
+ turn_id = turn.get("id")
689
+ if isinstance(turn_id, str) and turn_id:
690
+ return turn_id
691
+ return None
692
+
693
+ @staticmethod
694
+ def _notification_to_event(payload: dict[str, Any]) -> ResponseEvent:
695
+ method = str(payload.get("method", "unknown"))
696
+ params = payload.get("params") if isinstance(payload.get("params"), dict) else {}
697
+ parsed = AsyncCodexAgenticClient._parse_notification_fields(method, params)
698
+
699
+ req = params.get("requestId")
700
+
701
+ return ResponseEvent(
702
+ type=method,
703
+ phase=AsyncCodexAgenticClient._phase_for_method(method),
704
+ text_delta=parsed.get("text_delta"),
705
+ message_text=parsed.get("message_text"),
706
+ request_id=req if isinstance(req, str) else None,
707
+ session_id=AsyncCodexAgenticClient._extract_thread_id_from_payload(payload),
708
+ turn_id=AsyncCodexAgenticClient._extract_turn_id(payload),
709
+ item_id=parsed.get("item_id"),
710
+ summary_index=parsed.get("summary_index"),
711
+ thread_name=parsed.get("thread_name"),
712
+ token_usage=parsed.get("token_usage"),
713
+ plan=parsed.get("plan"),
714
+ diff=parsed.get("diff"),
715
+ raw=payload,
716
+ )
717
+
718
+ @staticmethod
719
+ def _parse_notification_fields(method: str, params: dict[str, Any]) -> dict[str, Any]:
720
+ parser_map: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
721
+ "item/agentMessage/delta": AsyncCodexAgenticClient._parse_delta_notification,
722
+ "item/reasoning/summaryTextDelta": AsyncCodexAgenticClient._parse_delta_notification,
723
+ "item/reasoning/textDelta": AsyncCodexAgenticClient._parse_delta_notification,
724
+ "item/commandExecution/outputDelta": AsyncCodexAgenticClient._parse_delta_notification,
725
+ "item/fileChange/outputDelta": AsyncCodexAgenticClient._parse_delta_notification,
726
+ "item/plan/delta": AsyncCodexAgenticClient._parse_delta_notification,
727
+ "item/mcpToolCall/progress": AsyncCodexAgenticClient._parse_mcp_progress_notification,
728
+ "thread/name/updated": AsyncCodexAgenticClient._parse_thread_name_notification,
729
+ "thread/tokenUsage/updated": AsyncCodexAgenticClient._parse_token_usage_notification,
730
+ "turn/diff/updated": AsyncCodexAgenticClient._parse_diff_notification,
731
+ "turn/plan/updated": AsyncCodexAgenticClient._parse_plan_notification,
732
+ "item/reasoning/summaryPartAdded": AsyncCodexAgenticClient._parse_summary_part_notification,
733
+ "thread/compacted": AsyncCodexAgenticClient._parse_thread_compacted_notification,
734
+ "item/completed": AsyncCodexAgenticClient._parse_item_completed_notification,
735
+ "turn/completed": AsyncCodexAgenticClient._parse_turn_completed_notification,
736
+ "deprecationNotice": AsyncCodexAgenticClient._parse_text_message_notification,
737
+ "configWarning": AsyncCodexAgenticClient._parse_text_message_notification,
738
+ "windows/worldWritableWarning": AsyncCodexAgenticClient._parse_text_message_notification,
739
+ "authStatusChange": AsyncCodexAgenticClient._parse_text_message_notification,
740
+ "account/updated": AsyncCodexAgenticClient._parse_text_message_notification,
741
+ "account/rateLimits/updated": AsyncCodexAgenticClient._parse_text_message_notification,
742
+ }
743
+ parser = parser_map.get(method)
744
+ if parser is not None:
745
+ return parser(params)
746
+ if method.startswith("codex/event/"):
747
+ return AsyncCodexAgenticClient._parse_text_message_notification(params)
748
+ return {}
749
+
750
+ @staticmethod
751
+ def _parse_delta_notification(params: dict[str, Any]) -> dict[str, Any]:
752
+ delta = params.get("delta")
753
+ if isinstance(delta, str):
754
+ return {"text_delta": delta, "message_text": delta}
755
+ return {}
756
+
757
+ @staticmethod
758
+ def _parse_mcp_progress_notification(params: dict[str, Any]) -> dict[str, Any]:
759
+ message = params.get("message")
760
+ if isinstance(message, str):
761
+ return {"message_text": message}
762
+ return {}
763
+
764
+ @staticmethod
765
+ def _parse_thread_name_notification(params: dict[str, Any]) -> dict[str, Any]:
766
+ name = params.get("threadName")
767
+ if isinstance(name, str):
768
+ return {"thread_name": name, "message_text": f"thread name: {name}"}
769
+ if name is None:
770
+ return {"message_text": "thread name cleared"}
771
+ return {}
772
+
773
+ @staticmethod
774
+ def _parse_token_usage_notification(params: dict[str, Any]) -> dict[str, Any]:
775
+ usage = params.get("tokenUsage")
776
+ if not isinstance(usage, dict):
777
+ return {}
778
+ return {
779
+ "token_usage": usage,
780
+ "message_text": token_usage_summary(usage) or "token usage updated",
781
+ }
782
+
783
+ @staticmethod
784
+ def _parse_diff_notification(params: dict[str, Any]) -> dict[str, Any]:
785
+ current_diff = params.get("diff")
786
+ if not isinstance(current_diff, str):
787
+ return {}
788
+ added, removed = diff_change_counts(current_diff)
789
+ return {"diff": current_diff, "message_text": f"+{added} -{removed}"}
790
+
791
+ @staticmethod
792
+ def _parse_plan_notification(params: dict[str, Any]) -> dict[str, Any]:
793
+ plan_value = params.get("plan")
794
+ parsed_plan: list[dict[str, Any]] | None = None
795
+ if isinstance(plan_value, list):
796
+ parsed_plan = [entry for entry in plan_value if isinstance(entry, dict)]
797
+ return {"plan": parsed_plan, "message_text": turn_plan_summary(params)}
798
+
799
+ @staticmethod
800
+ def _parse_summary_part_notification(params: dict[str, Any]) -> dict[str, Any]:
801
+ out: dict[str, Any] = {"message_text": "summary part added"}
802
+ maybe_item_id = params.get("itemId")
803
+ if isinstance(maybe_item_id, str):
804
+ out["item_id"] = maybe_item_id
805
+ maybe_summary_index = params.get("summaryIndex")
806
+ if isinstance(maybe_summary_index, int):
807
+ out["summary_index"] = maybe_summary_index
808
+ out["message_text"] = f"summary part #{maybe_summary_index}"
809
+ return out
810
+
811
+ @staticmethod
812
+ def _parse_thread_compacted_notification(params: dict[str, Any]) -> dict[str, Any]:
813
+ del params
814
+ return {"message_text": "thread compacted"}
815
+
816
+ @staticmethod
817
+ def _parse_text_message_notification(params: dict[str, Any]) -> dict[str, Any]:
818
+ text = first_nonempty_text(params)
819
+ if text:
820
+ return {"message_text": text}
821
+ return {}
822
+
823
+ @staticmethod
824
+ def _parse_item_completed_notification(params: dict[str, Any]) -> dict[str, Any]:
825
+ item = params.get("item")
826
+ if not isinstance(item, dict) or item.get("type") != "agentMessage":
827
+ return {}
828
+ text = item.get("text")
829
+ if isinstance(text, str):
830
+ return {"message_text": text}
831
+ return {}
832
+
833
+ @staticmethod
834
+ def _parse_turn_completed_notification(params: dict[str, Any]) -> dict[str, Any]:
835
+ turn = params.get("turn")
836
+ if not isinstance(turn, dict):
837
+ return {}
838
+ err = turn.get("error")
839
+ text = first_nonempty_text(err)
840
+ if text:
841
+ return {"message_text": text}
842
+ return {}
843
+
844
+ @staticmethod
845
+ def _merge_params(
846
+ default_params: dict[str, Any],
847
+ request_params: dict[str, Any] | None,
848
+ ) -> dict[str, Any]:
849
+ merged = dict(default_params)
850
+ if request_params:
851
+ for key, value in request_params.items():
852
+ if value is not None:
853
+ merged[key] = value
854
+ return merged
855
+
856
+ async def responses_events(
857
+ self,
858
+ *,
859
+ prompt: str,
860
+ session_id: str | None = None,
861
+ thread_params: dict[str, Any] | None = None,
862
+ turn_params: dict[str, Any] | None = None,
863
+ ) -> AsyncIterator[ResponseEvent]:
864
+ """Run one prompt and yield structured events in real time.
865
+
866
+ Args:
867
+ prompt: User prompt text.
868
+ session_id: Existing thread id. If omitted, a new thread is started.
869
+ thread_params: Per-request thread params merged over defaults.
870
+ turn_params: Per-request turn params merged over defaults.
871
+
872
+ Yields:
873
+ ``ResponseEvent`` objects in arrival order.
874
+ """
875
+
876
+ merged_thread_params = self._merge_params(self.default_thread_params, thread_params)
877
+ merged_turn_params = self._merge_params(self.default_turn_params, turn_params)
878
+
879
+ if session_id is None:
880
+ started = await self._request("thread/start", merged_thread_params)
881
+ thread = started.get("thread") if isinstance(started.get("thread"), dict) else {}
882
+ thread_id = thread.get("id") if isinstance(thread.get("id"), str) else None
883
+ if not thread_id:
884
+ raise CodexAgenticError("thread/start did not return a thread id.")
885
+ else:
886
+ thread_id = session_id
887
+ try:
888
+ resumed = await self._request(
889
+ "thread/resume",
890
+ {**merged_thread_params, "threadId": session_id},
891
+ )
892
+ except CodexAgenticError as exc:
893
+ if not self._is_no_rollout_found_error(exc):
894
+ raise
895
+ else:
896
+ thread = resumed.get("thread") if isinstance(resumed.get("thread"), dict) else {}
897
+ thread_id = thread.get("id") if isinstance(thread.get("id"), str) else session_id
898
+
899
+ yield ResponseEvent(
900
+ type="thread/ready",
901
+ phase="system",
902
+ session_id=thread_id,
903
+ raw={"threadId": thread_id},
904
+ )
905
+
906
+ turn_start_params: dict[str, Any] = dict(merged_turn_params)
907
+ turn_start_params["threadId"] = thread_id
908
+ turn_start_params["input"] = [{"type": "text", "text": prompt, "text_elements": []}]
909
+ turn_result = await self._request("turn/start", turn_start_params)
910
+ turn = turn_result.get("turn") if isinstance(turn_result.get("turn"), dict) else {}
911
+ turn_id = turn.get("id") if isinstance(turn.get("id"), str) else None
912
+ idle_deadline: float | None = None
913
+ if self.stream_idle_timeout_seconds is not None:
914
+ idle_deadline = asyncio.get_running_loop().time() + max(0.0, self.stream_idle_timeout_seconds)
915
+
916
+ while True:
917
+ try:
918
+ notification = await self._wait_for_matching_notification(thread_id, turn_id)
919
+ except asyncio.TimeoutError:
920
+ if self._proc is None:
921
+ raise AppServerConnectionError("App-server process is not running.")
922
+ if self._proc.returncode is not None:
923
+ raise AppServerConnectionError("App-server process exited while waiting for notifications.")
924
+ if not self._connected:
925
+ async with self._notification_condition:
926
+ if not self._notification_buffer:
927
+ raise AppServerConnectionError("App-server transport closed while waiting for notifications.")
928
+ if idle_deadline is not None and asyncio.get_running_loop().time() >= idle_deadline:
929
+ raise AppServerConnectionError(
930
+ f"Timed out waiting for matching notifications for {self.stream_idle_timeout_seconds} seconds."
931
+ )
932
+ continue
933
+ if self.stream_idle_timeout_seconds is not None:
934
+ idle_deadline = asyncio.get_running_loop().time() + max(0.0, self.stream_idle_timeout_seconds)
935
+ method = str(notification.get("method", ""))
936
+
937
+ event = self._notification_to_event(notification)
938
+ if not event.session_id:
939
+ event.session_id = thread_id
940
+ yield event
941
+
942
+ if method == "error":
943
+ msg = first_nonempty_text(notification.get("params")) or "App-server emitted an error notification."
944
+ raise CodexAgenticError(msg)
945
+
946
+ if method == "turn/completed":
947
+ params = notification.get("params") if isinstance(notification.get("params"), dict) else {}
948
+ turn_obj = params.get("turn") if isinstance(params.get("turn"), dict) else {}
949
+ status = turn_obj.get("status")
950
+ if status == "failed":
951
+ err = turn_obj.get("error") if isinstance(turn_obj.get("error"), dict) else {}
952
+ msg = first_nonempty_text(err) or "Turn failed without detailed error."
953
+ raise CodexAgenticError(msg)
954
+ break
955
+
956
+ async def responses_stream_text(
957
+ self,
958
+ *,
959
+ prompt: str,
960
+ session_id: str | None = None,
961
+ thread_params: dict[str, Any] | None = None,
962
+ turn_params: dict[str, Any] | None = None,
963
+ ) -> AsyncIterator[str]:
964
+ """Run one prompt and yield text deltas only."""
965
+
966
+ async for event in self.responses_events(
967
+ prompt=prompt,
968
+ session_id=session_id,
969
+ thread_params=thread_params,
970
+ turn_params=turn_params,
971
+ ):
972
+ if event.text_delta:
973
+ yield event.text_delta
974
+
975
+ async def responses_create(
976
+ self,
977
+ *,
978
+ prompt: str,
979
+ session_id: str | None = None,
980
+ thread_params: dict[str, Any] | None = None,
981
+ turn_params: dict[str, Any] | None = None,
982
+ include_events: bool = False,
983
+ ) -> AgentResponse:
984
+ """Run one prompt and return the final aggregated response.
985
+
986
+ Args:
987
+ prompt: User prompt text.
988
+ session_id: Existing thread id. If omitted, a new thread is started.
989
+ thread_params: Per-request thread params merged over defaults.
990
+ turn_params: Per-request turn params merged over defaults.
991
+ include_events: If true, include all streamed ``ResponseEvent`` objects in the result.
992
+ """
993
+
994
+ chunks: list[str] = []
995
+ assistant_chunks: list[str] = []
996
+ assistant_message: str | None = None
997
+ last_message: str | None = None
998
+ thread_id: str | None = session_id
999
+ request_id: str | None = None
1000
+ last_event_raw: dict[str, Any] | None = None
1001
+ events: list[ResponseEvent] | None = [] if include_events else None
1002
+
1003
+ async for event in self.responses_events(
1004
+ prompt=prompt,
1005
+ session_id=session_id,
1006
+ thread_params=thread_params,
1007
+ turn_params=turn_params,
1008
+ ):
1009
+ last_event_raw = event.raw
1010
+ if events is not None:
1011
+ events.append(event)
1012
+ if event.session_id and not thread_id:
1013
+ thread_id = event.session_id
1014
+ if event.request_id:
1015
+ request_id = event.request_id
1016
+ if event.text_delta:
1017
+ chunks.append(event.text_delta)
1018
+ if event.type == "item/agentMessage/delta":
1019
+ assistant_chunks.append(event.text_delta)
1020
+ extracted_assistant = self._extract_assistant_text_from_event(event)
1021
+ if extracted_assistant:
1022
+ assistant_message = extracted_assistant
1023
+ if event.message_text:
1024
+ last_message = event.message_text
1025
+
1026
+ if not thread_id:
1027
+ raise CodexAgenticError("No thread id was produced by app-server.")
1028
+
1029
+ text = (
1030
+ assistant_message
1031
+ or "".join(assistant_chunks).strip()
1032
+ or last_message
1033
+ or "".join(chunks).strip()
1034
+ )
1035
+ return AgentResponse(
1036
+ text=text,
1037
+ session_id=thread_id,
1038
+ request_id=request_id,
1039
+ tool_name="app-server",
1040
+ raw=last_event_raw or {},
1041
+ events=events,
1042
+ )
1043
+
1044
+ @staticmethod
1045
+ def _extract_assistant_text_from_event(event: ResponseEvent) -> str | None:
1046
+ if event.type == "codex/event/agent_message":
1047
+ return first_nonempty_text(event.message_text)
1048
+
1049
+ if event.type != "item/completed":
1050
+ return None
1051
+
1052
+ raw = event.raw if isinstance(event.raw, dict) else {}
1053
+ params = raw.get("params") if isinstance(raw.get("params"), dict) else {}
1054
+ item = params.get("item") if isinstance(params.get("item"), dict) else {}
1055
+ if item.get("type") != "agentMessage":
1056
+ return None
1057
+
1058
+ text = item.get("text")
1059
+ if isinstance(text, str) and text:
1060
+ return text
1061
+ return None
1062
+
1063
+ async def thread_start(
1064
+ self,
1065
+ *,
1066
+ params: dict[str, Any] | None = None,
1067
+ ) -> dict[str, Any]:
1068
+ """Create a new thread via ``thread/start``."""
1069
+
1070
+ return await self._request("thread/start", self._merge_params(self.default_thread_params, params))
1071
+
1072
+ async def thread_read(self, thread_id: str, *, include_turns: bool = False) -> dict[str, Any]:
1073
+ """Read one thread by id."""
1074
+
1075
+ result = await self._request("thread/read", {"threadId": thread_id, "includeTurns": include_turns})
1076
+ thread = result.get("thread") if isinstance(result.get("thread"), dict) else None
1077
+ if thread is None:
1078
+ raise SessionNotFoundError(f"Unknown thread_id: {thread_id}")
1079
+ return thread
1080
+
1081
+ async def thread_list(self, limit: int = 50, *, sort_key: str = "updated_at") -> list[dict[str, Any]]:
1082
+ """List available threads."""
1083
+
1084
+ result = await self._request("thread/list", {"limit": limit, "sortKey": sort_key})
1085
+ data = result.get("data")
1086
+ if isinstance(data, list):
1087
+ return [item for item in data if isinstance(item, dict)]
1088
+ return []
1089
+
1090
+ async def thread_archive(self, thread_id: str) -> dict[str, Any]:
1091
+ """Archive one thread."""
1092
+
1093
+ return await self._request("thread/archive", {"threadId": thread_id})
1094
+
1095
+ async def thread_fork(
1096
+ self,
1097
+ thread_id: str,
1098
+ *,
1099
+ params: dict[str, Any] | None = None,
1100
+ ) -> dict[str, Any]:
1101
+ merged = self._merge_params(self.default_thread_params, params)
1102
+ return await self._request("thread/fork", {**merged, "threadId": thread_id})
1103
+
1104
+ async def thread_name_set(self, thread_id: str, name: str) -> dict[str, Any]:
1105
+ return await self._request("thread/name/set", {"threadId": thread_id, "name": name})
1106
+
1107
+ async def thread_unarchive(self, thread_id: str) -> dict[str, Any]:
1108
+ return await self._request("thread/unarchive", {"threadId": thread_id})
1109
+
1110
+ async def thread_compact_start(self, thread_id: str) -> dict[str, Any]:
1111
+ return await self._request("thread/compact/start", {"threadId": thread_id})
1112
+
1113
+ async def thread_rollback(self, thread_id: str, num_turns: int) -> dict[str, Any]:
1114
+ return await self._request("thread/rollback", {"threadId": thread_id, "numTurns": num_turns})
1115
+
1116
+ async def thread_loaded_list(self, *, limit: int | None = None, cursor: str | None = None) -> dict[str, Any]:
1117
+ params: dict[str, Any] = {}
1118
+ if limit is not None:
1119
+ params["limit"] = limit
1120
+ if cursor is not None:
1121
+ params["cursor"] = cursor
1122
+ return await self._request("thread/loaded/list", params)
1123
+
1124
+ async def skills_list(
1125
+ self,
1126
+ *,
1127
+ cwds: list[str] | None = None,
1128
+ force_reload: bool | None = None,
1129
+ ) -> dict[str, Any]:
1130
+ params: dict[str, Any] = {}
1131
+ if cwds is not None:
1132
+ params["cwds"] = cwds
1133
+ if force_reload is not None:
1134
+ params["forceReload"] = force_reload
1135
+ return await self._request("skills/list", params)
1136
+
1137
+ async def skills_remote_read(self) -> dict[str, Any]:
1138
+ return await self._request("skills/remote/read", {})
1139
+
1140
+ async def skills_remote_write(self, hazelnut_id: str, is_preload: bool) -> dict[str, Any]:
1141
+ return await self._request(
1142
+ "skills/remote/write",
1143
+ {"hazelnutId": hazelnut_id, "isPreload": is_preload},
1144
+ )
1145
+
1146
+ async def app_list(self, *, limit: int | None = None, cursor: str | None = None) -> dict[str, Any]:
1147
+ params: dict[str, Any] = {}
1148
+ if limit is not None:
1149
+ params["limit"] = limit
1150
+ if cursor is not None:
1151
+ params["cursor"] = cursor
1152
+ return await self._request("app/list", params)
1153
+
1154
+ async def skills_config_write(self, path: str, enabled: bool) -> dict[str, Any]:
1155
+ return await self._request("skills/config/write", {"path": path, "enabled": enabled})
1156
+
1157
+ async def turn_interrupt(self, thread_id: str, turn_id: str) -> dict[str, Any]:
1158
+ return await self._request("turn/interrupt", {"threadId": thread_id, "turnId": turn_id})
1159
+
1160
+ async def turn_steer(self, thread_id: str, turn_id: str, prompt: str) -> dict[str, Any]:
1161
+ return await self._request(
1162
+ "turn/steer",
1163
+ {
1164
+ "threadId": thread_id,
1165
+ "expectedTurnId": turn_id,
1166
+ "input": [{"type": "text", "text": prompt, "text_elements": []}],
1167
+ },
1168
+ )
1169
+
1170
+ async def review_start(
1171
+ self,
1172
+ thread_id: str,
1173
+ target: dict[str, Any],
1174
+ *,
1175
+ delivery: str | None = None,
1176
+ ) -> dict[str, Any]:
1177
+ params: dict[str, Any] = {"threadId": thread_id, "target": target}
1178
+ if delivery is not None:
1179
+ params["delivery"] = delivery
1180
+ return await self._request("review/start", params)
1181
+
1182
+ async def model_list(self, *, limit: int | None = None, cursor: str | None = None) -> dict[str, Any]:
1183
+ params: dict[str, Any] = {}
1184
+ if limit is not None:
1185
+ params["limit"] = limit
1186
+ if cursor is not None:
1187
+ params["cursor"] = cursor
1188
+ return await self._request("model/list", params)
1189
+
1190
+ async def account_rate_limits_read(self) -> dict[str, Any]:
1191
+ return await self._request("account/rateLimits/read", None)
1192
+
1193
+ async def account_read(self, *, refresh_token: bool | None = None) -> dict[str, Any]:
1194
+ params: dict[str, Any] = {}
1195
+ if refresh_token is not None:
1196
+ params["refreshToken"] = refresh_token
1197
+ return await self._request("account/read", params)
1198
+
1199
+ async def command_exec(
1200
+ self,
1201
+ command: list[str],
1202
+ *,
1203
+ cwd: str | None = None,
1204
+ timeout_ms: int | None = None,
1205
+ sandbox_policy: dict[str, Any] | None = None,
1206
+ ) -> dict[str, Any]:
1207
+ params: dict[str, Any] = {"command": command}
1208
+ request_timeout_seconds: float | None = None
1209
+ if cwd is not None:
1210
+ params["cwd"] = cwd
1211
+ if timeout_ms is not None:
1212
+ params["timeoutMs"] = timeout_ms
1213
+ # Honor the caller's command timeout and leave room for transport overhead.
1214
+ request_timeout_seconds = max(0.0, timeout_ms / 1000.0) + 30.0
1215
+ if sandbox_policy is not None:
1216
+ params["sandboxPolicy"] = sandbox_policy
1217
+ return await self._request(
1218
+ "command/exec",
1219
+ params,
1220
+ timeout_seconds=request_timeout_seconds,
1221
+ )
1222
+
1223
+ async def config_read(self, *, cwd: str | None = None, include_layers: bool = False) -> dict[str, Any]:
1224
+ params: dict[str, Any] = {}
1225
+ if cwd is not None:
1226
+ params["cwd"] = cwd
1227
+ if include_layers:
1228
+ params["includeLayers"] = include_layers
1229
+ return await self._request("config/read", params)
1230
+
1231
+ async def config_value_write(
1232
+ self,
1233
+ key_path: str,
1234
+ value: Any,
1235
+ *,
1236
+ merge_strategy: str = "upsert",
1237
+ file_path: str | None = None,
1238
+ expected_version: str | None = None,
1239
+ ) -> dict[str, Any]:
1240
+ params: dict[str, Any] = {
1241
+ "keyPath": key_path,
1242
+ "value": value,
1243
+ "mergeStrategy": merge_strategy,
1244
+ }
1245
+ if file_path is not None:
1246
+ params["filePath"] = file_path
1247
+ if expected_version is not None:
1248
+ params["expectedVersion"] = expected_version
1249
+ return await self._request("config/value/write", params)
1250
+
1251
+ async def config_batch_write(
1252
+ self,
1253
+ edits: list[dict[str, Any]],
1254
+ *,
1255
+ file_path: str | None = None,
1256
+ expected_version: str | None = None,
1257
+ ) -> dict[str, Any]:
1258
+ params: dict[str, Any] = {"edits": edits}
1259
+ if file_path is not None:
1260
+ params["filePath"] = file_path
1261
+ if expected_version is not None:
1262
+ params["expectedVersion"] = expected_version
1263
+ return await self._request("config/batchWrite", params)
1264
+
1265
+ async def config_requirements_read(self) -> dict[str, Any]:
1266
+ return await self._request("configRequirements/read", None)
1267
+
1268
+ async def config_mcp_server_reload(self) -> dict[str, Any]:
1269
+ return await self._request("config/mcpServer/reload", None)
1270
+
1271
+ async def mcp_server_status_list(self, *, limit: int | None = None, cursor: str | None = None) -> dict[str, Any]:
1272
+ params: dict[str, Any] = {}
1273
+ if limit is not None:
1274
+ params["limit"] = limit
1275
+ if cursor is not None:
1276
+ params["cursor"] = cursor
1277
+ return await self._request("mcpServerStatus/list", params)
1278
+
1279
+ async def mcp_server_oauth_login(
1280
+ self,
1281
+ name: str,
1282
+ *,
1283
+ scopes: list[str] | None = None,
1284
+ timeout_secs: int | None = None,
1285
+ ) -> dict[str, Any]:
1286
+ params: dict[str, Any] = {"name": name}
1287
+ if scopes is not None:
1288
+ params["scopes"] = scopes
1289
+ if timeout_secs is not None:
1290
+ params["timeoutSecs"] = timeout_secs
1291
+ return await self._request("mcpServer/oauth/login", params)
1292
+
1293
+ async def fuzzy_file_search(
1294
+ self,
1295
+ query: str,
1296
+ *,
1297
+ roots: list[str] | None = None,
1298
+ cancellation_token: str | None = None,
1299
+ ) -> dict[str, Any]:
1300
+ params: dict[str, Any] = {
1301
+ "query": query,
1302
+ "roots": roots[:] if roots is not None else [self.process_cwd],
1303
+ }
1304
+ if cancellation_token is not None:
1305
+ params["cancellationToken"] = cancellation_token
1306
+ return await self._request("fuzzyFileSearch", params)
1307
+
1308
+ @staticmethod
1309
+ def _raise_connection_error(exc: Exception) -> None:
1310
+ text = str(exc)
1311
+ if AsyncCodexAgenticClient._is_auth_error_text(text):
1312
+ raise NotAuthenticatedError(AsyncCodexAgenticClient._not_authenticated_message(text)) from exc
1313
+ raise AppServerConnectionError(text) from exc